@absol-labs/agent 0.12.0-rc.3 → 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 +6 -0
- package/dist/cli/commands/claim.d.ts.map +1 -1
- package/dist/cli/commands/claim.js +53 -22
- 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 +21 -12
- package/dist/cli/commands/refund.d.ts.map +1 -1
- package/dist/cli/commands/refund.js +61 -206
- 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/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/docs/cli.md +39 -7
- package/docs/mcp-reference.md +23 -0
- package/docs/x402.md +43 -3
- package/package.json +1 -1
- package/src/cli/commands/claim.ts +55 -29
- package/src/cli/commands/pay.ts +299 -51
- package/src/cli/commands/refund.ts +89 -240
- package/src/cli/deps.ts +9 -0
- package/src/cli/errors.ts +8 -1
- 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/mcp/x402-tools.ts
CHANGED
|
@@ -31,6 +31,10 @@ import type {
|
|
|
31
31
|
PayX402WithMandateResult,
|
|
32
32
|
X402PostSignatureError,
|
|
33
33
|
} from "../x402-buyer/pay.js";
|
|
34
|
+
import {
|
|
35
|
+
closeAfterPayment,
|
|
36
|
+
type CloseAfterOutcome,
|
|
37
|
+
} from "../x402-buyer/refund-actions.js";
|
|
34
38
|
|
|
35
39
|
/**
|
|
36
40
|
* The MCP surface for x402 (metrik-agent#121 item 4): `x402_pay`,
|
|
@@ -45,7 +49,7 @@ import type {
|
|
|
45
49
|
*/
|
|
46
50
|
|
|
47
51
|
export const X402_PAY_TOOL_DESCRIPTION =
|
|
48
|
-
"Pay an x402 endpoint (HTTP 402 Payment Required, x402 v2 'exact') and return its response. A Metrik x402 seller quotes a payment that opens an escrow STREAM; the seller earns ratePerSecond x VERIFIED seconds, capped at the deposit, and the unearned remainder stays recoverable by this server's wallet. An UPFRONT ('minimum charge') quote splits the price: upfrontAmount is a NON-REFUNDABLE per-call charge paid to the seller at settlement, and only price - upfrontAmount is escrowed and refundable; when upfrontAmount == price no stream opens at all (streamId null, nothing to refund). Subject to the agent's spend mandate (the WHOLE price vs per-stream and total caps, rate, duration, operator) and a cap on the non-refundable part (maxUpfrontAmountUsdc, default 0.10 USDC), checked BEFORE anything is signed. Refunds are NOT automatic: use refund_status and refund with the returned streamId.";
|
|
52
|
+
"Pay an x402 endpoint (HTTP 402 Payment Required, x402 v2 'exact') and return its response. A Metrik x402 seller quotes a payment that opens an escrow STREAM; the seller earns ratePerSecond x VERIFIED seconds, capped at the deposit, and the unearned remainder stays recoverable by this server's wallet. An UPFRONT ('minimum charge') quote splits the price: upfrontAmount is a NON-REFUNDABLE per-call charge paid to the seller at settlement, and only price - upfrontAmount is escrowed and refundable; when upfrontAmount == price no stream opens at all (streamId null, nothing to refund). Subject to the agent's spend mandate (the WHOLE price vs per-stream and total caps, rate, duration, operator) and a cap on the non-refundable part (maxUpfrontAmountUsdc, default 0.10 USDC), checked BEFORE anything is signed. Refunds are NOT automatic: pass closeAfter=true to have this server close the stream and reclaim the unearned part right after the response (recommended for one-shot calls), or use refund_status and refund with the returned streamId later.";
|
|
49
53
|
export const REFUND_STATUS_TOOL_DESCRIPTION =
|
|
50
54
|
"Read the refund state of a stream this server paid for over x402, from the seller's gasless refund relay: whether it is running, what is reclaimable, and the next step (close / wait / reclaim). An upfront-only payment (no stream) has nothing to refund. Moves no funds.";
|
|
51
55
|
export const REFUND_TOOL_DESCRIPTION =
|
|
@@ -66,6 +70,8 @@ export interface McpX402Options {
|
|
|
66
70
|
readonly opener?: `0x${string}`;
|
|
67
71
|
/** The guarded fetch the relay client must use. */
|
|
68
72
|
readonly fetch?: typeof fetch;
|
|
73
|
+
/** When the payment settled, unix ms (`x402_pay` with `closeAfter`). */
|
|
74
|
+
readonly settledAt?: number;
|
|
69
75
|
}) => Promise<RefundClient>;
|
|
70
76
|
/** Passed to the SDK payer (e.g. a transport for the on-chain checks). */
|
|
71
77
|
readonly sdkOptions?: PayX402WithMandateInput["sdkOptions"];
|
|
@@ -76,6 +82,8 @@ export interface McpX402Options {
|
|
|
76
82
|
* refund_status may contact (exact, or `*.domain`). Unset = any PUBLIC host.
|
|
77
83
|
*/
|
|
78
84
|
readonly allowedHosts?: readonly string[];
|
|
85
|
+
/** Delay used while a close-after waits for the relay (tests). Default `setTimeout`. */
|
|
86
|
+
readonly sleep?: (ms: number) => Promise<void>;
|
|
79
87
|
/** Base fetch under the outbound guard (tests). Default `globalThis.fetch`. */
|
|
80
88
|
readonly fetch?: typeof fetch;
|
|
81
89
|
/** DNS resolution for the outbound guard (tests). */
|
|
@@ -118,6 +126,8 @@ export interface McpX402Context {
|
|
|
118
126
|
readonly x402Charge?: "upfront-only";
|
|
119
127
|
/** The non-refundable part of the payment (the upfront charge), base units. */
|
|
120
128
|
readonly nonRefundableUsdc?: bigint;
|
|
129
|
+
/** Base units this server's own verified reclaim returned to the payer. */
|
|
130
|
+
readonly reclaimedUsdc?: bigint;
|
|
121
131
|
}) => Promise<void>;
|
|
122
132
|
/** Drop a record (a released reservation). Absent = reservations are kept. */
|
|
123
133
|
readonly removeStream?: (streamId: `0x${string}`) => Promise<void>;
|
|
@@ -185,6 +195,12 @@ const x402PaySchema = {
|
|
|
185
195
|
.describe(
|
|
186
196
|
"Cap on the NON-REFUNDABLE per-call part of an upfront-opener quote, base units. Default 100000 (0.10 USDC); 0 refuses any upfront charge.",
|
|
187
197
|
),
|
|
198
|
+
closeAfter: z
|
|
199
|
+
.boolean()
|
|
200
|
+
.optional()
|
|
201
|
+
.describe(
|
|
202
|
+
"true = right after a successful paid response, this server closes the stream on your behalf (it signs the payer's Close; the seller's refund relay sends it and pays the gas), waits for the checkpoint covering the close, and reclaims the unearned part to the payer. For one-shot calls: without it the stream keeps running and the seller can earn the whole metered part for one response. Your request, sent by this server; not an automatic refund. Only what the payment finally keeps counts against the spend mandate. The tool can take a minute or two longer.",
|
|
203
|
+
),
|
|
188
204
|
} as const;
|
|
189
205
|
|
|
190
206
|
const ZERO_STREAM_ID = `0x${"00".repeat(32)}` as const;
|
|
@@ -245,12 +261,16 @@ export function registerX402Tools(
|
|
|
245
261
|
streamId: `0x${string}`;
|
|
246
262
|
opener?: `0x${string}`;
|
|
247
263
|
fetch?: typeof fetch;
|
|
264
|
+
settledAt?: number;
|
|
248
265
|
}) => {
|
|
249
266
|
const { refundClient } = await import("@absol-labs/sdk/x402");
|
|
250
267
|
return refundClient(target.refundUrl, {
|
|
251
268
|
streamId: target.streamId,
|
|
252
269
|
...(target.opener === undefined ? {} : { opener: target.opener }),
|
|
253
270
|
...(target.fetch === undefined ? {} : { fetch: target.fetch }),
|
|
271
|
+
...(target.settledAt === undefined
|
|
272
|
+
? {}
|
|
273
|
+
: { settledAt: target.settledAt }),
|
|
254
274
|
expectedChainId: ctx.x402.network.chain.id,
|
|
255
275
|
// Chain access is what lets the SDK read the opener's KIND on-chain and
|
|
256
276
|
// sign an upfront opener's Close under its own EIP-712 domain; without
|
|
@@ -292,7 +312,10 @@ export function registerX402Tools(
|
|
|
292
312
|
await assertOutboundUrlAllowed(args.url, guard);
|
|
293
313
|
const signedMandate = await ctx.resolveMandate();
|
|
294
314
|
const owner = signedMandate.owner as `0x${string}`;
|
|
295
|
-
|
|
315
|
+
// A stream payment whose close-after runs OUTSIDE this owner's
|
|
316
|
+
// serialized queue (it can wait a minute or two for a checkpoint).
|
|
317
|
+
const pending: { close: PendingCloseAfter | null } = { close: null };
|
|
318
|
+
const answer = await ctx.serialize(owner, async () => {
|
|
296
319
|
const spentSoFarUsdc = await ctx.getSpentSoFarUsdc(owner);
|
|
297
320
|
const revokedMandateIds = await ctx.getRevokedMandateIds(owner);
|
|
298
321
|
const nowSeconds = ctx.now();
|
|
@@ -372,6 +395,7 @@ export function registerX402Tools(
|
|
|
372
395
|
}
|
|
373
396
|
const opener = result.metrikStreamOpener;
|
|
374
397
|
const upfrontAmount = result.quote?.upfrontAmount ?? null;
|
|
398
|
+
let streamRecord: StreamRecord | null = null;
|
|
375
399
|
if (
|
|
376
400
|
result.paid &&
|
|
377
401
|
opener !== null &&
|
|
@@ -404,7 +428,7 @@ export function registerX402Tools(
|
|
|
404
428
|
result.approved !== null
|
|
405
429
|
) {
|
|
406
430
|
// Replace the reservation with the real stream (same amount).
|
|
407
|
-
|
|
431
|
+
streamRecord = {
|
|
408
432
|
streamId: opener.streamId,
|
|
409
433
|
buyer: owner,
|
|
410
434
|
operator: result.approved.operator,
|
|
@@ -422,7 +446,8 @@ export function registerX402Tools(
|
|
|
422
446
|
...(upfrontAmount === null || upfrontAmount === 0n
|
|
423
447
|
? {}
|
|
424
448
|
: { nonRefundableUsdc: upfrontAmount }),
|
|
425
|
-
}
|
|
449
|
+
};
|
|
450
|
+
await ctx.recordStream(streamRecord);
|
|
426
451
|
await release();
|
|
427
452
|
} else if (!result.paid) {
|
|
428
453
|
// No 402 was answered, so nothing was approved or signed.
|
|
@@ -461,19 +486,51 @@ export function registerX402Tools(
|
|
|
461
486
|
};
|
|
462
487
|
if (opener !== null && opener.streamId === null) {
|
|
463
488
|
return ctx.ok(
|
|
464
|
-
`paid ${result.quote?.amount.toString() ?? "?"} USDC base units as an UPFRONT-ONLY charge; HTTP ${result.response.status}. The whole price was a non-refundable per-call charge paid to the seller at settlement: no stream was opened, the payment is final and there is nothing to refund
|
|
465
|
-
|
|
489
|
+
`paid ${result.quote?.amount.toString() ?? "?"} USDC base units as an UPFRONT-ONLY charge; HTTP ${result.response.status}. The whole price was a non-refundable per-call charge paid to the seller at settlement: no stream was opened, the payment is final and there is nothing to refund.${args.closeAfter === true ? " closeAfter: nothing to close (no stream was opened); nothing was signed or sent." : ""}`,
|
|
490
|
+
args.closeAfter === true
|
|
491
|
+
? {
|
|
492
|
+
...structured,
|
|
493
|
+
closeAfter: {
|
|
494
|
+
outcome: "nothing-to-close",
|
|
495
|
+
refundIsAutomatic: false,
|
|
496
|
+
},
|
|
497
|
+
}
|
|
498
|
+
: structured,
|
|
466
499
|
);
|
|
467
500
|
}
|
|
468
501
|
const split =
|
|
469
502
|
upfrontAmount !== null && upfrontAmount > 0n
|
|
470
503
|
? ` Of the ${result.quote?.amount.toString() ?? "?"} paid, ${upfrontAmount.toString()} was a NON-REFUNDABLE per-call charge (final); only the ${opener?.deposit?.toString() ?? "?"} escrowed can come back.`
|
|
471
504
|
: "";
|
|
505
|
+
const paidText = `paid ${opener?.deposit?.toString() ?? "?"} USDC base units into stream ${opener?.streamId ?? "?"}; HTTP ${result.response.status}.${split}`;
|
|
506
|
+
if (
|
|
507
|
+
args.closeAfter === true &&
|
|
508
|
+
opener !== null &&
|
|
509
|
+
opener.streamId !== null &&
|
|
510
|
+
streamRecord !== null
|
|
511
|
+
) {
|
|
512
|
+
pending.close = {
|
|
513
|
+
opener: { ...opener, streamId: opener.streamId },
|
|
514
|
+
record: streamRecord,
|
|
515
|
+
paidText,
|
|
516
|
+
structured,
|
|
517
|
+
};
|
|
518
|
+
return null;
|
|
519
|
+
}
|
|
472
520
|
return ctx.ok(
|
|
473
|
-
|
|
521
|
+
`${paidText} The seller earns only for VERIFIED seconds; the unearned remainder is NOT refunded automatically — call refund (or refund_status) with this streamId.`,
|
|
474
522
|
structured,
|
|
475
523
|
);
|
|
476
524
|
});
|
|
525
|
+
if (answer !== null || pending.close === null) {
|
|
526
|
+
return answer ?? ctx.unexpected(new Error("no x402_pay result"));
|
|
527
|
+
}
|
|
528
|
+
return await runCloseAfter(ctx, pending.close, {
|
|
529
|
+
owner,
|
|
530
|
+
guard,
|
|
531
|
+
guardedFetch,
|
|
532
|
+
openRefunds,
|
|
533
|
+
});
|
|
477
534
|
} catch (error) {
|
|
478
535
|
if (error instanceof OutboundRequestRefusedError) {
|
|
479
536
|
return ctx.fail(error.message);
|
|
@@ -620,6 +677,164 @@ export function registerX402Tools(
|
|
|
620
677
|
);
|
|
621
678
|
}
|
|
622
679
|
|
|
680
|
+
type StreamRecord = Parameters<McpX402Context["recordStream"]>[0];
|
|
681
|
+
type ToolResult = ReturnType<McpX402Context["ok"]>;
|
|
682
|
+
|
|
683
|
+
/** A paid stream whose close-after runs once the owner's queue is released. */
|
|
684
|
+
interface PendingCloseAfter {
|
|
685
|
+
readonly opener: NonNullable<
|
|
686
|
+
PayX402WithMandateResult["metrikStreamOpener"]
|
|
687
|
+
> & {
|
|
688
|
+
readonly streamId: `0x${string}`;
|
|
689
|
+
};
|
|
690
|
+
readonly record: StreamRecord;
|
|
691
|
+
readonly paidText: string;
|
|
692
|
+
readonly structured: Record<string, unknown>;
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
/**
|
|
696
|
+
* `x402_pay` with `closeAfter`: right after the paid response, at the model's
|
|
697
|
+
* request, this server closes the stream (it signs the payer's Close, the
|
|
698
|
+
* seller's relay sends it) and reclaims the unearned part to the payer, with
|
|
699
|
+
* `metrik refund`'s run. The tool still SUCCEEDS when that does not finish —
|
|
700
|
+
* the payment and the response are good — and says exactly how to finish it.
|
|
701
|
+
*
|
|
702
|
+
* Spend accounting: only a reclaim whose mined receipt was VERIFIED
|
|
703
|
+
* (`ReclaimForwarded` to the payer) is credited back, as the record's
|
|
704
|
+
* `reclaimedUsdc`; the relay's unverified word never lowers counted spend.
|
|
705
|
+
*/
|
|
706
|
+
async function runCloseAfter(
|
|
707
|
+
ctx: McpX402Context,
|
|
708
|
+
pending: PendingCloseAfter,
|
|
709
|
+
wiring: {
|
|
710
|
+
readonly owner: `0x${string}`;
|
|
711
|
+
readonly guard: Parameters<typeof assertOutboundUrlAllowed>[1];
|
|
712
|
+
readonly guardedFetch: typeof fetch;
|
|
713
|
+
readonly openRefunds: (target: {
|
|
714
|
+
refundUrl: string;
|
|
715
|
+
streamId: `0x${string}`;
|
|
716
|
+
opener?: `0x${string}`;
|
|
717
|
+
fetch?: typeof fetch;
|
|
718
|
+
settledAt?: number;
|
|
719
|
+
}) => Promise<RefundClient>;
|
|
720
|
+
},
|
|
721
|
+
): Promise<ToolResult> {
|
|
722
|
+
const { opener, record } = pending;
|
|
723
|
+
const outcome = await closeAfterPayment({
|
|
724
|
+
payment: opener,
|
|
725
|
+
payer: ctx.x402.signer.address as `0x${string}`,
|
|
726
|
+
openRefunds: async () => {
|
|
727
|
+
const refundUrl = opener.refundUrl as string;
|
|
728
|
+
await assertOutboundUrlAllowed(
|
|
729
|
+
refundUrl.replace("{streamId}", opener.streamId),
|
|
730
|
+
wiring.guard,
|
|
731
|
+
);
|
|
732
|
+
return await wiring.openRefunds({
|
|
733
|
+
refundUrl,
|
|
734
|
+
streamId: opener.streamId,
|
|
735
|
+
opener: opener.opener,
|
|
736
|
+
fetch: wiring.guardedFetch,
|
|
737
|
+
...(typeof opener.settledAt === "number"
|
|
738
|
+
? { settledAt: opener.settledAt }
|
|
739
|
+
: {}),
|
|
740
|
+
});
|
|
741
|
+
},
|
|
742
|
+
// Buyer recovery, authorized on the mandate owner's signature alone —
|
|
743
|
+
// the same rule as the refund tool.
|
|
744
|
+
closeSigner: async () => {
|
|
745
|
+
const signedMandate = await ctx.resolveMandate();
|
|
746
|
+
const decision = await verifyMandateOwnerSignature(signedMandate);
|
|
747
|
+
if (!decision.allowed) {
|
|
748
|
+
throw new Error(`mandate denied: ${decision.reason ?? "unknown"}`);
|
|
749
|
+
}
|
|
750
|
+
return ctx.x402.signer;
|
|
751
|
+
},
|
|
752
|
+
sleep:
|
|
753
|
+
ctx.x402.sleep ??
|
|
754
|
+
(async (ms) => {
|
|
755
|
+
await new Promise((resolve) => setTimeout(resolve, ms));
|
|
756
|
+
}),
|
|
757
|
+
});
|
|
758
|
+
|
|
759
|
+
let counted = record.budgetUsdc;
|
|
760
|
+
const refunded = outcome.refundedUsdc;
|
|
761
|
+
if (refunded !== null && refunded > 0n && refunded <= record.budgetUsdc) {
|
|
762
|
+
// Only what the payment finally kept counts against the mandate.
|
|
763
|
+
await ctx.serialize(wiring.owner, async () => {
|
|
764
|
+
await ctx.recordStream({ ...record, reclaimedUsdc: refunded });
|
|
765
|
+
});
|
|
766
|
+
counted = record.budgetUsdc - refunded;
|
|
767
|
+
}
|
|
768
|
+
const closeAfter = {
|
|
769
|
+
outcome: outcome.outcome,
|
|
770
|
+
close:
|
|
771
|
+
outcome.close === null
|
|
772
|
+
? null
|
|
773
|
+
: {
|
|
774
|
+
transaction: outcome.close.transaction,
|
|
775
|
+
verified: outcome.close.verified,
|
|
776
|
+
},
|
|
777
|
+
closeSkipped: outcome.closeSkipped,
|
|
778
|
+
reclaim:
|
|
779
|
+
outcome.reclaim === null
|
|
780
|
+
? null
|
|
781
|
+
: {
|
|
782
|
+
transaction: outcome.reclaim.transaction,
|
|
783
|
+
amountUsdc: outcome.reclaim.amount?.toString() ?? null,
|
|
784
|
+
verified: outcome.reclaim.verified,
|
|
785
|
+
},
|
|
786
|
+
reclaimSkipped: outcome.reclaimSkipped,
|
|
787
|
+
refundedUsdc: refunded?.toString() ?? null,
|
|
788
|
+
finishedReason: outcome.finished,
|
|
789
|
+
incomplete:
|
|
790
|
+
outcome.incomplete === null
|
|
791
|
+
? null
|
|
792
|
+
: {
|
|
793
|
+
stage: outcome.incomplete.stage,
|
|
794
|
+
closed: outcome.incomplete.closed,
|
|
795
|
+
reason: outcome.incomplete.reason,
|
|
796
|
+
},
|
|
797
|
+
sentBy: "buyer",
|
|
798
|
+
refundIsAutomatic: false,
|
|
799
|
+
};
|
|
800
|
+
const structured = {
|
|
801
|
+
...pending.structured,
|
|
802
|
+
closeAfter,
|
|
803
|
+
countedSpendUsdc: counted.toString(),
|
|
804
|
+
};
|
|
805
|
+
return ctx.ok(
|
|
806
|
+
`${pending.paidText} ${describeCloseAfter(outcome, opener.streamId, counted)}`,
|
|
807
|
+
structured,
|
|
808
|
+
);
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
function describeCloseAfter(
|
|
812
|
+
outcome: CloseAfterOutcome,
|
|
813
|
+
streamId: `0x${string}`,
|
|
814
|
+
counted: bigint,
|
|
815
|
+
): string {
|
|
816
|
+
const spend = `${counted.toString()} USDC base units count against the spend mandate.`;
|
|
817
|
+
if (outcome.outcome === "incomplete") {
|
|
818
|
+
const incomplete = outcome.incomplete;
|
|
819
|
+
if (incomplete?.stage === "no-relay") {
|
|
820
|
+
return `closeAfter did NOT finish: ${incomplete.reason}. ${spend}`;
|
|
821
|
+
}
|
|
822
|
+
return `closeAfter did NOT finish: the ${incomplete?.stage ?? "close"} did not complete (${incomplete?.reason ?? "unknown"}). Nothing is lost: the unearned part stays in the escrow for the payer. Finish it with refund (streamId ${streamId}${incomplete?.closed === true ? ", reclaim=true" : ", close=true, reclaim=true"}). ${spend}`;
|
|
823
|
+
}
|
|
824
|
+
const closed =
|
|
825
|
+
outcome.close === null
|
|
826
|
+
? "the stream was already closed"
|
|
827
|
+
: `closed the stream (${outcome.close.transaction})`;
|
|
828
|
+
if (outcome.reclaim !== null) {
|
|
829
|
+
const amount = outcome.refundedUsdc ?? outcome.reclaim.amount;
|
|
830
|
+
return `closeAfter: at your request this server ${closed} and reclaimed ${amount?.toString() ?? "the unearned remainder"} USDC base units to the payer (${outcome.reclaim.transaction}${outcome.refundedUsdc !== null ? ", receipt verified on-chain" : ", relay-reported, not verified"}). ${spend}`;
|
|
831
|
+
}
|
|
832
|
+
if (outcome.finished === "reclaimed") {
|
|
833
|
+
return `closeAfter: ${closed}; it was already reclaimed to the payer. ${spend}`;
|
|
834
|
+
}
|
|
835
|
+
return `closeAfter: ${closed}; nothing to reclaim — the verified delivery covers the whole deposit. ${spend}`;
|
|
836
|
+
}
|
|
837
|
+
|
|
623
838
|
/** Upfront-only payments: a finished, successful answer — not an error. */
|
|
624
839
|
function nothingToRefund(ctx: McpX402Context, args: { streamId: string }) {
|
|
625
840
|
return ctx.ok(
|
package/src/x402-buyer/index.ts
CHANGED
package/src/x402-buyer/pay.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import type { MetrikNetwork } from "@absol-labs/sdk";
|
|
1
|
+
import type { MetrikClient, MetrikNetwork } from "@absol-labs/sdk";
|
|
2
2
|
import {
|
|
3
3
|
findStreamForAuthorization,
|
|
4
4
|
payWithX402,
|
|
5
|
+
refundClient,
|
|
5
6
|
X402PaymentRefusedError,
|
|
6
7
|
type PayWithX402Options,
|
|
7
8
|
type PayWithX402Result,
|
|
@@ -19,6 +20,7 @@ import {
|
|
|
19
20
|
type MandateDecision,
|
|
20
21
|
type SignedSpendMandate,
|
|
21
22
|
} from "../mandates/mandate.js";
|
|
23
|
+
import { closeAfterPayment, type CloseAfterOutcome } from "./refund-actions.js";
|
|
22
24
|
|
|
23
25
|
/**
|
|
24
26
|
* Buyer-side x402 payments under a spend mandate (metrik-agent#121).
|
|
@@ -164,6 +166,35 @@ export interface PayX402WithMandateInput {
|
|
|
164
166
|
PublicClient,
|
|
165
167
|
"readContract" | "getLogs" | "getBlockNumber"
|
|
166
168
|
>;
|
|
169
|
+
/**
|
|
170
|
+
* Close the stream and reclaim the unearned part right after a successful
|
|
171
|
+
* paid response, at the buyer's request: `signer` signs the payer's Close,
|
|
172
|
+
* the seller's refund relay sends it (and pays the gas), the client waits
|
|
173
|
+
* (bounded, honouring `Retry-After`) for the checkpoint covering the close,
|
|
174
|
+
* then asks the relay to reclaim to the payer. This is the buyer's own
|
|
175
|
+
* client acting for the buyer, not an automatic refund. Its outcome is
|
|
176
|
+
* {@link PayX402WithMandateResult.closeAfter}; a close/reclaim that does not
|
|
177
|
+
* finish never fails the payment. `true` uses the defaults below.
|
|
178
|
+
*/
|
|
179
|
+
readonly closeAfter?: boolean | CloseAfterOptions;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** Options of {@link PayX402WithMandateInput.closeAfter}. */
|
|
183
|
+
export interface CloseAfterOptions {
|
|
184
|
+
/**
|
|
185
|
+
* Recommended: checks every relayed close/reclaim against its mined receipt,
|
|
186
|
+
* so `closeAfter.refundedUsdc` is the amount the receipt proves. Without it
|
|
187
|
+
* the relay's word is reported and `refundedUsdc` stays `null`.
|
|
188
|
+
*/
|
|
189
|
+
readonly verifyWith?: MetrikClient;
|
|
190
|
+
/** Relay attempts while it says "not yet" (default the SDK's, 5). */
|
|
191
|
+
readonly maxAttempts?: number;
|
|
192
|
+
/** Longest single wait honoured, ms (default the SDK's, 60 000). */
|
|
193
|
+
readonly maxDelayMs?: number;
|
|
194
|
+
/** Injectable delay (tests). */
|
|
195
|
+
readonly sleep?: (ms: number) => Promise<void>;
|
|
196
|
+
/** Test seam: the refund client factory. Default the SDK's `refundClient`. */
|
|
197
|
+
readonly refundClientImpl?: typeof refundClient;
|
|
167
198
|
}
|
|
168
199
|
|
|
169
200
|
/** What is known about a payment whose signature was handed out but that did not complete cleanly. */
|
|
@@ -286,6 +317,13 @@ function authorizationFromHeader(
|
|
|
286
317
|
export interface PayX402WithMandateResult extends PayWithX402Result {
|
|
287
318
|
/** The stream-open request the mandate approved (null if nothing was paid). */
|
|
288
319
|
readonly approved: HireRequest | null;
|
|
320
|
+
/**
|
|
321
|
+
* What `closeAfter` did: `null` when it was not requested or nothing was
|
|
322
|
+
* paid. What the payment finally cost is `approved.budgetUsdc -
|
|
323
|
+
* closeAfter.refundedUsdc` (when `refundedUsdc` is non-null): a caller that
|
|
324
|
+
* tracks cumulative spend counts only that.
|
|
325
|
+
*/
|
|
326
|
+
readonly closeAfter: CloseAfterOutcome | null;
|
|
289
327
|
}
|
|
290
328
|
|
|
291
329
|
function smaller(a: bigint, b: bigint | undefined): bigint {
|
|
@@ -444,7 +482,66 @@ export async function payX402WithMandate(
|
|
|
444
482
|
sentAuthorization,
|
|
445
483
|
});
|
|
446
484
|
}
|
|
447
|
-
|
|
485
|
+
const closeAfter =
|
|
486
|
+
input.closeAfter === undefined ||
|
|
487
|
+
input.closeAfter === false ||
|
|
488
|
+
!result.paid ||
|
|
489
|
+
result.metrikStreamOpener === null
|
|
490
|
+
? null
|
|
491
|
+
: await runCloseAfter(input, result.metrikStreamOpener);
|
|
492
|
+
return { ...result, approved: result.paid ? approved : null, closeAfter };
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
/** {@link PayX402WithMandateInput.closeAfter}, with the SDK's refund client. */
|
|
496
|
+
async function runCloseAfter(
|
|
497
|
+
input: PayX402WithMandateInput,
|
|
498
|
+
payment: NonNullable<PayWithX402Result["metrikStreamOpener"]>,
|
|
499
|
+
): Promise<CloseAfterOutcome> {
|
|
500
|
+
const options: CloseAfterOptions =
|
|
501
|
+
typeof input.closeAfter === "object" ? input.closeAfter : {};
|
|
502
|
+
const sleep =
|
|
503
|
+
options.sleep ??
|
|
504
|
+
(async (ms: number) => {
|
|
505
|
+
await new Promise((resolve) => setTimeout(resolve, ms));
|
|
506
|
+
});
|
|
507
|
+
const open = options.refundClientImpl ?? refundClient;
|
|
508
|
+
return await closeAfterPayment({
|
|
509
|
+
payment,
|
|
510
|
+
payer: getAddress(input.signer.address),
|
|
511
|
+
openRefunds: async () =>
|
|
512
|
+
open(payment, {
|
|
513
|
+
...(options.verifyWith === undefined
|
|
514
|
+
? {
|
|
515
|
+
// Chain access proves the opener's kind (the Close's EIP-712
|
|
516
|
+
// domain) when no MetrikClient is given.
|
|
517
|
+
publicClient:
|
|
518
|
+
input.sdkOptions?.publicClient ??
|
|
519
|
+
(createPublicClient({
|
|
520
|
+
chain: input.network.chain,
|
|
521
|
+
transport:
|
|
522
|
+
input.sdkOptions?.transport ??
|
|
523
|
+
http(input.network.rpcUrls[0]),
|
|
524
|
+
}) as PublicClient),
|
|
525
|
+
network: input.network,
|
|
526
|
+
}
|
|
527
|
+
: { verifyWith: options.verifyWith }),
|
|
528
|
+
expectedChainId: input.network.chain.id,
|
|
529
|
+
...(input.sdkOptions?.fetch === undefined
|
|
530
|
+
? {}
|
|
531
|
+
: { fetch: input.sdkOptions.fetch }),
|
|
532
|
+
...(options.maxAttempts === undefined
|
|
533
|
+
? {}
|
|
534
|
+
: { maxAttempts: options.maxAttempts }),
|
|
535
|
+
...(options.maxDelayMs === undefined
|
|
536
|
+
? {}
|
|
537
|
+
: { maxDelayMs: options.maxDelayMs }),
|
|
538
|
+
sleep,
|
|
539
|
+
}),
|
|
540
|
+
// The mandate's owner IS the payer (checked before paying): the buyer's
|
|
541
|
+
// own recovery needs nothing more.
|
|
542
|
+
closeSigner: async () => input.signer,
|
|
543
|
+
sleep,
|
|
544
|
+
});
|
|
448
545
|
}
|
|
449
546
|
|
|
450
547
|
/**
|