@metalntwx/metal-pay 0.2.0 → 0.3.1
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/README.md +104 -38
- package/dist/clearing-abi.d.ts +1836 -0
- package/dist/clearing-abi.js +2361 -0
- package/dist/clearing-client.d.ts +96 -159
- package/dist/clearing-client.js +412 -553
- package/dist/clearing-codec.d.ts +57 -0
- package/dist/clearing-codec.js +173 -0
- package/dist/clearing-custody.d.ts +202 -0
- package/dist/clearing-custody.js +552 -0
- package/dist/clearing-server.d.ts +30 -49
- package/dist/clearing-server.js +87 -67
- package/dist/clearing-x402.d.ts +38 -0
- package/dist/clearing-x402.js +249 -0
- package/dist/client.d.ts +14 -15
- package/dist/client.js +53 -22
- package/dist/index.d.ts +4 -1
- package/dist/index.js +4 -1
- package/dist/mandates/account.d.ts +9 -4
- package/dist/mandates/account.js +15 -11
- package/dist/mandates/binding.d.ts +4 -27
- package/dist/mandates/binding.js +5 -9
- package/dist/mandates/checkout.js +5 -5
- package/dist/mandates/client.js +4 -3
- package/dist/mandates/l3a.d.ts +2 -2
- package/dist/mandates/l3a.js +5 -4
- package/dist/mandates/seller.d.ts +1 -1
- package/dist/mandates/seller.js +4 -4
- package/dist/mandates/verify.d.ts +4 -6
- package/dist/mandates/verify.js +147 -82
- package/dist/mandates/vi/credential.js +4 -4
- package/dist/networks.d.ts +9 -1
- package/dist/networks.js +105 -4
- package/dist/node.d.ts +11 -0
- package/dist/node.js +97 -0
- package/package.json +18 -11
- package/src/clearing-abi.ts +2361 -0
- package/src/clearing-client.test.ts +850 -0
- package/src/clearing-client.ts +612 -0
- package/src/clearing-codec.test.ts +57 -0
- package/src/clearing-codec.ts +251 -0
- package/src/clearing-custody.test.ts +763 -0
- package/src/clearing-custody.ts +743 -0
- package/src/clearing-fixture.json +37 -0
- package/src/clearing-server.test.ts +450 -0
- package/src/clearing-server.ts +123 -0
- package/src/clearing-x402.test.ts +181 -0
- package/src/clearing-x402.ts +328 -0
- package/src/client.test.ts +333 -0
- package/src/client.ts +195 -0
- package/src/index.ts +33 -0
- package/src/mandates/account.ts +95 -0
- package/src/mandates/binding.ts +280 -0
- package/src/mandates/chain.ts +68 -0
- package/src/mandates/checkout.ts +368 -0
- package/src/mandates/client.ts +159 -0
- package/src/mandates/extension.ts +48 -0
- package/src/mandates/index.ts +121 -0
- package/src/mandates/ingest.ts +42 -0
- package/src/mandates/jcs.ts +16 -0
- package/src/mandates/l3a.ts +216 -0
- package/src/mandates/seller.ts +91 -0
- package/src/mandates/verify.ts +625 -0
- package/src/mandates/vi/credential.ts +477 -0
- package/src/mandates/vi/issue.ts +118 -0
- package/src/mandates/vi/sd-jwt.ts +189 -0
- package/src/networks.test.ts +177 -0
- package/src/networks.ts +242 -0
- package/src/node.test.ts +24 -0
- package/src/node.ts +96 -0
- package/src/server.test.ts +223 -0
- package/src/server.ts +77 -0
- package/dist/clearing.d.ts +0 -204
- package/dist/clearing.js +0 -151
- package/dist/file-storage.d.ts +0 -39
- package/dist/file-storage.js +0 -195
package/dist/clearing-server.js
CHANGED
|
@@ -1,81 +1,101 @@
|
|
|
1
|
+
import * as x402Server from "@x402/core/server";
|
|
2
|
+
import { formatUnits, isAddress, sha256, stringToHex } from "viem";
|
|
3
|
+
import { CLEARING_SCHEME, clearingAccount } from "./clearing-codec.js";
|
|
4
|
+
import { CLEARING_MAX_AMOUNT } from "./clearing-custody.js";
|
|
1
5
|
import { USDZ } from "./networks.js";
|
|
2
6
|
import { dollarBaseUnits } from "./server.js";
|
|
3
|
-
/**
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
receipt.receipt !== null
|
|
11
|
-
? receipt
|
|
12
|
-
: null;
|
|
13
|
-
}
|
|
14
|
-
/** Metal's clearing lane on the seller's side. Register it next to the
|
|
15
|
-
* standard scheme, and list both in a route's `accepts`:
|
|
16
|
-
*
|
|
17
|
-
* server
|
|
18
|
-
* .register(network, new ExactEvmScheme().registerMoneyParser(metalUsd(network)))
|
|
19
|
-
* .register(network, new ClearingDebitServerScheme());
|
|
20
|
-
* accepts: [
|
|
21
|
-
* { scheme: "exact", network, payTo, price: "$0.01" },
|
|
22
|
-
* { scheme: "clearing-debit", network, payTo, price: "$0.001" },
|
|
23
|
-
* ]
|
|
7
|
+
/**
|
|
8
|
+
* The `bajillion` scheme for an `x402ResourceServer`, registered beside
|
|
9
|
+
* `exact` on `network`. A route's `payTo` is the seller's clearing key,
|
|
10
|
+
* `signer.publicKey` of a wallet from `openClearingWallet`, and its `price`
|
|
11
|
+
* is dollars in `token`. The offer names the Clearing deployment the
|
|
12
|
+
* facilitator lists for the network in `/supported`, so a seller takes only
|
|
13
|
+
* payments its facilitator settles.
|
|
24
14
|
*
|
|
25
|
-
*
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
*
|
|
30
|
-
* `onReceipt` is handed the operator's signed receipt for every payment
|
|
31
|
-
* settled, and again with any later answer for the same payment; store it
|
|
32
|
-
* by its `receiptDigest`. */
|
|
33
|
-
export class ClearingDebitServerScheme {
|
|
15
|
+
* server.register(network, new ClearingServerScheme(network));
|
|
16
|
+
*/
|
|
17
|
+
export class ClearingServerScheme {
|
|
18
|
+
network;
|
|
34
19
|
token;
|
|
35
|
-
scheme =
|
|
36
|
-
|
|
37
|
-
|
|
20
|
+
scheme = CLEARING_SCHEME;
|
|
21
|
+
// The money moved when the clearing service issued the receipt, so the
|
|
22
|
+
// facilitator records it before the handler runs: a receipt presented
|
|
23
|
+
// again, or twice at once, is served at most once.
|
|
24
|
+
defaultAssetTransferMethod = "default";
|
|
25
|
+
paymentFlows = {
|
|
26
|
+
default: { supported: ["upfront"], default: "upfront" },
|
|
27
|
+
};
|
|
28
|
+
constructor(network, token = USDZ) {
|
|
29
|
+
this.network = network;
|
|
38
30
|
this.token = token;
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
if (receipt)
|
|
45
|
-
await onReceipt(receipt);
|
|
46
|
-
},
|
|
47
|
-
}
|
|
48
|
-
: {};
|
|
31
|
+
if (!/^eip155:\d+$/.test(network))
|
|
32
|
+
throw new Error(`not a network: ${network}`);
|
|
33
|
+
// A server without payment flows ignores `paymentFlows` and settles after the handler.
|
|
34
|
+
if (!("resolvePaymentFlow" in x402Server))
|
|
35
|
+
throw new Error("ClearingServerScheme needs @x402/core 2.28 or later, which settles a clearing payment before the handler runs");
|
|
49
36
|
}
|
|
50
37
|
async parsePrice(price) {
|
|
51
|
-
if (typeof price === "object")
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
38
|
+
if (typeof price === "object" && price !== null && "amount" in price) {
|
|
39
|
+
let amount;
|
|
40
|
+
try {
|
|
41
|
+
amount = BigInt(price.amount);
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
throw new Error(`not a price: ${price.amount}`);
|
|
45
|
+
}
|
|
46
|
+
if (amount <= 0n)
|
|
47
|
+
throw new Error(`not a price: ${price.amount}`);
|
|
48
|
+
return { amount: this.payable(amount).toString(), asset: price.asset ?? this.token.address, extra: price.extra ?? {} };
|
|
49
|
+
}
|
|
50
|
+
return { amount: this.payable(BigInt(dollarBaseUnits(price, this.token))).toString(), asset: this.token.address };
|
|
51
|
+
}
|
|
52
|
+
/** Clearing counts whole clearing units, at most 2^64 - 1 of them in all.
|
|
53
|
+
* The unit is the one `Clearing.unit()` derives from the token's decimals
|
|
54
|
+
* at deploy: a millionth of the token, or its base unit at six decimals or
|
|
55
|
+
* fewer. */
|
|
56
|
+
payable(amount) {
|
|
57
|
+
const decimals = this.token.decimals;
|
|
58
|
+
const unit = decimals > 6 ? 10n ** BigInt(decimals - 6) : 1n;
|
|
59
|
+
if (amount % unit !== 0n)
|
|
60
|
+
throw new Error(`a clearing price is a whole number of ${formatUnits(unit, decimals)} ${this.token.symbol}`);
|
|
61
|
+
if (amount / unit > CLEARING_MAX_AMOUNT)
|
|
62
|
+
throw new Error(`a clearing price is at most ${formatUnits(CLEARING_MAX_AMOUNT * unit, decimals)} ${this.token.symbol}`);
|
|
63
|
+
return amount;
|
|
57
64
|
}
|
|
58
65
|
getAssetDecimals() {
|
|
59
66
|
return this.token.decimals;
|
|
60
67
|
}
|
|
61
68
|
async enhancePaymentRequirements(requirements, supportedKind) {
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
69
|
+
if (requirements.network !== this.network)
|
|
70
|
+
throw new Error(`this clearing scheme is on ${this.network}; the route offers it on ${requirements.network}`);
|
|
71
|
+
try {
|
|
72
|
+
clearingAccount(requirements.payTo);
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
throw new Error(`a clearing offer's payTo is the seller's clearing key (signer.publicKey of a wallet from openClearingWallet); got ${requirements.payTo}`);
|
|
76
|
+
}
|
|
77
|
+
if (requirements.asset.toLowerCase() !== this.token.address.toLowerCase())
|
|
78
|
+
throw new Error(`this clearing scheme prices in ${this.token.address}; the route offers ${requirements.asset}`);
|
|
79
|
+
const prefix = requirements.extra?.invoicePrefix;
|
|
80
|
+
if (prefix !== undefined && (typeof prefix !== "string" || !/^0x[0-9a-f]{64}$/.test(prefix)))
|
|
81
|
+
throw new Error(`a route's invoicePrefix is 32 bytes of lowercase hex; got ${String(prefix)}`);
|
|
82
|
+
const { settlement, asset, serviceUrl } = supportedKind.extra ?? {};
|
|
83
|
+
if (typeof settlement !== "string" ||
|
|
84
|
+
!isAddress(settlement) ||
|
|
85
|
+
typeof asset !== "string" ||
|
|
86
|
+
asset.toLowerCase() !== this.token.address.toLowerCase() ||
|
|
87
|
+
typeof serviceUrl !== "string" ||
|
|
88
|
+
!URL.canParse(serviceUrl))
|
|
89
|
+
throw new Error(`the facilitator settles no clearing deployment in ${this.token.symbol} on ${this.network}`);
|
|
90
|
+
return { ...requirements, extra: { ...requirements.extra, settlement, serviceUrl } };
|
|
91
|
+
}
|
|
92
|
+
/** Binds this network's offers' invoices to the resource asked for, at
|
|
93
|
+
* the challenge and again when a payment for it is verified. A route that
|
|
94
|
+
* sets its own `invoicePrefix` keeps it. */
|
|
95
|
+
async enrichPaymentRequiredResponse(ctx) {
|
|
96
|
+
const invoicePrefix = sha256(stringToHex(ctx.resourceInfo.url));
|
|
97
|
+
return ctx.requirements.map((offer) => offer.scheme === CLEARING_SCHEME && offer.network === this.network && offer.extra?.invoicePrefix === undefined
|
|
98
|
+
? { ...offer, extra: { ...offer.extra, invoicePrefix } }
|
|
99
|
+
: offer);
|
|
80
100
|
}
|
|
81
101
|
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { PaymentRequirements, PaymentPayload } from "@x402/core/types";
|
|
2
|
+
import type { x402Client } from "@x402/core/client";
|
|
3
|
+
import { type Hex } from "viem";
|
|
4
|
+
import { type ClearingInvoice } from "./clearing-codec.js";
|
|
5
|
+
import type { ClearingPaid, createClearingClient } from "./clearing-client.js";
|
|
6
|
+
export interface ClearingSeller {
|
|
7
|
+
network: `eip155:${number}`;
|
|
8
|
+
asset: Hex;
|
|
9
|
+
settlement: Hex;
|
|
10
|
+
recipient: Hex;
|
|
11
|
+
serviceUrl: string;
|
|
12
|
+
}
|
|
13
|
+
/** A resource-bound invoice namespace; the payer appends a unique payment id. */
|
|
14
|
+
export declare function clearingRequirements(seller: ClearingSeller, amount: bigint, resource: string): PaymentRequirements;
|
|
15
|
+
export declare function clearingInvoice(requirements: PaymentRequirements, id: string): ClearingInvoice;
|
|
16
|
+
export declare function clearingPaymentOf(payload: Pick<PaymentPayload, "payload">): ClearingPaid | null;
|
|
17
|
+
export declare function clearingPaymentMismatch(paid: ClearingPaid, requirements: PaymentRequirements): string | null;
|
|
18
|
+
export declare function clearingPaymentId(paid: ClearingPaid): string;
|
|
19
|
+
/** Both the RPC and service URL must come from the seller's trusted configuration. */
|
|
20
|
+
export declare function verifyClearingPayment(paid: ClearingPaid, requirements: PaymentRequirements, config: {
|
|
21
|
+
rpcUrl: string;
|
|
22
|
+
serviceUrl: string;
|
|
23
|
+
settlement: Hex;
|
|
24
|
+
}): Promise<{
|
|
25
|
+
payer: `0x${string}`;
|
|
26
|
+
id: string;
|
|
27
|
+
status: "preconfirmed";
|
|
28
|
+
}>;
|
|
29
|
+
/** Pay by clearing through `client` on `network`. A request with an offer on
|
|
30
|
+
* `network` but none the wallet can pay fails, unless `exactFallback` is
|
|
31
|
+
* true: then it is paid by another offer of the request on `network`,
|
|
32
|
+
* `exact` from its EVM account, and fails when it has none. A request with
|
|
33
|
+
* no offer on `network` is left to the
|
|
34
|
+
* client's other registrations. A payment the clearing service does not take
|
|
35
|
+
* throws a `ClearingRefusal`. */
|
|
36
|
+
export declare function registerClearingClient(client: x402Client, network: `eip155:${number}`, payer: Pick<ReturnType<typeof createClearingClient>, "pay" | "unpayable" | "pendingInvoice">, { exactFallback }?: {
|
|
37
|
+
exactFallback?: boolean;
|
|
38
|
+
}): x402Client;
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
import { createPublicClient, sha256, stringToHex } from "viem";
|
|
2
|
+
import { clearingAbi } from "./clearing-abi.js";
|
|
3
|
+
import { CLEARING_SCHEME, clearingUnits, decodeSend, invoiceMessage, verifyClearingSignature, verifyReceipt, outgoingRoot, } from "./clearing-codec.js";
|
|
4
|
+
import { rpcTransport } from "./networks.js";
|
|
5
|
+
/** A resource-bound invoice namespace; the payer appends a unique payment id. */
|
|
6
|
+
export function clearingRequirements(seller, amount, resource) {
|
|
7
|
+
if (amount <= 0n)
|
|
8
|
+
throw new Error("invalid clearing price");
|
|
9
|
+
return {
|
|
10
|
+
scheme: CLEARING_SCHEME,
|
|
11
|
+
network: seller.network,
|
|
12
|
+
asset: seller.asset,
|
|
13
|
+
amount: amount.toString(),
|
|
14
|
+
payTo: seller.recipient,
|
|
15
|
+
maxTimeoutSeconds: 60,
|
|
16
|
+
extra: {
|
|
17
|
+
settlement: seller.settlement,
|
|
18
|
+
serviceUrl: seller.serviceUrl,
|
|
19
|
+
invoicePrefix: sha256(stringToHex(resource)),
|
|
20
|
+
},
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
export function clearingInvoice(requirements, id) {
|
|
24
|
+
const extra = requirements.extra;
|
|
25
|
+
if (requirements.scheme !== CLEARING_SCHEME ||
|
|
26
|
+
!/^0x[0-9a-fA-F]{66}$/.test(requirements.payTo) ||
|
|
27
|
+
typeof extra?.settlement !== "string" ||
|
|
28
|
+
!/^0x[0-9a-fA-F]{40}$/.test(extra.settlement) ||
|
|
29
|
+
typeof extra.invoicePrefix !== "string" ||
|
|
30
|
+
!/^0x[0-9a-f]{64}$/.test(extra.invoicePrefix) ||
|
|
31
|
+
!id.startsWith(extra.invoicePrefix + ":"))
|
|
32
|
+
throw new Error("invalid clearing requirement");
|
|
33
|
+
return {
|
|
34
|
+
id,
|
|
35
|
+
network: requirements.network,
|
|
36
|
+
asset: requirements.asset,
|
|
37
|
+
settlement: extra.settlement,
|
|
38
|
+
recipient: requirements.payTo,
|
|
39
|
+
amount: requirements.amount,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
export function clearingPaymentOf(payload) {
|
|
43
|
+
try {
|
|
44
|
+
const paid = payload.payload;
|
|
45
|
+
const body = decodeSend(paid.receipt.body);
|
|
46
|
+
invoiceMessage(paid.invoice, paid.receipt.body);
|
|
47
|
+
const entries = paid.entries.map((e) => ({
|
|
48
|
+
...e,
|
|
49
|
+
cumulative: BigInt(e.cumulative),
|
|
50
|
+
count: BigInt(e.count),
|
|
51
|
+
}));
|
|
52
|
+
if (entries.length > 1024 ||
|
|
53
|
+
outgoingRoot(entries) !== body.vector ||
|
|
54
|
+
entries.reduce((n, e) => n + e.cumulative, 0n) !== body.debit ||
|
|
55
|
+
!verifyClearingSignature(body.payer, "_METAL_CLEARING_INVOICE", invoiceMessage(paid.invoice, paid.receipt.body), paid.invoiceSignature))
|
|
56
|
+
return null;
|
|
57
|
+
return paid;
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
export function clearingPaymentMismatch(paid, requirements) {
|
|
64
|
+
try {
|
|
65
|
+
return invoiceMessage(clearingInvoice(requirements, paid.invoice.id), paid.receipt.body) === invoiceMessage(paid.invoice, paid.receipt.body)
|
|
66
|
+
? null
|
|
67
|
+
: "invoice";
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
return "invoice";
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
export function clearingPaymentId(paid) {
|
|
74
|
+
return `${paid.invoice.network}:bajillion:${paid.invoice.settlement.toLowerCase()}:${sha256(paid.receipt.body)}`;
|
|
75
|
+
}
|
|
76
|
+
/** Both the RPC and service URL must come from the seller's trusted configuration. */
|
|
77
|
+
export async function verifyClearingPayment(paid, requirements, config) {
|
|
78
|
+
if (clearingPaymentMismatch(paid, requirements) ||
|
|
79
|
+
paid.invoice.settlement.toLowerCase() !== config.settlement.toLowerCase())
|
|
80
|
+
throw new Error("clearing invoice mismatch");
|
|
81
|
+
if (!clearingPaymentOf({ payload: paid }))
|
|
82
|
+
throw new Error("invalid clearing payment");
|
|
83
|
+
const send = decodeSend(paid.receipt.body);
|
|
84
|
+
const client = createPublicClient({ transport: rpcTransport(config.rpcUrl) });
|
|
85
|
+
const [context, fault, asset, chainId, unit] = await Promise.all([
|
|
86
|
+
client.readContract({
|
|
87
|
+
address: config.settlement,
|
|
88
|
+
abi: clearingAbi,
|
|
89
|
+
functionName: "contextOf",
|
|
90
|
+
args: [send.epoch],
|
|
91
|
+
}),
|
|
92
|
+
client.readContract({
|
|
93
|
+
address: config.settlement,
|
|
94
|
+
abi: clearingAbi,
|
|
95
|
+
functionName: "fault",
|
|
96
|
+
}),
|
|
97
|
+
client.readContract({
|
|
98
|
+
address: config.settlement,
|
|
99
|
+
abi: clearingAbi,
|
|
100
|
+
functionName: "token",
|
|
101
|
+
}),
|
|
102
|
+
client.getChainId(),
|
|
103
|
+
client.readContract({
|
|
104
|
+
address: config.settlement,
|
|
105
|
+
abi: clearingAbi,
|
|
106
|
+
functionName: "unit",
|
|
107
|
+
}),
|
|
108
|
+
]);
|
|
109
|
+
if (fault ||
|
|
110
|
+
`eip155:${chainId}` !== paid.invoice.network ||
|
|
111
|
+
asset.toLowerCase() !== paid.invoice.asset.toLowerCase() ||
|
|
112
|
+
context.anchor !== send.anchor ||
|
|
113
|
+
!verifyReceipt(paid.receipt, context.operatorKey))
|
|
114
|
+
throw new Error("invalid clearing receipt");
|
|
115
|
+
const previous = paid.previous;
|
|
116
|
+
let before = [];
|
|
117
|
+
if (send.sequence === 0n) {
|
|
118
|
+
if (previous)
|
|
119
|
+
throw new Error("unexpected predecessor receipt");
|
|
120
|
+
}
|
|
121
|
+
else {
|
|
122
|
+
if (!previous || !verifyReceipt(previous.receipt, context.operatorKey))
|
|
123
|
+
throw new Error("missing predecessor receipt");
|
|
124
|
+
const prior = decodeSend(previous.receipt.body);
|
|
125
|
+
before = previous.entries;
|
|
126
|
+
if (prior.epoch !== send.epoch ||
|
|
127
|
+
prior.anchor !== send.anchor ||
|
|
128
|
+
prior.payer !== send.payer ||
|
|
129
|
+
prior.sequence + 1n !== send.sequence ||
|
|
130
|
+
outgoingRoot(before.map((e) => ({
|
|
131
|
+
...e,
|
|
132
|
+
cumulative: BigInt(e.cumulative),
|
|
133
|
+
count: BigInt(e.count),
|
|
134
|
+
}))) !== prior.vector)
|
|
135
|
+
throw new Error("invalid predecessor vector");
|
|
136
|
+
}
|
|
137
|
+
const expected = new Map(before.map((e) => [e.recipient.toLowerCase(), { ...e }]));
|
|
138
|
+
const key = paid.invoice.recipient.toLowerCase();
|
|
139
|
+
const old = expected.get(key);
|
|
140
|
+
// The invoice is in token base units; the vector counts clearing units.
|
|
141
|
+
expected.set(key, {
|
|
142
|
+
recipient: paid.invoice.recipient,
|
|
143
|
+
cumulative: ((old ? BigInt(old.cumulative) : 0n) +
|
|
144
|
+
clearingUnits(BigInt(paid.invoice.amount), unit)).toString(),
|
|
145
|
+
count: ((old ? BigInt(old.count) : 0n) + 1n).toString(),
|
|
146
|
+
});
|
|
147
|
+
if (expected.size !== paid.entries.length ||
|
|
148
|
+
paid.entries.some((e) => {
|
|
149
|
+
const wanted = expected.get(e.recipient.toLowerCase());
|
|
150
|
+
return (!wanted ||
|
|
151
|
+
wanted.count !== e.count ||
|
|
152
|
+
wanted.cumulative !== e.cumulative);
|
|
153
|
+
}))
|
|
154
|
+
throw new Error("invoice is not the incremental payment");
|
|
155
|
+
// The operator's durable invoice journal attests the incremental charge,
|
|
156
|
+
// which cannot be inferred from a cumulative vector alone.
|
|
157
|
+
const response = await fetch(new URL(`/receipt/${send.payer}/${encodeURIComponent(paid.invoice.id)}?epoch=${send.epoch}`, config.serviceUrl), { signal: AbortSignal.timeout(10_000) });
|
|
158
|
+
if (!response.ok)
|
|
159
|
+
throw new Error("clearing receipt unavailable");
|
|
160
|
+
const recorded = (await response.json());
|
|
161
|
+
if (!recorded ||
|
|
162
|
+
recorded.receipt.body !== paid.receipt.body ||
|
|
163
|
+
recorded.receipt.operatorSignature !== paid.receipt.operatorSignature ||
|
|
164
|
+
invoiceMessage(recorded.invoice, recorded.receipt.body) !==
|
|
165
|
+
invoiceMessage(paid.invoice, paid.receipt.body))
|
|
166
|
+
throw new Error("clearing invoice not retained");
|
|
167
|
+
return {
|
|
168
|
+
payer: send.payer,
|
|
169
|
+
id: clearingPaymentId(paid),
|
|
170
|
+
status: "preconfirmed",
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
/** Pay by clearing through `client` on `network`. A request with an offer on
|
|
174
|
+
* `network` but none the wallet can pay fails, unless `exactFallback` is
|
|
175
|
+
* true: then it is paid by another offer of the request on `network`,
|
|
176
|
+
* `exact` from its EVM account, and fails when it has none. A request with
|
|
177
|
+
* no offer on `network` is left to the
|
|
178
|
+
* client's other registrations. A payment the clearing service does not take
|
|
179
|
+
* throws a `ClearingRefusal`. */
|
|
180
|
+
export function registerClearingClient(client, network, payer, { exactFallback = false } = {}) {
|
|
181
|
+
// The URL each offer was asked for, kept with a payment whose outcome is
|
|
182
|
+
// unknown so a refusal names the request to send again.
|
|
183
|
+
const urls = new WeakMap();
|
|
184
|
+
client.register(network, {
|
|
185
|
+
scheme: CLEARING_SCHEME,
|
|
186
|
+
schemeHooks: {
|
|
187
|
+
async onBeforePaymentCreation({ paymentRequired, selectedRequirements }) {
|
|
188
|
+
urls.set(selectedRequirements, paymentRequired.resource.url);
|
|
189
|
+
},
|
|
190
|
+
},
|
|
191
|
+
async createPaymentPayload(x402Version, requirements) {
|
|
192
|
+
const pending = await payer.pendingInvoice();
|
|
193
|
+
// A payment held for another URL is the wallet's to refuse, naming it.
|
|
194
|
+
const prefix = `${requirements.extra?.invoicePrefix}:`;
|
|
195
|
+
const invoice = clearingInvoice(requirements, pending?.id.startsWith(prefix)
|
|
196
|
+
? pending.id
|
|
197
|
+
: prefix + globalThis.crypto.randomUUID());
|
|
198
|
+
const paid = await payer.pay(invoice, urls.get(requirements));
|
|
199
|
+
return {
|
|
200
|
+
x402Version,
|
|
201
|
+
payload: paid,
|
|
202
|
+
};
|
|
203
|
+
},
|
|
204
|
+
});
|
|
205
|
+
// The stock client keeps only `authorization` offers when any remain after
|
|
206
|
+
// its policies, and a clearing offer is `upfront`: so a payer set up for
|
|
207
|
+
// clearing keeps the clearing offers its wallet can pay, when there are any.
|
|
208
|
+
client.registerPolicy((_, offers) => {
|
|
209
|
+
if (!offers.some((offer) => offer.network === network))
|
|
210
|
+
return offers;
|
|
211
|
+
const offered = offers.filter((offer) => offer.scheme === CLEARING_SCHEME && offer.network === network);
|
|
212
|
+
// Why the wallet does not pay `offer`, or undefined when it does.
|
|
213
|
+
const unpaid = (offer) => {
|
|
214
|
+
if (!/^[0-9]+$/.test(offer.amount) || BigInt(offer.amount) === 0n)
|
|
215
|
+
return { reason: `offer is priced at "${offer.amount}", not a positive whole number of base units`, aboveMaxAmount: false };
|
|
216
|
+
let invoice;
|
|
217
|
+
try {
|
|
218
|
+
invoice = clearingInvoice(offer, `${offer.extra?.invoicePrefix}:`);
|
|
219
|
+
}
|
|
220
|
+
catch {
|
|
221
|
+
return { reason: "offer is malformed (invalid clearing requirement)", aboveMaxAmount: false };
|
|
222
|
+
}
|
|
223
|
+
return payer.unpayable(invoice);
|
|
224
|
+
};
|
|
225
|
+
const reasons = offered.map(unpaid);
|
|
226
|
+
const clearing = offered.filter((_, i) => reasons[i] === undefined);
|
|
227
|
+
if (clearing.length > 0)
|
|
228
|
+
return clearing;
|
|
229
|
+
if (exactFallback) {
|
|
230
|
+
// The offers this wallet just refused stay out: the stock selector can
|
|
231
|
+
// choose them again, since a clearing offer with no extra.paymentFlow
|
|
232
|
+
// is otherwise indistinguishable from one it can pay. The fallback is
|
|
233
|
+
// for this request's offers on `network`; with none left, it fails.
|
|
234
|
+
if (offers.some((offer) => offer.network === network && !offered.includes(offer)))
|
|
235
|
+
return offers.filter((offer) => !offered.includes(offer));
|
|
236
|
+
}
|
|
237
|
+
const why = offered.length > 0
|
|
238
|
+
? `this clearing wallet pays none of the clearing offers here (${[...new Set(reasons.map((r) => r?.reason))].join("; ")})`
|
|
239
|
+
: `this request offers no clearing payment on ${network}`;
|
|
240
|
+
// An offer above the wallet's maxAmount is for its own deployment: the
|
|
241
|
+
// limit or the seller is what to change, not the scheme.
|
|
242
|
+
throw new Error(reasons.some((r) => r?.aboveMaxAmount)
|
|
243
|
+
? `${why}; nothing was paid: raise the wallet's maxAmount or pay another seller`
|
|
244
|
+
: exactFallback
|
|
245
|
+
? `${why}, and this request offers no other way to pay it; nothing was paid`
|
|
246
|
+
: `${why}, and paying by exact is off; nothing was paid: pass exactFallback: true to registerClearingClient to pay such a request by exact`);
|
|
247
|
+
});
|
|
248
|
+
return client;
|
|
249
|
+
}
|
package/dist/client.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { x402Client } from "@x402/core/client";
|
|
2
2
|
import { wrapFetchWithPayment } from "@x402/fetch";
|
|
3
3
|
import type { ClientEvmSigner } from "@x402/evm";
|
|
4
|
-
import type {
|
|
4
|
+
import type { Network } from "@x402/core/types";
|
|
5
5
|
import { type MetalNetwork, type MetalNetworkName } from "./networks.js";
|
|
6
6
|
/**
|
|
7
7
|
* What signs a payment: a viem local account (`privateKeyToAccount`) or
|
|
@@ -17,8 +17,8 @@ export interface RegisterMetalNetworksConfig {
|
|
|
17
17
|
* Register Metal's devnet and testnet with an `@x402/core` client for the
|
|
18
18
|
* `exact` scheme. The client signs an `exact` offer on `eip155:1337` or
|
|
19
19
|
* `eip155:63825` only when it is in `*USDZ` under USDZ's EIP-712 domain, and
|
|
20
|
-
* refuses any other before signing. Offers in other schemes
|
|
21
|
-
*
|
|
20
|
+
* refuses any other before signing. Offers in other schemes are left to
|
|
21
|
+
* the schemes explicitly registered for them.
|
|
22
22
|
*/
|
|
23
23
|
export declare function registerMetalNetworks(client: x402Client, { signer, networks }: RegisterMetalNetworksConfig): x402Client;
|
|
24
24
|
/** The most a payment client pays for one offer unless told otherwise: 1 `*USDZ`, in base units. */
|
|
@@ -31,21 +31,16 @@ export interface MetalPaymentClientConfig {
|
|
|
31
31
|
fetch?: typeof globalThis.fetch;
|
|
32
32
|
/**
|
|
33
33
|
* The most one payment may cost, in `*USDZ` base units; `DEFAULT_MAX_PAYMENT`
|
|
34
|
-
* by default. An offer above it is refused before anything is signed.
|
|
34
|
+
* by default. An offer above it is refused before anything is signed. A
|
|
35
|
+
* clearing payment is capped by its wallet's own `maxAmount` instead.
|
|
35
36
|
*/
|
|
36
37
|
maxAmount?: bigint;
|
|
37
|
-
/**
|
|
38
|
-
* Metal clearing, for sellers that offer it: the client pays by clearing
|
|
39
|
-
* and, while custody is short, by `exact` instead.
|
|
40
|
-
*/
|
|
41
|
-
clearing?: ClearingDebitScheme;
|
|
42
38
|
}
|
|
43
39
|
/**
|
|
44
|
-
*
|
|
45
|
-
* controls list others. This lists `asset` on `network
|
|
46
|
-
* `maxAmountPerPayment` base units when one is given.
|
|
40
|
+
* The stock client pays only assets in its own table unless its spend
|
|
41
|
+
* controls list others. This lists `asset` on `network`.
|
|
47
42
|
*/
|
|
48
|
-
export declare function allowAsset(client: x402Client, network:
|
|
43
|
+
export declare function allowAsset(client: x402Client, network: Network, asset: string): void;
|
|
49
44
|
/**
|
|
50
45
|
* `fetch` that follows the redirects of a request carrying a payment only
|
|
51
46
|
* within the origin it was made for, and answers a redirect to any other
|
|
@@ -53,10 +48,14 @@ export declare function allowAsset(client: x402Client, network: string, asset: s
|
|
|
53
48
|
*/
|
|
54
49
|
export declare function keepPaymentOnOrigin(fetch: typeof globalThis.fetch): typeof globalThis.fetch;
|
|
55
50
|
export interface MetalPaymentClient {
|
|
56
|
-
/**
|
|
51
|
+
/**
|
|
52
|
+
* `fetch` that answers a 402 by paying and retrying, once. When the
|
|
53
|
+
* clearing service does not take a payment, the error's `cause` is the
|
|
54
|
+
* `ClearingRefusal`.
|
|
55
|
+
*/
|
|
57
56
|
fetchWithPayment: ReturnType<typeof wrapFetchWithPayment>;
|
|
58
57
|
client: x402Client;
|
|
59
58
|
network: MetalNetwork;
|
|
60
59
|
}
|
|
61
60
|
/** One call to pay: `const { fetchWithPayment } = createMetalPaymentClient({ account, network: "devnet" })`. */
|
|
62
|
-
export declare function createMetalPaymentClient({ account, network, fetch, maxAmount,
|
|
61
|
+
export declare function createMetalPaymentClient({ account, network, fetch, maxAmount, }: MetalPaymentClientConfig): MetalPaymentClient;
|
package/dist/client.js
CHANGED
|
@@ -1,13 +1,15 @@
|
|
|
1
|
-
import { x402Client } from "@x402/core/client";
|
|
1
|
+
import { x402Client, x402HTTPClient } from "@x402/core/client";
|
|
2
2
|
import { wrapFetchWithPayment } from "@x402/fetch";
|
|
3
3
|
import { ExactEvmScheme } from "@x402/evm/exact/client";
|
|
4
|
+
import { ClearingRefusal } from "./clearing-client.js";
|
|
5
|
+
import { CLEARING_SCHEME } from "./clearing-codec.js";
|
|
4
6
|
import { METAL_NETWORKS, USDZ, formatUsdz, usdzBaseUnits, resolveNetwork, } from "./networks.js";
|
|
5
7
|
/**
|
|
6
8
|
* Register Metal's devnet and testnet with an `@x402/core` client for the
|
|
7
9
|
* `exact` scheme. The client signs an `exact` offer on `eip155:1337` or
|
|
8
10
|
* `eip155:63825` only when it is in `*USDZ` under USDZ's EIP-712 domain, and
|
|
9
|
-
* refuses any other before signing. Offers in other schemes
|
|
10
|
-
*
|
|
11
|
+
* refuses any other before signing. Offers in other schemes are left to
|
|
12
|
+
* the schemes explicitly registered for them.
|
|
11
13
|
*/
|
|
12
14
|
export function registerMetalNetworks(client, { signer, networks }) {
|
|
13
15
|
const scheme = new ExactEvmScheme(signer);
|
|
@@ -19,7 +21,10 @@ export function registerMetalNetworks(client, { signer, networks }) {
|
|
|
19
21
|
if (scheme !== "exact" || !registered.includes(network))
|
|
20
22
|
return;
|
|
21
23
|
if (asset.toLowerCase() !== USDZ.address.toLowerCase()) {
|
|
22
|
-
return {
|
|
24
|
+
return {
|
|
25
|
+
abort: true,
|
|
26
|
+
reason: `offer is in ${asset}, not ${USDZ.symbol} (${USDZ.address})`,
|
|
27
|
+
};
|
|
23
28
|
}
|
|
24
29
|
if (extra?.name !== USDZ.name || extra?.version !== USDZ.version) {
|
|
25
30
|
return {
|
|
@@ -33,15 +38,11 @@ export function registerMetalNetworks(client, { signer, networks }) {
|
|
|
33
38
|
/** The most a payment client pays for one offer unless told otherwise: 1 `*USDZ`, in base units. */
|
|
34
39
|
export const DEFAULT_MAX_PAYMENT = BigInt(usdzBaseUnits("1"));
|
|
35
40
|
/**
|
|
36
|
-
*
|
|
37
|
-
* controls list others. This lists `asset` on `network
|
|
38
|
-
* `maxAmountPerPayment` base units when one is given.
|
|
41
|
+
* The stock client pays only assets in its own table unless its spend
|
|
42
|
+
* controls list others. This lists `asset` on `network`.
|
|
39
43
|
*/
|
|
40
|
-
export function allowAsset(client, network, asset
|
|
41
|
-
|
|
42
|
-
controlled.setSpendControls?.({
|
|
43
|
-
allowedAssets: [{ network, asset, ...(maxAmountPerPayment !== undefined && { maxAmountPerPayment: maxAmountPerPayment.toString() }) }],
|
|
44
|
-
});
|
|
44
|
+
export function allowAsset(client, network, asset) {
|
|
45
|
+
client.setSpendControls({ allowedAssets: [{ network, asset }] });
|
|
45
46
|
}
|
|
46
47
|
const REDIRECTS = new Set([301, 302, 303, 307, 308]);
|
|
47
48
|
/**
|
|
@@ -73,27 +74,57 @@ export function keepPaymentOnOrigin(fetch) {
|
|
|
73
74
|
};
|
|
74
75
|
}
|
|
75
76
|
/** One call to pay: `const { fetchWithPayment } = createMetalPaymentClient({ account, network: "devnet" })`. */
|
|
76
|
-
export function createMetalPaymentClient({ account, network, fetch = globalThis.fetch, maxAmount = DEFAULT_MAX_PAYMENT,
|
|
77
|
+
export function createMetalPaymentClient({ account, network, fetch = globalThis.fetch, maxAmount = DEFAULT_MAX_PAYMENT, }) {
|
|
77
78
|
const resolved = resolveNetwork(network);
|
|
78
|
-
|
|
79
|
-
const client = registerMetalNetworks(new x402Client(clearing?.prefer), {
|
|
79
|
+
const client = registerMetalNetworks(new x402Client(), {
|
|
80
80
|
signer: account,
|
|
81
81
|
networks: [resolved],
|
|
82
82
|
});
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
// @x402/fetch signs whatever amount an offer asks; this hook is the payer's
|
|
87
|
-
|
|
88
|
-
|
|
83
|
+
// The hooks are the guard: the client's own spend controls would refuse
|
|
84
|
+
// first, naming nothing of the offer, so they stand aside on this client.
|
|
85
|
+
client.setSpendControls({ allowedAssets: true });
|
|
86
|
+
// @x402/fetch signs whatever amount an offer asks; this hook is the payer's
|
|
87
|
+
// spending guard. A clearing wallet guards its own payments.
|
|
88
|
+
client.onBeforePaymentCreation(async ({ selectedRequirements: { scheme, amount } }) => {
|
|
89
|
+
if (scheme === CLEARING_SCHEME || BigInt(amount) <= maxAmount)
|
|
89
90
|
return;
|
|
90
91
|
return {
|
|
91
92
|
abort: true,
|
|
92
93
|
reason: `offer of ${formatUsdz(amount)} ${USDZ.symbol} is above the ${formatUsdz(maxAmount)} ${USDZ.symbol} limit per payment`,
|
|
93
94
|
};
|
|
94
95
|
});
|
|
96
|
+
const paid = keepPaymentOnOrigin(fetch);
|
|
95
97
|
return {
|
|
96
|
-
|
|
98
|
+
// @x402/fetch rethrows a failure to pay as a new Error with the message
|
|
99
|
+
// alone; each request keeps its clearing refusal to give as the cause.
|
|
100
|
+
fetchWithPayment: async (input, init) => {
|
|
101
|
+
let refused;
|
|
102
|
+
// A clearing payment records the URL this request went to, as the
|
|
103
|
+
// fetch resolved it, for a refusal to name: behind a proxy, the
|
|
104
|
+
// seller's own name for the resource can differ from it. The seller
|
|
105
|
+
// receives its own name back.
|
|
106
|
+
let url;
|
|
107
|
+
const sending = (request, options) => {
|
|
108
|
+
url ??= request instanceof Request ? request.url : String(request);
|
|
109
|
+
return paid(request, options);
|
|
110
|
+
};
|
|
111
|
+
const paying = new x402HTTPClient(client);
|
|
112
|
+
paying.createPaymentPayload = (paymentRequired) => client
|
|
113
|
+
.createPaymentPayload({ ...paymentRequired, resource: { ...paymentRequired.resource, url: url ?? paymentRequired.resource.url } })
|
|
114
|
+
.then((payload) => ({ ...payload, resource: paymentRequired.resource }), (error) => {
|
|
115
|
+
if (error instanceof ClearingRefusal)
|
|
116
|
+
refused = error;
|
|
117
|
+
throw error;
|
|
118
|
+
});
|
|
119
|
+
try {
|
|
120
|
+
return await wrapFetchWithPayment(sending, paying)(input, init);
|
|
121
|
+
}
|
|
122
|
+
catch (error) {
|
|
123
|
+
if (!refused)
|
|
124
|
+
throw error;
|
|
125
|
+
throw new Error(`Failed to create payment payload: ${refused.message}`, { cause: refused });
|
|
126
|
+
}
|
|
127
|
+
},
|
|
97
128
|
client,
|
|
98
129
|
network: resolved,
|
|
99
130
|
};
|
package/dist/index.d.ts
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
export { METAL_NETWORKS, USDZ, formatUsdz, metalNetwork, usdzBaseUnits, accountFactoryFor, rpcUrlFor, type DollarToken, type MetalNetwork, type MetalNetworkName, } from "./networks.js";
|
|
2
2
|
export { DEFAULT_MAX_PAYMENT, createMetalPaymentClient, registerMetalNetworks, type MetalPaymentClient, type MetalPaymentClientConfig, type MetalPaymentSigner, type RegisterMetalNetworksConfig, } from "./client.js";
|
|
3
3
|
export { metalUsd, registerMetalScheme, sellerToken, type RegisterMetalSchemeConfig, } from "./server.js";
|
|
4
|
-
export * from "./clearing.js";
|
|
4
|
+
export * from "./clearing-codec.js";
|
|
5
|
+
export { clearingAbi } from "./clearing-abi.js";
|
|
5
6
|
export * from "./clearing-client.js";
|
|
7
|
+
export * from "./clearing-x402.js";
|
|
6
8
|
export * from "./clearing-server.js";
|
|
9
|
+
export * from "./clearing-custody.js";
|