@absol-labs/agent 0.12.0-rc.2 → 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 (86) hide show
  1. package/dist/cli/commands/claim.d.ts +15 -0
  2. package/dist/cli/commands/claim.d.ts.map +1 -1
  3. package/dist/cli/commands/claim.js +65 -23
  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 +23 -12
  10. package/dist/cli/commands/refund.d.ts.map +1 -1
  11. package/dist/cli/commands/refund.js +104 -131
  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/cli/status.d.ts +6 -0
  21. package/dist/cli/status.d.ts.map +1 -1
  22. package/dist/cli/status.js +25 -6
  23. package/dist/cli/status.js.map +1 -1
  24. package/dist/gateway/x402-opener-mode.d.ts.map +1 -1
  25. package/dist/gateway/x402-opener-mode.js +104 -7
  26. package/dist/gateway/x402-opener-mode.js.map +1 -1
  27. package/dist/mcp/server.d.ts +10 -0
  28. package/dist/mcp/server.d.ts.map +1 -1
  29. package/dist/mcp/server.js +21 -1
  30. package/dist/mcp/server.js.map +1 -1
  31. package/dist/mcp/x402-tools.d.ts +7 -1
  32. package/dist/mcp/x402-tools.d.ts.map +1 -1
  33. package/dist/mcp/x402-tools.js +159 -6
  34. package/dist/mcp/x402-tools.js.map +1 -1
  35. package/dist/x402-buyer/index.d.ts +1 -0
  36. package/dist/x402-buyer/index.d.ts.map +1 -1
  37. package/dist/x402-buyer/index.js +1 -0
  38. package/dist/x402-buyer/index.js.map +1 -1
  39. package/dist/x402-buyer/pay.d.ts +38 -2
  40. package/dist/x402-buyer/pay.d.ts.map +1 -1
  41. package/dist/x402-buyer/pay.js +52 -2
  42. package/dist/x402-buyer/pay.js.map +1 -1
  43. package/dist/x402-buyer/refund-actions.d.ts +168 -0
  44. package/dist/x402-buyer/refund-actions.d.ts.map +1 -0
  45. package/dist/x402-buyer/refund-actions.js +351 -0
  46. package/dist/x402-buyer/refund-actions.js.map +1 -0
  47. package/dist/x402-opener/block-floors.d.ts +35 -0
  48. package/dist/x402-opener/block-floors.d.ts.map +1 -0
  49. package/dist/x402-opener/block-floors.js +72 -0
  50. package/dist/x402-opener/block-floors.js.map +1 -0
  51. package/dist/x402-opener/facilitator-server.d.ts.map +1 -1
  52. package/dist/x402-opener/facilitator-server.js +7 -0
  53. package/dist/x402-opener/facilitator-server.js.map +1 -1
  54. package/dist/x402-opener/index.d.ts +1 -0
  55. package/dist/x402-opener/index.d.ts.map +1 -1
  56. package/dist/x402-opener/index.js +1 -0
  57. package/dist/x402-opener/index.js.map +1 -1
  58. package/dist/x402-opener/refund-relay.d.ts +62 -1
  59. package/dist/x402-opener/refund-relay.d.ts.map +1 -1
  60. package/dist/x402-opener/refund-relay.js +387 -208
  61. package/dist/x402-opener/refund-relay.js.map +1 -1
  62. package/dist/x402-opener/scheme.d.ts +8 -0
  63. package/dist/x402-opener/scheme.d.ts.map +1 -1
  64. package/dist/x402-opener/scheme.js +14 -0
  65. package/dist/x402-opener/scheme.js.map +1 -1
  66. package/docs/cli.md +62 -9
  67. package/docs/mcp-reference.md +23 -0
  68. package/docs/x402.md +126 -29
  69. package/package.json +2 -2
  70. package/src/cli/commands/claim.ts +72 -30
  71. package/src/cli/commands/pay.ts +299 -51
  72. package/src/cli/commands/refund.ts +133 -143
  73. package/src/cli/deps.ts +9 -0
  74. package/src/cli/errors.ts +8 -1
  75. package/src/cli/status.ts +28 -7
  76. package/src/gateway/x402-opener-mode.ts +131 -6
  77. package/src/mcp/server.ts +30 -1
  78. package/src/mcp/x402-tools.ts +222 -7
  79. package/src/x402-buyer/index.ts +1 -0
  80. package/src/x402-buyer/pay.ts +99 -2
  81. package/src/x402-buyer/refund-actions.ts +486 -0
  82. package/src/x402-opener/block-floors.ts +78 -0
  83. package/src/x402-opener/facilitator-server.ts +7 -0
  84. package/src/x402-opener/index.ts +1 -0
  85. package/src/x402-opener/refund-relay.ts +486 -217
  86. package/src/x402-opener/scheme.ts +38 -0
package/docs/x402.md CHANGED
@@ -145,9 +145,14 @@ instance, ever**:
145
145
  the mined outcome; it receives the one success only if none was delivered
146
146
  before.
147
147
  - The gateway enforces the same rule on its side: a `PAYMENT-SIGNATURE` whose
148
- authorization is in flight or already settled gets `409
149
- x402-payment-already-used` **without** an upstream call; a retry after
150
- `settlement_pending` is let through so it can reconcile.
148
+ authorization is in flight or already settled is refused **without** a
149
+ verify, an upstream call or a settlement. The refusal has the x402 v2 shape:
150
+ `402` with a fresh `PAYMENT-REQUIRED` whose `error` is
151
+ `x402-payment-already-used` (body `{"authorized": false, "reason":
152
+ "x402-payment-already-used", …}`), so a stock client can sign a NEW payment
153
+ or report the reason. (It was a bare `409` before; the strictness is
154
+ unchanged.) A retry after `settlement_pending` is let through so it can
155
+ reconcile.
151
156
  - Relayer submissions (simulate + broadcast) are serialized, so concurrent
152
157
  payers never collide on the relayer's account nonce.
153
158
 
@@ -362,6 +367,56 @@ no proof that matches the escrow's on-chain latest root is available (with an
362
367
  shape), `wait` (with `retryAt` / `retryAfterSeconds`), `reclaim`,
363
368
  `reclaim-unverified` (with `typedData`), or `none`.
364
369
 
370
+ How to read the fields:
371
+
372
+ - `status` is the escrow's own on-chain status, verbatim (`active` / `closed`).
373
+ A stream past `expiresAt` that nobody closed is still `active` on-chain;
374
+ `state` says what it means now: `active` (running), `expired` (past
375
+ `expiresAt`, accrues nothing more, needs no close) or `closed`.
376
+ `onChainStatus` repeats the raw value.
377
+ - `maxRefund` is a **ceiling**: the most this payment could ever refund (the
378
+ escrowed deposit), whatever was delivered. `reclaimable` is what a reclaim
379
+ returns **now**, given the latest verified delivery: `0` with `maxRefund:
380
+ 45000` means the verified delivery covers the whole deposit, not an error.
381
+ `maxRefundMeaning` and `reclaimableMeaning` say this in the response itself.
382
+
383
+ **Right after paying.** Every answer is read at one block, and never below the
384
+ block of a transaction the facilitator saw mined for that stream (its
385
+ settlement, or a close/reclaim the relay sent), so a load-balanced RPC whose
386
+ nodes lag each other cannot make a just-opened stream look unknown or a
387
+ just-closed one look active. The relay waits, bounded (8 s by default), for the
388
+ RPC to reach that block, then answers `503 chain-lagging` with `Retry-After`
389
+ rather than a wrong answer. A miss for a stream it has no such block for
390
+ (e.g. after a restart) is re-read once, a second later, before it is `404`. A
391
+ client should still treat `404 not-found` and `503` right after paying as
392
+ "retry shortly", and guard the body before reading `next`:
393
+
394
+ ```ts
395
+ // `opened` is PAYMENT-RESPONSE extra.metrikStreamOpener.
396
+ const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
397
+ async function relayStatus(refundUrl: string) {
398
+ for (let attempt = 0; attempt < 6; attempt++) {
399
+ const res = await fetch(refundUrl);
400
+ const body = await res.json().catch(() => undefined);
401
+ if (res.ok && body?.next !== undefined) return body;
402
+ // Just settled: the relay's RPC may not have caught up yet.
403
+ if (res.status === 404 || res.status === 503 || res.status === 429) {
404
+ await sleep(Number(res.headers.get("retry-after") ?? "2") * 1000);
405
+ continue;
406
+ }
407
+ throw new Error(`refund relay ${res.status}: ${body?.error ?? "no body"}`);
408
+ }
409
+ throw new Error("refund relay did not serve the stream; retry later");
410
+ }
411
+ const status = await relayStatus(opened.refundUrl);
412
+ if (status.next?.action === "close") {
413
+ // sign status.next.typedData (with your deadline) and POST it to `${refundUrl}/close`
414
+ }
415
+ ```
416
+
417
+ `@absol-labs/sdk`'s `refundClient` (≤ 0.10.0-rc.3) does not retry a status
418
+ read; `metrik refund` re-reads it itself after acting (see `docs/cli.md`).
419
+
365
420
  A successful relayed transaction answers `200` only from its **mined** receipt:
366
421
 
367
422
  ```json
@@ -375,18 +430,19 @@ A successful relayed transaction answers `200` only from its **mined** receipt:
375
430
  `{"ok": false, "error": "<code>", "message": "…", …}`, with `Retry-After`
376
431
  where a retry makes sense:
377
432
 
378
- | Status | `error` | Meaning |
379
- | ------ | ----------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------- |
380
- | 400 | `invalid-stream-id`, `invalid-request`, `invalid-json` | Malformed input |
381
- | 403 | `invalid-signature`, `signature-expired` | Not the payer's EIP-712 signature for this opener, stream and nonce; or deadline too near |
382
- | 404 | `not-found` | Unknown stream, or its buyer is not an opener of the configured factory |
383
- | 409 | `already-closed`, `stream-expired`, `already-reclaimed`, `nothing-to-reclaim`, `nonce-mismatch` | Would have no effect; nothing sent |
384
- | 409 | `not-reclaimable-yet`, `settlement-window-open`, `proof-unavailable`, `escape-hatch-not-ready` | Not yet; `retryAt` (unix s) says when at the latest |
385
- | 409 | `simulation-failed`, `in-progress`, `blocked` | The simulation reverted; a relayed tx is in flight; an earlier tx had an unexpected outcome |
386
- | 202 | `pending` | Sent, not mined yet (`transaction`); repeat the same request — it is never re-sent |
387
- | 429 | `rate-limited` | Per-IP, per-stream or global budget exhausted |
388
- | 502 | `transaction-reverted`, `unexpected-receipt`, `submission-failed` | Never a success without the expected mined receipt |
389
- | 503 | `chain-unavailable`, `oracle-unavailable`, `opener-domain-mismatch` | Fail closed |
433
+ | Status | `error` | Meaning |
434
+ | ------ | ----------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------- |
435
+ | 400 | `invalid-stream-id`, `invalid-request`, `invalid-json` | Malformed input |
436
+ | 403 | `invalid-signature`, `signature-expired` | Not the payer's EIP-712 signature for this opener, stream and nonce; or deadline too near |
437
+ | 404 | `not-found` | Unknown stream, or its buyer is not an opener of the configured factory (confirmed on-chain) |
438
+ | 409 | `already-closed`, `stream-expired`, `already-reclaimed`, `nothing-to-reclaim`, `nonce-mismatch` | Would have no effect; nothing sent |
439
+ | 409 | `not-reclaimable-yet`, `settlement-window-open`, `proof-unavailable`, `escape-hatch-not-ready` | Not yet; `retryAt` (unix s) says when at the latest |
440
+ | 409 | `simulation-failed`, `in-progress`, `blocked` | The simulation reverted; a relayed tx is in flight; an earlier tx had an unexpected outcome |
441
+ | 202 | `pending` | Sent, not mined yet (`transaction`); repeat the same request — it is never re-sent |
442
+ | 429 | `rate-limited` | Per-IP, per-stream or global budget exhausted |
443
+ | 502 | `transaction-reverted`, `unexpected-receipt`, `submission-failed` | Never a success without the expected mined receipt |
444
+ | 503 | `chain-unavailable`, `oracle-unavailable`, `opener-domain-mismatch` | Fail closed |
445
+ | 503 | `chain-lagging` | The RPC is still below a block the stream changed in (`minBlock`); retry after `Retry-After` |
390
446
 
391
447
  ### Bounded against gas abuse
392
448
 
@@ -556,17 +612,18 @@ informational http(s) links on top of the terms, never uses them for
556
612
  settlement, and still refuses any other key. "Verified" here means verified
557
613
  delivery, not verified output.
558
614
 
559
- | Request carries | Result |
560
- | ---------------------------------------------------------- | ------------------------------------------------------------------------------------- |
561
- | nothing | `402`, `PAYMENT-REQUIRED` quoting the opener `payTo` and the price |
562
- | `PAYMENT-SIGNATURE` | verified → upstream → settled if upstream `< 400` → `200` + body + `PAYMENT-RESPONSE` |
563
- | `PAYMENT-SIGNATURE`, upstream `>= 400` | upstream response, nothing settled |
564
- | `PAYMENT-SIGNATURE`, settle fails | `402` + `PAYMENT-RESPONSE`, no upstream body |
565
- | `x-metrik-capability` | the capability path, unchanged |
566
- | capability **and** `PAYMENT-SIGNATURE` | `400 ambiguous-capability-and-payment` |
567
- | probe capability **and** `PAYMENT-SIGNATURE` | `400 ambiguous-probe-mode` |
568
- | any request while the facilitator marker cannot be proven | `503 x402-facilitator-unavailable` |
569
- | a `PAYMENT-SIGNATURE` already in flight or already settled | `409 x402-payment-already-used`, no upstream call |
615
+ | Request carries | Result |
616
+ | --------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- |
617
+ | nothing | `402`, `PAYMENT-REQUIRED` quoting the opener `payTo` and the price |
618
+ | `PAYMENT-SIGNATURE` | verified → upstream → settled if upstream `< 400` → `200` + body + `PAYMENT-RESPONSE` |
619
+ | `PAYMENT-SIGNATURE`, upstream `>= 400` | upstream response, nothing settled |
620
+ | `PAYMENT-SIGNATURE`, verify refuses it (e.g. `validBefore` too short) | `402`, fresh `PAYMENT-REQUIRED` whose `error` is the reason; body `{"authorized": false, "reason": "x402-payment-invalid", "error": …}` |
621
+ | `PAYMENT-SIGNATURE`, settle fails | `402` + `PAYMENT-RESPONSE` (`success: false`, `errorReason`), no upstream body; body `{"reason": "x402-settlement-failed", "error": …}` |
622
+ | `x-metrik-capability` | the capability path, unchanged |
623
+ | capability **and** `PAYMENT-SIGNATURE` | `400 ambiguous-capability-and-payment` |
624
+ | probe capability **and** `PAYMENT-SIGNATURE` | `400 ambiguous-probe-mode` |
625
+ | any request while the facilitator marker cannot be proven | `503 x402-facilitator-unavailable` |
626
+ | a `PAYMENT-SIGNATURE` already in flight or already settled | `402`, fresh `PAYMENT-REQUIRED` with `error: "x402-payment-already-used"`; no verify, upstream call or settle |
570
627
 
571
628
  With `METRIK_GATEWAY_X402V2_OPENER` unset the gateway is byte for byte the
572
629
  capability-only gateway (`test/gateway-http-baseline.test.ts`), and the
@@ -595,8 +652,9 @@ chain and before anything is signed — and the caps are also passed to the SDK
595
652
  (`X402MandateDeniedError` reason `not-an-opener-quote`).
596
653
 
597
654
  For an upfront quote the mandate counts the WHOLE price, upfront part included,
598
- as spend (`budgetUsdc = price`); that part is non-recoverable and nothing a
599
- 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
600
658
  signing by `maxUpfrontAmount` (`metrik pay --max-upfront`, MCP
601
659
  `maxUpfrontAmountUsdc`; default 0.10 USDC, `0` refuses any upfront charge). The
602
660
  split is shown before signing and in the result. An upfront-only payment
@@ -608,13 +666,52 @@ non-stream charge that still counts against the mandate.
608
666
  ```bash
609
667
  metrik pay https://x402.137.23.50.249.sslip.io/transform --json '{"text":"# hi"}' --max-amount 0.1
610
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
611
673
  ```
612
674
 
613
675
  The MCP server offers the same as `x402_pay`, `refund_status` and `refund`
614
676
  (see [mcp-reference.md](./mcp-reference.md)); `x402_pay` shares the server
615
677
  mandate and cumulative spend with `hire_verified_service`. Refunds are still
616
678
  **requested, never pushed**: `metrik refund` / `refund` call the relay; nothing
617
- 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.
618
715
 
619
716
  The facilitator's `PAYMENT-RESPONSE` `extra.metrikStreamOpener.expiresAt` is a
620
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.2",
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",
@@ -85,7 +85,7 @@
85
85
  "pnpm": "9.15.x"
86
86
  },
87
87
  "dependencies": {
88
- "@absol-labs/sdk": "0.10.0-rc.3",
88
+ "@absol-labs/sdk": "0.10.0-rc.4",
89
89
  "@absol-labs/shared": "0.14.0-rc.2",
90
90
  "@modelcontextprotocol/sdk": "^1.29.0",
91
91
  "@napi-rs/keyring": "1.3.0",
@@ -63,47 +63,58 @@ export const claimCommand: CommandSpec = {
63
63
  `wallet ${account.address} is not the operator of this stream`,
64
64
  {
65
65
  exitCode: 3,
66
- hint: `only ${stream.operator} can claim it — the escrow enforces this, so no other wallet can. Set ${PRIVATE_KEY_ENV} to the operator's key. (The buyer's side of this stream is \`metrik reclaim ${streamId}\`.)`,
66
+ hint: `only ${stream.operator} can claim it — the escrow enforces this, so no other wallet can. Set ${PRIVATE_KEY_ENV} to the operator's key. (The buyer's side of this stream is \`${buyerSideCommand(stream, streamId, deps.nowSeconds())}\`.)`,
67
67
  },
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.
@@ -259,3 +285,19 @@ export async function planClaim(
259
285
  }),
260
286
  };
261
287
  }
288
+
289
+ /**
290
+ * The buyer's recovery command for a stream. A reclaim needs the clock
291
+ * stopped (closed, or past expiresAt), so a still-running stream gets
292
+ * `--close-first`; the plain form only once it has stopped.
293
+ */
294
+ export function buyerSideCommand(
295
+ stream: { readonly status: string; readonly expiresAt: number },
296
+ streamId: string,
297
+ nowSeconds: number,
298
+ ): string {
299
+ const stopped = stream.status === "closed" || nowSeconds >= stream.expiresAt;
300
+ return stopped
301
+ ? `metrik reclaim ${streamId}`
302
+ : `metrik reclaim ${streamId} --close-first`;
303
+ }