@absol-labs/agent 0.12.0-rc.3 → 0.12.0-rc.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/commands/claim.d.ts +6 -0
- package/dist/cli/commands/claim.d.ts.map +1 -1
- package/dist/cli/commands/claim.js +53 -22
- package/dist/cli/commands/claim.js.map +1 -1
- package/dist/cli/commands/pay.d.ts +10 -0
- package/dist/cli/commands/pay.d.ts.map +1 -1
- package/dist/cli/commands/pay.js +246 -50
- package/dist/cli/commands/pay.js.map +1 -1
- package/dist/cli/commands/refund.d.ts +21 -12
- package/dist/cli/commands/refund.d.ts.map +1 -1
- package/dist/cli/commands/refund.js +61 -206
- package/dist/cli/commands/refund.js.map +1 -1
- package/dist/cli/deps.d.ts +6 -0
- package/dist/cli/deps.d.ts.map +1 -1
- package/dist/cli/deps.js +3 -0
- package/dist/cli/deps.js.map +1 -1
- package/dist/cli/errors.d.ts +8 -1
- package/dist/cli/errors.d.ts.map +1 -1
- package/dist/cli/errors.js.map +1 -1
- package/dist/mcp/server.d.ts +10 -0
- package/dist/mcp/server.d.ts.map +1 -1
- package/dist/mcp/server.js +21 -1
- package/dist/mcp/server.js.map +1 -1
- package/dist/mcp/x402-tools.d.ts +7 -1
- package/dist/mcp/x402-tools.d.ts.map +1 -1
- package/dist/mcp/x402-tools.js +159 -6
- package/dist/mcp/x402-tools.js.map +1 -1
- package/dist/x402-buyer/index.d.ts +1 -0
- package/dist/x402-buyer/index.d.ts.map +1 -1
- package/dist/x402-buyer/index.js +1 -0
- package/dist/x402-buyer/index.js.map +1 -1
- package/dist/x402-buyer/pay.d.ts +38 -2
- package/dist/x402-buyer/pay.d.ts.map +1 -1
- package/dist/x402-buyer/pay.js +52 -2
- package/dist/x402-buyer/pay.js.map +1 -1
- package/dist/x402-buyer/refund-actions.d.ts +168 -0
- package/dist/x402-buyer/refund-actions.d.ts.map +1 -0
- package/dist/x402-buyer/refund-actions.js +351 -0
- package/dist/x402-buyer/refund-actions.js.map +1 -0
- package/docs/cli.md +39 -7
- package/docs/mcp-reference.md +23 -0
- package/docs/x402.md +43 -3
- package/package.json +1 -1
- package/src/cli/commands/claim.ts +55 -29
- package/src/cli/commands/pay.ts +299 -51
- package/src/cli/commands/refund.ts +89 -240
- package/src/cli/deps.ts +9 -0
- package/src/cli/errors.ts +8 -1
- package/src/mcp/server.ts +30 -1
- package/src/mcp/x402-tools.ts +222 -7
- package/src/x402-buyer/index.ts +1 -0
- package/src/x402-buyer/pay.ts +99 -2
- package/src/x402-buyer/refund-actions.ts +486 -0
|
@@ -6,6 +6,13 @@ import type {
|
|
|
6
6
|
} from "@absol-labs/sdk/x402";
|
|
7
7
|
|
|
8
8
|
import { verifyMandateOwnerSignature } from "../../mandates/mandate.js";
|
|
9
|
+
import {
|
|
10
|
+
RefundActionsError,
|
|
11
|
+
runRefundActions,
|
|
12
|
+
type FinishedReason,
|
|
13
|
+
type RefundActionsResult,
|
|
14
|
+
type SkipReason,
|
|
15
|
+
} from "../../x402-buyer/refund-actions.js";
|
|
9
16
|
import { booleanFlag, integerFlag, stringFlag } from "../args.js";
|
|
10
17
|
import { formatUsdc } from "../amounts.js";
|
|
11
18
|
import { assertTestnetRpc, parseBytes32 } from "../config.js";
|
|
@@ -20,20 +27,15 @@ import {
|
|
|
20
27
|
} from "../wallet.js";
|
|
21
28
|
import type { CommandContext, CommandSpec } from "../context.js";
|
|
22
29
|
|
|
30
|
+
export { finishedReason } from "../../x402-buyer/refund-actions.js";
|
|
31
|
+
export type { FinishedReason, SkipReason };
|
|
32
|
+
|
|
23
33
|
/** Environment fallback for `--refund-url`. */
|
|
24
34
|
export const REFUND_URL_ENV = "METRIK_AGENT_REFUND_URL";
|
|
25
35
|
|
|
26
|
-
|
|
27
|
-
const
|
|
28
|
-
|
|
29
|
-
* After a close/reclaim, how many times the summary re-reads the relay until
|
|
30
|
-
* it reflects the transaction just mined (a load-balanced RPC behind the relay
|
|
31
|
-
* can answer from a node that has not seen it yet), and how long it waits
|
|
32
|
-
* between reads. Bounded: past it the summary is derived from the verified
|
|
33
|
-
* receipts instead.
|
|
34
|
-
*/
|
|
35
|
-
const POST_ACTION_READS = 6;
|
|
36
|
-
const POST_ACTION_INTERVAL_MS = 1_500;
|
|
36
|
+
/** Relay attempts / longest single wait of a reclaim (also `pay --close-after`'s). */
|
|
37
|
+
export const DEFAULT_ATTEMPTS = 5;
|
|
38
|
+
export const DEFAULT_MAX_WAIT_SECONDS = 60;
|
|
37
39
|
|
|
38
40
|
export const refundCommand: CommandSpec = {
|
|
39
41
|
name: "refund",
|
|
@@ -106,112 +108,39 @@ export const refundCommand: CommandSpec = {
|
|
|
106
108
|
maxDelayMs: maxWait * 1_000,
|
|
107
109
|
});
|
|
108
110
|
|
|
109
|
-
let
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
finishedBefore === null
|
|
127
|
-
? "stream is already closed — no Close to sign."
|
|
128
|
-
: `stream is already closed and ${finishedBefore === "reclaimed" ? "reclaimed" : "has nothing left to reclaim"} — no Close to sign.`,
|
|
129
|
-
);
|
|
130
|
-
} else {
|
|
131
|
-
const wallet = await resolveCliWallet(env, {
|
|
132
|
-
purpose: "payer-close",
|
|
133
|
-
note: out.note,
|
|
134
|
-
rpcUrl: config.rpcUrl,
|
|
135
|
-
});
|
|
136
|
-
if (isPrivyWalletSource(wallet.source)) {
|
|
137
|
-
throw configError(
|
|
138
|
-
"metrik refund --close cannot sign with a Privy wallet",
|
|
139
|
-
"the payer signs the opener's EIP-712 Close, which the Privy broker's typed-data policy does not allow. Use the key that paid.",
|
|
140
|
-
);
|
|
141
|
-
}
|
|
142
|
-
// Buyer recovery: authorized on the mandate OWNER's signature alone,
|
|
143
|
-
// like `metrik close` — never on caps or expiry, so nothing can strand
|
|
144
|
-
// the payer's own money. The SDK then refuses a signer that is not
|
|
145
|
-
// `payerOf(streamId)` before anything is signed for the relay.
|
|
146
|
-
const mandate = await signRecoveryMandate(
|
|
147
|
-
wallet.account,
|
|
148
|
-
config.chainId,
|
|
149
|
-
);
|
|
150
|
-
const decision = await verifyMandateOwnerSignature(mandate);
|
|
151
|
-
if (!decision.allowed) {
|
|
152
|
-
throw new CliError(`mandate denied: ${decision.reason}`, {
|
|
153
|
-
exitCode: 3,
|
|
154
|
-
});
|
|
155
|
-
}
|
|
156
|
-
try {
|
|
157
|
-
closeResult = await relay(() =>
|
|
158
|
-
refunds.close({
|
|
159
|
-
signer: wallet.account as unknown as X402PayerSigner,
|
|
111
|
+
let actions: RefundActionsResult;
|
|
112
|
+
try {
|
|
113
|
+
actions = await runRefundActions({
|
|
114
|
+
refunds,
|
|
115
|
+
streamId,
|
|
116
|
+
opener,
|
|
117
|
+
payer: stream.payer ?? null,
|
|
118
|
+
close: doClose,
|
|
119
|
+
reclaim: doReclaim,
|
|
120
|
+
closeSigner: () => payerCloseSigner(context, "metrik refund --close"),
|
|
121
|
+
sleep: deps.sleep,
|
|
122
|
+
note: out.note,
|
|
123
|
+
...(context.progress === undefined
|
|
124
|
+
? {}
|
|
125
|
+
: {
|
|
126
|
+
setActivity: (activity: string | null) =>
|
|
127
|
+
context.progress?.setActivity(activity),
|
|
160
128
|
}),
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
// meantime (it reached expiresAt, or another close landed): the
|
|
168
|
-
// state the payer asked for. Anything else is a real failure.
|
|
169
|
-
if (relayCode(error) !== "already-closed") throw error;
|
|
170
|
-
closeSkipped = "already-closed";
|
|
171
|
-
out.note("stream was already closed — no Close was needed.");
|
|
172
|
-
}
|
|
173
|
-
}
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
let reclaimResult: RefundRelayResult | null = null;
|
|
177
|
-
let reclaimSkipped: SkipReason | null = null;
|
|
178
|
-
if (doReclaim) {
|
|
179
|
-
if (finishedByRelay !== null) {
|
|
180
|
-
reclaimSkipped = finishedByRelay;
|
|
181
|
-
} else {
|
|
182
|
-
context.progress?.setActivity(
|
|
183
|
-
`waiting on the refund relay's reclaim (it retries up to ${attempts} time${attempts === 1 ? "" : "s"} while the settlement window is open, at most ${maxWait}s per wait)`,
|
|
184
|
-
);
|
|
185
|
-
try {
|
|
186
|
-
reclaimResult = await relay(() => refunds.reclaim());
|
|
187
|
-
} catch (error) {
|
|
188
|
-
const finished = finishedFromRelayCode(relayCode(error));
|
|
189
|
-
if (finished === null) throw error;
|
|
190
|
-
reclaimSkipped = finished;
|
|
191
|
-
finishedByRelay = finished;
|
|
192
|
-
} finally {
|
|
193
|
-
context.progress?.setActivity(null);
|
|
194
|
-
}
|
|
195
|
-
}
|
|
129
|
+
reclaimActivity: reclaimActivity(attempts, maxWait),
|
|
130
|
+
});
|
|
131
|
+
} catch (error) {
|
|
132
|
+
throw explainRefundFailure(
|
|
133
|
+
error instanceof RefundActionsError ? error.cause : error,
|
|
134
|
+
);
|
|
196
135
|
}
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
// it from the verified receipts. Never the pre-action state.
|
|
201
|
-
const status = await statusAfterActions(refunds, {
|
|
202
|
-
closed: closeResult !== null,
|
|
136
|
+
const {
|
|
137
|
+
close: closeResult,
|
|
138
|
+
closeSkipped,
|
|
203
139
|
reclaim: reclaimResult,
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
});
|
|
209
|
-
// A reclaim THIS run performed is reported as a reclaim, not as "already
|
|
210
|
-
// finished".
|
|
211
|
-
const finished =
|
|
212
|
-
reclaimResult === null
|
|
213
|
-
? (finishedReason(status) ?? finishedByRelay)
|
|
214
|
-
: null;
|
|
140
|
+
reclaimSkipped,
|
|
141
|
+
status,
|
|
142
|
+
finished,
|
|
143
|
+
} = actions;
|
|
215
144
|
out.result({
|
|
216
145
|
streamId,
|
|
217
146
|
opener,
|
|
@@ -264,87 +193,6 @@ export const refundCommand: CommandSpec = {
|
|
|
264
193
|
},
|
|
265
194
|
};
|
|
266
195
|
|
|
267
|
-
/**
|
|
268
|
-
* The relay's view of a stream AFTER this run's actions. Reads until it
|
|
269
|
-
* reflects them (closed after a close, reclaimed after a reclaim), bounded;
|
|
270
|
-
* if the relay still lags, the state those verified transactions imply is
|
|
271
|
-
* reported instead, marked as derived. With no action, one plain read.
|
|
272
|
-
*/
|
|
273
|
-
async function statusAfterActions(
|
|
274
|
-
refunds: RefundClient,
|
|
275
|
-
done: {
|
|
276
|
-
readonly closed: boolean;
|
|
277
|
-
readonly reclaim: RefundRelayResult | null;
|
|
278
|
-
readonly payer: `0x${string}` | null;
|
|
279
|
-
readonly opener: `0x${string}`;
|
|
280
|
-
readonly streamId: `0x${string}`;
|
|
281
|
-
readonly sleep: (ms: number) => Promise<void>;
|
|
282
|
-
},
|
|
283
|
-
): Promise<RefundStatus> {
|
|
284
|
-
const reclaimed = done.reclaim !== null;
|
|
285
|
-
if (!done.closed && !reclaimed) return await relay(() => refunds.status());
|
|
286
|
-
const reflects = (status: RefundStatus): boolean =>
|
|
287
|
-
(!done.closed || status.status === "closed" || status.reclaimed) &&
|
|
288
|
-
(!reclaimed || status.reclaimed);
|
|
289
|
-
let last: RefundStatus | null = null;
|
|
290
|
-
for (let attempt = 1; attempt <= POST_ACTION_READS; attempt++) {
|
|
291
|
-
try {
|
|
292
|
-
const status = await refunds.status();
|
|
293
|
-
if (reflects(status)) return status;
|
|
294
|
-
last = status;
|
|
295
|
-
} catch {
|
|
296
|
-
// A lagging relay (503 chain-lagging, a stale not-found, a blip): the
|
|
297
|
-
// transactions already happened, so keep going and derive if need be.
|
|
298
|
-
}
|
|
299
|
-
if (attempt < POST_ACTION_READS) await done.sleep(POST_ACTION_INTERVAL_MS);
|
|
300
|
-
}
|
|
301
|
-
return derivedStatus(last, done);
|
|
302
|
-
}
|
|
303
|
-
|
|
304
|
-
/** The state this run's verified close/reclaim imply, when the relay lags. */
|
|
305
|
-
function derivedStatus(
|
|
306
|
-
last: RefundStatus | null,
|
|
307
|
-
done: {
|
|
308
|
-
readonly closed: boolean;
|
|
309
|
-
readonly reclaim: RefundRelayResult | null;
|
|
310
|
-
readonly payer: `0x${string}` | null;
|
|
311
|
-
readonly opener: `0x${string}`;
|
|
312
|
-
readonly streamId: `0x${string}`;
|
|
313
|
-
},
|
|
314
|
-
): RefundStatus {
|
|
315
|
-
const reclaimed = done.reclaim !== null;
|
|
316
|
-
const note =
|
|
317
|
-
"derived from this run's transaction receipts; the refund relay had not caught up with them yet";
|
|
318
|
-
return {
|
|
319
|
-
streamId: done.streamId,
|
|
320
|
-
chainId: last?.chainId ?? 0,
|
|
321
|
-
opener: last?.opener ?? done.opener,
|
|
322
|
-
payer: last?.payer ?? done.payer ?? done.opener,
|
|
323
|
-
status: "closed",
|
|
324
|
-
reclaimed: reclaimed || (last?.reclaimed ?? false),
|
|
325
|
-
chainTime: last?.chainTime ?? null,
|
|
326
|
-
reclaimable: null,
|
|
327
|
-
// Both legs this run verified are on-chain facts: the stream is closed.
|
|
328
|
-
state: "closed",
|
|
329
|
-
onChainStatus: "closed",
|
|
330
|
-
maxRefund: last?.maxRefund ?? null,
|
|
331
|
-
maxRefundMeaning: last?.maxRefundMeaning ?? null,
|
|
332
|
-
reclaimableMeaning: last?.reclaimableMeaning ?? null,
|
|
333
|
-
next: reclaimed
|
|
334
|
-
? {
|
|
335
|
-
action: "none",
|
|
336
|
-
message:
|
|
337
|
-
"Reclaimed: the unearned remainder was returned to the payer. Nothing further to do.",
|
|
338
|
-
}
|
|
339
|
-
: {
|
|
340
|
-
action: "reclaim",
|
|
341
|
-
message:
|
|
342
|
-
"Closed. Reclaim the unearned remainder once a checkpoint covering the close is posted (usually within a minute).",
|
|
343
|
-
},
|
|
344
|
-
raw: { derived: true, note },
|
|
345
|
-
} satisfies RefundStatus;
|
|
346
|
-
}
|
|
347
|
-
|
|
348
196
|
/** `closed`, `expired` (Active on-chain but past expiresAt) or `active`. */
|
|
349
197
|
export function stateOf(status: RefundStatus): "active" | "expired" | "closed" {
|
|
350
198
|
if (status.status === "closed" || status.reclaimed) return "closed";
|
|
@@ -364,43 +212,6 @@ function describeReclaimed(result: RefundRelayResult): string {
|
|
|
364
212
|
return `Reclaimed: ${result.amount === null ? "the unearned remainder" : formatUsdc(result.amount)} was returned to the payer${result.verified ? " (receipt verified on-chain)" : ""}. Nothing further to do for this stream.`;
|
|
365
213
|
}
|
|
366
214
|
|
|
367
|
-
/** Why a recovery is over. */
|
|
368
|
-
export type FinishedReason = "reclaimed" | "nothing-to-reclaim";
|
|
369
|
-
/** Why a requested `--close` / `--reclaim` sent nothing. */
|
|
370
|
-
export type SkipReason = FinishedReason | "already-closed";
|
|
371
|
-
|
|
372
|
-
/**
|
|
373
|
-
* Whether the relay's status says nothing is left to recover. `reclaimed` is
|
|
374
|
-
* the chain's one-reclaim-per-stream flag; `next: none` with `reclaimable: 0`
|
|
375
|
-
* is the relay's "the verified delivery covers the whole deposit".
|
|
376
|
-
*/
|
|
377
|
-
export function finishedReason(status: RefundStatus): FinishedReason | null {
|
|
378
|
-
if (status.reclaimed) return "reclaimed";
|
|
379
|
-
if (status.next.action === "none" && status.reclaimable === 0n) {
|
|
380
|
-
return "nothing-to-reclaim";
|
|
381
|
-
}
|
|
382
|
-
return null;
|
|
383
|
-
}
|
|
384
|
-
|
|
385
|
-
/** The relay's 409 codes that mean "finished", not "failed". */
|
|
386
|
-
function finishedFromRelayCode(code: string | null): FinishedReason | null {
|
|
387
|
-
if (code === "already-reclaimed") return "reclaimed";
|
|
388
|
-
if (code === "nothing-to-reclaim") return "nothing-to-reclaim";
|
|
389
|
-
return null;
|
|
390
|
-
}
|
|
391
|
-
|
|
392
|
-
function relayCode(error: unknown): string | null {
|
|
393
|
-
for (let current: unknown = error, depth = 0; depth < 4; depth++) {
|
|
394
|
-
if (!(current instanceof Error)) return null;
|
|
395
|
-
const code = (current as { code?: unknown }).code;
|
|
396
|
-
if (current.name === "RefundRelayError" && typeof code === "string") {
|
|
397
|
-
return code;
|
|
398
|
-
}
|
|
399
|
-
current = current.cause;
|
|
400
|
-
}
|
|
401
|
-
return null;
|
|
402
|
-
}
|
|
403
|
-
|
|
404
215
|
/** The `metrik refund` answer for an upfront-only payment (exit 0). */
|
|
405
216
|
function reportNothingToRefund(
|
|
406
217
|
context: CommandContext,
|
|
@@ -463,14 +274,6 @@ export function resolveRefundUrl(
|
|
|
463
274
|
return url.toString();
|
|
464
275
|
}
|
|
465
276
|
|
|
466
|
-
async function relay<T>(task: () => Promise<T>): Promise<T> {
|
|
467
|
-
try {
|
|
468
|
-
return await task();
|
|
469
|
-
} catch (error) {
|
|
470
|
-
throw explainRefundFailure(error);
|
|
471
|
-
}
|
|
472
|
-
}
|
|
473
|
-
|
|
474
277
|
export function explainRefundFailure(error: unknown): unknown {
|
|
475
278
|
if (!(error instanceof Error)) return error;
|
|
476
279
|
switch (error.name) {
|
|
@@ -613,4 +416,50 @@ function nextStep(
|
|
|
613
416
|
}
|
|
614
417
|
}
|
|
615
418
|
|
|
419
|
+
/**
|
|
420
|
+
* The payer's signer for an opener Close, authorized the way every buyer
|
|
421
|
+
* recovery is: on the mandate OWNER's signature alone (like `metrik close`) —
|
|
422
|
+
* never on caps or expiry, so nothing can strand the payer's own money. The
|
|
423
|
+
* SDK then refuses a signer that is not `payerOf(streamId)` before anything is
|
|
424
|
+
* signed for the relay.
|
|
425
|
+
*/
|
|
426
|
+
export async function payerCloseSigner(
|
|
427
|
+
context: CommandContext,
|
|
428
|
+
commandLabel: string,
|
|
429
|
+
): Promise<X402PayerSigner> {
|
|
430
|
+
const { config, env, out } = context;
|
|
431
|
+
const wallet = await resolveCliWallet(env, {
|
|
432
|
+
purpose: "payer-close",
|
|
433
|
+
note: out.note,
|
|
434
|
+
rpcUrl: config.rpcUrl,
|
|
435
|
+
});
|
|
436
|
+
if (isPrivyWalletSource(wallet.source)) {
|
|
437
|
+
throw configError(
|
|
438
|
+
`${commandLabel} cannot sign with a Privy wallet`,
|
|
439
|
+
"the payer signs the opener's EIP-712 Close, which the Privy broker's typed-data policy does not allow. Use the key that paid.",
|
|
440
|
+
);
|
|
441
|
+
}
|
|
442
|
+
return await authorizeCloseSigner(wallet.account, config.chainId);
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/** {@link payerCloseSigner} for an account the command already holds. */
|
|
446
|
+
export async function authorizeCloseSigner(
|
|
447
|
+
account: Parameters<typeof signRecoveryMandate>[0],
|
|
448
|
+
chainId: number,
|
|
449
|
+
): Promise<X402PayerSigner> {
|
|
450
|
+
const mandate = await signRecoveryMandate(account, chainId);
|
|
451
|
+
const decision = await verifyMandateOwnerSignature(mandate);
|
|
452
|
+
if (!decision.allowed) {
|
|
453
|
+
throw new CliError(`mandate denied: ${decision.reason}`, {
|
|
454
|
+
exitCode: 3,
|
|
455
|
+
});
|
|
456
|
+
}
|
|
457
|
+
return account as unknown as X402PayerSigner;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/** What the heartbeat says while the relay's reclaim waits for the checkpoint. */
|
|
461
|
+
export function reclaimActivity(attempts: number, maxWait: number): string {
|
|
462
|
+
return `waiting on the refund relay's reclaim (it retries up to ${attempts} time${attempts === 1 ? "" : "s"} while the settlement window is open, at most ${maxWait}s per wait)`;
|
|
463
|
+
}
|
|
464
|
+
|
|
616
465
|
export type { RefundClient };
|
package/src/cli/deps.ts
CHANGED
|
@@ -262,6 +262,12 @@ export interface CliX402 {
|
|
|
262
262
|
readonly opener?: `0x${string}`;
|
|
263
263
|
readonly maxAttempts?: number;
|
|
264
264
|
readonly maxDelayMs?: number;
|
|
265
|
+
/**
|
|
266
|
+
* When the payment settled, unix ms (`pay --close-after`): a relay that
|
|
267
|
+
* has not seen the just-opened stream yet answers `404`, which is then
|
|
268
|
+
* waited out briefly instead of failing.
|
|
269
|
+
*/
|
|
270
|
+
readonly settledAt?: number;
|
|
265
271
|
},
|
|
266
272
|
) => Promise<RefundClient>;
|
|
267
273
|
}
|
|
@@ -613,6 +619,9 @@ export const defaultCliDependencies: CliDependencies = {
|
|
|
613
619
|
...(target.maxDelayMs === undefined
|
|
614
620
|
? {}
|
|
615
621
|
: { maxDelayMs: target.maxDelayMs }),
|
|
622
|
+
...(target.settledAt === undefined
|
|
623
|
+
? {}
|
|
624
|
+
: { settledAt: target.settledAt }),
|
|
616
625
|
});
|
|
617
626
|
},
|
|
618
627
|
},
|
package/src/cli/errors.ts
CHANGED
|
@@ -9,7 +9,14 @@ export type CliExitCode =
|
|
|
9
9
|
/** Configuration refused: wrong chain, missing key, mainnet target. */
|
|
10
10
|
| 3
|
|
11
11
|
/** The operation itself failed (RPC, registry, escrow revert, HTTP). */
|
|
12
|
-
| 4
|
|
12
|
+
| 4
|
|
13
|
+
/**
|
|
14
|
+
* `metrik pay --close-after`: the payment and its response SUCCEEDED, but
|
|
15
|
+
* the buyer's close + reclaim of the stream did not finish. Nothing is lost
|
|
16
|
+
* (the unearned part stays in the escrow for the payer); the hint names the
|
|
17
|
+
* exact `metrik refund` command that finishes it.
|
|
18
|
+
*/
|
|
19
|
+
| 5;
|
|
13
20
|
|
|
14
21
|
export class CliError extends Error {
|
|
15
22
|
readonly exitCode: CliExitCode;
|
package/src/mcp/server.ts
CHANGED
|
@@ -389,6 +389,22 @@ export interface StoredStreamRecord {
|
|
|
389
389
|
* seller at settlement). Informational: `budgetUsdc` always counts it.
|
|
390
390
|
*/
|
|
391
391
|
readonly nonRefundableUsdc?: bigint;
|
|
392
|
+
/**
|
|
393
|
+
* x402 streams only: base units this server's own reclaim returned to the
|
|
394
|
+
* payer (`x402_pay` with `closeAfter`), as proven by the mined receipt's
|
|
395
|
+
* `ReclaimForwarded`. Subtracted from `budgetUsdc` when counting spend, so
|
|
396
|
+
* only what the payment finally kept counts against the mandate. Never set
|
|
397
|
+
* from a relay's unverified word.
|
|
398
|
+
*/
|
|
399
|
+
readonly reclaimedUsdc?: bigint;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/** What a record counts against the spend mandate: the budget less a verified reclaim. */
|
|
403
|
+
export function countedSpendUsdc(record: StoredStreamRecord): bigint {
|
|
404
|
+
const reclaimed = record.reclaimedUsdc ?? 0n;
|
|
405
|
+
return reclaimed > 0n && reclaimed <= record.budgetUsdc
|
|
406
|
+
? record.budgetUsdc - reclaimed
|
|
407
|
+
: record.budgetUsdc;
|
|
392
408
|
}
|
|
393
409
|
|
|
394
410
|
export interface StreamRegistry {
|
|
@@ -470,6 +486,7 @@ export class FileStreamRegistry implements StreamRegistry {
|
|
|
470
486
|
x402Reservation: z.boolean().optional(),
|
|
471
487
|
x402Charge: z.literal("upfront-only").optional(),
|
|
472
488
|
nonRefundableUsdc: decimalStringSchema.optional(),
|
|
489
|
+
reclaimedUsdc: decimalStringSchema.optional(),
|
|
473
490
|
}),
|
|
474
491
|
)
|
|
475
492
|
.parse(JSON.parse(contents));
|
|
@@ -500,6 +517,9 @@ export class FileStreamRegistry implements StreamRegistry {
|
|
|
500
517
|
...(record.nonRefundableUsdc === undefined
|
|
501
518
|
? {}
|
|
502
519
|
: { nonRefundableUsdc: BigInt(record.nonRefundableUsdc) }),
|
|
520
|
+
...(record.reclaimedUsdc === undefined
|
|
521
|
+
? {}
|
|
522
|
+
: { reclaimedUsdc: BigInt(record.reclaimedUsdc) }),
|
|
503
523
|
},
|
|
504
524
|
]),
|
|
505
525
|
);
|
|
@@ -519,6 +539,9 @@ export class FileStreamRegistry implements StreamRegistry {
|
|
|
519
539
|
...(record.nonRefundableUsdc === undefined
|
|
520
540
|
? {}
|
|
521
541
|
: { nonRefundableUsdc: record.nonRefundableUsdc.toString() }),
|
|
542
|
+
...(record.reclaimedUsdc === undefined
|
|
543
|
+
? {}
|
|
544
|
+
: { reclaimedUsdc: record.reclaimedUsdc.toString() }),
|
|
522
545
|
})),
|
|
523
546
|
null,
|
|
524
547
|
2,
|
|
@@ -711,7 +734,7 @@ export function createVerifiedStreamMcpServer(
|
|
|
711
734
|
const records = await registry.list();
|
|
712
735
|
return records
|
|
713
736
|
.filter((record) => record.buyer.toLowerCase() === owner.toLowerCase())
|
|
714
|
-
.reduce((sum, record) => sum + record
|
|
737
|
+
.reduce((sum, record) => sum + countedSpendUsdc(record), 0n);
|
|
715
738
|
};
|
|
716
739
|
if (options.mandateStateResolver?.getRevokedMandateIds === undefined) {
|
|
717
740
|
warnOnce(
|
|
@@ -1186,6 +1209,9 @@ export function createVerifiedStreamMcpServer(
|
|
|
1186
1209
|
...(record.nonRefundableUsdc === undefined
|
|
1187
1210
|
? {}
|
|
1188
1211
|
: { nonRefundableUsdc: record.nonRefundableUsdc.toString() }),
|
|
1212
|
+
...(record.reclaimedUsdc === undefined
|
|
1213
|
+
? {}
|
|
1214
|
+
: { reclaimedUsdc: record.reclaimedUsdc.toString() }),
|
|
1189
1215
|
status,
|
|
1190
1216
|
};
|
|
1191
1217
|
}),
|
|
@@ -1607,6 +1633,9 @@ export async function createVerifiedStreamMcpServerOptionsFromEnv(
|
|
|
1607
1633
|
? {}
|
|
1608
1634
|
: { opener: target.opener }),
|
|
1609
1635
|
...(target.fetch === undefined ? {} : { fetch: target.fetch }),
|
|
1636
|
+
...(target.settledAt === undefined
|
|
1637
|
+
? {}
|
|
1638
|
+
: { settledAt: target.settledAt }),
|
|
1610
1639
|
// Every relay "success" is checked against the receipt.
|
|
1611
1640
|
verifyWith: readOnlyMetrikClient({
|
|
1612
1641
|
escrowAddress: parsed.escrow,
|