diff --git a/src/core/__tests__/indexer.test.ts b/src/core/__tests__/indexer.test.ts index 90e1693..1975847 100644 --- a/src/core/__tests__/indexer.test.ts +++ b/src/core/__tests__/indexer.test.ts @@ -13,6 +13,23 @@ jest.mock('../../ui/patch', () => ({ patchWriteStreams: jest.fn(), })); +// Mock WebSocketChannel to prevent WebSocket initialization errors +jest.mock('starknet', () => { + const actual = jest.requireActual('starknet'); + return { + ...actual, + WebSocketChannel: jest.fn().mockImplementation(() => ({ + on: jest.fn(), + waitForConnection: jest.fn().mockResolvedValue(undefined), + subscribeNewHeads: jest.fn().mockResolvedValue({ + on: jest.fn(), + }), + isConnected: jest.fn().mockReturnValue(true), + disconnect: jest.fn(), + })), + }; +}); + describe('StarknetIndexer', () => { // Global cleanup to ensure Jest exits properly afterAll(async () => { @@ -49,6 +66,66 @@ describe('StarknetIndexer', () => { expect(indexer).toBeInstanceOf(StarknetIndexer); }); + it('should resolve start block from contract address nonce lookup', async () => { + const contractAddress = '0x4718f5a0fc34cc1af16a1cdee98ffb20c31f5cd61d6ab07201858f4287c938d'; + const normalizedAddress = contractAddress.toLowerCase(); + const mockDbHandler = { + isConnected: jest.fn().mockReturnValue(true), + connect: jest.fn().mockResolvedValue(undefined), + disconnect: jest.fn().mockResolvedValue(undefined), + initializeDb: jest.fn().mockResolvedValue(undefined), + getIndexerState: jest.fn().mockResolvedValue(null), + initializeIndexerState: jest.fn().mockResolvedValue(undefined), + }; + + const mockProvider = { + getBlockNumber: jest.fn().mockResolvedValue(100), + getNonceForAddress: jest.fn().mockImplementation((_address: string, blockNumber: number) => { + if (blockNumber < 42) { + throw new Error('Contract not found'); + } + return '0x1'; + }), + }; + + const mockWsChannel = { + on: jest.fn(), + waitForConnection: jest.fn().mockResolvedValue(undefined), + subscribeNewHeads: jest.fn().mockResolvedValue({ + on: jest.fn(), + }), + isConnected: jest.fn().mockReturnValue(true), + }; + + const indexer = new StarknetIndexer({ + rpcNodeUrl: 'http://localhost:9944', + wsNodeUrl: 'ws://localhost:9945', + database: { + type: 'sqlite', + config: { dbInstance: new Database('./memory.db') }, + }, + contractAddresses: [contractAddress], + }); + + (indexer as any).dbHandler = mockDbHandler; + (indexer as any).provider = mockProvider; + (indexer as any).wsChannel = mockWsChannel; + + const startBlock = await indexer.initializeDatabase(); + + expect(startBlock).toBe(42); + expect(mockProvider.getBlockNumber).toHaveBeenCalled(); + // Verify that getNonceForAddress was called (binary search should eventually call block 42) + expect(mockProvider.getNonceForAddress).toHaveBeenCalled(); + // Get the actual normalized address from the calls + const calls = (mockProvider.getNonceForAddress as jest.Mock).mock.calls; + const actualNormalizedAddress = calls[0][0]; + // Verify that block 42 was called during the binary search + const block42Call = calls.find((call) => call[1] === 42); + expect(block42Call).toBeDefined(); + expect(block42Call[0]).toBe(actualNormalizedAddress); + }); + it('should throw error if onEvent is called without contractAddress', async () => { const indexer = new StarknetIndexer({ rpcNodeUrl: 'http://localhost:9944', diff --git a/src/core/indexer.ts b/src/core/indexer.ts index 441af23..f476785 100644 --- a/src/core/indexer.ts +++ b/src/core/indexer.ts @@ -10,7 +10,11 @@ import { Subscription, } from 'starknet'; -import { groupConsecutiveBlocks, parallelMap } from '../utils/blockUtils'; +import { + findContractDeploymentBlock, + groupConsecutiveBlocks, + parallelMap, +} from '../utils/blockUtils'; import { EventHandlerConfig, BaseEventHandlerConfig, @@ -247,6 +251,44 @@ export class StarknetIndexer { } } + private async findContractDeploymentBlock(contractAddresses: string[]): Promise { + if (!this.provider) { + throw new Error('Provider not initialized'); + } + + if (contractAddresses.length > 0) { + this.logger.info( + `Resolving starting block from ${contractAddresses.length} contract address(es)` + ); + this.progressStats.incrementRpcRequest(); + let earliestDeploymentBlock: number | null = null; + for (const contractAddress of this.contractAddresses) { + this.logger.debug(`Finding deployment block for contract ${contractAddress}`); + const startTime = Date.now(); + const deploymentBlock = await findContractDeploymentBlock(this.provider, contractAddress, { + onRpcRequest: () => this.progressStats.incrementRpcRequest(), + }); + const duration = Date.now() - startTime; + this.logger.debug( + `Resolved deployment block for ${contractAddress} to ${deploymentBlock} (took ${duration}ms)` + ); + if (earliestDeploymentBlock === null || deploymentBlock < earliestDeploymentBlock) { + earliestDeploymentBlock = deploymentBlock; + } + } + + if (earliestDeploymentBlock !== null) { + this.logger.info(`Resolved earliest deployment block: ${earliestDeploymentBlock}`); + return earliestDeploymentBlock; + } + } + + this.logger.info('No contract deployment block found; using latest block'); + this.progressStats.incrementRpcRequest(); + const latestBlock = await this.provider.getBlockNumber(); + return latestBlock; + } + // Initialize the database schema public async initializeDatabase(): Promise { const shouldRelease = !this.dbHandler.isConnected(); @@ -270,14 +312,9 @@ export class StarknetIndexer { if (!result) { this.hasExistingState = false; - let startingBlock: number; - if (this.config.startingBlockNumber === 'latest') { - if (!this.provider) throw new Error('Provider not initialized'); - this.progressStats.incrementRpcRequest(); - startingBlock = await this.provider.getBlockNumber(); - } else { - startingBlock = this.config.startingBlockNumber; - } + const startingBlock = await this.findContractDeploymentBlock( + Array.from(this.contractAddresses) + ); this.cursor = { blockNumber: startingBlock, blockHash: '' }; await this.dbHandler.initializeIndexerState(startingBlock, this.config.cursorKey); return startingBlock; @@ -387,8 +424,12 @@ export class StarknetIndexer { // Fresh start: honor configured starting block (or latest) if (this.config.startingBlockNumber === 'latest') { targetBlock = currentBlock; - } else { + } else if (typeof this.config.startingBlockNumber === 'number') { targetBlock = this.config.startingBlockNumber; + } else if (this.contractAddresses.size > 0) { + targetBlock = await this.findContractDeploymentBlock(Array.from(this.contractAddresses)); + } else { + targetBlock = currentBlock; } } diff --git a/src/types/indexer.ts b/src/types/indexer.ts index e5d62ca..747d24f 100644 --- a/src/types/indexer.ts +++ b/src/types/indexer.ts @@ -83,7 +83,7 @@ export interface IndexerConfig { database: DatabaseConfig; /** Block number to start indexing from, or 'latest' to start from current block */ - startingBlockNumber: number | 'latest'; + startingBlockNumber?: number | 'latest'; /** Optional array of contract addresses to monitor for events */ contractAddresses?: string[]; diff --git a/src/utils/__tests__/blockUtils.test.ts b/src/utils/__tests__/blockUtils.test.ts new file mode 100644 index 0000000..61cb43d --- /dev/null +++ b/src/utils/__tests__/blockUtils.test.ts @@ -0,0 +1,51 @@ +import { findContractDeploymentBlock } from '../blockUtils'; +import { RpcProvider } from 'starknet'; + +describe('findContractDeploymentBlock', () => { + // Use public Starknet Sepolia RPC endpoint for testing + const provider = new RpcProvider({ + nodeUrl: 'https://starknet-sepolia-rpc.publicnode.com', + specVersion: '0.8.1', + }); + + // Well-known contract on Sepolia testnet + const deployedContractAddress = '0x4718f5a0fc34cc1af16a1cdee98ffb20c31f5cd61d6ab07201858f4287c938d'; + + it('should find deployment block for an existing contract', async () => { + const deploymentBlock = await findContractDeploymentBlock(provider, deployedContractAddress); + + expect(deploymentBlock).toBe(6379); + expect(typeof deploymentBlock).toBe('number'); + + // Verify the contract actually exists at the found block + const nonce = await provider.getNonceForAddress(deployedContractAddress, deploymentBlock); + expect(nonce).toBeDefined(); + }, 30000); // Increase timeout for network calls + + it('should throw error when contract is not deployed', async () => { + // Use an address that doesn't exist (random address) + const nonExistentAddress = '0x1234567890123456789012345678901234567890123456789012345678901234'; + + await expect( + findContractDeploymentBlock(provider, nonExistentAddress) + ).rejects.toThrow('Unable to find deployment block for contract'); + }, 30000); + + it('should efficiently find deployment block using binary search', async () => { + const rpcCallCount = { count: 0 }; + const onRpcRequest = () => { + rpcCallCount.count++; + }; + + const deploymentBlock = await findContractDeploymentBlock(provider, deployedContractAddress, { + onRpcRequest, + }); + + expect(deploymentBlock).toBe(6379); + // Binary search should make O(log n) calls + // For a typical blockchain with thousands of blocks, should be reasonable + expect(rpcCallCount.count).toBeGreaterThan(0); + expect(rpcCallCount.count).toBeLessThan(50); // Should be much less than linear search + }, 30000); +}); + diff --git a/src/utils/blockUtils.ts b/src/utils/blockUtils.ts index cfb1c3f..8636342 100644 --- a/src/utils/blockUtils.ts +++ b/src/utils/blockUtils.ts @@ -1,3 +1,5 @@ +import { BlockNumber, RpcProvider } from 'starknet'; + type BlockRange = { from: number; to: number }; export const groupConsecutiveBlocks = (blocks: number[]): BlockRange[] => { @@ -39,3 +41,37 @@ export async function parallelMap( await Promise.all(Array(concurrency).fill(0).map(worker)); return results; } + +type FindDeploymentOptions = { + onRpcRequest?: () => void; +}; + +export async function findContractDeploymentBlock( + provider: RpcProvider, + contractAddress: string, + options: FindDeploymentOptions = {} +): Promise { + let low = 0; + let high = await provider.getBlockNumber(); + + let foundBlock: number | null = null; + + while (low <= high) { + const mid = Math.floor((low + high) / 2); + + try { + options.onRpcRequest?.(); + await provider.getNonceForAddress(contractAddress, mid); + foundBlock = mid; + high = mid - 1; + } catch (_error) { + low = mid + 1; + } + } + + if (foundBlock === null) { + throw new Error(`Unable to find deployment block for contract ${contractAddress}`); + } + + return foundBlock; +}