@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,5 @@
1
1
  import { createServer, type Server } from "node:http";
2
+ import { inspect } from "node:util";
2
3
  import { afterAll, beforeAll, describe, expect, it } from "vitest";
3
4
  import {
4
5
  decodeFunctionData,
@@ -22,16 +23,20 @@ const sent: { hash: Hex; nonce: number; data: Hex; from: Hex }[] = [];
22
23
  let unreadable = 0;
23
24
  /** What the Clearing contract answers: its token, the custody it holds (one
24
25
  * value per read, the last repeated), and whether a deposit's dry run reverts
25
- * with `InvalidBoundary`. */
26
- const contract = { token: ASSET as Hex, custody: [0n], depositReverts: false };
26
+ * with `InvalidBoundary`; and what the depositor holds of the token (one value
27
+ * per read, the last repeated), past which its dry run reverts as the token
28
+ * does, with the reason "balance". */
29
+ const contract = { token: ASSET as Hex, custody: [0n], depositReverts: false, held: [2n ** 128n] };
27
30
  /** How the node misbehaves: it takes a transaction and loses its answer,
28
- * refuses it with an error, never mines, or mines the next deposit as a
29
- * revert; and the gas each dry run of a deposit was given. */
31
+ * refuses it with an error, never mines, or mines the next deposit, or the
32
+ * next call of a Clearing function named in `revertNext`, as a revert; and
33
+ * the gas each dry run of a deposit was given. */
30
34
  const node = {
31
35
  dropAnswer: false,
32
36
  refuseWith: undefined as string | undefined,
33
37
  neverMined: false,
34
38
  revertNextDeposit: false,
39
+ revertNext: new Set<string>(),
35
40
  dryRunGas: [] as (string | undefined)[],
36
41
  };
37
42
  const reverted = new Set<Hex>();
@@ -90,6 +95,11 @@ const rpc = createServer((req, res) => {
90
95
  if (data.startsWith(selector("activeCustody()")))
91
96
  return ok(word("uint64", contract.custody.length > 1 ? contract.custody.shift() : contract.custody[0]));
92
97
  if (data.startsWith(selector("claimReserve()"))) return ok(word("uint64", 0n));
98
+ const held = () => (contract.held.length > 1 ? contract.held.shift()! : contract.held[0]!);
99
+ if (data.startsWith(selector("balanceOf(address)")))
100
+ return ok(word("uint256", `0x${data.slice(-40)}`.toLowerCase() === depositor.address.toLowerCase() ? held() : 0n));
101
+ if (call?.functionName === "deposit" && (call.args[1] as bigint) * UNIT > held())
102
+ return res.end(JSON.stringify({ jsonrpc: "2.0", id, error: { code: 3, message: "execution reverted: balance", data: selector("Error(string)") + encodeAbiParameters([{ type: "string" }], ["balance"]).slice(2) } }));
93
103
  if (data.startsWith(selector("deposit(bytes,uint64)"))) node.dryRunGas.push(params[0].gas);
94
104
  if (data.startsWith(selector("deposit(bytes,uint64)")) && contract.depositReverts)
95
105
  return res.end(JSON.stringify({ jsonrpc: "2.0", id, error: { code: 3, message: "execution reverted", data: selector("InvalidBoundary()") } }));
@@ -114,8 +124,9 @@ const rpc = createServer((req, res) => {
114
124
  reverted.add(hash);
115
125
  }
116
126
  try {
117
- const claim = decodeFunctionData({ abi: clearingAbi, data: tx.data ?? "0x" });
118
- if (claim.functionName === "claim") claimedPositions.add((claim.args[2] as { location: bigint }).location);
127
+ const call = decodeFunctionData({ abi: clearingAbi, data: tx.data ?? "0x" });
128
+ if (node.revertNext.delete(call.functionName)) reverted.add(hash);
129
+ else if (call.functionName === "claim") claimedPositions.add((call.args[2] as { location: bigint }).location);
119
130
  } catch {}
120
131
  if (node.dropAnswer) return res.destroy();
121
132
  return ok(hash);
@@ -154,11 +165,12 @@ beforeAll(async () => {
154
165
  });
155
166
  afterAll(() => new Promise<void>((resolve) => rpc.close(() => resolve())));
156
167
 
168
+ const depositor = privateKeyToAccount(`0x${"45".repeat(32)}`);
157
169
  const deposit = (amount = 1000n * UNIT) =>
158
170
  depositToClearing({
159
171
  rpcUrl,
160
172
  chainId: 1337,
161
- from: privateKeyToAccount(`0x${"45".repeat(32)}`),
173
+ from: depositor,
162
174
  settlement: `0x${"22".repeat(20)}`,
163
175
  asset: ASSET,
164
176
  account: `0x02${"11".repeat(32)}`,
@@ -190,6 +202,28 @@ describe("depositToClearing", () => {
190
202
  expect(sent).toHaveLength(1);
191
203
  });
192
204
 
205
+ it("keeps the RPC URL, which can hold the endpoint's credential, out of the error and its cause", async () => {
206
+ sent.length = 0;
207
+ node.dropAnswer = true;
208
+ try {
209
+ const error = await depositToClearing({
210
+ rpcUrl: `${rpcUrl}/v2/k3y-0f-the-endpoint`,
211
+ chainId: 1337,
212
+ from: privateKeyToAccount(`0x${"45".repeat(32)}`),
213
+ settlement: SETTLEMENT,
214
+ asset: ASSET,
215
+ account: `0x02${"11".repeat(32)}`,
216
+ amount: 1000n * UNIT,
217
+ }).catch((e: unknown) => e);
218
+ expect(error).toHaveProperty("cause.name", "HttpRequestError");
219
+ expect(inspect(error, { depth: Infinity })).toContain("[RPC URL hidden]");
220
+ expect(inspect(error, { depth: Infinity })).not.toContain("k3y-0f-the-endpoint");
221
+ expect(inspect(error, { depth: Infinity })).not.toContain(rpcUrl);
222
+ } finally {
223
+ node.dropAnswer = false;
224
+ }
225
+ });
226
+
193
227
  it("names a deposit whose receipt does not arrive, and does not send it again", async () => {
194
228
  sent.length = 0;
195
229
  node.neverMined = true;
@@ -226,6 +260,29 @@ describe("depositToClearing", () => {
226
260
  expect(sent).toHaveLength(1);
227
261
  });
228
262
 
263
+ it("names what the sender holds and what the deposit needs, in *USDZ, when the token refuses the transfer for its balance", async () => {
264
+ // k12-backend's top-up: a deposit from a key that holds less than it.
265
+ sent.length = 0;
266
+ contract.held = [300_000n * UNIT];
267
+ try {
268
+ const from = depositor.address;
269
+ await expect(deposit(500_000n * UNIT)).rejects.toThrow(
270
+ `the deposit was refused, and nothing was deposited: ${from} holds 0.3 *USDZ and the deposit needs 0.5 *USDZ; fund it or deposit less`,
271
+ );
272
+ expect(sent).toHaveLength(0);
273
+ // Mined as a revert after its dry run passed, then refused for the balance read after it.
274
+ node.revertNextDeposit = true;
275
+ contract.held = [10n ** 18n, 300_000n * UNIT];
276
+ await expect(deposit(500_000n * UNIT)).rejects.toThrow(
277
+ new RegExp(`^deposit 0x[0-9a-f]{64} reverted: ${from} holds 0.3 \\*USDZ and the deposit needs 0.5 \\*USDZ; fund it or deposit less$`),
278
+ );
279
+ expect(sent).toHaveLength(1);
280
+ } finally {
281
+ contract.held = [2n ** 128n];
282
+ node.revertNextDeposit = false;
283
+ }
284
+ });
285
+
229
286
  it("refuses a Clearing contract that holds another token, sending nothing", async () => {
230
287
  sent.length = 0;
231
288
  contract.token = `0x${"44".repeat(20)}`;
@@ -323,6 +380,19 @@ describe("clearingSpendable", () => {
323
380
  });
324
381
  expect(spendable).toBe(950n * UNIT);
325
382
  });
383
+
384
+ it("names an RPC endpoint that does not answer as unreachable, hiding its URL", async () => {
385
+ const error = await clearingSpendable({
386
+ rpcUrl: "http://127.0.0.1:1/k3y-0f-the-endpoint",
387
+ settlement: SETTLEMENT,
388
+ serviceUrl: "http://clearing.test",
389
+ account: signer.publicKey,
390
+ fetch: service({}) as typeof globalThis.fetch,
391
+ }).catch((e: unknown) => e);
392
+ expect(String(error)).toMatch(/the RPC endpoint could not be reached/);
393
+ expect(String(error)).toMatch(/function:\s+unit\(\)/);
394
+ expect(inspect(error, { depth: Infinity })).not.toContain("k3y-0f-the-endpoint");
395
+ });
326
396
  });
327
397
 
328
398
  describe("withdrawing and recovering", () => {
@@ -337,7 +407,7 @@ describe("withdrawing and recovering", () => {
337
407
  expect(first).toMatchObject({ status: "sent", claimed: [{ amount: 150n * UNIT }], queued: null });
338
408
  expect(asked.filter((path) => path.startsWith("/payout/"))).toEqual([`/payout/2/${signer.publicKey}`]);
339
409
  expect([...claimedPositions]).toEqual([7n]);
340
- expect(await withdrawFromClearing(custody(fetch))).toMatchObject({ status: "sent", claimed: [] });
410
+ expect(await withdrawFromClearing(custody(fetch))).toEqual({ status: "empty" });
341
411
  });
342
412
 
343
413
  it("refuses a payout the service names to another destination, claiming nothing", async () => {
@@ -374,14 +444,22 @@ describe("withdrawing and recovering", () => {
374
444
  claimedPositions.clear();
375
445
  Object.assign(views, { fault: () => 0, withdrawals: withdrawalIn2, finalizedCount: () => 5n });
376
446
  const fetch = service({ [`/payout/2/${signer.publicKey}`]: { destination: DESTINATION, amount: "0", proof: proof(7) } });
377
- expect(await withdrawFromClearing(custody(fetch))).toMatchObject({ status: "sent", claimed: [], queued: null });
447
+ expect(await withdrawFromClearing(custody(fetch))).toEqual({ status: "empty" });
448
+ expect(sent).toHaveLength(0);
449
+ });
450
+
451
+ it("answers empty for a zero finalized balance and no withdrawal, sending nothing, every time", async () => {
452
+ sent.length = 0;
453
+ Object.assign(views, { fault: () => 0, withdrawals: () => [ZERO32, 0n, 0n, 0n, DESTINATION, false], finalizedCount: () => 5n });
454
+ expect(await withdrawFromClearing(custody(service({})))).toEqual({ status: "empty" });
455
+ expect(await withdrawFromClearing(custody(service({})))).toEqual({ status: "empty" });
378
456
  expect(sent).toHaveLength(0);
379
457
  });
380
458
 
381
459
  it("queues nothing before the first epoch finalizes", async () => {
382
460
  sent.length = 0;
383
461
  Object.assign(views, { fault: () => 0, withdrawals: () => [ZERO32, 0n, 0n, 0n, DESTINATION, false], finalizedCount: () => 0n });
384
- expect(await withdrawFromClearing(custody(service({})))).toEqual({ status: "sent", claimed: [], queued: null });
462
+ expect(await withdrawFromClearing(custody(service({})))).toEqual({ status: "empty" });
385
463
  expect(sent).toHaveLength(0);
386
464
  });
387
465
 
@@ -516,6 +594,8 @@ describe("withdrawing and recovering", () => {
516
594
  finalizedCount: () => 5n,
517
595
  deployment: () => `0x${"cd".repeat(32)}`,
518
596
  policy: () => [0n, 0n, 0n, 0n, 60n, 86_400n],
597
+ // The state the balance was proven against has since moved on.
598
+ finalizedState: () => `0x${"ee".repeat(32)}`,
519
599
  });
520
600
  balance = {
521
601
  state: ZERO32,
@@ -524,11 +604,160 @@ describe("withdrawing and recovering", () => {
524
604
  };
525
605
  refusing.queueWithdrawal = "InvalidBoundary";
526
606
  try {
527
- await expect(withdrawFromClearing(custody(service({})))).rejects.toThrow(/an epoch finalized under it: call withdrawFromClearing again/);
607
+ await expect(withdrawFromClearing(custody(service({})))).rejects.toThrow(
608
+ /^queueWithdrawal was refused because an epoch finalized under it, .*nothing was lost: make the same call again after the next epoch$/,
609
+ );
610
+ // Under the state it was proven against, the refusal is the contract's own, not a reason to call again.
611
+ views.finalizedState = () => ZERO32;
612
+ const error = await withdrawFromClearing(custody(service({}))).catch((e: Error) => e);
613
+ expect(String(error)).toMatch(/InvalidBoundary/);
614
+ expect(String(error)).not.toMatch(/finalized under it/);
528
615
  } finally {
529
616
  delete refusing.queueWithdrawal;
530
617
  balance = { state: ZERO32, amount: "0", opening: null };
531
618
  }
532
619
  expect(sent).toHaveLength(0);
533
620
  });
621
+
622
+ it("names a queued withdrawal mined as a revert because an epoch finalized under it as lost nothing, to call again after the next epoch", async () => {
623
+ sent.length = 0;
624
+ Object.assign(views, {
625
+ fault: () => 0,
626
+ withdrawals: () => [ZERO32, 0n, 0n, 0n, DESTINATION, false],
627
+ finalizedCount: () => 5n,
628
+ deployment: () => `0x${"cd".repeat(32)}`,
629
+ policy: () => [0n, 0n, 0n, 0n, 60n, 86_400n],
630
+ // The dry run passed; the state moved on before the withdrawal landed.
631
+ finalizedState: () => `0x${"ee".repeat(32)}`,
632
+ });
633
+ balance = {
634
+ state: ZERO32,
635
+ amount: "100",
636
+ opening: { ...proof(1), amount: "100", nextKey: ZERO32, chunk: ZERO32, opsRoot: ZERO32, pending: ZERO32, partialDigest: ZERO32 },
637
+ };
638
+ try {
639
+ node.revertNext.add("queueWithdrawal");
640
+ await expect(withdrawFromClearing(custody(service({})))).rejects.toThrow(
641
+ /^queueWithdrawal 0x[0-9a-f]{64} reverted because an epoch finalized under it, .*nothing was lost but its gas: make the same call again after the next epoch$/,
642
+ );
643
+ expect(sent).toHaveLength(1);
644
+ // Under the state it was proven against, the revert is not one to call again for.
645
+ views.finalizedState = () => ZERO32;
646
+ node.revertNext.add("queueWithdrawal");
647
+ const error = await withdrawFromClearing(custody(service({}))).catch((e: Error) => e);
648
+ expect(String(error)).toMatch(/queueWithdrawal reverted: 0x[0-9a-f]{64}/);
649
+ expect(String(error)).not.toMatch(/finalized under it/);
650
+ } finally {
651
+ node.revertNext.clear();
652
+ reverted.clear();
653
+ balance = { state: ZERO32, amount: "0", opening: null };
654
+ }
655
+ });
656
+
657
+ it("names a claim refused, or mined as a revert, because an epoch finalized under its proof as lost nothing, to call again after the next epoch", async () => {
658
+ const fetch = service({ [`/payout/2/${signer.publicKey}`]: { destination: DESTINATION, amount: "150", proof: proof(7) } });
659
+ // The payout is proven against a payouts log of 9 operations; the contract's has 10.
660
+ Object.assign(views, { fault: () => 0, withdrawals: withdrawalIn2, finalizedCount: () => 5n, finalizedPayouts: () => [ZERO32, 10n, 0n] });
661
+ try {
662
+ sent.length = 0;
663
+ claimedPositions.clear();
664
+ refusing.claim = "InvalidProof";
665
+ await expect(withdrawFromClearing(custody(fetch))).rejects.toThrow(
666
+ /^claim was refused because an epoch finalized under it, .*nothing was lost: make the same call again after the next epoch$/,
667
+ );
668
+ expect(sent).toHaveLength(0);
669
+ delete refusing.claim;
670
+ node.revertNext.add("claim");
671
+ await expect(withdrawFromClearing(custody(fetch))).rejects.toThrow(
672
+ /^claim 0x[0-9a-f]{64} reverted because an epoch finalized under it, .*nothing was lost but its gas: make the same call again after the next epoch$/,
673
+ );
674
+ expect(sent).toHaveLength(1);
675
+ // Against the contract's own payouts log, the refusal and the revert are not ones to call again for.
676
+ views.finalizedPayouts = () => [ZERO32, 9n, 0n];
677
+ refusing.claim = "InvalidProof";
678
+ expect(String(await withdrawFromClearing(custody(fetch)).catch((e: Error) => e))).not.toMatch(/finalized under it/);
679
+ delete refusing.claim;
680
+ node.revertNext.add("claim");
681
+ const error = await withdrawFromClearing(custody(fetch)).catch((e: Error) => e);
682
+ expect(String(error)).toMatch(/claim reverted: 0x[0-9a-f]{64}/);
683
+ expect(String(error)).not.toMatch(/finalized under it/);
684
+ } finally {
685
+ delete refusing.claim;
686
+ node.revertNext.clear();
687
+ reverted.clear();
688
+ }
689
+ });
690
+
691
+ it("queues a withdrawal from the finalized balance alone, through a fetch that serves only /balance and /payout, as a validator does", async () => {
692
+ sent.length = 0;
693
+ Object.assign(views, {
694
+ fault: () => 0,
695
+ withdrawals: () => [ZERO32, 0n, 0n, 0n, DESTINATION, false],
696
+ finalizedCount: () => 5n,
697
+ deployment: () => `0x${"cd".repeat(32)}`,
698
+ policy: () => [0n, 0n, 0n, 0n, 60n, 86_400n],
699
+ finalizedState: () => ZERO32,
700
+ });
701
+ balance = {
702
+ state: ZERO32,
703
+ amount: "100",
704
+ opening: { ...proof(1), amount: "100", nextKey: ZERO32, chunk: ZERO32, opsRoot: ZERO32, pending: ZERO32, partialDigest: ZERO32 },
705
+ };
706
+ const validatorOnly = async (input: URL | RequestInfo) => {
707
+ const path = new URL(String(input)).pathname;
708
+ return path.startsWith("/balance/") || path.startsWith("/payout/") ? service({})(input) : new Response(null, { status: 404 });
709
+ };
710
+ try {
711
+ expect(await withdrawFromClearing(custody(validatorOnly))).toMatchObject({ status: "sent", queued: { tx: expect.any(String) } });
712
+ expect(sent).toHaveLength(1);
713
+ } finally {
714
+ balance = { state: ZERO32, amount: "0", opening: null };
715
+ }
716
+ });
717
+
718
+ it("names a clearing service it cannot reach, for the balance or the payout, and a validator's URL as serviceUrl, never several URLs", async () => {
719
+ const down = (path: string, answer: () => Response) => async (input: URL | RequestInfo) =>
720
+ new URL(String(input)).pathname.startsWith(path) ? answer() : service({})(input);
721
+ const refused = () => new Response("busy", { status: 503 });
722
+ const gone = (): Response => {
723
+ throw new TypeError("fetch failed");
724
+ };
725
+ balance = { state: ZERO32, amount: "100", opening: null };
726
+ try {
727
+ for (const [views_, fetch] of [
728
+ [{ withdrawals: () => [ZERO32, 0n, 0n, 0n, DESTINATION, false] }, down("/balance/", refused)],
729
+ [{ withdrawals: () => [ZERO32, 0n, 0n, 0n, DESTINATION, false] }, down("/balance/", gone)],
730
+ [{ withdrawals: withdrawalIn2 }, down("/payout/", refused)],
731
+ [{ withdrawals: withdrawalIn2 }, down("/payout/", gone)],
732
+ ] as const) {
733
+ sent.length = 0;
734
+ Object.assign(views, { fault: () => 0, finalizedCount: () => 5n, ...views_ });
735
+ const error = await withdrawFromClearing(custody(fetch)).catch((e: Error) => e);
736
+ expect(String(error)).toMatch(/the clearing service could not be reached/);
737
+ expect(String(error)).toMatch(/pass a validator's URL from the facilitator's \/supported extra\.validatorUrls as serviceUrl/);
738
+ expect(String(error)).not.toMatch(/several|services named|list/);
739
+ expect(sent).toHaveLength(0);
740
+ }
741
+ // A service that answers, with no payout for the account, is not one that cannot be reached.
742
+ Object.assign(views, { withdrawals: withdrawalIn2 });
743
+ expect(String(await withdrawFromClearing(custody(service({}))).catch((e: Error) => e))).not.toMatch(/could not be reached/);
744
+ } finally {
745
+ balance = { state: ZERO32, amount: "0", opening: null };
746
+ }
747
+ });
748
+
749
+ it("points a withdrawal waiting on a service that has missed a deadline to recoverFromClearing, and waits while none is missed", async () => {
750
+ sent.length = 0;
751
+ Object.assign(views, { fault: () => 0, withdrawals: withdrawalIn2, finalizedCount: () => 1n, admittedCount: () => 3n, registered: () => true });
752
+ Object.assign(refusing, { faultRegistration: "InvalidDeadline", faultWithdrawal: "InvalidDeadline", faultDeposit: "InvalidDeadline" });
753
+ try {
754
+ expect(await withdrawFromClearing(custody(service({})))).toEqual({ status: "waiting", epoch: 2n });
755
+ // The service stopped: the withdrawal's deadline passed with its epoch unfinalized.
756
+ delete refusing.faultWithdrawal;
757
+ await expect(withdrawFromClearing(custody(service({})))).rejects.toThrow(/missed a deadline.*recoverFromClearing/);
758
+ } finally {
759
+ for (const name of ["faultRegistration", "faultWithdrawal", "faultDeposit"]) delete refusing[name];
760
+ }
761
+ expect(sent).toHaveLength(0);
762
+ });
534
763
  });
@@ -8,7 +8,6 @@ import {
8
8
  erc20Abi,
9
9
  FeeCapTooHighError,
10
10
  FeeCapTooLowError,
11
- http,
12
11
  InsufficientFundsError,
13
12
  IntrinsicGasTooHighError,
14
13
  IntrinsicGasTooLowError,
@@ -29,6 +28,7 @@ import {
29
28
  import { getNodeError } from "viem/utils";
30
29
  import { clearingAbi } from "./clearing-abi.js";
31
30
  import { clearingUint64, clearingUnits } from "./clearing-codec.js";
31
+ import { formatUsdz, rpcTransport, USDZ } from "./networks.js";
32
32
  import type { ClearingSigner } from "./clearing-client.js";
33
33
 
34
34
  export interface ClearingLogProof {
@@ -103,6 +103,9 @@ export async function clearingClose(
103
103
  * millionth of the token (its base unit at six decimals or fewer). */
104
104
  export const CLEARING_MAX_AMOUNT = 2n ** 64n - 1n;
105
105
 
106
+ /** A clearing service that gave no answer, or a 5xx. */
107
+ class ServiceUnreachable extends Error {}
108
+
106
109
  async function serviceGet<T>(
107
110
  serviceUrl: string,
108
111
  path: string,
@@ -110,9 +113,11 @@ async function serviceGet<T>(
110
113
  ): Promise<T> {
111
114
  const response = await fetcher(new URL(path, serviceUrl), {
112
115
  signal: AbortSignal.timeout(30_000),
116
+ }).catch((error: unknown) => {
117
+ throw new ServiceUnreachable(`clearing service gave no answer for ${path} (${error instanceof Error ? error.message : String(error)})`, { cause: error });
113
118
  });
114
119
  if (!response.ok)
115
- throw new Error(`clearing service returned ${response.status} for ${path}`);
120
+ throw new (response.status >= 500 ? ServiceUnreachable : Error)(`clearing service returned ${response.status} for ${path}`);
116
121
  return (await response.json()) as T;
117
122
  }
118
123
 
@@ -126,7 +131,7 @@ export async function clearingSpendable(opts: {
126
131
  account: Hex;
127
132
  fetch?: typeof globalThis.fetch;
128
133
  }): Promise<bigint> {
129
- const pub = createPublicClient({ transport: http(opts.rpcUrl) });
134
+ const pub = createPublicClient({ transport: rpcTransport(opts.rpcUrl) });
130
135
  const [{ amount }, unit] = await Promise.all([
131
136
  serviceGet<{ amount: string }>(
132
137
  opts.serviceUrl,
@@ -174,7 +179,7 @@ const REFUSALS = [
174
179
  /** Gas and fees are set here: on Metal, a fee-carrying `eth_estimateGas`
175
180
  * from an account with no native balance fails. */
176
181
  function chainClients({ rpcUrl, chainId, from, timeoutMs = 60_000, pollMs = 500 }: ClearingChain & { timeoutMs?: number; pollMs?: number }) {
177
- const transport = http(rpcUrl);
182
+ const transport = rpcTransport(rpcUrl);
178
183
  const pub = createPublicClient({ transport });
179
184
  const wallet = createWalletClient({ account: from, transport });
180
185
  // The dry run first, under the gas the transaction is sent with, so a
@@ -235,6 +240,16 @@ function succeeded(functionName: string, receipt: TransactionReceipt): Hex {
235
240
  return receipt.transactionHash;
236
241
  }
237
242
 
243
+ /** A withdrawal's queue or claim refused, by its dry run or as a mined
244
+ * revert (`tx`), because an epoch finalized after the service proved it:
245
+ * the contract checks a proof against its finalized state alone. */
246
+ function finalizedUnder(functionName: string, tx: Hex | undefined, cause?: unknown) {
247
+ return new Error(
248
+ `${tx ? `${functionName} ${tx} reverted` : `${functionName} was refused`} because an epoch finalized under it, so the service's proof is for the state before; nothing was lost${tx ? " but its gas" : ""}: make the same call again after the next epoch`,
249
+ cause === undefined ? {} : { cause },
250
+ );
251
+ }
252
+
238
253
  /** Put `amount` token base units into clearing custody under `account`, a
239
254
  * clearing key; it must be a whole number of the contract's clearing
240
255
  * units. `from` pays: an approval for the amount unless one that covers it
@@ -242,8 +257,10 @@ function succeeded(functionName: string, receipt: TransactionReceipt): Hex {
242
257
  * registrations, so the deposit waits for that window and is sent again
243
258
  * if the window closes under it, up to `timeoutMs` (default 60 s). It
244
259
  * refuses at once a contract that holds another token, and a deposit past
245
- * what the contract holds in all. A deposit whose outcome is unknown is not
246
- * sent again: the error names it. */
260
+ * what the contract holds in all. A deposit the token refuses because
261
+ * `from` holds less fails naming what `from` holds and what the deposit
262
+ * needs. A deposit whose outcome is unknown is not sent again: the error
263
+ * names it. */
247
264
  export async function depositToClearing(
248
265
  opts: ClearingChain & {
249
266
  asset: Address;
@@ -300,6 +317,14 @@ export async function depositToClearing(
300
317
  const windowClosed = (error: unknown) =>
301
318
  error instanceof BaseError &&
302
319
  error.walk((e) => e instanceof ContractFunctionRevertedError && e.data?.errorName === "InvalidBoundary") !== null;
320
+ // The token refuses a transfer past what `from` holds with the reason
321
+ // "balance"; what it holds is read after the refusal.
322
+ const shortOf = async (error: unknown) => {
323
+ if (!(error instanceof BaseError && error.walk((e) => e instanceof ContractFunctionRevertedError && e.reason === "balance")))
324
+ return undefined;
325
+ const held = await pub.readContract({ address: opts.asset, abi: erc20Abi, functionName: "balanceOf", args: [opts.from.address] });
326
+ return `${opts.from.address} holds ${formatUsdz(held)} ${USDZ.symbol} and the deposit needs ${formatUsdz(opts.amount)} ${USDZ.symbol}; fund it or deposit less`;
327
+ };
303
328
  for (;;) {
304
329
  // A deposit that reverts deposited nothing, and the next dry run says
305
330
  // why: if the window closed under it, the next round waits for the next
@@ -310,12 +335,17 @@ export async function depositToClearing(
310
335
  const why = await pub
311
336
  .simulateContract({ ...deposit, account: opts.from.address, gas: depositGas } as Parameters<typeof pub.simulateContract>[0])
312
337
  .then(() => undefined, (error: unknown) => error);
313
- if (!windowClosed(why))
338
+ if (!windowClosed(why)) {
339
+ const short = await shortOf(why);
314
340
  throw new Error(
315
- `deposit ${receipt.transactionHash} reverted${why instanceof BaseError ? `: ${why.shortMessage}` : ", and its dry run now passes"}`,
341
+ `deposit ${receipt.transactionHash} reverted${short ? `: ${short}` : why instanceof BaseError ? `: ${why.shortMessage}` : ", and its dry run now passes"}`,
316
342
  );
343
+ }
317
344
  } catch (error) {
318
- if (!windowClosed(error)) throw error;
345
+ if (!windowClosed(error)) {
346
+ const short = await shortOf(error);
347
+ throw short ? new Error(`the deposit was refused, and nothing was deposited: ${short}`, { cause: error }) : error;
348
+ }
319
349
  }
320
350
  await assertRoom();
321
351
  if (Date.now() >= deadline) throw new Error("the Clearing contract took no deposits before the timeout");
@@ -341,6 +371,7 @@ async function lookupPayout(
341
371
  destination: Address,
342
372
  ): Promise<ClearingPayout> {
343
373
  const failures: string[] = [];
374
+ const unreachable: ServiceUnreachable[] = [];
344
375
  for (const serviceUrl of serviceUrls) {
345
376
  try {
346
377
  const payout = await serviceGet<ClearingPayout | null>(serviceUrl, `/payout/${epoch}/${account}`, fetcher);
@@ -349,11 +380,14 @@ async function lookupPayout(
349
380
  failures.push(`${serviceUrl} names ${payout.destination}, not ${destination}, the destination the withdrawal named`);
350
381
  else return payout;
351
382
  } catch (error) {
383
+ if (error instanceof ServiceUnreachable) unreachable.push(error);
352
384
  failures.push(`${serviceUrl}: ${error instanceof Error ? error.message : String(error)}`);
353
385
  }
354
386
  }
355
387
  throw new Error(
356
388
  `no clearing service named proves the payout of this account's withdrawal in epoch ${epoch}; a validator proves it once it has retained that epoch (${failures.join("; ")})`,
389
+ // None of them answered.
390
+ unreachable.length === serviceUrls.length ? { cause: unreachable[0] } : {},
357
391
  );
358
392
  }
359
393
 
@@ -370,10 +404,17 @@ async function claimPayout(
370
404
  const position = BigInt(payout.proof.location);
371
405
  const consumed = await pub.readContract({ ...contract, functionName: "claimedPayouts", args: [position >> 8n] });
372
406
  if (consumed & (1n << (position & 255n))) return;
407
+ // The proof is for the payouts log the service read as finalized; once a
408
+ // later epoch finalizes, the contract's log is longer and refuses it.
409
+ const moved = async () =>
410
+ (await pub.readContract({ ...contract, functionName: "finalizedPayouts" }))[1] !== BigInt(payout.proof.leaves);
373
411
  const receipt = await send(
374
412
  { ...contract, functionName: "claim", args: [destination, BigInt(payout.amount), clearingLogProof(payout.proof)] },
375
413
  1_000_000n,
376
- );
414
+ ).catch(async (error: unknown) => {
415
+ throw revertOf(error) === "InvalidProof" && (await moved()) ? finalizedUnder("claim", undefined, error) : error;
416
+ });
417
+ if (receipt.status !== "success" && (await moved())) throw finalizedUnder("claim", receipt.transactionHash);
377
418
  const tx = succeeded("claim", receipt);
378
419
  const deferred = parseEventLogs({ abi: clearingAbi, eventName: "PayoutDeferred", logs: receipt.logs }).length > 0;
379
420
  claimed.push({ amount: BigInt(payout.amount) * unit, tx, deferred });
@@ -381,12 +422,17 @@ async function claimPayout(
381
422
 
382
423
  export type ClearingWithdrawal =
383
424
  /** A withdrawal is queued and `epoch`, the one that carries it, has not
384
- * finalized; nothing can be sent until it does. */
425
+ * finalized; nothing can be sent until it does. Once the service has
426
+ * missed a deadline, the call fails instead, naming `recoverFromClearing`. */
385
427
  | { status: "waiting"; epoch: bigint }
428
+ /** Nothing to withdraw: no payout to claim, and no finalized balance.
429
+ * What the account holds in an epoch not yet finalized can leave once
430
+ * it is. */
431
+ | { status: "empty" }
386
432
  /** What this call sent: the account's payout it claimed, to the
387
433
  * destination the finalized withdrawal named, and the withdrawal it
388
- * queued for the account's finalized balance, or `null` when that balance
389
- * is zero. A
434
+ * queued for the account's finalized balance, or `null` when nothing
435
+ * finalized is left to queue. A
390
436
  * deferred payout is one the destination refused: it waits in the
391
437
  * contract's `unclaimedPayout` until the destination calls
392
438
  * `withdrawUnclaimed`. */
@@ -397,7 +443,9 @@ export type ClearingWithdrawal =
397
443
  * withdrawal of whatever finalized balance remains, to `destination` (`from` itself
398
444
  * unless given). A payer's unused deposit and a seller's earnings leave the
399
445
  * same way. Call again after the queued withdrawal's epoch finalizes. A
400
- * failure after a claim names the claims made before it. */
446
+ * failure after a claim names the claims made before it. A queue or claim
447
+ * refused because an epoch finalized under its proof moved nothing: the
448
+ * error says to call again after the next epoch. */
401
449
  export async function withdrawFromClearing(
402
450
  opts: ClearingChain & {
403
451
  signer: ClearingSigner;
@@ -420,17 +468,38 @@ export async function withdrawFromClearing(
420
468
  ]);
421
469
  const [encodedHash, epochPlusOne, , , queuedTo] = status;
422
470
  if (encodedHash !== `0x${"00".repeat(32)}` && (epochPlusOne === 0n || epochPlusOne > finalized)) {
423
- if (epochPlusOne > 0n) return { status: "waiting", epoch: epochPlusOne - 1n };
424
- // Not yet registered: the next registration carries it.
425
471
  const [registered, admitted] = await Promise.all([
426
472
  pub.readContract({ ...contract, functionName: "registered" }),
427
473
  pub.readContract({ ...contract, functionName: "admittedCount" }),
428
474
  ]);
475
+ // A service that has missed a deadline may never finalize the epoch.
476
+ for (const call of faultCalls(admitted, opts.signer.publicKey)) {
477
+ const missed = await pub.simulateContract({ ...contract, ...call, account: opts.from.address }).then(
478
+ () => true,
479
+ (error: unknown) => {
480
+ if (revertOf(error) !== "InvalidDeadline") throw error;
481
+ return false;
482
+ },
483
+ );
484
+ if (missed)
485
+ throw new Error(
486
+ `the clearing service has missed a deadline (${call.functionName} would fault the deployment), so the withdrawal may never finalize: take the account's funds out with recoverFromClearing`,
487
+ );
488
+ }
489
+ if (epochPlusOne > 0n) return { status: "waiting", epoch: epochPlusOne - 1n };
490
+ // Not yet registered: the next registration carries it.
429
491
  return { status: "waiting", epoch: registered ? admitted + 1n : admitted };
430
492
  }
431
493
  const claimed: ClearingClaim[] = [];
432
494
  try {
433
- return await claimThenQueue();
495
+ return await claimThenQueue().catch((error: unknown) => {
496
+ const gone = error instanceof ServiceUnreachable ? error : error instanceof Error && error.cause instanceof ServiceUnreachable ? error.cause : undefined;
497
+ if (!gone) throw error;
498
+ throw new Error(
499
+ `the clearing service could not be reached (${gone.message}): call withdrawFromClearing again once it answers; if it has stopped, pass a validator's URL from the facilitator's /supported extra.validatorUrls as serviceUrl`,
500
+ { cause: error },
501
+ );
502
+ });
434
503
  } catch (error) {
435
504
  if (claimed.length === 0) throw error;
436
505
  const made = claimed.map((c) => `${c.amount} in ${c.tx}`).join(", ");
@@ -445,10 +514,12 @@ export async function withdrawFromClearing(
445
514
  queuedTo,
446
515
  claimed,
447
516
  );
517
+ const unqueued = (): ClearingWithdrawal => (claimed.length > 0 ? { status: "sent", claimed, queued: null } : { status: "empty" });
448
518
  // The service proves balances only against a finalized epoch.
449
- if (finalized === 0n) return { status: "sent", claimed, queued: null };
519
+ if (finalized === 0n) return unqueued();
450
520
  const balance = await clearingBalance(opts.serviceUrl, opts.signer.publicKey, fetcher);
451
- if (BigInt(balance.amount) === 0n || !balance.opening) return { status: "sent", claimed, queued: null };
521
+ if (BigInt(balance.amount) === 0n) return unqueued();
522
+ if (!balance.opening) return { status: "sent", claimed, queued: null };
452
523
  const [deployment, policy, block] = await Promise.all([
453
524
  pub.readContract({ ...contract, functionName: "deployment" }),
454
525
  pub.readContract({ ...contract, functionName: "policy" }),
@@ -458,12 +529,14 @@ export async function withdrawFromClearing(
458
529
  // The balance is proven against the finalized state: an epoch that
459
530
  // finalizes before the withdrawal lands moves it, and the contract
460
531
  // refuses the withdrawal with `InvalidBoundary`.
532
+ const moved = async () =>
533
+ (await pub.readContract({ ...contract, functionName: "finalizedState" })).toLowerCase() !== close.withdrawal.state.toLowerCase();
461
534
  const receipt = await send({ ...contract, functionName: "queueWithdrawal", args: [close.withdrawal, close.opening] }, 2_000_000n).catch(
462
- (error: unknown) => {
463
- if (revertOf(error) !== "InvalidBoundary") throw error;
464
- throw new Error("the withdrawal was refused because an epoch finalized under it: call withdrawFromClearing again", { cause: error });
535
+ async (error: unknown) => {
536
+ throw revertOf(error) === "InvalidBoundary" && (await moved()) ? finalizedUnder("queueWithdrawal", undefined, error) : error;
465
537
  },
466
538
  );
539
+ if (receipt.status !== "success" && (await moved())) throw finalizedUnder("queueWithdrawal", receipt.transactionHash);
467
540
  return { status: "sent", claimed, queued: { tx: succeeded("queueWithdrawal", receipt) } };
468
541
  }
469
542
  }
@@ -490,6 +563,16 @@ export type ClearingRecovery =
490
563
  refunded: { epoch: bigint; amount: bigint; tx: Hex }[];
491
564
  };
492
565
 
566
+ /** The calls that fault a deployment whose service has missed a deadline:
567
+ * to admit the epoch it registered, `account`'s queued withdrawal, or its
568
+ * deposit. Each reverts `InvalidDeadline` until its deadline passes. */
569
+ const faultCalls = (admitted: bigint, account: Hex) =>
570
+ [
571
+ { functionName: "faultRegistration", args: [] },
572
+ { functionName: "faultWithdrawal", args: [account] },
573
+ { functionName: "faultDeposit", args: [admitted, account] },
574
+ ] as const;
575
+
493
576
  const revertOf = (error: unknown): string | undefined => {
494
577
  if (!(error instanceof BaseError)) return undefined;
495
578
  const reverted = error.walk((e) => e instanceof ContractFunctionRevertedError);
@@ -550,12 +633,7 @@ export async function recoverFromClearing(
550
633
  let faulted: Hex | null = null;
551
634
  if ((await pub.readContract({ ...contract, functionName: "fault" })) === 0) {
552
635
  const admitted = await pub.readContract({ ...contract, functionName: "admittedCount" });
553
- const faults = [
554
- { functionName: "faultRegistration", args: [] },
555
- { functionName: "faultWithdrawal", args: [account] },
556
- { functionName: "faultDeposit", args: [admitted, account] },
557
- ] as const;
558
- for (const call of faults) {
636
+ for (const call of faultCalls(admitted, account)) {
559
637
  try {
560
638
  faulted = succeeded(call.functionName, await send({ ...contract, ...call }, 200_000n));
561
639
  break;