@absol-labs/agent 0.12.0-rc.2 → 0.12.0-rc.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/commands/claim.d.ts +15 -0
- package/dist/cli/commands/claim.d.ts.map +1 -1
- package/dist/cli/commands/claim.js +65 -23
- package/dist/cli/commands/claim.js.map +1 -1
- package/dist/cli/commands/pay.d.ts +10 -0
- package/dist/cli/commands/pay.d.ts.map +1 -1
- package/dist/cli/commands/pay.js +246 -50
- package/dist/cli/commands/pay.js.map +1 -1
- package/dist/cli/commands/refund.d.ts +23 -12
- package/dist/cli/commands/refund.d.ts.map +1 -1
- package/dist/cli/commands/refund.js +104 -131
- package/dist/cli/commands/refund.js.map +1 -1
- package/dist/cli/deps.d.ts +6 -0
- package/dist/cli/deps.d.ts.map +1 -1
- package/dist/cli/deps.js +3 -0
- package/dist/cli/deps.js.map +1 -1
- package/dist/cli/errors.d.ts +8 -1
- package/dist/cli/errors.d.ts.map +1 -1
- package/dist/cli/errors.js.map +1 -1
- package/dist/cli/status.d.ts +6 -0
- package/dist/cli/status.d.ts.map +1 -1
- package/dist/cli/status.js +25 -6
- package/dist/cli/status.js.map +1 -1
- package/dist/gateway/x402-opener-mode.d.ts.map +1 -1
- package/dist/gateway/x402-opener-mode.js +104 -7
- package/dist/gateway/x402-opener-mode.js.map +1 -1
- package/dist/mcp/server.d.ts +10 -0
- package/dist/mcp/server.d.ts.map +1 -1
- package/dist/mcp/server.js +21 -1
- package/dist/mcp/server.js.map +1 -1
- package/dist/mcp/x402-tools.d.ts +7 -1
- package/dist/mcp/x402-tools.d.ts.map +1 -1
- package/dist/mcp/x402-tools.js +159 -6
- package/dist/mcp/x402-tools.js.map +1 -1
- package/dist/x402-buyer/index.d.ts +1 -0
- package/dist/x402-buyer/index.d.ts.map +1 -1
- package/dist/x402-buyer/index.js +1 -0
- package/dist/x402-buyer/index.js.map +1 -1
- package/dist/x402-buyer/pay.d.ts +38 -2
- package/dist/x402-buyer/pay.d.ts.map +1 -1
- package/dist/x402-buyer/pay.js +52 -2
- package/dist/x402-buyer/pay.js.map +1 -1
- package/dist/x402-buyer/refund-actions.d.ts +168 -0
- package/dist/x402-buyer/refund-actions.d.ts.map +1 -0
- package/dist/x402-buyer/refund-actions.js +351 -0
- package/dist/x402-buyer/refund-actions.js.map +1 -0
- package/dist/x402-opener/block-floors.d.ts +35 -0
- package/dist/x402-opener/block-floors.d.ts.map +1 -0
- package/dist/x402-opener/block-floors.js +72 -0
- package/dist/x402-opener/block-floors.js.map +1 -0
- package/dist/x402-opener/facilitator-server.d.ts.map +1 -1
- package/dist/x402-opener/facilitator-server.js +7 -0
- package/dist/x402-opener/facilitator-server.js.map +1 -1
- package/dist/x402-opener/index.d.ts +1 -0
- package/dist/x402-opener/index.d.ts.map +1 -1
- package/dist/x402-opener/index.js +1 -0
- package/dist/x402-opener/index.js.map +1 -1
- package/dist/x402-opener/refund-relay.d.ts +62 -1
- package/dist/x402-opener/refund-relay.d.ts.map +1 -1
- package/dist/x402-opener/refund-relay.js +387 -208
- package/dist/x402-opener/refund-relay.js.map +1 -1
- package/dist/x402-opener/scheme.d.ts +8 -0
- package/dist/x402-opener/scheme.d.ts.map +1 -1
- package/dist/x402-opener/scheme.js +14 -0
- package/dist/x402-opener/scheme.js.map +1 -1
- package/docs/cli.md +62 -9
- package/docs/mcp-reference.md +23 -0
- package/docs/x402.md +126 -29
- package/package.json +2 -2
- package/src/cli/commands/claim.ts +72 -30
- package/src/cli/commands/pay.ts +299 -51
- package/src/cli/commands/refund.ts +133 -143
- package/src/cli/deps.ts +9 -0
- package/src/cli/errors.ts +8 -1
- package/src/cli/status.ts +28 -7
- package/src/gateway/x402-opener-mode.ts +131 -6
- package/src/mcp/server.ts +30 -1
- package/src/mcp/x402-tools.ts +222 -7
- package/src/x402-buyer/index.ts +1 -0
- package/src/x402-buyer/pay.ts +99 -2
- package/src/x402-buyer/refund-actions.ts +486 -0
- package/src/x402-opener/block-floors.ts +78 -0
- package/src/x402-opener/facilitator-server.ts +7 -0
- package/src/x402-opener/index.ts +1 -0
- package/src/x402-opener/refund-relay.ts +486 -217
- package/src/x402-opener/scheme.ts +38 -0
|
@@ -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
|
-
|
|
27
|
-
const
|
|
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
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
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
|
-
|
|
237
|
-
?
|
|
238
|
-
:
|
|
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
|
-
/**
|
|
244
|
-
export
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
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
|
|
208
|
+
return "active";
|
|
259
209
|
}
|
|
260
210
|
|
|
261
|
-
|
|
262
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|