Production hardening: Remove deprecations, optimize chain queries, enforce explicit configuration (#134)
* Initial plan * Fix onLimitReached deprecation in rate-limit middleware - Replace deprecated onLimitReached callback with inline logging in handler - Update tests to verify logging happens in the rate limit handler - Remove onLimitReached from exports as it's no longer a separate function Co-authored-by: onnwee <211922112+onnwee@users.noreply.github.com> * Fix fromBlock: 0 in event queries for better performance - Replace fromBlock: 0 with smart default (last 1M blocks) - Add REGISTRY_START_BLOCK env var for configurable starting block - Update make-proof.ts, verification-jobs.routes.ts, and verification-queue.service.ts - Document new env variable in .env.example Co-authored-by: onnwee <211922112+onnwee@users.noreply.github.com> * Remove hardcoded testnet RPC fallback for production safety - Replace testnet fallbacks with proper error handling when RPC_URL is not configured - Update registry.service.ts, blockchain.service.ts to throw errors if RPC_URL missing - Update CLI scripts (verify.ts, register.ts, make-proof.ts) to fail fast without RPC_URL - Update API routes to return 503 error when RPC_URL is not configured - Update .env.example to emphasize RPC_URL is required Co-authored-by: onnwee <211922112+onnwee@users.noreply.github.com> * Add documentation for dual Prisma generators and pin Redis version - Add comprehensive comments in schema.prisma explaining dual generator setup - Document why both generators are needed (API vs Next.js web app) - Pin Redis version to 7.2-alpine in all docker-compose files for reproducibility - Update docker-compose.yml, docker-compose.production.yml, and docker-compose.staging.yml Co-authored-by: onnwee <211922112+onnwee@users.noreply.github.com> * Address code review feedback - add validation for REGISTRY_START_BLOCK - Add proper validation for parseInt to handle NaN cases - Ensure REGISTRY_START_BLOCK is validated before use - Add comment explaining intentional empty catch block - Prevents invalid block numbers from breaking event queries Co-authored-by: onnwee <211922112+onnwee@users.noreply.github.com> * Refactor block range validation into shared utility function - Create block-range.util.ts with getStartBlock helper - Extract duplicated validation logic from make-proof.ts, verification-jobs.routes.ts, and verification-queue.service.ts - Improves code maintainability and ensures consistent validation - Add comprehensive JSDoc documentation Co-authored-by: onnwee <211922112+onnwee@users.noreply.github.com> * Add comprehensive test coverage for block-range utility - Create test/utils/block-range.util.test.ts following existing test patterns - Test valid REGISTRY_START_BLOCK values (positive, zero, large numbers) - Test invalid inputs (NaN, negative, empty string, whitespace) - Test default fallback behavior (current block - 1M) - Test edge cases (low block numbers, decimals, provider errors) - 15 test cases covering all code paths and validation logic Co-authored-by: PatrickFanella <61631520+PatrickFanella@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: onnwee <211922112+onnwee@users.noreply.github.com> Co-authored-by: PatrickFanella <61631520+PatrickFanella@users.noreply.github.com>
This commit was merged in pull request #134.
This commit is contained in:
15
.env.example
15
.env.example
@@ -19,7 +19,9 @@
|
||||
PRIVATE_KEY=
|
||||
|
||||
# Default RPC URL for blockchain interactions
|
||||
# Recommended: Base Sepolia testnet (free testnet faucets available)
|
||||
# REQUIRED: This must be configured for the API server to function
|
||||
# For production, use a reliable RPC provider (Infura, Alchemy, QuickNode, etc.)
|
||||
# For development/testing, use a testnet (Base Sepolia recommended)
|
||||
# See Multi-Chain section below for other networks
|
||||
RPC_URL=https://sepolia.base.org
|
||||
|
||||
@@ -157,6 +159,17 @@ LOCAL_RPC_URL=http://127.0.0.1:8545
|
||||
# IPFS_PROVIDER=local
|
||||
# IPFS_API_URL=http://127.0.0.1:5001
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Smart Contract Event Query Configuration (Optional)
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Starting block number for querying ContentRegistered events
|
||||
# This avoids scanning the entire blockchain history when searching for registration transactions
|
||||
# Set this to the block number when the registry contract was deployed
|
||||
# If not set, defaults to scanning the last 1,000,000 blocks
|
||||
# Example for Base Sepolia: 14000000 (approximate deployment block)
|
||||
# REGISTRY_START_BLOCK=14000000
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# API Configuration
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
@@ -137,7 +137,7 @@ services:
|
||||
|
||||
# Redis cache
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
image: redis:7.2-alpine
|
||||
command: redis-server --maxmemory 512mb --maxmemory-policy allkeys-lru --appendonly yes
|
||||
volumes:
|
||||
- redis_data_production:/data
|
||||
|
||||
@@ -93,7 +93,7 @@ services:
|
||||
|
||||
# Redis cache
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
image: redis:7.2-alpine
|
||||
command: redis-server --maxmemory 256mb --maxmemory-policy allkeys-lru
|
||||
volumes:
|
||||
- redis_data_staging:/data
|
||||
|
||||
@@ -102,7 +102,7 @@ services:
|
||||
|
||||
# Redis for queue and caching
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
image: redis:7.2-alpine
|
||||
ports:
|
||||
- "6379:6379"
|
||||
volumes:
|
||||
|
||||
@@ -1,3 +1,25 @@
|
||||
// This file contains the Prisma schema definition for Internet-ID
|
||||
//
|
||||
// DUAL GENERATOR SETUP:
|
||||
// This schema uses two Prisma Client generators for different parts of the application:
|
||||
//
|
||||
// 1. "client" - Default generator for API/scripts (outputs to root node_modules)
|
||||
// Used by: Express API server, CLI scripts, verification workers, queue services
|
||||
// Location: node_modules/.prisma/client
|
||||
//
|
||||
// 2. "client-web" - Web app generator (outputs to web/node_modules)
|
||||
// Used by: Next.js web application (web/ directory)
|
||||
// Location: web/node_modules/.prisma/client
|
||||
//
|
||||
// WHY TWO GENERATORS?
|
||||
// - Next.js app runs in a separate process with its own node_modules
|
||||
// - The web app needs Prisma Client in its node_modules for deployment
|
||||
// - API server and scripts share the root Prisma Client
|
||||
// - This avoids module resolution issues and keeps deployments clean
|
||||
//
|
||||
// IMPORTANT: Both generators read from the same schema and database
|
||||
// Run `npm run db:generate` to regenerate both Prisma Clients after schema changes
|
||||
|
||||
// Default generator for API/scripts (root node_modules)
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
|
||||
@@ -4,6 +4,7 @@ import * as path from "path";
|
||||
import { ethers } from "ethers";
|
||||
import * as https from "https";
|
||||
import * as dotenv from "dotenv";
|
||||
import { getStartBlock } from "./utils/block-range.util";
|
||||
dotenv.config();
|
||||
|
||||
/*
|
||||
@@ -76,9 +77,11 @@ async function findRegistrationTx(
|
||||
// Event: ContentRegistered(bytes32 indexed contentHash, address indexed creator, string manifestURI, uint64 timestamp)
|
||||
const topic0 = ethers.id("ContentRegistered(bytes32,address,string,uint64)");
|
||||
try {
|
||||
const startBlock = await getStartBlock(provider);
|
||||
|
||||
const logs = await provider.getLogs({
|
||||
address: registry,
|
||||
fromBlock: 0,
|
||||
fromBlock: startBlock,
|
||||
toBlock: "latest",
|
||||
topics: [topic0, contentHash],
|
||||
});
|
||||
@@ -86,6 +89,7 @@ async function findRegistrationTx(
|
||||
const log = logs[logs.length - 1];
|
||||
return { txHash: log.transactionHash, blockNumber: log.blockNumber };
|
||||
} catch {
|
||||
// Silently ignore log fetch failures - this is a best-effort operation
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -104,9 +108,13 @@ async function main() {
|
||||
const manifestHashOk = manifest.content_hash === fileHash;
|
||||
const recovered = await recoverSigner(manifest.content_hash, manifest.signature);
|
||||
|
||||
const provider = new ethers.JsonRpcProvider(
|
||||
rpcUrl || process.env.RPC_URL || "https://sepolia.base.org"
|
||||
);
|
||||
const url = rpcUrl || process.env.RPC_URL;
|
||||
if (!url) {
|
||||
console.error("RPC_URL is required. Set RPC_URL environment variable or provide rpcUrl argument.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const provider = new ethers.JsonRpcProvider(url);
|
||||
const net = await provider.getNetwork();
|
||||
const abi = [
|
||||
"function entries(bytes32) view returns (address creator, bytes32 contentHash, string manifestURI, uint64 timestamp)",
|
||||
@@ -126,7 +134,7 @@ async function main() {
|
||||
network: {
|
||||
chainId: Number(net.chainId),
|
||||
name: chainName(net.chainId),
|
||||
rpc: rpcUrl || process.env.RPC_URL || "https://sepolia.base.org",
|
||||
rpc: url,
|
||||
},
|
||||
registry: registryAddress,
|
||||
content: {
|
||||
|
||||
@@ -32,6 +32,11 @@ async function initRedisClient() {
|
||||
|
||||
// Rate limit handler that returns 429 with Retry-After header
|
||||
const rateLimitHandler = (req: Request, res: Response) => {
|
||||
// Log rate limit hits for monitoring
|
||||
const ip = req.ip || req.socket?.remoteAddress || "unknown";
|
||||
const path = req.path;
|
||||
console.warn(`[RATE_LIMIT_HIT] IP: ${ip}, Path: ${path}, Time: ${new Date().toISOString()}`);
|
||||
|
||||
const retryAfter = Math.ceil(
|
||||
req.rateLimit?.resetTime ? (req.rateLimit.resetTime.getTime() - Date.now()) / 1000 : 60
|
||||
);
|
||||
@@ -58,12 +63,7 @@ const skipRateLimit = (req: Request): boolean => {
|
||||
return false;
|
||||
};
|
||||
|
||||
// Log rate limit hits for monitoring
|
||||
const onLimitReached = (req: Request, _res: Response) => {
|
||||
const ip = req.ip || req.socket?.remoteAddress || "unknown";
|
||||
const path = req.path;
|
||||
console.warn(`[RATE_LIMIT_HIT] IP: ${ip}, Path: ${path}, Time: ${new Date().toISOString()}`);
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* Create a rate limiter with the specified configuration
|
||||
@@ -79,7 +79,6 @@ async function createRateLimiter(options: { windowMs: number; max: number; messa
|
||||
legacyHeaders: boolean;
|
||||
handler: typeof rateLimitHandler;
|
||||
skip: typeof skipRateLimit;
|
||||
onLimitReached: typeof onLimitReached;
|
||||
store?: RedisStore;
|
||||
}
|
||||
|
||||
@@ -91,7 +90,6 @@ async function createRateLimiter(options: { windowMs: number; max: number; messa
|
||||
legacyHeaders: false, // Disable X-RateLimit-* headers
|
||||
handler: rateLimitHandler,
|
||||
skip: skipRateLimit,
|
||||
onLimitReached,
|
||||
};
|
||||
|
||||
// Use Redis store if available, otherwise fall back to in-memory
|
||||
@@ -131,4 +129,4 @@ export const relaxedRateLimit = createRateLimiter({
|
||||
});
|
||||
|
||||
// Export for testing
|
||||
export { initRedisClient, rateLimitHandler, skipRateLimit, onLimitReached };
|
||||
export { initRedisClient, rateLimitHandler, skipRateLimit };
|
||||
|
||||
@@ -19,7 +19,12 @@ async function main() {
|
||||
const sha256 = createHash("sha256").update(data).digest("hex");
|
||||
const contentHash = "0x" + sha256;
|
||||
|
||||
const provider = new ethers.JsonRpcProvider(process.env.RPC_URL || "https://sepolia.base.org");
|
||||
const rpcUrl = process.env.RPC_URL;
|
||||
if (!rpcUrl) {
|
||||
console.error("Missing RPC_URL in .env");
|
||||
process.exit(1);
|
||||
}
|
||||
const provider = new ethers.JsonRpcProvider(rpcUrl);
|
||||
if (!process.env.PRIVATE_KEY) {
|
||||
console.error("Missing PRIVATE_KEY in .env");
|
||||
process.exit(1);
|
||||
|
||||
@@ -55,15 +55,23 @@ router.get("/health", async (_req: Request, res: Response) => {
|
||||
|
||||
// Check blockchain RPC connectivity
|
||||
try {
|
||||
const provider = new ethers.JsonRpcProvider(
|
||||
process.env.RPC_URL || "https://sepolia.base.org"
|
||||
);
|
||||
const blockNumber = await provider.getBlockNumber();
|
||||
checks.services.blockchain = {
|
||||
status: "healthy",
|
||||
blockNumber,
|
||||
};
|
||||
metricsService.updateHealthCheckStatus("blockchain", "healthy", true);
|
||||
const rpcUrl = process.env.RPC_URL;
|
||||
if (!rpcUrl) {
|
||||
checks.services.blockchain = {
|
||||
status: "unhealthy",
|
||||
error: "RPC_URL not configured",
|
||||
};
|
||||
checks.status = "degraded";
|
||||
metricsService.updateHealthCheckStatus("blockchain", "unhealthy", false);
|
||||
} else {
|
||||
const provider = new ethers.JsonRpcProvider(rpcUrl);
|
||||
const blockNumber = await provider.getBlockNumber();
|
||||
checks.services.blockchain = {
|
||||
status: "healthy",
|
||||
blockNumber,
|
||||
};
|
||||
metricsService.updateHealthCheckStatus("blockchain", "healthy", true);
|
||||
}
|
||||
} catch (rpcError: any) {
|
||||
checks.services.blockchain = {
|
||||
status: "unhealthy",
|
||||
@@ -111,7 +119,12 @@ router.get("/cache/metrics", (req: Request, res: Response) => {
|
||||
// Network info (for UI explorer links)
|
||||
router.get("/network", async (req: Request, res: Response) => {
|
||||
try {
|
||||
const provider = new ethers.JsonRpcProvider(process.env.RPC_URL || "https://sepolia.base.org");
|
||||
const rpcUrl = process.env.RPC_URL;
|
||||
if (!rpcUrl) {
|
||||
return res.status(503).json({ error: "RPC_URL not configured" });
|
||||
}
|
||||
|
||||
const provider = new ethers.JsonRpcProvider(rpcUrl);
|
||||
const net = await provider.getNetwork();
|
||||
res.json({ chainId: Number(net.chainId) });
|
||||
} catch (e: any) {
|
||||
@@ -128,7 +141,12 @@ router.get("/network", async (req: Request, res: Response) => {
|
||||
router.get("/registry", async (req: Request, res: Response) => {
|
||||
try {
|
||||
const override = process.env.REGISTRY_ADDRESS;
|
||||
const provider = new ethers.JsonRpcProvider(process.env.RPC_URL || "https://sepolia.base.org");
|
||||
const rpcUrl = process.env.RPC_URL;
|
||||
if (!rpcUrl) {
|
||||
return res.status(503).json({ error: "RPC_URL not configured" });
|
||||
}
|
||||
|
||||
const provider = new ethers.JsonRpcProvider(rpcUrl);
|
||||
const net = await provider.getNetwork();
|
||||
const chainId = Number(net.chainId);
|
||||
if (override) return res.json({ registryAddress: override, chainId });
|
||||
@@ -168,7 +186,11 @@ router.get("/resolve", validateQuery(resolveQuerySchema), async (req: Request, r
|
||||
return res.status(400).json({ error: "Provide url or platform + platformId" });
|
||||
}
|
||||
const { registryAddress, chainId } = await resolveDefaultRegistry();
|
||||
const provider = new ethers.JsonRpcProvider(process.env.RPC_URL || "https://sepolia.base.org");
|
||||
const rpcUrl = process.env.RPC_URL;
|
||||
if (!rpcUrl) {
|
||||
return res.status(503).json({ error: "RPC_URL not configured" });
|
||||
}
|
||||
const provider = new ethers.JsonRpcProvider(rpcUrl);
|
||||
|
||||
// Cache platform bindings
|
||||
const cacheKey = `binding:${parsed.platform}:${parsed.platformId}`;
|
||||
@@ -225,9 +247,11 @@ router.get(
|
||||
return res.status(400).json({ error: "Provide url or platform + platformId" });
|
||||
}
|
||||
const { registryAddress, chainId } = await resolveDefaultRegistry();
|
||||
const provider = new ethers.JsonRpcProvider(
|
||||
process.env.RPC_URL || "https://sepolia.base.org"
|
||||
);
|
||||
const rpcUrl = process.env.RPC_URL;
|
||||
if (!rpcUrl) {
|
||||
return res.status(503).json({ error: "RPC_URL not configured" });
|
||||
}
|
||||
const provider = new ethers.JsonRpcProvider(rpcUrl);
|
||||
|
||||
// Cache platform binding resolution
|
||||
const bindingCacheKey = `binding:${parsed.platform}:${parsed.platformId}`;
|
||||
|
||||
@@ -79,9 +79,11 @@ router.get(
|
||||
}
|
||||
|
||||
const { registryAddress, chainId } = await resolveDefaultRegistry();
|
||||
const provider = new ethers.JsonRpcProvider(
|
||||
process.env.RPC_URL || "https://sepolia.base.org"
|
||||
);
|
||||
const rpcUrl = process.env.RPC_URL;
|
||||
if (!rpcUrl) {
|
||||
return res.status(503).json({ error: "RPC_URL not configured" });
|
||||
}
|
||||
const provider = new ethers.JsonRpcProvider(rpcUrl);
|
||||
|
||||
// Cache platform binding resolution
|
||||
const bindingCacheKey = `binding:${parsed.platform}:${parsed.platformId}`;
|
||||
@@ -162,9 +164,11 @@ router.get(
|
||||
}
|
||||
|
||||
const { registryAddress, chainId } = await resolveDefaultRegistry();
|
||||
const provider = new ethers.JsonRpcProvider(
|
||||
process.env.RPC_URL || "https://sepolia.base.org"
|
||||
);
|
||||
const rpcUrl = process.env.RPC_URL;
|
||||
if (!rpcUrl) {
|
||||
return res.status(503).json({ error: "RPC_URL not configured" });
|
||||
}
|
||||
const provider = new ethers.JsonRpcProvider(rpcUrl);
|
||||
|
||||
const abi = [
|
||||
"function entries(bytes32) view returns (address creator, string manifestURI, uint256 timestamp, bool revoked)",
|
||||
|
||||
@@ -15,6 +15,7 @@ import { cacheService } from "../services/cache.service";
|
||||
import { sendErrorResponse } from "../utils/error-response.util";
|
||||
import { logger } from "../services/logger.service";
|
||||
import { sentryService } from "../services/sentry.service";
|
||||
import { getStartBlock } from "../utils/block-range.util";
|
||||
|
||||
const router = Router();
|
||||
|
||||
@@ -199,9 +200,11 @@ router.post(
|
||||
const topic0 = ethers.id("ContentRegistered(bytes32,address,string,uint64)");
|
||||
let txHash: string | undefined;
|
||||
try {
|
||||
const startBlock = await getStartBlock(provider);
|
||||
|
||||
const logs = await provider.getLogs({
|
||||
address: registryAddress,
|
||||
fromBlock: 0,
|
||||
fromBlock: startBlock,
|
||||
toBlock: "latest",
|
||||
topics: [topic0, fileHash],
|
||||
});
|
||||
|
||||
@@ -1,14 +1,23 @@
|
||||
import { ethers } from "ethers";
|
||||
|
||||
/**
|
||||
* Creates a JSON RPC provider with fallback to default RPC URL
|
||||
* @param rpcUrl Optional RPC URL, falls back to environment variable or default
|
||||
* Creates a JSON RPC provider
|
||||
* @param rpcUrl Optional RPC URL, falls back to RPC_URL environment variable
|
||||
* @returns ethers.JsonRpcProvider instance
|
||||
* @throws Error if neither rpcUrl nor RPC_URL environment variable is set
|
||||
*/
|
||||
export function createProvider(rpcUrl?: string): ethers.JsonRpcProvider {
|
||||
return new ethers.JsonRpcProvider(
|
||||
rpcUrl || process.env.RPC_URL || "https://sepolia.base.org"
|
||||
);
|
||||
const url = rpcUrl || process.env.RPC_URL;
|
||||
|
||||
if (!url) {
|
||||
throw new Error(
|
||||
"RPC_URL environment variable is required. " +
|
||||
"Set RPC_URL in your .env file to configure the blockchain network endpoint. " +
|
||||
"See .env.example for configuration examples."
|
||||
);
|
||||
}
|
||||
|
||||
return new ethers.JsonRpcProvider(url);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -32,7 +32,17 @@ const CHAIN_DEPLOYMENT_FILES: Record<number, string> = {
|
||||
|
||||
// Helper to resolve default registry address for current network
|
||||
export async function resolveDefaultRegistry(): Promise<RegistryInfo> {
|
||||
const provider = new ethers.JsonRpcProvider(process.env.RPC_URL || "https://sepolia.base.org");
|
||||
const rpcUrl = process.env.RPC_URL;
|
||||
|
||||
if (!rpcUrl) {
|
||||
throw new Error(
|
||||
"RPC_URL environment variable is required. " +
|
||||
"Set RPC_URL in your .env file to configure the blockchain network endpoint. " +
|
||||
"See .env.example for configuration examples."
|
||||
);
|
||||
}
|
||||
|
||||
const provider = new ethers.JsonRpcProvider(rpcUrl);
|
||||
const net = await provider.getNetwork();
|
||||
const chainId = Number(net.chainId);
|
||||
const override = process.env.REGISTRY_ADDRESS;
|
||||
@@ -90,9 +100,17 @@ export async function getAllRegistryAddresses(): Promise<Record<number, string>>
|
||||
}
|
||||
|
||||
export function getProvider(rpcUrl?: string): ethers.JsonRpcProvider {
|
||||
return new ethers.JsonRpcProvider(
|
||||
rpcUrl || process.env.RPC_URL || SUPPORTED_CHAINS.baseSepolia.rpcUrl
|
||||
);
|
||||
const url = rpcUrl || process.env.RPC_URL;
|
||||
|
||||
if (!url) {
|
||||
throw new Error(
|
||||
"RPC_URL environment variable is required. " +
|
||||
"Set RPC_URL in your .env file to configure the blockchain network endpoint. " +
|
||||
"See .env.example for configuration examples."
|
||||
);
|
||||
}
|
||||
|
||||
return new ethers.JsonRpcProvider(url);
|
||||
}
|
||||
|
||||
// Helper to get provider for a specific chain
|
||||
|
||||
@@ -12,6 +12,7 @@ import { logger } from "./logger.service";
|
||||
import { fetchManifest } from "./manifest.service";
|
||||
import { getProvider, getEntry } from "./registry.service";
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import { getStartBlock } from "../utils/block-range.util";
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
@@ -247,9 +248,11 @@ class VerificationQueueService {
|
||||
const topic0 = ethers.id("ContentRegistered(bytes32,address,string,uint64)");
|
||||
let txHash: string | undefined;
|
||||
try {
|
||||
const startBlock = await getStartBlock(provider);
|
||||
|
||||
const logs = await provider.getLogs({
|
||||
address: data.registryAddress,
|
||||
fromBlock: 0,
|
||||
fromBlock: startBlock,
|
||||
toBlock: "latest",
|
||||
topics: [topic0, fileHash],
|
||||
});
|
||||
|
||||
40
scripts/utils/block-range.util.ts
Normal file
40
scripts/utils/block-range.util.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* Utility functions for blockchain block range calculations
|
||||
*/
|
||||
|
||||
import { ethers } from "ethers";
|
||||
|
||||
/**
|
||||
* Get a safe starting block number for event queries
|
||||
*
|
||||
* @param provider - The ethers provider
|
||||
* @returns A safe starting block number
|
||||
*
|
||||
* Uses REGISTRY_START_BLOCK environment variable if set and valid,
|
||||
* otherwise defaults to (current block - 1,000,000) to avoid scanning entire chain history.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const provider = new ethers.JsonRpcProvider(rpcUrl);
|
||||
* const startBlock = await getStartBlock(provider);
|
||||
* const logs = await provider.getLogs({
|
||||
* address: registryAddress,
|
||||
* fromBlock: startBlock,
|
||||
* toBlock: "latest",
|
||||
* topics: [topic0, contentHash],
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
export async function getStartBlock(provider: ethers.JsonRpcProvider): Promise<number> {
|
||||
// Try to use configured starting block from environment
|
||||
if (process.env.REGISTRY_START_BLOCK) {
|
||||
const parsed = parseInt(process.env.REGISTRY_START_BLOCK, 10);
|
||||
if (!isNaN(parsed) && parsed >= 0) {
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
|
||||
// Default to last 1 million blocks (avoids full chain scan while being comprehensive)
|
||||
const currentBlock = await provider.getBlockNumber();
|
||||
return Math.max(0, currentBlock - 1000000);
|
||||
}
|
||||
@@ -81,9 +81,14 @@ async function main() {
|
||||
|
||||
const recoveredAddress = await verifySignature(manifest);
|
||||
|
||||
const provider = new ethers.JsonRpcProvider(
|
||||
rpcUrl || process.env.RPC_URL || "https://sepolia.base.org"
|
||||
);
|
||||
const url = rpcUrl || process.env.RPC_URL;
|
||||
if (!url) {
|
||||
throw new Error(
|
||||
"RPC_URL is required. Set RPC_URL environment variable or provide --rpc-url argument."
|
||||
);
|
||||
}
|
||||
|
||||
const provider = new ethers.JsonRpcProvider(url);
|
||||
const abi = [
|
||||
"function entries(bytes32) view returns (address creator, bytes32 contentHash, string manifestURI, uint64 timestamp)",
|
||||
];
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
relaxedRateLimit,
|
||||
rateLimitHandler,
|
||||
skipRateLimit,
|
||||
onLimitReached,
|
||||
} from "../../scripts/middleware/rate-limit.middleware";
|
||||
|
||||
describe("Rate Limiting Middleware", function () {
|
||||
@@ -149,7 +148,7 @@ describe("Rate Limiting Middleware", function () {
|
||||
});
|
||||
});
|
||||
|
||||
describe("On Limit Reached", function () {
|
||||
describe("Rate Limit Logging", function () {
|
||||
let consoleWarnStub: sinon.SinonStub;
|
||||
|
||||
beforeEach(function () {
|
||||
@@ -160,14 +159,21 @@ describe("Rate Limiting Middleware", function () {
|
||||
consoleWarnStub.restore();
|
||||
});
|
||||
|
||||
it("should log rate limit hit with IP and path", function () {
|
||||
it("should log rate limit hit with IP and path when handler is called", function () {
|
||||
const req = {
|
||||
ip: "192.168.1.100",
|
||||
path: "/api/upload",
|
||||
rateLimit: {
|
||||
resetTime: new Date(Date.now() + 60000),
|
||||
},
|
||||
} as any;
|
||||
const res = {
|
||||
status: sinon.stub().returnsThis(),
|
||||
json: sinon.stub(),
|
||||
setHeader: sinon.stub(),
|
||||
} as any;
|
||||
const res = {} as any;
|
||||
|
||||
onLimitReached(req, res);
|
||||
rateLimitHandler(req, res);
|
||||
|
||||
expect(consoleWarnStub.calledOnce).to.be.true;
|
||||
const logMessage = consoleWarnStub.getCall(0).args[0];
|
||||
@@ -180,10 +186,17 @@ describe("Rate Limiting Middleware", function () {
|
||||
const req = {
|
||||
socket: { remoteAddress: "10.0.0.1" },
|
||||
path: "/api/verify",
|
||||
rateLimit: {
|
||||
resetTime: new Date(Date.now() + 60000),
|
||||
},
|
||||
} as any;
|
||||
const res = {
|
||||
status: sinon.stub().returnsThis(),
|
||||
json: sinon.stub(),
|
||||
setHeader: sinon.stub(),
|
||||
} as any;
|
||||
const res = {} as any;
|
||||
|
||||
onLimitReached(req, res);
|
||||
rateLimitHandler(req, res);
|
||||
|
||||
expect(consoleWarnStub.calledOnce).to.be.true;
|
||||
const logMessage = consoleWarnStub.getCall(0).args[0];
|
||||
@@ -193,10 +206,17 @@ describe("Rate Limiting Middleware", function () {
|
||||
it("should use 'unknown' for missing IP", function () {
|
||||
const req = {
|
||||
path: "/api/test",
|
||||
rateLimit: {
|
||||
resetTime: new Date(Date.now() + 60000),
|
||||
},
|
||||
} as any;
|
||||
const res = {
|
||||
status: sinon.stub().returnsThis(),
|
||||
json: sinon.stub(),
|
||||
setHeader: sinon.stub(),
|
||||
} as any;
|
||||
const res = {} as any;
|
||||
|
||||
onLimitReached(req, res);
|
||||
rateLimitHandler(req, res);
|
||||
|
||||
expect(consoleWarnStub.calledOnce).to.be.true;
|
||||
const logMessage = consoleWarnStub.getCall(0).args[0];
|
||||
|
||||
175
test/utils/block-range.util.test.ts
Normal file
175
test/utils/block-range.util.test.ts
Normal file
@@ -0,0 +1,175 @@
|
||||
import { expect } from "chai";
|
||||
import { ethers } from "ethers";
|
||||
import * as sinon from "sinon";
|
||||
import { getStartBlock } from "../../scripts/utils/block-range.util";
|
||||
|
||||
describe("Block Range Utilities", () => {
|
||||
let originalEnv: string | undefined;
|
||||
let providerStub: sinon.SinonStubbedInstance<ethers.JsonRpcProvider>;
|
||||
|
||||
beforeEach(() => {
|
||||
// Store original REGISTRY_START_BLOCK
|
||||
originalEnv = process.env.REGISTRY_START_BLOCK;
|
||||
|
||||
// Create provider stub
|
||||
providerStub = sinon.createStubInstance(ethers.JsonRpcProvider);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Restore REGISTRY_START_BLOCK
|
||||
if (originalEnv !== undefined) {
|
||||
process.env.REGISTRY_START_BLOCK = originalEnv;
|
||||
} else {
|
||||
delete process.env.REGISTRY_START_BLOCK;
|
||||
}
|
||||
|
||||
// Restore stubs
|
||||
sinon.restore();
|
||||
});
|
||||
|
||||
describe("getStartBlock", () => {
|
||||
it("should use REGISTRY_START_BLOCK when set to valid positive number", async () => {
|
||||
process.env.REGISTRY_START_BLOCK = "5000000";
|
||||
providerStub.getBlockNumber.resolves(10000000);
|
||||
|
||||
const result = await getStartBlock(providerStub as any);
|
||||
|
||||
expect(result).to.equal(5000000);
|
||||
expect(providerStub.getBlockNumber.called).to.be.false;
|
||||
});
|
||||
|
||||
it("should use REGISTRY_START_BLOCK when set to zero", async () => {
|
||||
process.env.REGISTRY_START_BLOCK = "0";
|
||||
providerStub.getBlockNumber.resolves(10000000);
|
||||
|
||||
const result = await getStartBlock(providerStub as any);
|
||||
|
||||
expect(result).to.equal(0);
|
||||
expect(providerStub.getBlockNumber.called).to.be.false;
|
||||
});
|
||||
|
||||
it("should fall back to default when REGISTRY_START_BLOCK is invalid (NaN)", async () => {
|
||||
process.env.REGISTRY_START_BLOCK = "not-a-number";
|
||||
providerStub.getBlockNumber.resolves(10000000);
|
||||
|
||||
const result = await getStartBlock(providerStub as any);
|
||||
|
||||
expect(result).to.equal(9000000); // 10000000 - 1000000
|
||||
expect(providerStub.getBlockNumber.calledOnce).to.be.true;
|
||||
});
|
||||
|
||||
it("should fall back to default when REGISTRY_START_BLOCK is negative", async () => {
|
||||
process.env.REGISTRY_START_BLOCK = "-100";
|
||||
providerStub.getBlockNumber.resolves(10000000);
|
||||
|
||||
const result = await getStartBlock(providerStub as any);
|
||||
|
||||
expect(result).to.equal(9000000); // 10000000 - 1000000
|
||||
expect(providerStub.getBlockNumber.calledOnce).to.be.true;
|
||||
});
|
||||
|
||||
it("should fall back to default when REGISTRY_START_BLOCK is not set", async () => {
|
||||
delete process.env.REGISTRY_START_BLOCK;
|
||||
providerStub.getBlockNumber.resolves(10000000);
|
||||
|
||||
const result = await getStartBlock(providerStub as any);
|
||||
|
||||
expect(result).to.equal(9000000); // 10000000 - 1000000
|
||||
expect(providerStub.getBlockNumber.calledOnce).to.be.true;
|
||||
});
|
||||
|
||||
it("should return 0 when current block is less than 1 million", async () => {
|
||||
delete process.env.REGISTRY_START_BLOCK;
|
||||
providerStub.getBlockNumber.resolves(500000);
|
||||
|
||||
const result = await getStartBlock(providerStub as any);
|
||||
|
||||
expect(result).to.equal(0); // Math.max(0, 500000 - 1000000)
|
||||
expect(providerStub.getBlockNumber.calledOnce).to.be.true;
|
||||
});
|
||||
|
||||
it("should return 0 when current block is exactly 1 million", async () => {
|
||||
delete process.env.REGISTRY_START_BLOCK;
|
||||
providerStub.getBlockNumber.resolves(1000000);
|
||||
|
||||
const result = await getStartBlock(providerStub as any);
|
||||
|
||||
expect(result).to.equal(0); // Math.max(0, 1000000 - 1000000)
|
||||
expect(providerStub.getBlockNumber.calledOnce).to.be.true;
|
||||
});
|
||||
|
||||
it("should calculate correct starting block for high block numbers", async () => {
|
||||
delete process.env.REGISTRY_START_BLOCK;
|
||||
providerStub.getBlockNumber.resolves(20000000);
|
||||
|
||||
const result = await getStartBlock(providerStub as any);
|
||||
|
||||
expect(result).to.equal(19000000); // 20000000 - 1000000
|
||||
expect(providerStub.getBlockNumber.calledOnce).to.be.true;
|
||||
});
|
||||
|
||||
it("should handle REGISTRY_START_BLOCK with leading zeros", async () => {
|
||||
process.env.REGISTRY_START_BLOCK = "0001000000";
|
||||
providerStub.getBlockNumber.resolves(10000000);
|
||||
|
||||
const result = await getStartBlock(providerStub as any);
|
||||
|
||||
expect(result).to.equal(1000000);
|
||||
expect(providerStub.getBlockNumber.called).to.be.false;
|
||||
});
|
||||
|
||||
it("should handle REGISTRY_START_BLOCK with whitespace by treating it as invalid", async () => {
|
||||
process.env.REGISTRY_START_BLOCK = " 1000000 ";
|
||||
providerStub.getBlockNumber.resolves(10000000);
|
||||
|
||||
const result = await getStartBlock(providerStub as any);
|
||||
|
||||
// parseInt handles leading/trailing whitespace, so this should work
|
||||
expect(result).to.equal(1000000);
|
||||
expect(providerStub.getBlockNumber.called).to.be.false;
|
||||
});
|
||||
|
||||
it("should handle REGISTRY_START_BLOCK as empty string", async () => {
|
||||
process.env.REGISTRY_START_BLOCK = "";
|
||||
providerStub.getBlockNumber.resolves(10000000);
|
||||
|
||||
const result = await getStartBlock(providerStub as any);
|
||||
|
||||
// Empty string will be falsy in the if check, so it should use default
|
||||
expect(result).to.equal(9000000);
|
||||
expect(providerStub.getBlockNumber.calledOnce).to.be.true;
|
||||
});
|
||||
|
||||
it("should handle REGISTRY_START_BLOCK with decimal (uses floor)", async () => {
|
||||
process.env.REGISTRY_START_BLOCK = "1234567.89";
|
||||
providerStub.getBlockNumber.resolves(10000000);
|
||||
|
||||
const result = await getStartBlock(providerStub as any);
|
||||
|
||||
expect(result).to.equal(1234567); // parseInt floors the value
|
||||
expect(providerStub.getBlockNumber.called).to.be.false;
|
||||
});
|
||||
|
||||
it("should handle very large REGISTRY_START_BLOCK values", async () => {
|
||||
process.env.REGISTRY_START_BLOCK = "999999999999";
|
||||
providerStub.getBlockNumber.resolves(10000000);
|
||||
|
||||
const result = await getStartBlock(providerStub as any);
|
||||
|
||||
expect(result).to.equal(999999999999);
|
||||
expect(providerStub.getBlockNumber.called).to.be.false;
|
||||
});
|
||||
|
||||
it("should handle provider errors gracefully", async () => {
|
||||
delete process.env.REGISTRY_START_BLOCK;
|
||||
providerStub.getBlockNumber.rejects(new Error("Network error"));
|
||||
|
||||
try {
|
||||
await getStartBlock(providerStub as any);
|
||||
expect.fail("Should have thrown an error");
|
||||
} catch (error: any) {
|
||||
expect(error.message).to.equal("Network error");
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user