@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.
package/README.md CHANGED
@@ -15,7 +15,8 @@ client; `registerMetalNetworks(client, { signer, networks? })`
15
15
  registers the scheme on an `x402Client` you already have. Both refuse,
16
16
  before signing, a Metal `exact` offer in any token but `*USDZ` or under any
17
17
  EIP-712 domain but USDZ's. `createMetalPaymentClient` also refuses any offer
18
- above `maxAmount`, a `bigint` in `*USDZ` base units. The default is
18
+ above `maxAmount`, a `bigint` in `*USDZ` base units, except a clearing
19
+ offer, which its wallet's own `maxAmount` caps. The default is
19
20
  `DEFAULT_MAX_PAYMENT`, 1 `*USDZ`. It works with `@x402/core`, `@x402/evm`
20
21
  and `@x402/fetch` from 2.28 up to the next major, installed as its peers
21
22
  (npm 7+ and pnpm do that themselves). The stock client pays only tokens in
@@ -91,14 +92,28 @@ pending authorization across transport errors. Supply trusted `serviceUrl`,
91
92
  `openClearingWallet(directory)` from `@metalntwx/metal-pay/node` supplies a
92
93
  persistent signer and store with exclusive
93
94
  process ownership. Preserve that directory across restarts; call `close()` on
94
- shutdown. Retry an unknown outcome using the same invoice before another payment.
95
+ shutdown. A payment the service does not take throws a `ClearingRefusal`,
96
+ whose `reason` is `between_epochs`, `insufficient_deposit`, `answer_lost`
97
+ or `payment_held` and whose `retry` says what to do: wait `retry.ms`
98
+ (again after each further `between_epochs`), deposit, or send the held
99
+ request, `retry.url` at `retry.amount`, again after `retry.ms` before any
100
+ other. If that URL no longer offers clearing at that price, pass the
101
+ client's `pendingInvoice()` to its `pay`. A held payment sent again is
102
+ settled from the service's receipt when it kept one, and released when it
103
+ has none and its epoch has closed or is no longer the service's live one.
104
+ A wallet's record is for one Clearing deployment: open a new wallet for a
105
+ replacement. Through `createMetalPaymentClient`'s `fetchWithPayment` it is
106
+ the thrown error's `cause`.
95
107
 
96
108
  `clearingRequirements` and `clearingInvoice` bind an invoice to a resource,
97
109
  recipient, amount and deployment. `registerClearingClient` adds the `bajillion`
98
110
  scheme to an official x402 client, which then pays by clearing wherever its
99
- wallet can pay the offer. Behind the stock middleware, Metal's facilitator
100
- verifies and deduplicates clearing payments before the handler runs. A seller that
101
- verifies them itself calls `verifyClearingPayment` with its own trusted
111
+ wallet can pay the offer and refuses any other request on that
112
+ registration's network, unless given `{ exactFallback: true }` to pay it by
113
+ `exact`. Behind the stock middleware,
114
+ Metal's facilitator verifies and deduplicates clearing payments before the
115
+ handler runs. A seller that verifies them itself calls
116
+ `verifyClearingPayment` with its own trusted
102
117
  RPC/service configuration and deduplicates `clearingPaymentId` in durable
103
118
  storage before delivering. The returned receipt is **preconfirmed**;
104
119
  watch `Clearing.finalizedCount` before labeling its epoch finalized. It is not
@@ -106,19 +121,25 @@ an EVM transaction hash.
106
121
 
107
122
  `depositToClearing` funds a clearing account from an EVM account, waiting
108
123
  for the contract's deposit window; `clearingSpendable` reads the service's
109
- estimate of what the account can spend, in token base units, and
124
+ estimate of what the account can spend, in token base units, which counts
125
+ a deposit from the epoch after it lands and can read low around an
126
+ epoch's close, and
110
127
  `clearingBalance` the finalized balance and its proof, in clearing units
111
128
  (`Clearing.unit()` base units each, a millionth of `*USDZ`);
112
129
  `withdrawFromClearing` queues a withdrawal of the finalized balance and,
113
- once its epoch has finalized, claims it, for payer and seller alike.
130
+ once its epoch has finalized, claims it, for payer and seller alike; it
131
+ answers `empty` when there is nothing finalized to claim or queue. A
132
+ queue or claim refused because an epoch finalized under its proof moved
133
+ nothing, and its error says to call again after the next epoch.
114
134
  `clearingClose` and `clearingLogProof` are the pieces it is built from.
115
135
  `recoverFromClearing` takes an account's funds out of a deployment whose
116
136
  service has missed a deadline: it faults the deployment, starts recovery,
117
137
  and takes the payout of the account's finalized withdrawal, its last
118
138
  finalized balance and its uncounted deposits, with proofs from the service
119
- or the first of several validators that retained the epoch. The
120
- [service README](../../apps/clearing-service/README.md) describes
121
- operator/validator setup, receipt retention and proof endpoints.
139
+ or the first of several validators that retained the epoch. The clearing
140
+ service's README, `apps/clearing-service/README.md` in the Metal
141
+ repository, describes operator/validator setup, receipt retention and proof
142
+ endpoints.
122
143
 
123
144
  **Mandates.** `@metalntwx/metal-pay/mandates` lets an agent pay from its
124
145
  owner's account with a P-256 key of its own, within limits Metal enforces on
@@ -127,11 +148,13 @@ budget, how many payments, an expiry) as a Verifiable Intent mandate;
127
148
  `ingestCredential` verifies one against the issuers the owner trusts, and
128
149
  `bindCall`, `replaceCall` and `revokeCall` build the owner's transactions to its own
129
150
  MetalAccount. Wrap those calls with `authorizeMandateCall(client, trustedFactory,
130
- call, signQuorum)` to authorize an account self-call, or a list of them as one
151
+ call, validUntil, signQuorum)` to authorize an account self-call, or a list of them as one
131
152
  batch that runs whole or not at all, with its existing weighted quorum. The
132
- client must name its chain: the digest the quorum signs is computed from it,
133
- never read from the RPC endpoint. The callback supports the same EOA, P-256 and passkey signatures as
134
- ordinary account execution. Factory-created clones share an immutable VI
153
+ account refuses the signed batch after `validUntil` (unix seconds, a `bigint`), so
154
+ give it a short deadline. The client must name its chain: the digest the quorum
155
+ signs is computed from it, never read from the RPC endpoint. The callback
156
+ supports the same EOA, P-256 and passkey signatures as ordinary account
157
+ execution. Factory-created clones share an immutable VI
135
158
  verifier; budgets and replay protection live in each account. `createMandatePaymentClient({ credential, signDigest, network })`
136
159
  is the payer, and `MandateExactScheme` the same payer as a scheme for an
137
160
  `x402Client` of your own: each payment is an ERC-3009 authorization from the
@@ -146,6 +169,10 @@ are decimal, to the cent. `verifyMandate` and `verifyMandatePayment` trust the
146
169
  MetalAccountFactory `METAL_NETWORKS` names for the chain, or the
147
170
  `accountFactory` given, check the account's clone code against the
148
171
  factory's current implementation, and match the successful token transfer to the seller's requirements.
172
+ `verifyMandatePayment` checks a `checkoutMandate` against the payment, failing one of another
173
+ payment as such; its step 4 says when none was given, so no order was checked.
174
+ An event under the mandate that the SDK does not decode, as an account of
175
+ another version emits, fails the verification with its topic and the account.
149
176
  The module runs on Node only.
150
177
 
151
178
  **RPC.** Neither payers nor payees need one. Each network's `rpc` is its
@@ -27,10 +27,14 @@ export interface ClearingPaid {
27
27
  };
28
28
  }
29
29
  export interface ClearingClientState {
30
- epoch?: string;
30
+ epoch?: string | undefined;
31
31
  entries: ClearingEntry[];
32
32
  sequence: string;
33
- pending?: ClearingPayment | undefined;
33
+ /** The payment sent whose outcome is unknown, with the URL it pays for
34
+ * when `pay` was given one. */
35
+ pending?: (ClearingPayment & {
36
+ url?: string | undefined;
37
+ }) | undefined;
34
38
  payments: Record<string, ClearingPaid>;
35
39
  }
36
40
  /** save must complete an atomic durability barrier; one process owns each payer. */
@@ -49,10 +53,53 @@ export interface ClearingClientConfig {
49
53
  maxAmount: bigint;
50
54
  fetch?: typeof globalThis.fetch;
51
55
  }
56
+ /** Why a clearing payment was not made. */
57
+ export type ClearingRefusalReason = "between_epochs" | "insufficient_deposit" | "payment_held" | "answer_lost";
58
+ /** What to do after a `ClearingRefusal`. */
59
+ export type ClearingRetry =
60
+ /** Nothing was paid: send the request again after `ms`, and wait `ms`
61
+ * again after each further `between_epochs` refusal. */
62
+ {
63
+ action: "retry_after_ms";
64
+ ms: number;
65
+ }
66
+ /** Nothing was paid: deposit more, then send the request again. */
67
+ | {
68
+ action: "deposit";
69
+ }
70
+ /** The wallet holds a payment whose outcome is unknown, `amount` base
71
+ * units for `url` (`null` when `pay` was given none; through
72
+ * `fetchWithPayment`, the URL the request was sent to), and pays nothing
73
+ * else until it goes through: send that request again at that price
74
+ * after `ms`, which sends the same payment, so it is never paid twice. If
75
+ * that URL no longer offers clearing at that price, pass the wallet's
76
+ * `pendingInvoice()` to its `pay`, which sends the held payment itself
77
+ * once more; the wallet pays others once it goes through or is refused. */
78
+ | {
79
+ action: "repeat_same_request";
80
+ url: string | null;
81
+ amount: string;
82
+ ms: number;
83
+ };
84
+ /** A clearing payment the service did not take: `reason` says why, `retry`
85
+ * what to do, and the message both in words. */
86
+ export declare class ClearingRefusal extends Error {
87
+ readonly reason: ClearingRefusalReason;
88
+ readonly retry: ClearingRetry;
89
+ readonly name = "ClearingRefusal";
90
+ constructor(reason: ClearingRefusalReason, retry: ClearingRetry, message: string, options?: ErrorOptions);
91
+ }
52
92
  export declare function createClearingClient(config: ClearingClientConfig): {
53
93
  account: `0x${string}`;
54
94
  pendingInvoice(): Promise<ClearingInvoice | null>;
55
- payable(invoice: ClearingInvoice): boolean;
56
- pay(invoice: ClearingInvoice): Promise<ClearingPaid>;
95
+ /** Why this wallet does not pay `invoice`, or undefined when it does. */
96
+ unpayable: (invoice: ClearingInvoice) => {
97
+ reason: string;
98
+ aboveMaxAmount: boolean;
99
+ } | undefined;
100
+ /** Pay `invoice`; `url`, the request it pays for, is kept with a
101
+ * payment whose outcome is unknown, so a refusal can name it. A payment
102
+ * the service does not take throws a `ClearingRefusal`. */
103
+ pay(invoice: ClearingInvoice, url?: string): Promise<ClearingPaid>;
57
104
  request: <T>(path: string, body?: unknown) => Promise<T>;
58
105
  };
@@ -1,11 +1,84 @@
1
- import { createPublicClient, http } from "viem";
1
+ import { BaseError, createPublicClient } from "viem";
2
2
  import { clearingAbi } from "./clearing-abi.js";
3
3
  import { CLEARING_REFERENCE, clearingUnits, decodeSend, encodeSend, invoiceMessage, outgoingRoot, verifyReceipt, } from "./clearing-codec.js";
4
- class PaymentRejected extends Error {
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;
15
+ }
16
+ }
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 });
27
+ }
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 });
32
+ }
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}`}`;
36
+ }
37
+ function messageOf(cause) {
38
+ return cause instanceof BaseError
39
+ ? cause.shortMessage
40
+ : cause instanceof Error
41
+ ? cause.message
42
+ : String(cause);
43
+ }
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)}`);
5
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;
76
+ }
77
+ }
78
+ const unavailable = (error) => error instanceof ServiceRefused && error.status === 409 && !error.rejected;
6
79
  export function createClearingClient(config) {
7
80
  const fetcher = config.fetch ?? globalThis.fetch;
8
- const chain = createPublicClient({ transport: http(config.rpcUrl) });
81
+ const chain = createPublicClient({ transport: rpcTransport(config.rpcUrl) });
9
82
  let tail = Promise.resolve();
10
83
  let broken = false;
11
84
  async function save(state) {
@@ -30,28 +103,139 @@ export function createClearingClient(config) {
30
103
  });
31
104
  if (!response.ok) {
32
105
  const result = (await response.json().catch(() => null));
33
- if (result?.rejected === true)
34
- throw new PaymentRejected("clearing payment rejected before acknowledgment");
35
- throw new Error(`clearing service returned ${response.status}`);
106
+ throw new ServiceRefused(response.status, result?.rejected === true);
36
107
  }
37
108
  return (await response.json());
38
109
  }
39
- /** Why this wallet does not pay `invoice`, or undefined when it does. */
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 };
119
+ }
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. */
40
123
  function refusal(invoice) {
41
- if (invoice.network !== config.network ||
42
- invoice.asset.toLowerCase() !== config.asset.toLowerCase() ||
43
- invoice.settlement.toLowerCase() !== config.settlement.toLowerCase())
44
- return "clearing invoice deployment mismatch";
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 };
45
129
  const amount = BigInt(invoice.amount);
46
- if (amount <= 0n || amount > config.maxAmount)
47
- return "clearing payment exceeds spending limit";
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);
48
145
  }
49
- async function pay(invoice) {
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;
162
+ try {
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);
185
+ }
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 });
190
+ }
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 });
194
+ }
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
+ }
225
+ }
226
+ async function pay(invoice, url) {
50
227
  if (broken)
51
228
  throw new Error("clearing wallet persistence failed; reopen the wallet");
52
229
  const refused = refusal(invoice);
53
- if (refused)
54
- throw new Error(refused);
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
+ }
55
239
  const amount = BigInt(invoice.amount);
56
240
  let state = (await config.store.load()) ?? {
57
241
  entries: [],
@@ -67,17 +251,27 @@ export function createClearingClient(config) {
67
251
  }
68
252
  if (state.pending &&
69
253
  invoiceMessage(state.pending.invoice, state.pending.body) !==
70
- invoiceMessage(invoice, state.pending.body))
71
- throw new Error("an earlier clearing payment has an unknown outcome; retry that invoice first");
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);
257
+ }
258
+ // A payment sent again has been sent before.
259
+ const resent = state.pending !== undefined;
72
260
  if (!state.pending) {
73
- const context = await request("/context");
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
+ });
74
268
  if (context.reference !== CLEARING_REFERENCE ||
75
269
  context.network !== config.network ||
76
270
  context.settlement.toLowerCase() !== config.settlement.toLowerCase() ||
77
271
  context.asset.toLowerCase() !== config.asset.toLowerCase())
78
272
  throw new Error("unsupported clearing service deployment");
79
273
  const epoch = BigInt(context.epoch);
80
- const [registered, onchain, fault, chainId, admitted, block, unit] = await Promise.all([
274
+ const [registered, onchain, fault, admitted, block, unit] = await onChain(Promise.all([
81
275
  chain.readContract({
82
276
  address: config.settlement,
83
277
  abi: clearingAbi,
@@ -94,7 +288,6 @@ export function createClearingClient(config) {
94
288
  abi: clearingAbi,
95
289
  functionName: "fault",
96
290
  }),
97
- chain.getChainId(),
98
291
  chain.readContract({
99
292
  address: config.settlement,
100
293
  abi: clearingAbi,
@@ -106,13 +299,16 @@ export function createClearingClient(config) {
106
299
  abi: clearingAbi,
107
300
  functionName: "unit",
108
301
  }),
109
- ]);
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.
110
307
  if (!registered ||
111
- fault ||
112
308
  admitted !== epoch ||
113
- block.timestamp >= onchain.admissionDeadline ||
114
- `eip155:${chainId}` !== config.network ||
115
- onchain.anchor !== context.anchor ||
309
+ block.timestamp >= onchain.admissionDeadline)
310
+ throw betweenEpochs();
311
+ if (onchain.anchor !== context.anchor ||
116
312
  onchain.operatorKey !== context.operator)
117
313
  throw new Error("clearing epoch is not registered");
118
314
  if (state.epoch !== context.epoch)
@@ -126,6 +322,9 @@ export function createClearingClient(config) {
126
322
  // The invoice is in token base units; the vector counts clearing units.
127
323
  entry.cumulative = (BigInt(entry.cumulative) + clearingUnits(amount, unit)).toString();
128
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`);
129
328
  entries.sort((a, b) => a.recipient.toLowerCase().localeCompare(b.recipient.toLowerCase()));
130
329
  const vector = entries.map((e) => ({
131
330
  ...e,
@@ -148,31 +347,62 @@ export function createClearingClient(config) {
148
347
  entries,
149
348
  payerSignature: await config.signer.sign("_COMMONWARE_CLEARING_VECTOR_SEND", body),
150
349
  invoiceSignature: await config.signer.sign("_METAL_CLEARING_INVOICE", invoiceMessage(invoice, body)),
350
+ url,
151
351
  },
152
352
  };
153
353
  await save(state);
154
354
  }
155
355
  const pending = state.pending;
156
356
  let result;
357
+ let operator;
157
358
  try {
158
- result = await request("/payments", pending);
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
+ });
159
371
  }
160
372
  catch (error) {
161
- if (error instanceof PaymentRejected)
373
+ if (error instanceof ServiceRefused && error.rejected) {
162
374
  await save({ ...state, pending: undefined });
163
- throw error;
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);
164
393
  }
165
- const operator = await chain.readContract({
166
- address: config.settlement,
167
- abi: clearingAbi,
168
- functionName: "operatorKey",
169
- });
170
- if (result.receipt.body !== pending.body ||
171
- result.receipt.payerSignature !== pending.payerSignature ||
172
- invoiceMessage(result.invoice, pending.body) !==
173
- invoiceMessage(invoice, pending.body) ||
174
- !verifyReceipt(result.receipt, operator))
175
- throw new Error("invalid clearing acknowledgment; retain pending payment");
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);
176
406
  const settled = {
177
407
  invoice,
178
408
  receipt: result.receipt,
@@ -194,11 +424,13 @@ export function createClearingClient(config) {
194
424
  async pendingInvoice() {
195
425
  return (await config.store.load())?.pending?.invoice ?? null;
196
426
  },
197
- payable(invoice) {
198
- return refusal(invoice) === undefined;
199
- },
200
- pay(invoice) {
201
- const result = tail.then(() => pay(invoice));
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));
202
434
  tail = result.catch(() => { });
203
435
  return result;
204
436
  },
@@ -84,8 +84,10 @@ export interface ClearingChain {
84
84
  * registrations, so the deposit waits for that window and is sent again
85
85
  * if the window closes under it, up to `timeoutMs` (default 60 s). It
86
86
  * refuses at once a contract that holds another token, and a deposit past
87
- * what the contract holds in all. A deposit whose outcome is unknown is not
88
- * sent again: the error names it. */
87
+ * what the contract holds in all. A deposit the token refuses because
88
+ * `from` holds less fails naming what `from` holds and what the deposit
89
+ * needs. A deposit whose outcome is unknown is not sent again: the error
90
+ * names it. */
89
91
  export declare function depositToClearing(opts: ClearingChain & {
90
92
  asset: Address;
91
93
  account: Hex;
@@ -104,15 +106,22 @@ type ClearingClaim = {
104
106
  };
105
107
  export type ClearingWithdrawal =
106
108
  /** A withdrawal is queued and `epoch`, the one that carries it, has not
107
- * finalized; nothing can be sent until it does. */
109
+ * finalized; nothing can be sent until it does. Once the service has
110
+ * missed a deadline, the call fails instead, naming `recoverFromClearing`. */
108
111
  {
109
112
  status: "waiting";
110
113
  epoch: bigint;
111
114
  }
115
+ /** Nothing to withdraw: no payout to claim, and no finalized balance.
116
+ * What the account holds in an epoch not yet finalized can leave once
117
+ * it is. */
118
+ | {
119
+ status: "empty";
120
+ }
112
121
  /** What this call sent: the account's payout it claimed, to the
113
122
  * destination the finalized withdrawal named, and the withdrawal it
114
- * queued for the account's finalized balance, or `null` when that balance
115
- * is zero. A
123
+ * queued for the account's finalized balance, or `null` when nothing
124
+ * finalized is left to queue. A
116
125
  * deferred payout is one the destination refused: it waits in the
117
126
  * contract's `unclaimedPayout` until the destination calls
118
127
  * `withdrawUnclaimed`. */
@@ -128,7 +137,9 @@ export type ClearingWithdrawal =
128
137
  * withdrawal of whatever finalized balance remains, to `destination` (`from` itself
129
138
  * unless given). A payer's unused deposit and a seller's earnings leave the
130
139
  * same way. Call again after the queued withdrawal's epoch finalizes. A
131
- * failure after a claim names the claims made before it. */
140
+ * failure after a claim names the claims made before it. A queue or claim
141
+ * refused because an epoch finalized under its proof moved nothing: the
142
+ * error says to call again after the next epoch. */
132
143
  export declare function withdrawFromClearing(opts: ClearingChain & {
133
144
  signer: ClearingSigner;
134
145
  serviceUrl: string;