@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.
Files changed (75) hide show
  1. package/README.md +104 -38
  2. package/dist/clearing-abi.d.ts +1836 -0
  3. package/dist/clearing-abi.js +2361 -0
  4. package/dist/clearing-client.d.ts +96 -159
  5. package/dist/clearing-client.js +412 -553
  6. package/dist/clearing-codec.d.ts +57 -0
  7. package/dist/clearing-codec.js +173 -0
  8. package/dist/clearing-custody.d.ts +202 -0
  9. package/dist/clearing-custody.js +552 -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 +38 -0
  13. package/dist/clearing-x402.js +249 -0
  14. package/dist/client.d.ts +14 -15
  15. package/dist/client.js +53 -22
  16. package/dist/index.d.ts +4 -1
  17. package/dist/index.js +4 -1
  18. package/dist/mandates/account.d.ts +9 -4
  19. package/dist/mandates/account.js +15 -11
  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 +4 -3
  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 +4 -4
  28. package/dist/mandates/verify.d.ts +4 -6
  29. package/dist/mandates/verify.js +147 -82
  30. package/dist/mandates/vi/credential.js +4 -4
  31. package/dist/networks.d.ts +9 -1
  32. package/dist/networks.js +105 -4
  33. package/dist/node.d.ts +11 -0
  34. package/dist/node.js +97 -0
  35. package/package.json +18 -11
  36. package/src/clearing-abi.ts +2361 -0
  37. package/src/clearing-client.test.ts +850 -0
  38. package/src/clearing-client.ts +612 -0
  39. package/src/clearing-codec.test.ts +57 -0
  40. package/src/clearing-codec.ts +251 -0
  41. package/src/clearing-custody.test.ts +763 -0
  42. package/src/clearing-custody.ts +743 -0
  43. package/src/clearing-fixture.json +37 -0
  44. package/src/clearing-server.test.ts +450 -0
  45. package/src/clearing-server.ts +123 -0
  46. package/src/clearing-x402.test.ts +181 -0
  47. package/src/clearing-x402.ts +328 -0
  48. package/src/client.test.ts +333 -0
  49. package/src/client.ts +195 -0
  50. package/src/index.ts +33 -0
  51. package/src/mandates/account.ts +95 -0
  52. package/src/mandates/binding.ts +280 -0
  53. package/src/mandates/chain.ts +68 -0
  54. package/src/mandates/checkout.ts +368 -0
  55. package/src/mandates/client.ts +159 -0
  56. package/src/mandates/extension.ts +48 -0
  57. package/src/mandates/index.ts +121 -0
  58. package/src/mandates/ingest.ts +42 -0
  59. package/src/mandates/jcs.ts +16 -0
  60. package/src/mandates/l3a.ts +216 -0
  61. package/src/mandates/seller.ts +91 -0
  62. package/src/mandates/verify.ts +625 -0
  63. package/src/mandates/vi/credential.ts +477 -0
  64. package/src/mandates/vi/issue.ts +118 -0
  65. package/src/mandates/vi/sd-jwt.ts +189 -0
  66. package/src/networks.test.ts +177 -0
  67. package/src/networks.ts +242 -0
  68. package/src/node.test.ts +24 -0
  69. package/src/node.ts +96 -0
  70. package/src/server.test.ts +223 -0
  71. package/src/server.ts +77 -0
  72. package/dist/clearing.d.ts +0 -204
  73. package/dist/clearing.js +0 -151
  74. package/dist/file-storage.d.ts +0 -39
  75. package/dist/file-storage.js +0 -195
@@ -0,0 +1,552 @@
1
+ import { BaseError, ContractFunctionRevertedError, concatHex, createPublicClient, createWalletClient, encodeFunctionData, erc20Abi, FeeCapTooHighError, FeeCapTooLowError, InsufficientFundsError, IntrinsicGasTooHighError, IntrinsicGasTooLowError, keccak256, NonceMaxValueError, NonceTooHighError, parseEventLogs, sha256, stringToHex, TipAboveFeeCapError, TransactionTypeNotSupportedError, } from "viem";
2
+ import { getNodeError } from "viem/utils";
3
+ import { clearingAbi } from "./clearing-abi.js";
4
+ import { clearingUint64, clearingUnits } from "./clearing-codec.js";
5
+ import { formatUsdz, rpcTransport, USDZ } from "./networks.js";
6
+ export function clearingLogProof(proof) {
7
+ return {
8
+ ...proof,
9
+ location: BigInt(proof.location),
10
+ leaves: BigInt(proof.leaves),
11
+ inactivePeaks: BigInt(proof.inactivePeaks),
12
+ };
13
+ }
14
+ /** Authorize an epoch-tail close to a fixed EVM destination. */
15
+ export async function clearingClose(signer, deployment, balance, destination, deadline) {
16
+ if (!balance.opening)
17
+ throw new Error("no retained balance proof");
18
+ const body = concatHex([
19
+ deployment,
20
+ balance.state,
21
+ "0x14",
22
+ destination,
23
+ "0x02",
24
+ clearingUint64(deadline),
25
+ ]);
26
+ const { amount, nextKey, ...proof } = balance.opening;
27
+ return {
28
+ withdrawal: {
29
+ account: signer.publicKey,
30
+ deployment,
31
+ state: balance.state,
32
+ destination,
33
+ amount: 0n,
34
+ deadline,
35
+ signature: await signer.sign("_COMMONWARE_CLEARING_BOUNDARY_WITHDRAWAL", body),
36
+ },
37
+ opening: {
38
+ amount: BigInt(amount),
39
+ nextKey,
40
+ proof: clearingLogProof(proof),
41
+ },
42
+ };
43
+ }
44
+ /** The most the Clearing contract holds in all, in clearing units: it
45
+ * counts `uint64` units of `Clearing.unit()` token base units each, a
46
+ * millionth of the token (its base unit at six decimals or fewer). */
47
+ export const CLEARING_MAX_AMOUNT = 2n ** 64n - 1n;
48
+ /** A clearing service that gave no answer, or a 5xx. */
49
+ class ServiceUnreachable extends Error {
50
+ }
51
+ async function serviceGet(serviceUrl, path, fetcher) {
52
+ const response = await fetcher(new URL(path, serviceUrl), {
53
+ signal: AbortSignal.timeout(30_000),
54
+ }).catch((error) => {
55
+ throw new ServiceUnreachable(`clearing service gave no answer for ${path} (${error instanceof Error ? error.message : String(error)})`, { cause: error });
56
+ });
57
+ if (!response.ok)
58
+ throw new (response.status >= 500 ? ServiceUnreachable : Error)(`clearing service returned ${response.status} for ${path}`);
59
+ return (await response.json());
60
+ }
61
+ /** What `account` can still spend in the open epoch, in the token's base
62
+ * units: the operator's estimate from the balance and deposits it has
63
+ * counted and the payments it has acknowledged. */
64
+ export async function clearingSpendable(opts) {
65
+ const pub = createPublicClient({ transport: rpcTransport(opts.rpcUrl) });
66
+ const [{ amount }, unit] = await Promise.all([
67
+ serviceGet(opts.serviceUrl, `/spendable/${opts.account}`, opts.fetch ?? globalThis.fetch),
68
+ pub.readContract({ address: opts.settlement, abi: clearingAbi, functionName: "unit" }),
69
+ ]);
70
+ // The service counts clearing units.
71
+ return BigInt(amount) * unit;
72
+ }
73
+ /** `account`'s finalized balance in clearing units and, when the service
74
+ * retains one, the proof `withdrawFromClearing` closes it with. */
75
+ export function clearingBalance(serviceUrl, account, fetcher = globalThis.fetch) {
76
+ return serviceGet(serviceUrl, `/balance/${account}`, fetcher);
77
+ }
78
+ /** What a node answers when it refuses a transaction outright, leaving
79
+ * nothing in its pool. */
80
+ const REFUSALS = [
81
+ InsufficientFundsError,
82
+ FeeCapTooLowError,
83
+ FeeCapTooHighError,
84
+ TipAboveFeeCapError,
85
+ IntrinsicGasTooLowError,
86
+ IntrinsicGasTooHighError,
87
+ NonceTooHighError,
88
+ NonceMaxValueError,
89
+ TransactionTypeNotSupportedError,
90
+ ];
91
+ /** Gas and fees are set here: on Metal, a fee-carrying `eth_estimateGas`
92
+ * from an account with no native balance fails. */
93
+ function chainClients({ rpcUrl, chainId, from, timeoutMs = 60_000, pollMs = 500 }) {
94
+ const transport = rpcTransport(rpcUrl);
95
+ const pub = createPublicClient({ transport });
96
+ const wallet = createWalletClient({ account: from, transport });
97
+ // The dry run first, under the gas the transaction is sent with, so a
98
+ // refusal comes back with its reason. The transaction is signed here, so
99
+ // its hash is known before it is broadcast: once broadcast, any failure to
100
+ // learn its outcome leaves it in doubt, named, and never sent again here.
101
+ const send = async (call, gas) => {
102
+ await pub.simulateContract({ ...call, account: from.address, gas });
103
+ const [block, nonce] = await Promise.all([
104
+ pub.getBlock({ blockTag: "latest" }),
105
+ pub.getTransactionCount({ address: from.address, blockTag: "pending" }),
106
+ ]);
107
+ const raw = await wallet.signTransaction({
108
+ chain: null,
109
+ chainId,
110
+ to: call.address,
111
+ data: encodeFunctionData(call),
112
+ value: 0n,
113
+ gas,
114
+ maxFeePerGas: (block.baseFeePerGas ?? 0n) * 2n + 1n,
115
+ maxPriorityFeePerGas: 1n,
116
+ nonce,
117
+ type: "eip1559",
118
+ });
119
+ const hash = keccak256(raw);
120
+ // The node's error, if any, rides along: a refusal its hash cannot show.
121
+ const inDoubt = (cause) => new Error(`${call.functionName} ${hash} with nonce ${nonce} was sent and its outcome is unknown: look it up before sending it again`, {
122
+ cause,
123
+ });
124
+ await pub.sendRawTransaction({ serializedTransaction: raw }).catch((error) => {
125
+ const refusal = error instanceof BaseError ? getNodeError(error, {}) : undefined;
126
+ if (REFUSALS.some((type) => refusal instanceof type))
127
+ throw new Error(`the node refused ${call.functionName}: ${refusal.shortMessage}`, { cause: error });
128
+ throw inDoubt(error);
129
+ });
130
+ // A failed read of the receipt is not yet, until the deadline.
131
+ const deadline = Date.now() + timeoutMs;
132
+ let unread;
133
+ for (;;) {
134
+ const receipt = await pub.getTransactionReceipt({ hash }).catch((error) => {
135
+ unread = error;
136
+ return null;
137
+ });
138
+ if (receipt)
139
+ return receipt;
140
+ if (Date.now() >= deadline)
141
+ throw inDoubt(unread);
142
+ await new Promise((resolve) => setTimeout(resolve, pollMs));
143
+ }
144
+ };
145
+ return { pub, send };
146
+ }
147
+ function succeeded(functionName, receipt) {
148
+ if (receipt.status !== "success")
149
+ throw new Error(`${functionName} reverted: ${receipt.transactionHash}`);
150
+ return receipt.transactionHash;
151
+ }
152
+ /** A withdrawal's queue or claim refused, by its dry run or as a mined
153
+ * revert (`tx`), because an epoch finalized after the service proved it:
154
+ * the contract checks a proof against its finalized state alone. */
155
+ function finalizedUnder(functionName, tx, cause) {
156
+ return new Error(`${tx ? `${functionName} ${tx} reverted` : `${functionName} was refused`} because an epoch finalized under it, so the service's proof is for the state before; nothing was lost${tx ? " but its gas" : ""}: make the same call again after the next epoch`, cause === undefined ? {} : { cause });
157
+ }
158
+ /** Put `amount` token base units into clearing custody under `account`, a
159
+ * clearing key; it must be a whole number of the contract's clearing
160
+ * units. `from` pays: an approval for the amount unless one that covers it
161
+ * stands, then the deposit. The contract takes deposits only between
162
+ * registrations, so the deposit waits for that window and is sent again
163
+ * if the window closes under it, up to `timeoutMs` (default 60 s). It
164
+ * refuses at once a contract that holds another token, and a deposit past
165
+ * what the contract holds in all. A deposit the token refuses because
166
+ * `from` holds less fails naming what `from` holds and what the deposit
167
+ * needs. A deposit whose outcome is unknown is not sent again: the error
168
+ * names it. */
169
+ export async function depositToClearing(opts) {
170
+ if (opts.amount <= 0n)
171
+ throw new Error("deposit amount must be positive");
172
+ const { pub, send } = chainClients(opts);
173
+ const contract = { address: opts.settlement, abi: clearingAbi };
174
+ const [token, unit] = await Promise.all([
175
+ pub.readContract({ ...contract, functionName: "token" }),
176
+ pub.readContract({ ...contract, functionName: "unit" }),
177
+ ]);
178
+ if (token.toLowerCase() !== opts.asset.toLowerCase())
179
+ throw new Error(`this Clearing contract holds ${token}, not ${opts.asset}`);
180
+ // The contract counts clearing units; the approval moves base units.
181
+ const units = clearingUnits(opts.amount, unit);
182
+ // The contract refuses a deposit past this, with the same error as a closed window.
183
+ const assertRoom = async () => {
184
+ const [active, reserve] = await Promise.all([
185
+ pub.readContract({ ...contract, functionName: "activeCustody" }),
186
+ pub.readContract({ ...contract, functionName: "claimReserve" }),
187
+ ]);
188
+ const room = CLEARING_MAX_AMOUNT - active - reserve;
189
+ if (units > room)
190
+ throw new Error(`this Clearing contract holds at most ${CLEARING_MAX_AMOUNT * unit} base units in all, and has room for ${room * unit} more`);
191
+ };
192
+ await assertRoom();
193
+ const allowance = await pub.readContract({
194
+ address: opts.asset,
195
+ abi: erc20Abi,
196
+ functionName: "allowance",
197
+ args: [opts.from.address, opts.settlement],
198
+ });
199
+ const approveTx = allowance >= opts.amount
200
+ ? null
201
+ : succeeded("approve", await send({ address: opts.asset, abi: erc20Abi, functionName: "approve", args: [opts.settlement, opts.amount] }, 100000n));
202
+ const deposit = { ...contract, functionName: "deposit", args: [opts.account, units] };
203
+ // About 450k on Metal, which charges 64 times Ethereum's gas for the modexp
204
+ // that decompresses the account's key.
205
+ const depositGas = 1000000n;
206
+ const deadline = Date.now() + (opts.timeoutMs ?? 60_000);
207
+ // Only the closed window is waited out; any other refusal is the caller's.
208
+ // A full contract reverts the same way, and `assertRoom` tells them apart.
209
+ const windowClosed = (error) => error instanceof BaseError &&
210
+ error.walk((e) => e instanceof ContractFunctionRevertedError && e.data?.errorName === "InvalidBoundary") !== null;
211
+ // The token refuses a transfer past what `from` holds with the reason
212
+ // "balance"; what it holds is read after the refusal.
213
+ const shortOf = async (error) => {
214
+ if (!(error instanceof BaseError && error.walk((e) => e instanceof ContractFunctionRevertedError && e.reason === "balance")))
215
+ return undefined;
216
+ const held = await pub.readContract({ address: opts.asset, abi: erc20Abi, functionName: "balanceOf", args: [opts.from.address] });
217
+ return `${opts.from.address} holds ${formatUsdz(held)} ${USDZ.symbol} and the deposit needs ${formatUsdz(opts.amount)} ${USDZ.symbol}; fund it or deposit less`;
218
+ };
219
+ for (;;) {
220
+ // A deposit that reverts deposited nothing, and the next dry run says
221
+ // why: if the window closed under it, the next round waits for the next
222
+ // one; any other reason is named with the reverted deposit.
223
+ try {
224
+ const receipt = await send(deposit, depositGas);
225
+ if (receipt.status === "success")
226
+ return { approveTx, depositTx: receipt.transactionHash };
227
+ const why = await pub
228
+ .simulateContract({ ...deposit, account: opts.from.address, gas: depositGas })
229
+ .then(() => undefined, (error) => error);
230
+ if (!windowClosed(why)) {
231
+ const short = await shortOf(why);
232
+ throw new Error(`deposit ${receipt.transactionHash} reverted${short ? `: ${short}` : why instanceof BaseError ? `: ${why.shortMessage}` : ", and its dry run now passes"}`);
233
+ }
234
+ }
235
+ catch (error) {
236
+ if (!windowClosed(error)) {
237
+ const short = await shortOf(error);
238
+ throw short ? new Error(`the deposit was refused, and nothing was deposited: ${short}`, { cause: error }) : error;
239
+ }
240
+ }
241
+ await assertRoom();
242
+ if (Date.now() >= deadline)
243
+ throw new Error("the Clearing contract took no deposits before the timeout");
244
+ await new Promise((resolve) => setTimeout(resolve, opts.pollMs ?? 500));
245
+ }
246
+ }
247
+ const accountKey = (account) => sha256(concatHex([stringToHex("_COMMONWARE_CLEARING_ACCOUNT_KEY"), account]));
248
+ /** `account`'s payout of its withdrawal in the finalized epoch `epoch` to
249
+ * `destination`, the one the withdrawal named, from the first of
250
+ * `serviceUrls` that proves it. */
251
+ async function lookupPayout(serviceUrls, fetcher, epoch, account, destination) {
252
+ const failures = [];
253
+ const unreachable = [];
254
+ for (const serviceUrl of serviceUrls) {
255
+ try {
256
+ const payout = await serviceGet(serviceUrl, `/payout/${epoch}/${account}`, fetcher);
257
+ if (!payout)
258
+ failures.push(`${serviceUrl} has none`);
259
+ else if (payout.destination.toLowerCase() !== destination.toLowerCase())
260
+ failures.push(`${serviceUrl} names ${payout.destination}, not ${destination}, the destination the withdrawal named`);
261
+ else
262
+ return payout;
263
+ }
264
+ catch (error) {
265
+ if (error instanceof ServiceUnreachable)
266
+ unreachable.push(error);
267
+ failures.push(`${serviceUrl}: ${error instanceof Error ? error.message : String(error)}`);
268
+ }
269
+ }
270
+ throw new Error(`no clearing service named proves the payout of this account's withdrawal in epoch ${epoch}; a validator proves it once it has retained that epoch (${failures.join("; ")})`,
271
+ // None of them answered.
272
+ unreachable.length === serviceUrls.length ? { cause: unreachable[0] } : {});
273
+ }
274
+ /** Claims `payout` to `destination` unless it is 0 or claimed already. The
275
+ * claim is pushed to `claimed` as it lands. */
276
+ async function claimPayout({ pub, send, settlement, unit }, payout, destination, claimed) {
277
+ const contract = { address: settlement, abi: clearingAbi };
278
+ if (BigInt(payout.amount) === 0n)
279
+ return;
280
+ const position = BigInt(payout.proof.location);
281
+ const consumed = await pub.readContract({ ...contract, functionName: "claimedPayouts", args: [position >> 8n] });
282
+ if (consumed & (1n << (position & 255n)))
283
+ return;
284
+ // The proof is for the payouts log the service read as finalized; once a
285
+ // later epoch finalizes, the contract's log is longer and refuses it.
286
+ const moved = async () => (await pub.readContract({ ...contract, functionName: "finalizedPayouts" }))[1] !== BigInt(payout.proof.leaves);
287
+ const receipt = await send({ ...contract, functionName: "claim", args: [destination, BigInt(payout.amount), clearingLogProof(payout.proof)] }, 1000000n).catch(async (error) => {
288
+ throw revertOf(error) === "InvalidProof" && (await moved()) ? finalizedUnder("claim", undefined, error) : error;
289
+ });
290
+ if (receipt.status !== "success" && (await moved()))
291
+ throw finalizedUnder("claim", receipt.transactionHash);
292
+ const tx = succeeded("claim", receipt);
293
+ const deferred = parseEventLogs({ abi: clearingAbi, eventName: "PayoutDeferred", logs: receipt.logs }).length > 0;
294
+ claimed.push({ amount: BigInt(payout.amount) * unit, tx, deferred });
295
+ }
296
+ /** Take `signer`'s funds out of clearing: claim the account's payout of a
297
+ * withdrawal that has finalized, to the destination it named, then queue a
298
+ * withdrawal of whatever finalized balance remains, to `destination` (`from` itself
299
+ * unless given). A payer's unused deposit and a seller's earnings leave the
300
+ * same way. Call again after the queued withdrawal's epoch finalizes. A
301
+ * failure after a claim names the claims made before it. A queue or claim
302
+ * refused because an epoch finalized under its proof moved nothing: the
303
+ * error says to call again after the next epoch. */
304
+ export async function withdrawFromClearing(opts) {
305
+ const { pub, send } = chainClients(opts);
306
+ const fetcher = opts.fetch ?? globalThis.fetch;
307
+ const destination = opts.destination ?? opts.from.address;
308
+ const contract = { address: opts.settlement, abi: clearingAbi };
309
+ const key = accountKey(opts.signer.publicKey);
310
+ if ((await pub.readContract({ ...contract, functionName: "fault" })) !== 0)
311
+ throw new Error("this Clearing deployment has faulted and takes no withdrawals: take the account's funds out with recoverFromClearing");
312
+ const [status, finalized, unit] = await Promise.all([
313
+ pub.readContract({ ...contract, functionName: "withdrawals", args: [key] }),
314
+ pub.readContract({ ...contract, functionName: "finalizedCount" }),
315
+ pub.readContract({ ...contract, functionName: "unit" }),
316
+ ]);
317
+ const [encodedHash, epochPlusOne, , , queuedTo] = status;
318
+ if (encodedHash !== `0x${"00".repeat(32)}` && (epochPlusOne === 0n || epochPlusOne > finalized)) {
319
+ const [registered, admitted] = await Promise.all([
320
+ pub.readContract({ ...contract, functionName: "registered" }),
321
+ pub.readContract({ ...contract, functionName: "admittedCount" }),
322
+ ]);
323
+ // A service that has missed a deadline may never finalize the epoch.
324
+ for (const call of faultCalls(admitted, opts.signer.publicKey)) {
325
+ const missed = await pub.simulateContract({ ...contract, ...call, account: opts.from.address }).then(() => true, (error) => {
326
+ if (revertOf(error) !== "InvalidDeadline")
327
+ throw error;
328
+ return false;
329
+ });
330
+ if (missed)
331
+ throw new Error(`the clearing service has missed a deadline (${call.functionName} would fault the deployment), so the withdrawal may never finalize: take the account's funds out with recoverFromClearing`);
332
+ }
333
+ if (epochPlusOne > 0n)
334
+ return { status: "waiting", epoch: epochPlusOne - 1n };
335
+ // Not yet registered: the next registration carries it.
336
+ return { status: "waiting", epoch: registered ? admitted + 1n : admitted };
337
+ }
338
+ const claimed = [];
339
+ try {
340
+ return await claimThenQueue().catch((error) => {
341
+ const gone = error instanceof ServiceUnreachable ? error : error instanceof Error && error.cause instanceof ServiceUnreachable ? error.cause : undefined;
342
+ if (!gone)
343
+ throw error;
344
+ throw new Error(`the clearing service could not be reached (${gone.message}): call withdrawFromClearing again once it answers; if it has stopped, pass a validator's URL from the facilitator's /supported extra.validatorUrls as serviceUrl`, { cause: error });
345
+ });
346
+ }
347
+ catch (error) {
348
+ if (claimed.length === 0)
349
+ throw error;
350
+ const made = claimed.map((c) => `${c.amount} in ${c.tx}`).join(", ");
351
+ throw new Error(`${error instanceof Error ? error.message : String(error)}; claimed before it: ${made}`, { cause: error });
352
+ }
353
+ async function claimThenQueue() {
354
+ if (epochPlusOne > 0n)
355
+ await claimPayout({ pub, send, settlement: opts.settlement, unit }, await lookupPayout([opts.serviceUrl], fetcher, epochPlusOne - 1n, opts.signer.publicKey, queuedTo), queuedTo, claimed);
356
+ const unqueued = () => (claimed.length > 0 ? { status: "sent", claimed, queued: null } : { status: "empty" });
357
+ // The service proves balances only against a finalized epoch.
358
+ if (finalized === 0n)
359
+ return unqueued();
360
+ const balance = await clearingBalance(opts.serviceUrl, opts.signer.publicKey, fetcher);
361
+ if (BigInt(balance.amount) === 0n)
362
+ return unqueued();
363
+ if (!balance.opening)
364
+ return { status: "sent", claimed, queued: null };
365
+ const [deployment, policy, block] = await Promise.all([
366
+ pub.readContract({ ...contract, functionName: "deployment" }),
367
+ pub.readContract({ ...contract, functionName: "policy" }),
368
+ pub.getBlock(),
369
+ ]);
370
+ const close = await clearingClose(opts.signer, deployment, balance, destination, block.timestamp + policy[5]);
371
+ // The balance is proven against the finalized state: an epoch that
372
+ // finalizes before the withdrawal lands moves it, and the contract
373
+ // refuses the withdrawal with `InvalidBoundary`.
374
+ const moved = async () => (await pub.readContract({ ...contract, functionName: "finalizedState" })).toLowerCase() !== close.withdrawal.state.toLowerCase();
375
+ const receipt = await send({ ...contract, functionName: "queueWithdrawal", args: [close.withdrawal, close.opening] }, 2000000n).catch(async (error) => {
376
+ throw revertOf(error) === "InvalidBoundary" && (await moved()) ? finalizedUnder("queueWithdrawal", undefined, error) : error;
377
+ });
378
+ if (receipt.status !== "success" && (await moved()))
379
+ throw finalizedUnder("queueWithdrawal", receipt.transactionHash);
380
+ return { status: "sent", claimed, queued: { tx: succeeded("queueWithdrawal", receipt) } };
381
+ }
382
+ }
383
+ /** The calls that fault a deployment whose service has missed a deadline:
384
+ * to admit the epoch it registered, `account`'s queued withdrawal, or its
385
+ * deposit. Each reverts `InvalidDeadline` until its deadline passes. */
386
+ const faultCalls = (admitted, account) => [
387
+ { functionName: "faultRegistration", args: [] },
388
+ { functionName: "faultWithdrawal", args: [account] },
389
+ { functionName: "faultDeposit", args: [admitted, account] },
390
+ ];
391
+ const revertOf = (error) => {
392
+ if (!(error instanceof BaseError))
393
+ return undefined;
394
+ const reverted = error.walk((e) => e instanceof ContractFunctionRevertedError);
395
+ return reverted instanceof ContractFunctionRevertedError ? (reverted.data?.errorName ?? "reverted") : undefined;
396
+ };
397
+ /** Take `signer`'s funds out of a Clearing deployment whose service has
398
+ * stopped. Once the service has missed a deadline, to admit an epoch it
399
+ * registered, a deposit, or the account's queued withdrawal, this faults
400
+ * the deployment, finalizes the epochs it admitted, starts recovery, and
401
+ * takes the payout of the account's finalized withdrawal, its last
402
+ * finalized balance and every deposit no final epoch counted, to
403
+ * `destination` (`from` itself unless given) or, for a withdrawal, to the
404
+ * destination it named. The payout and balance proofs come from
405
+ * `serviceUrl`, the clearing service or any of its validators, or the
406
+ * first of several that proves each. A proof none of them gives does not
407
+ * stop the steps that need none: they are taken, and then the error names
408
+ * what is left and what was sent. Each step already taken is skipped, so
409
+ * call it again after `waiting` or such an error. `from` pays the gas. */
410
+ export async function recoverFromClearing(opts) {
411
+ const serviceUrls = typeof opts.serviceUrl === "string" ? [opts.serviceUrl] : opts.serviceUrl;
412
+ if (serviceUrls.length === 0)
413
+ throw new Error("recoverFromClearing needs a clearing service URL");
414
+ // A proof no service gives does not stop the steps that need none; the
415
+ // answer then names it, and what was sent.
416
+ let unclaimed;
417
+ let unproven;
418
+ const finish = (result) => {
419
+ const missed = [unclaimed, unproven].filter((error) => error !== undefined);
420
+ if (missed.length === 0)
421
+ return result;
422
+ const sent = [
423
+ result.status === "recovered" && result.faulted && `fault ${result.faulted}`,
424
+ ...result.claimed.map((c) => `claim ${c.tx}${c.deferred ? " (deferred: the destination calls withdrawUnclaimed)" : ""}`),
425
+ ...(result.status === "recovered"
426
+ ? [result.recovered && `recover ${result.recovered.tx}`, ...result.refunded.map((r) => `refundDeposit ${r.tx}`)]
427
+ : []),
428
+ ].filter(Boolean);
429
+ throw new Error(`${missed.map((error) => (error instanceof Error ? error.message : String(error))).join("; ")}. ${result.status === "waiting" ? `Epoch ${result.epoch} is still in its challenge window. ` : ""}This call sent ${sent.length > 0 ? sent.join(", ") : "nothing"}: call recoverFromClearing again to take the rest`, { cause: missed[0] });
430
+ };
431
+ const { pub, send } = chainClients(opts);
432
+ const fetcher = opts.fetch ?? globalThis.fetch;
433
+ const destination = opts.destination ?? opts.from.address;
434
+ const contract = { address: opts.settlement, abi: clearingAbi };
435
+ const account = opts.signer.publicKey;
436
+ const key = accountKey(account);
437
+ const unit = await pub.readContract({ ...contract, functionName: "unit" });
438
+ let faulted = null;
439
+ if ((await pub.readContract({ ...contract, functionName: "fault" })) === 0) {
440
+ const admitted = await pub.readContract({ ...contract, functionName: "admittedCount" });
441
+ for (const call of faultCalls(admitted, account)) {
442
+ try {
443
+ faulted = succeeded(call.functionName, await send({ ...contract, ...call }, 200000n));
444
+ break;
445
+ }
446
+ catch (error) {
447
+ // Each fault reverts `InvalidDeadline` until its deadline passes; any
448
+ // other refusal, running out of gas among them, is an error.
449
+ if (revertOf(error) !== "InvalidDeadline")
450
+ throw error;
451
+ }
452
+ }
453
+ if (!faulted)
454
+ return { status: "operating" };
455
+ }
456
+ // A withdrawal whose epoch finalized is paid by `claim`, not `recover`,
457
+ // and needs no recovery: it is claimed first, and again once this call
458
+ // finalizes the epoch that carries it.
459
+ const claimed = [];
460
+ const claimFinalized = async () => {
461
+ const [[, epochPlusOne, , , queuedTo], finalized] = await Promise.all([
462
+ pub.readContract({ ...contract, functionName: "withdrawals", args: [key] }),
463
+ pub.readContract({ ...contract, functionName: "finalizedCount" }),
464
+ ]);
465
+ unclaimed = undefined;
466
+ if (epochPlusOne === 0n || epochPlusOne > finalized)
467
+ return;
468
+ // `recover` does not pay a finalized withdrawal, so a later call claims it.
469
+ const payout = await lookupPayout(serviceUrls, fetcher, epochPlusOne - 1n, account, queuedTo).catch((error) => {
470
+ unclaimed = error;
471
+ });
472
+ if (payout)
473
+ await claimPayout({ pub, send, settlement: opts.settlement, unit }, payout, queuedTo, claimed);
474
+ };
475
+ await claimFinalized();
476
+ if (!(await pub.readContract({ ...contract, functionName: "recovering" }))) {
477
+ for (;;) {
478
+ const [finalized, admitted, invalidFrom] = await Promise.all([
479
+ pub.readContract({ ...contract, functionName: "finalizedCount" }),
480
+ pub.readContract({ ...contract, functionName: "admittedCount" }),
481
+ pub.readContract({ ...contract, functionName: "invalidFrom" }),
482
+ ]);
483
+ if (finalized >= admitted || finalized >= invalidFrom)
484
+ break;
485
+ try {
486
+ succeeded("finalize", await send({ ...contract, functionName: "finalize", args: [] }, 2000000n));
487
+ }
488
+ catch (error) {
489
+ if (revertOf(error) === "InvalidDeadline")
490
+ return finish({ status: "waiting", epoch: finalized, claimed });
491
+ throw error;
492
+ }
493
+ }
494
+ try {
495
+ succeeded("beginRecovery", await send({ ...contract, functionName: "beginRecovery", args: [] }, 200000n));
496
+ }
497
+ catch (error) {
498
+ // Someone else started it between the read and the dry run.
499
+ if (revertOf(error) === undefined || !(await pub.readContract({ ...contract, functionName: "recovering" })))
500
+ throw error;
501
+ }
502
+ }
503
+ await claimFinalized();
504
+ const [deployment, state, finalized, admitted, done] = await Promise.all([
505
+ pub.readContract({ ...contract, functionName: "deployment" }),
506
+ pub.readContract({ ...contract, functionName: "finalizedState" }),
507
+ pub.readContract({ ...contract, functionName: "finalizedCount" }),
508
+ pub.readContract({ ...contract, functionName: "admittedCount" }),
509
+ pub.readContract({ ...contract, functionName: "recoveredAccounts", args: [key] }),
510
+ ]);
511
+ let recovered = null;
512
+ const balance = !done && finalized > 0n ? await provenBalance(state).catch((error) => void (unproven = error)) : undefined;
513
+ if (balance) {
514
+ if (BigInt(balance.amount) > 0n && balance.opening) {
515
+ const { amount, nextKey, ...proof } = balance.opening;
516
+ const signature = await opts.signer.sign("_METAL_CLEARING_RECOVERY", concatHex([deployment, state, destination]));
517
+ const receipt = await send({
518
+ ...contract,
519
+ functionName: "recover",
520
+ args: [account, destination, signature, { amount: BigInt(amount), nextKey, proof: clearingLogProof(proof) }],
521
+ }, 2000000n);
522
+ recovered = { amount: BigInt(balance.amount) * unit, tx: succeeded("recover", receipt) };
523
+ }
524
+ }
525
+ const refunded = [];
526
+ for (let epoch = finalized; epoch <= admitted; epoch++) {
527
+ const amount = await pub.readContract({ ...contract, functionName: "depositAmounts", args: [epoch, key] });
528
+ if (amount === 0n)
529
+ continue;
530
+ const signature = await opts.signer.sign("_METAL_CLEARING_DEPOSIT_REFUND", concatHex([deployment, clearingUint64(epoch), clearingUint64(amount), destination]));
531
+ // About 760k on Metal, which charges 64 times Ethereum's gas for P-256 and modexp.
532
+ const receipt = await send({ ...contract, functionName: "refundDeposit", args: [epoch, account, destination, signature] }, 1200000n);
533
+ refunded.push({ epoch, amount: amount * unit, tx: succeeded("refundDeposit", receipt) });
534
+ }
535
+ return finish({ status: "recovered", faulted, claimed, recovered, refunded });
536
+ /** The account's balance as of `state`, from the first service that proves it there. */
537
+ async function provenBalance(state) {
538
+ const failures = [];
539
+ for (const serviceUrl of serviceUrls) {
540
+ try {
541
+ const balance = await clearingBalance(serviceUrl, account, fetcher);
542
+ if (balance.state.toLowerCase() === state.toLowerCase())
543
+ return balance;
544
+ failures.push(`${serviceUrl} proves balances against ${balance.state}`);
545
+ }
546
+ catch (error) {
547
+ failures.push(`${serviceUrl}: ${error instanceof Error ? error.message : String(error)}`);
548
+ }
549
+ }
550
+ throw new Error(`no clearing service named proves this account's balance at the contract's finalized state ${state}; a validator proves it once it has retained the last finalized epoch (${failures.join("; ")})`);
551
+ }
552
+ }
@@ -1,56 +1,37 @@
1
- import type { AssetAmount, PaymentRequirements, Price, SchemeNetworkServer, SchemeServerHooks, SettleResponse, SupportedKind } from "@x402/core/types";
1
+ import type { AssetAmount, PaymentRequirements, Price, SchemeNetworkServer, SchemePaymentRequiredContext, SupportedKind } from "@x402/core/types";
2
2
  import { type DollarToken } from "./networks.js";
3
- /** Metal clearing operator's signed receipt for one payment, as the
4
- * facilitator returns it in `extra.receipt` of a settle response. Keep it:
5
- * it is the seller's own proof of what it is owed, which can be used on
6
- * chain if an epoch close leaves the credit out, without trusting Metal.
7
- * uint values are decimal strings; the digest and signature are EIP-712
8
- * under the Clearing contract's domain ("MetalClearing", "1") on `network`. */
9
- export interface ClearingReceipt {
10
- network: string;
11
- contract: string;
12
- operator: string;
13
- receipt: {
14
- recipient: string;
15
- epoch: string;
16
- shard: string;
17
- index: string;
18
- cumulativeCredit: string;
19
- prevReceiptDigest: string;
20
- debitDigest: string;
21
- };
22
- receiptDigest: string;
23
- receiptSignature: string;
24
- }
25
- /** The clearing receipt in a settle response, or null when it has none. */
26
- export declare function clearingReceiptOf(response: Pick<SettleResponse, "extra">): ClearingReceipt | null;
27
- /** Metal's clearing lane on the seller's side. Register it next to the
28
- * standard scheme, and list both in a route's `accepts`:
29
- *
30
- * server
31
- * .register(network, new ExactEvmScheme().registerMoneyParser(metalUsd(network)))
32
- * .register(network, new ClearingDebitServerScheme());
33
- * accepts: [
34
- * { scheme: "exact", network, payTo, price: "$0.01" },
35
- * { scheme: "clearing-debit", network, payTo, price: "$0.001" },
36
- * ]
3
+ /**
4
+ * The `bajillion` scheme for an `x402ResourceServer`, registered beside
5
+ * `exact` on `network`. A route's `payTo` is the seller's clearing key,
6
+ * `signer.publicKey` of a wallet from `openClearingWallet`, and its `price`
7
+ * is dollars in `token`. The offer names the Clearing deployment the
8
+ * facilitator lists for the network in `/supported`, so a seller takes only
9
+ * payments its facilitator settles.
37
10
  *
38
- * A payer pays by signing a running total; the facilitator has Metal's
39
- * clearing operator admit it, and the seller is credited in clearing, from
40
- * which it withdraws whenever it likes. The Clearing contract comes from the
41
- * facilitator's list of what it supports.
42
- *
43
- * `onReceipt` is handed the operator's signed receipt for every payment
44
- * settled, and again with any later answer for the same payment; store it
45
- * by its `receiptDigest`. */
46
- export declare class ClearingDebitServerScheme implements SchemeNetworkServer {
11
+ * server.register(network, new ClearingServerScheme(network));
12
+ */
13
+ export declare class ClearingServerScheme implements SchemeNetworkServer {
14
+ private readonly network;
47
15
  private readonly token;
48
- readonly scheme = "clearing-debit";
49
- readonly schemeHooks: SchemeServerHooks;
50
- constructor(token?: DollarToken, options?: {
51
- onReceipt?: (receipt: ClearingReceipt) => Promise<void>;
52
- });
16
+ readonly scheme = "bajillion";
17
+ readonly defaultAssetTransferMethod = "default";
18
+ readonly paymentFlows: {
19
+ readonly default: {
20
+ readonly supported: readonly ["upfront"];
21
+ readonly default: "upfront";
22
+ };
23
+ };
24
+ constructor(network: `eip155:${number}`, token?: DollarToken);
53
25
  parsePrice(price: Price): Promise<AssetAmount>;
26
+ /** Clearing counts whole clearing units, at most 2^64 - 1 of them in all.
27
+ * The unit is the one `Clearing.unit()` derives from the token's decimals
28
+ * at deploy: a millionth of the token, or its base unit at six decimals or
29
+ * fewer. */
30
+ private payable;
54
31
  getAssetDecimals(): number;
55
32
  enhancePaymentRequirements(requirements: PaymentRequirements, supportedKind: SupportedKind): Promise<PaymentRequirements>;
33
+ /** Binds this network's offers' invoices to the resource asked for, at
34
+ * the challenge and again when a payment for it is verified. A route that
35
+ * sets its own `invoicePrefix` keeps it. */
36
+ enrichPaymentRequiredResponse(ctx: SchemePaymentRequiredContext): Promise<PaymentRequirements[]>;
56
37
  }