Skip to content
Open
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
2 changes: 1 addition & 1 deletion packages/sdk/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
17 changes: 17 additions & 0 deletions packages/sdk/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
11 changes: 11 additions & 0 deletions packages/sdk/src/public-api-snapshot.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -25,9 +27,11 @@ const EXPECTED_EXPORTS = [
"SubRosaSubmitError",
"SubRosaTimeoutError",
"SubRosaTransactionError",
"TERMINAL_ROUND_STATUSES",
"assertMainnetConfirmed",
"assertMicroAmounts",
"assertReadinessForExecute",
"classifyRoundStatus",
"contractErrorCode",
"createOzChannelsSubmitter",
"createOzChannelsSubmitterFromEnv",
Expand All @@ -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",
Expand Down
124 changes: 124 additions & 0 deletions packages/sdk/src/round-status.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
77 changes: 77 additions & 0 deletions packages/sdk/src/round-status.ts
Original file line number Diff line number Diff line change
@@ -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<RoundStatus, string> = {
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);
}
3 changes: 2 additions & 1 deletion services/keeper/src/keeper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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(
Expand Down
47 changes: 42 additions & 5 deletions services/keeper/src/store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -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);
Expand Down
Loading