@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
@@ -1,580 +1,439 @@
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;
43
- }
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));
1
+ import { BaseError, createPublicClient } from "viem";
2
+ import { clearingAbi } from "./clearing-abi.js";
3
+ import { CLEARING_REFERENCE, clearingUnits, decodeSend, encodeSend, invoiceMessage, outgoingRoot, verifyReceipt, } from "./clearing-codec.js";
4
+ import { formatUsdz, rpcTransport, USDZ } from "./networks.js";
5
+ /** A clearing payment the service did not take: `reason` says why, `retry`
6
+ * what to do, and the message both in words. */
7
+ export class ClearingRefusal extends Error {
8
+ reason;
9
+ retry;
10
+ name = "ClearingRefusal";
11
+ constructor(reason, retry, message, options) {
12
+ super(message, options);
13
+ this.reason = reason;
14
+ this.retry = retry;
52
15
  }
53
16
  }
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;
17
+ /** The service takes no payment from an epoch's close until the next
18
+ * epoch's registration, a gap of a few seconds. The wait between tries,
19
+ * and before a held payment is sent again, is the 2 s the service waits
20
+ * after an epoch's admission before registering the next, plus a block to
21
+ * include the registration. */
22
+ const BETWEEN_EPOCHS_RETRY_MS = 3_000;
23
+ /** The most recipients the service's payment parser takes in a vector. */
24
+ const MAX_ENTRIES = 1024;
25
+ function betweenEpochs(cause) {
26
+ return new ClearingRefusal("between_epochs", { action: "retry_after_ms", ms: BETWEEN_EPOCHS_RETRY_MS }, `clearing is between epochs: from an epoch's close until the next one is registered, a gap of a few seconds that recurs every epoch, the service takes no payments; nothing was paid: send the request again after ${BETWEEN_EPOCHS_RETRY_MS} ms, and again after each further between_epochs refusal; if it lasts longer than an epoch, check that serviceUrl is the facilitator's extra.serviceUrl (a validator takes no payments), or the service is down`, cause === undefined ? {} : { cause });
59
27
  }
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
- };
28
+ /** The error for a faulted deployment; `sent` when the payment reached the
29
+ * service with its outcome unknown. */
30
+ function faulted(cause, sent = false) {
31
+ return new Error(`the Clearing deployment has faulted, so its service takes no more payments; ${sent ? "whether this payment went through shows in the balance recovery pays out, and the wallet keeps it held, so a replacement deployment takes a new wallet" : "nothing was paid"}: take the account's funds out with recoverFromClearing`, cause === undefined ? {} : { cause });
81
32
  }
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;
33
+ /** A held payment, by its price and the URL it pays for. */
34
+ function describe(pending) {
35
+ return `${pending.invoice.amount} base units ${pending.url === undefined ? `under invoice ${pending.invoice.id}` : `for ${pending.url}`}`;
92
36
  }
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;
37
+ function messageOf(cause) {
38
+ return cause instanceof BaseError
39
+ ? cause.shortMessage
40
+ : cause instanceof Error
41
+ ? cause.message
42
+ : String(cause);
100
43
  }
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
- };
44
+ /** A refusal naming the payment the wallet holds until it goes through:
45
+ * one held from before, one whose answer was lost, or one whose
46
+ * acknowledgment did not verify, all three held alike. */
47
+ function held(kind, pending, cause) {
48
+ const payment = describe(pending);
49
+ const release = "if that URL no longer offers clearing at that price, pass the wallet's pendingInvoice() to its pay, which sends the held payment itself once more; the wallet pays others once it goes through or is refused";
50
+ return new ClearingRefusal(kind === "payment_held" ? "payment_held" : "answer_lost", {
51
+ action: "repeat_same_request",
52
+ url: pending.url ?? null,
53
+ amount: pending.invoice.amount,
54
+ ms: BETWEEN_EPOCHS_RETRY_MS,
55
+ }, kind === "unverified"
56
+ ? `the clearing service answered this payment, ${payment}, but its acknowledgment does not verify against this payment and the deployment's operator key, so serviceUrl may belong to another deployment: check it against the facilitator's /supported; the payment's outcome is unknown, and the wallet holds it and pays nothing else until it is sent once to the right service, which never pays it twice`
57
+ : kind === "answer_lost"
58
+ ? `no receipt this wallet could check came back for this payment, ${payment} (${messageOf(cause)}), so its outcome is unknown: the wallet holds it and pays nothing else until it goes through; send the same request again at the same price after ${BETWEEN_EPOCHS_RETRY_MS} ms, which sends this payment again and never pays it twice; if it is still held after an epoch, check that serviceUrl is the facilitator's extra.serviceUrl (a validator takes no payments); ${release}`
59
+ : `the wallet holds an earlier payment whose outcome is unknown, ${payment}, and pays nothing else until it goes through; nothing was paid for this request: send that request again after ${BETWEEN_EPOCHS_RETRY_MS} ms at that price first, which sends the same payment and never pays it twice; ${release}`, cause === undefined ? {} : { cause });
60
+ }
61
+ /** The error for a payment held for another deployment, network or asset
62
+ * than the wallet's: it can never be sent here. */
63
+ function stranded(pending, which) {
64
+ return new Error(`the wallet holds a payment for ${which.what}${which.theirs}, which this wallet cannot send to ${which.ours}, so it can never go through here and the wallet pays nothing else: take the account's funds out of that deployment with withdrawFromClearing, or recoverFromClearing once it has faulted, given its settlement and service, and open a new wallet for ${which.ours}; the payment is ${describe(pending)}`);
65
+ }
66
+ /** A clearing service's refusal; `rejected` when it never takes the payment. */
67
+ class ServiceRefused extends Error {
68
+ status;
69
+ rejected;
70
+ constructor(status, rejected) {
71
+ super(rejected
72
+ ? "clearing payment rejected before acknowledgment"
73
+ : `clearing service returned ${status}`);
74
+ this.status = status;
75
+ this.rejected = rejected;
180
76
  }
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);
77
+ }
78
+ const unavailable = (error) => error instanceof ServiceRefused && error.status === 409 && !error.rejected;
79
+ export function createClearingClient(config) {
80
+ const fetcher = config.fetch ?? globalThis.fetch;
81
+ const chain = createPublicClient({ transport: rpcTransport(config.rpcUrl) });
82
+ let tail = Promise.resolve();
83
+ let broken = false;
84
+ async function save(state) {
85
+ try {
86
+ await config.store.save(state);
192
87
  }
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);
88
+ catch (error) {
89
+ broken = true;
90
+ throw error;
203
91
  }
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
92
  }
217
- key(requirements) {
218
- const clearing = requirements.extra?.["clearing"];
219
- return `${requirements.network}:${String(clearing?.contract).toLowerCase()}:${this.signer.address.toLowerCase()}`;
93
+ async function request(path, body) {
94
+ const response = await fetcher(new URL(path, config.serviceUrl), {
95
+ method: body ? "POST" : "GET",
96
+ ...(body
97
+ ? {
98
+ headers: { "content-type": "application/json" },
99
+ body: JSON.stringify(body),
100
+ }
101
+ : {}),
102
+ signal: AbortSignal.timeout(30_000),
103
+ });
104
+ if (!response.ok) {
105
+ const result = (await response.json().catch(() => null));
106
+ throw new ServiceRefused(response.status, result?.rejected === true);
107
+ }
108
+ return (await response.json());
220
109
  }
221
- sellerOf(requirements) {
222
- const clearing = requirements.extra?.["clearing"];
223
- return `${requirements.network}:${String(clearing?.contract).toLowerCase()}:${requirements.payTo.toLowerCase()}`;
110
+ /** How `invoice` is for another network, deployment or asset than this
111
+ * wallet's, or undefined when it is not. */
112
+ function differs(invoice) {
113
+ if (invoice.network !== config.network)
114
+ return { what: "", theirs: invoice.network, ours: config.network };
115
+ if (invoice.settlement.toLowerCase() !== config.settlement.toLowerCase())
116
+ return { what: "deployment ", theirs: invoice.settlement, ours: config.settlement };
117
+ if (invoice.asset.toLowerCase() !== config.asset.toLowerCase())
118
+ return { what: "asset ", theirs: invoice.asset, ours: config.asset };
224
119
  }
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;
252
- };
120
+ /** Why this wallet does not pay `invoice`, or undefined when it does;
121
+ * `aboveMaxAmount` when it is for this wallet's deployment at a price
122
+ * above its maxAmount. */
123
+ function refusal(invoice) {
124
+ const elsewhere = differs(invoice);
125
+ if (elsewhere)
126
+ return { reason: `offer is for ${elsewhere.what}${elsewhere.theirs}, not this wallet's ${elsewhere.ours}`, aboveMaxAmount: false };
127
+ if (!/^[1-9][0-9]*$/.test(invoice.amount))
128
+ return { reason: `offer of ${invoice.amount} base units is not a positive whole number in canonical form`, aboveMaxAmount: false };
129
+ const amount = BigInt(invoice.amount);
130
+ if (amount > config.maxAmount)
131
+ return {
132
+ reason: `offer ${formatUsdz(amount)} ${USDZ.symbol} is above this wallet's maxAmount ${formatUsdz(config.maxAmount)} ${USDZ.symbol}`,
133
+ aboveMaxAmount: true,
134
+ };
135
+ }
136
+ /** Why a service that takes no payments does not: the deployment has
137
+ * faulted, for good, or it is between epochs. */
138
+ async function closed(cause) {
139
+ const fault = await onChain(chain.readContract({
140
+ address: config.settlement,
141
+ abi: clearingAbi,
142
+ functionName: "fault",
143
+ }));
144
+ return fault ? faulted(cause) : betweenEpochs(cause);
253
145
  }
254
- /** Holds the Debit a request carried, if it carried one of this client's. */
255
- holdSent(header) {
256
- let paid;
146
+ /** `reads` of the Clearing contract, once the RPC is known to serve
147
+ * `config.network`: on another chain they fail or mean nothing. */
148
+ async function onChain(reads) {
149
+ reads.catch(() => { });
150
+ const chainId = await chain.getChainId();
151
+ if (`eip155:${chainId}` !== config.network)
152
+ throw new Error(`the clearing client's rpcUrl serves eip155:${chainId}, not ${config.network}; nothing was paid: give it an RPC URL for ${config.network}`);
153
+ return reads;
154
+ }
155
+ /** Why the service refused a payment signed for `epoch` for good: the
156
+ * epoch closed as it was sent, or, while it is still open, the account's
157
+ * deposit does not cover the payment, or neither, which the service does
158
+ * not say. */
159
+ async function rejection(epoch, invoice, cause) {
160
+ let covered;
161
+ let opened = false;
257
162
  try {
258
- paid = header ? decodePaymentSignatureHeader(header) : undefined;
163
+ const [context, fault] = await Promise.all([
164
+ request("/context"),
165
+ chain.readContract({
166
+ address: config.settlement,
167
+ abi: clearingAbi,
168
+ functionName: "fault",
169
+ }),
170
+ ]);
171
+ if (fault)
172
+ return faulted(cause);
173
+ if (BigInt(context.epoch) !== epoch)
174
+ return betweenEpochs(cause);
175
+ opened = true;
176
+ const [{ amount }, unit] = await Promise.all([
177
+ request(`/spendable/${config.signer.publicKey}`),
178
+ chain.readContract({
179
+ address: config.settlement,
180
+ abi: clearingAbi,
181
+ functionName: "unit",
182
+ }),
183
+ ]);
184
+ covered = BigInt(amount) >= clearingUnits(BigInt(invoice.amount), unit);
259
185
  }
260
- catch {
261
- // Not a payment this client made.
186
+ catch (error) {
187
+ if (!opened && unavailable(error))
188
+ return closed(cause);
189
+ return new Error(`the clearing service refused this payment of ${invoice.amount} base units, and reading why failed (${messageOf(error)}); nothing was paid and nothing is held: send the request again`, { cause });
262
190
  }
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);
191
+ if (covered)
192
+ return new Error(`the clearing service refused this payment of ${invoice.amount} base units without saying why, though the account's deposit covers it; nothing was paid and nothing is held: send the request again, and if it is refused again this epoch, check that no other process pays from this wallet`, { cause });
193
+ return new ClearingRefusal("insufficient_deposit", { action: "deposit" }, `the clearing service refused this payment of ${invoice.amount} base units because the account's deposit does not cover it; nothing was paid: deposit more with depositToClearing, which pays from the next epoch, then send the request again`, { cause });
281
194
  }
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("|");
195
+ /** What the service retained of a payment sent again without a usable
196
+ * answer: its receipt; `"released"` when it has no record of the payment
197
+ * and the epoch it was signed for is closed, or is no longer the live
198
+ * one on a service that is this wallet's deployment, so a later
199
+ * acceptance cannot overtake the answer; or nothing, when it cannot say.
200
+ * A refusal alone proves nothing: a proxy can answer one after the
201
+ * service accepted the payment. */
202
+ async function retained(pending) {
203
+ try {
204
+ const sent = decodeSend(pending.body);
205
+ // Read before the receipt: a closed epoch takes no payment after it.
206
+ const closed = await Promise.all([
207
+ chain.readContract({ address: config.settlement, abi: clearingAbi, functionName: "admittedCount" }),
208
+ request("/context"),
209
+ ]).then(([admitted, context]) => context.network !== config.network ||
210
+ context.settlement.toLowerCase() !== config.settlement.toLowerCase() ||
211
+ context.asset.toLowerCase() !== config.asset.toLowerCase()
212
+ ? undefined
213
+ : admitted > sent.epoch || BigInt(context.epoch) !== sent.epoch || context.anchor !== sent.anchor, () => undefined);
214
+ const found = await request(`/receipt/${config.signer.publicKey}/${encodeURIComponent(pending.invoice.id)}?epoch=${sent.epoch}`);
215
+ if (found)
216
+ return {
217
+ paid: found,
218
+ operator: await chain.readContract({ address: config.settlement, abi: clearingAbi, functionName: "operatorKey" }),
219
+ };
220
+ return closed ? "released" : undefined;
221
+ }
222
+ catch {
223
+ return undefined;
224
+ }
296
225
  }
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(),
226
+ async function pay(invoice, url) {
227
+ if (broken)
228
+ throw new Error("clearing wallet persistence failed; reopen the wallet");
229
+ const refused = refusal(invoice);
230
+ if (refused) {
231
+ const held = (await config.store.load())?.pending;
232
+ const elsewhere = held?.invoice.id === invoice.id ? differs(held.invoice) : undefined;
233
+ throw held && elsewhere
234
+ ? stranded(held, elsewhere)
235
+ : new Error(held?.invoice.id === invoice.id && refused.aboveMaxAmount
236
+ ? `${refused.reason}; the wallet holds this payment and pays nothing else until it goes through: raise the wallet's maxAmount to send it`
237
+ : refused.reason);
238
+ }
239
+ const amount = BigInt(invoice.amount);
240
+ let state = (await config.store.load()) ?? {
241
+ entries: [],
242
+ sequence: "0",
243
+ payments: {},
311
244
  };
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;
245
+ const paid = state.payments[invoice.id];
246
+ if (paid) {
247
+ if (invoiceMessage(paid.invoice, paid.receipt.body) !==
248
+ invoiceMessage(invoice, paid.receipt.body))
249
+ throw new Error("invoice id reused with different terms");
250
+ return paid;
340
251
  }
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);
252
+ if (state.pending &&
253
+ invoiceMessage(state.pending.invoice, state.pending.body) !==
254
+ invoiceMessage(invoice, state.pending.body)) {
255
+ const elsewhere = differs(state.pending.invoice);
256
+ throw elsewhere ? stranded(state.pending, elsewhere) : held("payment_held", state.pending);
348
257
  }
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,
383
- });
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
- },
258
+ // A payment sent again has been sent before.
259
+ const resent = state.pending !== undefined;
260
+ if (!state.pending) {
261
+ const context = await request("/context").catch(async (error) => {
262
+ throw unavailable(error)
263
+ ? await closed(error)
264
+ : error instanceof ServiceRefused && [401, 403, 404].includes(error.status)
265
+ ? new Error(`the clearing service answered ${error.status} for /context, so serviceUrl is not the clearing service the facilitator's /supported names (extra.serviceUrl); nothing was paid and nothing is held: set serviceUrl to it`, { cause: error })
266
+ : new Error(`the clearing service did not give its epoch (${messageOf(error)}); nothing was paid and nothing is held: send the request again`, { cause: error });
267
+ });
268
+ if (context.reference !== CLEARING_REFERENCE ||
269
+ context.network !== config.network ||
270
+ context.settlement.toLowerCase() !== config.settlement.toLowerCase() ||
271
+ context.asset.toLowerCase() !== config.asset.toLowerCase())
272
+ throw new Error("unsupported clearing service deployment");
273
+ const epoch = BigInt(context.epoch);
274
+ const [registered, onchain, fault, admitted, block, unit] = await onChain(Promise.all([
275
+ chain.readContract({
276
+ address: config.settlement,
277
+ abi: clearingAbi,
278
+ functionName: "registered",
279
+ }),
280
+ chain.readContract({
281
+ address: config.settlement,
282
+ abi: clearingAbi,
283
+ functionName: "contextOf",
284
+ args: [epoch],
285
+ }),
286
+ chain.readContract({
287
+ address: config.settlement,
288
+ abi: clearingAbi,
289
+ functionName: "fault",
290
+ }),
291
+ chain.readContract({
292
+ address: config.settlement,
293
+ abi: clearingAbi,
294
+ functionName: "admittedCount",
295
+ }),
296
+ chain.getBlock(),
297
+ chain.readContract({
298
+ address: config.settlement,
299
+ abi: clearingAbi,
300
+ functionName: "unit",
301
+ }),
302
+ ]));
303
+ if (fault)
304
+ throw faulted();
305
+ // The contract has not registered the service's epoch yet, or has
306
+ // closed it to payments.
307
+ if (!registered ||
308
+ admitted !== epoch ||
309
+ block.timestamp >= onchain.admissionDeadline)
310
+ throw betweenEpochs();
311
+ if (onchain.anchor !== context.anchor ||
312
+ onchain.operatorKey !== context.operator)
313
+ throw new Error("clearing epoch is not registered");
314
+ if (state.epoch !== context.epoch)
315
+ state = { ...state, epoch: context.epoch, sequence: "0", entries: [] };
316
+ const entries = state.entries.map((e) => ({ ...e }));
317
+ let entry = entries.find((e) => e.recipient.toLowerCase() === invoice.recipient.toLowerCase());
318
+ if (!entry) {
319
+ entry = { recipient: invoice.recipient, cumulative: "0", count: "0" };
320
+ entries.push(entry);
321
+ }
322
+ // The invoice is in token base units; the vector counts clearing units.
323
+ entry.cumulative = (BigInt(entry.cumulative) + clearingUnits(amount, unit)).toString();
324
+ entry.count = (BigInt(entry.count) + 1n).toString();
325
+ // The service takes at most this many recipients in one payment's vector.
326
+ if (entries.length > MAX_ENTRIES)
327
+ throw new Error(`this wallet has paid ${MAX_ENTRIES} recipients in epoch ${context.epoch}, the most one payment's vector holds; nothing was paid and nothing is held: send the request again after the next epoch`);
328
+ entries.sort((a, b) => a.recipient.toLowerCase().localeCompare(b.recipient.toLowerCase()));
329
+ const vector = entries.map((e) => ({
330
+ ...e,
331
+ cumulative: BigInt(e.cumulative),
332
+ count: BigInt(e.count),
333
+ }));
334
+ const body = encodeSend({
335
+ anchor: context.anchor,
336
+ epoch,
337
+ payer: config.signer.publicKey,
338
+ sequence: BigInt(state.sequence),
339
+ debit: vector.reduce((sum, e) => sum + e.cumulative, 0n),
340
+ vector: outgoingRoot(vector),
341
+ });
342
+ state = {
343
+ ...state,
344
+ pending: {
345
+ invoice,
346
+ body,
347
+ entries,
348
+ payerSignature: await config.signer.sign("_COMMONWARE_CLEARING_VECTOR_SEND", body),
349
+ invoiceSignature: await config.signer.sign("_METAL_CLEARING_INVOICE", invoiceMessage(invoice, body)),
350
+ url,
351
+ },
352
+ };
353
+ await save(state);
354
+ }
355
+ const pending = state.pending;
356
+ let result;
357
+ let operator;
358
+ try {
359
+ result = await request("/payments", {
360
+ invoice: pending.invoice,
361
+ body: pending.body,
362
+ payerSignature: pending.payerSignature,
363
+ invoiceSignature: pending.invoiceSignature,
364
+ entries: pending.entries,
365
+ });
366
+ operator = await chain.readContract({
367
+ address: config.settlement,
368
+ abi: clearingAbi,
369
+ functionName: "operatorKey",
370
+ });
371
+ }
372
+ catch (error) {
373
+ if (error instanceof ServiceRefused && error.rejected) {
374
+ await save({ ...state, pending: undefined });
375
+ throw await rejection(decodeSend(pending.body).epoch, invoice, error);
376
+ }
377
+ // Once the deployment has faulted, recovery takes the account's
378
+ // funds whatever became of the payment.
379
+ const fault = await chain
380
+ .readContract({ address: config.settlement, abi: clearingAbi, functionName: "fault" })
381
+ .catch(() => 0);
382
+ if (fault)
383
+ throw faulted(error, true);
384
+ const kept = resent ? await retained(pending) : undefined;
385
+ if (kept === "released") {
386
+ // The vector it extended belongs to an epoch that is closed or gone.
387
+ await save({ ...state, epoch: undefined, entries: [], sequence: "0", pending: undefined });
388
+ throw new Error(`the clearing service has no record of this payment, ${describe(pending)}, and the epoch it was signed for has closed or is no longer the live one, so it can never be taken: it is released; nothing was paid and nothing is held: send the request again`, { cause: error });
389
+ }
390
+ if (!kept)
391
+ throw held("answer_lost", pending, error);
392
+ ({ paid: result, operator } = kept);
393
+ }
394
+ let verified = false;
395
+ try {
396
+ verified =
397
+ result.receipt.payerSignature === pending.payerSignature &&
398
+ invoiceMessage(result.invoice, pending.body) === invoiceMessage(invoice, pending.body) &&
399
+ verifyReceipt(result.receipt, operator);
400
+ }
401
+ catch {
402
+ // An answer that is no receipt does not verify.
403
+ }
404
+ if (!verified)
405
+ throw held("unverified", pending);
406
+ const settled = {
407
+ invoice,
408
+ receipt: result.receipt,
409
+ invoiceSignature: pending.invoiceSignature,
410
+ entries: pending.entries,
411
+ ...(result.previous ? { previous: result.previous } : {}),
394
412
  };
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",
413
+ await save({
414
+ ...state,
415
+ entries: pending.entries,
416
+ sequence: (decodeSend(pending.body).sequence + 1n).toString(),
417
+ pending: undefined,
418
+ payments: { ...state.payments, [invoice.id]: settled },
465
419
  });
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));
420
+ return settled;
516
421
  }
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
422
  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,
423
+ account: config.signer.publicKey,
424
+ async pendingInvoice() {
425
+ return (await config.store.load())?.pending?.invoice ?? null;
426
+ },
427
+ /** Why this wallet does not pay `invoice`, or undefined when it does. */
428
+ unpayable: refusal,
429
+ /** Pay `invoice`; `url`, the request it pays for, is kept with a
430
+ * payment whose outcome is unknown, so a refusal can name it. A payment
431
+ * the service does not take throws a `ClearingRefusal`. */
432
+ pay(invoice, url) {
433
+ const result = tail.then(() => pay(invoice, url));
434
+ tail = result.catch(() => { });
435
+ return result;
436
+ },
437
+ request,
579
438
  };
580
439
  }