diff --git a/apps/indexer/src/handlers/erc1155.ts b/apps/indexer/src/handlers/erc1155.ts new file mode 100644 index 0000000..57264fb --- /dev/null +++ b/apps/indexer/src/handlers/erc1155.ts @@ -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); diff --git a/apps/indexer/src/handlers/erc20.ts b/apps/indexer/src/handlers/erc20.ts new file mode 100644 index 0000000..060dd58 --- /dev/null +++ b/apps/indexer/src/handlers/erc20.ts @@ -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); diff --git a/apps/indexer/src/handlers/erc721.ts b/apps/indexer/src/handlers/erc721.ts new file mode 100644 index 0000000..55b5858 --- /dev/null +++ b/apps/indexer/src/handlers/erc721.ts @@ -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); diff --git a/apps/indexer/src/handlers/index.ts b/apps/indexer/src/handlers/index.ts new file mode 100644 index 0000000..be98652 --- /dev/null +++ b/apps/indexer/src/handlers/index.ts @@ -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"; diff --git a/apps/indexer/src/handlers/registry.ts b/apps/indexer/src/handlers/registry.ts new file mode 100644 index 0000000..5e578c2 --- /dev/null +++ b/apps/indexer/src/handlers/registry.ts @@ -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(); + +/** 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(); +} diff --git a/apps/indexer/src/sync.ts b/apps/indexer/src/sync.ts index 6d36edf..0b9fffc 100644 --- a/apps/indexer/src/sync.ts +++ b/apps/indexer/src/sync.ts @@ -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; @@ -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 @@ -353,7 +313,3 @@ async function readLastSynced(db: DbClient): Promise { 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(); -}