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