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);
}
1 change: 1 addition & 0 deletions services/agent/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ export {
assertAppraisalSpendAllowed,
assertBidWithinMandate,
bidFromAppraisal,
remainingAppraisalSpend,
mandateDigest,
usdcToStroops,
stroopsToUsdc,
Expand Down
38 changes: 38 additions & 0 deletions services/agent/src/mandate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import {
createSessionMandate,
MandateCapError,
MandateError,
remainingAppraisalSpend,
stroopsToUsdc,
usdcToStroops,
verifySessionMandate,
} from "./mandate.js";
Expand Down Expand Up @@ -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,
);
});
44 changes: 42 additions & 2 deletions services/agent/src/mandate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
Expand Down
Loading