@metalntwx/metal-pay 0.2.0 → 0.3.0
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 +72 -33
- package/dist/clearing-abi.d.ts +1836 -0
- package/dist/clearing-abi.js +2361 -0
- package/dist/clearing-client.d.ts +49 -159
- package/dist/clearing-client.js +191 -564
- package/dist/clearing-codec.d.ts +57 -0
- package/dist/clearing-codec.js +173 -0
- package/dist/clearing-custody.d.ts +191 -0
- package/dist/clearing-custody.js +490 -0
- package/dist/clearing-server.d.ts +30 -49
- package/dist/clearing-server.js +87 -67
- package/dist/clearing-x402.d.ts +29 -0
- package/dist/clearing-x402.js +203 -0
- package/dist/client.d.ts +7 -13
- package/dist/client.js +16 -18
- package/dist/index.d.ts +4 -1
- package/dist/index.js +4 -1
- 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 +1 -1
- package/dist/mandates/verify.d.ts +4 -6
- package/dist/mandates/verify.js +42 -66
- package/dist/mandates/vi/credential.js +4 -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.ts +303 -0
- package/src/clearing-codec.test.ts +57 -0
- package/src/clearing-codec.ts +251 -0
- package/src/clearing-custody.test.ts +534 -0
- package/src/clearing-custody.ts +665 -0
- package/src/clearing-fixture.json +37 -0
- package/src/clearing-server.test.ts +388 -0
- package/src/clearing-server.ts +123 -0
- package/src/clearing-x402.test.ts +181 -0
- package/src/clearing-x402.ts +279 -0
- package/src/client.test.ts +325 -0
- package/src/client.ts +156 -0
- package/src/index.ts +33 -0
- package/src/mandates/account.ts +91 -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 +532 -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 +16 -0
- package/src/networks.ts +152 -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,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"operator": "0x03871d350cbe37fa4ea973c3ab837df73a5aecb0d39771798ebb183e7175f8310a",
|
|
3
|
+
"receipt": {
|
|
4
|
+
"body": "0x6842b636532bed19771b0e7216acfb53de4dde53c6f988c4348f18b85c85ad5100000000000000000258b2faa97c091b3fde391789a65dc1f9cefaed8a8e7b48d614efc5f49ac82a900000000000000000000000000000025852aec30aced3eff085db19802648a6409a4fdca76f0701f0ec0afebafbce4c6f",
|
|
5
|
+
"entry": {
|
|
6
|
+
"amount": 600,
|
|
7
|
+
"count": 1,
|
|
8
|
+
"length": 1,
|
|
9
|
+
"nativeRoot": "0x035f9453736cbc9c0a5883f3e27450012421dd3af52a1801a2448e81bf0ee45e",
|
|
10
|
+
"position": 0,
|
|
11
|
+
"recipient": "0x021df9ccf407d5b7465b74a0584f337ee72295e6ba773eb29283117eeefc2ca963",
|
|
12
|
+
"siblings": []
|
|
13
|
+
},
|
|
14
|
+
"fork": {
|
|
15
|
+
"operatorSignature": "0x93779ea219b93bf2426636b882214d456efd087ab1b65b71aa27ff16cb97d0936e7753f77d2d5708895b61188fefb3efc16f9e133fae4d8ede7219bc8b59caac",
|
|
16
|
+
"payerSignature": "0xf40a218fe56c263d9716a120240e9dc0f22ea9bfeea7e787feae73fd0083d0034dd2fc8656b541358bd5aec70c6446ddff142223f6cdf221ecca1d5c230c7eea"
|
|
17
|
+
},
|
|
18
|
+
"higherEntry": {
|
|
19
|
+
"entry": {
|
|
20
|
+
"amount": 601,
|
|
21
|
+
"count": 2,
|
|
22
|
+
"length": 1,
|
|
23
|
+
"nativeRoot": "0xd9f437456571741f3c125c1baf9f7dae29ec43a28dd9af5aeca5a5e954aa3b9f",
|
|
24
|
+
"position": 0,
|
|
25
|
+
"recipient": "0x021df9ccf407d5b7465b74a0584f337ee72295e6ba773eb29283117eeefc2ca963",
|
|
26
|
+
"siblings": []
|
|
27
|
+
},
|
|
28
|
+
"operatorSignature": "0x4fc438c68684c20d5734d6b6d96f1904b8e41656903fc319a038f1ce07cb6a1849b1a0bd5bcdc901a403bbd0293bfcce207aac9584ee31b03e6a458c5876c4f4",
|
|
29
|
+
"payerSignature": "0xde3a8a79cf180e4e669bb0464f7b9b62a1cd2326c153fad279fb3a9c8c84aded5631c791aa19c009258ccde6fde66b439d7b70f6489aa6b741ea1db9eeb99352",
|
|
30
|
+
"vector": "0xa4c558752c2682689ccd38349967da6f567c293cea615f779e821125929d3981"
|
|
31
|
+
},
|
|
32
|
+
"operatorSignature": "0xa27ba7d942eef63a4b371ddaa123050ea334dd49ff9ffd2d709adc4602fa612236fcf2075fb53e44f8b487f92081c27dc90923f91bce365cc5f52ed274264866",
|
|
33
|
+
"payer": "0x0258b2faa97c091b3fde391789a65dc1f9cefaed8a8e7b48d614efc5f49ac82a90",
|
|
34
|
+
"payerSignature": "0x99f45a7b1ffbdbde8e5a0d070e1565bf7bc52b08a3de28fd37065e76f73e02e43c32fb291574db333df1efac1385dfc5b655d987926c102c6c2c3117ab15b55a",
|
|
35
|
+
"vector": "0x52aec30aced3eff085db19802648a6409a4fdca76f0701f0ec0afebafbce4c6f"
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -0,0 +1,388 @@
|
|
|
1
|
+
import { createServer, type Server } from "node:http";
|
|
2
|
+
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
|
3
|
+
import { Hono } from "hono";
|
|
4
|
+
import { paymentMiddleware } from "@x402/hono";
|
|
5
|
+
import { HTTPFacilitatorClient, x402ResourceServer } from "@x402/core/server";
|
|
6
|
+
import { decodePaymentRequiredHeader, decodePaymentResponseHeader, encodePaymentSignatureHeader } from "@x402/core/http";
|
|
7
|
+
import type { PaymentPayload, PaymentRequirements } from "@x402/core/types";
|
|
8
|
+
import { sha256, stringToHex } from "viem";
|
|
9
|
+
import { privateKeyToAccount } from "viem/accounts";
|
|
10
|
+
import { createClearingSigner, encodeSend, invoiceMessage, outgoingRoot, type ClearingInvoice } from "./clearing-codec.js";
|
|
11
|
+
import type { ClearingPaid } from "./clearing-client.js";
|
|
12
|
+
import { ClearingServerScheme } from "./clearing-server.js";
|
|
13
|
+
import { clearingPaymentMismatch, clearingPaymentOf, registerClearingClient } from "./clearing-x402.js";
|
|
14
|
+
import { createMetalPaymentClient } from "./client.js";
|
|
15
|
+
import { USDZ } from "./networks.js";
|
|
16
|
+
import { registerMetalScheme } from "./server.js";
|
|
17
|
+
|
|
18
|
+
const NETWORK = "eip155:1337";
|
|
19
|
+
const SETTLEMENT = "0x2222222222222222222222222222222222222222";
|
|
20
|
+
const SERVICE_URL = "http://clearing.local:7091";
|
|
21
|
+
/** The facilitator's /supported entry for clearing: the deployment it settles. */
|
|
22
|
+
const KIND = {
|
|
23
|
+
x402Version: 2,
|
|
24
|
+
scheme: "bajillion",
|
|
25
|
+
network: NETWORK,
|
|
26
|
+
extra: { settlement: SETTLEMENT, asset: USDZ.address, serviceUrl: SERVICE_URL },
|
|
27
|
+
} as const;
|
|
28
|
+
const seller = createClearingSigner(`0x${"23".repeat(32)}`);
|
|
29
|
+
const payer = createClearingSigner(`0x${"12".repeat(32)}`);
|
|
30
|
+
const operator = createClearingSigner(`0x${"34".repeat(32)}`);
|
|
31
|
+
|
|
32
|
+
/** What the facilitator does with a clearing payment before its chain reads: the same checks, from the same module,
|
|
33
|
+
* at verify and again at settle, and a receipt it recorded before refused as `already_settled` at both. */
|
|
34
|
+
const checked: { requirements: PaymentRequirements; payload: PaymentPayload }[] = [];
|
|
35
|
+
const settled = new Set<string>();
|
|
36
|
+
const facilitator = createServer((req, res) => {
|
|
37
|
+
const chunks: Buffer[] = [];
|
|
38
|
+
req.on("data", (c: Buffer) => chunks.push(c));
|
|
39
|
+
req.on("end", () => {
|
|
40
|
+
const body = (chunks.length ? JSON.parse(Buffer.concat(chunks).toString()) : {}) as {
|
|
41
|
+
paymentPayload?: PaymentPayload;
|
|
42
|
+
paymentRequirements?: PaymentRequirements;
|
|
43
|
+
};
|
|
44
|
+
const reply = (status: number, json: unknown) => {
|
|
45
|
+
res.writeHead(status, { "content-type": "application/json" });
|
|
46
|
+
res.end(JSON.stringify(json));
|
|
47
|
+
};
|
|
48
|
+
if (req.url === "/supported")
|
|
49
|
+
return reply(200, {
|
|
50
|
+
kinds: [
|
|
51
|
+
{ x402Version: 2, scheme: "exact", network: NETWORK },
|
|
52
|
+
KIND,
|
|
53
|
+
],
|
|
54
|
+
extensions: [],
|
|
55
|
+
signers: { "eip155:*": ["0x00000000000000000000000000000000000000fa"] },
|
|
56
|
+
});
|
|
57
|
+
const { paymentPayload, paymentRequirements } = body;
|
|
58
|
+
if (!paymentPayload || !paymentRequirements) return reply(400, {});
|
|
59
|
+
const paid = clearingPaymentOf(paymentPayload);
|
|
60
|
+
const mismatch =
|
|
61
|
+
!paid ||
|
|
62
|
+
clearingPaymentMismatch(paid, paymentRequirements) ||
|
|
63
|
+
clearingPaymentMismatch(paid, paymentPayload.accepted as PaymentRequirements);
|
|
64
|
+
checked.push({ requirements: paymentRequirements, payload: paymentPayload });
|
|
65
|
+
if (req.url === "/verify") {
|
|
66
|
+
if (mismatch) return reply(200, { isValid: false, invalidReason: "invalid clearing invoice" });
|
|
67
|
+
if (settled.has(paid.invoice.id)) return reply(200, { isValid: false, invalidReason: "already_settled" });
|
|
68
|
+
return reply(200, { isValid: true, payer: payer.publicKey });
|
|
69
|
+
}
|
|
70
|
+
if (req.url === "/settle") {
|
|
71
|
+
if (mismatch) return reply(200, { success: false, errorReason: "invalid_clearing_payment", transaction: "", network: NETWORK });
|
|
72
|
+
const first = !settled.has(paid.invoice.id);
|
|
73
|
+
settled.add(paid.invoice.id);
|
|
74
|
+
return reply(200, { success: first, ...(first ? {} : { errorReason: "already_settled" }), transaction: "", network: NETWORK, payer: payer.publicKey });
|
|
75
|
+
}
|
|
76
|
+
reply(404, {});
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
/** A payer whose wallet signs a first payment for whatever invoice it is given. */
|
|
81
|
+
const fakePayer = {
|
|
82
|
+
async pendingInvoice() {
|
|
83
|
+
return null;
|
|
84
|
+
},
|
|
85
|
+
payable() {
|
|
86
|
+
return true;
|
|
87
|
+
},
|
|
88
|
+
async pay(invoice: ClearingInvoice): Promise<ClearingPaid> {
|
|
89
|
+
const entries = [{ recipient: invoice.recipient, cumulative: invoice.amount, count: "1" }];
|
|
90
|
+
const body = encodeSend({
|
|
91
|
+
anchor: `0x${"00".repeat(32)}`,
|
|
92
|
+
epoch: 1n,
|
|
93
|
+
payer: payer.publicKey,
|
|
94
|
+
sequence: 0n,
|
|
95
|
+
debit: BigInt(invoice.amount),
|
|
96
|
+
vector: outgoingRoot([{ recipient: invoice.recipient, cumulative: BigInt(invoice.amount), count: 1n }]),
|
|
97
|
+
});
|
|
98
|
+
return {
|
|
99
|
+
invoice,
|
|
100
|
+
entries,
|
|
101
|
+
invoiceSignature: payer.sign("_METAL_CLEARING_INVOICE", invoiceMessage(invoice, body)),
|
|
102
|
+
receipt: {
|
|
103
|
+
body,
|
|
104
|
+
payerSignature: payer.sign("_COMMONWARE_CLEARING_VECTOR_SEND", body),
|
|
105
|
+
operatorSignature: operator.sign("_COMMONWARE_CLEARING_VECTOR_ACK", body),
|
|
106
|
+
},
|
|
107
|
+
};
|
|
108
|
+
},
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
let app: Hono;
|
|
112
|
+
let served = 0;
|
|
113
|
+
|
|
114
|
+
beforeAll(async () => {
|
|
115
|
+
const facilitatorUrl = await listen(facilitator);
|
|
116
|
+
const server = registerMetalScheme(new x402ResourceServer(new HTTPFacilitatorClient({ url: facilitatorUrl })), {
|
|
117
|
+
networks: ["devnet"],
|
|
118
|
+
}).register(NETWORK, new ClearingServerScheme(NETWORK));
|
|
119
|
+
app = new Hono();
|
|
120
|
+
app.use(
|
|
121
|
+
paymentMiddleware(
|
|
122
|
+
{
|
|
123
|
+
"GET /chunk": {
|
|
124
|
+
accepts: [
|
|
125
|
+
{ scheme: "bajillion", network: NETWORK, payTo: seller.publicKey, price: "0.001" },
|
|
126
|
+
{ scheme: "exact", network: NETWORK, payTo: "0x000000000000000000000000000000000000b22b", price: "0.01" },
|
|
127
|
+
],
|
|
128
|
+
description: "one chunk",
|
|
129
|
+
},
|
|
130
|
+
"GET /other": {
|
|
131
|
+
accepts: { scheme: "bajillion", network: NETWORK, payTo: seller.publicKey, price: "0.001", extra: { note: "kept" } },
|
|
132
|
+
},
|
|
133
|
+
},
|
|
134
|
+
server,
|
|
135
|
+
),
|
|
136
|
+
);
|
|
137
|
+
app.get("/chunk", (c) => {
|
|
138
|
+
served++;
|
|
139
|
+
return c.json({ chunk: 1 });
|
|
140
|
+
});
|
|
141
|
+
app.get("/other", (c) => c.json({ other: 1 }));
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
afterAll(() => new Promise<void>((resolve) => facilitator.close(() => resolve())));
|
|
145
|
+
|
|
146
|
+
const prefixOf = (url: string) => sha256(stringToHex(url));
|
|
147
|
+
|
|
148
|
+
async function challenge(url: string): Promise<PaymentRequirements[]> {
|
|
149
|
+
const unpaid = await app.request(url);
|
|
150
|
+
expect(unpaid.status).toBe(402);
|
|
151
|
+
return decodePaymentRequiredHeader(unpaid.headers.get("payment-required") as string).accepts as PaymentRequirements[];
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
describe("ClearingServerScheme under the stock middleware", () => {
|
|
155
|
+
it("offers clearing beside exact, in the token's base units, bound to the resource", async () => {
|
|
156
|
+
const accepts = await challenge("http://seller.local/chunk");
|
|
157
|
+
expect(accepts).toHaveLength(2);
|
|
158
|
+
expect(accepts[0]).toEqual({
|
|
159
|
+
scheme: "bajillion",
|
|
160
|
+
network: NETWORK,
|
|
161
|
+
asset: USDZ.address,
|
|
162
|
+
amount: (10n ** 15n).toString(),
|
|
163
|
+
payTo: seller.publicKey,
|
|
164
|
+
maxTimeoutSeconds: 300,
|
|
165
|
+
extra: { settlement: SETTLEMENT, serviceUrl: SERVICE_URL, invoicePrefix: prefixOf("http://seller.local/chunk"), paymentFlow: "upfront" },
|
|
166
|
+
});
|
|
167
|
+
expect(accepts[1]).toMatchObject({ scheme: "exact", amount: (10n ** 16n).toString() });
|
|
168
|
+
const [other] = await challenge("http://seller.local/other");
|
|
169
|
+
expect(other?.extra?.invoicePrefix).toBe(prefixOf("http://seller.local/other"));
|
|
170
|
+
expect(other?.extra?.invoicePrefix).not.toBe(accepts[0]?.extra?.invoicePrefix);
|
|
171
|
+
// The route's own extra stays beside the deployment's.
|
|
172
|
+
expect(other?.extra).toMatchObject({ note: "kept", settlement: SETTLEMENT, serviceUrl: SERVICE_URL });
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it("serves a payment the facilitator verifies against the offer it was made for", async () => {
|
|
176
|
+
// The payer's recipe: the Metal client, with the clearing payer registered on it.
|
|
177
|
+
const { client, fetchWithPayment } = createMetalPaymentClient({
|
|
178
|
+
account: privateKeyToAccount(`0x${"45".repeat(32)}`),
|
|
179
|
+
network: "devnet",
|
|
180
|
+
fetch: async (input, init) => app.request(input as string, init),
|
|
181
|
+
});
|
|
182
|
+
registerClearingClient(client, NETWORK, fakePayer);
|
|
183
|
+
checked.length = 0;
|
|
184
|
+
const paid = await fetchWithPayment("http://seller.local/chunk");
|
|
185
|
+
expect(paid.status).toBe(200);
|
|
186
|
+
expect(await paid.json()).toEqual({ chunk: 1 });
|
|
187
|
+
expect(decodePaymentResponseHeader(paid.headers.get("payment-response") as string)).toMatchObject({
|
|
188
|
+
success: true,
|
|
189
|
+
network: NETWORK,
|
|
190
|
+
});
|
|
191
|
+
const { requirements, payload } = checked.at(-1)!;
|
|
192
|
+
const prefix = prefixOf("http://seller.local/chunk");
|
|
193
|
+
expect(requirements.extra?.invoicePrefix).toBe(prefix);
|
|
194
|
+
expect((payload.accepted as PaymentRequirements).extra?.invoicePrefix).toBe(prefix);
|
|
195
|
+
expect(clearingPaymentOf(payload)?.invoice).toMatchObject({
|
|
196
|
+
recipient: seller.publicKey,
|
|
197
|
+
amount: (10n ** 15n).toString(),
|
|
198
|
+
settlement: SETTLEMENT,
|
|
199
|
+
network: NETWORK,
|
|
200
|
+
asset: USDZ.address,
|
|
201
|
+
});
|
|
202
|
+
expect(clearingPaymentOf(payload)?.invoice.id.startsWith(`${prefix}:`)).toBe(true);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it("refuses a receipt made for another resource even under this resource's offer", async () => {
|
|
206
|
+
// The payer presents a receipt whose invoice was issued for /other with
|
|
207
|
+
// /chunk's offer as what it accepted: the middleware matches, and the
|
|
208
|
+
// facilitator's invoice check refuses.
|
|
209
|
+
const { client } = createMetalPaymentClient({ account: privateKeyToAccount(`0x${"45".repeat(32)}`), network: "devnet" });
|
|
210
|
+
registerClearingClient(client, NETWORK, fakePayer);
|
|
211
|
+
const [other] = await challenge("http://seller.local/other");
|
|
212
|
+
const [chunk] = await challenge("http://seller.local/chunk");
|
|
213
|
+
const forOther = await client.createPaymentPayload({ x402Version: 2, resource: { url: "http://seller.local/other" }, accepts: [other!] });
|
|
214
|
+
checked.length = 0;
|
|
215
|
+
const refused = await app.request("http://seller.local/chunk", {
|
|
216
|
+
headers: { "payment-signature": encodePaymentSignatureHeader({ ...forOther, accepted: chunk! }) },
|
|
217
|
+
});
|
|
218
|
+
expect(refused.status).toBe(402);
|
|
219
|
+
expect(checked).toHaveLength(1);
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it("refuses a payment made for another resource", async () => {
|
|
223
|
+
const { client } = createMetalPaymentClient({ account: privateKeyToAccount(`0x${"45".repeat(32)}`), network: "devnet" });
|
|
224
|
+
registerClearingClient(client, NETWORK, fakePayer);
|
|
225
|
+
const [offer] = await challenge("http://seller.local/other");
|
|
226
|
+
const payload = await client.createPaymentPayload({
|
|
227
|
+
x402Version: 2,
|
|
228
|
+
resource: { url: "http://seller.local/other" },
|
|
229
|
+
accepts: [offer!],
|
|
230
|
+
});
|
|
231
|
+
checked.length = 0;
|
|
232
|
+
const refused = await app.request("http://seller.local/chunk", {
|
|
233
|
+
headers: { "payment-signature": encodePaymentSignatureHeader(payload) },
|
|
234
|
+
});
|
|
235
|
+
expect(refused.status).toBe(402);
|
|
236
|
+
expect(checked).toHaveLength(0);
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
it("refuses a receipt presented again, before serving it", async () => {
|
|
240
|
+
const { client } = createMetalPaymentClient({ account: privateKeyToAccount(`0x${"45".repeat(32)}`), network: "devnet" });
|
|
241
|
+
registerClearingClient(client, NETWORK, fakePayer);
|
|
242
|
+
const [offer] = await challenge("http://seller.local/chunk");
|
|
243
|
+
const payload = await client.createPaymentPayload({ x402Version: 2, resource: { url: "http://seller.local/chunk" }, accepts: [offer!] });
|
|
244
|
+
const headers = { "payment-signature": encodePaymentSignatureHeader(payload) };
|
|
245
|
+
served = 0;
|
|
246
|
+
expect((await app.request("http://seller.local/chunk", { headers })).status).toBe(200);
|
|
247
|
+
expect((await app.request("http://seller.local/chunk", { headers })).status).toBe(402);
|
|
248
|
+
expect(served).toBe(1);
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
it("serves one of two overlapping presentations of a receipt", async () => {
|
|
252
|
+
const { client } = createMetalPaymentClient({ account: privateKeyToAccount(`0x${"45".repeat(32)}`), network: "devnet" });
|
|
253
|
+
registerClearingClient(client, NETWORK, fakePayer);
|
|
254
|
+
const [offer] = await challenge("http://seller.local/chunk");
|
|
255
|
+
const payload = await client.createPaymentPayload({ x402Version: 2, resource: { url: "http://seller.local/chunk" }, accepts: [offer!] });
|
|
256
|
+
const headers = { "payment-signature": encodePaymentSignatureHeader(payload) };
|
|
257
|
+
served = 0;
|
|
258
|
+
const statuses = await Promise.all([1, 2].map(async () => (await app.request("http://seller.local/chunk", { headers })).status));
|
|
259
|
+
expect(statuses.sort()).toEqual([200, 402]);
|
|
260
|
+
expect(served).toBe(1);
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
it("pays with clearing when offered clearing and exact, in either order, and with exact when it cannot pay the clearing offer", async () => {
|
|
264
|
+
const accepts = await challenge("http://seller.local/chunk");
|
|
265
|
+
const chosen = async (payer: typeof fakePayer, offers: PaymentRequirements[]) => {
|
|
266
|
+
const { client } = createMetalPaymentClient({ account: privateKeyToAccount(`0x${"45".repeat(32)}`), network: "devnet" });
|
|
267
|
+
registerClearingClient(client, NETWORK, payer);
|
|
268
|
+
return (await client.createPaymentPayload({ x402Version: 2, resource: { url: "http://seller.local/chunk" }, accepts: offers })).accepted.scheme;
|
|
269
|
+
};
|
|
270
|
+
expect(await chosen(fakePayer, accepts)).toBe("bajillion");
|
|
271
|
+
expect(await chosen(fakePayer, [...accepts].reverse())).toBe("bajillion");
|
|
272
|
+
expect(await chosen({ ...fakePayer, payable: () => false }, accepts)).toBe("exact");
|
|
273
|
+
});
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
describe("ClearingServerScheme on its own", () => {
|
|
277
|
+
const scheme = new ClearingServerScheme(NETWORK);
|
|
278
|
+
|
|
279
|
+
it("prices dollars in the token's base units and refuses a price of zero", async () => {
|
|
280
|
+
expect(await scheme.parsePrice("$0.001")).toEqual({ amount: (10n ** 15n).toString(), asset: USDZ.address });
|
|
281
|
+
expect(await scheme.parsePrice({ amount: "5000000000000", asset: USDZ.address })).toMatchObject({ amount: "5000000000000" });
|
|
282
|
+
await expect(scheme.parsePrice(0)).rejects.toThrow("not a price: 0");
|
|
283
|
+
await expect(scheme.parsePrice({ amount: "0", asset: USDZ.address })).rejects.toThrow("not a price: 0");
|
|
284
|
+
await expect(scheme.parsePrice({ amount: "-5", asset: USDZ.address })).rejects.toThrow("not a price: -5");
|
|
285
|
+
await expect(scheme.parsePrice({ amount: "abc", asset: USDZ.address })).rejects.toThrow("not a price: abc");
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
it("refuses a price above what a Clearing contract can hold, or a fraction of its unit", async () => {
|
|
289
|
+
// Clearing counts uint64 units of a millionth of *USDZ: at most about 1.8e13 *USDZ.
|
|
290
|
+
const unit = 10n ** 12n;
|
|
291
|
+
expect(await scheme.parsePrice("19")).toMatchObject({ amount: (19n * 10n ** 18n).toString() });
|
|
292
|
+
const most = (2n ** 64n - 1n) * unit;
|
|
293
|
+
expect(await scheme.parsePrice({ amount: most.toString(), asset: USDZ.address })).toMatchObject({ amount: most.toString() });
|
|
294
|
+
await expect(scheme.parsePrice({ amount: (most + unit).toString(), asset: USDZ.address })).rejects.toThrow(/at most/);
|
|
295
|
+
await expect(scheme.parsePrice({ amount: (unit + 1n).toString(), asset: USDZ.address })).rejects.toThrow(
|
|
296
|
+
/whole number of 0.000001 \*USDZ/,
|
|
297
|
+
);
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
it("offers the deployment its facilitator settles, and refuses a facilitator that settles none in its token", async () => {
|
|
301
|
+
const offer = { scheme: "bajillion", network: NETWORK, asset: USDZ.address, amount: "1", payTo: seller.publicKey, maxTimeoutSeconds: 60, extra: { note: "kept" } } as const;
|
|
302
|
+
expect((await scheme.enhancePaymentRequirements(offer, KIND)).extra).toEqual({ note: "kept", settlement: SETTLEMENT, serviceUrl: SERVICE_URL });
|
|
303
|
+
const { extra: _, ...bare } = KIND;
|
|
304
|
+
await expect(scheme.enhancePaymentRequirements(offer, bare)).rejects.toThrow(/settles no clearing deployment/);
|
|
305
|
+
await expect(
|
|
306
|
+
scheme.enhancePaymentRequirements(offer, { ...KIND, extra: { ...KIND.extra, asset: "0x000000000000000000000000000000000000c0de" } }),
|
|
307
|
+
).rejects.toThrow(/settles no clearing deployment/);
|
|
308
|
+
await expect(
|
|
309
|
+
scheme.enhancePaymentRequirements(offer, { ...KIND, extra: { ...KIND.extra, settlement: "0x1234" } }),
|
|
310
|
+
).rejects.toThrow(/settles no clearing deployment/);
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
it("binds and offers only on its own network", async () => {
|
|
314
|
+
const offer = {
|
|
315
|
+
scheme: "bajillion",
|
|
316
|
+
network: "eip155:63825",
|
|
317
|
+
asset: USDZ.address,
|
|
318
|
+
amount: "1",
|
|
319
|
+
payTo: seller.publicKey,
|
|
320
|
+
maxTimeoutSeconds: 60,
|
|
321
|
+
extra: {},
|
|
322
|
+
} as const;
|
|
323
|
+
await expect(scheme.enhancePaymentRequirements(offer, KIND)).rejects.toThrow(/on eip155:1337/);
|
|
324
|
+
const enriched = await scheme.enrichPaymentRequiredResponse({
|
|
325
|
+
requirements: [offer, { ...offer, network: NETWORK }, { ...offer, network: NETWORK, extra: { invoicePrefix: `0x${"ab".repeat(32)}` } }],
|
|
326
|
+
resourceInfo: { url: "http://seller.local/chunk" },
|
|
327
|
+
} as never);
|
|
328
|
+
expect(enriched[0]!.extra).toEqual({});
|
|
329
|
+
expect(enriched[1]!.extra).toEqual({ invoicePrefix: prefixOf("http://seller.local/chunk") });
|
|
330
|
+
expect(enriched[2]!.extra).toEqual({ invoicePrefix: `0x${"ab".repeat(32)}` });
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
it("refuses an @x402/core before 2.28, whose server settles after the handler runs", async () => {
|
|
334
|
+
vi.resetModules();
|
|
335
|
+
vi.doMock("@x402/core/server", async () => {
|
|
336
|
+
const { resolvePaymentFlow: _, ...older } = await vi.importActual<Record<string, unknown>>("@x402/core/server");
|
|
337
|
+
return older;
|
|
338
|
+
});
|
|
339
|
+
try {
|
|
340
|
+
const { ClearingServerScheme: Scheme } = await import("./clearing-server.js");
|
|
341
|
+
expect(() => new Scheme(NETWORK)).toThrow(/@x402\/core 2\.28/);
|
|
342
|
+
} finally {
|
|
343
|
+
vi.doUnmock("@x402/core/server");
|
|
344
|
+
vi.resetModules();
|
|
345
|
+
}
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
it("refuses a payTo that is not a clearing key, an offer in another asset or with a malformed invoicePrefix, and a network that is not one", async () => {
|
|
349
|
+
await expect(
|
|
350
|
+
scheme.enhancePaymentRequirements({
|
|
351
|
+
scheme: "bajillion",
|
|
352
|
+
network: NETWORK,
|
|
353
|
+
asset: USDZ.address,
|
|
354
|
+
amount: "1",
|
|
355
|
+
payTo: "0x000000000000000000000000000000000000b22b",
|
|
356
|
+
maxTimeoutSeconds: 60,
|
|
357
|
+
extra: {},
|
|
358
|
+
}, KIND),
|
|
359
|
+
).rejects.toThrow(/clearing key/);
|
|
360
|
+
// The right length, but no P-256 point: x is past the field.
|
|
361
|
+
await expect(
|
|
362
|
+
scheme.enhancePaymentRequirements({
|
|
363
|
+
scheme: "bajillion",
|
|
364
|
+
network: NETWORK,
|
|
365
|
+
asset: USDZ.address,
|
|
366
|
+
amount: "1",
|
|
367
|
+
payTo: `0x02${"ff".repeat(32)}`,
|
|
368
|
+
maxTimeoutSeconds: 60,
|
|
369
|
+
extra: {},
|
|
370
|
+
}, KIND),
|
|
371
|
+
).rejects.toThrow(/clearing key/);
|
|
372
|
+
const offer = { scheme: "bajillion", network: NETWORK, asset: USDZ.address, amount: "1", payTo: seller.publicKey, maxTimeoutSeconds: 60, extra: {} } as const;
|
|
373
|
+
await expect(scheme.enhancePaymentRequirements({ ...offer, asset: "0x000000000000000000000000000000000000c0de" }, KIND)).rejects.toThrow(/prices in/);
|
|
374
|
+
await expect(scheme.enhancePaymentRequirements({ ...offer, extra: { invoicePrefix: `0x${"AB".repeat(32)}` } }, KIND)).rejects.toThrow(/invoicePrefix/);
|
|
375
|
+
expect(() => new ClearingServerScheme("mainnet" as never)).toThrow(/network/);
|
|
376
|
+
});
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
function listen(server: Server): Promise<string> {
|
|
380
|
+
return new Promise((resolve, reject) => {
|
|
381
|
+
server.once("error", reject);
|
|
382
|
+
server.listen(0, "127.0.0.1", () => {
|
|
383
|
+
const address = server.address();
|
|
384
|
+
if (!address || typeof address === "string") return reject(new Error("no port"));
|
|
385
|
+
resolve(`http://127.0.0.1:${address.port}`);
|
|
386
|
+
});
|
|
387
|
+
});
|
|
388
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
// The seller's side of native clearing for the official x402 server
|
|
2
|
+
// packages: a scheme the stock middleware prices and offers like `exact`,
|
|
3
|
+
// and settles before the handler runs. The offer names the Clearing
|
|
4
|
+
// contract and the service the facilitator settles against, and binds the
|
|
5
|
+
// invoice namespace to the resource the payer asked for, so the
|
|
6
|
+
// facilitator's `verifyClearingPayment` finds every field it checks.
|
|
7
|
+
import type {
|
|
8
|
+
AssetAmount,
|
|
9
|
+
PaymentRequirements,
|
|
10
|
+
Price,
|
|
11
|
+
SchemeNetworkServer,
|
|
12
|
+
SchemePaymentRequiredContext,
|
|
13
|
+
SupportedKind,
|
|
14
|
+
} from "@x402/core/types";
|
|
15
|
+
import * as x402Server from "@x402/core/server";
|
|
16
|
+
import { formatUnits, isAddress, sha256, stringToHex, type Hex } from "viem";
|
|
17
|
+
import { CLEARING_SCHEME, clearingAccount } from "./clearing-codec.js";
|
|
18
|
+
import { CLEARING_MAX_AMOUNT } from "./clearing-custody.js";
|
|
19
|
+
import { USDZ, type DollarToken } from "./networks.js";
|
|
20
|
+
import { dollarBaseUnits } from "./server.js";
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The `bajillion` scheme for an `x402ResourceServer`, registered beside
|
|
24
|
+
* `exact` on `network`. A route's `payTo` is the seller's clearing key,
|
|
25
|
+
* `signer.publicKey` of a wallet from `openClearingWallet`, and its `price`
|
|
26
|
+
* is dollars in `token`. The offer names the Clearing deployment the
|
|
27
|
+
* facilitator lists for the network in `/supported`, so a seller takes only
|
|
28
|
+
* payments its facilitator settles.
|
|
29
|
+
*
|
|
30
|
+
* server.register(network, new ClearingServerScheme(network));
|
|
31
|
+
*/
|
|
32
|
+
export class ClearingServerScheme implements SchemeNetworkServer {
|
|
33
|
+
readonly scheme = CLEARING_SCHEME;
|
|
34
|
+
// The money moved when the clearing service issued the receipt, so the
|
|
35
|
+
// facilitator records it before the handler runs: a receipt presented
|
|
36
|
+
// again, or twice at once, is served at most once.
|
|
37
|
+
readonly defaultAssetTransferMethod = "default";
|
|
38
|
+
readonly paymentFlows = {
|
|
39
|
+
default: { supported: ["upfront"], default: "upfront" },
|
|
40
|
+
} as const;
|
|
41
|
+
|
|
42
|
+
constructor(
|
|
43
|
+
private readonly network: `eip155:${number}`,
|
|
44
|
+
private readonly token: DollarToken = USDZ,
|
|
45
|
+
) {
|
|
46
|
+
if (!/^eip155:\d+$/.test(network)) throw new Error(`not a network: ${network}`);
|
|
47
|
+
// A server without payment flows ignores `paymentFlows` and settles after the handler.
|
|
48
|
+
if (!("resolvePaymentFlow" in x402Server))
|
|
49
|
+
throw new Error("ClearingServerScheme needs @x402/core 2.28 or later, which settles a clearing payment before the handler runs");
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async parsePrice(price: Price): Promise<AssetAmount> {
|
|
53
|
+
if (typeof price === "object" && price !== null && "amount" in price) {
|
|
54
|
+
let amount: bigint;
|
|
55
|
+
try {
|
|
56
|
+
amount = BigInt(price.amount);
|
|
57
|
+
} catch {
|
|
58
|
+
throw new Error(`not a price: ${price.amount}`);
|
|
59
|
+
}
|
|
60
|
+
if (amount <= 0n) throw new Error(`not a price: ${price.amount}`);
|
|
61
|
+
return { amount: this.payable(amount).toString(), asset: price.asset ?? this.token.address, extra: price.extra ?? {} };
|
|
62
|
+
}
|
|
63
|
+
return { amount: this.payable(BigInt(dollarBaseUnits(price, this.token))).toString(), asset: this.token.address };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Clearing counts whole clearing units, at most 2^64 - 1 of them in all.
|
|
67
|
+
* The unit is the one `Clearing.unit()` derives from the token's decimals
|
|
68
|
+
* at deploy: a millionth of the token, or its base unit at six decimals or
|
|
69
|
+
* fewer. */
|
|
70
|
+
private payable(amount: bigint): bigint {
|
|
71
|
+
const decimals = this.token.decimals;
|
|
72
|
+
const unit = decimals > 6 ? 10n ** BigInt(decimals - 6) : 1n;
|
|
73
|
+
if (amount % unit !== 0n)
|
|
74
|
+
throw new Error(`a clearing price is a whole number of ${formatUnits(unit, decimals)} ${this.token.symbol}`);
|
|
75
|
+
if (amount / unit > CLEARING_MAX_AMOUNT)
|
|
76
|
+
throw new Error(`a clearing price is at most ${formatUnits(CLEARING_MAX_AMOUNT * unit, decimals)} ${this.token.symbol}`);
|
|
77
|
+
return amount;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
getAssetDecimals(): number {
|
|
81
|
+
return this.token.decimals;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async enhancePaymentRequirements(requirements: PaymentRequirements, supportedKind: SupportedKind): Promise<PaymentRequirements> {
|
|
85
|
+
if (requirements.network !== this.network)
|
|
86
|
+
throw new Error(`this clearing scheme is on ${this.network}; the route offers it on ${requirements.network}`);
|
|
87
|
+
try {
|
|
88
|
+
clearingAccount(requirements.payTo as Hex);
|
|
89
|
+
} catch {
|
|
90
|
+
throw new Error(
|
|
91
|
+
`a clearing offer's payTo is the seller's clearing key (signer.publicKey of a wallet from openClearingWallet); got ${requirements.payTo}`,
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
if (requirements.asset.toLowerCase() !== this.token.address.toLowerCase())
|
|
95
|
+
throw new Error(`this clearing scheme prices in ${this.token.address}; the route offers ${requirements.asset}`);
|
|
96
|
+
const prefix = requirements.extra?.invoicePrefix;
|
|
97
|
+
if (prefix !== undefined && (typeof prefix !== "string" || !/^0x[0-9a-f]{64}$/.test(prefix)))
|
|
98
|
+
throw new Error(`a route's invoicePrefix is 32 bytes of lowercase hex; got ${String(prefix)}`);
|
|
99
|
+
const { settlement, asset, serviceUrl } = supportedKind.extra ?? {};
|
|
100
|
+
if (
|
|
101
|
+
typeof settlement !== "string" ||
|
|
102
|
+
!isAddress(settlement) ||
|
|
103
|
+
typeof asset !== "string" ||
|
|
104
|
+
asset.toLowerCase() !== this.token.address.toLowerCase() ||
|
|
105
|
+
typeof serviceUrl !== "string" ||
|
|
106
|
+
!URL.canParse(serviceUrl)
|
|
107
|
+
)
|
|
108
|
+
throw new Error(`the facilitator settles no clearing deployment in ${this.token.symbol} on ${this.network}`);
|
|
109
|
+
return { ...requirements, extra: { ...requirements.extra, settlement, serviceUrl } };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Binds this network's offers' invoices to the resource asked for, at
|
|
113
|
+
* the challenge and again when a payment for it is verified. A route that
|
|
114
|
+
* sets its own `invoicePrefix` keeps it. */
|
|
115
|
+
async enrichPaymentRequiredResponse(ctx: SchemePaymentRequiredContext): Promise<PaymentRequirements[]> {
|
|
116
|
+
const invoicePrefix = sha256(stringToHex(ctx.resourceInfo.url));
|
|
117
|
+
return ctx.requirements.map((offer) =>
|
|
118
|
+
offer.scheme === CLEARING_SCHEME && offer.network === this.network && offer.extra?.invoicePrefix === undefined
|
|
119
|
+
? { ...offer, extra: { ...offer.extra, invoicePrefix } }
|
|
120
|
+
: offer,
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
}
|