@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,665 @@
|
|
|
1
|
+
import {
|
|
2
|
+
BaseError,
|
|
3
|
+
ContractFunctionRevertedError,
|
|
4
|
+
concatHex,
|
|
5
|
+
createPublicClient,
|
|
6
|
+
createWalletClient,
|
|
7
|
+
encodeFunctionData,
|
|
8
|
+
erc20Abi,
|
|
9
|
+
FeeCapTooHighError,
|
|
10
|
+
FeeCapTooLowError,
|
|
11
|
+
http,
|
|
12
|
+
InsufficientFundsError,
|
|
13
|
+
IntrinsicGasTooHighError,
|
|
14
|
+
IntrinsicGasTooLowError,
|
|
15
|
+
keccak256,
|
|
16
|
+
NonceMaxValueError,
|
|
17
|
+
NonceTooHighError,
|
|
18
|
+
parseEventLogs,
|
|
19
|
+
sha256,
|
|
20
|
+
stringToHex,
|
|
21
|
+
TipAboveFeeCapError,
|
|
22
|
+
TransactionTypeNotSupportedError,
|
|
23
|
+
type Abi,
|
|
24
|
+
type Address,
|
|
25
|
+
type Hex,
|
|
26
|
+
type LocalAccount,
|
|
27
|
+
type TransactionReceipt,
|
|
28
|
+
} from "viem";
|
|
29
|
+
import { getNodeError } from "viem/utils";
|
|
30
|
+
import { clearingAbi } from "./clearing-abi.js";
|
|
31
|
+
import { clearingUint64, clearingUnits } from "./clearing-codec.js";
|
|
32
|
+
import type { ClearingSigner } from "./clearing-client.js";
|
|
33
|
+
|
|
34
|
+
export interface ClearingLogProof {
|
|
35
|
+
location: string;
|
|
36
|
+
leaves: string;
|
|
37
|
+
inactivePeaks: number;
|
|
38
|
+
digests: Hex[];
|
|
39
|
+
}
|
|
40
|
+
export interface ClearingBalance {
|
|
41
|
+
state: Hex;
|
|
42
|
+
amount: string;
|
|
43
|
+
opening:
|
|
44
|
+
| null
|
|
45
|
+
| (ClearingLogProof & {
|
|
46
|
+
amount: string;
|
|
47
|
+
nextKey: Hex;
|
|
48
|
+
chunk: Hex;
|
|
49
|
+
opsRoot: Hex;
|
|
50
|
+
pending: Hex;
|
|
51
|
+
partialDigest: Hex;
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
export function clearingLogProof<T extends ClearingLogProof>(proof: T) {
|
|
55
|
+
return {
|
|
56
|
+
...proof,
|
|
57
|
+
location: BigInt(proof.location),
|
|
58
|
+
leaves: BigInt(proof.leaves),
|
|
59
|
+
inactivePeaks: BigInt(proof.inactivePeaks),
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
/** Authorize an epoch-tail close to a fixed EVM destination. */
|
|
63
|
+
export async function clearingClose(
|
|
64
|
+
signer: ClearingSigner,
|
|
65
|
+
deployment: Hex,
|
|
66
|
+
balance: ClearingBalance,
|
|
67
|
+
destination: Hex,
|
|
68
|
+
deadline: bigint,
|
|
69
|
+
) {
|
|
70
|
+
if (!balance.opening) throw new Error("no retained balance proof");
|
|
71
|
+
const body = concatHex([
|
|
72
|
+
deployment,
|
|
73
|
+
balance.state,
|
|
74
|
+
"0x14",
|
|
75
|
+
destination,
|
|
76
|
+
"0x02",
|
|
77
|
+
clearingUint64(deadline),
|
|
78
|
+
]);
|
|
79
|
+
const { amount, nextKey, ...proof } = balance.opening;
|
|
80
|
+
return {
|
|
81
|
+
withdrawal: {
|
|
82
|
+
account: signer.publicKey,
|
|
83
|
+
deployment,
|
|
84
|
+
state: balance.state,
|
|
85
|
+
destination,
|
|
86
|
+
amount: 0n,
|
|
87
|
+
deadline,
|
|
88
|
+
signature: await signer.sign(
|
|
89
|
+
"_COMMONWARE_CLEARING_BOUNDARY_WITHDRAWAL",
|
|
90
|
+
body,
|
|
91
|
+
),
|
|
92
|
+
},
|
|
93
|
+
opening: {
|
|
94
|
+
amount: BigInt(amount),
|
|
95
|
+
nextKey,
|
|
96
|
+
proof: clearingLogProof(proof),
|
|
97
|
+
},
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** The most the Clearing contract holds in all, in clearing units: it
|
|
102
|
+
* counts `uint64` units of `Clearing.unit()` token base units each, a
|
|
103
|
+
* millionth of the token (its base unit at six decimals or fewer). */
|
|
104
|
+
export const CLEARING_MAX_AMOUNT = 2n ** 64n - 1n;
|
|
105
|
+
|
|
106
|
+
async function serviceGet<T>(
|
|
107
|
+
serviceUrl: string,
|
|
108
|
+
path: string,
|
|
109
|
+
fetcher: typeof globalThis.fetch,
|
|
110
|
+
): Promise<T> {
|
|
111
|
+
const response = await fetcher(new URL(path, serviceUrl), {
|
|
112
|
+
signal: AbortSignal.timeout(30_000),
|
|
113
|
+
});
|
|
114
|
+
if (!response.ok)
|
|
115
|
+
throw new Error(`clearing service returned ${response.status} for ${path}`);
|
|
116
|
+
return (await response.json()) as T;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** What `account` can still spend in the open epoch, in the token's base
|
|
120
|
+
* units: the operator's estimate from the balance and deposits it has
|
|
121
|
+
* counted and the payments it has acknowledged. */
|
|
122
|
+
export async function clearingSpendable(opts: {
|
|
123
|
+
rpcUrl: string;
|
|
124
|
+
settlement: Address;
|
|
125
|
+
serviceUrl: string;
|
|
126
|
+
account: Hex;
|
|
127
|
+
fetch?: typeof globalThis.fetch;
|
|
128
|
+
}): Promise<bigint> {
|
|
129
|
+
const pub = createPublicClient({ transport: http(opts.rpcUrl) });
|
|
130
|
+
const [{ amount }, unit] = await Promise.all([
|
|
131
|
+
serviceGet<{ amount: string }>(
|
|
132
|
+
opts.serviceUrl,
|
|
133
|
+
`/spendable/${opts.account}`,
|
|
134
|
+
opts.fetch ?? globalThis.fetch,
|
|
135
|
+
),
|
|
136
|
+
pub.readContract({ address: opts.settlement, abi: clearingAbi, functionName: "unit" }),
|
|
137
|
+
]);
|
|
138
|
+
// The service counts clearing units.
|
|
139
|
+
return BigInt(amount) * unit;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** `account`'s finalized balance in clearing units and, when the service
|
|
143
|
+
* retains one, the proof `withdrawFromClearing` closes it with. */
|
|
144
|
+
export function clearingBalance(
|
|
145
|
+
serviceUrl: string,
|
|
146
|
+
account: Hex,
|
|
147
|
+
fetcher: typeof globalThis.fetch = globalThis.fetch,
|
|
148
|
+
): Promise<ClearingBalance> {
|
|
149
|
+
return serviceGet<ClearingBalance>(serviceUrl, `/balance/${account}`, fetcher);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export interface ClearingChain {
|
|
153
|
+
rpcUrl: string;
|
|
154
|
+
chainId: number;
|
|
155
|
+
/** The EVM account that sends the transactions and pays their gas. */
|
|
156
|
+
from: LocalAccount;
|
|
157
|
+
settlement: Address;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** What a node answers when it refuses a transaction outright, leaving
|
|
161
|
+
* nothing in its pool. */
|
|
162
|
+
const REFUSALS = [
|
|
163
|
+
InsufficientFundsError,
|
|
164
|
+
FeeCapTooLowError,
|
|
165
|
+
FeeCapTooHighError,
|
|
166
|
+
TipAboveFeeCapError,
|
|
167
|
+
IntrinsicGasTooLowError,
|
|
168
|
+
IntrinsicGasTooHighError,
|
|
169
|
+
NonceTooHighError,
|
|
170
|
+
NonceMaxValueError,
|
|
171
|
+
TransactionTypeNotSupportedError,
|
|
172
|
+
];
|
|
173
|
+
|
|
174
|
+
/** Gas and fees are set here: on Metal, a fee-carrying `eth_estimateGas`
|
|
175
|
+
* from an account with no native balance fails. */
|
|
176
|
+
function chainClients({ rpcUrl, chainId, from, timeoutMs = 60_000, pollMs = 500 }: ClearingChain & { timeoutMs?: number; pollMs?: number }) {
|
|
177
|
+
const transport = http(rpcUrl);
|
|
178
|
+
const pub = createPublicClient({ transport });
|
|
179
|
+
const wallet = createWalletClient({ account: from, transport });
|
|
180
|
+
// The dry run first, under the gas the transaction is sent with, so a
|
|
181
|
+
// refusal comes back with its reason. The transaction is signed here, so
|
|
182
|
+
// its hash is known before it is broadcast: once broadcast, any failure to
|
|
183
|
+
// learn its outcome leaves it in doubt, named, and never sent again here.
|
|
184
|
+
const send = async (
|
|
185
|
+
call: { address: Address; abi: Abi; functionName: string; args: readonly unknown[] },
|
|
186
|
+
gas: bigint,
|
|
187
|
+
): Promise<TransactionReceipt> => {
|
|
188
|
+
await pub.simulateContract({ ...call, account: from.address, gas } as Parameters<typeof pub.simulateContract>[0]);
|
|
189
|
+
const [block, nonce] = await Promise.all([
|
|
190
|
+
pub.getBlock({ blockTag: "latest" }),
|
|
191
|
+
pub.getTransactionCount({ address: from.address, blockTag: "pending" }),
|
|
192
|
+
]);
|
|
193
|
+
const raw = await wallet.signTransaction({
|
|
194
|
+
chain: null,
|
|
195
|
+
chainId,
|
|
196
|
+
to: call.address,
|
|
197
|
+
data: encodeFunctionData(call as Parameters<typeof encodeFunctionData>[0]),
|
|
198
|
+
value: 0n,
|
|
199
|
+
gas,
|
|
200
|
+
maxFeePerGas: (block.baseFeePerGas ?? 0n) * 2n + 1n,
|
|
201
|
+
maxPriorityFeePerGas: 1n,
|
|
202
|
+
nonce,
|
|
203
|
+
type: "eip1559",
|
|
204
|
+
});
|
|
205
|
+
const hash = keccak256(raw);
|
|
206
|
+
// The node's error, if any, rides along: a refusal its hash cannot show.
|
|
207
|
+
const inDoubt = (cause: unknown) =>
|
|
208
|
+
new Error(`${call.functionName} ${hash} with nonce ${nonce} was sent and its outcome is unknown: look it up before sending it again`, {
|
|
209
|
+
cause,
|
|
210
|
+
});
|
|
211
|
+
await pub.sendRawTransaction({ serializedTransaction: raw }).catch((error: unknown) => {
|
|
212
|
+
const refusal = error instanceof BaseError ? getNodeError(error, {}) : undefined;
|
|
213
|
+
if (REFUSALS.some((type) => refusal instanceof type))
|
|
214
|
+
throw new Error(`the node refused ${call.functionName}: ${refusal!.shortMessage}`, { cause: error });
|
|
215
|
+
throw inDoubt(error);
|
|
216
|
+
});
|
|
217
|
+
// A failed read of the receipt is not yet, until the deadline.
|
|
218
|
+
const deadline = Date.now() + timeoutMs;
|
|
219
|
+
let unread: unknown;
|
|
220
|
+
for (;;) {
|
|
221
|
+
const receipt = await pub.getTransactionReceipt({ hash }).catch((error: unknown) => {
|
|
222
|
+
unread = error;
|
|
223
|
+
return null;
|
|
224
|
+
});
|
|
225
|
+
if (receipt) return receipt;
|
|
226
|
+
if (Date.now() >= deadline) throw inDoubt(unread);
|
|
227
|
+
await new Promise((resolve) => setTimeout(resolve, pollMs));
|
|
228
|
+
}
|
|
229
|
+
};
|
|
230
|
+
return { pub, send };
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function succeeded(functionName: string, receipt: TransactionReceipt): Hex {
|
|
234
|
+
if (receipt.status !== "success") throw new Error(`${functionName} reverted: ${receipt.transactionHash}`);
|
|
235
|
+
return receipt.transactionHash;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** Put `amount` token base units into clearing custody under `account`, a
|
|
239
|
+
* clearing key; it must be a whole number of the contract's clearing
|
|
240
|
+
* units. `from` pays: an approval for the amount unless one that covers it
|
|
241
|
+
* stands, then the deposit. The contract takes deposits only between
|
|
242
|
+
* registrations, so the deposit waits for that window and is sent again
|
|
243
|
+
* if the window closes under it, up to `timeoutMs` (default 60 s). It
|
|
244
|
+
* refuses at once a contract that holds another token, and a deposit past
|
|
245
|
+
* what the contract holds in all. A deposit whose outcome is unknown is not
|
|
246
|
+
* sent again: the error names it. */
|
|
247
|
+
export async function depositToClearing(
|
|
248
|
+
opts: ClearingChain & {
|
|
249
|
+
asset: Address;
|
|
250
|
+
account: Hex;
|
|
251
|
+
amount: bigint;
|
|
252
|
+
timeoutMs?: number;
|
|
253
|
+
pollMs?: number;
|
|
254
|
+
},
|
|
255
|
+
): Promise<{ approveTx: Hex | null; depositTx: Hex }> {
|
|
256
|
+
if (opts.amount <= 0n) throw new Error("deposit amount must be positive");
|
|
257
|
+
const { pub, send } = chainClients(opts);
|
|
258
|
+
const contract = { address: opts.settlement, abi: clearingAbi } as const;
|
|
259
|
+
const [token, unit] = await Promise.all([
|
|
260
|
+
pub.readContract({ ...contract, functionName: "token" }),
|
|
261
|
+
pub.readContract({ ...contract, functionName: "unit" }),
|
|
262
|
+
]);
|
|
263
|
+
if (token.toLowerCase() !== opts.asset.toLowerCase())
|
|
264
|
+
throw new Error(`this Clearing contract holds ${token}, not ${opts.asset}`);
|
|
265
|
+
// The contract counts clearing units; the approval moves base units.
|
|
266
|
+
const units = clearingUnits(opts.amount, unit);
|
|
267
|
+
// The contract refuses a deposit past this, with the same error as a closed window.
|
|
268
|
+
const assertRoom = async () => {
|
|
269
|
+
const [active, reserve] = await Promise.all([
|
|
270
|
+
pub.readContract({ ...contract, functionName: "activeCustody" }),
|
|
271
|
+
pub.readContract({ ...contract, functionName: "claimReserve" }),
|
|
272
|
+
]);
|
|
273
|
+
const room = CLEARING_MAX_AMOUNT - active - reserve;
|
|
274
|
+
if (units > room)
|
|
275
|
+
throw new Error(
|
|
276
|
+
`this Clearing contract holds at most ${CLEARING_MAX_AMOUNT * unit} base units in all, and has room for ${room * unit} more`,
|
|
277
|
+
);
|
|
278
|
+
};
|
|
279
|
+
await assertRoom();
|
|
280
|
+
const allowance = await pub.readContract({
|
|
281
|
+
address: opts.asset,
|
|
282
|
+
abi: erc20Abi,
|
|
283
|
+
functionName: "allowance",
|
|
284
|
+
args: [opts.from.address, opts.settlement],
|
|
285
|
+
});
|
|
286
|
+
const approveTx =
|
|
287
|
+
allowance >= opts.amount
|
|
288
|
+
? null
|
|
289
|
+
: succeeded(
|
|
290
|
+
"approve",
|
|
291
|
+
await send({ address: opts.asset, abi: erc20Abi, functionName: "approve", args: [opts.settlement, opts.amount] }, 100_000n),
|
|
292
|
+
);
|
|
293
|
+
const deposit = { ...contract, functionName: "deposit", args: [opts.account, units] } as const;
|
|
294
|
+
// About 450k on Metal, which charges 64 times Ethereum's gas for the modexp
|
|
295
|
+
// that decompresses the account's key.
|
|
296
|
+
const depositGas = 1_000_000n;
|
|
297
|
+
const deadline = Date.now() + (opts.timeoutMs ?? 60_000);
|
|
298
|
+
// Only the closed window is waited out; any other refusal is the caller's.
|
|
299
|
+
// A full contract reverts the same way, and `assertRoom` tells them apart.
|
|
300
|
+
const windowClosed = (error: unknown) =>
|
|
301
|
+
error instanceof BaseError &&
|
|
302
|
+
error.walk((e) => e instanceof ContractFunctionRevertedError && e.data?.errorName === "InvalidBoundary") !== null;
|
|
303
|
+
for (;;) {
|
|
304
|
+
// A deposit that reverts deposited nothing, and the next dry run says
|
|
305
|
+
// why: if the window closed under it, the next round waits for the next
|
|
306
|
+
// one; any other reason is named with the reverted deposit.
|
|
307
|
+
try {
|
|
308
|
+
const receipt = await send(deposit, depositGas);
|
|
309
|
+
if (receipt.status === "success") return { approveTx, depositTx: receipt.transactionHash };
|
|
310
|
+
const why = await pub
|
|
311
|
+
.simulateContract({ ...deposit, account: opts.from.address, gas: depositGas } as Parameters<typeof pub.simulateContract>[0])
|
|
312
|
+
.then(() => undefined, (error: unknown) => error);
|
|
313
|
+
if (!windowClosed(why))
|
|
314
|
+
throw new Error(
|
|
315
|
+
`deposit ${receipt.transactionHash} reverted${why instanceof BaseError ? `: ${why.shortMessage}` : ", and its dry run now passes"}`,
|
|
316
|
+
);
|
|
317
|
+
} catch (error) {
|
|
318
|
+
if (!windowClosed(error)) throw error;
|
|
319
|
+
}
|
|
320
|
+
await assertRoom();
|
|
321
|
+
if (Date.now() >= deadline) throw new Error("the Clearing contract took no deposits before the timeout");
|
|
322
|
+
await new Promise((resolve) => setTimeout(resolve, opts.pollMs ?? 500));
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
const accountKey = (account: Hex) =>
|
|
327
|
+
sha256(concatHex([stringToHex("_COMMONWARE_CLEARING_ACCOUNT_KEY"), account]));
|
|
328
|
+
|
|
329
|
+
/** A payout claimed, `amount` in token base units. */
|
|
330
|
+
type ClearingClaim = { amount: bigint; tx: Hex; deferred: boolean };
|
|
331
|
+
type ClearingPayout = { destination: Hex; amount: string; proof: ClearingLogProof };
|
|
332
|
+
|
|
333
|
+
/** `account`'s payout of its withdrawal in the finalized epoch `epoch` to
|
|
334
|
+
* `destination`, the one the withdrawal named, from the first of
|
|
335
|
+
* `serviceUrls` that proves it. */
|
|
336
|
+
async function lookupPayout(
|
|
337
|
+
serviceUrls: readonly string[],
|
|
338
|
+
fetcher: typeof globalThis.fetch,
|
|
339
|
+
epoch: bigint,
|
|
340
|
+
account: Hex,
|
|
341
|
+
destination: Address,
|
|
342
|
+
): Promise<ClearingPayout> {
|
|
343
|
+
const failures: string[] = [];
|
|
344
|
+
for (const serviceUrl of serviceUrls) {
|
|
345
|
+
try {
|
|
346
|
+
const payout = await serviceGet<ClearingPayout | null>(serviceUrl, `/payout/${epoch}/${account}`, fetcher);
|
|
347
|
+
if (!payout) failures.push(`${serviceUrl} has none`);
|
|
348
|
+
else if (payout.destination.toLowerCase() !== destination.toLowerCase())
|
|
349
|
+
failures.push(`${serviceUrl} names ${payout.destination}, not ${destination}, the destination the withdrawal named`);
|
|
350
|
+
else return payout;
|
|
351
|
+
} catch (error) {
|
|
352
|
+
failures.push(`${serviceUrl}: ${error instanceof Error ? error.message : String(error)}`);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
throw new Error(
|
|
356
|
+
`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("; ")})`,
|
|
357
|
+
);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/** Claims `payout` to `destination` unless it is 0 or claimed already. The
|
|
361
|
+
* claim is pushed to `claimed` as it lands. */
|
|
362
|
+
async function claimPayout(
|
|
363
|
+
{ pub, send, settlement, unit }: ReturnType<typeof chainClients> & { settlement: Address; unit: bigint },
|
|
364
|
+
payout: ClearingPayout,
|
|
365
|
+
destination: Address,
|
|
366
|
+
claimed: ClearingClaim[],
|
|
367
|
+
): Promise<void> {
|
|
368
|
+
const contract = { address: settlement, abi: clearingAbi } as const;
|
|
369
|
+
if (BigInt(payout.amount) === 0n) return;
|
|
370
|
+
const position = BigInt(payout.proof.location);
|
|
371
|
+
const consumed = await pub.readContract({ ...contract, functionName: "claimedPayouts", args: [position >> 8n] });
|
|
372
|
+
if (consumed & (1n << (position & 255n))) return;
|
|
373
|
+
const receipt = await send(
|
|
374
|
+
{ ...contract, functionName: "claim", args: [destination, BigInt(payout.amount), clearingLogProof(payout.proof)] },
|
|
375
|
+
1_000_000n,
|
|
376
|
+
);
|
|
377
|
+
const tx = succeeded("claim", receipt);
|
|
378
|
+
const deferred = parseEventLogs({ abi: clearingAbi, eventName: "PayoutDeferred", logs: receipt.logs }).length > 0;
|
|
379
|
+
claimed.push({ amount: BigInt(payout.amount) * unit, tx, deferred });
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
export type ClearingWithdrawal =
|
|
383
|
+
/** A withdrawal is queued and `epoch`, the one that carries it, has not
|
|
384
|
+
* finalized; nothing can be sent until it does. */
|
|
385
|
+
| { status: "waiting"; epoch: bigint }
|
|
386
|
+
/** What this call sent: the account's payout it claimed, to the
|
|
387
|
+
* destination the finalized withdrawal named, and the withdrawal it
|
|
388
|
+
* queued for the account's finalized balance, or `null` when that balance
|
|
389
|
+
* is zero. A
|
|
390
|
+
* deferred payout is one the destination refused: it waits in the
|
|
391
|
+
* contract's `unclaimedPayout` until the destination calls
|
|
392
|
+
* `withdrawUnclaimed`. */
|
|
393
|
+
| { status: "sent"; claimed: ClearingClaim[]; queued: { tx: Hex } | null };
|
|
394
|
+
|
|
395
|
+
/** Take `signer`'s funds out of clearing: claim the account's payout of a
|
|
396
|
+
* withdrawal that has finalized, to the destination it named, then queue a
|
|
397
|
+
* withdrawal of whatever finalized balance remains, to `destination` (`from` itself
|
|
398
|
+
* unless given). A payer's unused deposit and a seller's earnings leave the
|
|
399
|
+
* same way. Call again after the queued withdrawal's epoch finalizes. A
|
|
400
|
+
* failure after a claim names the claims made before it. */
|
|
401
|
+
export async function withdrawFromClearing(
|
|
402
|
+
opts: ClearingChain & {
|
|
403
|
+
signer: ClearingSigner;
|
|
404
|
+
serviceUrl: string;
|
|
405
|
+
destination?: Address;
|
|
406
|
+
fetch?: typeof globalThis.fetch;
|
|
407
|
+
},
|
|
408
|
+
): Promise<ClearingWithdrawal> {
|
|
409
|
+
const { pub, send } = chainClients(opts);
|
|
410
|
+
const fetcher = opts.fetch ?? globalThis.fetch;
|
|
411
|
+
const destination = opts.destination ?? opts.from.address;
|
|
412
|
+
const contract = { address: opts.settlement, abi: clearingAbi } as const;
|
|
413
|
+
const key = accountKey(opts.signer.publicKey);
|
|
414
|
+
if ((await pub.readContract({ ...contract, functionName: "fault" })) !== 0)
|
|
415
|
+
throw new Error("this Clearing deployment has faulted and takes no withdrawals: take the account's funds out with recoverFromClearing");
|
|
416
|
+
const [status, finalized, unit] = await Promise.all([
|
|
417
|
+
pub.readContract({ ...contract, functionName: "withdrawals", args: [key] }),
|
|
418
|
+
pub.readContract({ ...contract, functionName: "finalizedCount" }),
|
|
419
|
+
pub.readContract({ ...contract, functionName: "unit" }),
|
|
420
|
+
]);
|
|
421
|
+
const [encodedHash, epochPlusOne, , , queuedTo] = status;
|
|
422
|
+
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
|
+
const [registered, admitted] = await Promise.all([
|
|
426
|
+
pub.readContract({ ...contract, functionName: "registered" }),
|
|
427
|
+
pub.readContract({ ...contract, functionName: "admittedCount" }),
|
|
428
|
+
]);
|
|
429
|
+
return { status: "waiting", epoch: registered ? admitted + 1n : admitted };
|
|
430
|
+
}
|
|
431
|
+
const claimed: ClearingClaim[] = [];
|
|
432
|
+
try {
|
|
433
|
+
return await claimThenQueue();
|
|
434
|
+
} catch (error) {
|
|
435
|
+
if (claimed.length === 0) throw error;
|
|
436
|
+
const made = claimed.map((c) => `${c.amount} in ${c.tx}`).join(", ");
|
|
437
|
+
throw new Error(`${error instanceof Error ? error.message : String(error)}; claimed before it: ${made}`, { cause: error });
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
async function claimThenQueue(): Promise<ClearingWithdrawal> {
|
|
441
|
+
if (epochPlusOne > 0n)
|
|
442
|
+
await claimPayout(
|
|
443
|
+
{ pub, send, settlement: opts.settlement, unit },
|
|
444
|
+
await lookupPayout([opts.serviceUrl], fetcher, epochPlusOne - 1n, opts.signer.publicKey, queuedTo),
|
|
445
|
+
queuedTo,
|
|
446
|
+
claimed,
|
|
447
|
+
);
|
|
448
|
+
// The service proves balances only against a finalized epoch.
|
|
449
|
+
if (finalized === 0n) return { status: "sent", claimed, queued: null };
|
|
450
|
+
const balance = await clearingBalance(opts.serviceUrl, opts.signer.publicKey, fetcher);
|
|
451
|
+
if (BigInt(balance.amount) === 0n || !balance.opening) return { status: "sent", claimed, queued: null };
|
|
452
|
+
const [deployment, policy, block] = await Promise.all([
|
|
453
|
+
pub.readContract({ ...contract, functionName: "deployment" }),
|
|
454
|
+
pub.readContract({ ...contract, functionName: "policy" }),
|
|
455
|
+
pub.getBlock(),
|
|
456
|
+
]);
|
|
457
|
+
const close = await clearingClose(opts.signer, deployment, balance, destination, block.timestamp + policy[5]);
|
|
458
|
+
// The balance is proven against the finalized state: an epoch that
|
|
459
|
+
// finalizes before the withdrawal lands moves it, and the contract
|
|
460
|
+
// refuses the withdrawal with `InvalidBoundary`.
|
|
461
|
+
const receipt = await send({ ...contract, functionName: "queueWithdrawal", args: [close.withdrawal, close.opening] }, 2_000_000n).catch(
|
|
462
|
+
(error: unknown) => {
|
|
463
|
+
if (revertOf(error) !== "InvalidBoundary") throw error;
|
|
464
|
+
throw new Error("the withdrawal was refused because an epoch finalized under it: call withdrawFromClearing again", { cause: error });
|
|
465
|
+
},
|
|
466
|
+
);
|
|
467
|
+
return { status: "sent", claimed, queued: { tx: succeeded("queueWithdrawal", receipt) } };
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
export type ClearingRecovery =
|
|
472
|
+
/** The deployment has missed no deadline: withdraw with `withdrawFromClearing`. */
|
|
473
|
+
| { status: "operating" }
|
|
474
|
+
/** Faulted, and `epoch`, admitted before the fault, is still in its
|
|
475
|
+
* challenge window; recovery starts once it is final. `claimed` is the
|
|
476
|
+
* payout of the account's finalized withdrawal, claimed meanwhile. */
|
|
477
|
+
| { status: "waiting"; epoch: bigint; claimed: ClearingClaim[] }
|
|
478
|
+
/** What this call sent: the fault it raised, if it raised one, the
|
|
479
|
+
* payout of the account's finalized withdrawal, claimed to the
|
|
480
|
+
* destination it named, the account's last finalized balance, and each
|
|
481
|
+
* deposit no final epoch counted. `recover` pays a withdrawal still in
|
|
482
|
+
* flight to the destination it named and the rest to `destination`. A
|
|
483
|
+
* deferred payout waits in `unclaimedPayout` for `withdrawUnclaimed`.
|
|
484
|
+
* Every amount is in token base units. */
|
|
485
|
+
| {
|
|
486
|
+
status: "recovered";
|
|
487
|
+
faulted: Hex | null;
|
|
488
|
+
claimed: ClearingClaim[];
|
|
489
|
+
recovered: { amount: bigint; tx: Hex } | null;
|
|
490
|
+
refunded: { epoch: bigint; amount: bigint; tx: Hex }[];
|
|
491
|
+
};
|
|
492
|
+
|
|
493
|
+
const revertOf = (error: unknown): string | undefined => {
|
|
494
|
+
if (!(error instanceof BaseError)) return undefined;
|
|
495
|
+
const reverted = error.walk((e) => e instanceof ContractFunctionRevertedError);
|
|
496
|
+
return reverted instanceof ContractFunctionRevertedError ? (reverted.data?.errorName ?? "reverted") : undefined;
|
|
497
|
+
};
|
|
498
|
+
|
|
499
|
+
/** Take `signer`'s funds out of a Clearing deployment whose service has
|
|
500
|
+
* stopped. Once the service has missed a deadline, to admit an epoch it
|
|
501
|
+
* registered, a deposit, or the account's queued withdrawal, this faults
|
|
502
|
+
* the deployment, finalizes the epochs it admitted, starts recovery, and
|
|
503
|
+
* takes the payout of the account's finalized withdrawal, its last
|
|
504
|
+
* finalized balance and every deposit no final epoch counted, to
|
|
505
|
+
* `destination` (`from` itself unless given) or, for a withdrawal, to the
|
|
506
|
+
* destination it named. The payout and balance proofs come from
|
|
507
|
+
* `serviceUrl`, the clearing service or any of its validators, or the
|
|
508
|
+
* first of several that proves each. A proof none of them gives does not
|
|
509
|
+
* stop the steps that need none: they are taken, and then the error names
|
|
510
|
+
* what is left and what was sent. Each step already taken is skipped, so
|
|
511
|
+
* call it again after `waiting` or such an error. `from` pays the gas. */
|
|
512
|
+
export async function recoverFromClearing(
|
|
513
|
+
opts: ClearingChain & {
|
|
514
|
+
signer: ClearingSigner;
|
|
515
|
+
serviceUrl: string | readonly string[];
|
|
516
|
+
destination?: Address;
|
|
517
|
+
fetch?: typeof globalThis.fetch;
|
|
518
|
+
},
|
|
519
|
+
): Promise<ClearingRecovery> {
|
|
520
|
+
const serviceUrls = typeof opts.serviceUrl === "string" ? [opts.serviceUrl] : opts.serviceUrl;
|
|
521
|
+
if (serviceUrls.length === 0) throw new Error("recoverFromClearing needs a clearing service URL");
|
|
522
|
+
// A proof no service gives does not stop the steps that need none; the
|
|
523
|
+
// answer then names it, and what was sent.
|
|
524
|
+
let unclaimed: unknown;
|
|
525
|
+
let unproven: unknown;
|
|
526
|
+
const finish = (result: ClearingRecovery & { status: "waiting" | "recovered" }): ClearingRecovery => {
|
|
527
|
+
const missed = [unclaimed, unproven].filter((error) => error !== undefined);
|
|
528
|
+
if (missed.length === 0) return result;
|
|
529
|
+
const sent = [
|
|
530
|
+
result.status === "recovered" && result.faulted && `fault ${result.faulted}`,
|
|
531
|
+
...result.claimed.map((c) => `claim ${c.tx}${c.deferred ? " (deferred: the destination calls withdrawUnclaimed)" : ""}`),
|
|
532
|
+
...(result.status === "recovered"
|
|
533
|
+
? [result.recovered && `recover ${result.recovered.tx}`, ...result.refunded.map((r) => `refundDeposit ${r.tx}`)]
|
|
534
|
+
: []),
|
|
535
|
+
].filter(Boolean);
|
|
536
|
+
throw new Error(
|
|
537
|
+
`${missed.map((error) => (error instanceof Error ? error.message : String(error))).join("; ")}. ${
|
|
538
|
+
result.status === "waiting" ? `Epoch ${result.epoch} is still in its challenge window. ` : ""
|
|
539
|
+
}This call sent ${sent.length > 0 ? sent.join(", ") : "nothing"}: call recoverFromClearing again to take the rest`,
|
|
540
|
+
{ cause: missed[0] },
|
|
541
|
+
);
|
|
542
|
+
};
|
|
543
|
+
const { pub, send } = chainClients(opts);
|
|
544
|
+
const fetcher = opts.fetch ?? globalThis.fetch;
|
|
545
|
+
const destination = opts.destination ?? opts.from.address;
|
|
546
|
+
const contract = { address: opts.settlement, abi: clearingAbi } as const;
|
|
547
|
+
const account = opts.signer.publicKey;
|
|
548
|
+
const key = accountKey(account);
|
|
549
|
+
const unit = await pub.readContract({ ...contract, functionName: "unit" });
|
|
550
|
+
let faulted: Hex | null = null;
|
|
551
|
+
if ((await pub.readContract({ ...contract, functionName: "fault" })) === 0) {
|
|
552
|
+
const admitted = await pub.readContract({ ...contract, functionName: "admittedCount" });
|
|
553
|
+
const faults = [
|
|
554
|
+
{ functionName: "faultRegistration", args: [] },
|
|
555
|
+
{ functionName: "faultWithdrawal", args: [account] },
|
|
556
|
+
{ functionName: "faultDeposit", args: [admitted, account] },
|
|
557
|
+
] as const;
|
|
558
|
+
for (const call of faults) {
|
|
559
|
+
try {
|
|
560
|
+
faulted = succeeded(call.functionName, await send({ ...contract, ...call }, 200_000n));
|
|
561
|
+
break;
|
|
562
|
+
} catch (error) {
|
|
563
|
+
// Each fault reverts `InvalidDeadline` until its deadline passes; any
|
|
564
|
+
// other refusal, running out of gas among them, is an error.
|
|
565
|
+
if (revertOf(error) !== "InvalidDeadline") throw error;
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
if (!faulted) return { status: "operating" };
|
|
569
|
+
}
|
|
570
|
+
// A withdrawal whose epoch finalized is paid by `claim`, not `recover`,
|
|
571
|
+
// and needs no recovery: it is claimed first, and again once this call
|
|
572
|
+
// finalizes the epoch that carries it.
|
|
573
|
+
const claimed: ClearingClaim[] = [];
|
|
574
|
+
const claimFinalized = async () => {
|
|
575
|
+
const [[, epochPlusOne, , , queuedTo], finalized] = await Promise.all([
|
|
576
|
+
pub.readContract({ ...contract, functionName: "withdrawals", args: [key] }),
|
|
577
|
+
pub.readContract({ ...contract, functionName: "finalizedCount" }),
|
|
578
|
+
]);
|
|
579
|
+
unclaimed = undefined;
|
|
580
|
+
if (epochPlusOne === 0n || epochPlusOne > finalized) return;
|
|
581
|
+
// `recover` does not pay a finalized withdrawal, so a later call claims it.
|
|
582
|
+
const payout = await lookupPayout(serviceUrls, fetcher, epochPlusOne - 1n, account, queuedTo).catch((error: unknown) => {
|
|
583
|
+
unclaimed = error;
|
|
584
|
+
});
|
|
585
|
+
if (payout) await claimPayout({ pub, send, settlement: opts.settlement, unit }, payout, queuedTo, claimed);
|
|
586
|
+
};
|
|
587
|
+
await claimFinalized();
|
|
588
|
+
if (!(await pub.readContract({ ...contract, functionName: "recovering" }))) {
|
|
589
|
+
for (;;) {
|
|
590
|
+
const [finalized, admitted, invalidFrom] = await Promise.all([
|
|
591
|
+
pub.readContract({ ...contract, functionName: "finalizedCount" }),
|
|
592
|
+
pub.readContract({ ...contract, functionName: "admittedCount" }),
|
|
593
|
+
pub.readContract({ ...contract, functionName: "invalidFrom" }),
|
|
594
|
+
]);
|
|
595
|
+
if (finalized >= admitted || finalized >= invalidFrom) break;
|
|
596
|
+
try {
|
|
597
|
+
succeeded("finalize", await send({ ...contract, functionName: "finalize", args: [] }, 2_000_000n));
|
|
598
|
+
} catch (error) {
|
|
599
|
+
if (revertOf(error) === "InvalidDeadline") return finish({ status: "waiting", epoch: finalized, claimed });
|
|
600
|
+
throw error;
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
try {
|
|
604
|
+
succeeded("beginRecovery", await send({ ...contract, functionName: "beginRecovery", args: [] }, 200_000n));
|
|
605
|
+
} catch (error) {
|
|
606
|
+
// Someone else started it between the read and the dry run.
|
|
607
|
+
if (revertOf(error) === undefined || !(await pub.readContract({ ...contract, functionName: "recovering" }))) throw error;
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
await claimFinalized();
|
|
611
|
+
const [deployment, state, finalized, admitted, done] = await Promise.all([
|
|
612
|
+
pub.readContract({ ...contract, functionName: "deployment" }),
|
|
613
|
+
pub.readContract({ ...contract, functionName: "finalizedState" }),
|
|
614
|
+
pub.readContract({ ...contract, functionName: "finalizedCount" }),
|
|
615
|
+
pub.readContract({ ...contract, functionName: "admittedCount" }),
|
|
616
|
+
pub.readContract({ ...contract, functionName: "recoveredAccounts", args: [key] }),
|
|
617
|
+
]);
|
|
618
|
+
let recovered: { amount: bigint; tx: Hex } | null = null;
|
|
619
|
+
const balance = !done && finalized > 0n ? await provenBalance(state).catch((error: unknown) => void (unproven = error)) : undefined;
|
|
620
|
+
if (balance) {
|
|
621
|
+
if (BigInt(balance.amount) > 0n && balance.opening) {
|
|
622
|
+
const { amount, nextKey, ...proof } = balance.opening;
|
|
623
|
+
const signature = await opts.signer.sign("_METAL_CLEARING_RECOVERY", concatHex([deployment, state, destination]));
|
|
624
|
+
const receipt = await send(
|
|
625
|
+
{
|
|
626
|
+
...contract,
|
|
627
|
+
functionName: "recover",
|
|
628
|
+
args: [account, destination, signature, { amount: BigInt(amount), nextKey, proof: clearingLogProof(proof) }],
|
|
629
|
+
},
|
|
630
|
+
2_000_000n,
|
|
631
|
+
);
|
|
632
|
+
recovered = { amount: BigInt(balance.amount) * unit, tx: succeeded("recover", receipt) };
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
const refunded: { epoch: bigint; amount: bigint; tx: Hex }[] = [];
|
|
636
|
+
for (let epoch = finalized; epoch <= admitted; epoch++) {
|
|
637
|
+
const amount = await pub.readContract({ ...contract, functionName: "depositAmounts", args: [epoch, key] });
|
|
638
|
+
if (amount === 0n) continue;
|
|
639
|
+
const signature = await opts.signer.sign(
|
|
640
|
+
"_METAL_CLEARING_DEPOSIT_REFUND",
|
|
641
|
+
concatHex([deployment, clearingUint64(epoch), clearingUint64(amount), destination]),
|
|
642
|
+
);
|
|
643
|
+
// About 760k on Metal, which charges 64 times Ethereum's gas for P-256 and modexp.
|
|
644
|
+
const receipt = await send({ ...contract, functionName: "refundDeposit", args: [epoch, account, destination, signature] }, 1_200_000n);
|
|
645
|
+
refunded.push({ epoch, amount: amount * unit, tx: succeeded("refundDeposit", receipt) });
|
|
646
|
+
}
|
|
647
|
+
return finish({ status: "recovered", faulted, claimed, recovered, refunded });
|
|
648
|
+
|
|
649
|
+
/** The account's balance as of `state`, from the first service that proves it there. */
|
|
650
|
+
async function provenBalance(state: Hex): Promise<ClearingBalance> {
|
|
651
|
+
const failures: string[] = [];
|
|
652
|
+
for (const serviceUrl of serviceUrls) {
|
|
653
|
+
try {
|
|
654
|
+
const balance = await clearingBalance(serviceUrl, account, fetcher);
|
|
655
|
+
if (balance.state.toLowerCase() === state.toLowerCase()) return balance;
|
|
656
|
+
failures.push(`${serviceUrl} proves balances against ${balance.state}`);
|
|
657
|
+
} catch (error) {
|
|
658
|
+
failures.push(`${serviceUrl}: ${error instanceof Error ? error.message : String(error)}`);
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
throw new Error(
|
|
662
|
+
`no clearing service named proves this account's balance at the contract's finalized state ${state}; a validator proves it once it has retained the last finalized epoch (${failures.join("; ")})`,
|
|
663
|
+
);
|
|
664
|
+
}
|
|
665
|
+
}
|