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
48 changes: 48 additions & 0 deletions apps/api/src/routes/native.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
89 changes: 89 additions & 0 deletions apps/indexer/src/contract-detect.ts
Original file line number Diff line number Diff line change
@@ -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);
};
}
11 changes: 11 additions & 0 deletions apps/indexer/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" });
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -172,6 +178,11 @@ async function main() {
} catch {
/* ignore */
}
try {
stopContractDetector();
} catch {
/* ignore */
}
await app.close().catch(() => {});
process.exit(0);
};
Expand Down
41 changes: 41 additions & 0 deletions apps/indexer/src/sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import type { Logger } from "pino";

import {
type DbClient,
addresses as addressesTable,
blocks as blocksTable,
logs as logsTable,
meta,
Expand Down Expand Up @@ -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.
Expand Down
23 changes: 23 additions & 0 deletions docker-compose.testnet.yml
Original file line number Diff line number Diff line change
Expand Up @@ -51,8 +51,23 @@ 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
# `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:
Expand All @@ -71,3 +86,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
11 changes: 11 additions & 0 deletions packages/chain/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down