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
56 changes: 56 additions & 0 deletions apps/indexer/src/handlers/erc1155.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
// ERC-1155 TransferSingle handler. Layout differs from ERC-20/721:
// topic0: TransferSingle signature
// topic1: indexed operator (ignored — same as msg.sender for transfers)
// topic2: indexed from
// topic3: indexed to
// data: abi.encode(uint256 id, uint256 value) — two 32-byte words
//
// TransferBatch shares the same shape header but data is two dynamic
// arrays — non-trivial to decode without an ABI decoder. We register
// a stub that records the raw log via sync.ts (the registry returns
// null so no transfer row, but the log row still lands) and defer the
// batch materialisation to a follow-up worker.

import { register, topicToAddress, type EventHandler } from "./registry.js";

export const ERC1155_TRANSFER_SINGLE =
"0xc3d58168c5ae7397731d063d5bbf3d657854427343f4c083240f7aacaa2d0f62";
export const ERC1155_TRANSFER_BATCH =
"0x4a39dc06d4c0dbc64b70af90fd698a233a518aa5d07e595d983b8c0526c8f7fb";

const single: EventHandler = {
topic0: ERC1155_TRANSFER_SINGLE,
decode: ({ log, contract, txHash }) => {
if (log.blockNumber == null || log.logIndex == null) return null;
if (log.topics.length < 4) return null;
const data = log.data.replace(/^0x/, "");
if (data.length < 128) return null; // need two 32-byte words
const id = BigInt("0x" + data.slice(0, 64));
const value = BigInt("0x" + data.slice(64, 128));
return {
blockHeight: log.blockNumber,
txHash,
logIndex: log.logIndex,
contract,
standard: "erc1155",
fromAddr: topicToAddress(log.topics[2]!),
toAddr: topicToAddress(log.topics[3]!),
tokenId: id.toString(),
amount: value.toString(),
};
},
};

const batch: EventHandler = {
topic0: ERC1155_TRANSFER_BATCH,
decode: () => {
// Two dynamic arrays encoded inline — the per-transfer rows can't
// be flattened cleanly into the schema's one-row-per-transfer
// shape without growing tokenTransfers. The raw log still lands
// via sync.ts so the deferred batch materialiser can re-decode.
return null;
},
};

register(single);
register(batch);
38 changes: 38 additions & 0 deletions apps/indexer/src/handlers/erc20.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
// ERC-20 Transfer handler. The Transfer event signature is shared with
// ERC-721 — both emit topic0 = keccak("Transfer(address,address,uint256)").
// The two are disambiguated by indexed-arg count: ERC-20 indexes from
// + to (3 topics total including topic0); ERC-721 also indexes the
// tokenId (4 topics total).

import { register, topicToAddress, type EventHandler } from "./registry.js";

export const ERC20_TRANSFER_TOPIC =
"0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef";

const handler: EventHandler = {
topic0: ERC20_TRANSFER_TOPIC,
decode: ({ log, contract, txHash }) => {
// ERC-20 has exactly three topics (Transfer signature + indexed
// from + indexed to). Anything else is the ERC-721 sibling
// (4 topics) which the dedicated handler picks up via its own
// length check.
if (log.topics.length !== 3) return null;
if (log.blockNumber == null || log.logIndex == null) return null;
return {
blockHeight: log.blockNumber,
txHash,
logIndex: log.logIndex,
contract,
standard: "erc20",
fromAddr: topicToAddress(log.topics[1]!),
toAddr: topicToAddress(log.topics[2]!),
tokenId: null,
// value is the unindexed uint256 in `data`. Empty data is a
// malformed but on-chain-valid event; treat as zero so the row
// still lands and the operator can grep for it later.
amount: BigInt(log.data || "0x0").toString(),
};
},
};

register(handler);
27 changes: 27 additions & 0 deletions apps/indexer/src/handlers/erc721.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
// ERC-721 Transfer handler. Shares topic0 with ERC-20 — disambiguated
// here by the indexed-arg count (4 topics: signature + indexed from,
// to, tokenId).

import { register, topicToAddress, type EventHandler } from "./registry.js";
import { ERC20_TRANSFER_TOPIC } from "./erc20.js";

const handler: EventHandler = {
topic0: ERC20_TRANSFER_TOPIC,
decode: ({ log, contract, txHash }) => {
if (log.topics.length !== 4) return null;
if (log.blockNumber == null || log.logIndex == null) return null;
return {
blockHeight: log.blockNumber,
txHash,
logIndex: log.logIndex,
contract,
standard: "erc721",
fromAddr: topicToAddress(log.topics[1]!),
toAddr: topicToAddress(log.topics[2]!),
tokenId: BigInt(log.topics[3]!).toString(),
amount: "1",
};
},
};

register(handler);
10 changes: 10 additions & 0 deletions apps/indexer/src/handlers/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
// Side-effect import — each handler module calls register() at top
// level. Importing this barrel from the worker once boots the registry
// with every built-in handler. Adding a new event type is then
// `import "./handlers/my-event.js"` here, no sync.ts edit.

import "./erc20.js";
import "./erc721.js";
import "./erc1155.js";

export { dispatch, register, type EventHandler, type DecodedLogContext } from "./registry.js";
93 changes: 93 additions & 0 deletions apps/indexer/src/handlers/registry.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
// Declarative event-handler registry. Pre-Tier-3 sync.ts hard-coded the
// dispatch:
//
// if (t0 === ERC20_TRANSFER && length === 3) { … erc-20 row … }
// else if (t0 === ERC20_TRANSFER && length === 4) { … erc-721 row … }
// else if (t0 === ERC1155_SINGLE) { … }
//
// Adding a new event type — DEX swap, NFT mint, custom protocol log —
// meant editing sync.ts in the middle of its hot loop and growing an
// already-busy if/else. The registry pattern lifts the dispatch into a
// table the loop just iterates: each handler declares the topic0 it
// owns and a pure decoder that returns the row to insert (or null if
// the log shape is recognised but should be skipped, eg ERC-1155 batch
// transfers we defer materialising).
//
// Same wire-format / behaviour as the previous hardcoded path — this is
// purely a refactor. Adding a handler is now `register(myHandler)` from
// a new file under apps/indexer/src/handlers/, no sync.ts edits.

import type { Log as ViemLog } from "viem";

import type { tokenTransfers } from "@sentriscloud/indexer-db";

export type TransferRow = typeof tokenTransfers.$inferInsert;

/** Chain-shape log we hand to a handler. Same field set sync.ts already
* produces from `chain.getLogsRange()` plus the lowercased + per-block
* fields the handler needs to build a row. */
export interface DecodedLogContext {
/** Log itself, viem shape. Topics may be undefined past the indexed
* count; handlers should validate the length they expect. */
log: ViemLog;
/** Lowercased contract address — already normalised so handlers don't
* each repeat the toLowerCase. */
contract: string;
/** Lowercased tx hash. */
txHash: string;
}

/** Handler contract: declare which topic0 you own + how to decode it. */
export interface EventHandler {
/** Owning topic0 (lowercased hex string with 0x prefix). The registry
* keys handlers by this — at most one handler per topic0 today.
* Multiple-handler-per-topic-0 (eg disambiguating ERC-20 vs ERC-721
* Transfer by topic count) is encoded inside the decode function via
* a length check + null return. */
topic0: string;
/** Pure decoder. Returns the transfer row to insert, or null if the
* log matched topic0 but the handler chose to skip it (wrong arity,
* deferred decode, malformed data). Throwing is reserved for genuine
* bugs — caller surfaces them via log.error so an operator can grep. */
decode: (ctx: DecodedLogContext) => TransferRow | null;
}

const REGISTRY = new Map<string, EventHandler[]>();

/** Register a handler. Multiple handlers can share a topic0 — the
* dispatcher walks them in registration order and keeps the first
* non-null result. Useful for the ERC-20 / ERC-721 split where both
* declare the same Transfer topic but disambiguate via topic count. */
export function register(handler: EventHandler) {
const existing = REGISTRY.get(handler.topic0);
if (existing) {
existing.push(handler);
} else {
REGISTRY.set(handler.topic0, [handler]);
}
}

/** Run every handler that matched the log's topic0; return the first
* non-null decoded row, or null if no handler claimed it. */
export function dispatch(ctx: DecodedLogContext): TransferRow | null {
const t0 = ctx.log.topics[0]?.toLowerCase();
if (!t0) return null;
const handlers = REGISTRY.get(t0);
if (!handlers) return null;
for (const h of handlers) {
const row = h.decode(ctx);
if (row !== null) return row;
}
return null;
}

/** Reset between tests — never called from the worker. */
export function _reset() {
REGISTRY.clear();
}

/** Helper: 32-byte right-padded address topic → 0x-prefixed lowercased
* 20-byte address. */
export function topicToAddress(topic: string): string {
return "0x" + topic.slice(-40).toLowerCase();
}
66 changes: 11 additions & 55 deletions apps/indexer/src/sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,13 +24,7 @@ import {
transactions as txsTable,
} from "@sentriscloud/indexer-db";
import type { SentrixClient } from "@sentriscloud/indexer-chain";

const ERC20_TRANSFER =
"0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef";
const ERC1155_SINGLE =
"0xc3d58168c5ae7397731d063d5bbf3d657854427343f4c083240f7aacaa2d0f62";
const ERC1155_BATCH =
"0x4a39dc06d4c0dbc64b70af90fd698a233a518aa5d07e595d983b8c0526c8f7fb";
import { dispatch } from "./handlers/index.js";

interface SyncOnceArgs {
db: DbClient;
Expand Down Expand Up @@ -232,50 +226,16 @@ export async function indexBlock(args: IndexBlockArgs) {
topic3: lower(l.topics[3]),
data: l.data,
});
const t0 = l.topics[0];
if (t0 === ERC20_TRANSFER && l.topics.length === 3) {
transferRows.push({
blockHeight: l.blockNumber,
txHash: txHashLower,
logIndex: l.logIndex,
contract: logAddr,
standard: "erc20",
fromAddr: topicToAddress(l.topics[1]!),
toAddr: topicToAddress(l.topics[2]!),
tokenId: null,
amount: BigInt(l.data || "0x0").toString(),
});
} else if (t0 === ERC20_TRANSFER && l.topics.length === 4) {
transferRows.push({
blockHeight: l.blockNumber,
txHash: txHashLower,
logIndex: l.logIndex,
contract: logAddr,
standard: "erc721",
fromAddr: topicToAddress(l.topics[1]!),
toAddr: topicToAddress(l.topics[2]!),
tokenId: BigInt(l.topics[3]!).toString(),
amount: "1",
});
} else if (t0 === ERC1155_SINGLE) {
const data = l.data.replace(/^0x/, "");
const id = BigInt("0x" + data.slice(0, 64));
const value = BigInt("0x" + data.slice(64, 128));
transferRows.push({
blockHeight: l.blockNumber,
txHash: txHashLower,
logIndex: l.logIndex,
contract: logAddr,
standard: "erc1155",
fromAddr: topicToAddress(l.topics[2]!),
toAddr: topicToAddress(l.topics[3]!),
tokenId: id.toString(),
amount: value.toString(),
});
} else if (t0 === ERC1155_BATCH) {
// Two dynamic arrays — defer batch decode. Raw log still inserted
// so the materialiser can re-decode at its own pace.
}
// Hand the log to the registry; whichever handler owns this topic0
// returns a TransferRow (or null to skip — eg ERC-1155 batch
// currently null-returns since the per-transfer rows can't be
// flattened into one schema row without growing tokenTransfers).
const transferRow = dispatch({
log: l,
contract: logAddr,
txHash: txHashLower,
});
if (transferRow) transferRows.push(transferRow);
}

// ── PHASE 4: single SQL transaction with all batch INSERTs. The
Expand Down Expand Up @@ -353,7 +313,3 @@ async function readLastSynced(db: DbClient): Promise<bigint> {
return BigInt(rows[0].value);
}

function topicToAddress(topic: string): string {
// Topics are 32-byte right-padded; addresses are the right-most 20 bytes.
return "0x" + topic.slice(-40).toLowerCase();
}
Loading