@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.
Files changed (46) hide show
  1. package/dist/cli/commands/claim.d.ts +9 -0
  2. package/dist/cli/commands/claim.d.ts.map +1 -1
  3. package/dist/cli/commands/claim.js +12 -1
  4. package/dist/cli/commands/claim.js.map +1 -1
  5. package/dist/cli/commands/refund.d.ts +2 -0
  6. package/dist/cli/commands/refund.d.ts.map +1 -1
  7. package/dist/cli/commands/refund.js +126 -8
  8. package/dist/cli/commands/refund.js.map +1 -1
  9. package/dist/cli/status.d.ts +6 -0
  10. package/dist/cli/status.d.ts.map +1 -1
  11. package/dist/cli/status.js +25 -6
  12. package/dist/cli/status.js.map +1 -1
  13. package/dist/gateway/x402-opener-mode.d.ts.map +1 -1
  14. package/dist/gateway/x402-opener-mode.js +104 -7
  15. package/dist/gateway/x402-opener-mode.js.map +1 -1
  16. package/dist/x402-opener/block-floors.d.ts +35 -0
  17. package/dist/x402-opener/block-floors.d.ts.map +1 -0
  18. package/dist/x402-opener/block-floors.js +72 -0
  19. package/dist/x402-opener/block-floors.js.map +1 -0
  20. package/dist/x402-opener/facilitator-server.d.ts.map +1 -1
  21. package/dist/x402-opener/facilitator-server.js +7 -0
  22. package/dist/x402-opener/facilitator-server.js.map +1 -1
  23. package/dist/x402-opener/index.d.ts +1 -0
  24. package/dist/x402-opener/index.d.ts.map +1 -1
  25. package/dist/x402-opener/index.js +1 -0
  26. package/dist/x402-opener/index.js.map +1 -1
  27. package/dist/x402-opener/refund-relay.d.ts +62 -1
  28. package/dist/x402-opener/refund-relay.d.ts.map +1 -1
  29. package/dist/x402-opener/refund-relay.js +387 -208
  30. package/dist/x402-opener/refund-relay.js.map +1 -1
  31. package/dist/x402-opener/scheme.d.ts +8 -0
  32. package/dist/x402-opener/scheme.d.ts.map +1 -1
  33. package/dist/x402-opener/scheme.js +14 -0
  34. package/dist/x402-opener/scheme.js.map +1 -1
  35. package/docs/cli.md +23 -2
  36. package/docs/x402.md +83 -26
  37. package/package.json +2 -2
  38. package/src/cli/commands/claim.ts +17 -1
  39. package/src/cli/commands/refund.ts +149 -8
  40. package/src/cli/status.ts +28 -7
  41. package/src/gateway/x402-opener-mode.ts +131 -6
  42. package/src/x402-opener/block-floors.ts +78 -0
  43. package/src/x402-opener/facilitator-server.ts +7 -0
  44. package/src/x402-opener/index.ts +1 -0
  45. package/src/x402-opener/refund-relay.ts +486 -217
  46. 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
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.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.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,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 \`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
  }
@@ -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
- const status = await relay(() => refunds.status());
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
- finished === null
237
- ? nextStep(status, streamId, refundUrl)
238
- : describeFinished(finished),
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 ? `${status.status} (reclaimed)` : status.status,
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
- status.reclaimable === null ? "-" : formatUsdc(status.reclaimable),
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
- ["expires", describeDeadline(stream.expiresAt, report.nowSeconds)],
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
- function describeDeadline(expiresAt: number, nowSeconds: number): string {
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
- const suffix =
787
- remaining > 0
788
- ? `in ${formatDuration(remaining)}`
789
- : `${formatDuration(-remaining)} ago`;
790
- return `${formatTimestamp(expiresAt)} — ${suffix}`;
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 { decodePaymentSignatureHeader } from "@x402/core/http";
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
- io.writeJson(409, "x402-payment-already-used");
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
- writeInstructions(io, result.response);
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
- writeInstructions(io, settled.response);
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
- io.write(response.status, headers, JSON.stringify(response.body ?? {}));
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
  }