@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
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import { x402Client } from "@x402/core/client";
|
|
2
|
+
import { describe, it, expect } from "vitest";
|
|
3
|
+
import {
|
|
4
|
+
registerClearingClient,
|
|
5
|
+
clearingRequirements,
|
|
6
|
+
clearingInvoice,
|
|
7
|
+
clearingPaymentOf,
|
|
8
|
+
clearingPaymentMismatch,
|
|
9
|
+
clearingPaymentId,
|
|
10
|
+
} from "./clearing-x402.js";
|
|
11
|
+
import {
|
|
12
|
+
createClearingSigner,
|
|
13
|
+
clearingUnits,
|
|
14
|
+
decodeSend,
|
|
15
|
+
encodeSend,
|
|
16
|
+
outgoingRoot,
|
|
17
|
+
invoiceMessage,
|
|
18
|
+
} from "./clearing-codec.js";
|
|
19
|
+
import type { ClearingInvoice } from "./clearing-codec.js";
|
|
20
|
+
import type { ClearingPaid } from "./clearing-client.js";
|
|
21
|
+
|
|
22
|
+
describe("native x402 invoice binding", () => {
|
|
23
|
+
const payer = createClearingSigner(`0x${"12".repeat(32)}`);
|
|
24
|
+
const operator = createClearingSigner(`0x${"23".repeat(32)}`);
|
|
25
|
+
const requirements = clearingRequirements(
|
|
26
|
+
{
|
|
27
|
+
network: "eip155:1337",
|
|
28
|
+
asset: `0x${"11".repeat(20)}`,
|
|
29
|
+
settlement: `0x${"22".repeat(20)}`,
|
|
30
|
+
recipient: operator.publicKey,
|
|
31
|
+
serviceUrl: "http://localhost:7091",
|
|
32
|
+
},
|
|
33
|
+
5n,
|
|
34
|
+
"/coffee/cart-1",
|
|
35
|
+
);
|
|
36
|
+
const invoice = clearingInvoice(
|
|
37
|
+
requirements,
|
|
38
|
+
`${requirements.extra!.invoicePrefix}:purchase-1`,
|
|
39
|
+
);
|
|
40
|
+
const entries = [
|
|
41
|
+
{ recipient: operator.publicKey, cumulative: "5", count: "1" },
|
|
42
|
+
];
|
|
43
|
+
const body = encodeSend({
|
|
44
|
+
anchor: `0x${"00".repeat(32)}`,
|
|
45
|
+
epoch: 1n,
|
|
46
|
+
payer: payer.publicKey,
|
|
47
|
+
sequence: 0n,
|
|
48
|
+
debit: 5n,
|
|
49
|
+
vector: outgoingRoot([
|
|
50
|
+
{ recipient: operator.publicKey, cumulative: 5n, count: 1n },
|
|
51
|
+
]),
|
|
52
|
+
});
|
|
53
|
+
const paid: ClearingPaid = {
|
|
54
|
+
invoice,
|
|
55
|
+
entries,
|
|
56
|
+
invoiceSignature: payer.sign(
|
|
57
|
+
"_METAL_CLEARING_INVOICE",
|
|
58
|
+
invoiceMessage(invoice, body),
|
|
59
|
+
),
|
|
60
|
+
receipt: {
|
|
61
|
+
body,
|
|
62
|
+
payerSignature: payer.sign("_COMMONWARE_CLEARING_VECTOR_SEND", body),
|
|
63
|
+
operatorSignature: operator.sign("_COMMONWARE_CLEARING_VECTOR_ACK", body),
|
|
64
|
+
},
|
|
65
|
+
};
|
|
66
|
+
it.each([
|
|
67
|
+
{ amount: 19n * 10n ** 18n, unit: 10n ** 12n },
|
|
68
|
+
{ amount: ((1n << 64n) - 1n) * 10n ** 30n, unit: 10n ** 30n },
|
|
69
|
+
])("binds a $amount base-unit invoice", ({ amount, unit }) => {
|
|
70
|
+
const largeInvoice = { ...invoice, amount: amount.toString() };
|
|
71
|
+
const cumulative = clearingUnits(amount, unit);
|
|
72
|
+
const vector = [{ recipient: operator.publicKey, cumulative, count: 1n }];
|
|
73
|
+
const largeBody = encodeSend({
|
|
74
|
+
...decodeSend(body),
|
|
75
|
+
debit: cumulative,
|
|
76
|
+
vector: outgoingRoot(vector),
|
|
77
|
+
});
|
|
78
|
+
const largePaid: ClearingPaid = {
|
|
79
|
+
invoice: largeInvoice,
|
|
80
|
+
entries: [{ ...entries[0]!, cumulative: cumulative.toString() }],
|
|
81
|
+
invoiceSignature: payer.sign(
|
|
82
|
+
"_METAL_CLEARING_INVOICE",
|
|
83
|
+
invoiceMessage(largeInvoice, largeBody),
|
|
84
|
+
),
|
|
85
|
+
receipt: {
|
|
86
|
+
body: largeBody,
|
|
87
|
+
payerSignature: payer.sign("_COMMONWARE_CLEARING_VECTOR_SEND", largeBody),
|
|
88
|
+
operatorSignature: operator.sign(
|
|
89
|
+
"_COMMONWARE_CLEARING_VECTOR_ACK",
|
|
90
|
+
largeBody,
|
|
91
|
+
),
|
|
92
|
+
},
|
|
93
|
+
};
|
|
94
|
+
expect(clearingPaymentOf({ payload: largePaid as never })).toEqual(largePaid);
|
|
95
|
+
expect(
|
|
96
|
+
clearingPaymentMismatch(largePaid, {
|
|
97
|
+
...requirements,
|
|
98
|
+
amount: amount.toString(),
|
|
99
|
+
}),
|
|
100
|
+
).toBeNull();
|
|
101
|
+
expect(
|
|
102
|
+
clearingPaymentOf({
|
|
103
|
+
payload: {
|
|
104
|
+
...largePaid,
|
|
105
|
+
invoice: { ...largeInvoice, amount: (amount + 1n).toString() },
|
|
106
|
+
},
|
|
107
|
+
}),
|
|
108
|
+
).toBeNull();
|
|
109
|
+
});
|
|
110
|
+
it.each([-1n, 1n << 256n])(
|
|
111
|
+
"rejects an invoice amount outside uint256: %s",
|
|
112
|
+
(amount) => {
|
|
113
|
+
expect(() =>
|
|
114
|
+
invoiceMessage({ ...invoice, amount: amount.toString() }, body),
|
|
115
|
+
).toThrow();
|
|
116
|
+
},
|
|
117
|
+
);
|
|
118
|
+
it("rejects altered invoice amounts and cross-resource reuse", () => {
|
|
119
|
+
expect(clearingPaymentOf({ payload: paid as never })).toEqual(paid);
|
|
120
|
+
expect(
|
|
121
|
+
clearingPaymentOf({
|
|
122
|
+
payload: { ...paid, invoice: { ...invoice, amount: "6" } },
|
|
123
|
+
}),
|
|
124
|
+
).toBeNull();
|
|
125
|
+
expect(clearingPaymentMismatch(paid, requirements)).toBeNull();
|
|
126
|
+
expect(
|
|
127
|
+
clearingPaymentMismatch(paid, {
|
|
128
|
+
...requirements,
|
|
129
|
+
extra: { ...requirements.extra, invoicePrefix: `0x${"33".repeat(32)}` },
|
|
130
|
+
}),
|
|
131
|
+
).toBe("invoice");
|
|
132
|
+
expect(clearingPaymentId(paid)).toBe(
|
|
133
|
+
clearingPaymentId({
|
|
134
|
+
...paid,
|
|
135
|
+
invoice: { ...invoice, id: invoice.id + "other" },
|
|
136
|
+
}),
|
|
137
|
+
);
|
|
138
|
+
});
|
|
139
|
+
it("rejects a different vector or cumulative debit", () => {
|
|
140
|
+
expect(
|
|
141
|
+
clearingPaymentOf({
|
|
142
|
+
payload: { ...paid, entries: [{ ...entries[0], cumulative: "6" }] },
|
|
143
|
+
}),
|
|
144
|
+
).toBeNull();
|
|
145
|
+
});
|
|
146
|
+
it("retries the pending invoice after the x402 adapter loses a response", async () => {
|
|
147
|
+
let pending: ClearingInvoice | null = null;
|
|
148
|
+
const ids: string[] = [];
|
|
149
|
+
const payer = {
|
|
150
|
+
async pendingInvoice() {
|
|
151
|
+
return pending;
|
|
152
|
+
},
|
|
153
|
+
unpayable: () => undefined,
|
|
154
|
+
async pay(next: ClearingInvoice) {
|
|
155
|
+
ids.push(next.id);
|
|
156
|
+
if (!pending) {
|
|
157
|
+
pending = next;
|
|
158
|
+
throw new Error("response lost");
|
|
159
|
+
}
|
|
160
|
+
expect(next).toEqual(pending);
|
|
161
|
+
pending = null;
|
|
162
|
+
return { ...paid, invoice: next };
|
|
163
|
+
},
|
|
164
|
+
};
|
|
165
|
+
const client = registerClearingClient(
|
|
166
|
+
new x402Client().setSpendControls({ allowedAssets: true }),
|
|
167
|
+
"eip155:1337",
|
|
168
|
+
payer as never,
|
|
169
|
+
);
|
|
170
|
+
const offer = {
|
|
171
|
+
x402Version: 2,
|
|
172
|
+
resource: { url: "/coffee/cart-1" },
|
|
173
|
+
accepts: [requirements],
|
|
174
|
+
};
|
|
175
|
+
await expect(client.createPaymentPayload(offer)).rejects.toThrow(
|
|
176
|
+
"response lost",
|
|
177
|
+
);
|
|
178
|
+
await client.createPaymentPayload(offer);
|
|
179
|
+
expect(ids[0]).toBe(ids[1]);
|
|
180
|
+
});
|
|
181
|
+
});
|
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
import type { PaymentRequirements, PaymentPayload } from "@x402/core/types";
|
|
2
|
+
import type { x402Client } from "@x402/core/client";
|
|
3
|
+
import { createPublicClient, sha256, stringToHex, type Hex } from "viem";
|
|
4
|
+
import { clearingAbi } from "./clearing-abi.js";
|
|
5
|
+
import {
|
|
6
|
+
CLEARING_SCHEME,
|
|
7
|
+
clearingUnits,
|
|
8
|
+
decodeSend,
|
|
9
|
+
invoiceMessage,
|
|
10
|
+
verifyClearingSignature,
|
|
11
|
+
verifyReceipt,
|
|
12
|
+
outgoingRoot,
|
|
13
|
+
type ClearingInvoice,
|
|
14
|
+
} from "./clearing-codec.js";
|
|
15
|
+
import type { ClearingPaid, createClearingClient } from "./clearing-client.js";
|
|
16
|
+
import { rpcTransport } from "./networks.js";
|
|
17
|
+
|
|
18
|
+
export interface ClearingSeller {
|
|
19
|
+
network: `eip155:${number}`;
|
|
20
|
+
asset: Hex;
|
|
21
|
+
settlement: Hex;
|
|
22
|
+
recipient: Hex;
|
|
23
|
+
serviceUrl: string;
|
|
24
|
+
}
|
|
25
|
+
/** A resource-bound invoice namespace; the payer appends a unique payment id. */
|
|
26
|
+
export function clearingRequirements(
|
|
27
|
+
seller: ClearingSeller,
|
|
28
|
+
amount: bigint,
|
|
29
|
+
resource: string,
|
|
30
|
+
): PaymentRequirements {
|
|
31
|
+
if (amount <= 0n) throw new Error("invalid clearing price");
|
|
32
|
+
return {
|
|
33
|
+
scheme: CLEARING_SCHEME,
|
|
34
|
+
network: seller.network,
|
|
35
|
+
asset: seller.asset,
|
|
36
|
+
amount: amount.toString(),
|
|
37
|
+
payTo: seller.recipient,
|
|
38
|
+
maxTimeoutSeconds: 60,
|
|
39
|
+
extra: {
|
|
40
|
+
settlement: seller.settlement,
|
|
41
|
+
serviceUrl: seller.serviceUrl,
|
|
42
|
+
invoicePrefix: sha256(stringToHex(resource)),
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
export function clearingInvoice(
|
|
47
|
+
requirements: PaymentRequirements,
|
|
48
|
+
id: string,
|
|
49
|
+
): ClearingInvoice {
|
|
50
|
+
const extra = requirements.extra;
|
|
51
|
+
if (
|
|
52
|
+
requirements.scheme !== CLEARING_SCHEME ||
|
|
53
|
+
!/^0x[0-9a-fA-F]{66}$/.test(requirements.payTo) ||
|
|
54
|
+
typeof extra?.settlement !== "string" ||
|
|
55
|
+
!/^0x[0-9a-fA-F]{40}$/.test(extra.settlement) ||
|
|
56
|
+
typeof extra.invoicePrefix !== "string" ||
|
|
57
|
+
!/^0x[0-9a-f]{64}$/.test(extra.invoicePrefix) ||
|
|
58
|
+
!id.startsWith(extra.invoicePrefix + ":")
|
|
59
|
+
)
|
|
60
|
+
throw new Error("invalid clearing requirement");
|
|
61
|
+
return {
|
|
62
|
+
id,
|
|
63
|
+
network: requirements.network,
|
|
64
|
+
asset: requirements.asset as Hex,
|
|
65
|
+
settlement: extra.settlement as Hex,
|
|
66
|
+
recipient: requirements.payTo as Hex,
|
|
67
|
+
amount: requirements.amount,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
export function clearingPaymentOf(
|
|
71
|
+
payload: Pick<PaymentPayload, "payload">,
|
|
72
|
+
): ClearingPaid | null {
|
|
73
|
+
try {
|
|
74
|
+
const paid = payload.payload as unknown as ClearingPaid;
|
|
75
|
+
const body = decodeSend(paid.receipt.body);
|
|
76
|
+
invoiceMessage(paid.invoice, paid.receipt.body);
|
|
77
|
+
const entries = paid.entries.map((e) => ({
|
|
78
|
+
...e,
|
|
79
|
+
cumulative: BigInt(e.cumulative),
|
|
80
|
+
count: BigInt(e.count),
|
|
81
|
+
}));
|
|
82
|
+
if (
|
|
83
|
+
entries.length > 1024 ||
|
|
84
|
+
outgoingRoot(entries) !== body.vector ||
|
|
85
|
+
entries.reduce((n, e) => n + e.cumulative, 0n) !== body.debit ||
|
|
86
|
+
!verifyClearingSignature(
|
|
87
|
+
body.payer,
|
|
88
|
+
"_METAL_CLEARING_INVOICE",
|
|
89
|
+
invoiceMessage(paid.invoice, paid.receipt.body),
|
|
90
|
+
paid.invoiceSignature,
|
|
91
|
+
)
|
|
92
|
+
)
|
|
93
|
+
return null;
|
|
94
|
+
return paid;
|
|
95
|
+
} catch {
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
export function clearingPaymentMismatch(
|
|
100
|
+
paid: ClearingPaid,
|
|
101
|
+
requirements: PaymentRequirements,
|
|
102
|
+
): string | null {
|
|
103
|
+
try {
|
|
104
|
+
return invoiceMessage(
|
|
105
|
+
clearingInvoice(requirements, paid.invoice.id),
|
|
106
|
+
paid.receipt.body,
|
|
107
|
+
) === invoiceMessage(paid.invoice, paid.receipt.body)
|
|
108
|
+
? null
|
|
109
|
+
: "invoice";
|
|
110
|
+
} catch {
|
|
111
|
+
return "invoice";
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
export function clearingPaymentId(paid: ClearingPaid): string {
|
|
115
|
+
return `${paid.invoice.network}:bajillion:${paid.invoice.settlement.toLowerCase()}:${sha256(paid.receipt.body)}`;
|
|
116
|
+
}
|
|
117
|
+
/** Both the RPC and service URL must come from the seller's trusted configuration. */
|
|
118
|
+
export async function verifyClearingPayment(
|
|
119
|
+
paid: ClearingPaid,
|
|
120
|
+
requirements: PaymentRequirements,
|
|
121
|
+
config: { rpcUrl: string; serviceUrl: string; settlement: Hex },
|
|
122
|
+
) {
|
|
123
|
+
if (
|
|
124
|
+
clearingPaymentMismatch(paid, requirements) ||
|
|
125
|
+
paid.invoice.settlement.toLowerCase() !== config.settlement.toLowerCase()
|
|
126
|
+
)
|
|
127
|
+
throw new Error("clearing invoice mismatch");
|
|
128
|
+
if (
|
|
129
|
+
!clearingPaymentOf({ payload: paid as unknown as Record<string, unknown> })
|
|
130
|
+
)
|
|
131
|
+
throw new Error("invalid clearing payment");
|
|
132
|
+
const send = decodeSend(paid.receipt.body);
|
|
133
|
+
const client = createPublicClient({ transport: rpcTransport(config.rpcUrl) });
|
|
134
|
+
const [context, fault, asset, chainId, unit] = await Promise.all([
|
|
135
|
+
client.readContract({
|
|
136
|
+
address: config.settlement,
|
|
137
|
+
abi: clearingAbi,
|
|
138
|
+
functionName: "contextOf",
|
|
139
|
+
args: [send.epoch],
|
|
140
|
+
}),
|
|
141
|
+
client.readContract({
|
|
142
|
+
address: config.settlement,
|
|
143
|
+
abi: clearingAbi,
|
|
144
|
+
functionName: "fault",
|
|
145
|
+
}),
|
|
146
|
+
client.readContract({
|
|
147
|
+
address: config.settlement,
|
|
148
|
+
abi: clearingAbi,
|
|
149
|
+
functionName: "token",
|
|
150
|
+
}),
|
|
151
|
+
client.getChainId(),
|
|
152
|
+
client.readContract({
|
|
153
|
+
address: config.settlement,
|
|
154
|
+
abi: clearingAbi,
|
|
155
|
+
functionName: "unit",
|
|
156
|
+
}),
|
|
157
|
+
]);
|
|
158
|
+
if (
|
|
159
|
+
fault ||
|
|
160
|
+
`eip155:${chainId}` !== paid.invoice.network ||
|
|
161
|
+
asset.toLowerCase() !== paid.invoice.asset.toLowerCase() ||
|
|
162
|
+
context.anchor !== send.anchor ||
|
|
163
|
+
!verifyReceipt(paid.receipt, context.operatorKey)
|
|
164
|
+
)
|
|
165
|
+
throw new Error("invalid clearing receipt");
|
|
166
|
+
const previous = paid.previous;
|
|
167
|
+
let before: ClearingPaid["entries"] = [];
|
|
168
|
+
if (send.sequence === 0n) {
|
|
169
|
+
if (previous) throw new Error("unexpected predecessor receipt");
|
|
170
|
+
} else {
|
|
171
|
+
if (!previous || !verifyReceipt(previous.receipt, context.operatorKey))
|
|
172
|
+
throw new Error("missing predecessor receipt");
|
|
173
|
+
const prior = decodeSend(previous.receipt.body);
|
|
174
|
+
before = previous.entries;
|
|
175
|
+
if (
|
|
176
|
+
prior.epoch !== send.epoch ||
|
|
177
|
+
prior.anchor !== send.anchor ||
|
|
178
|
+
prior.payer !== send.payer ||
|
|
179
|
+
prior.sequence + 1n !== send.sequence ||
|
|
180
|
+
outgoingRoot(
|
|
181
|
+
before.map((e) => ({
|
|
182
|
+
...e,
|
|
183
|
+
cumulative: BigInt(e.cumulative),
|
|
184
|
+
count: BigInt(e.count),
|
|
185
|
+
})),
|
|
186
|
+
) !== prior.vector
|
|
187
|
+
)
|
|
188
|
+
throw new Error("invalid predecessor vector");
|
|
189
|
+
}
|
|
190
|
+
const expected = new Map(
|
|
191
|
+
before.map((e) => [e.recipient.toLowerCase(), { ...e }]),
|
|
192
|
+
);
|
|
193
|
+
const key = paid.invoice.recipient.toLowerCase();
|
|
194
|
+
const old = expected.get(key);
|
|
195
|
+
// The invoice is in token base units; the vector counts clearing units.
|
|
196
|
+
expected.set(key, {
|
|
197
|
+
recipient: paid.invoice.recipient,
|
|
198
|
+
cumulative: (
|
|
199
|
+
(old ? BigInt(old.cumulative) : 0n) +
|
|
200
|
+
clearingUnits(BigInt(paid.invoice.amount), unit)
|
|
201
|
+
).toString(),
|
|
202
|
+
count: ((old ? BigInt(old.count) : 0n) + 1n).toString(),
|
|
203
|
+
});
|
|
204
|
+
if (
|
|
205
|
+
expected.size !== paid.entries.length ||
|
|
206
|
+
paid.entries.some((e) => {
|
|
207
|
+
const wanted = expected.get(e.recipient.toLowerCase());
|
|
208
|
+
return (
|
|
209
|
+
!wanted ||
|
|
210
|
+
wanted.count !== e.count ||
|
|
211
|
+
wanted.cumulative !== e.cumulative
|
|
212
|
+
);
|
|
213
|
+
})
|
|
214
|
+
)
|
|
215
|
+
throw new Error("invoice is not the incremental payment");
|
|
216
|
+
// The operator's durable invoice journal attests the incremental charge,
|
|
217
|
+
// which cannot be inferred from a cumulative vector alone.
|
|
218
|
+
const response = await fetch(
|
|
219
|
+
new URL(
|
|
220
|
+
`/receipt/${send.payer}/${encodeURIComponent(paid.invoice.id)}?epoch=${send.epoch}`,
|
|
221
|
+
config.serviceUrl,
|
|
222
|
+
),
|
|
223
|
+
{ signal: AbortSignal.timeout(10_000) },
|
|
224
|
+
);
|
|
225
|
+
if (!response.ok) throw new Error("clearing receipt unavailable");
|
|
226
|
+
const recorded = (await response.json()) as ClearingPaid | null;
|
|
227
|
+
if (
|
|
228
|
+
!recorded ||
|
|
229
|
+
recorded.receipt.body !== paid.receipt.body ||
|
|
230
|
+
recorded.receipt.operatorSignature !== paid.receipt.operatorSignature ||
|
|
231
|
+
invoiceMessage(recorded.invoice, recorded.receipt.body) !==
|
|
232
|
+
invoiceMessage(paid.invoice, paid.receipt.body)
|
|
233
|
+
)
|
|
234
|
+
throw new Error("clearing invoice not retained");
|
|
235
|
+
return {
|
|
236
|
+
payer: send.payer,
|
|
237
|
+
id: clearingPaymentId(paid),
|
|
238
|
+
status: "preconfirmed" as const,
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
/** Pay by clearing through `client` on `network`. A request with an offer on
|
|
242
|
+
* `network` but none the wallet can pay fails, unless `exactFallback` is
|
|
243
|
+
* true: then it is paid by another offer of the request on `network`,
|
|
244
|
+
* `exact` from its EVM account, and fails when it has none. A request with
|
|
245
|
+
* no offer on `network` is left to the
|
|
246
|
+
* client's other registrations. A payment the clearing service does not take
|
|
247
|
+
* throws a `ClearingRefusal`. */
|
|
248
|
+
export function registerClearingClient(
|
|
249
|
+
client: x402Client,
|
|
250
|
+
network: `eip155:${number}`,
|
|
251
|
+
payer: Pick<
|
|
252
|
+
ReturnType<typeof createClearingClient>,
|
|
253
|
+
"pay" | "unpayable" | "pendingInvoice"
|
|
254
|
+
>,
|
|
255
|
+
{ exactFallback = false }: { exactFallback?: boolean } = {},
|
|
256
|
+
) {
|
|
257
|
+
// The URL each offer was asked for, kept with a payment whose outcome is
|
|
258
|
+
// unknown so a refusal names the request to send again.
|
|
259
|
+
const urls = new WeakMap<PaymentRequirements, string>();
|
|
260
|
+
client.register(network, {
|
|
261
|
+
scheme: CLEARING_SCHEME,
|
|
262
|
+
schemeHooks: {
|
|
263
|
+
async onBeforePaymentCreation({ paymentRequired, selectedRequirements }) {
|
|
264
|
+
urls.set(selectedRequirements, paymentRequired.resource.url);
|
|
265
|
+
},
|
|
266
|
+
},
|
|
267
|
+
async createPaymentPayload(x402Version, requirements) {
|
|
268
|
+
const pending = await payer.pendingInvoice();
|
|
269
|
+
// A payment held for another URL is the wallet's to refuse, naming it.
|
|
270
|
+
const prefix = `${requirements.extra?.invoicePrefix}:`;
|
|
271
|
+
const invoice = clearingInvoice(
|
|
272
|
+
requirements,
|
|
273
|
+
pending?.id.startsWith(prefix)
|
|
274
|
+
? pending.id
|
|
275
|
+
: prefix + globalThis.crypto.randomUUID(),
|
|
276
|
+
);
|
|
277
|
+
const paid = await payer.pay(invoice, urls.get(requirements));
|
|
278
|
+
return {
|
|
279
|
+
x402Version,
|
|
280
|
+
payload: paid as unknown as Record<string, unknown>,
|
|
281
|
+
};
|
|
282
|
+
},
|
|
283
|
+
});
|
|
284
|
+
// The stock client keeps only `authorization` offers when any remain after
|
|
285
|
+
// its policies, and a clearing offer is `upfront`: so a payer set up for
|
|
286
|
+
// clearing keeps the clearing offers its wallet can pay, when there are any.
|
|
287
|
+
client.registerPolicy((_, offers) => {
|
|
288
|
+
if (!offers.some((offer) => offer.network === network)) return offers;
|
|
289
|
+
const offered = offers.filter((offer) => offer.scheme === CLEARING_SCHEME && offer.network === network);
|
|
290
|
+
// Why the wallet does not pay `offer`, or undefined when it does.
|
|
291
|
+
const unpaid = (offer: PaymentRequirements) => {
|
|
292
|
+
if (!/^[0-9]+$/.test(offer.amount) || BigInt(offer.amount) === 0n)
|
|
293
|
+
return { reason: `offer is priced at "${offer.amount}", not a positive whole number of base units`, aboveMaxAmount: false };
|
|
294
|
+
let invoice: ClearingInvoice;
|
|
295
|
+
try {
|
|
296
|
+
invoice = clearingInvoice(offer, `${offer.extra?.invoicePrefix}:`);
|
|
297
|
+
} catch {
|
|
298
|
+
return { reason: "offer is malformed (invalid clearing requirement)", aboveMaxAmount: false };
|
|
299
|
+
}
|
|
300
|
+
return payer.unpayable(invoice);
|
|
301
|
+
};
|
|
302
|
+
const reasons = offered.map(unpaid);
|
|
303
|
+
const clearing = offered.filter((_, i) => reasons[i] === undefined);
|
|
304
|
+
if (clearing.length > 0) return clearing;
|
|
305
|
+
if (exactFallback) {
|
|
306
|
+
// The offers this wallet just refused stay out: the stock selector can
|
|
307
|
+
// choose them again, since a clearing offer with no extra.paymentFlow
|
|
308
|
+
// is otherwise indistinguishable from one it can pay. The fallback is
|
|
309
|
+
// for this request's offers on `network`; with none left, it fails.
|
|
310
|
+
if (offers.some((offer) => offer.network === network && !offered.includes(offer)))
|
|
311
|
+
return offers.filter((offer) => !offered.includes(offer));
|
|
312
|
+
}
|
|
313
|
+
const why =
|
|
314
|
+
offered.length > 0
|
|
315
|
+
? `this clearing wallet pays none of the clearing offers here (${[...new Set(reasons.map((r) => r?.reason))].join("; ")})`
|
|
316
|
+
: `this request offers no clearing payment on ${network}`;
|
|
317
|
+
// An offer above the wallet's maxAmount is for its own deployment: the
|
|
318
|
+
// limit or the seller is what to change, not the scheme.
|
|
319
|
+
throw new Error(
|
|
320
|
+
reasons.some((r) => r?.aboveMaxAmount)
|
|
321
|
+
? `${why}; nothing was paid: raise the wallet's maxAmount or pay another seller`
|
|
322
|
+
: exactFallback
|
|
323
|
+
? `${why}, and this request offers no other way to pay it; nothing was paid`
|
|
324
|
+
: `${why}, and paying by exact is off; nothing was paid: pass exactFallback: true to registerClearingClient to pay such a request by exact`,
|
|
325
|
+
);
|
|
326
|
+
});
|
|
327
|
+
return client;
|
|
328
|
+
}
|