@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 +41 -14
- package/dist/clearing-client.d.ts +51 -4
- package/dist/clearing-client.js +277 -45
- package/dist/clearing-custody.d.ts +17 -6
- package/dist/clearing-custody.js +91 -29
- package/dist/clearing-x402.d.ts +10 -1
- package/dist/clearing-x402.js +59 -13
- package/dist/client.d.ts +7 -2
- package/dist/client.js +38 -5
- package/dist/mandates/account.d.ts +9 -4
- package/dist/mandates/account.js +15 -11
- package/dist/mandates/seller.js +3 -3
- package/dist/mandates/verify.js +107 -18
- package/dist/networks.d.ts +9 -1
- package/dist/networks.js +105 -4
- package/package.json +1 -1
- package/src/clearing-client.test.ts +850 -0
- package/src/clearing-client.ts +366 -57
- package/src/clearing-custody.test.ts +240 -11
- package/src/clearing-custody.ts +106 -28
- package/src/clearing-server.test.ts +72 -10
- package/src/clearing-x402.test.ts +1 -1
- package/src/clearing-x402.ts +61 -12
- package/src/client.test.ts +8 -0
- package/src/client.ts +46 -7
- package/src/mandates/account.ts +15 -11
- package/src/mandates/seller.ts +3 -3
- package/src/mandates/verify.ts +113 -20
- package/src/networks.test.ts +163 -2
- package/src/networks.ts +95 -5
package/src/clearing-client.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
/**
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
invoice.settlement
|
|
106
|
-
)
|
|
107
|
-
|
|
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
|
|
110
|
-
return
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
138
|
-
|
|
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,
|
|
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
|
-
|
|
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",
|
|
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
|
|
547
|
+
if (error instanceof ServiceRefused && error.rejected) {
|
|
254
548
|
await save({ ...state, pending: undefined });
|
|
255
|
-
|
|
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
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
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
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
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
|
},
|