@metalntwx/metal-pay 0.1.1 → 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.
Files changed (72) hide show
  1. package/README.md +82 -39
  2. package/dist/clearing-abi.d.ts +1836 -0
  3. package/dist/clearing-abi.js +2361 -0
  4. package/dist/clearing-client.d.ts +49 -159
  5. package/dist/clearing-client.js +191 -564
  6. package/dist/clearing-codec.d.ts +57 -0
  7. package/dist/clearing-codec.js +173 -0
  8. package/dist/clearing-custody.d.ts +191 -0
  9. package/dist/clearing-custody.js +490 -0
  10. package/dist/clearing-server.d.ts +30 -49
  11. package/dist/clearing-server.js +87 -67
  12. package/dist/clearing-x402.d.ts +29 -0
  13. package/dist/clearing-x402.js +203 -0
  14. package/dist/client.d.ts +13 -13
  15. package/dist/client.js +45 -18
  16. package/dist/index.d.ts +4 -1
  17. package/dist/index.js +4 -1
  18. package/dist/mandates/account.d.ts +5 -21
  19. package/dist/mandates/account.js +28 -10
  20. package/dist/mandates/binding.d.ts +4 -27
  21. package/dist/mandates/binding.js +5 -9
  22. package/dist/mandates/checkout.js +5 -5
  23. package/dist/mandates/client.js +10 -6
  24. package/dist/mandates/l3a.d.ts +2 -2
  25. package/dist/mandates/l3a.js +5 -4
  26. package/dist/mandates/seller.d.ts +1 -1
  27. package/dist/mandates/seller.js +1 -1
  28. package/dist/mandates/verify.d.ts +4 -6
  29. package/dist/mandates/verify.js +42 -66
  30. package/dist/mandates/vi/credential.js +4 -4
  31. package/dist/node.d.ts +11 -0
  32. package/dist/node.js +97 -0
  33. package/package.json +19 -12
  34. package/src/clearing-abi.ts +2361 -0
  35. package/src/clearing-client.ts +303 -0
  36. package/src/clearing-codec.test.ts +57 -0
  37. package/src/clearing-codec.ts +251 -0
  38. package/src/clearing-custody.test.ts +534 -0
  39. package/src/clearing-custody.ts +665 -0
  40. package/src/clearing-fixture.json +37 -0
  41. package/src/clearing-server.test.ts +388 -0
  42. package/src/clearing-server.ts +123 -0
  43. package/src/clearing-x402.test.ts +181 -0
  44. package/src/clearing-x402.ts +279 -0
  45. package/src/client.test.ts +325 -0
  46. package/src/client.ts +156 -0
  47. package/src/index.ts +33 -0
  48. package/src/mandates/account.ts +91 -0
  49. package/src/mandates/binding.ts +280 -0
  50. package/src/mandates/chain.ts +68 -0
  51. package/src/mandates/checkout.ts +368 -0
  52. package/src/mandates/client.ts +159 -0
  53. package/src/mandates/extension.ts +48 -0
  54. package/src/mandates/index.ts +121 -0
  55. package/src/mandates/ingest.ts +42 -0
  56. package/src/mandates/jcs.ts +16 -0
  57. package/src/mandates/l3a.ts +216 -0
  58. package/src/mandates/seller.ts +91 -0
  59. package/src/mandates/verify.ts +532 -0
  60. package/src/mandates/vi/credential.ts +477 -0
  61. package/src/mandates/vi/issue.ts +118 -0
  62. package/src/mandates/vi/sd-jwt.ts +189 -0
  63. package/src/networks.test.ts +16 -0
  64. package/src/networks.ts +152 -0
  65. package/src/node.test.ts +24 -0
  66. package/src/node.ts +96 -0
  67. package/src/server.test.ts +223 -0
  68. package/src/server.ts +77 -0
  69. package/dist/clearing.d.ts +0 -204
  70. package/dist/clearing.js +0 -151
  71. package/dist/file-storage.d.ts +0 -39
  72. package/dist/file-storage.js +0 -195
@@ -1,580 +1,207 @@
1
- // The payer's side of the clearing lane: a scheme plug-in for the official
2
- // x402 client. Register it next to the standard `exact` scheme and pass its
3
- // selector to the client:
4
- //
5
- // const clearing = new ClearingDebitScheme(signer, {
6
- // facilitator: "https://<facilitator host>/x402/facilitator",
7
- // });
8
- // const client = new x402Client(clearing.prefer)
9
- // .register(network, new ExactEvmScheme(signer))
10
- // .register(network, clearing);
11
- // const pay = wrapFetchWithPayment(clearing.wrapFetch(fetch), client);
12
- //
13
- // A payment is a signed cumulative Debit: "I have now spent X in total this
14
- // epoch". It draws on funds the payer holds in the Clearing contract, which
15
- // the payer (or anyone funding it) puts there by calling the contract's
16
- // `deposit`; `depositToClearing` does that. While custody is short the
17
- // selector takes the standard offer instead.
18
- //
19
- // Where the next Debit starts comes from the facilitator the payer names
20
- // here, never from the seller: the client asks it, with a request it signs,
21
- // about every 20 seconds while it pays and after any refusal. A seller
22
- // therefore cannot make the payer sign for more than it spent. The client
23
- // also keeps its own record of the highest total it has signed each epoch
24
- // (`storage`; in memory by default, `FileClearingPositionStorage` from
25
- // `@metalntwx/metal-pay/file-storage` for one that survives a restart) and refuses to sign past a total the facilitator
26
- // reports above it.
27
- //
28
- // When a seller cannot say whether a Debit was admitted, or its answer never
29
- // reaches the payer (`wrapFetch` sees the request fail, or a server error
30
- // that says nothing about the Debit), the client presents that same Debit
31
- // again rather than signing a new one, so the payer is not charged twice for
32
- // one payment.
33
- import { decodePaymentSignatureHeader } from "@x402/core/http";
34
- import { createPublicClient, createWalletClient, encodeFunctionData, erc20Abi, http, } from "viem";
35
- import { ACCOUNT_REQUEST_TYPES, CLEARING_DEBIT, clearingDomain, DEBIT_TYPES, EXIT_TYPES, } from "./clearing.js";
36
- /** `next`, with `highestSigned` no lower than the one already stored. */
37
- export function keepHighest(existing, next) {
38
- if (!existing)
39
- return next;
40
- return BigInt(existing.highestSigned) > BigInt(next.highestSigned)
41
- ? { ...next, highestSigned: existing.highestSigned }
42
- : next;
1
+ import { createPublicClient, http } from "viem";
2
+ import { clearingAbi } from "./clearing-abi.js";
3
+ import { CLEARING_REFERENCE, clearingUnits, decodeSend, encodeSend, invoiceMessage, outgoingRoot, verifyReceipt, } from "./clearing-codec.js";
4
+ class PaymentRejected extends Error {
43
5
  }
44
- /** The default store: positions do not survive a process restart. */
45
- export class InMemoryClearingPositionStorage {
46
- positions = new Map();
47
- async get(key) {
48
- return this.positions.get(key);
49
- }
50
- async set(key, position) {
51
- this.positions.set(key, keepHighest(this.positions.get(key), position));
52
- }
53
- }
54
- function clearingExtraOf(requirements) {
55
- const clearing = requirements.extra?.["clearing"];
56
- if (!clearing?.contract || !clearing.ref)
57
- throw new Error("clearing offer carries no contract or ref");
58
- return clearing;
59
- }
60
- const chainIdOf = (network) => {
61
- const chainId = Number(network.slice("eip155:".length));
62
- if (!network.startsWith("eip155:") || !Number.isSafeInteger(chainId))
63
- throw new Error(`not an EVM network: ${network}`);
64
- return chainId;
65
- };
66
- /** A request this account signs to ask the facilitator about itself. */
67
- async function accountRequest(signer, network, contract, nowMs) {
68
- const deadline = BigInt(Math.floor(nowMs / 1000) + 60);
69
- const signature = await signer.signTypedData({
70
- domain: clearingDomain(chainIdOf(network), contract),
71
- types: ACCOUNT_REQUEST_TYPES,
72
- primaryType: "AccountRequest",
73
- message: { account: signer.address, deadline },
74
- });
75
- return {
76
- account: signer.address,
77
- network,
78
- deadline: deadline.toString(),
79
- signature,
80
- };
81
- }
82
- async function post(doFetch, url, body) {
83
- const res = await doFetch(url, {
84
- method: "POST",
85
- headers: { "content-type": "application/json" },
86
- body: JSON.stringify(body),
87
- });
88
- const json = (await res.json().catch(() => ({})));
89
- if (!res.ok)
90
- throw new Error(`facilitator refused: ${json.error ?? res.status}`);
91
- return json;
92
- }
93
- /** Ask the facilitator where `signer`'s clearing position stands, with a
94
- * request `signer` signs. */
95
- async function fetchPosition(doFetch, facilitator, signer, network, contract, nowMs) {
96
- const answer = await post(doFetch, `${facilitator}/clearing/position`, await accountRequest(signer, network, contract, nowMs));
97
- if (answer.contract.toLowerCase() !== contract.toLowerCase())
98
- throw new Error("the seller names a different Clearing contract than the facilitator");
99
- return answer;
100
- }
101
- const CUSTODY_SHORT = "clearing custody is short of the price: deposit into the Clearing contract first (see depositToClearing)";
102
- const SELLER_REFUSES_CLEARING = "this seller cannot take clearing payments (clearing_requires_seller_token) and offers no standard payment";
103
- /** How long a position from the facilitator is used before asking again:
104
- * well inside an epoch, so the client rarely signs for one that has closed. */
105
- const POSITION_TTL_MS = 20_000;
106
- /** How long the client pays the standard way after being told custody is
107
- * short, before trying clearing again. */
108
- const SHORT_MS = 60_000;
109
- /** What tells one Debit from another: its signature. */
110
- const signatureOf = (payload) => payload.signature;
111
- /** Reasons a seller gives that say a Debit may yet have been admitted. */
112
- const IN_DOUBT = new Set(["settlement_in_doubt", "payment_in_flight"]);
113
- export class ClearingDebitScheme {
114
- signer;
115
- scheme = CLEARING_DEBIT;
116
- schemeHooks;
117
- facilitator;
118
- storage;
119
- doFetch;
120
- now;
121
- /** Per payer and contract: the facilitator's last answer, adjusted for
122
- * what this client has signed since. */
123
- positions = new Map();
124
- /** Per payer and contract: until when to pay the standard way. */
125
- shortUntil = new Map();
126
- /** Sellers the facilitator settles no clearing for, by network, contract
127
- * and payee: a seller without Metal's seller token. That holds for as long
128
- * as the client runs, so they are paid the standard way from then on. */
129
- standardOnly = new Set();
130
- /** Per payer and contract: the Debits whose outcome is in doubt, each to
131
- * be presented again for its own offer. Only an answer about that Debit
132
- * clears it. */
133
- unresolved = new Map();
134
- constructor(signer, options) {
135
- this.signer = signer;
136
- this.facilitator = options.facilitator.replace(/\/$/, "");
137
- this.storage = options.storage ?? new InMemoryClearingPositionStorage();
138
- this.doFetch = options.fetch ?? globalThis.fetch;
139
- this.now = options.now ?? (() => Date.now());
140
- this.schemeHooks = {
141
- // How a paid request ended decides the retry the transport makes.
142
- onPaymentResponse: async (ctx) => {
143
- if (ctx.requirements.scheme !== CLEARING_DEBIT)
144
- return;
145
- const key = this.key(ctx.requirements);
146
- const payload = ctx.paymentPayload.payload;
147
- if (ctx.settleResponse?.success) {
148
- // Settled by clearing: custody covered it, whatever was thought.
149
- this.release(key, payload);
150
- this.shortUntil.delete(key);
151
- return;
152
- }
153
- // No word about the Debit: `wrapFetch` holds it if it may have been
154
- // admitted, and where the next one starts is asked afresh.
155
- if (!ctx.settleResponse && !ctx.paymentRequired) {
156
- this.positions.delete(key);
157
- return;
158
- }
159
- const reason = ctx.settleResponse?.errorReason ?? ctx.paymentRequired?.error ?? "";
160
- // Perhaps admitted: the retry presents the same Debit again.
161
- if (IN_DOUBT.has(reason)) {
162
- this.hold(key, this.offerId(ctx.requirements), payload);
163
- return { recovered: true };
164
- }
165
- this.release(key, payload);
166
- this.positions.delete(key);
167
- if (reason === "insufficient_custody") {
168
- this.shortUntil.set(key, this.now() + SHORT_MS);
169
- return { recovered: true };
170
- }
171
- if (reason === "clearing_requires_seller_token") {
172
- this.standardOnly.add(this.sellerOf(ctx.requirements));
173
- return { recovered: true };
174
- }
175
- if (reason === "debit_out_of_sequence")
176
- return { recovered: true };
177
- return undefined;
178
- },
179
- };
180
- }
181
- /** The selector to give `new x402Client(...)`: the clearing offer, unless
182
- * custody was lately found short or the seller cannot take clearing, in
183
- * which case the standard one. A Debit whose outcome is in doubt is always
184
- * presented again by clearing. */
185
- prefer = (_x402Version, accepts) => {
186
- const clearing = accepts.find((a) => a.scheme === CLEARING_DEBIT);
187
- const standard = accepts.find((a) => a.scheme !== CLEARING_DEBIT);
188
- if (clearing && this.standardOnly.has(this.sellerOf(clearing))) {
189
- if (standard)
190
- return standard;
191
- throw new Error(SELLER_REFUSES_CLEARING);
6
+ export function createClearingClient(config) {
7
+ const fetcher = config.fetch ?? globalThis.fetch;
8
+ const chain = createPublicClient({ transport: http(config.rpcUrl) });
9
+ let tail = Promise.resolve();
10
+ let broken = false;
11
+ async function save(state) {
12
+ try {
13
+ await config.store.save(state);
192
14
  }
193
- if (clearing) {
194
- const key = this.key(clearing);
195
- const offer = this.offerId(clearing);
196
- const short = !this.unresolved.get(key)?.some((held) => held.offer === offer) &&
197
- (this.shortUntil.get(key) ?? 0) > this.now();
198
- if (!short)
199
- return clearing;
200
- if (standard)
201
- return standard;
202
- throw new Error(CUSTODY_SHORT);
15
+ catch (error) {
16
+ broken = true;
17
+ throw error;
203
18
  }
204
- if (!standard)
205
- throw new Error("no payable offer");
206
- return standard;
207
- };
208
- /** One payment at a time per payer and contract: reading the position,
209
- * signing and recording it must not interleave, or two payments would sign
210
- * the same index. */
211
- queues = new Map();
212
- exclusive(key, job) {
213
- const run = (this.queues.get(key) ?? Promise.resolve()).then(job, job);
214
- this.queues.set(key, run.catch(() => undefined));
215
- return run;
216
19
  }
217
- key(requirements) {
218
- const clearing = requirements.extra?.["clearing"];
219
- return `${requirements.network}:${String(clearing?.contract).toLowerCase()}:${this.signer.address.toLowerCase()}`;
220
- }
221
- sellerOf(requirements) {
222
- const clearing = requirements.extra?.["clearing"];
223
- return `${requirements.network}:${String(clearing?.contract).toLowerCase()}:${requirements.payTo.toLowerCase()}`;
224
- }
225
- /** The fetch to give `wrapFetchWithPayment`. A request carrying a Debit
226
- * that fails without an answer, or ends in a server error that says
227
- * nothing about the Debit (a proxy's 502 or 504), may still have been
228
- * admitted, so its Debit is held in doubt: the next payment for that offer
229
- * presents it again. It is held in memory, for as long as the client
230
- * runs. */
231
- wrapFetch(fetch) {
232
- return async (input, init) => {
233
- const headers = init?.headers
234
- ? new Headers(init.headers)
235
- : input instanceof Request
236
- ? input.headers
237
- : undefined;
238
- const header = headers?.get("payment-signature");
239
- let response;
240
- try {
241
- response = await fetch(input, init);
242
- }
243
- catch (error) {
244
- this.holdSent(header);
245
- throw error;
246
- }
247
- if (response.status >= 500 &&
248
- !response.headers.has("payment-response") &&
249
- !response.headers.has("x-payment-response"))
250
- this.holdSent(header);
251
- return response;
20
+ async function request(path, body) {
21
+ const response = await fetcher(new URL(path, config.serviceUrl), {
22
+ method: body ? "POST" : "GET",
23
+ ...(body
24
+ ? {
25
+ headers: { "content-type": "application/json" },
26
+ body: JSON.stringify(body),
27
+ }
28
+ : {}),
29
+ signal: AbortSignal.timeout(30_000),
30
+ });
31
+ if (!response.ok) {
32
+ const result = (await response.json().catch(() => null));
33
+ if (result?.rejected === true)
34
+ throw new PaymentRejected("clearing payment rejected before acknowledgment");
35
+ throw new Error(`clearing service returned ${response.status}`);
36
+ }
37
+ return (await response.json());
38
+ }
39
+ /** Why this wallet does not pay `invoice`, or undefined when it does. */
40
+ function refusal(invoice) {
41
+ if (invoice.network !== config.network ||
42
+ invoice.asset.toLowerCase() !== config.asset.toLowerCase() ||
43
+ invoice.settlement.toLowerCase() !== config.settlement.toLowerCase())
44
+ return "clearing invoice deployment mismatch";
45
+ const amount = BigInt(invoice.amount);
46
+ if (amount <= 0n || amount > config.maxAmount)
47
+ return "clearing payment exceeds spending limit";
48
+ }
49
+ async function pay(invoice) {
50
+ if (broken)
51
+ throw new Error("clearing wallet persistence failed; reopen the wallet");
52
+ const refused = refusal(invoice);
53
+ if (refused)
54
+ throw new Error(refused);
55
+ const amount = BigInt(invoice.amount);
56
+ let state = (await config.store.load()) ?? {
57
+ entries: [],
58
+ sequence: "0",
59
+ payments: {},
252
60
  };
253
- }
254
- /** Holds the Debit a request carried, if it carried one of this client's. */
255
- holdSent(header) {
256
- let paid;
257
- try {
258
- paid = header ? decodePaymentSignatureHeader(header) : undefined;
61
+ const paid = state.payments[invoice.id];
62
+ if (paid) {
63
+ if (invoiceMessage(paid.invoice, paid.receipt.body) !==
64
+ invoiceMessage(invoice, paid.receipt.body))
65
+ throw new Error("invoice id reused with different terms");
66
+ return paid;
259
67
  }
260
- catch {
261
- // Not a payment this client made.
68
+ if (state.pending &&
69
+ invoiceMessage(state.pending.invoice, state.pending.body) !==
70
+ invoiceMessage(invoice, state.pending.body))
71
+ throw new Error("an earlier clearing payment has an unknown outcome; retry that invoice first");
72
+ if (!state.pending) {
73
+ const context = await request("/context");
74
+ if (context.reference !== CLEARING_REFERENCE ||
75
+ context.network !== config.network ||
76
+ context.settlement.toLowerCase() !== config.settlement.toLowerCase() ||
77
+ context.asset.toLowerCase() !== config.asset.toLowerCase())
78
+ throw new Error("unsupported clearing service deployment");
79
+ const epoch = BigInt(context.epoch);
80
+ const [registered, onchain, fault, chainId, admitted, block, unit] = await Promise.all([
81
+ chain.readContract({
82
+ address: config.settlement,
83
+ abi: clearingAbi,
84
+ functionName: "registered",
85
+ }),
86
+ chain.readContract({
87
+ address: config.settlement,
88
+ abi: clearingAbi,
89
+ functionName: "contextOf",
90
+ args: [epoch],
91
+ }),
92
+ chain.readContract({
93
+ address: config.settlement,
94
+ abi: clearingAbi,
95
+ functionName: "fault",
96
+ }),
97
+ chain.getChainId(),
98
+ chain.readContract({
99
+ address: config.settlement,
100
+ abi: clearingAbi,
101
+ functionName: "admittedCount",
102
+ }),
103
+ chain.getBlock(),
104
+ chain.readContract({
105
+ address: config.settlement,
106
+ abi: clearingAbi,
107
+ functionName: "unit",
108
+ }),
109
+ ]);
110
+ if (!registered ||
111
+ fault ||
112
+ admitted !== epoch ||
113
+ block.timestamp >= onchain.admissionDeadline ||
114
+ `eip155:${chainId}` !== config.network ||
115
+ onchain.anchor !== context.anchor ||
116
+ onchain.operatorKey !== context.operator)
117
+ throw new Error("clearing epoch is not registered");
118
+ if (state.epoch !== context.epoch)
119
+ state = { ...state, epoch: context.epoch, sequence: "0", entries: [] };
120
+ const entries = state.entries.map((e) => ({ ...e }));
121
+ let entry = entries.find((e) => e.recipient.toLowerCase() === invoice.recipient.toLowerCase());
122
+ if (!entry) {
123
+ entry = { recipient: invoice.recipient, cumulative: "0", count: "0" };
124
+ entries.push(entry);
125
+ }
126
+ // The invoice is in token base units; the vector counts clearing units.
127
+ entry.cumulative = (BigInt(entry.cumulative) + clearingUnits(amount, unit)).toString();
128
+ entry.count = (BigInt(entry.count) + 1n).toString();
129
+ entries.sort((a, b) => a.recipient.toLowerCase().localeCompare(b.recipient.toLowerCase()));
130
+ const vector = entries.map((e) => ({
131
+ ...e,
132
+ cumulative: BigInt(e.cumulative),
133
+ count: BigInt(e.count),
134
+ }));
135
+ const body = encodeSend({
136
+ anchor: context.anchor,
137
+ epoch,
138
+ payer: config.signer.publicKey,
139
+ sequence: BigInt(state.sequence),
140
+ debit: vector.reduce((sum, e) => sum + e.cumulative, 0n),
141
+ vector: outgoingRoot(vector),
142
+ });
143
+ state = {
144
+ ...state,
145
+ pending: {
146
+ invoice,
147
+ body,
148
+ entries,
149
+ payerSignature: await config.signer.sign("_COMMONWARE_CLEARING_VECTOR_SEND", body),
150
+ invoiceSignature: await config.signer.sign("_METAL_CLEARING_INVOICE", invoiceMessage(invoice, body)),
151
+ },
152
+ };
153
+ await save(state);
262
154
  }
263
- if (paid?.accepted.scheme === CLEARING_DEBIT)
264
- this.hold(this.key(paid.accepted), this.offerId(paid.accepted), paid.payload);
265
- }
266
- hold(key, offer, payload) {
267
- const held = this.unresolved.get(key) ?? [];
268
- const same = held.find((h) => signatureOf(h.payload) === signatureOf(payload));
269
- if (same)
270
- same.unasked = true;
271
- else
272
- this.unresolved.set(key, [...held, { offer, payload, unasked: true }]);
273
- }
274
- /** Clears a Debit once a seller has answered about it. */
275
- release(key, payload) {
276
- const held = (this.unresolved.get(key) ?? []).filter((h) => signatureOf(h.payload) !== signatureOf(payload));
277
- if (held.length)
278
- this.unresolved.set(key, held);
279
- else
280
- this.unresolved.delete(key);
281
- }
282
- /** What makes two offers the same payment: a Debit signed for one can be
283
- * presented again for the other. */
284
- offerId(requirements) {
285
- const clearing = requirements.extra?.["clearing"];
286
- return [
287
- requirements.network,
288
- requirements.asset,
289
- requirements.payTo,
290
- requirements.amount,
291
- clearing?.contract,
292
- clearing?.ref,
293
- ]
294
- .map((part) => String(part).toLowerCase())
295
- .join("|");
296
- }
297
- /** Where the next Debit starts: the facilitator's answer while it is
298
- * fresh, else a new one. */
299
- async position(requirements, clearing) {
300
- const key = this.key(requirements);
301
- const known = this.positions.get(key);
302
- if (known && this.now() - known.fetchedAt < POSITION_TTL_MS)
303
- return known;
304
- const answer = await fetchPosition(this.doFetch, this.facilitator, this.signer, requirements.network, clearing.contract, this.now());
305
- const position = {
306
- epoch: BigInt(answer.epoch),
307
- index: BigInt(answer.index),
308
- cumulative: BigInt(answer.cumulativeDebit),
309
- available: BigInt(answer.available),
310
- fetchedAt: this.now(),
311
- };
312
- // Every Debit this client signed it recorded first, so the true total
313
- // cannot be above the highest it signed. A lower one is fine: a Debit
314
- // that was never admitted.
315
- const record = await this.storage.get(`${key}:${position.epoch}`);
316
- if (record && position.cumulative > BigInt(record.highestSigned))
317
- throw new Error("the facilitator reports a clearing total above what this client has signed; refusing to sign");
318
- this.positions.set(key, position);
319
- return position;
320
- }
321
- createPaymentPayload(x402Version, requirements) {
322
- return this.exclusive(this.key(requirements), () => this.sign(x402Version, requirements));
323
- }
324
- async sign(x402Version, requirements) {
325
- const key = this.key(requirements);
326
- const offer = this.offerId(requirements);
327
- // A Debit whose outcome is in doubt is presented again as it is: if it
328
- // was admitted, the seller's facilitator answers with its receipt.
329
- const held = this.unresolved.get(key) ?? [];
330
- const pending = held.find((h) => h.offer === offer);
331
- if (pending)
332
- return { x402Version, payload: pending.payload };
333
- // A different payment now. Whether a Debit held since the position was
334
- // last asked for was admitted shows in a fresh one; it stays held for
335
- // its own offer.
336
- if (held.some((h) => h.unasked)) {
337
- this.positions.delete(key);
338
- for (const h of held)
339
- h.unasked = false;
155
+ const pending = state.pending;
156
+ let result;
157
+ try {
158
+ result = await request("/payments", pending);
340
159
  }
341
- const clearing = clearingExtraOf(requirements);
342
- const price = BigInt(requirements.amount);
343
- const position = await this.position(requirements, clearing);
344
- if (position.available < price) {
345
- // Signed all the same: the seller refuses it as short, and the
346
- // transport's retry takes the standard offer.
347
- this.shortUntil.set(key, this.now() + SHORT_MS);
160
+ catch (error) {
161
+ if (error instanceof PaymentRejected)
162
+ await save({ ...state, pending: undefined });
163
+ throw error;
348
164
  }
349
- const debit = {
350
- payer: this.signer.address,
351
- recipient: requirements.payTo,
352
- epoch: position.epoch,
353
- index: position.index,
354
- cumulativeDebit: position.cumulative + price,
355
- ref: clearing.ref,
356
- };
357
- // Recorded before it is released: a Debit the client has no record of
358
- // signing would weaken every later check.
359
- const recordKey = `${key}:${debit.epoch}`;
360
- const record = await this.storage.get(recordKey);
361
- const highest = record && BigInt(record.highestSigned) > debit.cumulativeDebit
362
- ? BigInt(record.highestSigned)
363
- : debit.cumulativeDebit;
364
- await this.storage.set(recordKey, {
365
- epoch: debit.epoch.toString(),
366
- index: (debit.index + 1n).toString(),
367
- cumulativeDebit: debit.cumulativeDebit.toString(),
368
- highestSigned: highest.toString(),
369
- });
370
- const signature = await this.signer.signTypedData({
371
- domain: clearingDomain(chainIdOf(requirements.network), clearing.contract),
372
- types: DEBIT_TYPES,
373
- primaryType: "Debit",
374
- message: debit,
375
- });
376
- // The next Debit starts where this one ends, if it is admitted; if it is
377
- // not, the seller's refusal says so and the position is asked again.
378
- this.positions.set(key, {
379
- ...position,
380
- index: debit.index + 1n,
381
- cumulative: debit.cumulativeDebit,
382
- available: position.available - price,
165
+ const operator = await chain.readContract({
166
+ address: config.settlement,
167
+ abi: clearingAbi,
168
+ functionName: "operatorKey",
383
169
  });
384
- const payload = {
385
- signature,
386
- debit: {
387
- payer: debit.payer,
388
- recipient: debit.recipient,
389
- epoch: debit.epoch.toString(),
390
- index: debit.index.toString(),
391
- cumulativeDebit: debit.cumulativeDebit.toString(),
392
- ref: debit.ref,
393
- },
170
+ if (result.receipt.body !== pending.body ||
171
+ result.receipt.payerSignature !== pending.payerSignature ||
172
+ invoiceMessage(result.invoice, pending.body) !==
173
+ invoiceMessage(invoice, pending.body) ||
174
+ !verifyReceipt(result.receipt, operator))
175
+ throw new Error("invalid clearing acknowledgment; retain pending payment");
176
+ const settled = {
177
+ invoice,
178
+ receipt: result.receipt,
179
+ invoiceSignature: pending.invoiceSignature,
180
+ entries: pending.entries,
181
+ ...(result.previous ? { previous: result.previous } : {}),
394
182
  };
395
- return { x402Version, payload };
396
- }
397
- }
398
- const CLEARING_DEPOSIT_ABI = [
399
- {
400
- type: "function",
401
- name: "deposit",
402
- stateMutability: "nonpayable",
403
- inputs: [
404
- { name: "account", type: "address" },
405
- { name: "amount", type: "uint256" },
406
- ],
407
- outputs: [],
408
- },
409
- ];
410
- /** Put funds into clearing custody. `from` pays and sends the transactions:
411
- * the agent's own wallet, or anyone funding it, in which case `account` is
412
- * the agent's address. The tokens go from `from` straight into custody under
413
- * `account`; only `account`'s signature can spend or withdraw them.
414
- *
415
- * Two transactions (an approval for exactly this amount, unless that much
416
- * is already approved, then the deposit), paid for in the network's fee token by `from`. Gas and
417
- * fees are set here because a Metal node cannot be asked to estimate them.
418
- *
419
- * The operator counts a deposit some seconds after its receipt, and refuses
420
- * clearing payments against it until then. Given `facilitator`, this waits
421
- * until the facilitator reports the deposit in the payer's position, so the
422
- * next payment can draw on it. */
423
- export async function depositToClearing(opts) {
424
- if (opts.amount <= 0n)
425
- throw new Error("deposit amount must be positive");
426
- const credited = opts.account ?? opts.from.address;
427
- if (opts.facilitator && credited.toLowerCase() !== opts.from.address.toLowerCase())
428
- throw new Error("only the account itself can ask the facilitator about its custody; deposit without `facilitator` to fund another account");
429
- const network = `eip155:${opts.chainId}`;
430
- const facilitator = opts.facilitator?.replace(/\/$/, "");
431
- const doFetch = opts.fetch ?? globalThis.fetch;
432
- // What custody holds before this epoch's debits: `available` plus the
433
- // epoch's running total. Paying by clearing moves value between the two,
434
- // so only a deposit raises it.
435
- const custody = async () => {
436
- const position = await fetchPosition(doFetch, facilitator, opts.from, network, opts.contract, Date.now());
437
- const available = BigInt(position.available);
438
- return { epoch: position.epoch, available, held: available + BigInt(position.cumulativeDebit) };
439
- };
440
- // Read before depositing: an unreachable facilitator fails before any funds move.
441
- let last = facilitator ? await custody() : undefined;
442
- let before = last?.held ?? 0n;
443
- const transport = http(opts.rpcUrl);
444
- const pub = createPublicClient({ transport });
445
- const wallet = createWalletClient({ account: opts.from, transport });
446
- const send = async (to, data) => {
447
- const [block, nonce] = await Promise.all([
448
- pub.getBlock({ blockTag: "latest" }),
449
- pub.getTransactionCount({
450
- address: opts.from.address,
451
- blockTag: "pending",
452
- }),
453
- ]);
454
- const hash = await wallet.sendTransaction({
455
- chain: null,
456
- chainId: opts.chainId,
457
- to,
458
- data,
459
- value: 0n,
460
- gas: 500000n,
461
- maxFeePerGas: (block.baseFeePerGas ?? 0n) * 2n + 1n,
462
- maxPriorityFeePerGas: 1n,
463
- nonce,
464
- type: "eip1559",
183
+ await save({
184
+ ...state,
185
+ entries: pending.entries,
186
+ sequence: (decodeSend(pending.body).sequence + 1n).toString(),
187
+ pending: undefined,
188
+ payments: { ...state.payments, [invoice.id]: settled },
465
189
  });
466
- const receipt = await pub.waitForTransactionReceipt({
467
- hash,
468
- timeout: 60_000,
469
- });
470
- if (receipt.status !== "success")
471
- throw new Error(`transaction reverted: ${hash}`);
472
- return hash;
473
- };
474
- const allowance = await pub.readContract({
475
- address: opts.asset,
476
- abi: erc20Abi,
477
- functionName: "allowance",
478
- args: [opts.from.address, opts.contract],
479
- });
480
- const approveTx = allowance >= opts.amount
481
- ? null
482
- : await send(opts.asset, encodeFunctionData({
483
- abi: erc20Abi,
484
- functionName: "approve",
485
- args: [opts.contract, opts.amount],
486
- }));
487
- const deposit = {
488
- abi: CLEARING_DEPOSIT_ABI,
489
- functionName: "deposit",
490
- args: [credited, opts.amount],
491
- };
492
- // Simulated first, so a deposit the contract would refuse (a short
493
- // balance, a halted contract) fails with its reason and costs no gas.
494
- await pub.simulateContract({
495
- ...deposit,
496
- account: opts.from,
497
- address: opts.contract,
498
- gas: 500000n,
499
- });
500
- const depositTx = await send(opts.contract, encodeFunctionData(deposit));
501
- if (!facilitator)
502
- return { approveTx, depositTx };
503
- const deadline = Date.now() + (opts.timeoutMs ?? 60_000);
504
- for (;;) {
505
- const now = await custody();
506
- // A close settles the epoch's debits out of custody: what the last
507
- // reading had available is the new base.
508
- if (last && now.epoch !== last.epoch)
509
- before = last.available;
510
- last = now;
511
- if (now.held >= before + opts.amount)
512
- return { approveTx, depositTx, available: now.available };
513
- if (Date.now() >= deadline)
514
- throw new Error(`deposit ${depositTx} landed, but the facilitator has not counted it yet; clearing payments are refused until it does`);
515
- await new Promise((resolve) => setTimeout(resolve, opts.pollMs ?? 1_000));
190
+ return settled;
516
191
  }
517
- }
518
- /** Take funds out of clearing custody: a payer's unused deposit, or what a
519
- * seller has been paid. It asks the facilitator what can be withdrawn, signs
520
- * an Exit for that amount, and submits it; Metal's operator sends the two
521
- * transactions (request, then release) and pays their gas.
522
- *
523
- * Only this wallet's signature moves its funds, and only to the destination
524
- * it names, which defaults to the wallet itself. */
525
- export async function withdrawFromClearing(signer, facilitator, options) {
526
- const doFetch = options.fetch ?? globalThis.fetch;
527
- const base = facilitator.replace(/\/$/, "");
528
- const now = options.now?.() ?? Date.now();
529
- const listed = await doFetch(`${base}/supported`);
530
- if (!listed.ok)
531
- throw new Error(`facilitator refused: ${listed.status}`);
532
- const supported = (await listed.json());
533
- const kind = supported.kinds.find((item) => item.scheme === CLEARING_DEBIT && item.network === options.network);
534
- const contract = kind?.extra?.contract;
535
- if (!contract)
536
- throw new Error(`no clearing on ${options.network} here`);
537
- const quote = await post(doFetch, `${base}/clearing/exit/quote`, await accountRequest(signer, options.network, contract, now));
538
- if (BigInt(quote.deadline) > BigInt(Math.floor(now / 1000) + 86_400))
539
- throw new Error("the quoted deadline is too far away to sign");
540
- const available = BigInt(quote.amount);
541
- const amount = options.amount ?? available;
542
- if (amount > available)
543
- throw new Error(`only ${available} can be withdrawn now`);
544
- if (amount <= 0n) {
545
- // Custody becomes withdrawable once the epoch holding it has closed and
546
- // passed its challenge window; until then it is only spendable.
547
- const held = BigInt((await fetchPosition(doFetch, base, signer, options.network, contract, now))
548
- .available);
549
- throw new Error(held > 0n
550
- ? `nothing to withdraw yet: ${held} (base units) is in custody and can be withdrawn once the epoch it is in has closed and passed its challenge window`
551
- : "nothing to withdraw");
552
- }
553
- const exit = {
554
- account: signer.address,
555
- destination: options.destination ?? signer.address,
556
- amount,
557
- deadline: BigInt(quote.deadline),
558
- };
559
- const signature = await signer.signTypedData({
560
- domain: clearingDomain(chainIdOf(options.network), contract),
561
- types: EXIT_TYPES,
562
- primaryType: "Exit",
563
- message: exit,
564
- });
565
- const done = await post(doFetch, `${base}/clearing/exit`, {
566
- network: options.network,
567
- account: exit.account,
568
- destination: exit.destination,
569
- amount: exit.amount.toString(),
570
- deadline: exit.deadline.toString(),
571
- signature,
572
- });
573
192
  return {
574
- amount: BigInt(done.amount),
575
- requestTx: done.requestTx,
576
- // Null when another caller released the queued exit on chain first; the
577
- // funds still went to the Exit's destination.
578
- releaseTx: done.releaseTx ?? null,
193
+ account: config.signer.publicKey,
194
+ async pendingInvoice() {
195
+ return (await config.store.load())?.pending?.invoice ?? null;
196
+ },
197
+ payable(invoice) {
198
+ return refusal(invoice) === undefined;
199
+ },
200
+ pay(invoice) {
201
+ const result = tail.then(() => pay(invoice));
202
+ tail = result.catch(() => { });
203
+ return result;
204
+ },
205
+ request,
579
206
  };
580
207
  }