Skip to content

Commit 4fc8b9d

Browse files
feat(agent): add remaining-spend helper and harden amount conversion
1 parent 5577867 commit 4fc8b9d

3 files changed

Lines changed: 81 additions & 2 deletions

File tree

services/agent/src/index.ts

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

services/agent/src/mandate.test.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ import {
1010
createSessionMandate,
1111
MandateCapError,
1212
MandateError,
13+
remainingAppraisalSpend,
14+
stroopsToUsdc,
1315
usdcToStroops,
1416
verifySessionMandate,
1517
} from "./mandate.js";
@@ -83,3 +85,39 @@ test("bidFromAppraisal clamps to mandate maxBid", () => {
8385
assert.equal(bidValue, usdcToStroops(40));
8486
assert.equal(escrow, usdcToStroops(40));
8587
});
88+
89+
test("usdcToStroops converts and hardens input", () => {
90+
assert.equal(usdcToStroops(1), 10_000_000n);
91+
assert.equal(usdcToStroops(0.1), 1_000_000n);
92+
assert.equal(usdcToStroops(0), 0n);
93+
assert.throws(() => usdcToStroops(Number.NaN), MandateError);
94+
assert.throws(() => usdcToStroops(Number.POSITIVE_INFINITY), MandateError);
95+
assert.throws(() => usdcToStroops(-1), MandateError);
96+
});
97+
98+
test("stroopsToUsdc converts and hardens input", () => {
99+
assert.equal(stroopsToUsdc(10_000_000n), 1);
100+
assert.equal(stroopsToUsdc(1_500_000n), 0.15);
101+
assert.equal(stroopsToUsdc(0n), 0);
102+
assert.throws(() => stroopsToUsdc(123 as unknown as bigint), MandateError);
103+
assert.throws(() => stroopsToUsdc(-1n), MandateError);
104+
});
105+
106+
test("remainingAppraisalSpend tracks remaining budget", () => {
107+
const p = baseParams();
108+
p.maxAppraisalSpendStroops = usdcToStroops(1);
109+
const { mandate } = createSessionMandate(p);
110+
assert.equal(remainingAppraisalSpend(mandate), usdcToStroops(1));
111+
assert.equal(
112+
remainingAppraisalSpend(mandate, usdcToStroops(0.4)),
113+
usdcToStroops(0.6),
114+
);
115+
assert.throws(
116+
() => remainingAppraisalSpend(mandate, usdcToStroops(1.5)),
117+
MandateCapError,
118+
);
119+
assert.throws(
120+
() => remainingAppraisalSpend(mandate, -1n as unknown as bigint),
121+
MandateError,
122+
);
123+
});

services/agent/src/mandate.ts

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

6767
export function usdcToStroops(amount: number): bigint {
68-
return BigInt(Math.round(amount * 1e7));
68+
if (!Number.isFinite(amount)) {
69+
throw new MandateError(`usdc amount must be a finite number, got ${amount}`);
70+
}
71+
if (amount < 0) {
72+
throw new MandateError(`usdc amount must be non-negative, got ${amount}`);
73+
}
74+
const scaled = Math.round(amount * 1e7);
75+
if (!Number.isSafeInteger(scaled)) {
76+
throw new MandateError(`usdc amount ${amount} is out of stroop-safe range`);
77+
}
78+
return BigInt(scaled);
6979
}
7080

7181
export function stroopsToUsdc(stroops: bigint): number {
72-
return Number(stroops) / 1e7;
82+
if (typeof stroops !== "bigint") {
83+
throw new MandateError(`stroops must be a bigint, got ${typeof stroops}`);
84+
}
85+
if (stroops < 0n) {
86+
throw new MandateError(`stroops must be non-negative, got ${stroops}`);
87+
}
88+
// Split whole/fraction to avoid `Number(bigint)` precision loss for large
89+
// escrow/bid values that exceed Number's safe integer range.
90+
const whole = Number(stroops / 10_000_000n);
91+
const frac = Number(stroops % 10_000_000n) / 1e7;
92+
return whole + frac;
7393
}
7494

7595
export interface CreateMandateParams {
@@ -178,6 +198,26 @@ export function assertAppraisalSpendAllowed(
178198
}
179199
}
180200

201+
/** Remaining x402 appraisal budget (stroops) before the mandate cap is hit. */
202+
export function remainingAppraisalSpend(
203+
mandate: SessionMandate,
204+
spentSoFarStroops: bigint = 0n,
205+
): bigint {
206+
if (typeof spentSoFarStroops !== "bigint" || spentSoFarStroops < 0n) {
207+
throw new MandateError(
208+
`spentSoFarStroops must be a non-negative bigint, got ${String(spentSoFarStroops)}`,
209+
);
210+
}
211+
const cap = BigInt(mandate.maxAppraisalSpendStroops);
212+
const remaining = cap - spentSoFarStroops;
213+
if (remaining < 0n) {
214+
throw new MandateCapError(
215+
`appraisal spend ${spentSoFarStroops} already exceeds mandate cap ${cap}`,
216+
);
217+
}
218+
return remaining;
219+
}
220+
181221
/** Refuse a bid/escrow pair that exceeds mandate caps (agent-side guard). */
182222
export function assertBidWithinMandate(
183223
mandate: SessionMandate,

0 commit comments

Comments
 (0)