From 208a9042f9f6cca045e585e6c1688835cfa73b54 Mon Sep 17 00:00:00 2001 From: satyakwok Date: Tue, 5 May 2026 14:52:44 +0200 Subject: [PATCH 1/4] ops(testnet): override Dockerfile healthcheck for relocated ports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Image `wget`s 127.0.0.1:8081/health (api) and 127.0.0.1:8082/health (worker) by default — that's the mainnet stack's port layout. Testnet relocates both via API_PORT=8083 + INDEXER_HEALTH_PORT=8084 to share the host with the mainnet stack, but the bake-time healthcheck still hit the old ports → exit 1 → docker reported unhealthy even though both services were happily serving 200s on testnet-api.sentrixchain.com. Add explicit `healthcheck:` blocks on each compose service that point at the relocated ports. Same interval / timeout / retries as the Dockerfile defaults so behaviour matches mainnet otherwise. Verified: post-recreate, `docker inspect -f '{{.State.Health.Status}}'` returns healthy on both `sentrix-indexer-testnet-{api,worker}` within seconds of start_period elapsing. --- docker-compose.testnet.yml | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/docker-compose.testnet.yml b/docker-compose.testnet.yml index 764b0e5..3e47832 100644 --- a/docker-compose.testnet.yml +++ b/docker-compose.testnet.yml @@ -53,6 +53,15 @@ services: LOG_LEVEL: info ports: - "127.0.0.1:8084:8084" + # Override the Dockerfile's built-in healthcheck — the image bakes + # `wget http://127.0.0.1:8082/health` (mainnet default) but the + # testnet stack runs the worker on 8084 via INDEXER_HEALTH_PORT. + healthcheck: + test: ["CMD-SHELL", "wget --no-verbose --tries=1 --spider http://127.0.0.1:8084/health || exit 1"] + interval: 30s + timeout: 5s + start_period: 20s + retries: 3 api: build: @@ -71,3 +80,11 @@ services: LOG_LEVEL: info ports: - "127.0.0.1:8083:8083" + # Same Dockerfile-port-mismatch story as the worker — bake-time + # default is 8081, testnet runs on 8083 via API_PORT env. + healthcheck: + test: ["CMD-SHELL", "wget --no-verbose --tries=1 --spider http://127.0.0.1:8083/health || exit 1"] + interval: 30s + timeout: 5s + start_period: 15s + retries: 3 From 0b5e4f4900566316f7a27bb19f63e76a0c75e3d9 Mon Sep 17 00:00:00 2001 From: satyakwok Date: Tue, 5 May 2026 16:37:28 +0200 Subject: [PATCH 2/4] fix(indexer): populate addresses table from each tx MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit indexBlock was writing blocks/transactions/logs/token_transfers but never upserting into the addresses table — so addresses sat empty even after 50K+ indexed txs. Any UI/API that lists "addresses we've seen" (eg /contracts/stats, scan recent-deployments feed) returned nothing. Adds per-tx upsert of from + to (when non-null), tracking first_seen_block / last_seen_block. Coinbase sentinel skipped on the from side so the all-zero address doesn't claim a row from validator rewards. is_contract stays false at insert time; a separate eth_getCode pass marks it true for addresses with non-empty code (cheap, lazy, out of the hot write path). Surfaced by PR #8266 reviewer asking why a deployed contract didn't appear in any list — the contract is on-chain and readable via eth_getCode, but our indexer's address-derived endpoints had no row to return. --- apps/indexer/src/sync.ts | 41 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/apps/indexer/src/sync.ts b/apps/indexer/src/sync.ts index c23e5ad..233ee72 100644 --- a/apps/indexer/src/sync.ts +++ b/apps/indexer/src/sync.ts @@ -16,6 +16,7 @@ import type { Logger } from "pino"; import { type DbClient, + addresses as addressesTable, blocks as blocksTable, logs as logsTable, meta, @@ -131,6 +132,46 @@ export async function indexBlock(args: IndexBlockArgs) { txType: isCoinbase ? "coinbase" : "native", }) .onConflictDoNothing(); + + // Upsert into addresses for both sender and receiver. Without this, + // the table sits empty and any "list of addresses I've ever seen on + // chain" query (eg `/contracts/stats`, scan's recent-deployments feed) + // returns nothing — even though we have millions of indexed txs. + // is_contract stays false here; a separate eth_getCode pass marks + // it true for addresses with non-empty code (cheap, lazy backfill). + // Coinbase sentinel skipped on the from side — the all-zero address + // shouldn't claim a balance row from validator rewards. + const heightBig = BigInt(height); + if (!isCoinbase) { + await tx + .insert(addressesTable) + .values({ + address: fromAddr, + firstSeenBlock: heightBig, + lastSeenBlock: heightBig, + }) + .onConflictDoUpdate({ + target: addressesTable.address, + set: { + lastSeenBlock: sql`GREATEST(${addressesTable.lastSeenBlock}, EXCLUDED.last_seen_block)`, + }, + }); + } + if (toAddr) { + await tx + .insert(addressesTable) + .values({ + address: toAddr, + firstSeenBlock: heightBig, + lastSeenBlock: heightBig, + }) + .onConflictDoUpdate({ + target: addressesTable.address, + set: { + lastSeenBlock: sql`GREATEST(${addressesTable.lastSeenBlock}, EXCLUDED.last_seen_block)`, + }, + }); + } } // Pull all logs in this block in one shot. From e1336e2b6b70ae206cf9e633643624e72d790d25 Mon Sep 17 00:00:00 2001 From: satyakwok Date: Tue, 5 May 2026 16:53:09 +0200 Subject: [PATCH 3/4] feat(indexer): is_contract auto-detection + faster backfill Two follow-ups to the addresses-table fix (PR #8): 1. **Contract detection worker** (`apps/indexer/src/contract-detect.ts`). The hot tx-insertion path in sync.ts upserts addresses with is_contract=false + code_hash=NULL because doing eth_getCode mid-batch would dominate runtime. This worker runs in the background, picks up addresses with code_hash IS NULL, and flips the flag based on whether the chain reports any deployed code. Slow cadence (10 addrs / 4s) so a fresh boot doesn't fire 1000+ getCode calls in one second. Uses a "0x" sentinel for code_hash on EOAs so we never re-probe them. 2. **Backfill batch size 50 -> 500** in `docker-compose.testnet.yml`. Testnet sits 2.5M blocks ahead of the indexer's main cursor; at 50/batch the catch-up ETA was ~70h. 500/batch trims that to ~7h with no observed RPC pressure increase (chain has retry429 handling). 3. `SentrixClient.getCode(address)` thin wrapper around viem's `getBytecode`. Returns "0x" when the address is an EOA so the detector worker can use a string sentinel rather than special-case undefined. Surfaced by the PR #8266 audit: even after the addresses-table fix (commit 037662d5cc), `/contracts/stats` returned empty because every new row had is_contract=false by default and only one address (manually upserted) was flipped. With the auto-detector running, every contract deployed across the chain will surface in addresses-table queries within seconds of being indexed. --- apps/indexer/src/contract-detect.ts | 89 +++++++++++++++++++++++++++++ apps/indexer/src/index.ts | 11 ++++ docker-compose.testnet.yml | 6 ++ packages/chain/src/index.ts | 11 ++++ 4 files changed, 117 insertions(+) create mode 100644 apps/indexer/src/contract-detect.ts diff --git a/apps/indexer/src/contract-detect.ts b/apps/indexer/src/contract-detect.ts new file mode 100644 index 0000000..0816452 --- /dev/null +++ b/apps/indexer/src/contract-detect.ts @@ -0,0 +1,89 @@ +// Lazy is_contract detection. The hot tx-insertion path in sync.ts upserts +// addresses with `is_contract=false` + `code_hash=NULL` because doing an +// `eth_getCode` per address mid-batch would dominate runtime. This worker +// runs in the background, picks up addresses with `code_hash IS NULL`, and +// flips the flag based on whether the chain reports any deployed code. +// +// Cadence is intentionally slow (4s between scans, 10 addresses per scan) +// so a fresh testnet boot doesn't fire 1000+ getCode calls in one second +// and trip the public-RPC rate limit. The pace catches up to real-world +// contract-deploy traffic comfortably (~150 contracts/min capacity). + +import { eq, isNull } from "drizzle-orm"; +import type { Logger } from "pino"; +import { keccak256 } from "viem"; + +import { + type DbClient, + addresses as addressesTable, +} from "@sentriscloud/indexer-db"; +import type { SentrixClient } from "@sentriscloud/indexer-chain"; + +interface DetectorArgs { + db: DbClient; + chain: SentrixClient; + log: Logger; +} + +const SCAN_INTERVAL_MS = Number(process.env.INDEXER_CONTRACT_DETECT_INTERVAL_MS ?? 4_000); +const SCAN_BATCH = Number(process.env.INDEXER_CONTRACT_DETECT_BATCH ?? 10); +const NO_CODE_SENTINEL = "0x"; // matches what RPC returns for EOAs + +export function startContractDetector(args: DetectorArgs): () => void { + const { db, chain, log } = args; + let stopped = false; + + const tick = async () => { + if (stopped) return; + try { + const candidates = await db + .select({ address: addressesTable.address }) + .from(addressesTable) + .where(isNull(addressesTable.codeHash)) + .limit(SCAN_BATCH); + + if (candidates.length === 0) return; + + for (const { address } of candidates) { + if (stopped) return; + try { + const code = await chain.getCode(address as `0x${string}`); + const isContract = code !== NO_CODE_SENTINEL; + // Sentinel for "checked, no code" so we never re-probe an EOA. + // Real contract code_hash uses keccak256 to match how the chain + // computes it on EVM-side state; the column is informational + // here, not a consensus value, so the cost-of-mismatch is zero. + const codeHash = isContract ? keccak256(code) : NO_CODE_SENTINEL; + await db + .update(addressesTable) + .set({ isContract, codeHash }) + .where(eq(addressesTable.address, address)); + if (isContract) { + log.debug({ address }, "marked as contract"); + } + } catch (err) { + // Single-address failure shouldn't block the rest of the batch. + // Most likely cause: transient 502 from the public RPC, fixed on + // next tick when we'll re-pick this address up (code_hash still NULL). + log.warn({ address, err: String(err) }, "getCode failed; will retry"); + } + } + } catch (err) { + log.error({ err: String(err) }, "contract-detect tick failed"); + } + }; + + const interval = setInterval(() => { + void tick(); + }, SCAN_INTERVAL_MS); + + log.info( + { intervalMs: SCAN_INTERVAL_MS, batchSize: SCAN_BATCH }, + "contract-detect worker started", + ); + + return () => { + stopped = true; + clearInterval(interval); + }; +} diff --git a/apps/indexer/src/index.ts b/apps/indexer/src/index.ts index fca458a..379386e 100644 --- a/apps/indexer/src/index.ts +++ b/apps/indexer/src/index.ts @@ -19,6 +19,7 @@ import { SentrixClient } from "@sentriscloud/indexer-chain"; import { eq, sql } from "drizzle-orm"; import { syncOnce, indexBlock } from "./sync.js"; +import { startContractDetector } from "./contract-detect.js"; import { runCoinblastWorker } from "./coinblast/worker.js"; const log = pino({ name: "indexer", level: process.env.LOG_LEVEL ?? "info" }); @@ -82,6 +83,11 @@ async function main() { log.error({ err: String(err) }, "coinblast worker exited unexpectedly"); }); + // Contract-detect worker — flips addresses.is_contract=true for addresses + // with non-empty bytecode. Runs in parallel to backfill + tip on a slow + // cadence so it never starves the hot path. + const stopContractDetector = startContractDetector({ db, chain, log }); + // Phase 1 — backfill. log.info("starting backfill phase"); while (true) { @@ -172,6 +178,11 @@ async function main() { } catch { /* ignore */ } + try { + stopContractDetector(); + } catch { + /* ignore */ + } await app.close().catch(() => {}); process.exit(0); }; diff --git a/docker-compose.testnet.yml b/docker-compose.testnet.yml index 3e47832..10b368e 100644 --- a/docker-compose.testnet.yml +++ b/docker-compose.testnet.yml @@ -51,6 +51,12 @@ services: INDEXER_NETWORK: testnet INDEXER_HEALTH_PORT: 8084 LOG_LEVEL: info + # Bumped from default 50 — testnet currently 2.5M blocks ahead of the + # backfill cursor; at 50/batch the catch-up ETA is ~70h. 500/batch + # tightens that to ~7h with no observed RPC pressure increase + # (each block fetch is independent and our retry429 wrapper handles + # transient 429/502s anyway). + INDEXER_BATCH_SIZE: "500" ports: - "127.0.0.1:8084:8084" # Override the Dockerfile's built-in healthcheck — the image bakes diff --git a/packages/chain/src/index.ts b/packages/chain/src/index.ts index ea71d7e..fb15143 100644 --- a/packages/chain/src/index.ts +++ b/packages/chain/src/index.ts @@ -432,6 +432,17 @@ export class SentrixClient { return retry429(() => this.http.getLogs({ fromBlock, toBlock })); } + /** + * Fetch deployed bytecode at an address. Returns "0x" when the address has + * no code (= EOA). Used by the contract-detect worker to flip the + * `addresses.is_contract` flag for addresses we've seen but haven't probed + * yet — the hot path in sync.ts skips this lookup to keep tx insertion fast. + */ + async getCode(address: `0x${string}`): Promise<`0x${string}`> { + const code = await retry429(() => this.http.getBytecode({ address })); + return (code ?? "0x") as `0x${string}`; + } + /** * Subscribe to new heads. Each event delivers the next block's header — * the indexer should refetch the block by number to get full tx + log data. From e62c03b90166045e6acdc708e043d003c518e068 Mon Sep 17 00:00:00 2001 From: satyakwok Date: Tue, 5 May 2026 17:34:19 +0200 Subject: [PATCH 4/4] =?UTF-8?q?feat(api):=20/contracts/recent=20endpoint?= =?UTF-8?q?=20=E2=80=94=20addresses=20by=20deployment=20height?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pairs with the contract-detect worker (PR #9): once an address gets flipped to is_contract=true, this endpoint surfaces it ordered by first_seen_block DESC. Doesn't depend on the transactions table — unlike /contracts/stats which INNER JOINs on indexed call history and lags the addresses table by hours during backfill catch-up. Surfaced by PR #8266 reviewer feedback: a deployed contract should appear in the explorer's contracts list immediately, not just via direct address lookup. Schema returns rank, address, first_seen_block, last_seen_block, code_hash. limit clamped to MAX_PAGE (100). --- apps/api/src/routes/native.ts | 48 +++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/apps/api/src/routes/native.ts b/apps/api/src/routes/native.ts index bf08c2e..833ed8b 100644 --- a/apps/api/src/routes/native.ts +++ b/apps/api/src/routes/native.ts @@ -229,6 +229,54 @@ export function registerNativeRoutes( } ); + // ── /contracts/recent ───────────────────────────────────── + // List user-deployed contracts ordered by deployment height (most recent + // first). Doesn't depend on the transactions table — works the moment + // contract-detect.ts marks an address as is_contract=true, which happens + // within ~4s of the address landing in the addresses table. + // + // Why a separate endpoint from /contracts/stats: stats does INNER JOIN + // with transactions to count calls, so a freshly-deployed contract with + // zero indexed calls (eg backfill not caught up yet) wouldn't show. This + // endpoint is the "list everything we know is a contract" surface. + app.get<{ Querystring: { limit?: string } }>( + "/contracts/recent", + async (req) => { + const limit = clampLimit(req.query.limit); + const rows = await ctx.db.execute<{ + address: string; + first_seen_block: string; + last_seen_block: string; + code_hash: string | null; + }>( + sql` + SELECT address, + first_seen_block::text, + last_seen_block::text, + code_hash + FROM ${addresses} + WHERE is_contract = true + ORDER BY first_seen_block DESC + LIMIT ${limit} + ` + ); + return { + contracts: (rows as unknown as Array<{ + address: string; + first_seen_block: string; + last_seen_block: string; + code_hash: string | null; + }>).map((r, i) => ({ + rank: i + 1, + address: r.address, + first_seen_block: Number(r.first_seen_block), + last_seen_block: Number(r.last_seen_block), + code_hash: r.code_hash, + })), + }; + } + ); + // ── /whale/tx ───────────────────────────────────────────── // Whale transfers: top tx by `value` (native SRX in wei via numeric). // Used by scan leaderboard /whale/recent. Default threshold is the