@metalntwx/metal-pay 0.3.0 → 0.3.2

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.
@@ -1,4 +1,4 @@
1
- import { createPublicClient, http, type Hex } from "viem";
1
+ import { BaseError, createPublicClient, type Hex } from "viem";
2
2
  import { clearingAbi } from "./clearing-abi.js";
3
3
  import {
4
4
  CLEARING_REFERENCE,
@@ -11,6 +11,7 @@ import {
11
11
  type ClearingInvoice,
12
12
  type ClearingReceipt,
13
13
  } from "./clearing-codec.js";
14
+ import { formatUsdz, rpcTransport, USDZ } from "./networks.js";
14
15
 
15
16
  export interface ClearingSigner {
16
17
  publicKey: Hex;
@@ -36,10 +37,12 @@ export interface ClearingPaid {
36
37
  previous?: { receipt: ClearingReceipt; entries: ClearingEntry[] };
37
38
  }
38
39
  export interface ClearingClientState {
39
- epoch?: string;
40
+ epoch?: string | undefined;
40
41
  entries: ClearingEntry[];
41
42
  sequence: string;
42
- pending?: ClearingPayment | undefined;
43
+ /** The payment sent whose outcome is unknown, with the URL it pays for
44
+ * when `pay` was given one. */
45
+ pending?: (ClearingPayment & { url?: string | undefined }) | undefined;
43
46
  payments: Record<string, ClearingPaid>;
44
47
  }
45
48
  /** save must complete an atomic durability barrier; one process owns each payer. */
@@ -59,11 +62,143 @@ export interface ClearingClientConfig {
59
62
  fetch?: typeof globalThis.fetch;
60
63
  }
61
64
 
62
- class PaymentRejected extends Error {}
65
+ /** Why a clearing payment was not made. */
66
+ export type ClearingRefusalReason =
67
+ | "between_epochs"
68
+ | "insufficient_deposit"
69
+ | "payment_held"
70
+ | "answer_lost";
71
+ /** What to do after a `ClearingRefusal`. */
72
+ export type ClearingRetry =
73
+ /** Nothing was paid: send the request again after `ms`, and wait `ms`
74
+ * again after each further `between_epochs` refusal. */
75
+ | { action: "retry_after_ms"; ms: number }
76
+ /** Nothing was paid: deposit more, then send the request again. */
77
+ | { action: "deposit" }
78
+ /** The wallet holds a payment whose outcome is unknown, `amount` base
79
+ * units for `url` (`null` when `pay` was given none; through
80
+ * `fetchWithPayment`, the URL the request was sent to), and pays nothing
81
+ * else until it goes through: send that request again at that price
82
+ * after `ms`, which sends the same payment, so it is never paid twice. If
83
+ * that URL no longer offers clearing at that price, pass the wallet's
84
+ * `pendingInvoice()` to its `pay`, which sends the held payment itself
85
+ * once more; the wallet pays others once it goes through or is refused. */
86
+ | {
87
+ action: "repeat_same_request";
88
+ url: string | null;
89
+ amount: string;
90
+ ms: number;
91
+ };
92
+
93
+ /** A clearing payment the service did not take: `reason` says why, `retry`
94
+ * what to do, and the message both in words. */
95
+ export class ClearingRefusal extends Error {
96
+ override readonly name = "ClearingRefusal";
97
+ constructor(
98
+ readonly reason: ClearingRefusalReason,
99
+ readonly retry: ClearingRetry,
100
+ message: string,
101
+ options?: ErrorOptions,
102
+ ) {
103
+ super(message, options);
104
+ }
105
+ }
106
+
107
+ /** The service takes no payment from an epoch's close until the next
108
+ * epoch's registration, a gap of a few seconds. The wait between tries,
109
+ * and before a held payment is sent again, is the 2 s the service waits
110
+ * after an epoch's admission before registering the next, plus a block to
111
+ * include the registration. */
112
+ const BETWEEN_EPOCHS_RETRY_MS = 3_000;
113
+ /** The most recipients the service's payment parser takes in a vector. */
114
+ const MAX_ENTRIES = 1024;
115
+
116
+ function betweenEpochs(cause?: unknown) {
117
+ return new ClearingRefusal(
118
+ "between_epochs",
119
+ { action: "retry_after_ms", ms: BETWEEN_EPOCHS_RETRY_MS },
120
+ `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`,
121
+ cause === undefined ? {} : { cause },
122
+ );
123
+ }
124
+
125
+ /** The error for a faulted deployment; `sent` when the payment reached the
126
+ * service with its outcome unknown. */
127
+ function faulted(cause?: unknown, sent = false) {
128
+ return new Error(
129
+ `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`,
130
+ cause === undefined ? {} : { cause },
131
+ );
132
+ }
133
+
134
+ /** A held payment, by its price and the URL it pays for. */
135
+ function describe(pending: NonNullable<ClearingClientState["pending"]>) {
136
+ return `${pending.invoice.amount} base units ${pending.url === undefined ? `under invoice ${pending.invoice.id}` : `for ${pending.url}`}`;
137
+ }
138
+
139
+ function messageOf(cause: unknown) {
140
+ return cause instanceof BaseError
141
+ ? cause.shortMessage
142
+ : cause instanceof Error
143
+ ? cause.message
144
+ : String(cause);
145
+ }
146
+
147
+ /** A refusal naming the payment the wallet holds until it goes through:
148
+ * one held from before, one whose answer was lost, or one whose
149
+ * acknowledgment did not verify, all three held alike. */
150
+ function held(
151
+ kind: "payment_held" | "answer_lost" | "unverified",
152
+ pending: NonNullable<ClearingClientState["pending"]>,
153
+ cause?: unknown,
154
+ ) {
155
+ const payment = describe(pending);
156
+ const release =
157
+ "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";
158
+ return new ClearingRefusal(
159
+ kind === "payment_held" ? "payment_held" : "answer_lost",
160
+ {
161
+ action: "repeat_same_request",
162
+ url: pending.url ?? null,
163
+ amount: pending.invoice.amount,
164
+ ms: BETWEEN_EPOCHS_RETRY_MS,
165
+ },
166
+ kind === "unverified"
167
+ ? `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`
168
+ : kind === "answer_lost"
169
+ ? `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}`
170
+ : `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}`,
171
+ cause === undefined ? {} : { cause },
172
+ );
173
+ }
174
+
175
+ /** The error for a payment held for another deployment, network or asset
176
+ * than the wallet's: it can never be sent here. */
177
+ function stranded(pending: NonNullable<ClearingClientState["pending"]>, which: { what: string; theirs: string; ours: string }) {
178
+ return new Error(
179
+ `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)}`,
180
+ );
181
+ }
182
+
183
+ /** A clearing service's refusal; `rejected` when it never takes the payment. */
184
+ class ServiceRefused extends Error {
185
+ constructor(
186
+ readonly status: number,
187
+ readonly rejected: boolean,
188
+ ) {
189
+ super(
190
+ rejected
191
+ ? "clearing payment rejected before acknowledgment"
192
+ : `clearing service returned ${status}`,
193
+ );
194
+ }
195
+ }
196
+ const unavailable = (error: unknown) =>
197
+ error instanceof ServiceRefused && error.status === 409 && !error.rejected;
63
198
 
64
199
  export function createClearingClient(config: ClearingClientConfig) {
65
200
  const fetcher = config.fetch ?? globalThis.fetch;
66
- const chain = createPublicClient({ transport: http(config.rpcUrl) });
201
+ const chain = createPublicClient({ transport: rpcTransport(config.rpcUrl) });
67
202
  let tail: Promise<unknown> = Promise.resolve();
68
203
  let broken = false;
69
204
  async function save(state: ClearingClientState) {
@@ -89,31 +224,155 @@ export function createClearingClient(config: ClearingClientConfig) {
89
224
  const result = (await response.json().catch(() => null)) as {
90
225
  rejected?: boolean;
91
226
  } | null;
92
- if (result?.rejected === true)
93
- throw new PaymentRejected(
94
- "clearing payment rejected before acknowledgment",
95
- );
96
- throw new Error(`clearing service returned ${response.status}`);
227
+ throw new ServiceRefused(response.status, result?.rejected === true);
97
228
  }
98
229
  return (await response.json()) as T;
99
230
  }
100
- /** Why this wallet does not pay `invoice`, or undefined when it does. */
101
- function refusal(invoice: ClearingInvoice) {
102
- if (
103
- invoice.network !== config.network ||
104
- invoice.asset.toLowerCase() !== config.asset.toLowerCase() ||
105
- invoice.settlement.toLowerCase() !== config.settlement.toLowerCase()
106
- )
107
- return "clearing invoice deployment mismatch";
231
+ /** How `invoice` is for another network, deployment or asset than this
232
+ * wallet's, or undefined when it is not. */
233
+ function differs(invoice: ClearingInvoice) {
234
+ if (invoice.network !== config.network) return { what: "", theirs: invoice.network, ours: config.network };
235
+ if (invoice.settlement.toLowerCase() !== config.settlement.toLowerCase())
236
+ return { what: "deployment ", theirs: invoice.settlement, ours: config.settlement };
237
+ if (invoice.asset.toLowerCase() !== config.asset.toLowerCase()) return { what: "asset ", theirs: invoice.asset, ours: config.asset };
238
+ }
239
+ /** Why this wallet does not pay `invoice`, or undefined when it does;
240
+ * `aboveMaxAmount` when it is for this wallet's deployment at a price
241
+ * above its maxAmount. */
242
+ function refusal(invoice: ClearingInvoice): { reason: string; aboveMaxAmount: boolean } | undefined {
243
+ const elsewhere = differs(invoice);
244
+ if (elsewhere)
245
+ return { reason: `offer is for ${elsewhere.what}${elsewhere.theirs}, not this wallet's ${elsewhere.ours}`, aboveMaxAmount: false };
246
+ if (!/^[1-9][0-9]*$/.test(invoice.amount))
247
+ return { reason: `offer of ${invoice.amount} base units is not a positive whole number in canonical form`, aboveMaxAmount: false };
108
248
  const amount = BigInt(invoice.amount);
109
- if (amount <= 0n || amount > config.maxAmount)
110
- return "clearing payment exceeds spending limit";
249
+ if (amount > config.maxAmount)
250
+ return {
251
+ reason: `offer ${formatUsdz(amount)} ${USDZ.symbol} is above this wallet's maxAmount ${formatUsdz(config.maxAmount)} ${USDZ.symbol}`,
252
+ aboveMaxAmount: true,
253
+ };
254
+ }
255
+ /** Why a service that takes no payments does not: the deployment has
256
+ * faulted, for good, or it is between epochs. */
257
+ async function closed(cause: unknown) {
258
+ const fault = await onChain(
259
+ chain.readContract({
260
+ address: config.settlement,
261
+ abi: clearingAbi,
262
+ functionName: "fault",
263
+ }),
264
+ );
265
+ return fault ? faulted(cause) : betweenEpochs(cause);
266
+ }
267
+ /** `reads` of the Clearing contract, once the RPC is known to serve
268
+ * `config.network`: on another chain they fail or mean nothing. */
269
+ async function onChain<T>(reads: Promise<T>): Promise<T> {
270
+ reads.catch(() => {});
271
+ const chainId = await chain.getChainId();
272
+ if (`eip155:${chainId}` !== config.network)
273
+ throw new Error(
274
+ `the clearing client's rpcUrl serves eip155:${chainId}, not ${config.network}; nothing was paid: give it an RPC URL for ${config.network}`,
275
+ );
276
+ return reads;
277
+ }
278
+ /** Why the service refused a payment signed for `epoch` for good: the
279
+ * epoch closed as it was sent, or, while it is still open, the account's
280
+ * deposit does not cover the payment, or neither, which the service does
281
+ * not say. */
282
+ async function rejection(epoch: bigint, invoice: ClearingInvoice, cause: unknown) {
283
+ let covered: boolean;
284
+ let opened = false;
285
+ try {
286
+ const [context, fault] = await Promise.all([
287
+ request<{ epoch: string }>("/context"),
288
+ chain.readContract({
289
+ address: config.settlement,
290
+ abi: clearingAbi,
291
+ functionName: "fault",
292
+ }),
293
+ ]);
294
+ if (fault) return faulted(cause);
295
+ if (BigInt(context.epoch) !== epoch) return betweenEpochs(cause);
296
+ opened = true;
297
+ const [{ amount }, unit] = await Promise.all([
298
+ request<{ amount: string }>(`/spendable/${config.signer.publicKey}`),
299
+ chain.readContract({
300
+ address: config.settlement,
301
+ abi: clearingAbi,
302
+ functionName: "unit",
303
+ }),
304
+ ]);
305
+ covered = BigInt(amount) >= clearingUnits(BigInt(invoice.amount), unit);
306
+ } catch (error) {
307
+ if (!opened && unavailable(error)) return closed(cause);
308
+ return new Error(
309
+ `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`,
310
+ { cause },
311
+ );
312
+ }
313
+ if (covered)
314
+ return new Error(
315
+ `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`,
316
+ { cause },
317
+ );
318
+ return new ClearingRefusal(
319
+ "insufficient_deposit",
320
+ { action: "deposit" },
321
+ `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`,
322
+ { cause },
323
+ );
111
324
  }
112
- async function pay(invoice: ClearingInvoice) {
325
+ /** What the service retained of a payment sent again without a usable
326
+ * answer: its receipt; `"released"` when it has no record of the payment
327
+ * and the epoch it was signed for is closed, or is no longer the live
328
+ * one on a service that is this wallet's deployment, so a later
329
+ * acceptance cannot overtake the answer; or nothing, when it cannot say.
330
+ * A refusal alone proves nothing: a proxy can answer one after the
331
+ * service accepted the payment. */
332
+ async function retained(pending: NonNullable<ClearingClientState["pending"]>) {
333
+ try {
334
+ const sent = decodeSend(pending.body);
335
+ // Read before the receipt: a closed epoch takes no payment after it.
336
+ const closed = await Promise.all([
337
+ chain.readContract({ address: config.settlement, abi: clearingAbi, functionName: "admittedCount" }),
338
+ request<{ network: string; settlement: Hex; asset: Hex; epoch: string; anchor: Hex }>("/context"),
339
+ ]).then(
340
+ ([admitted, context]) =>
341
+ context.network !== config.network ||
342
+ context.settlement.toLowerCase() !== config.settlement.toLowerCase() ||
343
+ context.asset.toLowerCase() !== config.asset.toLowerCase()
344
+ ? undefined
345
+ : admitted > sent.epoch || BigInt(context.epoch) !== sent.epoch || context.anchor !== sent.anchor,
346
+ () => undefined,
347
+ );
348
+ const found = await request<ClearingPaid | null>(
349
+ `/receipt/${config.signer.publicKey}/${encodeURIComponent(pending.invoice.id)}?epoch=${sent.epoch}`,
350
+ );
351
+ if (found)
352
+ return {
353
+ paid: found,
354
+ operator: await chain.readContract({ address: config.settlement, abi: clearingAbi, functionName: "operatorKey" }),
355
+ };
356
+ return closed ? ("released" as const) : undefined;
357
+ } catch {
358
+ return undefined;
359
+ }
360
+ }
361
+ async function pay(invoice: ClearingInvoice, url?: string) {
113
362
  if (broken)
114
363
  throw new Error("clearing wallet persistence failed; reopen the wallet");
115
364
  const refused = refusal(invoice);
116
- if (refused) throw new Error(refused);
365
+ if (refused) {
366
+ const held = (await config.store.load())?.pending;
367
+ const elsewhere = held?.invoice.id === invoice.id ? differs(held.invoice) : undefined;
368
+ throw held && elsewhere
369
+ ? stranded(held, elsewhere)
370
+ : new Error(
371
+ held?.invoice.id === invoice.id && refused.aboveMaxAmount
372
+ ? `${refused.reason}; the wallet holds this payment and pays nothing else until it goes through: raise the wallet's maxAmount to send it`
373
+ : refused.reason,
374
+ );
375
+ }
117
376
  const amount = BigInt(invoice.amount);
118
377
  let state = (await config.store.load()) ?? {
119
378
  entries: [],
@@ -133,10 +392,12 @@ export function createClearingClient(config: ClearingClientConfig) {
133
392
  state.pending &&
134
393
  invoiceMessage(state.pending.invoice, state.pending.body) !==
135
394
  invoiceMessage(invoice, state.pending.body)
136
- )
137
- throw new Error(
138
- "an earlier clearing payment has an unknown outcome; retry that invoice first",
139
- );
395
+ ) {
396
+ const elsewhere = differs(state.pending.invoice);
397
+ throw elsewhere ? stranded(state.pending, elsewhere) : held("payment_held", state.pending);
398
+ }
399
+ // A payment sent again has been sent before.
400
+ const resent = state.pending !== undefined;
140
401
  if (!state.pending) {
141
402
  const context = await request<{
142
403
  reference: string;
@@ -146,7 +407,19 @@ export function createClearingClient(config: ClearingClientConfig) {
146
407
  operator: Hex;
147
408
  epoch: string;
148
409
  anchor: Hex;
149
- }>("/context");
410
+ }>("/context").catch(async (error: unknown) => {
411
+ throw unavailable(error)
412
+ ? await closed(error)
413
+ : error instanceof ServiceRefused && [401, 403, 404].includes(error.status)
414
+ ? new Error(
415
+ `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`,
416
+ { cause: error },
417
+ )
418
+ : new Error(
419
+ `the clearing service did not give its epoch (${messageOf(error)}); nothing was paid and nothing is held: send the request again`,
420
+ { cause: error },
421
+ );
422
+ });
150
423
  if (
151
424
  context.reference !== CLEARING_REFERENCE ||
152
425
  context.network !== config.network ||
@@ -155,8 +428,8 @@ export function createClearingClient(config: ClearingClientConfig) {
155
428
  )
156
429
  throw new Error("unsupported clearing service deployment");
157
430
  const epoch = BigInt(context.epoch);
158
- const [registered, onchain, fault, chainId, admitted, block, unit] =
159
- await Promise.all([
431
+ const [registered, onchain, fault, admitted, block, unit] =
432
+ await onChain(Promise.all([
160
433
  chain.readContract({
161
434
  address: config.settlement,
162
435
  abi: clearingAbi,
@@ -173,7 +446,6 @@ export function createClearingClient(config: ClearingClientConfig) {
173
446
  abi: clearingAbi,
174
447
  functionName: "fault",
175
448
  }),
176
- chain.getChainId(),
177
449
  chain.readContract({
178
450
  address: config.settlement,
179
451
  abi: clearingAbi,
@@ -185,13 +457,17 @@ export function createClearingClient(config: ClearingClientConfig) {
185
457
  abi: clearingAbi,
186
458
  functionName: "unit",
187
459
  }),
188
- ]);
460
+ ]));
461
+ if (fault) throw faulted();
462
+ // The contract has not registered the service's epoch yet, or has
463
+ // closed it to payments.
189
464
  if (
190
465
  !registered ||
191
- fault ||
192
466
  admitted !== epoch ||
193
- block.timestamp >= onchain.admissionDeadline ||
194
- `eip155:${chainId}` !== config.network ||
467
+ block.timestamp >= onchain.admissionDeadline
468
+ )
469
+ throw betweenEpochs();
470
+ if (
195
471
  onchain.anchor !== context.anchor ||
196
472
  onchain.operatorKey !== context.operator
197
473
  )
@@ -211,6 +487,11 @@ export function createClearingClient(config: ClearingClientConfig) {
211
487
  BigInt(entry.cumulative) + clearingUnits(amount, unit)
212
488
  ).toString();
213
489
  entry.count = (BigInt(entry.count) + 1n).toString();
490
+ // The service takes at most this many recipients in one payment's vector.
491
+ if (entries.length > MAX_ENTRIES)
492
+ throw new Error(
493
+ `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`,
494
+ );
214
495
  entries.sort((a, b) =>
215
496
  a.recipient.toLowerCase().localeCompare(b.recipient.toLowerCase()),
216
497
  );
@@ -241,34 +522,60 @@ export function createClearingClient(config: ClearingClientConfig) {
241
522
  "_METAL_CLEARING_INVOICE",
242
523
  invoiceMessage(invoice, body),
243
524
  ),
525
+ url,
244
526
  },
245
527
  };
246
528
  await save(state);
247
529
  }
248
530
  const pending = state.pending!;
249
531
  let result: ClearingPaid;
532
+ let operator: Hex;
250
533
  try {
251
- result = await request<ClearingPaid>("/payments", pending);
534
+ result = await request<ClearingPaid>("/payments", {
535
+ invoice: pending.invoice,
536
+ body: pending.body,
537
+ payerSignature: pending.payerSignature,
538
+ invoiceSignature: pending.invoiceSignature,
539
+ entries: pending.entries,
540
+ } satisfies ClearingPayment);
541
+ operator = await chain.readContract({
542
+ address: config.settlement,
543
+ abi: clearingAbi,
544
+ functionName: "operatorKey",
545
+ });
252
546
  } catch (error) {
253
- if (error instanceof PaymentRejected)
547
+ if (error instanceof ServiceRefused && error.rejected) {
254
548
  await save({ ...state, pending: undefined });
255
- throw error;
549
+ throw await rejection(decodeSend(pending.body).epoch, invoice, error);
550
+ }
551
+ // Once the deployment has faulted, recovery takes the account's
552
+ // funds whatever became of the payment.
553
+ const fault = await chain
554
+ .readContract({ address: config.settlement, abi: clearingAbi, functionName: "fault" })
555
+ .catch(() => 0);
556
+ if (fault) throw faulted(error, true);
557
+ const kept = resent ? await retained(pending) : undefined;
558
+ if (kept === "released") {
559
+ // The vector it extended belongs to an epoch that is closed or gone.
560
+ await save({ ...state, epoch: undefined, entries: [], sequence: "0", pending: undefined });
561
+ throw new Error(
562
+ `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`,
563
+ { cause: error },
564
+ );
565
+ }
566
+ if (!kept) throw held("answer_lost", pending, error);
567
+ ({ paid: result, operator } = kept);
256
568
  }
257
- const operator = await chain.readContract({
258
- address: config.settlement,
259
- abi: clearingAbi,
260
- functionName: "operatorKey",
261
- });
262
- if (
263
- result.receipt.body !== pending.body ||
264
- result.receipt.payerSignature !== pending.payerSignature ||
265
- invoiceMessage(result.invoice, pending.body) !==
266
- invoiceMessage(invoice, pending.body) ||
267
- !verifyReceipt(result.receipt, operator)
268
- )
269
- throw new Error(
270
- "invalid clearing acknowledgment; retain pending payment",
271
- );
569
+ let verified = false;
570
+ try {
571
+ verified =
572
+ result.receipt.payerSignature === pending.payerSignature &&
573
+ invoiceMessage(result.invoice, pending.body) === invoiceMessage(invoice, pending.body) &&
574
+ verifyReceipt(result.receipt, operator);
575
+ } catch {
576
+ // An answer that is no receipt does not verify.
577
+ }
578
+ if (!verified) throw held("unverified", pending);
272
579
  const settled = {
273
580
  invoice,
274
581
  receipt: result.receipt,
@@ -290,11 +597,13 @@ export function createClearingClient(config: ClearingClientConfig) {
290
597
  async pendingInvoice() {
291
598
  return (await config.store.load())?.pending?.invoice ?? null;
292
599
  },
293
- payable(invoice: ClearingInvoice) {
294
- return refusal(invoice) === undefined;
295
- },
296
- pay(invoice: ClearingInvoice) {
297
- const result = tail.then(() => pay(invoice));
600
+ /** Why this wallet does not pay `invoice`, or undefined when it does. */
601
+ unpayable: refusal,
602
+ /** Pay `invoice`; `url`, the request it pays for, is kept with a
603
+ * payment whose outcome is unknown, so a refusal can name it. A payment
604
+ * the service does not take throws a `ClearingRefusal`. */
605
+ pay(invoice: ClearingInvoice, url?: string) {
606
+ const result = tail.then(() => pay(invoice, url));
298
607
  tail = result.catch(() => {});
299
608
  return result;
300
609
  },