diff --git a/packages/sdk/package.json b/packages/sdk/package.json index 716d6c6..df7684d 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -24,7 +24,7 @@ ".": "./src/index.ts" }, "scripts": { - "test": "node --import tsx --test src/client.test.ts src/encoding.test.ts src/errors.test.ts src/ids.test.ts src/mainnet-readiness.test.ts src/network.test.ts src/preflight.test.ts src/public-api-snapshot.test.ts src/redact.test.ts src/verify.test.ts", + "test": "node --import tsx --test src/client.test.ts src/encoding.test.ts src/errors.test.ts src/ids.test.ts src/mainnet-readiness.test.ts src/network.test.ts src/preflight.test.ts src/public-api-snapshot.test.ts src/redact.test.ts src/round-status.test.ts src/verify.test.ts", "typecheck": "tsc --noEmit -p tsconfig.json" }, "dependencies": { diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index 20f31f6..e39d415 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -110,6 +110,23 @@ export { type Severity, } from "./verify.js"; +// Round-status predicates and human-readable labels. Mirror +// services/keeper/src/status.ts status vocab. +export { + ACTIVE_ROUND_STATUSES, + TERMINAL_ROUND_STATUSES, + ERROR_ROUND_STATUSES, + type RoundStatusClass, + classifyRoundStatus, + isActiveRoundStatus, + isTerminalRoundStatus, + isErrorRoundStatus, + roundStatusLabel, + isKeeperRoundActive, + isKeeperRoundTerminal, + isKeeperRoundSettlementPending, +} from "./round-status.js"; + // Keeper status-API response shapes. Mirror services/keeper/src/status.ts. export { type RoundStatus, diff --git a/packages/sdk/src/public-api-snapshot.test.ts b/packages/sdk/src/public-api-snapshot.test.ts index ff2736a..56a55fd 100644 --- a/packages/sdk/src/public-api-snapshot.test.ts +++ b/packages/sdk/src/public-api-snapshot.test.ts @@ -5,6 +5,8 @@ import * as sdk from "./index.js"; const EXPECTED_EXPORTS = [ "ASSET_FIXTURES", "AssetConfigError", + "ACTIVE_ROUND_STATUSES", + "ERROR_ROUND_STATUSES", "KeeperStatusClient", "MAINNET_ARTIFACTS", "MAINNET_CONFIRM_PHRASE", @@ -25,9 +27,11 @@ const EXPECTED_EXPORTS = [ "SubRosaSubmitError", "SubRosaTimeoutError", "SubRosaTransactionError", + "TERMINAL_ROUND_STATUSES", "assertMainnetConfirmed", "assertMicroAmounts", "assertReadinessForExecute", + "classifyRoundStatus", "contractErrorCode", "createOzChannelsSubmitter", "createOzChannelsSubmitterFromEnv", @@ -38,12 +42,19 @@ const EXPECTED_EXPORTS = [ "fetchKeeperStatus", "formatReadinessReport", "hasBlockingFailures", + "isActiveRoundStatus", + "isErrorRoundStatus", + "isKeeperRoundActive", + "isKeeperRoundSettlementPending", + "isKeeperRoundTerminal", + "isTerminalRoundStatus", "nativeXlmSacId", "networkFingerprint", "normalizeRoundId", "normalizeSorobanContractId", "parseReceipt", "redactReceipt", + "roundStatusLabel", "runMainnetReadiness", "serializeReceipt", "tryDecodeBase64", diff --git a/packages/sdk/src/round-status.test.ts b/packages/sdk/src/round-status.test.ts new file mode 100644 index 0000000..0d323b4 --- /dev/null +++ b/packages/sdk/src/round-status.test.ts @@ -0,0 +1,124 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; + +import type { KeeperRoundStatusView, RoundStatus } from "./status.js"; +import { + ACTIVE_ROUND_STATUSES, + ERROR_ROUND_STATUSES, + TERMINAL_ROUND_STATUSES, + classifyRoundStatus, + isActiveRoundStatus, + isErrorRoundStatus, + isKeeperRoundActive, + isKeeperRoundSettlementPending, + isKeeperRoundTerminal, + isTerminalRoundStatus, + roundStatusLabel, +} from "./round-status.js"; + +const ALL_STATUSES: RoundStatus[] = [ + "Unknown", + "Open", + "Revealing", + "Cleared", + "Settled", + "Voided", + "NotFound", +]; + +function viewFor(status: RoundStatus, settlement: KeeperRoundStatusView["settlement"] = "none"): KeeperRoundStatusView { + return { + roundId: "1", + status, + phase: "complete", + nextAction: "none", + commitDeadline: null, + revealDeadline: null, + revealRound: null, + revealReady: false, + commitClosed: false, + revealWindowOpen: false, + voidableAfter: null, + bidderCount: null, + revealedCount: null, + winner: null, + winningValue: null, + clearingRule: null, + settlement, + lastKeeperAction: null, + lastError: null, + retryCount: 0, + updatedAt: "2026-01-01T00:00:00.000Z", + }; +} + +describe("round-status classification", () => { + it("partitions every status into exactly one class", () => { + const covered = new Set([ + ...ACTIVE_ROUND_STATUSES, + ...TERMINAL_ROUND_STATUSES, + ...ERROR_ROUND_STATUSES, + ]); + assert.equal(covered.size, ALL_STATUSES.length); + for (const status of ALL_STATUSES) { + assert.ok(covered.has(status), `status ${status} must be classified`); + } + }); + + it("classifies active statuses", () => { + for (const status of ["Open", "Revealing", "Cleared"] as RoundStatus[]) { + assert.equal(isActiveRoundStatus(status), true); + assert.equal(classifyRoundStatus(status), "active"); + } + }); + + it("classifies terminal statuses", () => { + for (const status of ["Settled", "Voided"] as RoundStatus[]) { + assert.equal(isTerminalRoundStatus(status), true); + assert.equal(classifyRoundStatus(status), "terminal"); + } + }); + + it("classifies error statuses", () => { + for (const status of ["Unknown", "NotFound"] as RoundStatus[]) { + assert.equal(isErrorRoundStatus(status), true); + assert.equal(classifyRoundStatus(status), "error"); + } + }); + + it("does not double-count across buckets", () => { + for (const status of ALL_STATUSES) { + const hits = + Number(isActiveRoundStatus(status)) + + Number(isTerminalRoundStatus(status)) + + Number(isErrorRoundStatus(status)); + assert.equal(hits, 1, `status ${status} should match exactly one predicate`); + } + }); +}); + +describe("roundStatusLabel", () => { + it("returns a human-readable label for every status", () => { + for (const status of ALL_STATUSES) { + const label = roundStatusLabel(status); + assert.ok(typeof label === "string" && label.length > 0); + assert.ok(label.toLowerCase().startsWith(status.toLowerCase())); + } + }); +}); + +describe("keeper round view helpers", () => { + it("mirror status classification", () => { + assert.equal(isKeeperRoundActive(viewFor("Open")), true); + assert.equal(isKeeperRoundActive(viewFor("Settled")), false); + assert.equal(isKeeperRoundTerminal(viewFor("Voided")), true); + assert.equal(isKeeperRoundTerminal(viewFor("Revealing")), false); + }); + + it("treats pending and submitted settlement as pending", () => { + assert.equal(isKeeperRoundSettlementPending(viewFor("Cleared", "pending")), true); + assert.equal(isKeeperRoundSettlementPending(viewFor("Cleared", "submitted")), true); + assert.equal(isKeeperRoundSettlementPending(viewFor("Cleared", "none")), false); + assert.equal(isKeeperRoundSettlementPending(viewFor("Settled", "terminal")), false); + }); +}); diff --git a/packages/sdk/src/round-status.ts b/packages/sdk/src/round-status.ts new file mode 100644 index 0000000..e7cdb71 --- /dev/null +++ b/packages/sdk/src/round-status.ts @@ -0,0 +1,77 @@ +// Pure helpers for reasoning about keeper round statuses without depending on +// the keeper service itself. These classify a `RoundStatus` into coarse +// buckets (active / terminal / error) and turn a status into a human-readable +// label suitable for dashboards, operator CLIs and alert copy. +// +// Keep the status vocab in lockstep with `services/keeper/src/status.ts`. + +import type { + KeeperRoundStatusView, + RoundStatus, + SettlementIndicator, +} from "./status.js"; + +export const ACTIVE_ROUND_STATUSES: readonly RoundStatus[] = [ + "Open", + "Revealing", + "Cleared", +]; + +export const TERMINAL_ROUND_STATUSES: readonly RoundStatus[] = [ + "Settled", + "Voided", +]; + +export const ERROR_ROUND_STATUSES: readonly RoundStatus[] = [ + "Unknown", + "NotFound", +]; + +export type RoundStatusClass = "active" | "terminal" | "error"; + +export function classifyRoundStatus(status: RoundStatus): RoundStatusClass { + if (isActiveRoundStatus(status)) return "active"; + if (isTerminalRoundStatus(status)) return "terminal"; + return "error"; +} + +export function isActiveRoundStatus(status: RoundStatus): boolean { + return (ACTIVE_ROUND_STATUSES as readonly string[]).includes(status); +} + +export function isTerminalRoundStatus(status: RoundStatus): boolean { + return (TERMINAL_ROUND_STATUSES as readonly string[]).includes(status); +} + +export function isErrorRoundStatus(status: RoundStatus): boolean { + return (ERROR_ROUND_STATUSES as readonly string[]).includes(status); +} + +const ROUND_STATUS_LABELS: Record = { + Unknown: "Unknown — keeper has not resolved the round yet", + Open: "Open — accepting commitments", + Revealing: "Revealing — accepting reveals", + Cleared: "Cleared — awaiting settlement", + Settled: "Settled — round complete", + Voided: "Voided — escrow refunded", + NotFound: "NotFound — round does not exist on-chain", +}; + +export function roundStatusLabel(status: RoundStatus): string { + return ROUND_STATUS_LABELS[status]; +} + +export function isKeeperRoundActive(view: KeeperRoundStatusView): boolean { + return isActiveRoundStatus(view.status); +} + +export function isKeeperRoundTerminal(view: KeeperRoundStatusView): boolean { + return isTerminalRoundStatus(view.status); +} + +export function isKeeperRoundSettlementPending( + view: KeeperRoundStatusView, +): boolean { + const pending: readonly SettlementIndicator[] = ["pending", "submitted"]; + return (pending as readonly string[]).includes(view.settlement); +} diff --git a/services/keeper/src/keeper.ts b/services/keeper/src/keeper.ts index 6fc9e06..5b551c2 100644 --- a/services/keeper/src/keeper.ts +++ b/services/keeper/src/keeper.ts @@ -16,6 +16,7 @@ import type { SubRosaClient } from "@sub-rosa/sdk"; import { openBid, fetchRoundSignature, type DrandClient } from "@sub-rosa/tlock"; +import { compareRoundIds } from "./store.js"; export type KeeperLogger = (msg: string) => void; @@ -369,7 +370,7 @@ export function parseRoundIdSpec(spec: string): bigint[] { ids.add(BigInt(part)); } } - return [...ids].sort((x, y) => (x < y ? -1 : x > y ? 1 : 0)); + return [...ids].sort((x, y) => compareRoundIds(x, y)); } export async function discoverRoundIds( diff --git a/services/keeper/src/store.test.ts b/services/keeper/src/store.test.ts index 7429eec..cb18ba5 100644 --- a/services/keeper/src/store.test.ts +++ b/services/keeper/src/store.test.ts @@ -2,7 +2,7 @@ import { describe, it } from "node:test"; import * as assert from "node:assert"; import * as fs from "node:fs"; import * as path from "node:path"; -import { KeeperStore, normalizeRoundId } from "./store.js"; +import { KeeperStore, normalizeRoundId, compareRoundIds } from "./store.js"; describe("KeeperStore", () => { const TEST_STORE_PATH = path.join(process.cwd(), ".test-keeper-store.json"); @@ -77,23 +77,60 @@ describe("KeeperStore", () => { cleanUp(); }); - it("backs up persisted data with an invalid round ID before sorting", () => { + it("drops a single malformed round entry on load and keeps the valid ones", () => { cleanUp(); fs.writeFileSync( TEST_STORE_PATH, - JSON.stringify({ rounds: { invalid: { roundId: "abc", lastStatus: "Unknown", retryCount: 0 } } }), + JSON.stringify({ + rounds: { + "7": { roundId: "7", lastStatus: "Open", retryCount: 0 }, + invalid: { roundId: "abc", lastStatus: "Unknown", retryCount: 0 }, + }, + }), "utf-8", ); const store = new KeeperStore(TEST_STORE_PATH); - assert.deepEqual(store.listRounds(), []); + assert.deepEqual( + store.listRounds().map((round) => round.roundId), + ["7"], + ); + // No full-file corrupted backup should be created for a single bad entry const backups = fs.readdirSync(process.cwd()).filter( (file) => file.startsWith(".test-keeper-store.json.corrupted."), ); - assert.strictEqual(backups.length, 1); + assert.strictEqual(backups.length, 0); + cleanUp(); + }); + + it("drops entries whose key is non-numeric and has no valid roundId", () => { + cleanUp(); + fs.writeFileSync( + TEST_STORE_PATH, + JSON.stringify({ + rounds: { + notanumber: { lastStatus: "Open", retryCount: 0 }, + "12": { roundId: "12", lastStatus: "Open", retryCount: 0 }, + }, + }), + "utf-8", + ); + + const store = new KeeperStore(TEST_STORE_PATH); + assert.deepEqual( + store.listRounds().map((round) => round.roundId), + ["12"], + ); cleanUp(); }); + it("orders rounds numerically via the shared comparator regardless of id type", () => { + assert.strictEqual(compareRoundIds(2n, "10"), -1); + assert.strictEqual(compareRoundIds("10", 2), 1); + assert.strictEqual(compareRoundIds("7", "07"), 0); + assert.strictEqual(compareRoundIds(10, "10"), 0); + }); + it("should remove a round", () => { cleanUp(); const store = new KeeperStore(TEST_STORE_PATH); diff --git a/services/keeper/src/store.ts b/services/keeper/src/store.ts index 665bf0a..a79b2d3 100644 --- a/services/keeper/src/store.ts +++ b/services/keeper/src/store.ts @@ -18,6 +18,17 @@ export interface StoreData { export type RoundIdInput = bigint | number | string; +/** + * Numeric round-id comparator. Orders two round ids by their numeric value + * regardless of the input type (bigint, number, or string). Returns a negative + * number if `a < b`, zero if equal, and a positive number if `a > b`. + */ +export function compareRoundIds(a: RoundIdInput, b: RoundIdInput): number { + const aBig = BigInt(normalizeRoundId(a)); + const bBig = BigInt(normalizeRoundId(b)); + return aBig < bBig ? -1 : aBig > bBig ? 1 : 0; +} + export function normalizeRoundId(roundId: RoundIdInput): string { let value: bigint; @@ -68,10 +79,19 @@ export class KeeperStore { const rounds: Record = {}; for (const [key, value] of Object.entries(parsed.rounds)) { if (!value || typeof value !== "object" || Array.isArray(value)) { - throw new Error(`invalid stored round ${key}`); + console.warn(`[Store] Dropping malformed stored round entry ${key}: expected an object`); + continue; } const stored = value as Partial; - const id = normalizeRoundId(stored.roundId ?? key); + let id: string; + try { + id = normalizeRoundId(stored.roundId ?? key); + } catch { + console.warn( + `[Store] Dropping malformed stored round entry ${key}: non-numeric or invalid round id ${JSON.stringify(stored.roundId ?? key)}`, + ); + continue; + } rounds[id] = { ...stored, roundId: id } as WatchedRound; } return { rounds }; @@ -140,12 +160,8 @@ export class KeeperStore { } public listRounds(): WatchedRound[] { - // Return sorted by roundId mathematically - return Object.values(this.data.rounds).sort((a, b) => { - const aBig = BigInt(a.roundId); - const bBig = BigInt(b.roundId); - return aBig < bBig ? -1 : aBig > bBig ? 1 : 0; - }); + // Return sorted by roundId numerically, regardless of id type + return Object.values(this.data.rounds).sort((a, b) => compareRoundIds(a.roundId, b.roundId)); } public getRawData(): StoreData {