@metalntwx/metal-pay 0.2.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 +104 -38
- package/dist/clearing-abi.d.ts +1836 -0
- package/dist/clearing-abi.js +2361 -0
- package/dist/clearing-client.d.ts +96 -159
- package/dist/clearing-client.js +412 -553
- package/dist/clearing-codec.d.ts +57 -0
- package/dist/clearing-codec.js +173 -0
- package/dist/clearing-custody.d.ts +202 -0
- package/dist/clearing-custody.js +552 -0
- package/dist/clearing-server.d.ts +30 -49
- package/dist/clearing-server.js +87 -67
- package/dist/clearing-x402.d.ts +38 -0
- package/dist/clearing-x402.js +249 -0
- package/dist/client.d.ts +14 -15
- package/dist/client.js +53 -22
- package/dist/index.d.ts +4 -1
- package/dist/index.js +4 -1
- package/dist/mandates/account.d.ts +9 -4
- package/dist/mandates/account.js +15 -11
- package/dist/mandates/binding.d.ts +4 -27
- package/dist/mandates/binding.js +5 -9
- package/dist/mandates/checkout.js +5 -5
- package/dist/mandates/client.js +4 -3
- package/dist/mandates/l3a.d.ts +2 -2
- package/dist/mandates/l3a.js +5 -4
- package/dist/mandates/seller.d.ts +1 -1
- package/dist/mandates/seller.js +4 -4
- package/dist/mandates/verify.d.ts +4 -6
- package/dist/mandates/verify.js +147 -82
- package/dist/mandates/vi/credential.js +4 -4
- package/dist/networks.d.ts +9 -1
- package/dist/networks.js +105 -4
- package/dist/node.d.ts +11 -0
- package/dist/node.js +97 -0
- package/package.json +18 -11
- package/src/clearing-abi.ts +2361 -0
- package/src/clearing-client.test.ts +850 -0
- package/src/clearing-client.ts +612 -0
- package/src/clearing-codec.test.ts +57 -0
- package/src/clearing-codec.ts +251 -0
- package/src/clearing-custody.test.ts +763 -0
- package/src/clearing-custody.ts +743 -0
- package/src/clearing-fixture.json +37 -0
- package/src/clearing-server.test.ts +450 -0
- package/src/clearing-server.ts +123 -0
- package/src/clearing-x402.test.ts +181 -0
- package/src/clearing-x402.ts +328 -0
- package/src/client.test.ts +333 -0
- package/src/client.ts +195 -0
- package/src/index.ts +33 -0
- package/src/mandates/account.ts +95 -0
- package/src/mandates/binding.ts +280 -0
- package/src/mandates/chain.ts +68 -0
- package/src/mandates/checkout.ts +368 -0
- package/src/mandates/client.ts +159 -0
- package/src/mandates/extension.ts +48 -0
- package/src/mandates/index.ts +121 -0
- package/src/mandates/ingest.ts +42 -0
- package/src/mandates/jcs.ts +16 -0
- package/src/mandates/l3a.ts +216 -0
- package/src/mandates/seller.ts +91 -0
- package/src/mandates/verify.ts +625 -0
- package/src/mandates/vi/credential.ts +477 -0
- package/src/mandates/vi/issue.ts +118 -0
- package/src/mandates/vi/sd-jwt.ts +189 -0
- package/src/networks.test.ts +177 -0
- package/src/networks.ts +242 -0
- package/src/node.test.ts +24 -0
- package/src/node.ts +96 -0
- package/src/server.test.ts +223 -0
- package/src/server.ts +77 -0
- package/dist/clearing.d.ts +0 -204
- package/dist/clearing.js +0 -151
- package/dist/file-storage.d.ts +0 -39
- package/dist/file-storage.js +0 -195
|
@@ -0,0 +1,763 @@
|
|
|
1
|
+
import { createServer, type Server } from "node:http";
|
|
2
|
+
import { inspect } from "node:util";
|
|
3
|
+
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
|
4
|
+
import {
|
|
5
|
+
decodeFunctionData,
|
|
6
|
+
encodeAbiParameters,
|
|
7
|
+
encodeFunctionResult,
|
|
8
|
+
keccak256,
|
|
9
|
+
numberToHex,
|
|
10
|
+
parseTransaction,
|
|
11
|
+
recoverTransactionAddress,
|
|
12
|
+
toFunctionSelector,
|
|
13
|
+
type Hex,
|
|
14
|
+
} from "viem";
|
|
15
|
+
import { privateKeyToAccount } from "viem/accounts";
|
|
16
|
+
import { clearingAbi } from "./clearing-abi.js";
|
|
17
|
+
import { createClearingSigner } from "./clearing-codec.js";
|
|
18
|
+
import { CLEARING_MAX_AMOUNT, clearingSpendable, depositToClearing, recoverFromClearing, withdrawFromClearing } from "./clearing-custody.js";
|
|
19
|
+
|
|
20
|
+
const ZERO32 = `0x${"00".repeat(32)}`;
|
|
21
|
+
const ASSET = `0x${"33".repeat(20)}` as const;
|
|
22
|
+
const sent: { hash: Hex; nonce: number; data: Hex; from: Hex }[] = [];
|
|
23
|
+
let unreadable = 0;
|
|
24
|
+
/** What the Clearing contract answers: its token, the custody it holds (one
|
|
25
|
+
* value per read, the last repeated), and whether a deposit's dry run reverts
|
|
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] };
|
|
30
|
+
/** How the node misbehaves: it takes a transaction and loses its answer,
|
|
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. */
|
|
34
|
+
const node = {
|
|
35
|
+
dropAnswer: false,
|
|
36
|
+
refuseWith: undefined as string | undefined,
|
|
37
|
+
neverMined: false,
|
|
38
|
+
revertNextDeposit: false,
|
|
39
|
+
revertNext: new Set<string>(),
|
|
40
|
+
dryRunGas: [] as (string | undefined)[],
|
|
41
|
+
};
|
|
42
|
+
const reverted = new Set<Hex>();
|
|
43
|
+
const SETTLEMENT = `0x${"22".repeat(20)}` as const;
|
|
44
|
+
/** The Clearing contract's views a withdrawal or recovery reads, by name, and
|
|
45
|
+
* the calls whose dry run reverts, with the error they revert with ("" for a
|
|
46
|
+
* revert with no reason, as running out of gas inside a call gives). */
|
|
47
|
+
/** The contract's clearing unit: an 18-decimal token's, a millionth of it. */
|
|
48
|
+
const UNIT = 10n ** 12n;
|
|
49
|
+
const views: Record<string, (args: readonly unknown[]) => unknown> = { unit: () => UNIT };
|
|
50
|
+
const refusing: Record<string, string> = {};
|
|
51
|
+
/** The payout positions claimed, as the contract's bitmap records them. */
|
|
52
|
+
const claimedPositions = new Set<bigint>();
|
|
53
|
+
const word = (type: "address" | "uint64" | "uint256", value: unknown) => encodeAbiParameters([{ type }], [value as never]);
|
|
54
|
+
const selector = (signature: string) => toFunctionSelector(signature);
|
|
55
|
+
|
|
56
|
+
/** A chain that mines every transaction, where the first deposit's receipt cannot be read for a while. */
|
|
57
|
+
const rpc = createServer((req, res) => {
|
|
58
|
+
let body = "";
|
|
59
|
+
req.on("data", (c) => (body += c));
|
|
60
|
+
req.on("end", async () => {
|
|
61
|
+
const { id, method, params } = JSON.parse(body);
|
|
62
|
+
const ok = (result: unknown) => res.end(JSON.stringify({ jsonrpc: "2.0", id, result }));
|
|
63
|
+
const fail = (code: number, message: string) => res.end(JSON.stringify({ jsonrpc: "2.0", id, error: { code, message } }));
|
|
64
|
+
res.setHeader("content-type", "application/json");
|
|
65
|
+
const mined = (hash: Hex) => sent.find((s) => s.hash === hash);
|
|
66
|
+
switch (method) {
|
|
67
|
+
case "eth_chainId":
|
|
68
|
+
return ok("0x539");
|
|
69
|
+
case "eth_blockNumber":
|
|
70
|
+
return ok(numberToHex(10 + sent.length));
|
|
71
|
+
case "eth_call": {
|
|
72
|
+
const data = String(params[0].data ?? params[0].input) as Hex;
|
|
73
|
+
const call = (() => {
|
|
74
|
+
try {
|
|
75
|
+
return decodeFunctionData({ abi: clearingAbi, data });
|
|
76
|
+
} catch {
|
|
77
|
+
return undefined;
|
|
78
|
+
}
|
|
79
|
+
})();
|
|
80
|
+
if (call && call.functionName in refusing) {
|
|
81
|
+
const reason = refusing[call.functionName];
|
|
82
|
+
return res.end(JSON.stringify({ jsonrpc: "2.0", id, error: { code: 3, message: "execution reverted", data: reason ? selector(`${reason}()`) : undefined } }));
|
|
83
|
+
}
|
|
84
|
+
if (call?.functionName === "claimedPayouts") {
|
|
85
|
+
const word = call.args[0] as bigint;
|
|
86
|
+
let bits = 0n;
|
|
87
|
+
for (const position of claimedPositions) if (position >> 8n === word) bits |= 1n << (position & 255n);
|
|
88
|
+
return ok(encodeFunctionResult({ abi: clearingAbi, functionName: "claimedPayouts", result: bits }));
|
|
89
|
+
}
|
|
90
|
+
if (call && views[call.functionName])
|
|
91
|
+
return ok(encodeFunctionResult({ abi: clearingAbi, functionName: call.functionName, result: views[call.functionName]!(call.args ?? []) } as never));
|
|
92
|
+
// A standing allowance.
|
|
93
|
+
if (data.startsWith(selector("allowance(address,address)"))) return ok(`0x${"ff".repeat(32)}`);
|
|
94
|
+
if (data.startsWith(selector("token()"))) return ok(word("address", contract.token));
|
|
95
|
+
if (data.startsWith(selector("activeCustody()")))
|
|
96
|
+
return ok(word("uint64", contract.custody.length > 1 ? contract.custody.shift() : contract.custody[0]));
|
|
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) } }));
|
|
103
|
+
if (data.startsWith(selector("deposit(bytes,uint64)"))) node.dryRunGas.push(params[0].gas);
|
|
104
|
+
if (data.startsWith(selector("deposit(bytes,uint64)")) && contract.depositReverts)
|
|
105
|
+
return res.end(JSON.stringify({ jsonrpc: "2.0", id, error: { code: 3, message: "execution reverted", data: selector("InvalidBoundary()") } }));
|
|
106
|
+
return ok("0x");
|
|
107
|
+
}
|
|
108
|
+
case "eth_getBlockByNumber":
|
|
109
|
+
return ok({
|
|
110
|
+
number: "0xa", hash: ZERO32, parentHash: ZERO32, timestamp: "0x1", baseFeePerGas: "0x7", gasLimit: "0x1", gasUsed: "0x0",
|
|
111
|
+
transactions: [], logsBloom: `0x${"00".repeat(256)}`, miner: `0x${"00".repeat(20)}`, extraData: "0x", difficulty: "0x0",
|
|
112
|
+
nonce: "0x0000000000000000", sha3Uncles: ZERO32, stateRoot: ZERO32, receiptsRoot: ZERO32, transactionsRoot: ZERO32, size: "0x1", uncles: [],
|
|
113
|
+
});
|
|
114
|
+
case "eth_getTransactionCount":
|
|
115
|
+
return ok(numberToHex(sent.length));
|
|
116
|
+
case "eth_sendRawTransaction": {
|
|
117
|
+
if (node.refuseWith) return fail(-32000, node.refuseWith);
|
|
118
|
+
const raw = params[0] as Hex;
|
|
119
|
+
const tx = parseTransaction(raw);
|
|
120
|
+
const hash = keccak256(raw);
|
|
121
|
+
sent.push({ hash, nonce: tx.nonce ?? 0, data: tx.data ?? "0x", from: await recoverTransactionAddress({ serializedTransaction: raw as never }) });
|
|
122
|
+
if (node.revertNextDeposit && (tx.data ?? "").startsWith(selector("deposit(bytes,uint64)"))) {
|
|
123
|
+
node.revertNextDeposit = false;
|
|
124
|
+
reverted.add(hash);
|
|
125
|
+
}
|
|
126
|
+
try {
|
|
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);
|
|
130
|
+
} catch {}
|
|
131
|
+
if (node.dropAnswer) return res.destroy();
|
|
132
|
+
return ok(hash);
|
|
133
|
+
}
|
|
134
|
+
case "eth_getTransactionByHash": {
|
|
135
|
+
const t = mined(params[0]);
|
|
136
|
+
if (!t) return ok(null);
|
|
137
|
+
return ok({
|
|
138
|
+
hash: t.hash, nonce: numberToHex(t.nonce), blockNumber: "0xb", blockHash: ZERO32, transactionIndex: "0x0", from: t.from,
|
|
139
|
+
to: `0x${"22".repeat(20)}`, input: t.data, value: "0x0", gas: "0x1", type: "0x2", maxFeePerGas: "0xf", maxPriorityFeePerGas: "0x1",
|
|
140
|
+
chainId: "0x539", v: "0x0", r: ZERO32, s: ZERO32,
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
case "eth_getTransactionReceipt": {
|
|
144
|
+
const t = mined(params[0]);
|
|
145
|
+
if (!t || node.neverMined) return ok(null);
|
|
146
|
+
if (t === sent[0] && unreadable < 2) {
|
|
147
|
+
unreadable++;
|
|
148
|
+
return fail(-32000, "upstream node unavailable");
|
|
149
|
+
}
|
|
150
|
+
return ok({
|
|
151
|
+
status: reverted.has(t.hash) ? "0x0" : "0x1", transactionHash: t.hash, blockNumber: "0xb", blockHash: ZERO32, from: t.from, to: `0x${"22".repeat(20)}`,
|
|
152
|
+
gasUsed: "0x1", cumulativeGasUsed: "0x1", effectiveGasPrice: "0x1", logs: [], logsBloom: `0x${"00".repeat(256)}`, type: "0x2",
|
|
153
|
+
transactionIndex: "0x0", contractAddress: null,
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
default:
|
|
157
|
+
return fail(-32601, `unhandled ${method}`);
|
|
158
|
+
}
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
let rpcUrl: string;
|
|
163
|
+
beforeAll(async () => {
|
|
164
|
+
rpcUrl = await listen(rpc);
|
|
165
|
+
});
|
|
166
|
+
afterAll(() => new Promise<void>((resolve) => rpc.close(() => resolve())));
|
|
167
|
+
|
|
168
|
+
const depositor = privateKeyToAccount(`0x${"45".repeat(32)}`);
|
|
169
|
+
const deposit = (amount = 1000n * UNIT) =>
|
|
170
|
+
depositToClearing({
|
|
171
|
+
rpcUrl,
|
|
172
|
+
chainId: 1337,
|
|
173
|
+
from: depositor,
|
|
174
|
+
settlement: `0x${"22".repeat(20)}`,
|
|
175
|
+
asset: ASSET,
|
|
176
|
+
account: `0x02${"11".repeat(32)}`,
|
|
177
|
+
amount,
|
|
178
|
+
timeoutMs: 500,
|
|
179
|
+
pollMs: 10,
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
describe("depositToClearing", () => {
|
|
183
|
+
|
|
184
|
+
it("reports a mined deposit done when reads of its receipt fail for a while", async () => {
|
|
185
|
+
const { depositTx } = await deposit();
|
|
186
|
+
expect(sent).toHaveLength(1);
|
|
187
|
+
expect(depositTx).toBe(sent[0]!.hash);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it("names the deposit it sent, and sends nothing more, when the node's answer to it is lost", async () => {
|
|
191
|
+
sent.length = 0;
|
|
192
|
+
node.dropAnswer = true;
|
|
193
|
+
try {
|
|
194
|
+
const refused = deposit();
|
|
195
|
+
await expect(refused).rejects.toThrow(/^deposit 0x[0-9a-f]{64} with nonce 0 /);
|
|
196
|
+
await expect(refused).rejects.toThrow(sent[0]!.hash);
|
|
197
|
+
// The node's own answer stays with it, for a refusal the hash cannot show.
|
|
198
|
+
await expect(refused).rejects.toHaveProperty("cause.name", "HttpRequestError");
|
|
199
|
+
} finally {
|
|
200
|
+
node.dropAnswer = false;
|
|
201
|
+
}
|
|
202
|
+
expect(sent).toHaveLength(1);
|
|
203
|
+
});
|
|
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
|
+
|
|
227
|
+
it("names a deposit whose receipt does not arrive, and does not send it again", async () => {
|
|
228
|
+
sent.length = 0;
|
|
229
|
+
node.neverMined = true;
|
|
230
|
+
try {
|
|
231
|
+
await expect(deposit()).rejects.toThrow(/^deposit 0x[0-9a-f]{64} with nonce 0 was sent and its outcome is unknown/);
|
|
232
|
+
} finally {
|
|
233
|
+
node.neverMined = false;
|
|
234
|
+
}
|
|
235
|
+
expect(sent).toHaveLength(1);
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it("names a deposit the node refuses as refused, not in doubt", async () => {
|
|
239
|
+
node.refuseWith = "insufficient funds for gas * price + value";
|
|
240
|
+
try {
|
|
241
|
+
const error = await deposit().catch((e: unknown) => e);
|
|
242
|
+
expect(String(error)).toMatch(/refused deposit/);
|
|
243
|
+
expect(String(error)).not.toMatch(/outcome is unknown/);
|
|
244
|
+
} finally {
|
|
245
|
+
node.refuseWith = undefined;
|
|
246
|
+
}
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
it("dry-runs a deposit under the gas it is sent with", async () => {
|
|
250
|
+
sent.length = 0;
|
|
251
|
+
node.dryRunGas.length = 0;
|
|
252
|
+
await deposit();
|
|
253
|
+
expect(node.dryRunGas.at(-1)).toBe(numberToHex(1_000_000));
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
it("names a deposit that reverted when its next dry run passes, and sends it no more", async () => {
|
|
257
|
+
sent.length = 0;
|
|
258
|
+
node.revertNextDeposit = true;
|
|
259
|
+
await expect(deposit()).rejects.toThrow(/^deposit 0x[0-9a-f]{64} reverted/);
|
|
260
|
+
expect(sent).toHaveLength(1);
|
|
261
|
+
});
|
|
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
|
+
|
|
286
|
+
it("refuses a Clearing contract that holds another token, sending nothing", async () => {
|
|
287
|
+
sent.length = 0;
|
|
288
|
+
contract.token = `0x${"44".repeat(20)}`;
|
|
289
|
+
try {
|
|
290
|
+
await expect(deposit()).rejects.toThrow(/holds 0x4444444444444444444444444444444444444444/);
|
|
291
|
+
} finally {
|
|
292
|
+
contract.token = ASSET;
|
|
293
|
+
}
|
|
294
|
+
expect(sent).toHaveLength(0);
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
it("names the contract's limit on what it holds in all, before sending and when another deposit fills it first", async () => {
|
|
298
|
+
sent.length = 0;
|
|
299
|
+
contract.custody = [CLEARING_MAX_AMOUNT - 10n];
|
|
300
|
+
await expect(deposit()).rejects.toThrow(
|
|
301
|
+
`holds at most ${CLEARING_MAX_AMOUNT * UNIT} base units in all, and has room for ${10n * UNIT} more`,
|
|
302
|
+
);
|
|
303
|
+
expect(sent).toHaveLength(0);
|
|
304
|
+
// Room at first, then the dry run reverts because another deposit took it.
|
|
305
|
+
contract.custody = [0n, CLEARING_MAX_AMOUNT - 10n];
|
|
306
|
+
contract.depositReverts = true;
|
|
307
|
+
try {
|
|
308
|
+
await expect(deposit()).rejects.toThrow(`has room for ${10n * UNIT} more`);
|
|
309
|
+
} finally {
|
|
310
|
+
contract.custody = [0n];
|
|
311
|
+
contract.depositReverts = false;
|
|
312
|
+
}
|
|
313
|
+
expect(sent).toHaveLength(0);
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
it("deposits whole clearing units and refuses a fraction of one, sending nothing", async () => {
|
|
317
|
+
sent.length = 0;
|
|
318
|
+
await expect(deposit(UNIT + 1n)).rejects.toThrow(/whole number of clearing units/);
|
|
319
|
+
expect(sent).toHaveLength(0);
|
|
320
|
+
await deposit(5n * UNIT);
|
|
321
|
+
const sentDeposit = decodeFunctionData({ abi: clearingAbi, data: sent.at(-1)!.data });
|
|
322
|
+
expect(sentDeposit).toMatchObject({ functionName: "deposit", args: [expect.any(String), 5n] });
|
|
323
|
+
});
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
function listen(server: Server): Promise<string> {
|
|
327
|
+
return new Promise((resolve, reject) => {
|
|
328
|
+
server.once("error", reject);
|
|
329
|
+
server.listen(0, "127.0.0.1", () => {
|
|
330
|
+
const address = server.address();
|
|
331
|
+
if (!address || typeof address === "string") return reject(new Error("no port"));
|
|
332
|
+
resolve(`http://127.0.0.1:${address.port}`);
|
|
333
|
+
});
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
const DESTINATION = `0x${"77".repeat(20)}` as const;
|
|
338
|
+
const signer = createClearingSigner(`0x${"56".repeat(32)}`);
|
|
339
|
+
const proof = (location: number) => ({ location: String(location), leaves: "9", inactivePeaks: 0, digests: [] });
|
|
340
|
+
/** The account's finalized balance, as the clearing service proves it. */
|
|
341
|
+
let balance: unknown = { state: ZERO32, amount: "0", opening: null };
|
|
342
|
+
/** The clearing service's answers, and the paths asked of it. */
|
|
343
|
+
const asked: string[] = [];
|
|
344
|
+
const service = (payouts: Record<string, unknown>) => async (input: URL | RequestInfo) => {
|
|
345
|
+
const path = new URL(String(input)).pathname;
|
|
346
|
+
asked.push(path);
|
|
347
|
+
if (path.startsWith("/payout/")) return Response.json(payouts[path] ?? null);
|
|
348
|
+
// The service proves balances only against a finalized epoch.
|
|
349
|
+
if (path.startsWith("/balance/"))
|
|
350
|
+
return views.finalizedCount?.([]) === 0n ? new Response("no finalized clearing balances", { status: 409 }) : Response.json(balance);
|
|
351
|
+
if (path === `/spendable/${signer.publicKey}`) return Response.json({ amount: "950" });
|
|
352
|
+
return new Response(null, { status: 404 });
|
|
353
|
+
};
|
|
354
|
+
const custody = (fetch: ReturnType<typeof service>) => ({
|
|
355
|
+
rpcUrl,
|
|
356
|
+
chainId: 1337,
|
|
357
|
+
from: privateKeyToAccount(`0x${"45".repeat(32)}`),
|
|
358
|
+
settlement: SETTLEMENT,
|
|
359
|
+
signer,
|
|
360
|
+
serviceUrl: "http://clearing.test",
|
|
361
|
+
fetch: fetch as typeof globalThis.fetch,
|
|
362
|
+
});
|
|
363
|
+
/** Several clearing services, by host. */
|
|
364
|
+
const hosts =
|
|
365
|
+
(byHost: Record<string, ReturnType<typeof service>>) =>
|
|
366
|
+
(input: URL | RequestInfo) =>
|
|
367
|
+
byHost[new URL(String(input)).host]!(input);
|
|
368
|
+
const unavailable = async () => Response.json({ error: "clearing request unavailable" }, { status: 409 });
|
|
369
|
+
/** This account's withdrawal, queued to DESTINATION in epoch 2. */
|
|
370
|
+
const withdrawalIn2 = () => [`0x${"ab".repeat(32)}`, 3n, 0n, 0n, DESTINATION, true];
|
|
371
|
+
|
|
372
|
+
describe("clearingSpendable", () => {
|
|
373
|
+
it("answers in token base units, as deposits and prices count, though the service counts clearing units", async () => {
|
|
374
|
+
const spendable = await clearingSpendable({
|
|
375
|
+
rpcUrl,
|
|
376
|
+
settlement: SETTLEMENT,
|
|
377
|
+
serviceUrl: "http://clearing.test",
|
|
378
|
+
account: signer.publicKey,
|
|
379
|
+
fetch: service({}) as typeof globalThis.fetch,
|
|
380
|
+
});
|
|
381
|
+
expect(spendable).toBe(950n * UNIT);
|
|
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
|
+
});
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
describe("withdrawing and recovering", () => {
|
|
399
|
+
it("claims only this account's payout, by one lookup, when another account's payout goes to the same destination, and not again", async () => {
|
|
400
|
+
sent.length = 0;
|
|
401
|
+
asked.length = 0;
|
|
402
|
+
claimedPositions.clear();
|
|
403
|
+
Object.assign(views, { fault: () => 0, withdrawals: withdrawalIn2, finalizedCount: () => 5n });
|
|
404
|
+
// Positions 6 and 7 both pay DESTINATION; 7 is this account's.
|
|
405
|
+
const fetch = service({ [`/payout/2/${signer.publicKey}`]: { destination: DESTINATION, amount: "150", proof: proof(7) } });
|
|
406
|
+
const first = await withdrawFromClearing(custody(fetch));
|
|
407
|
+
expect(first).toMatchObject({ status: "sent", claimed: [{ amount: 150n * UNIT }], queued: null });
|
|
408
|
+
expect(asked.filter((path) => path.startsWith("/payout/"))).toEqual([`/payout/2/${signer.publicKey}`]);
|
|
409
|
+
expect([...claimedPositions]).toEqual([7n]);
|
|
410
|
+
expect(await withdrawFromClearing(custody(fetch))).toEqual({ status: "empty" });
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
it("refuses a payout the service names to another destination, claiming nothing", async () => {
|
|
414
|
+
sent.length = 0;
|
|
415
|
+
claimedPositions.clear();
|
|
416
|
+
Object.assign(views, { fault: () => 0, withdrawals: withdrawalIn2, finalizedCount: () => 5n });
|
|
417
|
+
const fetch = service({ [`/payout/2/${signer.publicKey}`]: { destination: `0x${"88".repeat(20)}`, amount: "150", proof: proof(7) } });
|
|
418
|
+
await expect(withdrawFromClearing(custody(fetch))).rejects.toThrow(/not 0x7777777777777777777777777777777777777777/);
|
|
419
|
+
expect(sent).toHaveLength(0);
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
it("claims a finalized withdrawal while an epoch admitted before the fault is still in its challenge window", async () => {
|
|
423
|
+
sent.length = 0;
|
|
424
|
+
claimedPositions.clear();
|
|
425
|
+
Object.assign(views, {
|
|
426
|
+
fault: () => 1,
|
|
427
|
+
recovering: () => false,
|
|
428
|
+
withdrawals: withdrawalIn2,
|
|
429
|
+
finalizedCount: () => 5n,
|
|
430
|
+
admittedCount: () => 6n,
|
|
431
|
+
invalidFrom: () => 7n,
|
|
432
|
+
});
|
|
433
|
+
refusing.finalize = "InvalidDeadline";
|
|
434
|
+
try {
|
|
435
|
+
const fetch = service({ [`/payout/2/${signer.publicKey}`]: { destination: DESTINATION, amount: "150", proof: proof(7) } });
|
|
436
|
+
expect(await recoverFromClearing(custody(fetch))).toMatchObject({ status: "waiting", epoch: 5n, claimed: [{ amount: 150n * UNIT }] });
|
|
437
|
+
} finally {
|
|
438
|
+
delete refusing.finalize;
|
|
439
|
+
}
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
it("claims nothing for a payout of 0", async () => {
|
|
443
|
+
sent.length = 0;
|
|
444
|
+
claimedPositions.clear();
|
|
445
|
+
Object.assign(views, { fault: () => 0, withdrawals: withdrawalIn2, finalizedCount: () => 5n });
|
|
446
|
+
const fetch = service({ [`/payout/2/${signer.publicKey}`]: { destination: DESTINATION, amount: "0", proof: proof(7) } });
|
|
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" });
|
|
456
|
+
expect(sent).toHaveLength(0);
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
it("queues nothing before the first epoch finalizes", async () => {
|
|
460
|
+
sent.length = 0;
|
|
461
|
+
Object.assign(views, { fault: () => 0, withdrawals: () => [ZERO32, 0n, 0n, 0n, DESTINATION, false], finalizedCount: () => 0n });
|
|
462
|
+
expect(await withdrawFromClearing(custody(service({})))).toEqual({ status: "empty" });
|
|
463
|
+
expect(sent).toHaveLength(0);
|
|
464
|
+
});
|
|
465
|
+
|
|
466
|
+
it("fails, rather than answering that the deployment operates, when a fault's dry run reverts for another reason than its deadline", async () => {
|
|
467
|
+
sent.length = 0;
|
|
468
|
+
Object.assign(views, { fault: () => 0, admittedCount: () => 5n });
|
|
469
|
+
Object.assign(refusing, { faultRegistration: "InvalidDeadline", faultWithdrawal: "", faultDeposit: "InvalidDeadline" });
|
|
470
|
+
try {
|
|
471
|
+
await expect(recoverFromClearing(custody(service({})))).rejects.toThrow(/faultWithdrawal/);
|
|
472
|
+
} finally {
|
|
473
|
+
for (const name of ["faultRegistration", "faultWithdrawal", "faultDeposit"]) delete refusing[name];
|
|
474
|
+
}
|
|
475
|
+
expect(sent).toHaveLength(0);
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
it("refunds the deposits of a deployment that faulted before any epoch finalized", async () => {
|
|
479
|
+
sent.length = 0;
|
|
480
|
+
Object.assign(views, {
|
|
481
|
+
fault: () => 1,
|
|
482
|
+
recovering: () => true,
|
|
483
|
+
withdrawals: () => [ZERO32, 0n, 0n, 0n, DESTINATION, false],
|
|
484
|
+
finalizedCount: () => 0n,
|
|
485
|
+
admittedCount: () => 0n,
|
|
486
|
+
deployment: () => `0x${"cd".repeat(32)}`,
|
|
487
|
+
finalizedState: () => ZERO32,
|
|
488
|
+
recoveredAccounts: () => false,
|
|
489
|
+
depositAmounts: ([epoch]: readonly unknown[]) => (epoch === 0n ? 40n : 0n),
|
|
490
|
+
});
|
|
491
|
+
expect(await recoverFromClearing(custody(service({})))).toMatchObject({ status: "recovered", recovered: null, refunded: [{ epoch: 0n, amount: 40n * UNIT }] });
|
|
492
|
+
});
|
|
493
|
+
|
|
494
|
+
it("takes the payout from the next service named when one has not retained its epoch", async () => {
|
|
495
|
+
sent.length = 0;
|
|
496
|
+
claimedPositions.clear();
|
|
497
|
+
Object.assign(views, {
|
|
498
|
+
fault: () => 1,
|
|
499
|
+
recovering: () => false,
|
|
500
|
+
withdrawals: withdrawalIn2,
|
|
501
|
+
finalizedCount: () => 5n,
|
|
502
|
+
admittedCount: () => 6n,
|
|
503
|
+
invalidFrom: () => 7n,
|
|
504
|
+
});
|
|
505
|
+
refusing.finalize = "InvalidDeadline";
|
|
506
|
+
try {
|
|
507
|
+
const fetch = hosts({
|
|
508
|
+
"a.test": unavailable,
|
|
509
|
+
"b.test": service({ [`/payout/2/${signer.publicKey}`]: { destination: DESTINATION, amount: "150", proof: proof(7) } }),
|
|
510
|
+
});
|
|
511
|
+
expect(await recoverFromClearing({ ...custody(fetch), serviceUrl: ["http://a.test", "http://b.test"] })).toMatchObject({
|
|
512
|
+
status: "waiting",
|
|
513
|
+
epoch: 5n,
|
|
514
|
+
claimed: [{ amount: 150n * UNIT }],
|
|
515
|
+
});
|
|
516
|
+
} finally {
|
|
517
|
+
delete refusing.finalize;
|
|
518
|
+
}
|
|
519
|
+
});
|
|
520
|
+
|
|
521
|
+
it("takes the balance from the next service named when one proves another state", async () => {
|
|
522
|
+
sent.length = 0;
|
|
523
|
+
Object.assign(views, {
|
|
524
|
+
fault: () => 1,
|
|
525
|
+
recovering: () => true,
|
|
526
|
+
withdrawals: () => [ZERO32, 0n, 0n, 0n, DESTINATION, false],
|
|
527
|
+
finalizedCount: () => 5n,
|
|
528
|
+
admittedCount: () => 5n,
|
|
529
|
+
deployment: () => `0x${"cd".repeat(32)}`,
|
|
530
|
+
finalizedState: () => ZERO32,
|
|
531
|
+
recoveredAccounts: () => false,
|
|
532
|
+
depositAmounts: () => 0n,
|
|
533
|
+
});
|
|
534
|
+
const proven = (state: string) => async () =>
|
|
535
|
+
Response.json({
|
|
536
|
+
state,
|
|
537
|
+
amount: "100",
|
|
538
|
+
opening: { ...proof(1), amount: "100", nextKey: ZERO32, chunk: ZERO32, opsRoot: ZERO32, pending: ZERO32, partialDigest: ZERO32 },
|
|
539
|
+
});
|
|
540
|
+
const fetch = hosts({ "a.test": proven(`0x${"11".repeat(32)}`), "b.test": proven(ZERO32) });
|
|
541
|
+
expect(await recoverFromClearing({ ...custody(fetch), serviceUrl: ["http://a.test", "http://b.test"] })).toMatchObject({
|
|
542
|
+
status: "recovered",
|
|
543
|
+
recovered: { amount: 100n * UNIT },
|
|
544
|
+
});
|
|
545
|
+
});
|
|
546
|
+
|
|
547
|
+
it("refunds and recovers when no service named has the payout, then names the payout left to claim", async () => {
|
|
548
|
+
sent.length = 0;
|
|
549
|
+
Object.assign(views, {
|
|
550
|
+
fault: () => 1,
|
|
551
|
+
recovering: () => true,
|
|
552
|
+
withdrawals: withdrawalIn2,
|
|
553
|
+
finalizedCount: () => 5n,
|
|
554
|
+
admittedCount: () => 5n,
|
|
555
|
+
deployment: () => `0x${"cd".repeat(32)}`,
|
|
556
|
+
finalizedState: () => ZERO32,
|
|
557
|
+
recoveredAccounts: () => false,
|
|
558
|
+
depositAmounts: ([epoch]: readonly unknown[]) => (epoch === 5n ? 40n : 0n),
|
|
559
|
+
});
|
|
560
|
+
const fetch = hosts({ "a.test": unavailable, "b.test": async (input) => (new URL(String(input)).pathname.startsWith("/payout/") ? unavailable() : service({})(input)) });
|
|
561
|
+
const error = await recoverFromClearing({ ...custody(fetch), serviceUrl: ["http://a.test", "http://b.test"] }).catch((e: unknown) => e);
|
|
562
|
+
expect(String(error)).toMatch(/epoch 2/);
|
|
563
|
+
expect(String(error)).toMatch(/http:\/\/a\.test.*409.*http:\/\/b\.test.*409/);
|
|
564
|
+
expect(String(error)).toMatch(/refundDeposit 0x[0-9a-f]{64}.*call recoverFromClearing again/);
|
|
565
|
+
expect(sent).toHaveLength(1);
|
|
566
|
+
});
|
|
567
|
+
|
|
568
|
+
it("names the claim it sent when no service named proves the balance", async () => {
|
|
569
|
+
sent.length = 0;
|
|
570
|
+
claimedPositions.clear();
|
|
571
|
+
Object.assign(views, {
|
|
572
|
+
fault: () => 1,
|
|
573
|
+
recovering: () => true,
|
|
574
|
+
withdrawals: withdrawalIn2,
|
|
575
|
+
finalizedCount: () => 5n,
|
|
576
|
+
admittedCount: () => 5n,
|
|
577
|
+
deployment: () => `0x${"cd".repeat(32)}`,
|
|
578
|
+
finalizedState: () => ZERO32,
|
|
579
|
+
recoveredAccounts: () => false,
|
|
580
|
+
depositAmounts: () => 0n,
|
|
581
|
+
});
|
|
582
|
+
const payouts = service({ [`/payout/2/${signer.publicKey}`]: { destination: DESTINATION, amount: "150", proof: proof(7) } });
|
|
583
|
+
const fetch = async (input: URL | RequestInfo) =>
|
|
584
|
+
new URL(String(input)).pathname.startsWith("/balance/") ? unavailable() : payouts(input);
|
|
585
|
+
const error = await recoverFromClearing(custody(fetch)).catch((e: unknown) => e);
|
|
586
|
+
expect(String(error)).toMatch(/balance.*This call sent claim 0x[0-9a-f]{64}: call recoverFromClearing again/);
|
|
587
|
+
});
|
|
588
|
+
|
|
589
|
+
it("asks for another call when an epoch finalizes while the withdrawal is queued", async () => {
|
|
590
|
+
sent.length = 0;
|
|
591
|
+
Object.assign(views, {
|
|
592
|
+
fault: () => 0,
|
|
593
|
+
withdrawals: () => [ZERO32, 0n, 0n, 0n, DESTINATION, false],
|
|
594
|
+
finalizedCount: () => 5n,
|
|
595
|
+
deployment: () => `0x${"cd".repeat(32)}`,
|
|
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)}`,
|
|
599
|
+
});
|
|
600
|
+
balance = {
|
|
601
|
+
state: ZERO32,
|
|
602
|
+
amount: "100",
|
|
603
|
+
opening: { ...proof(1), amount: "100", nextKey: ZERO32, chunk: ZERO32, opsRoot: ZERO32, pending: ZERO32, partialDigest: ZERO32 },
|
|
604
|
+
};
|
|
605
|
+
refusing.queueWithdrawal = "InvalidBoundary";
|
|
606
|
+
try {
|
|
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/);
|
|
615
|
+
} finally {
|
|
616
|
+
delete refusing.queueWithdrawal;
|
|
617
|
+
balance = { state: ZERO32, amount: "0", opening: null };
|
|
618
|
+
}
|
|
619
|
+
expect(sent).toHaveLength(0);
|
|
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
|
+
});
|
|
763
|
+
});
|