@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.
Files changed (53) hide show
  1. package/dist/cli/commands/claim.d.ts +6 -0
  2. package/dist/cli/commands/claim.d.ts.map +1 -1
  3. package/dist/cli/commands/claim.js +53 -22
  4. package/dist/cli/commands/claim.js.map +1 -1
  5. package/dist/cli/commands/pay.d.ts +10 -0
  6. package/dist/cli/commands/pay.d.ts.map +1 -1
  7. package/dist/cli/commands/pay.js +246 -50
  8. package/dist/cli/commands/pay.js.map +1 -1
  9. package/dist/cli/commands/refund.d.ts +21 -12
  10. package/dist/cli/commands/refund.d.ts.map +1 -1
  11. package/dist/cli/commands/refund.js +61 -206
  12. package/dist/cli/commands/refund.js.map +1 -1
  13. package/dist/cli/deps.d.ts +6 -0
  14. package/dist/cli/deps.d.ts.map +1 -1
  15. package/dist/cli/deps.js +3 -0
  16. package/dist/cli/deps.js.map +1 -1
  17. package/dist/cli/errors.d.ts +8 -1
  18. package/dist/cli/errors.d.ts.map +1 -1
  19. package/dist/cli/errors.js.map +1 -1
  20. package/dist/mcp/server.d.ts +10 -0
  21. package/dist/mcp/server.d.ts.map +1 -1
  22. package/dist/mcp/server.js +21 -1
  23. package/dist/mcp/server.js.map +1 -1
  24. package/dist/mcp/x402-tools.d.ts +7 -1
  25. package/dist/mcp/x402-tools.d.ts.map +1 -1
  26. package/dist/mcp/x402-tools.js +159 -6
  27. package/dist/mcp/x402-tools.js.map +1 -1
  28. package/dist/x402-buyer/index.d.ts +1 -0
  29. package/dist/x402-buyer/index.d.ts.map +1 -1
  30. package/dist/x402-buyer/index.js +1 -0
  31. package/dist/x402-buyer/index.js.map +1 -1
  32. package/dist/x402-buyer/pay.d.ts +38 -2
  33. package/dist/x402-buyer/pay.d.ts.map +1 -1
  34. package/dist/x402-buyer/pay.js +52 -2
  35. package/dist/x402-buyer/pay.js.map +1 -1
  36. package/dist/x402-buyer/refund-actions.d.ts +168 -0
  37. package/dist/x402-buyer/refund-actions.d.ts.map +1 -0
  38. package/dist/x402-buyer/refund-actions.js +351 -0
  39. package/dist/x402-buyer/refund-actions.js.map +1 -0
  40. package/docs/cli.md +39 -7
  41. package/docs/mcp-reference.md +23 -0
  42. package/docs/x402.md +43 -3
  43. package/package.json +1 -1
  44. package/src/cli/commands/claim.ts +55 -29
  45. package/src/cli/commands/pay.ts +299 -51
  46. package/src/cli/commands/refund.ts +89 -240
  47. package/src/cli/deps.ts +9 -0
  48. package/src/cli/errors.ts +8 -1
  49. package/src/mcp/server.ts +30 -1
  50. package/src/mcp/x402-tools.ts +222 -7
  51. package/src/x402-buyer/index.ts +1 -0
  52. package/src/x402-buyer/pay.ts +99 -2
  53. package/src/x402-buyer/refund-actions.ts +486 -0
@@ -0,0 +1,351 @@
1
+ /**
2
+ * The buyer's own close + reclaim of an x402 stream, through the seller's
3
+ * gasless refund relay. ONE implementation behind `metrik refund`,
4
+ * `metrik pay --close-after`, the MCP `x402_pay` tool's `closeAfter` and
5
+ * {@link closeAfterPayment} (`@absol-labs/agent/x402-buyer`).
6
+ *
7
+ * Nothing here is an automatic refund: it runs only when the buyer asks for
8
+ * it, and it is the buyer's own client that signs the Close and asks the
9
+ * relay to send the permissionless reclaim. The relay pays the gas; the
10
+ * opener always sends the reclaim to the payer.
11
+ *
12
+ * The waits are bounded and the relay's own: `RefundClient.status()` waits
13
+ * out a `503 chain-lagging` (honouring `Retry-After`), and
14
+ * `RefundClient.reclaim()` retries, bounded by the client's `maxAttempts` /
15
+ * `maxDelayMs`, while the relay says the checkpoint covering the close is not
16
+ * posted yet.
17
+ */
18
+ /**
19
+ * After a close/reclaim, how many times the summary re-reads the relay until
20
+ * it reflects the transaction just mined (a load-balanced RPC behind the relay
21
+ * can answer from a node that has not seen it yet), and how long it waits
22
+ * between reads. Bounded: past it the summary is derived from the verified
23
+ * receipts instead.
24
+ */
25
+ export const POST_ACTION_READS = 6;
26
+ export const POST_ACTION_INTERVAL_MS = 1_500;
27
+ /**
28
+ * A close or reclaim that did not complete. `closed` says whether the stream
29
+ * is known to be closed (by this run or before it), so the caller can name the
30
+ * exact command that finishes the job. `cause` is the SDK's error, unchanged.
31
+ */
32
+ export class RefundActionsError extends Error {
33
+ stage;
34
+ closed;
35
+ closeResult;
36
+ constructor(context) {
37
+ super(`the ${context.stage} did not finish: ${context.cause instanceof Error ? context.cause.message : String(context.cause)}`, { cause: context.cause });
38
+ this.name = "RefundActionsError";
39
+ this.stage = context.stage;
40
+ this.closed = context.closed;
41
+ this.closeResult = context.closeResult;
42
+ }
43
+ }
44
+ /**
45
+ * Close and/or reclaim one x402 stream through its refund relay, then read
46
+ * the state AFTER those actions. Finished states (already closed, already
47
+ * reclaimed, nothing to reclaim) are reported, not thrown. Anything else that
48
+ * fails is a {@link RefundActionsError} carrying the SDK's error as `cause`.
49
+ */
50
+ export async function runRefundActions(input) {
51
+ const { refunds } = input;
52
+ let closeResult = null;
53
+ let closeSkipped = null;
54
+ // Set when the relay itself says there is nothing left: the stream was
55
+ // already reclaimed, or the verified delivery covers the whole deposit.
56
+ // Those are FINISHED recoveries, not failures — a second close + reclaim on
57
+ // a done stream must succeed and never point the payer at a gas-spending
58
+ // on-chain recovery that cannot recover a cent.
59
+ let finishedByRelay = null;
60
+ if (input.close) {
61
+ try {
62
+ // Ask first: a stream that is already closed (or already finished)
63
+ // needs no Close, so there is nothing to sign and no wallet is needed.
64
+ const before = await refunds.status();
65
+ const finishedBefore = finishedReason(before);
66
+ if (finishedBefore !== null || before.status === "closed") {
67
+ closeSkipped = finishedBefore ?? "already-closed";
68
+ finishedByRelay = finishedBefore;
69
+ input.note?.(finishedBefore === null
70
+ ? "stream is already closed — no Close to sign."
71
+ : `stream is already closed and ${finishedBefore === "reclaimed" ? "reclaimed" : "has nothing left to reclaim"} — no Close to sign.`);
72
+ }
73
+ else {
74
+ const signer = await input.closeSigner();
75
+ try {
76
+ closeResult = await refunds.close({ signer });
77
+ input.note?.(`close relayed in ${closeResult.transaction}${closeResult.verified ? " (receipt verified on-chain)" : ""}`);
78
+ }
79
+ catch (error) {
80
+ // The status read above said "active", but the stream closed in the
81
+ // meantime (it reached expiresAt, or another close landed): the
82
+ // state the payer asked for. Anything else is a real failure.
83
+ if (relayCode(error) !== "already-closed")
84
+ throw error;
85
+ closeSkipped = "already-closed";
86
+ input.note?.("stream was already closed — no Close was needed.");
87
+ }
88
+ }
89
+ }
90
+ catch (error) {
91
+ throw new RefundActionsError({
92
+ stage: "close",
93
+ closed: false,
94
+ closeResult: null,
95
+ cause: error,
96
+ });
97
+ }
98
+ }
99
+ let reclaimResult = null;
100
+ let reclaimSkipped = null;
101
+ if (input.reclaim) {
102
+ if (finishedByRelay !== null) {
103
+ reclaimSkipped = finishedByRelay;
104
+ }
105
+ else {
106
+ if (input.reclaimActivity !== undefined) {
107
+ input.setActivity?.(input.reclaimActivity);
108
+ }
109
+ try {
110
+ reclaimResult = await refunds.reclaim();
111
+ }
112
+ catch (error) {
113
+ const finished = finishedFromRelayCode(relayCode(error));
114
+ if (finished === null) {
115
+ throw new RefundActionsError({
116
+ stage: "reclaim",
117
+ closed: closeResult !== null || closeSkipped !== null,
118
+ closeResult,
119
+ cause: error,
120
+ });
121
+ }
122
+ reclaimSkipped = finished;
123
+ finishedByRelay = finished;
124
+ }
125
+ finally {
126
+ input.setActivity?.(null);
127
+ }
128
+ }
129
+ }
130
+ // The summary describes the state AFTER what this run did: re-read until
131
+ // the relay reflects the close/reclaim just mined (bounded), else derive it
132
+ // from the verified receipts. Never the pre-action state.
133
+ const status = await statusAfterActions(refunds, {
134
+ closed: closeResult !== null,
135
+ reclaim: reclaimResult,
136
+ payer: input.payer,
137
+ opener: input.opener,
138
+ streamId: input.streamId,
139
+ sleep: input.sleep,
140
+ });
141
+ // A reclaim THIS run performed is reported as a reclaim, not as "already
142
+ // finished".
143
+ const finished = reclaimResult === null ? (finishedReason(status) ?? finishedByRelay) : null;
144
+ return {
145
+ close: closeResult,
146
+ closeSkipped,
147
+ reclaim: reclaimResult,
148
+ reclaimSkipped,
149
+ status,
150
+ finished,
151
+ };
152
+ }
153
+ /**
154
+ * Right after a paid response, at the BUYER's request: close the stream the
155
+ * payment opened (the payer signs the Close, the relay pays the gas), wait for
156
+ * the checkpoint covering the close, and reclaim the unearned remainder to the
157
+ * payer. Never throws for a close/reclaim failure: the payment already
158
+ * succeeded, so the outcome says what did not finish instead.
159
+ *
160
+ * An upfront-only payment (no stream) has nothing to close: the outcome is
161
+ * `nothing-to-close` and nothing is requested, signed or sent.
162
+ */
163
+ export async function closeAfterPayment(input) {
164
+ const { payment } = input;
165
+ const empty = {
166
+ close: null,
167
+ closeSkipped: null,
168
+ reclaim: null,
169
+ reclaimSkipped: null,
170
+ refundedUsdc: null,
171
+ finished: null,
172
+ status: null,
173
+ };
174
+ if (payment.streamId === null || /^0x0{64}$/i.test(payment.streamId)) {
175
+ return {
176
+ ...empty,
177
+ outcome: "nothing-to-close",
178
+ streamId: null,
179
+ incomplete: null,
180
+ };
181
+ }
182
+ const streamId = payment.streamId;
183
+ if (payment.refundUrl === null) {
184
+ return {
185
+ ...empty,
186
+ outcome: "incomplete",
187
+ streamId,
188
+ incomplete: {
189
+ stage: "no-relay",
190
+ closed: false,
191
+ reason: "the seller advertised no refund relay, so there is no gasless close; the stream accrues only for verified seconds until it expires, and anyone can reclaim the unearned remainder to the payer after that",
192
+ error: null,
193
+ },
194
+ };
195
+ }
196
+ let refunds;
197
+ try {
198
+ refunds = await input.openRefunds();
199
+ }
200
+ catch (error) {
201
+ return {
202
+ ...empty,
203
+ outcome: "incomplete",
204
+ streamId,
205
+ incomplete: {
206
+ stage: "close",
207
+ closed: false,
208
+ reason: errorMessage(error),
209
+ error,
210
+ },
211
+ };
212
+ }
213
+ try {
214
+ const result = await runRefundActions({
215
+ ...input,
216
+ refunds,
217
+ streamId,
218
+ opener: payment.opener,
219
+ close: true,
220
+ reclaim: true,
221
+ });
222
+ return {
223
+ outcome: "completed",
224
+ streamId,
225
+ close: result.close,
226
+ closeSkipped: result.closeSkipped,
227
+ reclaim: result.reclaim,
228
+ reclaimSkipped: result.reclaimSkipped,
229
+ refundedUsdc: result.reclaim !== null && result.reclaim.verified
230
+ ? result.reclaim.amount
231
+ : null,
232
+ finished: result.finished,
233
+ status: result.status,
234
+ incomplete: null,
235
+ };
236
+ }
237
+ catch (error) {
238
+ if (!(error instanceof RefundActionsError))
239
+ throw error;
240
+ return {
241
+ ...empty,
242
+ outcome: "incomplete",
243
+ streamId,
244
+ close: error.closeResult,
245
+ incomplete: {
246
+ stage: error.stage,
247
+ closed: error.closed,
248
+ reason: errorMessage(error.cause),
249
+ error: error.cause,
250
+ },
251
+ };
252
+ }
253
+ }
254
+ function errorMessage(error) {
255
+ return error instanceof Error ? error.message : String(error);
256
+ }
257
+ /**
258
+ * The relay's view of a stream AFTER this run's actions. Reads until it
259
+ * reflects them (closed after a close, reclaimed after a reclaim), bounded;
260
+ * if the relay still lags, the state those verified transactions imply is
261
+ * reported instead, marked as derived. With no action, one plain read.
262
+ */
263
+ export async function statusAfterActions(refunds, done) {
264
+ const reclaimed = done.reclaim !== null;
265
+ if (!done.closed && !reclaimed)
266
+ return await refunds.status();
267
+ const reflects = (status) => (!done.closed || status.status === "closed" || status.reclaimed) &&
268
+ (!reclaimed || status.reclaimed);
269
+ let last = null;
270
+ for (let attempt = 1; attempt <= POST_ACTION_READS; attempt++) {
271
+ try {
272
+ const status = await refunds.status();
273
+ if (reflects(status))
274
+ return status;
275
+ last = status;
276
+ }
277
+ catch {
278
+ // A lagging relay (503 chain-lagging, a stale not-found, a blip): the
279
+ // transactions already happened, so keep going and derive if need be.
280
+ }
281
+ if (attempt < POST_ACTION_READS)
282
+ await done.sleep(POST_ACTION_INTERVAL_MS);
283
+ }
284
+ return derivedStatus(last, done);
285
+ }
286
+ /** The state this run's verified close/reclaim imply, when the relay lags. */
287
+ function derivedStatus(last, done) {
288
+ const reclaimed = done.reclaim !== null;
289
+ const note = "derived from this run's transaction receipts; the refund relay had not caught up with them yet";
290
+ return {
291
+ streamId: done.streamId,
292
+ chainId: last?.chainId ?? 0,
293
+ opener: last?.opener ?? done.opener,
294
+ payer: last?.payer ?? done.payer ?? done.opener,
295
+ status: "closed",
296
+ reclaimed: reclaimed || (last?.reclaimed ?? false),
297
+ chainTime: last?.chainTime ?? null,
298
+ reclaimable: null,
299
+ // Both legs this run verified are on-chain facts: the stream is closed.
300
+ state: "closed",
301
+ onChainStatus: "closed",
302
+ maxRefund: last?.maxRefund ?? null,
303
+ maxRefundMeaning: last?.maxRefundMeaning ?? null,
304
+ reclaimableMeaning: last?.reclaimableMeaning ?? null,
305
+ next: reclaimed
306
+ ? {
307
+ action: "none",
308
+ message: "Reclaimed: the unearned remainder was returned to the payer. Nothing further to do.",
309
+ }
310
+ : {
311
+ action: "reclaim",
312
+ message: "Closed. Reclaim the unearned remainder once a checkpoint covering the close is posted (usually within a minute).",
313
+ },
314
+ raw: { derived: true, note },
315
+ };
316
+ }
317
+ /**
318
+ * Whether the relay's status says nothing is left to recover. `reclaimed` is
319
+ * the chain's one-reclaim-per-stream flag; `next: none` with `reclaimable: 0`
320
+ * is the relay's "the verified delivery covers the whole deposit".
321
+ */
322
+ export function finishedReason(status) {
323
+ if (status.reclaimed)
324
+ return "reclaimed";
325
+ if (status.next.action === "none" && status.reclaimable === 0n) {
326
+ return "nothing-to-reclaim";
327
+ }
328
+ return null;
329
+ }
330
+ /** The relay's 409 codes that mean "finished", not "failed". */
331
+ function finishedFromRelayCode(code) {
332
+ if (code === "already-reclaimed")
333
+ return "reclaimed";
334
+ if (code === "nothing-to-reclaim")
335
+ return "nothing-to-reclaim";
336
+ return null;
337
+ }
338
+ /** The relay's refusal code, looked up through a few `cause` links. */
339
+ export function relayCode(error) {
340
+ for (let current = error, depth = 0; depth < 4; depth++) {
341
+ if (!(current instanceof Error))
342
+ return null;
343
+ const code = current.code;
344
+ if (current.name === "RefundRelayError" && typeof code === "string") {
345
+ return code;
346
+ }
347
+ current = current.cause;
348
+ }
349
+ return null;
350
+ }
351
+ //# sourceMappingURL=refund-actions.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"refund-actions.js","sourceRoot":"","sources":["../../src/x402-buyer/refund-actions.ts"],"names":[],"mappings":"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"}
package/docs/cli.md CHANGED
@@ -605,9 +605,10 @@ everyone who ran it. It is left out until that constraint lifts.
605
605
 
606
606
  ```bash
607
607
  metrik pay https://x402.example/transform --json '{"text":"# hi"}' --max-amount 0.1
608
+ metrik pay https://x402.example/transform --json '{"text":"# hi"}' --max-amount 0.1 --close-after
608
609
  metrik pay <url> [--json <body> | --data <body>] [--method POST] [--header "K: V"] \
609
610
  --max-amount <usdc> [--max-rate <usdc/s>] [--max-upfront <usdc>] [--max-duration <s>] \
610
- [--min-duration <s>] [--operator <address>] [--units atomic]
611
+ [--min-duration <s>] [--operator <address>] [--close-after] [--units atomic]
611
612
  ```
612
613
 
613
614
  Pays any **x402 v2 `exact`** endpoint through `@absol-labs/sdk/x402`'s `payWithX402`,
@@ -679,11 +680,41 @@ facilitator's `PAYMENT-RESPONSE` header (relayed by the seller) carries the sett
679
680
  transaction hash, it is printed as `settlement tx` and returned as
680
681
  `settlementTransaction` with `settlementTransactionSource: "PAYMENT-RESPONSE"`: the
681
682
  facilitator's relayer broadcast it, not the payer, and the CLI reports it as given.
682
- **Refunds are not automatic** — see `metrik refund`.
683
-
684
- Exit codes: `2` usage (including no ceiling), `3` refused by the mandate or the SDK
685
- (nothing signed, including an upfront part above `--max-upfront`), `4` the settlement failed after signing (the error says whether the
686
- authorization was used and how to cancel it; see [x402.md](./x402.md)).
683
+ **Refunds are not automatic** — see `metrik refund`, or ask for the close up front with
684
+ `--close-after`.
685
+
686
+ **Close right after the response (`--close-after`).** The metered part of a payment pays
687
+ per verified second of the seller's uptime, not per call: a one-shot caller who never
688
+ closes lets the seller earn the whole metered part for a single response. With
689
+ `--close-after`, once the paid response is in (the request settled), the CLI does what
690
+ `metrik refund <streamId> --refund-url <url> --close --reclaim` does, in the same run and
691
+ with the same code: it signs the payer's `Close` (the payer key, no gas), the seller's
692
+ refund relay sends it and pays the gas, the CLI waits for the checkpoint covering the close
693
+ (bounded: up to 5 relay attempts, at most 60 s each, honouring `Retry-After`; a relay that
694
+ lags the chain is waited out the same way), then asks the relay to reclaim the unearned
695
+ part to the payer. This is your client acting on your request — the escrow does not
696
+ return anything by itself, and nothing is sent unless you pass the flag. It then prints
697
+ what came back, from the verified receipt: `close tx`, `reclaim tx`, `refunded to payer`,
698
+ `seller earned` (verified seconds) and `payment cost` (price minus refund: the upfront
699
+ part plus what was earned). JSON adds `closeAfter` (`outcome`, `close`, `reclaim`,
700
+ `refunded`, `kept`, `earned`, `finishCommand`, `sentBy: "buyer"`, `refundIsAutomatic:
701
+ false`). An upfront-only payment has nothing to close: it says so and exits `0` with
702
+ `closeAfter.outcome: "nothing-to-close"`. A request that did not settle (nothing paid)
703
+ closes nothing (`closeAfter.outcome: "not-paid"`).
704
+
705
+ If the close or the reclaim does not finish (the relay is down, the checkpoint is late,
706
+ the seller advertised no relay), the payment and the response still stand and nothing is
707
+ lost — the unearned part stays in the escrow for the payer. `pay` then exits **`5`**,
708
+ keeps the full result on stdout (`closeAfter.outcome: "incomplete"`, with the `stage` that
709
+ did not finish), and prints the exact command that finishes it, for example
710
+ `metrik refund <streamId> --refund-url <url> --reclaim` when the stream is already closed.
711
+
712
+ Exit codes: `0` paid (or nothing to pay), `2` usage (including no ceiling), `3` refused by
713
+ the mandate or the SDK (nothing signed, including an upfront part above `--max-upfront`),
714
+ `4` the settlement failed after signing (the error says whether the authorization was used
715
+ and how to cancel it; see [x402.md](./x402.md)), `5` paid and answered, but the
716
+ `--close-after` close/reclaim did not finish (the hint names the command that finishes
717
+ it).
687
718
 
688
719
  ### `metrik refund`
689
720
 
@@ -906,7 +937,8 @@ done
906
937
  ```
907
938
 
908
939
  Exit codes: `0` success, `2` usage error, `3` refused configuration (wrong chain, no
909
- wallet, mandate denied), `4` the operation failed.
940
+ wallet, mandate denied), `4` the operation failed, `5` (`metrik pay --close-after` only)
941
+ paid and answered, but the close/reclaim did not finish.
910
942
 
911
943
  **Nothing waits silently.** Every RPC call carries a 15s budget with two bounded retries,
912
944
  and the oracle fetch — an HTTP call the RPC transport does not cover — has its own 20s
@@ -193,6 +193,29 @@ the stream record carries `nonRefundableUsdc`. An **upfront-only** payment
193
193
  reservation) is recorded — only a non-stream charge that keeps counting against
194
194
  `maxTotalUsdc` and shows in `list_streams` as such — and the payment is final.
195
195
 
196
+ **`closeAfter: true` — close right after the response.** The metered part pays per
197
+ verified second of the seller's uptime, not per call, so a one-shot call that is never
198
+ closed lets the seller earn the whole metered part for one response. With `closeAfter`,
199
+ once the paid response is in, this server does what `refund` with `close: true` does, in
200
+ the same call and with the same code as `metrik refund`: it signs the payer's `Close`
201
+ (the relay sends it and pays the gas), waits — bounded, honouring `Retry-After` — for the
202
+ checkpoint covering the close, and asks the relay to reclaim the unearned part to the
203
+ payer. It is this server acting on the model's request; the escrow does not return
204
+ anything by itself, and nothing is sent unless `closeAfter` is set. The wait runs after
205
+ the owner's spend queue is released, and can add a minute or two to the call. The result
206
+ adds `closeAfter` (`outcome` `completed` / `nothing-to-close` / `incomplete`, `close`,
207
+ `reclaim` with `amountUsdc` and `verified`, `refundedUsdc`, `incomplete.stage`,
208
+ `sentBy: "buyer"`, `refundIsAutomatic: false`) and `countedSpendUsdc`.
209
+
210
+ _Spend accounting:_ only what the payment finally kept counts against the mandate. A
211
+ reclaim whose mined receipt is verified (`ReclaimForwarded` to the payer) is recorded on
212
+ the stream as `reclaimedUsdc`, and cumulative spend counts `budgetUsdc - reclaimedUsdc`
213
+ for it (persisted by the file registry, shown by `list_streams`). A relay-reported amount
214
+ that nothing verified is reported but never credited back. If the close or the reclaim
215
+ does not finish, the tool still succeeds (the payment and the response stand), counts the
216
+ full price, and the text says which `refund` call finishes it. An upfront-only payment has
217
+ nothing to close: `closeAfter.outcome` is `nothing-to-close` and no relay is contacted.
218
+
196
219
  ### `refund_status` / `refund`
197
220
 
198
221
  Arguments: `streamId`, optional `refundUrl` (not needed for a stream this server
package/docs/x402.md CHANGED
@@ -652,8 +652,9 @@ chain and before anything is signed — and the caps are also passed to the SDK
652
652
  (`X402MandateDeniedError` reason `not-an-opener-quote`).
653
653
 
654
654
  For an upfront quote the mandate counts the WHOLE price, upfront part included,
655
- as spend (`budgetUsdc = price`); that part is non-recoverable and nothing a
656
- refund returns is credited back. Its size is also capped separately before
655
+ as spend (`budgetUsdc = price`); that part is non-recoverable, and a refund is
656
+ credited back only in the one case below (an MCP `closeAfter` reclaim proven by
657
+ its receipt). Its size is also capped separately before
657
658
  signing by `maxUpfrontAmount` (`metrik pay --max-upfront`, MCP
658
659
  `maxUpfrontAmountUsdc`; default 0.10 USDC, `0` refuses any upfront charge). The
659
660
  split is shown before signing and in the result. An upfront-only payment
@@ -665,13 +666,52 @@ non-stream charge that still counts against the mandate.
665
666
  ```bash
666
667
  metrik pay https://x402.137.23.50.249.sslip.io/transform --json '{"text":"# hi"}' --max-amount 0.1
667
668
  metrik refund <streamId> --refund-url <url printed above> --close --reclaim
669
+
670
+ # one-shot call: close and reclaim in the same run
671
+ metrik pay https://x402.137.23.50.249.sslip.io/transform --json '{"text":"# hi"}' \
672
+ --max-amount 0.1 --max-upfront 0.02 --close-after
668
673
  ```
669
674
 
670
675
  The MCP server offers the same as `x402_pay`, `refund_status` and `refund`
671
676
  (see [mcp-reference.md](./mcp-reference.md)); `x402_pay` shares the server
672
677
  mandate and cumulative spend with `hire_verified_service`. Refunds are still
673
678
  **requested, never pushed**: `metrik refund` / `refund` call the relay; nothing
674
- calls it for you.
679
+ calls it for you unless you ask for it.
680
+
681
+ ### Closing right after the response (`--close-after`, `closeAfter`)
682
+
683
+ The metered part of a payment pays per verified second of the seller's uptime,
684
+ not per call. A one-shot caller that never closes lets the seller earn the whole
685
+ metered part for a single response (testers measured never closing at 4x the
686
+ cost of closing quickly); a caller that closes immediately gets back everything
687
+ unearned. So the buyer can ask for the close up front:
688
+
689
+ - `metrik pay … --close-after`
690
+ - MCP `x402_pay` with `closeAfter: true`
691
+ - `payX402WithMandate({ …, closeAfter: true | { verifyWith, maxAttempts, maxDelayMs } })`,
692
+ or `closeAfterPayment(…)` on an existing payment (`@absol-labs/agent/x402-buyer`)
693
+
694
+ After a successful paid response, the buyer's own client runs exactly what
695
+ `metrik refund --close --reclaim` runs (one shared implementation): the payer
696
+ signs the opener's `Close`, the seller's refund relay sends it and pays the gas,
697
+ the client waits — bounded, honouring the relay's `Retry-After` — for the
698
+ checkpoint covering the close, then asks the relay for the permissionless
699
+ reclaim, which the opener always pays to the payer. This is not an automatic
700
+ refund: it is the buyer's client sending the buyer's close and reclaim, because
701
+ the buyer asked; without the option nothing is sent. The payer still broadcasts
702
+ nothing and needs no gas.
703
+
704
+ What came back is reported from the verified receipt (`ReclaimForwarded` to the
705
+ payer): the refund, what the seller earned, and what the payment finally cost
706
+ (`price - refund` = the upfront part plus the earned seconds). An upfront-only
707
+ payment has nothing to close (exit 0 / `nothing-to-close`); a request that never
708
+ settled closes nothing. If the close or the reclaim does not finish, the payment
709
+ and the response stand and nothing is lost — the unearned part stays escrowed for
710
+ the payer: `metrik pay` exits `5` and prints the exact `metrik refund …` command
711
+ that finishes it; the MCP tool still succeeds and names the `refund` call; the
712
+ programmatic outcome is `incomplete` with the `stage`. In MCP, a verified reclaim
713
+ is recorded as the stream's `reclaimedUsdc`, so only what the payment kept counts
714
+ against the spend mandate.
675
715
 
676
716
  The facilitator's `PAYMENT-RESPONSE` `extra.metrikStreamOpener.expiresAt` is a
677
717
  JSON **number** (it was a decimal string before metrik-agent#121, which the SDK's
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@absol-labs/agent",
3
- "version": "0.12.0-rc.3",
3
+ "version": "0.12.0-rc.4",
4
4
  "description": "Metrik agent layer: verified-streaming USDC payments metered by delivery, an MCP server, framework tools, and spend mandates so AI agents can hire and pay verified services safely.",
5
5
  "license": "MIT",
6
6
  "author": "Absol Labs",
@@ -68,42 +68,53 @@ export const claimCommand: CommandSpec = {
68
68
  );
69
69
  }
70
70
 
71
- const plan = await planClaim(
72
- read,
73
- streamId,
74
- stream,
75
- escrowGenerationFor(config.chainId, config.escrow),
76
- );
77
- if (plan.kind === "nothing") throw plan.error;
78
-
79
71
  const write = deps.createWriteClient(
80
72
  config,
81
73
  account,
82
74
  writeClientOptions(wallet),
83
75
  );
84
- const action = {
85
- streamId,
86
- // `null` for a policy-enforced wallet — see `../spend-policy.ts`.
87
- signedMandate: isPolicyEnforced(wallet)
88
- ? null
89
- : await signClaimMandate(account, config.chainId),
90
- spentSoFarUsdc: 0n,
91
- nowSeconds: deps.nowSeconds(),
92
- };
76
+ const generation = escrowGenerationFor(config.chainId, config.escrow);
77
+ // The oracle can post (or the escrow retain) a newer checkpoint between
78
+ // this plan and the transaction, so a proof read moments ago can revert
79
+ // `InvalidProof`. Nothing was spent on a reverted simulation: re-plan from
80
+ // fresh chain and proof state and try again, a bounded number of times.
81
+ let plan: Extract<ClaimPlan, { kind: "claimWithProof" | "claimProven" }>;
93
82
  let result: { readonly txHash: `0x${string}` };
94
- if (plan.kind === "claimWithProof") {
95
- result = await write.claimStream(action);
96
- } else {
97
- if (write.claimProvenStream === undefined) {
98
- throw new CliError(
99
- "this write client cannot send claimProven, which is the only way to collect this stream's proven value",
100
- {
101
- exitCode: 3,
102
- hint: "no current checkpoint proof pays it, so a proof claim would revert NothingToClaim",
103
- },
104
- );
83
+ for (let attempt = 1; ; attempt += 1) {
84
+ const planned = await planClaim(read, streamId, stream, generation);
85
+ if (planned.kind === "nothing") throw planned.error;
86
+ plan = planned;
87
+ const action = {
88
+ streamId,
89
+ // `null` for a policy-enforced wallet — see `../spend-policy.ts`.
90
+ signedMandate: isPolicyEnforced(wallet)
91
+ ? null
92
+ : await signClaimMandate(account, config.chainId),
93
+ spentSoFarUsdc: 0n,
94
+ nowSeconds: deps.nowSeconds(),
95
+ };
96
+ try {
97
+ if (plan.kind === "claimWithProof") {
98
+ result = await write.claimStream(action);
99
+ } else {
100
+ if (write.claimProvenStream === undefined) {
101
+ throw new CliError(
102
+ "this write client cannot send claimProven, which is the only way to collect this stream's proven value",
103
+ {
104
+ exitCode: 3,
105
+ hint: "no current checkpoint proof pays it, so a proof claim would revert NothingToClaim",
106
+ },
107
+ );
108
+ }
109
+ result = await write.claimProvenStream(action);
110
+ }
111
+ break;
112
+ } catch (error) {
113
+ if (attempt >= CLAIM_REPLAN_ATTEMPTS || !isStaleProofError(error)) {
114
+ throw error;
115
+ }
116
+ await deps.sleep(CLAIM_REPLAN_DELAY_MS);
105
117
  }
106
- result = await write.claimProvenStream(action);
107
118
  }
108
119
 
109
120
  out.result({
@@ -131,6 +142,21 @@ export const claimCommand: CommandSpec = {
131
142
  },
132
143
  };
133
144
 
145
+ /** How many plan-then-send rounds `metrik claim` makes before giving up. */
146
+ const CLAIM_REPLAN_ATTEMPTS = 3;
147
+ /** Pause between rounds, so a just-posted checkpoint can settle on the RPC. */
148
+ const CLAIM_REPLAN_DELAY_MS = 3_000;
149
+
150
+ /**
151
+ * A claim reverted because its proof no longer matches a root the escrow
152
+ * accepts (a checkpoint rotated, or a lagging node served a not-yet-visible
153
+ * root). Re-planning reads a fresh proof, so it is worth one more round.
154
+ */
155
+ export function isStaleProofError(error: unknown): boolean {
156
+ const text = describeError(error);
157
+ return /InvalidProof|fresh checkpoint proof/.test(text);
158
+ }
159
+
134
160
  /**
135
161
  * Which claim, if any, `metrik claim` would send. Reads only, so it is
136
162
  * testable (and checkable against a live stream) without a key.