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
1,602 changes: 1,602 additions & 0 deletions indexer/package-lock.json

Large diffs are not rendered by default.

19 changes: 19 additions & 0 deletions indexer/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
{
"name": "truthbounty-indexer",
"version": "0.1.0",
"private": true,
"scripts": {
"build": "tsc",
"start": "node dist/index.js",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"ethers": "^6.13.0"
},
"devDependencies": {
"typescript": "^5.5.0",
"vitest": "^2.0.0",
"@types/node": "^22.0.0"
}
}
36 changes: 36 additions & 0 deletions indexer/src/checkpoint.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { IndexerCheckpoint, StorageAdapter } from "./types";

export class CheckpointManager {
private storage: StorageAdapter;
private checkpoint: IndexerCheckpoint | null = null;

constructor(storage: StorageAdapter) {
this.storage = storage;
}

async load(): Promise<void> {
this.checkpoint = await this.storage.getCheckpoint();
if (this.checkpoint) {
console.log(`[checkpoint] resumed from block ${this.checkpoint.blockNumber}`);
} else {
console.log("[checkpoint] no checkpoint found, starting fresh");
}
}

get(): IndexerCheckpoint | null {
return this.checkpoint;
}

async update(blockNumber: number, blockHash: string): Promise<void> {
this.checkpoint = { blockNumber, blockHash, updatedAt: new Date() };
await this.storage.saveCheckpoint(this.checkpoint);
}

getLastBlockNumber(): number {
return this.checkpoint?.blockNumber ?? 0;
}

getLastBlockHash(): string | null {
return this.checkpoint?.blockHash ?? null;
}
}
58 changes: 58 additions & 0 deletions indexer/src/events.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
export const TRUTH_BOUNTY_ABI = [
"event ClaimCreated(uint256 indexed claimId, address indexed submitter, string content, uint256 verificationWindowEnd)",
"event VoteCast(uint256 indexed claimId, address indexed verifier, bool support, uint256 rawStake, uint256 effectiveStake, uint256 reputationScore)",
"event ClaimSettled(uint256 indexed claimId, bool passed, uint256 totalWeightedFor, uint256 totalWeightedAgainst, uint256 totalRewards, uint256 totalSlashed)",
"event RewardsDistributed(uint256 indexed claimId, address indexed verifier, uint256 amount)",
"event StakeSlashed(uint256 indexed claimId, address indexed verifier, uint256 amount)",
"event StakeDeposited(address indexed verifier, uint256 amount)",
"event StakeWithdrawn(address indexed verifier, uint256 amount)",
"event ClaimWiped(uint256 indexed claimId, address indexed admin, string reason)",
];

export const REPUTATION_DECAY_ABI = [
"event ReputationUpdated(address indexed user, uint256 oldReputation, uint256 newReputation, uint256 timestamp)",
"event ActivityRecorded(address indexed user, uint256 timestamp)",
"event ReputationDecayed(address indexed verifier, uint256 previousScore, uint256 newScore)",
"event DecayConfigUpdated(uint256 decayInterval, uint256 decayPercentage, uint256 minimumReputation, bool enabled)",
];

export const REPUTATION_SNAPSHOT_ABI = [
"event ReputationSnapshotRecorded(address indexed user, uint256 reputationScore, uint256 timestamp)",
"event ReputationStalenessValidated(address indexed user, uint256 expectedReputation, uint256 actualReputation, uint256 maxDrift)",
];

export interface KnownEvent {
contract: string;
event: string;
signature: string;
abi: string;
}

export function getEventSignatures(): KnownEvent[] {
return [
...TRUTH_BOUNTY_ABI.map((abi) => ({
contract: "TruthBountyWeighted",
event: extractEventName(abi),
signature: abi,
abi,
})),
...REPUTATION_DECAY_ABI.map((abi) => ({
contract: "ReputationDecay",
event: extractEventName(abi),
signature: abi,
abi,
})),
...REPUTATION_SNAPSHOT_ABI.map((abi) => ({
contract: "ReputationSnapshot",
event: extractEventName(abi),
signature: abi,
abi,
})),
];
}

function extractEventName(abi: string): string {
const match = abi.match(/^event\s+(\w+)/);
if (!match) throw new Error(`Invalid event ABI: ${abi}`);
return match[1];
}
64 changes: 64 additions & 0 deletions indexer/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { Indexer } from "./indexer";
import { IndexerConfig } from "./types";
import { TRUTH_BOUNTY_ABI, REPUTATION_DECAY_ABI, REPUTATION_SNAPSHOT_ABI } from "./events";

function loadConfig(): IndexerConfig {
return {
chainId: parseInt(process.env.CHAIN_ID || "11155420", 10),
rpcUrl: process.env.RPC_URL || "http://localhost:8545",
wsUrl: process.env.WS_URL,
contracts: [
{
name: "TruthBountyWeighted",
address: process.env.TRUTH_BOUNTY_ADDRESS || "",
abi: TRUTH_BOUNTY_ABI,
fromBlock: parseInt(process.env.TRUTH_BOUNTY_FROM_BLOCK || "0", 10),
},
{
name: "ReputationDecay",
address: process.env.REPUTATION_DECAY_ADDRESS || "",
abi: REPUTATION_DECAY_ABI,
fromBlock: parseInt(process.env.REPUTATION_DECAY_FROM_BLOCK || "0", 10),
},
{
name: "ReputationSnapshot",
address: process.env.REPUTATION_SNAPSHOT_ADDRESS || "",
abi: REPUTATION_SNAPSHOT_ABI,
fromBlock: parseInt(process.env.REPUTATION_SNAPSHOT_FROM_BLOCK || "0", 10),
},
].filter((c) => c.address),
startBlock: parseInt(process.env.START_BLOCK || "0", 10),
checkpointInterval: parseInt(process.env.CHECKPOINT_INTERVAL || "100", 10),
maxReorgDepth: parseInt(process.env.MAX_REORG_DEPTH || "12", 10),
pollIntervalMs: parseInt(process.env.POLL_INTERVAL_MS || "5000", 10),
confirmations: parseInt(process.env.CONFIRMATIONS || "6", 10),
};
}

async function main() {
const config = loadConfig();

if (!config.contracts.length) {
console.error("No contract addresses configured. Set TRUTH_BOUNTY_ADDRESS, REPUTATION_DECAY_ADDRESS, etc.");
process.exit(1);
}

const indexer = new Indexer(config);

indexer.onEvent(async (event) => {
console.log(`[event] ${event.contractName}.${event.eventName} (block ${event.blockNumber})`);
});

process.on("SIGINT", async () => {
console.log("\n[indexer] shutting down...");
await indexer.stop();
process.exit(0);
});

await indexer.start();
}

main().catch((err) => {
console.error(`[indexer] fatal: ${err}`);
process.exit(1);
});
143 changes: 143 additions & 0 deletions indexer/src/indexer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
import { ProviderManager } from "./provider";
import { CheckpointManager } from "./checkpoint";
import { EventPipeline } from "./pipeline";
import { IndexerConfig, StorageAdapter, IndexedEvent } from "./types";
import { InMemoryStorage } from "./storage";

export class Indexer {
private config: IndexerConfig;
private provider: ProviderManager;
private checkpoint: CheckpointManager;
private pipeline: EventPipeline;
private storage: StorageAdapter;
private running = false;
private pollTimer: ReturnType<typeof setInterval> | null = null;
private eventHandlers: Array<(event: IndexedEvent) => Promise<void>> = [];

constructor(config: IndexerConfig) {
this.config = config;
this.storage = new InMemoryStorage();
this.provider = new ProviderManager(config.rpcUrl, config.wsUrl);
this.checkpoint = new CheckpointManager(this.storage);
this.pipeline = new EventPipeline(this.storage, config.contracts);
}

onEvent(handler: (event: IndexedEvent) => Promise<void>): void {
this.eventHandlers.push(handler);
}

getStorage(): StorageAdapter {
return this.storage;
}

async start(): Promise<void> {
this.running = true;
await this.storage.connect();
await this.checkpoint.load();

console.log(`[indexer] starting from block ${this.checkpoint.getLastBlockNumber() || this.config.startBlock}`);

await this.syncHistorical();

if (this.provider.hasWebSocket) {
await this.provider.onNewBlock((blockNumber) => {
this.onNewBlock(blockNumber).catch((err) =>
console.error(`[indexer] live block error: ${err}`)
);
});
}

this.startPolling();
console.log("[indexer] live listening started");
}

async stop(): Promise<void> {
this.running = false;
if (this.pollTimer) clearInterval(this.pollTimer);
this.provider.destroy();
await this.storage.disconnect();
console.log("[indexer] stopped");
}

private async syncHistorical(): Promise<void> {
const startBlock = this.checkpoint.getLastBlockNumber() || this.config.startBlock;
const latestBlock = await this.provider.getBlockNumber();
const safeLatest = latestBlock - this.config.confirmations;

if (startBlock >= safeLatest) return;

console.log(`[indexer] historical sync: blocks ${startBlock} → ${safeLatest}`);

const batchSize = 1000;
for (let from = startBlock; from < safeLatest; from += batchSize) {
if (!this.running) break;
const to = Math.min(from + batchSize - 1, safeLatest);
await this.syncBlocks(from, to);
}
}

private async syncBlocks(fromBlock: number, toBlock: number): Promise<void> {
const logs = await this.pipeline.fetchLogs(this.provider.provider, fromBlock, toBlock);
if (logs.length === 0) {
await this.checkpoint.update(toBlock, "");
return;
}

const events = await this.pipeline.processLogs(logs, this.config.chainId);
await this.pipeline.persistEvents(events);
await this.runHandlers(events);

const lastLog = logs[logs.length - 1];
await this.checkpoint.update(lastLog.blockNumber, lastLog.blockHash);

console.log(`[indexer] synced blocks ${fromBlock}-${toBlock}: ${events.length} events`);
}

private async onNewBlock(blockNumber: number): Promise<void> {
const cp = this.checkpoint.get();
if (cp) {
const reorgBlock = await this.pipeline.detectReorg(
this.provider.provider,
cp,
this.config.maxReorgDepth
);
if (reorgBlock !== null) {
console.log(`[indexer] reorg detected at block ${reorgBlock}, rolling back`);
await this.syncBlocks(reorgBlock, blockNumber);
return;
}
}

const fromBlock = this.checkpoint.getLastBlockNumber() + 1;
if (fromBlock <= blockNumber) {
await this.syncBlocks(fromBlock, blockNumber);
}
}

private startPolling(): void {
this.pollTimer = setInterval(async () => {
if (!this.running) return;
try {
const latest = await this.provider.getBlockNumber();
const safeLatest = latest - this.config.confirmations;
const lastIndexed = this.checkpoint.getLastBlockNumber();

if (safeLatest > lastIndexed) {
await this.onNewBlock(safeLatest);
}
} catch (err) {
console.error(`[indexer] poll error: ${err}`);
}
}, this.config.pollIntervalMs);
}

private async runHandlers(events: IndexedEvent[]): Promise<void> {
for (const event of events) {
for (const handler of this.eventHandlers) {
await handler(event);
}
}
}
}

export { InMemoryStorage };
Loading