@absol-labs/agent 0.12.0-rc.2 → 0.12.0-rc.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (86) hide show
  1. package/dist/cli/commands/claim.d.ts +15 -0
  2. package/dist/cli/commands/claim.d.ts.map +1 -1
  3. package/dist/cli/commands/claim.js +65 -23
  4. package/dist/cli/commands/claim.js.map +1 -1
  5. package/dist/cli/commands/pay.d.ts +10 -0
  6. package/dist/cli/commands/pay.d.ts.map +1 -1
  7. package/dist/cli/commands/pay.js +246 -50
  8. package/dist/cli/commands/pay.js.map +1 -1
  9. package/dist/cli/commands/refund.d.ts +23 -12
  10. package/dist/cli/commands/refund.d.ts.map +1 -1
  11. package/dist/cli/commands/refund.js +104 -131
  12. package/dist/cli/commands/refund.js.map +1 -1
  13. package/dist/cli/deps.d.ts +6 -0
  14. package/dist/cli/deps.d.ts.map +1 -1
  15. package/dist/cli/deps.js +3 -0
  16. package/dist/cli/deps.js.map +1 -1
  17. package/dist/cli/errors.d.ts +8 -1
  18. package/dist/cli/errors.d.ts.map +1 -1
  19. package/dist/cli/errors.js.map +1 -1
  20. package/dist/cli/status.d.ts +6 -0
  21. package/dist/cli/status.d.ts.map +1 -1
  22. package/dist/cli/status.js +25 -6
  23. package/dist/cli/status.js.map +1 -1
  24. package/dist/gateway/x402-opener-mode.d.ts.map +1 -1
  25. package/dist/gateway/x402-opener-mode.js +104 -7
  26. package/dist/gateway/x402-opener-mode.js.map +1 -1
  27. package/dist/mcp/server.d.ts +10 -0
  28. package/dist/mcp/server.d.ts.map +1 -1
  29. package/dist/mcp/server.js +21 -1
  30. package/dist/mcp/server.js.map +1 -1
  31. package/dist/mcp/x402-tools.d.ts +7 -1
  32. package/dist/mcp/x402-tools.d.ts.map +1 -1
  33. package/dist/mcp/x402-tools.js +159 -6
  34. package/dist/mcp/x402-tools.js.map +1 -1
  35. package/dist/x402-buyer/index.d.ts +1 -0
  36. package/dist/x402-buyer/index.d.ts.map +1 -1
  37. package/dist/x402-buyer/index.js +1 -0
  38. package/dist/x402-buyer/index.js.map +1 -1
  39. package/dist/x402-buyer/pay.d.ts +38 -2
  40. package/dist/x402-buyer/pay.d.ts.map +1 -1
  41. package/dist/x402-buyer/pay.js +52 -2
  42. package/dist/x402-buyer/pay.js.map +1 -1
  43. package/dist/x402-buyer/refund-actions.d.ts +168 -0
  44. package/dist/x402-buyer/refund-actions.d.ts.map +1 -0
  45. package/dist/x402-buyer/refund-actions.js +351 -0
  46. package/dist/x402-buyer/refund-actions.js.map +1 -0
  47. package/dist/x402-opener/block-floors.d.ts +35 -0
  48. package/dist/x402-opener/block-floors.d.ts.map +1 -0
  49. package/dist/x402-opener/block-floors.js +72 -0
  50. package/dist/x402-opener/block-floors.js.map +1 -0
  51. package/dist/x402-opener/facilitator-server.d.ts.map +1 -1
  52. package/dist/x402-opener/facilitator-server.js +7 -0
  53. package/dist/x402-opener/facilitator-server.js.map +1 -1
  54. package/dist/x402-opener/index.d.ts +1 -0
  55. package/dist/x402-opener/index.d.ts.map +1 -1
  56. package/dist/x402-opener/index.js +1 -0
  57. package/dist/x402-opener/index.js.map +1 -1
  58. package/dist/x402-opener/refund-relay.d.ts +62 -1
  59. package/dist/x402-opener/refund-relay.d.ts.map +1 -1
  60. package/dist/x402-opener/refund-relay.js +387 -208
  61. package/dist/x402-opener/refund-relay.js.map +1 -1
  62. package/dist/x402-opener/scheme.d.ts +8 -0
  63. package/dist/x402-opener/scheme.d.ts.map +1 -1
  64. package/dist/x402-opener/scheme.js +14 -0
  65. package/dist/x402-opener/scheme.js.map +1 -1
  66. package/docs/cli.md +62 -9
  67. package/docs/mcp-reference.md +23 -0
  68. package/docs/x402.md +126 -29
  69. package/package.json +2 -2
  70. package/src/cli/commands/claim.ts +72 -30
  71. package/src/cli/commands/pay.ts +299 -51
  72. package/src/cli/commands/refund.ts +133 -143
  73. package/src/cli/deps.ts +9 -0
  74. package/src/cli/errors.ts +8 -1
  75. package/src/cli/status.ts +28 -7
  76. package/src/gateway/x402-opener-mode.ts +131 -6
  77. package/src/mcp/server.ts +30 -1
  78. package/src/mcp/x402-tools.ts +222 -7
  79. package/src/x402-buyer/index.ts +1 -0
  80. package/src/x402-buyer/pay.ts +99 -2
  81. package/src/x402-buyer/refund-actions.ts +486 -0
  82. package/src/x402-opener/block-floors.ts +78 -0
  83. package/src/x402-opener/facilitator-server.ts +7 -0
  84. package/src/x402-opener/index.ts +1 -0
  85. package/src/x402-opener/refund-relay.ts +486 -217
  86. package/src/x402-opener/scheme.ts +38 -0
@@ -6,6 +6,13 @@ import type {
6
6
  } from "@absol-labs/sdk/x402";
7
7
 
8
8
  import { verifyMandateOwnerSignature } from "../../mandates/mandate.js";
9
+ import {
10
+ RefundActionsError,
11
+ runRefundActions,
12
+ type FinishedReason,
13
+ type RefundActionsResult,
14
+ type SkipReason,
15
+ } from "../../x402-buyer/refund-actions.js";
9
16
  import { booleanFlag, integerFlag, stringFlag } from "../args.js";
10
17
  import { formatUsdc } from "../amounts.js";
11
18
  import { assertTestnetRpc, parseBytes32 } from "../config.js";
@@ -20,11 +27,15 @@ import {
20
27
  } from "../wallet.js";
21
28
  import type { CommandContext, CommandSpec } from "../context.js";
22
29
 
30
+ export { finishedReason } from "../../x402-buyer/refund-actions.js";
31
+ export type { FinishedReason, SkipReason };
32
+
23
33
  /** Environment fallback for `--refund-url`. */
24
34
  export const REFUND_URL_ENV = "METRIK_AGENT_REFUND_URL";
25
35
 
26
- const DEFAULT_ATTEMPTS = 5;
27
- const DEFAULT_MAX_WAIT_SECONDS = 60;
36
+ /** Relay attempts / longest single wait of a reclaim (also `pay --close-after`'s). */
37
+ export const DEFAULT_ATTEMPTS = 5;
38
+ export const DEFAULT_MAX_WAIT_SECONDS = 60;
28
39
 
29
40
  export const refundCommand: CommandSpec = {
30
41
  name: "refund",
@@ -97,107 +108,47 @@ export const refundCommand: CommandSpec = {
97
108
  maxDelayMs: maxWait * 1_000,
98
109
  });
99
110
 
100
- let closeResult: RefundRelayResult | null = null;
101
- let closeSkipped: SkipReason | null = null;
102
- // Set when the relay itself says there is nothing left: the stream was
103
- // already reclaimed, or the verified delivery covers the whole deposit.
104
- // Those are FINISHED recoveries, not failures — a second
105
- // `--close --reclaim` on a done stream must exit 0 and must never point
106
- // the payer at a gas-spending on-chain recovery that cannot recover a cent.
107
- let finishedByRelay: FinishedReason | null = null;
108
- if (doClose) {
109
- // Ask first: a stream that is already closed (or already finished) needs
110
- // no Close, so there is nothing to sign and no wallet is needed.
111
- const before = await relay(() => refunds.status());
112
- const finishedBefore = finishedReason(before);
113
- if (finishedBefore !== null || before.status === "closed") {
114
- closeSkipped = finishedBefore ?? "already-closed";
115
- finishedByRelay = finishedBefore;
116
- out.note(
117
- finishedBefore === null
118
- ? "stream is already closed — no Close to sign."
119
- : `stream is already closed and ${finishedBefore === "reclaimed" ? "reclaimed" : "has nothing left to reclaim"} — no Close to sign.`,
120
- );
121
- } else {
122
- const wallet = await resolveCliWallet(env, {
123
- purpose: "payer-close",
124
- note: out.note,
125
- rpcUrl: config.rpcUrl,
126
- });
127
- if (isPrivyWalletSource(wallet.source)) {
128
- throw configError(
129
- "metrik refund --close cannot sign with a Privy wallet",
130
- "the payer signs the opener's EIP-712 Close, which the Privy broker's typed-data policy does not allow. Use the key that paid.",
131
- );
132
- }
133
- // Buyer recovery: authorized on the mandate OWNER's signature alone,
134
- // like `metrik close` — never on caps or expiry, so nothing can strand
135
- // the payer's own money. The SDK then refuses a signer that is not
136
- // `payerOf(streamId)` before anything is signed for the relay.
137
- const mandate = await signRecoveryMandate(
138
- wallet.account,
139
- config.chainId,
140
- );
141
- const decision = await verifyMandateOwnerSignature(mandate);
142
- if (!decision.allowed) {
143
- throw new CliError(`mandate denied: ${decision.reason}`, {
144
- exitCode: 3,
145
- });
146
- }
147
- try {
148
- closeResult = await relay(() =>
149
- refunds.close({
150
- signer: wallet.account as unknown as X402PayerSigner,
111
+ let actions: RefundActionsResult;
112
+ try {
113
+ actions = await runRefundActions({
114
+ refunds,
115
+ streamId,
116
+ opener,
117
+ payer: stream.payer ?? null,
118
+ close: doClose,
119
+ reclaim: doReclaim,
120
+ closeSigner: () => payerCloseSigner(context, "metrik refund --close"),
121
+ sleep: deps.sleep,
122
+ note: out.note,
123
+ ...(context.progress === undefined
124
+ ? {}
125
+ : {
126
+ setActivity: (activity: string | null) =>
127
+ context.progress?.setActivity(activity),
151
128
  }),
152
- );
153
- out.note(
154
- `close relayed in ${closeResult.transaction}${closeResult.verified ? " (receipt verified on-chain)" : ""}`,
155
- );
156
- } catch (error) {
157
- // The status read above said "active", but the stream closed in the
158
- // meantime (it reached expiresAt, or another close landed): the
159
- // state the payer asked for. Anything else is a real failure.
160
- if (relayCode(error) !== "already-closed") throw error;
161
- closeSkipped = "already-closed";
162
- out.note("stream was already closed — no Close was needed.");
163
- }
164
- }
165
- }
166
-
167
- let reclaimResult: RefundRelayResult | null = null;
168
- let reclaimSkipped: SkipReason | null = null;
169
- if (doReclaim) {
170
- if (finishedByRelay !== null) {
171
- reclaimSkipped = finishedByRelay;
172
- } else {
173
- context.progress?.setActivity(
174
- `waiting on the refund relay's reclaim (it retries up to ${attempts} time${attempts === 1 ? "" : "s"} while the settlement window is open, at most ${maxWait}s per wait)`,
175
- );
176
- try {
177
- reclaimResult = await relay(() => refunds.reclaim());
178
- } catch (error) {
179
- const finished = finishedFromRelayCode(relayCode(error));
180
- if (finished === null) throw error;
181
- reclaimSkipped = finished;
182
- finishedByRelay = finished;
183
- } finally {
184
- context.progress?.setActivity(null);
185
- }
186
- }
129
+ reclaimActivity: reclaimActivity(attempts, maxWait),
130
+ });
131
+ } catch (error) {
132
+ throw explainRefundFailure(
133
+ error instanceof RefundActionsError ? error.cause : error,
134
+ );
187
135
  }
188
-
189
- const status = await relay(() => refunds.status());
190
- // A reclaim THIS run performed is reported as a reclaim, not as "already
191
- // finished".
192
- const finished =
193
- reclaimResult === null
194
- ? (finishedReason(status) ?? finishedByRelay)
195
- : null;
136
+ const {
137
+ close: closeResult,
138
+ closeSkipped,
139
+ reclaim: reclaimResult,
140
+ reclaimSkipped,
141
+ status,
142
+ finished,
143
+ } = actions;
196
144
  out.result({
197
145
  streamId,
198
146
  opener,
199
147
  payer: status.payer,
148
+ // The escrow's own on-chain status, verbatim.
200
149
  status: status.status,
150
+ // What it means now: an Active stream past expiresAt is "expired".
151
+ state: stateOf(status),
201
152
  reclaimed: status.reclaimed,
202
153
  reclaimable: status.reclaimable,
203
154
  next: status.next,
@@ -233,48 +184,32 @@ export const refundCommand: CommandSpec = {
233
184
  );
234
185
  out.print("");
235
186
  out.print(
236
- finished === null
237
- ? nextStep(status, streamId, refundUrl)
238
- : describeFinished(finished),
187
+ reclaimResult !== null
188
+ ? describeReclaimed(reclaimResult)
189
+ : finished === null
190
+ ? nextStep(status, streamId, refundUrl)
191
+ : describeFinished(finished),
239
192
  );
240
193
  },
241
194
  };
242
195
 
243
- /** Why a recovery is over. */
244
- export type FinishedReason = "reclaimed" | "nothing-to-reclaim";
245
- /** Why a requested `--close` / `--reclaim` sent nothing. */
246
- export type SkipReason = FinishedReason | "already-closed";
247
-
248
- /**
249
- * Whether the relay's status says nothing is left to recover. `reclaimed` is
250
- * the chain's one-reclaim-per-stream flag; `next: none` with `reclaimable: 0`
251
- * is the relay's "the verified delivery covers the whole deposit".
252
- */
253
- export function finishedReason(status: RefundStatus): FinishedReason | null {
254
- if (status.reclaimed) return "reclaimed";
255
- if (status.next.action === "none" && status.reclaimable === 0n) {
256
- return "nothing-to-reclaim";
196
+ /** `closed`, `expired` (Active on-chain but past expiresAt) or `active`. */
197
+ export function stateOf(status: RefundStatus): "active" | "expired" | "closed" {
198
+ if (status.status === "closed" || status.reclaimed) return "closed";
199
+ const raw = status.raw as { state?: unknown; expiresAt?: unknown };
200
+ if (raw.state === "expired") return "expired";
201
+ if (
202
+ typeof raw.expiresAt === "number" &&
203
+ status.chainTime !== null &&
204
+ status.chainTime >= raw.expiresAt
205
+ ) {
206
+ return "expired";
257
207
  }
258
- return null;
208
+ return "active";
259
209
  }
260
210
 
261
- /** The relay's 409 codes that mean "finished", not "failed". */
262
- function finishedFromRelayCode(code: string | null): FinishedReason | null {
263
- if (code === "already-reclaimed") return "reclaimed";
264
- if (code === "nothing-to-reclaim") return "nothing-to-reclaim";
265
- return null;
266
- }
267
-
268
- function relayCode(error: unknown): string | null {
269
- for (let current: unknown = error, depth = 0; depth < 4; depth++) {
270
- if (!(current instanceof Error)) return null;
271
- const code = (current as { code?: unknown }).code;
272
- if (current.name === "RefundRelayError" && typeof code === "string") {
273
- return code;
274
- }
275
- current = current.cause;
276
- }
277
- return null;
211
+ function describeReclaimed(result: RefundRelayResult): string {
212
+ 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.`;
278
213
  }
279
214
 
280
215
  /** The `metrik refund` answer for an upfront-only payment (exit 0). */
@@ -339,14 +274,6 @@ export function resolveRefundUrl(
339
274
  return url.toString();
340
275
  }
341
276
 
342
- async function relay<T>(task: () => Promise<T>): Promise<T> {
343
- try {
344
- return await task();
345
- } catch (error) {
346
- throw explainRefundFailure(error);
347
- }
348
- }
349
-
350
277
  export function explainRefundFailure(error: unknown): unknown {
351
278
  if (!(error instanceof Error)) return error;
352
279
  switch (error.name) {
@@ -414,10 +341,19 @@ function statusRows(
414
341
  closeSkipped: SkipReason | null,
415
342
  finished: FinishedReason | null,
416
343
  ): (readonly [string, string])[] {
344
+ const state = stateOf(status);
345
+ const derived = (status.raw as { derived?: unknown }).derived === true;
417
346
  const rows: (readonly [string, string])[] = [
418
347
  [
419
348
  "status",
420
- status.reclaimed ? `${status.status} (reclaimed)` : status.status,
349
+ (status.reclaimed
350
+ ? `${state} (reclaimed)`
351
+ : state === status.status
352
+ ? state
353
+ : `${state} (on-chain: ${status.status}; past expiresAt, accrues nothing more)`) +
354
+ (derived
355
+ ? " — from this run's receipts; the relay is still catching up"
356
+ : ""),
421
357
  ],
422
358
  ...(finished === null
423
359
  ? []
@@ -425,10 +361,14 @@ function statusRows(
425
361
  ["payer", status.payer],
426
362
  ["opener", status.opener],
427
363
  [
428
- "reclaimable",
429
- status.reclaimable === null ? "-" : formatUsdc(status.reclaimable),
364
+ "reclaimable now",
365
+ reclaimResult !== null || status.reclaimed
366
+ ? "- (already reclaimed)"
367
+ : status.reclaimable === null
368
+ ? "-"
369
+ : formatUsdc(status.reclaimable),
430
370
  ],
431
- ["next", status.next.action],
371
+ ["next", reclaimResult !== null ? "none" : status.next.action],
432
372
  ];
433
373
  if (closeResult === null && closeSkipped !== null) {
434
374
  rows.push(["close", "skipped — already closed"]);
@@ -462,6 +402,10 @@ function nextStep(
462
402
  const base = `metrik refund ${streamId} --refund-url ${refundUrl}`;
463
403
  switch (status.next.action) {
464
404
  case "close":
405
+ if (status.status === "closed") {
406
+ // Never "still running" for a stream the chain shows closed.
407
+ return `Refunds are NOT automatic. The stream is closed: reclaim the unearned remainder with ${base} --reclaim`;
408
+ }
465
409
  return `Refunds are NOT automatic. The stream is still running: stop it with ${base} --close --reclaim`;
466
410
  case "reclaim":
467
411
  return `Refunds are NOT automatic. Reclaim the unearned remainder with ${base} --reclaim`;
@@ -472,4 +416,50 @@ function nextStep(
472
416
  }
473
417
  }
474
418
 
419
+ /**
420
+ * The payer's signer for an opener Close, authorized the way every buyer
421
+ * recovery is: on the mandate OWNER's signature alone (like `metrik close`) —
422
+ * never on caps or expiry, so nothing can strand the payer's own money. The
423
+ * SDK then refuses a signer that is not `payerOf(streamId)` before anything is
424
+ * signed for the relay.
425
+ */
426
+ export async function payerCloseSigner(
427
+ context: CommandContext,
428
+ commandLabel: string,
429
+ ): Promise<X402PayerSigner> {
430
+ const { config, env, out } = context;
431
+ const wallet = await resolveCliWallet(env, {
432
+ purpose: "payer-close",
433
+ note: out.note,
434
+ rpcUrl: config.rpcUrl,
435
+ });
436
+ if (isPrivyWalletSource(wallet.source)) {
437
+ throw configError(
438
+ `${commandLabel} cannot sign with a Privy wallet`,
439
+ "the payer signs the opener's EIP-712 Close, which the Privy broker's typed-data policy does not allow. Use the key that paid.",
440
+ );
441
+ }
442
+ return await authorizeCloseSigner(wallet.account, config.chainId);
443
+ }
444
+
445
+ /** {@link payerCloseSigner} for an account the command already holds. */
446
+ export async function authorizeCloseSigner(
447
+ account: Parameters<typeof signRecoveryMandate>[0],
448
+ chainId: number,
449
+ ): Promise<X402PayerSigner> {
450
+ const mandate = await signRecoveryMandate(account, chainId);
451
+ const decision = await verifyMandateOwnerSignature(mandate);
452
+ if (!decision.allowed) {
453
+ throw new CliError(`mandate denied: ${decision.reason}`, {
454
+ exitCode: 3,
455
+ });
456
+ }
457
+ return account as unknown as X402PayerSigner;
458
+ }
459
+
460
+ /** What the heartbeat says while the relay's reclaim waits for the checkpoint. */
461
+ export function reclaimActivity(attempts: number, maxWait: number): string {
462
+ return `waiting on the refund relay's reclaim (it retries up to ${attempts} time${attempts === 1 ? "" : "s"} while the settlement window is open, at most ${maxWait}s per wait)`;
463
+ }
464
+
475
465
  export type { RefundClient };
package/src/cli/deps.ts CHANGED
@@ -262,6 +262,12 @@ export interface CliX402 {
262
262
  readonly opener?: `0x${string}`;
263
263
  readonly maxAttempts?: number;
264
264
  readonly maxDelayMs?: number;
265
+ /**
266
+ * When the payment settled, unix ms (`pay --close-after`): a relay that
267
+ * has not seen the just-opened stream yet answers `404`, which is then
268
+ * waited out briefly instead of failing.
269
+ */
270
+ readonly settledAt?: number;
265
271
  },
266
272
  ) => Promise<RefundClient>;
267
273
  }
@@ -613,6 +619,9 @@ export const defaultCliDependencies: CliDependencies = {
613
619
  ...(target.maxDelayMs === undefined
614
620
  ? {}
615
621
  : { maxDelayMs: target.maxDelayMs }),
622
+ ...(target.settledAt === undefined
623
+ ? {}
624
+ : { settledAt: target.settledAt }),
616
625
  });
617
626
  },
618
627
  },
package/src/cli/errors.ts CHANGED
@@ -9,7 +9,14 @@ export type CliExitCode =
9
9
  /** Configuration refused: wrong chain, missing key, mainnet target. */
10
10
  | 3
11
11
  /** The operation itself failed (RPC, registry, escrow revert, HTTP). */
12
- | 4;
12
+ | 4
13
+ /**
14
+ * `metrik pay --close-after`: the payment and its response SUCCEEDED, but
15
+ * the buyer's close + reclaim of the stream did not finish. Nothing is lost
16
+ * (the unearned part stays in the escrow for the payer); the hint names the
17
+ * exact `metrik refund` command that finishes it.
18
+ */
19
+ | 5;
13
20
 
14
21
  export class CliError extends Error {
15
22
  readonly exitCode: CliExitCode;
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
  }