Skip to content

Commit bf2c182

Browse files
authored
Merge pull request #228 from Olasunkanmi975/feat/agent-remaining-spend-helper-193
feat(agent): add remaining-spend helper and harden amount conversion
2 parents 68c187f + e570ec4 commit bf2c182

11 files changed

Lines changed: 379 additions & 17 deletions

File tree

packages/sdk/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@
2424
".": "./src/index.ts"
2525
},
2626
"scripts": {
27-
"test": "node --import tsx --test src/asset-config.test.ts src/client.test.ts src/encoding.test.ts src/encrypted-blob.test.ts src/errors.test.ts src/export-receipt.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/receipt.test.ts src/redact.test.ts src/status-client.test.ts src/verify.test.ts",
27+
"test": "node --import tsx --test src/asset-config.test.ts src/client.test.ts src/encoding.test.ts src/encrypted-blob.test.ts src/errors.test.ts src/export-receipt.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/receipt.test.ts src/redact.test.ts src/round-status.test.ts src/status-client.test.ts src/verify.test.ts",
2828
"typecheck": "tsc --noEmit -p tsconfig.json"
2929
},
3030
"dependencies": {

packages/sdk/src/index.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,23 @@ export {
110110
type Severity,
111111
} from "./verify.js";
112112

113+
// Round-status predicates and human-readable labels. Mirror
114+
// services/keeper/src/status.ts status vocab.
115+
export {
116+
ACTIVE_ROUND_STATUSES,
117+
TERMINAL_ROUND_STATUSES,
118+
ERROR_ROUND_STATUSES,
119+
type RoundStatusClass,
120+
classifyRoundStatus,
121+
isActiveRoundStatus,
122+
isTerminalRoundStatus,
123+
isErrorRoundStatus,
124+
roundStatusLabel,
125+
isKeeperRoundActive,
126+
isKeeperRoundTerminal,
127+
isKeeperRoundSettlementPending,
128+
} from "./round-status.js";
129+
113130
// Keeper status-API response shapes. Mirror services/keeper/src/status.ts.
114131
export {
115132
type RoundStatus,

packages/sdk/src/public-api-snapshot.test.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ import * as sdk from "./index.js";
66
const EXPECTED_EXPORTS = [
77
"ASSET_FIXTURES",
88
"AssetConfigError",
9+
"ACTIVE_ROUND_STATUSES",
10+
"ERROR_ROUND_STATUSES",
911
"KeeperStatusClient",
1012
"MAINNET_ARTIFACTS",
1113
"MAINNET_CONFIRM_PHRASE",
@@ -27,9 +29,11 @@ const EXPECTED_EXPORTS = [
2729
"SubRosaSubmitError",
2830
"SubRosaTimeoutError",
2931
"SubRosaTransactionError",
32+
"TERMINAL_ROUND_STATUSES",
3033
"assertMainnetConfirmed",
3134
"assertMicroAmounts",
3235
"assertReadinessForExecute",
36+
"classifyRoundStatus",
3337
"contractErrorCode",
3438
"createOzChannelsSubmitter",
3539
"createOzChannelsSubmitterFromEnv",
@@ -40,12 +44,19 @@ const EXPECTED_EXPORTS = [
4044
"fetchKeeperStatus",
4145
"formatReadinessReport",
4246
"hasBlockingFailures",
47+
"isActiveRoundStatus",
48+
"isErrorRoundStatus",
49+
"isKeeperRoundActive",
50+
"isKeeperRoundSettlementPending",
51+
"isKeeperRoundTerminal",
52+
"isTerminalRoundStatus",
4353
"nativeXlmSacId",
4454
"networkFingerprint",
4555
"normalizeRoundId",
4656
"normalizeSorobanContractId",
4757
"parseReceipt",
4858
"redactReceipt",
59+
"roundStatusLabel",
4960
"runMainnetReadiness",
5061
"serializeReceipt",
5162
"tryDecodeBase64",
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
import assert from "node:assert/strict";
2+
import { describe, it } from "node:test";
3+
4+
import type { KeeperRoundStatusView, RoundStatus } from "./status.js";
5+
import {
6+
ACTIVE_ROUND_STATUSES,
7+
ERROR_ROUND_STATUSES,
8+
TERMINAL_ROUND_STATUSES,
9+
classifyRoundStatus,
10+
isActiveRoundStatus,
11+
isErrorRoundStatus,
12+
isKeeperRoundActive,
13+
isKeeperRoundSettlementPending,
14+
isKeeperRoundTerminal,
15+
isTerminalRoundStatus,
16+
roundStatusLabel,
17+
} from "./round-status.js";
18+
19+
const ALL_STATUSES: RoundStatus[] = [
20+
"Unknown",
21+
"Open",
22+
"Revealing",
23+
"Cleared",
24+
"Settled",
25+
"Voided",
26+
"NotFound",
27+
];
28+
29+
function viewFor(status: RoundStatus, settlement: KeeperRoundStatusView["settlement"] = "none"): KeeperRoundStatusView {
30+
return {
31+
roundId: "1",
32+
status,
33+
phase: "complete",
34+
nextAction: "none",
35+
commitDeadline: null,
36+
revealDeadline: null,
37+
revealRound: null,
38+
revealReady: false,
39+
commitClosed: false,
40+
revealWindowOpen: false,
41+
voidableAfter: null,
42+
bidderCount: null,
43+
revealedCount: null,
44+
winner: null,
45+
winningValue: null,
46+
clearingRule: null,
47+
settlement,
48+
lastKeeperAction: null,
49+
lastError: null,
50+
retryCount: 0,
51+
updatedAt: "2026-01-01T00:00:00.000Z",
52+
};
53+
}
54+
55+
describe("round-status classification", () => {
56+
it("partitions every status into exactly one class", () => {
57+
const covered = new Set([
58+
...ACTIVE_ROUND_STATUSES,
59+
...TERMINAL_ROUND_STATUSES,
60+
...ERROR_ROUND_STATUSES,
61+
]);
62+
assert.equal(covered.size, ALL_STATUSES.length);
63+
for (const status of ALL_STATUSES) {
64+
assert.ok(covered.has(status), `status ${status} must be classified`);
65+
}
66+
});
67+
68+
it("classifies active statuses", () => {
69+
for (const status of ["Open", "Revealing", "Cleared"] as RoundStatus[]) {
70+
assert.equal(isActiveRoundStatus(status), true);
71+
assert.equal(classifyRoundStatus(status), "active");
72+
}
73+
});
74+
75+
it("classifies terminal statuses", () => {
76+
for (const status of ["Settled", "Voided"] as RoundStatus[]) {
77+
assert.equal(isTerminalRoundStatus(status), true);
78+
assert.equal(classifyRoundStatus(status), "terminal");
79+
}
80+
});
81+
82+
it("classifies error statuses", () => {
83+
for (const status of ["Unknown", "NotFound"] as RoundStatus[]) {
84+
assert.equal(isErrorRoundStatus(status), true);
85+
assert.equal(classifyRoundStatus(status), "error");
86+
}
87+
});
88+
89+
it("does not double-count across buckets", () => {
90+
for (const status of ALL_STATUSES) {
91+
const hits =
92+
Number(isActiveRoundStatus(status)) +
93+
Number(isTerminalRoundStatus(status)) +
94+
Number(isErrorRoundStatus(status));
95+
assert.equal(hits, 1, `status ${status} should match exactly one predicate`);
96+
}
97+
});
98+
});
99+
100+
describe("roundStatusLabel", () => {
101+
it("returns a human-readable label for every status", () => {
102+
for (const status of ALL_STATUSES) {
103+
const label = roundStatusLabel(status);
104+
assert.ok(typeof label === "string" && label.length > 0);
105+
assert.ok(label.toLowerCase().startsWith(status.toLowerCase()));
106+
}
107+
});
108+
});
109+
110+
describe("keeper round view helpers", () => {
111+
it("mirror status classification", () => {
112+
assert.equal(isKeeperRoundActive(viewFor("Open")), true);
113+
assert.equal(isKeeperRoundActive(viewFor("Settled")), false);
114+
assert.equal(isKeeperRoundTerminal(viewFor("Voided")), true);
115+
assert.equal(isKeeperRoundTerminal(viewFor("Revealing")), false);
116+
});
117+
118+
it("treats pending and submitted settlement as pending", () => {
119+
assert.equal(isKeeperRoundSettlementPending(viewFor("Cleared", "pending")), true);
120+
assert.equal(isKeeperRoundSettlementPending(viewFor("Cleared", "submitted")), true);
121+
assert.equal(isKeeperRoundSettlementPending(viewFor("Cleared", "none")), false);
122+
assert.equal(isKeeperRoundSettlementPending(viewFor("Settled", "terminal")), false);
123+
});
124+
});

packages/sdk/src/round-status.ts

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
// Pure helpers for reasoning about keeper round statuses without depending on
2+
// the keeper service itself. These classify a `RoundStatus` into coarse
3+
// buckets (active / terminal / error) and turn a status into a human-readable
4+
// label suitable for dashboards, operator CLIs and alert copy.
5+
//
6+
// Keep the status vocab in lockstep with `services/keeper/src/status.ts`.
7+
8+
import type {
9+
KeeperRoundStatusView,
10+
RoundStatus,
11+
SettlementIndicator,
12+
} from "./status.js";
13+
14+
export const ACTIVE_ROUND_STATUSES: readonly RoundStatus[] = [
15+
"Open",
16+
"Revealing",
17+
"Cleared",
18+
];
19+
20+
export const TERMINAL_ROUND_STATUSES: readonly RoundStatus[] = [
21+
"Settled",
22+
"Voided",
23+
];
24+
25+
export const ERROR_ROUND_STATUSES: readonly RoundStatus[] = [
26+
"Unknown",
27+
"NotFound",
28+
];
29+
30+
export type RoundStatusClass = "active" | "terminal" | "error";
31+
32+
export function classifyRoundStatus(status: RoundStatus): RoundStatusClass {
33+
if (isActiveRoundStatus(status)) return "active";
34+
if (isTerminalRoundStatus(status)) return "terminal";
35+
return "error";
36+
}
37+
38+
export function isActiveRoundStatus(status: RoundStatus): boolean {
39+
return (ACTIVE_ROUND_STATUSES as readonly string[]).includes(status);
40+
}
41+
42+
export function isTerminalRoundStatus(status: RoundStatus): boolean {
43+
return (TERMINAL_ROUND_STATUSES as readonly string[]).includes(status);
44+
}
45+
46+
export function isErrorRoundStatus(status: RoundStatus): boolean {
47+
return (ERROR_ROUND_STATUSES as readonly string[]).includes(status);
48+
}
49+
50+
const ROUND_STATUS_LABELS: Record<RoundStatus, string> = {
51+
Unknown: "Unknown — keeper has not resolved the round yet",
52+
Open: "Open — accepting commitments",
53+
Revealing: "Revealing — accepting reveals",
54+
Cleared: "Cleared — awaiting settlement",
55+
Settled: "Settled — round complete",
56+
Voided: "Voided — escrow refunded",
57+
NotFound: "NotFound — round does not exist on-chain",
58+
};
59+
60+
export function roundStatusLabel(status: RoundStatus): string {
61+
return ROUND_STATUS_LABELS[status];
62+
}
63+
64+
export function isKeeperRoundActive(view: KeeperRoundStatusView): boolean {
65+
return isActiveRoundStatus(view.status);
66+
}
67+
68+
export function isKeeperRoundTerminal(view: KeeperRoundStatusView): boolean {
69+
return isTerminalRoundStatus(view.status);
70+
}
71+
72+
export function isKeeperRoundSettlementPending(
73+
view: KeeperRoundStatusView,
74+
): boolean {
75+
const pending: readonly SettlementIndicator[] = ["pending", "submitted"];
76+
return (pending as readonly string[]).includes(view.settlement);
77+
}

services/agent/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ export {
66
assertAppraisalSpendAllowed,
77
assertBidWithinMandate,
88
bidFromAppraisal,
9+
remainingAppraisalSpend,
910
mandateDigest,
1011
usdcToStroops,
1112
stroopsToUsdc,

services/agent/src/mandate.test.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ import {
1212
mandateDigest,
1313
MandateCapError,
1414
MandateError,
15+
remainingAppraisalSpend,
16+
stroopsToUsdc,
1517
usdcToStroops,
1618
verifySessionMandate,
1719
} from "./mandate.js";
@@ -93,6 +95,42 @@ test("bidFromAppraisal clamps to mandate maxBid", () => {
9395
assert.equal(escrow, usdcToStroops(40));
9496
});
9597

98+
test("usdcToStroops converts and hardens input", () => {
99+
assert.equal(usdcToStroops(1), 10_000_000n);
100+
assert.equal(usdcToStroops(0.1), 1_000_000n);
101+
assert.equal(usdcToStroops(0), 0n);
102+
assert.throws(() => usdcToStroops(Number.NaN), MandateError);
103+
assert.throws(() => usdcToStroops(Number.POSITIVE_INFINITY), MandateError);
104+
assert.throws(() => usdcToStroops(-1), MandateError);
105+
});
106+
107+
test("stroopsToUsdc converts and hardens input", () => {
108+
assert.equal(stroopsToUsdc(10_000_000n), 1);
109+
assert.equal(stroopsToUsdc(1_500_000n), 0.15);
110+
assert.equal(stroopsToUsdc(0n), 0);
111+
assert.throws(() => stroopsToUsdc(123 as unknown as bigint), MandateError);
112+
assert.throws(() => stroopsToUsdc(-1n), MandateError);
113+
});
114+
115+
test("remainingAppraisalSpend tracks remaining budget", () => {
116+
const p = baseParams();
117+
p.maxAppraisalSpendStroops = usdcToStroops(1);
118+
const { mandate } = createSessionMandate(p);
119+
assert.equal(remainingAppraisalSpend(mandate), usdcToStroops(1));
120+
assert.equal(
121+
remainingAppraisalSpend(mandate, usdcToStroops(0.4)),
122+
usdcToStroops(0.6),
123+
);
124+
assert.throws(
125+
() => remainingAppraisalSpend(mandate, usdcToStroops(1.5)),
126+
MandateCapError,
127+
);
128+
assert.throws(
129+
() => remainingAppraisalSpend(mandate, -1n as unknown as bigint),
130+
MandateError,
131+
);
132+
});
133+
96134
test("createSessionMandate rejects unsafe numeric fields", () => {
97135
const p = baseParams();
98136
assert.throws(

services/agent/src/mandate.ts

Lines changed: 42 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -105,11 +105,31 @@ export function mandateDigest(payload: SessionMandatePayload): Buffer {
105105
}
106106

107107
export function usdcToStroops(amount: number): bigint {
108-
return BigInt(Math.round(amount * 1e7));
108+
if (!Number.isFinite(amount)) {
109+
throw new MandateError(`usdc amount must be a finite number, got ${amount}`);
110+
}
111+
if (amount < 0) {
112+
throw new MandateError(`usdc amount must be non-negative, got ${amount}`);
113+
}
114+
const scaled = Math.round(amount * 1e7);
115+
if (!Number.isSafeInteger(scaled)) {
116+
throw new MandateError(`usdc amount ${amount} is out of stroop-safe range`);
117+
}
118+
return BigInt(scaled);
109119
}
110120

111121
export function stroopsToUsdc(stroops: bigint): number {
112-
return Number(stroops) / 1e7;
122+
if (typeof stroops !== "bigint") {
123+
throw new MandateError(`stroops must be a bigint, got ${typeof stroops}`);
124+
}
125+
if (stroops < 0n) {
126+
throw new MandateError(`stroops must be non-negative, got ${stroops}`);
127+
}
128+
// Split whole/fraction to avoid `Number(bigint)` precision loss for large
129+
// escrow/bid values that exceed Number's safe integer range.
130+
const whole = Number(stroops / 10_000_000n);
131+
const frac = Number(stroops % 10_000_000n) / 1e7;
132+
return whole + frac;
113133
}
114134

115135
export interface CreateMandateParams {
@@ -226,6 +246,26 @@ export function assertAppraisalSpendAllowed(
226246
}
227247
}
228248

249+
/** Remaining x402 appraisal budget (stroops) before the mandate cap is hit. */
250+
export function remainingAppraisalSpend(
251+
mandate: SessionMandate,
252+
spentSoFarStroops: bigint = 0n,
253+
): bigint {
254+
if (typeof spentSoFarStroops !== "bigint" || spentSoFarStroops < 0n) {
255+
throw new MandateError(
256+
`spentSoFarStroops must be a non-negative bigint, got ${String(spentSoFarStroops)}`,
257+
);
258+
}
259+
const cap = BigInt(mandate.maxAppraisalSpendStroops);
260+
const remaining = cap - spentSoFarStroops;
261+
if (remaining < 0n) {
262+
throw new MandateCapError(
263+
`appraisal spend ${spentSoFarStroops} already exceeds mandate cap ${cap}`,
264+
);
265+
}
266+
return remaining;
267+
}
268+
229269
/** Refuse a bid/escrow pair that exceeds mandate caps (agent-side guard). */
230270
export function assertBidWithinMandate(
231271
mandate: SessionMandate,

0 commit comments

Comments
 (0)