@absol-labs/agent 0.12.0-rc.2 → 0.12.0-rc.3
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 +9 -0
- package/dist/cli/commands/claim.d.ts.map +1 -1
- package/dist/cli/commands/claim.js +12 -1
- package/dist/cli/commands/claim.js.map +1 -1
- package/dist/cli/commands/refund.d.ts +2 -0
- package/dist/cli/commands/refund.d.ts.map +1 -1
- package/dist/cli/commands/refund.js +126 -8
- package/dist/cli/commands/refund.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/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 +23 -2
- package/docs/x402.md +83 -26
- package/package.json +2 -2
- package/src/cli/commands/claim.ts +17 -1
- package/src/cli/commands/refund.ts +149 -8
- package/src/cli/status.ts +28 -7
- package/src/gateway/x402-opener-mode.ts +131 -6
- 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
|
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.3",
|
|
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,7 +63,7 @@ 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
|
}
|
|
@@ -259,3 +259,19 @@ export async function planClaim(
|
|
|
259
259
|
}),
|
|
260
260
|
};
|
|
261
261
|
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* The buyer's recovery command for a stream. A reclaim needs the clock
|
|
265
|
+
* stopped (closed, or past expiresAt), so a still-running stream gets
|
|
266
|
+
* `--close-first`; the plain form only once it has stopped.
|
|
267
|
+
*/
|
|
268
|
+
export function buyerSideCommand(
|
|
269
|
+
stream: { readonly status: string; readonly expiresAt: number },
|
|
270
|
+
streamId: string,
|
|
271
|
+
nowSeconds: number,
|
|
272
|
+
): string {
|
|
273
|
+
const stopped = stream.status === "closed" || nowSeconds >= stream.expiresAt;
|
|
274
|
+
return stopped
|
|
275
|
+
? `metrik reclaim ${streamId}`
|
|
276
|
+
: `metrik reclaim ${streamId} --close-first`;
|
|
277
|
+
}
|
|
@@ -25,6 +25,15 @@ export const REFUND_URL_ENV = "METRIK_AGENT_REFUND_URL";
|
|
|
25
25
|
|
|
26
26
|
const DEFAULT_ATTEMPTS = 5;
|
|
27
27
|
const DEFAULT_MAX_WAIT_SECONDS = 60;
|
|
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;
|
|
28
37
|
|
|
29
38
|
export const refundCommand: CommandSpec = {
|
|
30
39
|
name: "refund",
|
|
@@ -186,7 +195,17 @@ export const refundCommand: CommandSpec = {
|
|
|
186
195
|
}
|
|
187
196
|
}
|
|
188
197
|
|
|
189
|
-
|
|
198
|
+
// The summary describes the state AFTER what this run did: re-read until
|
|
199
|
+
// the relay reflects the close/reclaim just mined (bounded), else derive
|
|
200
|
+
// it from the verified receipts. Never the pre-action state.
|
|
201
|
+
const status = await statusAfterActions(refunds, {
|
|
202
|
+
closed: closeResult !== null,
|
|
203
|
+
reclaim: reclaimResult,
|
|
204
|
+
payer: stream.payer ?? null,
|
|
205
|
+
opener,
|
|
206
|
+
streamId,
|
|
207
|
+
sleep: deps.sleep,
|
|
208
|
+
});
|
|
190
209
|
// A reclaim THIS run performed is reported as a reclaim, not as "already
|
|
191
210
|
// finished".
|
|
192
211
|
const finished =
|
|
@@ -197,7 +216,10 @@ export const refundCommand: CommandSpec = {
|
|
|
197
216
|
streamId,
|
|
198
217
|
opener,
|
|
199
218
|
payer: status.payer,
|
|
219
|
+
// The escrow's own on-chain status, verbatim.
|
|
200
220
|
status: status.status,
|
|
221
|
+
// What it means now: an Active stream past expiresAt is "expired".
|
|
222
|
+
state: stateOf(status),
|
|
201
223
|
reclaimed: status.reclaimed,
|
|
202
224
|
reclaimable: status.reclaimable,
|
|
203
225
|
next: status.next,
|
|
@@ -233,13 +255,115 @@ export const refundCommand: CommandSpec = {
|
|
|
233
255
|
);
|
|
234
256
|
out.print("");
|
|
235
257
|
out.print(
|
|
236
|
-
|
|
237
|
-
?
|
|
238
|
-
:
|
|
258
|
+
reclaimResult !== null
|
|
259
|
+
? describeReclaimed(reclaimResult)
|
|
260
|
+
: finished === null
|
|
261
|
+
? nextStep(status, streamId, refundUrl)
|
|
262
|
+
: describeFinished(finished),
|
|
239
263
|
);
|
|
240
264
|
},
|
|
241
265
|
};
|
|
242
266
|
|
|
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
|
+
/** `closed`, `expired` (Active on-chain but past expiresAt) or `active`. */
|
|
349
|
+
export function stateOf(status: RefundStatus): "active" | "expired" | "closed" {
|
|
350
|
+
if (status.status === "closed" || status.reclaimed) return "closed";
|
|
351
|
+
const raw = status.raw as { state?: unknown; expiresAt?: unknown };
|
|
352
|
+
if (raw.state === "expired") return "expired";
|
|
353
|
+
if (
|
|
354
|
+
typeof raw.expiresAt === "number" &&
|
|
355
|
+
status.chainTime !== null &&
|
|
356
|
+
status.chainTime >= raw.expiresAt
|
|
357
|
+
) {
|
|
358
|
+
return "expired";
|
|
359
|
+
}
|
|
360
|
+
return "active";
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
function describeReclaimed(result: RefundRelayResult): string {
|
|
364
|
+
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
|
+
}
|
|
366
|
+
|
|
243
367
|
/** Why a recovery is over. */
|
|
244
368
|
export type FinishedReason = "reclaimed" | "nothing-to-reclaim";
|
|
245
369
|
/** Why a requested `--close` / `--reclaim` sent nothing. */
|
|
@@ -414,10 +538,19 @@ function statusRows(
|
|
|
414
538
|
closeSkipped: SkipReason | null,
|
|
415
539
|
finished: FinishedReason | null,
|
|
416
540
|
): (readonly [string, string])[] {
|
|
541
|
+
const state = stateOf(status);
|
|
542
|
+
const derived = (status.raw as { derived?: unknown }).derived === true;
|
|
417
543
|
const rows: (readonly [string, string])[] = [
|
|
418
544
|
[
|
|
419
545
|
"status",
|
|
420
|
-
status.reclaimed
|
|
546
|
+
(status.reclaimed
|
|
547
|
+
? `${state} (reclaimed)`
|
|
548
|
+
: state === status.status
|
|
549
|
+
? state
|
|
550
|
+
: `${state} (on-chain: ${status.status}; past expiresAt, accrues nothing more)`) +
|
|
551
|
+
(derived
|
|
552
|
+
? " — from this run's receipts; the relay is still catching up"
|
|
553
|
+
: ""),
|
|
421
554
|
],
|
|
422
555
|
...(finished === null
|
|
423
556
|
? []
|
|
@@ -425,10 +558,14 @@ function statusRows(
|
|
|
425
558
|
["payer", status.payer],
|
|
426
559
|
["opener", status.opener],
|
|
427
560
|
[
|
|
428
|
-
"reclaimable",
|
|
429
|
-
|
|
561
|
+
"reclaimable now",
|
|
562
|
+
reclaimResult !== null || status.reclaimed
|
|
563
|
+
? "- (already reclaimed)"
|
|
564
|
+
: status.reclaimable === null
|
|
565
|
+
? "-"
|
|
566
|
+
: formatUsdc(status.reclaimable),
|
|
430
567
|
],
|
|
431
|
-
["next", status.next.action],
|
|
568
|
+
["next", reclaimResult !== null ? "none" : status.next.action],
|
|
432
569
|
];
|
|
433
570
|
if (closeResult === null && closeSkipped !== null) {
|
|
434
571
|
rows.push(["close", "skipped — already closed"]);
|
|
@@ -462,6 +599,10 @@ function nextStep(
|
|
|
462
599
|
const base = `metrik refund ${streamId} --refund-url ${refundUrl}`;
|
|
463
600
|
switch (status.next.action) {
|
|
464
601
|
case "close":
|
|
602
|
+
if (status.status === "closed") {
|
|
603
|
+
// Never "still running" for a stream the chain shows closed.
|
|
604
|
+
return `Refunds are NOT automatic. The stream is closed: reclaim the unearned remainder with ${base} --reclaim`;
|
|
605
|
+
}
|
|
465
606
|
return `Refunds are NOT automatic. The stream is still running: stop it with ${base} --close --reclaim`;
|
|
466
607
|
case "reclaim":
|
|
467
608
|
return `Refunds are NOT automatic. Reclaim the unearned remainder with ${base} --reclaim`;
|
package/src/cli/status.ts
CHANGED
|
@@ -564,7 +564,7 @@ export function streamReportRows(
|
|
|
564
564
|
],
|
|
565
565
|
["rate", `${formatUsdc(stream.ratePerSecond)} / second`],
|
|
566
566
|
["opened", formatTimestamp(stream.openedAt)],
|
|
567
|
-
|
|
567
|
+
describeDeadline(stream, report.nowSeconds),
|
|
568
568
|
];
|
|
569
569
|
if (stream.closedAt > 0) {
|
|
570
570
|
rows.push(["closed", formatTimestamp(stream.closedAt)]);
|
|
@@ -781,11 +781,32 @@ function describePendingSettlement(settlement: SettlementCoverage): string {
|
|
|
781
781
|
return `pending final checkpoint — the stream stopped at ${stopAt}, the oracle has published only to ${covered}. Reclaim reverts until a checkpoint reaches the stop time (~15-22s apart).`;
|
|
782
782
|
}
|
|
783
783
|
|
|
784
|
-
|
|
784
|
+
/**
|
|
785
|
+
* The expiry row. A stream that was closed before its expiry never reaches
|
|
786
|
+
* it, so it never reads as a countdown ("in 57m") — that looked like a stream
|
|
787
|
+
* still running. Exported for tests.
|
|
788
|
+
*/
|
|
789
|
+
export function describeDeadline(
|
|
790
|
+
stream: Pick<StreamV2, "expiresAt" | "closedAt">,
|
|
791
|
+
nowSeconds: number,
|
|
792
|
+
): readonly [string, string] {
|
|
793
|
+
const { expiresAt } = stream;
|
|
794
|
+
const closedEarly = stream.closedAt > 0 && stream.closedAt < expiresAt;
|
|
795
|
+
if (closedEarly) {
|
|
796
|
+
return [
|
|
797
|
+
"would have expired",
|
|
798
|
+
`${formatTimestamp(expiresAt)} — not reached: closed early at ${formatTimestamp(stream.closedAt)}, so it accrues nothing more`,
|
|
799
|
+
];
|
|
800
|
+
}
|
|
785
801
|
const remaining = expiresAt - nowSeconds;
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
802
|
+
if (remaining > 0) {
|
|
803
|
+
return [
|
|
804
|
+
"expires",
|
|
805
|
+
`${formatTimestamp(expiresAt)} — in ${formatDuration(remaining)}`,
|
|
806
|
+
];
|
|
807
|
+
}
|
|
808
|
+
return [
|
|
809
|
+
"expired",
|
|
810
|
+
`${formatTimestamp(expiresAt)} — ${formatDuration(-remaining)} ago`,
|
|
811
|
+
];
|
|
791
812
|
}
|
|
@@ -14,7 +14,11 @@ import {
|
|
|
14
14
|
type SettleResultContext,
|
|
15
15
|
} from "@x402/core/server";
|
|
16
16
|
import type { Network } from "@x402/core/types";
|
|
17
|
-
import {
|
|
17
|
+
import {
|
|
18
|
+
decodePaymentRequiredHeader,
|
|
19
|
+
decodePaymentSignatureHeader,
|
|
20
|
+
encodePaymentRequiredHeader,
|
|
21
|
+
} from "@x402/core/http";
|
|
18
22
|
import { ExactEvmScheme } from "@x402/evm/exact/server";
|
|
19
23
|
import {
|
|
20
24
|
createPublicClient,
|
|
@@ -1029,7 +1033,16 @@ async function handleX402GatewayRequest(
|
|
|
1029
1033
|
claim !== undefined &&
|
|
1030
1034
|
!mode.authorizations.claim(claim.key, claim.validBefore)
|
|
1031
1035
|
) {
|
|
1032
|
-
|
|
1036
|
+
// Refused exactly as strictly as before (no verify, no upstream, no
|
|
1037
|
+
// settle), but in the x402 v2 shape: a 402 with a FRESH PAYMENT-REQUIRED
|
|
1038
|
+
// whose `error` says why, so a stock client can pay again or report it.
|
|
1039
|
+
await writePaymentRejected(
|
|
1040
|
+
mode,
|
|
1041
|
+
io,
|
|
1042
|
+
context,
|
|
1043
|
+
"x402-payment-already-used",
|
|
1044
|
+
"this PAYMENT-SIGNATURE's authorization was already used (or is being settled) for another request; each authorization buys at most one response. Sign a new payment to call again.",
|
|
1045
|
+
);
|
|
1033
1046
|
return;
|
|
1034
1047
|
}
|
|
1035
1048
|
const release = () => {
|
|
@@ -1054,7 +1067,10 @@ async function handleX402GatewayRequest(
|
|
|
1054
1067
|
}
|
|
1055
1068
|
if (result.type === "payment-error") {
|
|
1056
1069
|
release();
|
|
1057
|
-
|
|
1070
|
+
// The stock 402: a fresh PAYMENT-REQUIRED whose `error` is the verifier's
|
|
1071
|
+
// reason (e.g. an authorization whose validBefore is too short). The body
|
|
1072
|
+
// is made readable too, instead of `{}`.
|
|
1073
|
+
writeInstructions(io, result.response, "x402-payment-invalid");
|
|
1058
1074
|
return;
|
|
1059
1075
|
}
|
|
1060
1076
|
|
|
@@ -1133,8 +1149,14 @@ async function handleX402GatewayRequest(
|
|
|
1133
1149
|
release();
|
|
1134
1150
|
}
|
|
1135
1151
|
}
|
|
1136
|
-
// No upstream body on a failed settlement.
|
|
1137
|
-
|
|
1152
|
+
// No upstream body on a failed settlement. The stock settlement-failure
|
|
1153
|
+
// 402 carries PAYMENT-RESPONSE (success false, errorReason).
|
|
1154
|
+
writeInstructions(
|
|
1155
|
+
io,
|
|
1156
|
+
settled.response,
|
|
1157
|
+
"x402-settlement-failed",
|
|
1158
|
+
settled.errorReason,
|
|
1159
|
+
);
|
|
1138
1160
|
return;
|
|
1139
1161
|
}
|
|
1140
1162
|
if (claim !== undefined) {
|
|
@@ -1159,6 +1181,8 @@ async function handleX402GatewayRequest(
|
|
|
1159
1181
|
function writeInstructions(
|
|
1160
1182
|
io: X402GatewayRequestIO,
|
|
1161
1183
|
response: HTTPResponseInstructions,
|
|
1184
|
+
reason?: "x402-payment-invalid" | "x402-settlement-failed",
|
|
1185
|
+
settlementError?: string,
|
|
1162
1186
|
): void {
|
|
1163
1187
|
const headers: Record<string, string> = { ...response.headers };
|
|
1164
1188
|
if (response.isHtml === true) {
|
|
@@ -1167,5 +1191,106 @@ function writeInstructions(
|
|
|
1167
1191
|
return;
|
|
1168
1192
|
}
|
|
1169
1193
|
headers["Content-Type"] = "application/json";
|
|
1170
|
-
|
|
1194
|
+
let body = response.body ?? {};
|
|
1195
|
+
if (
|
|
1196
|
+
reason !== undefined &&
|
|
1197
|
+
typeof body === "object" &&
|
|
1198
|
+
body !== null &&
|
|
1199
|
+
Object.keys(body).length === 0
|
|
1200
|
+
) {
|
|
1201
|
+
// Same JSON shape as the gateway's other refusals, plus the x402 error.
|
|
1202
|
+
const error = settlementError ?? paymentRequiredError(headers);
|
|
1203
|
+
body = {
|
|
1204
|
+
authorized: false,
|
|
1205
|
+
reason,
|
|
1206
|
+
...(error === undefined ? {} : { error }),
|
|
1207
|
+
};
|
|
1208
|
+
}
|
|
1209
|
+
io.write(response.status, headers, JSON.stringify(body));
|
|
1210
|
+
}
|
|
1211
|
+
|
|
1212
|
+
/** The `error` carried in a response's PAYMENT-REQUIRED header, if any. */
|
|
1213
|
+
function paymentRequiredError(
|
|
1214
|
+
headers: Record<string, string>,
|
|
1215
|
+
): string | undefined {
|
|
1216
|
+
const value = headerValue(headers, "payment-required");
|
|
1217
|
+
if (value === undefined) return undefined;
|
|
1218
|
+
try {
|
|
1219
|
+
const decoded = decodePaymentRequiredHeader(value);
|
|
1220
|
+
return typeof decoded.error === "string" ? decoded.error : undefined;
|
|
1221
|
+
} catch {
|
|
1222
|
+
return undefined;
|
|
1223
|
+
}
|
|
1224
|
+
}
|
|
1225
|
+
|
|
1226
|
+
function headerValue(
|
|
1227
|
+
headers: Record<string, string>,
|
|
1228
|
+
name: string,
|
|
1229
|
+
): string | undefined {
|
|
1230
|
+
for (const [key, value] of Object.entries(headers)) {
|
|
1231
|
+
if (key.toLowerCase() === name) return value;
|
|
1232
|
+
}
|
|
1233
|
+
return undefined;
|
|
1234
|
+
}
|
|
1235
|
+
|
|
1236
|
+
/**
|
|
1237
|
+
* A spec-shaped x402 v2 refusal of a payment the gateway will not accept: a
|
|
1238
|
+
* `402` with a fresh `PAYMENT-REQUIRED` (built by the same resource server
|
|
1239
|
+
* that quotes every 402, with no payment attached, so nothing is verified or
|
|
1240
|
+
* settled) whose `error` is `reason`, and a JSON body naming it.
|
|
1241
|
+
*/
|
|
1242
|
+
async function writePaymentRejected(
|
|
1243
|
+
mode: GatewayX402OpenerMode,
|
|
1244
|
+
io: X402GatewayRequestIO,
|
|
1245
|
+
context: HTTPRequestContext,
|
|
1246
|
+
reason: "x402-payment-already-used",
|
|
1247
|
+
message: string,
|
|
1248
|
+
): Promise<void> {
|
|
1249
|
+
const body = JSON.stringify({
|
|
1250
|
+
authorized: false,
|
|
1251
|
+
reason,
|
|
1252
|
+
error: reason,
|
|
1253
|
+
message,
|
|
1254
|
+
});
|
|
1255
|
+
let headers: Record<string, string> = {};
|
|
1256
|
+
try {
|
|
1257
|
+
const { paymentHeader: _omitted, ...unpaid } = context;
|
|
1258
|
+
const challenge = await mode.httpServer.processHTTPRequest({
|
|
1259
|
+
...unpaid,
|
|
1260
|
+
adapter: {
|
|
1261
|
+
...context.adapter,
|
|
1262
|
+
// The core reads the payment from the adapter's headers: hide it, so
|
|
1263
|
+
// this builds a quote only and NOTHING is verified or settled.
|
|
1264
|
+
getHeader: (name: string) =>
|
|
1265
|
+
["payment-signature", "x-payment"].includes(name.toLowerCase())
|
|
1266
|
+
? undefined
|
|
1267
|
+
: context.adapter.getHeader(name),
|
|
1268
|
+
// Always the machine-readable 402, never the browser paywall.
|
|
1269
|
+
getAcceptHeader: () => "application/json",
|
|
1270
|
+
},
|
|
1271
|
+
});
|
|
1272
|
+
if (challenge.type === "payment-error") {
|
|
1273
|
+
for (const [key, value] of Object.entries(challenge.response.headers)) {
|
|
1274
|
+
if (key.toLowerCase() === "content-type") continue;
|
|
1275
|
+
headers[key] = value;
|
|
1276
|
+
}
|
|
1277
|
+
const required = headerValue(headers, "payment-required");
|
|
1278
|
+
if (required !== undefined) {
|
|
1279
|
+
const decoded = decodePaymentRequiredHeader(required);
|
|
1280
|
+
for (const key of Object.keys(headers)) {
|
|
1281
|
+
if (key.toLowerCase() === "payment-required") delete headers[key];
|
|
1282
|
+
}
|
|
1283
|
+
headers["PAYMENT-REQUIRED"] = encodePaymentRequiredHeader({
|
|
1284
|
+
...decoded,
|
|
1285
|
+
error: reason,
|
|
1286
|
+
});
|
|
1287
|
+
}
|
|
1288
|
+
}
|
|
1289
|
+
} catch (error) {
|
|
1290
|
+
// Still a 402 refusal (never served, never settled); just without a quote.
|
|
1291
|
+
io.logError("x402 fresh PAYMENT-REQUIRED failed", error);
|
|
1292
|
+
headers = {};
|
|
1293
|
+
}
|
|
1294
|
+
headers["Content-Type"] = "application/json";
|
|
1295
|
+
io.write(402, headers, body);
|
|
1171
1296
|
}
|