@metalntwx/metal-pay 0.3.0 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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
|
@@ -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
|
-
|
|
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
|
|
29
|
-
*
|
|
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
|
|
118
|
-
if (
|
|
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:
|
|
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))).
|
|
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))).
|
|
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: "
|
|
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(
|
|
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
|
});
|
package/src/clearing-custody.ts
CHANGED
|
@@ -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:
|
|
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 =
|
|
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
|
|
246
|
-
*
|
|
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))
|
|
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
|
|
389
|
-
* is
|
|
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
|
|
519
|
+
if (finalized === 0n) return unqueued();
|
|
450
520
|
const balance = await clearingBalance(opts.serviceUrl, opts.signer.publicKey, fetcher);
|
|
451
|
-
if (BigInt(balance.amount) === 0n
|
|
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
|
-
|
|
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
|
|
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;
|