From 42b34442ebc560e1e79edf55736f4e3d41e9e487 Mon Sep 17 00:00:00 2001 From: Olasunkanmi975 Date: Tue, 25 Aug 2026 21:52:31 +0000 Subject: [PATCH 1/3] fix(keeper): drop malformed store entries on load and extract round-id comparator Previously a single malformed/non-numeric round entry in .keeper-store.json caused listRounds() ordering (which did BigInt() on the id) to throw, which the load path surfaced as a full corrupted-file backup that dropped every entry. - Drop individual malformed round entries on load instead of nuking the whole store (still warn, still back up on truly corrupted JSON). - Extract a shared compareRoundIds() numeric comparator so listRounds and callers (parseRoundIdSpec) order rounds consistently regardless of id type. Closes #194 --- services/keeper/src/keeper.ts | 3 +- services/keeper/src/store.test.ts | 47 +++++++++++++++++++++++++++---- services/keeper/src/store.ts | 32 +++++++++++++++------ 3 files changed, 68 insertions(+), 14 deletions(-) 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 { From 55778671f74fee214035c64697808f7ceec09d90 Mon Sep 17 00:00:00 2001 From: Olasunkanmi975 Date: Wed, 26 Aug 2026 06:24:23 +0000 Subject: [PATCH 2/3] feat(sdk): add round-status predicates and human-readable label helper --- packages/sdk/package.json | 2 +- packages/sdk/src/index.ts | 17 +++ packages/sdk/src/public-api-snapshot.test.ts | 11 ++ packages/sdk/src/round-status.test.ts | 124 +++++++++++++++++++ packages/sdk/src/round-status.ts | 77 ++++++++++++ 5 files changed, 230 insertions(+), 1 deletion(-) create mode 100644 packages/sdk/src/round-status.test.ts create mode 100644 packages/sdk/src/round-status.ts 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); +} From 4fc8b9d14fd8cc569b48c1e3caa20534623dd41d Mon Sep 17 00:00:00 2001 From: Olasunkanmi975 Date: Wed, 26 Aug 2026 06:32:07 +0000 Subject: [PATCH 3/3] feat(agent): add remaining-spend helper and harden amount conversion --- services/agent/src/index.ts | 1 + services/agent/src/mandate.test.ts | 38 ++++++++++++++++++++++++++ services/agent/src/mandate.ts | 44 ++++++++++++++++++++++++++++-- 3 files changed, 81 insertions(+), 2 deletions(-) diff --git a/services/agent/src/index.ts b/services/agent/src/index.ts index c591929..b6091d0 100644 --- a/services/agent/src/index.ts +++ b/services/agent/src/index.ts @@ -5,6 +5,7 @@ export { assertAppraisalSpendAllowed, assertBidWithinMandate, bidFromAppraisal, + remainingAppraisalSpend, mandateDigest, usdcToStroops, stroopsToUsdc, diff --git a/services/agent/src/mandate.test.ts b/services/agent/src/mandate.test.ts index 5579e03..51a19af 100644 --- a/services/agent/src/mandate.test.ts +++ b/services/agent/src/mandate.test.ts @@ -10,6 +10,8 @@ import { createSessionMandate, MandateCapError, MandateError, + remainingAppraisalSpend, + stroopsToUsdc, usdcToStroops, verifySessionMandate, } from "./mandate.js"; @@ -83,3 +85,39 @@ test("bidFromAppraisal clamps to mandate maxBid", () => { assert.equal(bidValue, usdcToStroops(40)); assert.equal(escrow, usdcToStroops(40)); }); + +test("usdcToStroops converts and hardens input", () => { + assert.equal(usdcToStroops(1), 10_000_000n); + assert.equal(usdcToStroops(0.1), 1_000_000n); + assert.equal(usdcToStroops(0), 0n); + assert.throws(() => usdcToStroops(Number.NaN), MandateError); + assert.throws(() => usdcToStroops(Number.POSITIVE_INFINITY), MandateError); + assert.throws(() => usdcToStroops(-1), MandateError); +}); + +test("stroopsToUsdc converts and hardens input", () => { + assert.equal(stroopsToUsdc(10_000_000n), 1); + assert.equal(stroopsToUsdc(1_500_000n), 0.15); + assert.equal(stroopsToUsdc(0n), 0); + assert.throws(() => stroopsToUsdc(123 as unknown as bigint), MandateError); + assert.throws(() => stroopsToUsdc(-1n), MandateError); +}); + +test("remainingAppraisalSpend tracks remaining budget", () => { + const p = baseParams(); + p.maxAppraisalSpendStroops = usdcToStroops(1); + const { mandate } = createSessionMandate(p); + assert.equal(remainingAppraisalSpend(mandate), usdcToStroops(1)); + assert.equal( + remainingAppraisalSpend(mandate, usdcToStroops(0.4)), + usdcToStroops(0.6), + ); + assert.throws( + () => remainingAppraisalSpend(mandate, usdcToStroops(1.5)), + MandateCapError, + ); + assert.throws( + () => remainingAppraisalSpend(mandate, -1n as unknown as bigint), + MandateError, + ); +}); diff --git a/services/agent/src/mandate.ts b/services/agent/src/mandate.ts index 6c34fd1..32a5c79 100644 --- a/services/agent/src/mandate.ts +++ b/services/agent/src/mandate.ts @@ -65,11 +65,31 @@ export function mandateDigest(payload: SessionMandatePayload): Buffer { } export function usdcToStroops(amount: number): bigint { - return BigInt(Math.round(amount * 1e7)); + if (!Number.isFinite(amount)) { + throw new MandateError(`usdc amount must be a finite number, got ${amount}`); + } + if (amount < 0) { + throw new MandateError(`usdc amount must be non-negative, got ${amount}`); + } + const scaled = Math.round(amount * 1e7); + if (!Number.isSafeInteger(scaled)) { + throw new MandateError(`usdc amount ${amount} is out of stroop-safe range`); + } + return BigInt(scaled); } export function stroopsToUsdc(stroops: bigint): number { - return Number(stroops) / 1e7; + if (typeof stroops !== "bigint") { + throw new MandateError(`stroops must be a bigint, got ${typeof stroops}`); + } + if (stroops < 0n) { + throw new MandateError(`stroops must be non-negative, got ${stroops}`); + } + // Split whole/fraction to avoid `Number(bigint)` precision loss for large + // escrow/bid values that exceed Number's safe integer range. + const whole = Number(stroops / 10_000_000n); + const frac = Number(stroops % 10_000_000n) / 1e7; + return whole + frac; } export interface CreateMandateParams { @@ -178,6 +198,26 @@ export function assertAppraisalSpendAllowed( } } +/** Remaining x402 appraisal budget (stroops) before the mandate cap is hit. */ +export function remainingAppraisalSpend( + mandate: SessionMandate, + spentSoFarStroops: bigint = 0n, +): bigint { + if (typeof spentSoFarStroops !== "bigint" || spentSoFarStroops < 0n) { + throw new MandateError( + `spentSoFarStroops must be a non-negative bigint, got ${String(spentSoFarStroops)}`, + ); + } + const cap = BigInt(mandate.maxAppraisalSpendStroops); + const remaining = cap - spentSoFarStroops; + if (remaining < 0n) { + throw new MandateCapError( + `appraisal spend ${spentSoFarStroops} already exceeds mandate cap ${cap}`, + ); + } + return remaining; +} + /** Refuse a bid/escrow pair that exceeds mandate caps (agent-side guard). */ export function assertBidWithinMandate( mandate: SessionMandate,