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
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,12 @@ npm run index # start indexer (separate terminal)
| GET | `/api/v1/tokens/:address/transfers` | Token transfer history |
| GET | `/health` | Health check |

## Decode Fallback Metric

The indexer's `GET /metrics` (Prometheus registry defined in [`indexer/src/metrics.js`](./indexer/src/metrics.js)) exposes `soroban_decode_fallback_total{contract_id="..."}`: a counter of events the decoder could not attribute to a recognised function — it fell back to `function: "unknown"` and returned only `raw_topics`. Each occurrence also emits a structured debug log (`component: "decoder"`, `event: "decode_fallback"`, with `contract_id`, `ledger`, `tx_hash`) for sampling.

A rising fallback rate means a growing share of on-chain activity is going through with no human-readable description — typically an unregistered contract ABI, an unrecognised event signature, or a decoder regression — and should be investigated before it silently erodes the product's core value (human-readable event descriptions).

## Registering a Contract ABI

```bash
Expand Down
1 change: 1 addition & 0 deletions indexer/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
"dev": "node --watch src/index.js",
"test": "node --experimental-vm-modules node_modules/jest/bin/jest.js --runInBand test/api/*.test.js",
"test:decoder": "node --test tests/decoder.test.js",
"test:decode-fallback": "node --test tests/decodeFallback.test.js",
"test:decoder-parity": "node --test tests/decoder-parity.test.js",
"test:scval": "node --test tests/scval.test.js",
"test:coverage": "node --experimental-test-coverage --test-coverage-include=src/scval.js --test-coverage-include=src/xdr_decoder.js --test-coverage-include=src/int128.js --test-coverage-lines=88 --test-coverage-branches=70 --test-coverage-functions=88 --test tests/decoder.test.js tests/decoder-parity.test.js tests/scval.test.js",
Expand Down
29 changes: 29 additions & 0 deletions indexer/src/decoder.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { decodeRwaEvent } from "./rwaDecoder.js";
import { parseHeuristic } from "./heuristicParser.js";
import { parseTTLHostFunction, formatTTLExtension } from "./ttlExtensionParser.js";
import { parseZkHostFunctions, computeZkCostDelta } from "./zkHostFunctions.js";
import { decodeFallbackTotal } from "./metrics.js";

/**
* Raw Soroban RPC event as received from the RPC poller. The `txMeta` field
Expand Down Expand Up @@ -138,6 +139,31 @@ const NATIVE_SAC_IDS = new Set([
"CAS3J7GYLGXMF6TDJBBYYSE3HQ6BBSMLNUQ34T6TZMYMW2EVH34XOWMA", // mainnet
]);

/** @param {string} fnName */
export function isDecodeFallback(fnName) {
return fnName === "unknown";
}

/**
* Record a decode fallback: increments the Prometheus counter (labeled by
* contract) and emits a structured debug log for sampling/investigation.
* @param {string} contractId
* @param {{ ledger?: number, txHash?: string }} ev
* @param {{ debug: (...args: any[]) => void }} [logger]
*/
export function recordDecodeFallback(contractId, ev, logger = console) {
decodeFallbackTotal.inc({ contract_id: contractId });
logger.debug(
JSON.stringify({
component: "decoder",
event: "decode_fallback",
contract_id: contractId,
ledger: ev.ledger,
tx_hash: ev.txHash,
}),
);
}

/**
* Decode a raw Soroban RPC event into a human-readable record.
* Uses the ABI template when available; falls back to a generic description.
Expand All @@ -151,6 +177,9 @@ export async function decode(ev) {

// First topic is typically the function name symbol
const fnName = typeof topics[0] === "symbol" || typeof topics[0] === "string" ? String(topics[0]) : "unknown";
if (isDecodeFallback(fnName)) {
recordDecodeFallback(contractId, ev);
}

// Detect native XLM wrap/unwrap on the SAC contract
if (NATIVE_SAC_IDS.has(contractId)) {
Expand Down
14 changes: 14 additions & 0 deletions indexer/src/metrics.js
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,20 @@ export const decoderSchemaViolationsTotal = new Counter({
registers: [registry],
});

/**
* Events the decoder could not attribute to a recognised function — it fell
* back to function: "unknown" and raw_topics only. A rising rate means a
* growing share of events are going through with no human-readable
* description (unregistered ABI, unrecognised event signature, or a decoder
* regression) and should be investigated.
*/
export const decodeFallbackTotal = new Counter({
name: "soroban_decode_fallback_total",
help: "Total events that fell back to unrecognized (function: unknown) decoding, by contract",
labelNames: ["contract_id"],
registers: [registry],
});

/**
* Update DB pool gauges from a pg.Pool instance.
* Call this periodically (e.g. every 15 s).
Expand Down
75 changes: 75 additions & 0 deletions indexer/tests/decodeFallback.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
/**
* Unit tests for the decode-fallback metric (indexer/src/decoder.js).
*
* Covers issue #51: decoding an unrecognized event must increment
* soroban_decode_fallback_total (labeled by contract) and emit a structured
* debug log; decoding a known event must not.
*/
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { isDecodeFallback, recordDecodeFallback } from "../src/decoder.js";
import { decodeFallbackTotal } from "../src/metrics.js";

async function counterValue(contractId) {
const { values } = await decodeFallbackTotal.get();
const match = values.find((v) => v.labels.contract_id === contractId);
return match ? match.value : 0;
}

describe("isDecodeFallback", () => {
it("is true for the unrecognized-function sentinel", () => {
assert.equal(isDecodeFallback("unknown"), true);
});

it("is false for a recognized function name", () => {
assert.equal(isDecodeFallback("transfer"), false);
});
});

describe("recordDecodeFallback", () => {
it("increments the fallback counter for an unrecognized event", async () => {
const contractId = "CFALLBACKTEST0000000000000000000000000000000000000001";
const before = await counterValue(contractId);

recordDecodeFallback(contractId, { ledger: 12345, txHash: "deadbeef" });

const after = await counterValue(contractId);
assert.equal(after, before + 1);
});

it("labels the counter by contract so unrelated contracts stay isolated", async () => {
const contractA = "CFALLBACKTEST0000000000000000000000000000000000000002";
const contractB = "CFALLBACKTEST0000000000000000000000000000000000000003";

recordDecodeFallback(contractA, { ledger: 1, txHash: "aa" });

assert.equal(await counterValue(contractA), 1);
assert.equal(await counterValue(contractB), 0);
});

it("does not increment for a known event (decode() only calls it when isDecodeFallback is true)", async () => {
const contractId = "CFALLBACKTEST0000000000000000000000000000000000000004";
// Mirrors the gate in decode(): a known fnName never reaches recordDecodeFallback.
const fnName = "transfer";
if (isDecodeFallback(fnName)) {
recordDecodeFallback(contractId, { ledger: 1, txHash: "bb" });
}
assert.equal(await counterValue(contractId), 0);
});

it("emits a structured debug log with contract, ledger, and tx hash", () => {
const calls = [];
const stubLogger = { debug: (...args) => calls.push(args) };
const contractId = "CFALLBACKTEST0000000000000000000000000000000000000005";

recordDecodeFallback(contractId, { ledger: 999, txHash: "cafebabe" }, stubLogger);

assert.equal(calls.length, 1);
const logged = JSON.parse(calls[0][0]);
assert.equal(logged.component, "decoder");
assert.equal(logged.event, "decode_fallback");
assert.equal(logged.contract_id, contractId);
assert.equal(logged.ledger, 999);
assert.equal(logged.tx_hash, "cafebabe");
});
});
Loading