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
8 changes: 8 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -108,3 +108,11 @@ CACHE_KEY_PREFIX="wraith:cache:"
# Per-route TTLs in milliseconds.
CACHE_TTL_POPULAR_MS=60000
CACHE_TTL_SEARCH_MS=15000

# ─── Contract tombstones ────────────────────────────────────────────────────
# How many poll cycles between contract-liveness checks (#137). Each check
# costs one RPC call per unique watched contract, so this is deliberately far
# rarer than a poll: a contract TTL is measured in weeks, and noticing an
# expiry ten minutes late costs nothing. Set to 0 to disable the check.
# Default: 100 cycles (~10 min at the default 6s poll interval).
TOMBSTONE_CHECK_EVERY_CYCLES=100
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
-- Track contract liveness: one tombstone row per contract whose persistent
-- storage instance entry has expired (liveUntilLedger fell behind the current
-- ledger). Downstream consumers watch this table for a "contract gone" signal.
--
-- Ordered AFTER 20260829120000_add_network deliberately. That migration
-- back-filled a `network` column onto the tables that existed when it was
-- written; this table did not, so it carries its own from the start rather
-- than being silently left as the one network-blind table in the schema.
CREATE TABLE "wraith"."ContractTombstone" (
"id" SERIAL NOT NULL,
"network" TEXT NOT NULL DEFAULT 'testnet',
"contractId" TEXT NOT NULL,
"liveUntilLedger" INTEGER NOT NULL,
"detectedLedger" INTEGER NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,

CONSTRAINT "ContractTombstone_pkey" PRIMARY KEY ("id")
);

-- One tombstone per contract PER NETWORK; first expiry detection wins and
-- re-detection is a no-op. Scoping by network matters: the same contract id can
-- exist on both chains with different TTLs, and a global unique would let a
-- testnet expiry permanently suppress the mainnet tombstone.
CREATE UNIQUE INDEX "ContractTombstone_network_contractId_key" ON "wraith"."ContractTombstone"("network", "contractId");

CREATE INDEX "ContractTombstone_network_detectedLedger_idx" ON "wraith"."ContractTombstone"("network", "detectedLedger");
33 changes: 33 additions & 0 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,39 @@ model RetentionJobRun {
@@schema("wraith")
}

// ─── Contract Tombstones ────────────────────────────────────────────────────
// A tombstone marks a contract whose persistent storage has expired (its
// instance entry's liveUntilLedger fell behind the current ledger). Downstream
// consumers watch this table for a "the contract is gone" signal. One row per
// contract — the first expiry detection wins; re-detection is idempotent.
model ContractTombstone {
id Int @id @default(autoincrement())

// Which chain the contract expired on. The same contract id can exist on both
// networks with different TTLs, so a tombstone is only meaningful per network
// — a global unique on contractId would let a testnet expiry suppress the
// mainnet one forever.
network String @default("testnet")

// The contract whose storage expired (C...)
contractId String

// The contract instance's liveUntilLedger at the moment of expiry — the last
// ledger for which the entry was still live.
liveUntilLedger Int

// The ledger at which we observed the entry had expired
// (detectedLedger > liveUntilLedger).
detectedLedger Int

createdAt DateTime @default(now())

// First expiry detection wins, per network; re-detection is a no-op.
@@unique([network, contractId])
@@index([network, detectedLedger])
@@schema("wraith")
}

// ─── Backfill Cursor ───────────────────────────────────────────────────────────
// Durable cursor so the backfill job can resume mid-range after a crash.
// One row per network — one backfill at a time *per chain*, not globally.
Expand Down
102 changes: 102 additions & 0 deletions src/__tests__/tombstoneWiring.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
/**
* The tombstone detector is wired into the indexer loop (#137).
*
* These exist because the original failure here was invisible: the detection
* logic was complete, correct and thoroughly unit-tested, and nothing ever
* called it. A detector that never runs is indistinguishable from a chain on
* which nothing has expired — the table simply stays empty forever and no test
* goes red. So these assert the *call*, not the arithmetic.
*/

import { describe, it, expect, jest, beforeEach } from "@jest/globals";

const tombstoneExpiredContracts = jest.fn<
(...args: unknown[]) => Promise<{ tombstones: unknown[]; inserted: number }>
>();

jest.mock("../indexer/tombstones", () => ({ tombstoneExpiredContracts }));

import { maybeTombstoneExpiredContracts } from "../indexer";

const CURRENT_LEDGER = 5_000_000;

/** Minimal LoopState stand-in — only the fields the helper reads. */
function loopState(overrides: Record<string, unknown> = {}) {
return {
network: "testnet",
allContractIds: ["CAAA", "CBBB"],
tombstoneCycleCount: 0,
...overrides,
} as never;
}

/** The cadence the helper is compiled with (TOMBSTONE_CHECK_EVERY_CYCLES). */
const EVERY = parseInt(process.env.TOMBSTONE_CHECK_EVERY_CYCLES ?? "100", 10);

describe("maybeTombstoneExpiredContracts", () => {
beforeEach(() => {
tombstoneExpiredContracts.mockReset();
tombstoneExpiredContracts.mockResolvedValue({ tombstones: [], inserted: 0 });
});

it("does not check on every poll — that would cost one RPC per contract per cycle", async () => {
const loop = loopState();

const ran = await maybeTombstoneExpiredContracts(loop, CURRENT_LEDGER);

expect(ran).toBe(false);
expect(tombstoneExpiredContracts).not.toHaveBeenCalled();
});

it("checks once the cadence is reached, and passes the watched contracts", async () => {
const loop = loopState({ tombstoneCycleCount: EVERY - 1 });

const ran = await maybeTombstoneExpiredContracts(loop, CURRENT_LEDGER);

expect(ran).toBe(true);
expect(tombstoneExpiredContracts).toHaveBeenCalledTimes(1);
const [contractIds, ledger] = tombstoneExpiredContracts.mock.calls[0];
expect(contractIds).toEqual(["CAAA", "CBBB"]);
expect(ledger).toBe(CURRENT_LEDGER);
});

it("passes the loop's own network, so each loop asks its own chain", async () => {
const loop = loopState({ network: "mainnet", tombstoneCycleCount: EVERY - 1 });

await maybeTombstoneExpiredContracts(loop, CURRENT_LEDGER);

expect(tombstoneExpiredContracts.mock.calls[0][3]).toBe("mainnet");
});

it("resets its counter so the check recurs rather than firing once forever", async () => {
const loop = loopState({ tombstoneCycleCount: EVERY - 1 }) as unknown as {
tombstoneCycleCount: number;
};

await maybeTombstoneExpiredContracts(loop as never, CURRENT_LEDGER);
expect(loop.tombstoneCycleCount).toBe(0);

await maybeTombstoneExpiredContracts(loop as never, CURRENT_LEDGER);
expect(tombstoneExpiredContracts).toHaveBeenCalledTimes(1);
});

it("skips the RPC round-trip entirely when nothing is being watched", async () => {
const loop = loopState({ allContractIds: [], tombstoneCycleCount: EVERY - 1 });

const ran = await maybeTombstoneExpiredContracts(loop, CURRENT_LEDGER);

expect(ran).toBe(false);
expect(tombstoneExpiredContracts).not.toHaveBeenCalled();
});

it("swallows a failed check rather than stalling the indexer", async () => {
// A missed liveness check is retried on the next cadence. A loop that dies
// on an RPC hiccup is not self-healing, and stops indexing entirely.
tombstoneExpiredContracts.mockRejectedValue(new Error("rpc down"));
const loop = loopState({ tombstoneCycleCount: EVERY - 1 });

await expect(
maybeTombstoneExpiredContracts(loop, CURRENT_LEDGER),
).resolves.toBe(true);
});
});
Loading
Loading