Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 77 additions & 0 deletions src/core/__tests__/indexer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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',
Expand Down
61 changes: 51 additions & 10 deletions src/core/indexer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,11 @@ import {
Subscription,
} from 'starknet';

import { groupConsecutiveBlocks, parallelMap } from '../utils/blockUtils';
import {
findContractDeploymentBlock,
groupConsecutiveBlocks,
parallelMap,
} from '../utils/blockUtils';
import {
EventHandlerConfig,
BaseEventHandlerConfig,
Expand Down Expand Up @@ -247,6 +251,44 @@ export class StarknetIndexer {
}
}

private async findContractDeploymentBlock(contractAddresses: string[]): Promise<number> {
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<number | undefined> {
const shouldRelease = !this.dbHandler.isConnected();
Expand All @@ -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;
Expand Down Expand Up @@ -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;
}
}

Expand Down
2 changes: 1 addition & 1 deletion src/types/indexer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
Expand Down
51 changes: 51 additions & 0 deletions src/utils/__tests__/blockUtils.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});

36 changes: 36 additions & 0 deletions src/utils/blockUtils.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { BlockNumber, RpcProvider } from 'starknet';

type BlockRange = { from: number; to: number };

export const groupConsecutiveBlocks = (blocks: number[]): BlockRange[] => {
Expand Down Expand Up @@ -39,3 +41,37 @@ export async function parallelMap<T, R>(
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<number> {
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;
}
Loading