@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.
- package/dist/cli/commands/claim.d.ts +15 -0
- package/dist/cli/commands/claim.d.ts.map +1 -1
- package/dist/cli/commands/claim.js +65 -23
- 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 +23 -12
- package/dist/cli/commands/refund.d.ts.map +1 -1
- package/dist/cli/commands/refund.js +104 -131
- 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/cli/status.d.ts +6 -0
- package/dist/cli/status.d.ts.map +1 -1
- package/dist/cli/status.js +25 -6
- package/dist/cli/status.js.map +1 -1
- package/dist/gateway/x402-opener-mode.d.ts.map +1 -1
- package/dist/gateway/x402-opener-mode.js +104 -7
- package/dist/gateway/x402-opener-mode.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/dist/x402-opener/block-floors.d.ts +35 -0
- package/dist/x402-opener/block-floors.d.ts.map +1 -0
- package/dist/x402-opener/block-floors.js +72 -0
- package/dist/x402-opener/block-floors.js.map +1 -0
- package/dist/x402-opener/facilitator-server.d.ts.map +1 -1
- package/dist/x402-opener/facilitator-server.js +7 -0
- package/dist/x402-opener/facilitator-server.js.map +1 -1
- package/dist/x402-opener/index.d.ts +1 -0
- package/dist/x402-opener/index.d.ts.map +1 -1
- package/dist/x402-opener/index.js +1 -0
- package/dist/x402-opener/index.js.map +1 -1
- package/dist/x402-opener/refund-relay.d.ts +62 -1
- package/dist/x402-opener/refund-relay.d.ts.map +1 -1
- package/dist/x402-opener/refund-relay.js +387 -208
- package/dist/x402-opener/refund-relay.js.map +1 -1
- package/dist/x402-opener/scheme.d.ts +8 -0
- package/dist/x402-opener/scheme.d.ts.map +1 -1
- package/dist/x402-opener/scheme.js +14 -0
- package/dist/x402-opener/scheme.js.map +1 -1
- package/docs/cli.md +62 -9
- package/docs/mcp-reference.md +23 -0
- package/docs/x402.md +126 -29
- package/package.json +2 -2
- package/src/cli/commands/claim.ts +72 -30
- package/src/cli/commands/pay.ts +299 -51
- package/src/cli/commands/refund.ts +133 -143
- package/src/cli/deps.ts +9 -0
- package/src/cli/errors.ts +8 -1
- package/src/cli/status.ts +28 -7
- package/src/gateway/x402-opener-mode.ts +131 -6
- 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
- package/src/x402-opener/block-floors.ts +78 -0
- package/src/x402-opener/facilitator-server.ts +7 -0
- package/src/x402-opener/index.ts +1 -0
- package/src/x402-opener/refund-relay.ts +486 -217
- 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
|
|
149
|
-
|
|
150
|
-
`
|
|
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
|
|
560
|
-
|
|
|
561
|
-
| nothing
|
|
562
|
-
| `PAYMENT-SIGNATURE`
|
|
563
|
-
| `PAYMENT-SIGNATURE`, upstream `>= 400`
|
|
564
|
-
| `PAYMENT-SIGNATURE`,
|
|
565
|
-
| `
|
|
566
|
-
|
|
|
567
|
-
|
|
|
568
|
-
|
|
|
569
|
-
|
|
|
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
|
|
599
|
-
|
|
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.
|
|
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.
|
|
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
|
|
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
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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
|
+
}
|