Skip to content

Commit 2d64432

Browse files
authored
fix(indexer): unblock testnet backfill (#74)
Co-authored-by: satyakwok <satyakwok@users.noreply.github.com>
1 parent c98a8d6 commit 2d64432

4 files changed

Lines changed: 141 additions & 27 deletions

File tree

apps/indexer/Dockerfile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,6 @@ WORKDIR /app/apps/indexer
5151
EXPOSE 8082
5252

5353
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
54-
CMD wget --no-verbose --tries=1 --spider http://127.0.0.1:8082/health || exit 1
54+
CMD wget --no-verbose --tries=1 --spider "http://127.0.0.1:${INDEXER_HEALTH_PORT:-8082}/health" || exit 1
5555

5656
CMD ["npx", "tsx", "src/index.ts"]

apps/indexer/src/sync.ts

Lines changed: 35 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,10 @@ import {
2323
tokenTransfers,
2424
transactions as txsTable,
2525
} from "@sentriscloud/indexer-db";
26-
import type { SentrixClient } from "@sentriscloud/indexer-chain";
26+
import {
27+
BlockNotFoundError,
28+
type SentrixClient,
29+
} from "@sentriscloud/indexer-chain";
2730
import { dispatch } from "./handlers/index.js";
2831

2932
interface SyncOnceArgs {
@@ -49,7 +52,16 @@ export async function syncOnce(args: SyncOnceArgs): Promise<bigint> {
4952
log.info({ from: start.toString(), to: end.toString() }, "backfill batch");
5053

5154
for (let h = start; h <= end; h++) {
52-
await indexBlock({ db, chain, height: h, log });
55+
try {
56+
await indexBlock({ db, chain, height: h, log });
57+
} catch (err) {
58+
if (!(err instanceof BlockNotFoundError)) throw err;
59+
log.warn(
60+
{ height: h.toString() },
61+
"block missing from RPC — advancing cursor",
62+
);
63+
await advanceLastSynced(db, h);
64+
}
5365
}
5466
return end;
5567
}
@@ -120,7 +132,10 @@ export async function indexBlock(args: IndexBlockArgs) {
120132
const natives = await mapWithConcurrency(
121133
txEntries,
122134
TX_FETCH_CONCURRENCY,
123-
async (e) => ({ entry: e, native: await chain.getNativeTransaction(e.hash) }),
135+
async (e) => ({
136+
entry: e,
137+
native: await chain.getNativeTransaction(e.hash),
138+
}),
124139
);
125140

126141
// ── PHASE 2: build batch INSERT row arrays.
@@ -294,7 +309,7 @@ export async function indexBlock(args: IndexBlockArgs) {
294309
.onConflictDoUpdate({
295310
target: meta.key,
296311
set: {
297-
value: sql`excluded.value`,
312+
value: sql`GREATEST(${meta.value}::numeric, excluded.value::numeric)::text`,
298313
updatedAt: sql`excluded.updated_at`,
299314
},
300315
});
@@ -313,3 +328,19 @@ async function readLastSynced(db: DbClient): Promise<bigint> {
313328
return BigInt(rows[0].value);
314329
}
315330

331+
async function advanceLastSynced(db: DbClient, height: bigint): Promise<void> {
332+
await db
333+
.insert(meta)
334+
.values({
335+
key: "last_synced_height",
336+
value: height.toString(),
337+
updatedAt: BigInt(Math.floor(Date.now() / 1000)),
338+
})
339+
.onConflictDoUpdate({
340+
target: meta.key,
341+
set: {
342+
value: sql`GREATEST(${meta.value}::numeric, excluded.value::numeric)::text`,
343+
updatedAt: sql`excluded.updated_at`,
344+
},
345+
});
346+
}

docker-compose.testnet.yml

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -37,24 +37,28 @@ services:
3737
depends_on:
3838
postgres:
3939
condition: service_healthy
40+
network_mode: host
4041
environment:
41-
INDEXER_DATABASE_URL: ${INDEXER_DATABASE_URL:?env required}
42+
INDEXER_DATABASE_URL: postgres://${POSTGRES_USER:?env required}:${POSTGRES_PASSWORD:?env required}@127.0.0.1:5433/${POSTGRES_DB:?env required}
4243
INDEXER_NETWORK: testnet
4344
INDEXER_HEALTH_PORT: 8084
45+
INDEXER_RPC_HTTP_URL: http://127.0.0.1:9545/rpc
46+
INDEXER_RPC_WS_URL: ws://127.0.0.1:9545/ws
4447
LOG_LEVEL: info
4548
# Bumped from default 50 — testnet currently 2.5M blocks ahead of the
4649
# backfill cursor; at 50/batch the catch-up ETA is ~70h. 500/batch
4750
# tightens that to ~7h with no observed RPC pressure increase
4851
# (each block fetch is independent and our retry429 wrapper handles
4952
# transient 429/502s anyway).
5053
INDEXER_BATCH_SIZE: "500"
51-
ports:
52-
- "127.0.0.1:8084:8084"
53-
# Override the Dockerfile's built-in healthcheck — the image bakes
54-
# `wget http://127.0.0.1:8082/health` (mainnet default) but the
55-
# testnet stack runs the worker on 8084 via INDEXER_HEALTH_PORT.
54+
# Keep an explicit compose healthcheck for testnet; the worker listens
55+
# on 8084 via INDEXER_HEALTH_PORT.
5656
healthcheck:
57-
test: ["CMD-SHELL", "wget --no-verbose --tries=1 --spider http://127.0.0.1:8084/health || exit 1"]
57+
test:
58+
[
59+
"CMD-SHELL",
60+
"wget --no-verbose --tries=1 --spider http://127.0.0.1:8084/health || exit 1",
61+
]
5862
interval: 30s
5963
timeout: 5s
6064
start_period: 20s
@@ -80,7 +84,11 @@ services:
8084
# Same Dockerfile-port-mismatch story as the worker — bake-time
8185
# default is 8081, testnet runs on 8083 via API_PORT env.
8286
healthcheck:
83-
test: ["CMD-SHELL", "wget --no-verbose --tries=1 --spider http://127.0.0.1:8083/health || exit 1"]
87+
test:
88+
[
89+
"CMD-SHELL",
90+
"wget --no-verbose --tries=1 --spider http://127.0.0.1:8083/health || exit 1",
91+
]
8492
interval: 30s
8593
timeout: 5s
8694
start_period: 15s

packages/chain/src/index.ts

Lines changed: 89 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,8 @@ async function retry429<T>(fn: () => Promise<T>, attempts = 6): Promise<T> {
5656
} catch (err) {
5757
lastErr = err;
5858
const msg = String((err as Error)?.message ?? "");
59-
if (!msg.includes("Status: 429") && !msg.includes("rate limit")) throw err;
59+
if (!msg.includes("Status: 429") && !msg.includes("rate limit"))
60+
throw err;
6061
// Exponential backoff: 0.5s → 1s → 2s → 4s → 8s → 16s.
6162
await new Promise((r) => setTimeout(r, delayMs));
6263
delayMs = Math.min(delayMs * 2, 16_000);
@@ -102,6 +103,37 @@ interface GrpcBlockHeader {
102103
hash: string;
103104
}
104105

106+
type RpcQuantity = `0x${string}`;
107+
type RpcHash = `0x${string}`;
108+
109+
interface RpcBlock {
110+
baseFeePerGas?: RpcQuantity | null;
111+
gasLimit?: RpcQuantity | null;
112+
gasUsed?: RpcQuantity | null;
113+
hash?: RpcHash | null;
114+
miner?: `0x${string}` | null;
115+
number?: RpcQuantity | null;
116+
parentHash: RpcHash;
117+
stateRoot?: RpcHash | null;
118+
timestamp: RpcQuantity;
119+
transactions: (RpcHash | { hash: RpcHash })[];
120+
}
121+
122+
function quantityToBigInt(value: RpcQuantity | null | undefined): bigint {
123+
return value == null ? 0n : BigInt(value);
124+
}
125+
126+
function bigintToQuantity(value: bigint): RpcQuantity {
127+
return `0x${value.toString(16)}`;
128+
}
129+
130+
export class BlockNotFoundError extends Error {
131+
constructor(readonly height: bigint) {
132+
super(`Block at number "${height}" could not be found.`);
133+
this.name = "BlockNotFoundError";
134+
}
135+
}
136+
105137
/**
106138
* Tip watcher backed by the side-car gRPC `GetBlock {latest:true}`. Called
107139
* once per `intervalMs`; emits the new tip height whenever it advances.
@@ -135,17 +167,17 @@ export interface NativeTransaction {
135167
block_index: number;
136168
block_timestamp: number;
137169
transaction: {
138-
amount: number; // sentri — 1 SRX = 1e8 sentri
170+
amount: number; // sentri — 1 SRX = 1e8 sentri
139171
chain_id: number;
140172
data: string;
141-
fee: number; // sentri
173+
fee: number; // sentri
142174
from_address: string; // 0x… hex OR 'COINBASE' sentinel
143175
nonce: number;
144176
public_key: string;
145177
signature: string;
146178
timestamp: number;
147179
to_address: string;
148-
txid: string; // bare hex, NOT 0x-prefixed
180+
txid: string; // bare hex, NOT 0x-prefixed
149181
};
150182
}
151183

@@ -241,13 +273,19 @@ export class SentrixClient {
241273
// chain genuinely doesn't have the tx.
242274
let delayMs = 250;
243275
for (let attempt = 0; attempt < 4; attempt++) {
276+
const controller = new AbortController();
277+
const timeout = setTimeout(() => controller.abort(), 10_000);
244278
try {
245-
const r = await fetch(`${this.restBase}/transactions/${bareHash}`);
279+
const r = await fetch(`${this.restBase}/transactions/${bareHash}`, {
280+
signal: controller.signal,
281+
});
246282
if (r.status === 404) return null;
247283
if (r.ok) return (await r.json()) as NativeTransaction;
248284
// Non-2xx, non-404 → retry. Falls through to backoff below.
249285
} catch {
250286
// Network error / aborted fetch → retry. Falls through.
287+
} finally {
288+
clearTimeout(timeout);
251289
}
252290
if (attempt < 3) {
253291
await new Promise((r) => setTimeout(r, delayMs));
@@ -284,7 +322,9 @@ export class SentrixClient {
284322
// proto-loader returns `index` as string when longs:String. Hash is
285323
// { value: Buffer }.
286324
const idx = BigInt(resp.index);
287-
const hash = Buffer.from(resp.hash?.value ?? new Uint8Array()).toString("hex");
325+
const hash = Buffer.from(
326+
resp.hash?.value ?? new Uint8Array(),
327+
).toString("hex");
288328
resolve({ index: idx, hash });
289329
},
290330
);
@@ -323,7 +363,10 @@ export class SentrixClient {
323363
| { kind: "block"; height: bigint; hash: string; latencyMs: number }
324364
| { kind: "lagged"; skipped: bigint },
325365
) => void,
326-
opts: { onError?: (err: unknown) => void; onReconnect?: (attempt: number) => void } = {},
366+
opts: {
367+
onError?: (err: unknown) => void;
368+
onReconnect?: (attempt: number) => void;
369+
} = {},
327370
): BlockStreamSub {
328371
let stopped = false;
329372
let backoffMs = 500;
@@ -352,7 +395,9 @@ export class SentrixClient {
352395
onBlock({
353396
kind: "block",
354397
height: BigInt(b.index),
355-
hash: Buffer.from(b.hash?.value ?? new Uint8Array()).toString("hex"),
398+
hash: Buffer.from(b.hash?.value ?? new Uint8Array()).toString(
399+
"hex",
400+
),
356401
latencyMs,
357402
});
358403
} else if (msg.lagged) {
@@ -468,16 +513,46 @@ export class SentrixClient {
468513
* string entries (`typeof t === "string" → continue`), so the blocks
469514
* table populates fine and the transactions table stays empty until
470515
* the chain RPC is brought into spec OR the indexer reads tx via the
471-
* REST `/transactions/<hash>` shape with a native-format adapter. See
472-
* Sentriscloud/indexer issue tracker.
516+
* REST `/transactions/<hash>` shape with a native-format adapter. Viem's
517+
* `getBlock` parser can reject historical Sentrix block responses even
518+
* when raw `eth_getBlockByNumber` succeeds, so this method intentionally
519+
* stays on raw JSON-RPC and maps only the fields the indexer uses.
473520
*/
474521
async getBlock(height: bigint): Promise<Block<bigint, true>> {
475-
return retry429(() =>
476-
this.http.getBlock({ blockNumber: height, includeTransactions: true }),
477-
);
522+
const block = (await retry429(() =>
523+
this.http.request({
524+
method: "eth_getBlockByNumber",
525+
params: [bigintToQuantity(height), true],
526+
}),
527+
)) as RpcBlock | null;
528+
529+
if (!block) {
530+
throw new BlockNotFoundError(height);
531+
}
532+
533+
return {
534+
baseFeePerGas:
535+
block.baseFeePerGas == null
536+
? null
537+
: quantityToBigInt(block.baseFeePerGas),
538+
gasLimit: quantityToBigInt(block.gasLimit),
539+
gasUsed: quantityToBigInt(block.gasUsed),
540+
hash: block.hash ?? null,
541+
miner: block.miner ?? null,
542+
number: block.number == null ? null : quantityToBigInt(block.number),
543+
parentHash: block.parentHash,
544+
stateRoot: block.stateRoot ?? null,
545+
timestamp: quantityToBigInt(block.timestamp),
546+
transactions: block.transactions.map((tx) =>
547+
typeof tx === "string" ? tx : { hash: tx.hash },
548+
),
549+
} as Block<bigint, true>;
478550
}
479551

480-
async getLogsRange(fromBlock: bigint, toBlock: bigint): Promise<GetLogsReturnType> {
552+
async getLogsRange(
553+
fromBlock: bigint,
554+
toBlock: bigint,
555+
): Promise<GetLogsReturnType> {
481556
return retry429(() => this.http.getLogs({ fromBlock, toBlock }));
482557
}
483558

0 commit comments

Comments
 (0)