@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,612 @@
|
|
|
1
|
+
import { BaseError, createPublicClient, type Hex } from "viem";
|
|
2
|
+
import { clearingAbi } from "./clearing-abi.js";
|
|
3
|
+
import {
|
|
4
|
+
CLEARING_REFERENCE,
|
|
5
|
+
clearingUnits,
|
|
6
|
+
decodeSend,
|
|
7
|
+
encodeSend,
|
|
8
|
+
invoiceMessage,
|
|
9
|
+
outgoingRoot,
|
|
10
|
+
verifyReceipt,
|
|
11
|
+
type ClearingInvoice,
|
|
12
|
+
type ClearingReceipt,
|
|
13
|
+
} from "./clearing-codec.js";
|
|
14
|
+
import { formatUsdz, rpcTransport, USDZ } from "./networks.js";
|
|
15
|
+
|
|
16
|
+
export interface ClearingSigner {
|
|
17
|
+
publicKey: Hex;
|
|
18
|
+
sign(namespace: string, message: Hex): Hex | Promise<Hex>;
|
|
19
|
+
}
|
|
20
|
+
export interface ClearingEntry {
|
|
21
|
+
recipient: Hex;
|
|
22
|
+
cumulative: string;
|
|
23
|
+
count: string;
|
|
24
|
+
}
|
|
25
|
+
export interface ClearingPayment {
|
|
26
|
+
invoice: ClearingInvoice;
|
|
27
|
+
body: Hex;
|
|
28
|
+
payerSignature: Hex;
|
|
29
|
+
invoiceSignature: Hex;
|
|
30
|
+
entries: ClearingEntry[];
|
|
31
|
+
}
|
|
32
|
+
export interface ClearingPaid {
|
|
33
|
+
invoice: ClearingInvoice;
|
|
34
|
+
receipt: ClearingReceipt;
|
|
35
|
+
invoiceSignature: Hex;
|
|
36
|
+
entries: ClearingEntry[];
|
|
37
|
+
previous?: { receipt: ClearingReceipt; entries: ClearingEntry[] };
|
|
38
|
+
}
|
|
39
|
+
export interface ClearingClientState {
|
|
40
|
+
epoch?: string | undefined;
|
|
41
|
+
entries: ClearingEntry[];
|
|
42
|
+
sequence: string;
|
|
43
|
+
/** The payment sent whose outcome is unknown, with the URL it pays for
|
|
44
|
+
* when `pay` was given one. */
|
|
45
|
+
pending?: (ClearingPayment & { url?: string | undefined }) | undefined;
|
|
46
|
+
payments: Record<string, ClearingPaid>;
|
|
47
|
+
}
|
|
48
|
+
/** save must complete an atomic durability barrier; one process owns each payer. */
|
|
49
|
+
export interface ClearingClientStore {
|
|
50
|
+
load(): Promise<ClearingClientState | null>;
|
|
51
|
+
save(state: ClearingClientState): Promise<void>;
|
|
52
|
+
}
|
|
53
|
+
export interface ClearingClientConfig {
|
|
54
|
+
serviceUrl: string;
|
|
55
|
+
rpcUrl: string;
|
|
56
|
+
network: `eip155:${number}`;
|
|
57
|
+
settlement: Hex;
|
|
58
|
+
asset: Hex;
|
|
59
|
+
signer: ClearingSigner;
|
|
60
|
+
store: ClearingClientStore;
|
|
61
|
+
maxAmount: bigint;
|
|
62
|
+
fetch?: typeof globalThis.fetch;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Why a clearing payment was not made. */
|
|
66
|
+
export type ClearingRefusalReason =
|
|
67
|
+
| "between_epochs"
|
|
68
|
+
| "insufficient_deposit"
|
|
69
|
+
| "payment_held"
|
|
70
|
+
| "answer_lost";
|
|
71
|
+
/** What to do after a `ClearingRefusal`. */
|
|
72
|
+
export type ClearingRetry =
|
|
73
|
+
/** Nothing was paid: send the request again after `ms`, and wait `ms`
|
|
74
|
+
* again after each further `between_epochs` refusal. */
|
|
75
|
+
| { action: "retry_after_ms"; ms: number }
|
|
76
|
+
/** Nothing was paid: deposit more, then send the request again. */
|
|
77
|
+
| { action: "deposit" }
|
|
78
|
+
/** The wallet holds a payment whose outcome is unknown, `amount` base
|
|
79
|
+
* units for `url` (`null` when `pay` was given none; through
|
|
80
|
+
* `fetchWithPayment`, the URL the request was sent to), and pays nothing
|
|
81
|
+
* else until it goes through: send that request again at that price
|
|
82
|
+
* after `ms`, which sends the same payment, so it is never paid twice. If
|
|
83
|
+
* that URL no longer offers clearing at that price, pass the wallet's
|
|
84
|
+
* `pendingInvoice()` to its `pay`, which sends the held payment itself
|
|
85
|
+
* once more; the wallet pays others once it goes through or is refused. */
|
|
86
|
+
| {
|
|
87
|
+
action: "repeat_same_request";
|
|
88
|
+
url: string | null;
|
|
89
|
+
amount: string;
|
|
90
|
+
ms: number;
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
/** A clearing payment the service did not take: `reason` says why, `retry`
|
|
94
|
+
* what to do, and the message both in words. */
|
|
95
|
+
export class ClearingRefusal extends Error {
|
|
96
|
+
override readonly name = "ClearingRefusal";
|
|
97
|
+
constructor(
|
|
98
|
+
readonly reason: ClearingRefusalReason,
|
|
99
|
+
readonly retry: ClearingRetry,
|
|
100
|
+
message: string,
|
|
101
|
+
options?: ErrorOptions,
|
|
102
|
+
) {
|
|
103
|
+
super(message, options);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** The service takes no payment from an epoch's close until the next
|
|
108
|
+
* epoch's registration, a gap of a few seconds. The wait between tries,
|
|
109
|
+
* and before a held payment is sent again, is the 2 s the service waits
|
|
110
|
+
* after an epoch's admission before registering the next, plus a block to
|
|
111
|
+
* include the registration. */
|
|
112
|
+
const BETWEEN_EPOCHS_RETRY_MS = 3_000;
|
|
113
|
+
/** The most recipients the service's payment parser takes in a vector. */
|
|
114
|
+
const MAX_ENTRIES = 1024;
|
|
115
|
+
|
|
116
|
+
function betweenEpochs(cause?: unknown) {
|
|
117
|
+
return new ClearingRefusal(
|
|
118
|
+
"between_epochs",
|
|
119
|
+
{ action: "retry_after_ms", ms: BETWEEN_EPOCHS_RETRY_MS },
|
|
120
|
+
`clearing is between epochs: from an epoch's close until the next one is registered, a gap of a few seconds that recurs every epoch, the service takes no payments; nothing was paid: send the request again after ${BETWEEN_EPOCHS_RETRY_MS} ms, and again after each further between_epochs refusal; if it lasts longer than an epoch, check that serviceUrl is the facilitator's extra.serviceUrl (a validator takes no payments), or the service is down`,
|
|
121
|
+
cause === undefined ? {} : { cause },
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** The error for a faulted deployment; `sent` when the payment reached the
|
|
126
|
+
* service with its outcome unknown. */
|
|
127
|
+
function faulted(cause?: unknown, sent = false) {
|
|
128
|
+
return new Error(
|
|
129
|
+
`the Clearing deployment has faulted, so its service takes no more payments; ${sent ? "whether this payment went through shows in the balance recovery pays out, and the wallet keeps it held, so a replacement deployment takes a new wallet" : "nothing was paid"}: take the account's funds out with recoverFromClearing`,
|
|
130
|
+
cause === undefined ? {} : { cause },
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** A held payment, by its price and the URL it pays for. */
|
|
135
|
+
function describe(pending: NonNullable<ClearingClientState["pending"]>) {
|
|
136
|
+
return `${pending.invoice.amount} base units ${pending.url === undefined ? `under invoice ${pending.invoice.id}` : `for ${pending.url}`}`;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function messageOf(cause: unknown) {
|
|
140
|
+
return cause instanceof BaseError
|
|
141
|
+
? cause.shortMessage
|
|
142
|
+
: cause instanceof Error
|
|
143
|
+
? cause.message
|
|
144
|
+
: String(cause);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** A refusal naming the payment the wallet holds until it goes through:
|
|
148
|
+
* one held from before, one whose answer was lost, or one whose
|
|
149
|
+
* acknowledgment did not verify, all three held alike. */
|
|
150
|
+
function held(
|
|
151
|
+
kind: "payment_held" | "answer_lost" | "unverified",
|
|
152
|
+
pending: NonNullable<ClearingClientState["pending"]>,
|
|
153
|
+
cause?: unknown,
|
|
154
|
+
) {
|
|
155
|
+
const payment = describe(pending);
|
|
156
|
+
const release =
|
|
157
|
+
"if that URL no longer offers clearing at that price, pass the wallet's pendingInvoice() to its pay, which sends the held payment itself once more; the wallet pays others once it goes through or is refused";
|
|
158
|
+
return new ClearingRefusal(
|
|
159
|
+
kind === "payment_held" ? "payment_held" : "answer_lost",
|
|
160
|
+
{
|
|
161
|
+
action: "repeat_same_request",
|
|
162
|
+
url: pending.url ?? null,
|
|
163
|
+
amount: pending.invoice.amount,
|
|
164
|
+
ms: BETWEEN_EPOCHS_RETRY_MS,
|
|
165
|
+
},
|
|
166
|
+
kind === "unverified"
|
|
167
|
+
? `the clearing service answered this payment, ${payment}, but its acknowledgment does not verify against this payment and the deployment's operator key, so serviceUrl may belong to another deployment: check it against the facilitator's /supported; the payment's outcome is unknown, and the wallet holds it and pays nothing else until it is sent once to the right service, which never pays it twice`
|
|
168
|
+
: kind === "answer_lost"
|
|
169
|
+
? `no receipt this wallet could check came back for this payment, ${payment} (${messageOf(cause)}), so its outcome is unknown: the wallet holds it and pays nothing else until it goes through; send the same request again at the same price after ${BETWEEN_EPOCHS_RETRY_MS} ms, which sends this payment again and never pays it twice; if it is still held after an epoch, check that serviceUrl is the facilitator's extra.serviceUrl (a validator takes no payments); ${release}`
|
|
170
|
+
: `the wallet holds an earlier payment whose outcome is unknown, ${payment}, and pays nothing else until it goes through; nothing was paid for this request: send that request again after ${BETWEEN_EPOCHS_RETRY_MS} ms at that price first, which sends the same payment and never pays it twice; ${release}`,
|
|
171
|
+
cause === undefined ? {} : { cause },
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** The error for a payment held for another deployment, network or asset
|
|
176
|
+
* than the wallet's: it can never be sent here. */
|
|
177
|
+
function stranded(pending: NonNullable<ClearingClientState["pending"]>, which: { what: string; theirs: string; ours: string }) {
|
|
178
|
+
return new Error(
|
|
179
|
+
`the wallet holds a payment for ${which.what}${which.theirs}, which this wallet cannot send to ${which.ours}, so it can never go through here and the wallet pays nothing else: take the account's funds out of that deployment with withdrawFromClearing, or recoverFromClearing once it has faulted, given its settlement and service, and open a new wallet for ${which.ours}; the payment is ${describe(pending)}`,
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** A clearing service's refusal; `rejected` when it never takes the payment. */
|
|
184
|
+
class ServiceRefused extends Error {
|
|
185
|
+
constructor(
|
|
186
|
+
readonly status: number,
|
|
187
|
+
readonly rejected: boolean,
|
|
188
|
+
) {
|
|
189
|
+
super(
|
|
190
|
+
rejected
|
|
191
|
+
? "clearing payment rejected before acknowledgment"
|
|
192
|
+
: `clearing service returned ${status}`,
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
const unavailable = (error: unknown) =>
|
|
197
|
+
error instanceof ServiceRefused && error.status === 409 && !error.rejected;
|
|
198
|
+
|
|
199
|
+
export function createClearingClient(config: ClearingClientConfig) {
|
|
200
|
+
const fetcher = config.fetch ?? globalThis.fetch;
|
|
201
|
+
const chain = createPublicClient({ transport: rpcTransport(config.rpcUrl) });
|
|
202
|
+
let tail: Promise<unknown> = Promise.resolve();
|
|
203
|
+
let broken = false;
|
|
204
|
+
async function save(state: ClearingClientState) {
|
|
205
|
+
try {
|
|
206
|
+
await config.store.save(state);
|
|
207
|
+
} catch (error) {
|
|
208
|
+
broken = true;
|
|
209
|
+
throw error;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
async function request<T>(path: string, body?: unknown): Promise<T> {
|
|
213
|
+
const response = await fetcher(new URL(path, config.serviceUrl), {
|
|
214
|
+
method: body ? "POST" : "GET",
|
|
215
|
+
...(body
|
|
216
|
+
? {
|
|
217
|
+
headers: { "content-type": "application/json" },
|
|
218
|
+
body: JSON.stringify(body),
|
|
219
|
+
}
|
|
220
|
+
: {}),
|
|
221
|
+
signal: AbortSignal.timeout(30_000),
|
|
222
|
+
});
|
|
223
|
+
if (!response.ok) {
|
|
224
|
+
const result = (await response.json().catch(() => null)) as {
|
|
225
|
+
rejected?: boolean;
|
|
226
|
+
} | null;
|
|
227
|
+
throw new ServiceRefused(response.status, result?.rejected === true);
|
|
228
|
+
}
|
|
229
|
+
return (await response.json()) as T;
|
|
230
|
+
}
|
|
231
|
+
/** How `invoice` is for another network, deployment or asset than this
|
|
232
|
+
* wallet's, or undefined when it is not. */
|
|
233
|
+
function differs(invoice: ClearingInvoice) {
|
|
234
|
+
if (invoice.network !== config.network) return { what: "", theirs: invoice.network, ours: config.network };
|
|
235
|
+
if (invoice.settlement.toLowerCase() !== config.settlement.toLowerCase())
|
|
236
|
+
return { what: "deployment ", theirs: invoice.settlement, ours: config.settlement };
|
|
237
|
+
if (invoice.asset.toLowerCase() !== config.asset.toLowerCase()) return { what: "asset ", theirs: invoice.asset, ours: config.asset };
|
|
238
|
+
}
|
|
239
|
+
/** Why this wallet does not pay `invoice`, or undefined when it does;
|
|
240
|
+
* `aboveMaxAmount` when it is for this wallet's deployment at a price
|
|
241
|
+
* above its maxAmount. */
|
|
242
|
+
function refusal(invoice: ClearingInvoice): { reason: string; aboveMaxAmount: boolean } | undefined {
|
|
243
|
+
const elsewhere = differs(invoice);
|
|
244
|
+
if (elsewhere)
|
|
245
|
+
return { reason: `offer is for ${elsewhere.what}${elsewhere.theirs}, not this wallet's ${elsewhere.ours}`, aboveMaxAmount: false };
|
|
246
|
+
if (!/^[1-9][0-9]*$/.test(invoice.amount))
|
|
247
|
+
return { reason: `offer of ${invoice.amount} base units is not a positive whole number in canonical form`, aboveMaxAmount: false };
|
|
248
|
+
const amount = BigInt(invoice.amount);
|
|
249
|
+
if (amount > config.maxAmount)
|
|
250
|
+
return {
|
|
251
|
+
reason: `offer ${formatUsdz(amount)} ${USDZ.symbol} is above this wallet's maxAmount ${formatUsdz(config.maxAmount)} ${USDZ.symbol}`,
|
|
252
|
+
aboveMaxAmount: true,
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
/** Why a service that takes no payments does not: the deployment has
|
|
256
|
+
* faulted, for good, or it is between epochs. */
|
|
257
|
+
async function closed(cause: unknown) {
|
|
258
|
+
const fault = await onChain(
|
|
259
|
+
chain.readContract({
|
|
260
|
+
address: config.settlement,
|
|
261
|
+
abi: clearingAbi,
|
|
262
|
+
functionName: "fault",
|
|
263
|
+
}),
|
|
264
|
+
);
|
|
265
|
+
return fault ? faulted(cause) : betweenEpochs(cause);
|
|
266
|
+
}
|
|
267
|
+
/** `reads` of the Clearing contract, once the RPC is known to serve
|
|
268
|
+
* `config.network`: on another chain they fail or mean nothing. */
|
|
269
|
+
async function onChain<T>(reads: Promise<T>): Promise<T> {
|
|
270
|
+
reads.catch(() => {});
|
|
271
|
+
const chainId = await chain.getChainId();
|
|
272
|
+
if (`eip155:${chainId}` !== config.network)
|
|
273
|
+
throw new Error(
|
|
274
|
+
`the clearing client's rpcUrl serves eip155:${chainId}, not ${config.network}; nothing was paid: give it an RPC URL for ${config.network}`,
|
|
275
|
+
);
|
|
276
|
+
return reads;
|
|
277
|
+
}
|
|
278
|
+
/** Why the service refused a payment signed for `epoch` for good: the
|
|
279
|
+
* epoch closed as it was sent, or, while it is still open, the account's
|
|
280
|
+
* deposit does not cover the payment, or neither, which the service does
|
|
281
|
+
* not say. */
|
|
282
|
+
async function rejection(epoch: bigint, invoice: ClearingInvoice, cause: unknown) {
|
|
283
|
+
let covered: boolean;
|
|
284
|
+
let opened = false;
|
|
285
|
+
try {
|
|
286
|
+
const [context, fault] = await Promise.all([
|
|
287
|
+
request<{ epoch: string }>("/context"),
|
|
288
|
+
chain.readContract({
|
|
289
|
+
address: config.settlement,
|
|
290
|
+
abi: clearingAbi,
|
|
291
|
+
functionName: "fault",
|
|
292
|
+
}),
|
|
293
|
+
]);
|
|
294
|
+
if (fault) return faulted(cause);
|
|
295
|
+
if (BigInt(context.epoch) !== epoch) return betweenEpochs(cause);
|
|
296
|
+
opened = true;
|
|
297
|
+
const [{ amount }, unit] = await Promise.all([
|
|
298
|
+
request<{ amount: string }>(`/spendable/${config.signer.publicKey}`),
|
|
299
|
+
chain.readContract({
|
|
300
|
+
address: config.settlement,
|
|
301
|
+
abi: clearingAbi,
|
|
302
|
+
functionName: "unit",
|
|
303
|
+
}),
|
|
304
|
+
]);
|
|
305
|
+
covered = BigInt(amount) >= clearingUnits(BigInt(invoice.amount), unit);
|
|
306
|
+
} catch (error) {
|
|
307
|
+
if (!opened && unavailable(error)) return closed(cause);
|
|
308
|
+
return new Error(
|
|
309
|
+
`the clearing service refused this payment of ${invoice.amount} base units, and reading why failed (${messageOf(error)}); nothing was paid and nothing is held: send the request again`,
|
|
310
|
+
{ cause },
|
|
311
|
+
);
|
|
312
|
+
}
|
|
313
|
+
if (covered)
|
|
314
|
+
return new Error(
|
|
315
|
+
`the clearing service refused this payment of ${invoice.amount} base units without saying why, though the account's deposit covers it; nothing was paid and nothing is held: send the request again, and if it is refused again this epoch, check that no other process pays from this wallet`,
|
|
316
|
+
{ cause },
|
|
317
|
+
);
|
|
318
|
+
return new ClearingRefusal(
|
|
319
|
+
"insufficient_deposit",
|
|
320
|
+
{ action: "deposit" },
|
|
321
|
+
`the clearing service refused this payment of ${invoice.amount} base units because the account's deposit does not cover it; nothing was paid: deposit more with depositToClearing, which pays from the next epoch, then send the request again`,
|
|
322
|
+
{ cause },
|
|
323
|
+
);
|
|
324
|
+
}
|
|
325
|
+
/** What the service retained of a payment sent again without a usable
|
|
326
|
+
* answer: its receipt; `"released"` when it has no record of the payment
|
|
327
|
+
* and the epoch it was signed for is closed, or is no longer the live
|
|
328
|
+
* one on a service that is this wallet's deployment, so a later
|
|
329
|
+
* acceptance cannot overtake the answer; or nothing, when it cannot say.
|
|
330
|
+
* A refusal alone proves nothing: a proxy can answer one after the
|
|
331
|
+
* service accepted the payment. */
|
|
332
|
+
async function retained(pending: NonNullable<ClearingClientState["pending"]>) {
|
|
333
|
+
try {
|
|
334
|
+
const sent = decodeSend(pending.body);
|
|
335
|
+
// Read before the receipt: a closed epoch takes no payment after it.
|
|
336
|
+
const closed = await Promise.all([
|
|
337
|
+
chain.readContract({ address: config.settlement, abi: clearingAbi, functionName: "admittedCount" }),
|
|
338
|
+
request<{ network: string; settlement: Hex; asset: Hex; epoch: string; anchor: Hex }>("/context"),
|
|
339
|
+
]).then(
|
|
340
|
+
([admitted, context]) =>
|
|
341
|
+
context.network !== config.network ||
|
|
342
|
+
context.settlement.toLowerCase() !== config.settlement.toLowerCase() ||
|
|
343
|
+
context.asset.toLowerCase() !== config.asset.toLowerCase()
|
|
344
|
+
? undefined
|
|
345
|
+
: admitted > sent.epoch || BigInt(context.epoch) !== sent.epoch || context.anchor !== sent.anchor,
|
|
346
|
+
() => undefined,
|
|
347
|
+
);
|
|
348
|
+
const found = await request<ClearingPaid | null>(
|
|
349
|
+
`/receipt/${config.signer.publicKey}/${encodeURIComponent(pending.invoice.id)}?epoch=${sent.epoch}`,
|
|
350
|
+
);
|
|
351
|
+
if (found)
|
|
352
|
+
return {
|
|
353
|
+
paid: found,
|
|
354
|
+
operator: await chain.readContract({ address: config.settlement, abi: clearingAbi, functionName: "operatorKey" }),
|
|
355
|
+
};
|
|
356
|
+
return closed ? ("released" as const) : undefined;
|
|
357
|
+
} catch {
|
|
358
|
+
return undefined;
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
async function pay(invoice: ClearingInvoice, url?: string) {
|
|
362
|
+
if (broken)
|
|
363
|
+
throw new Error("clearing wallet persistence failed; reopen the wallet");
|
|
364
|
+
const refused = refusal(invoice);
|
|
365
|
+
if (refused) {
|
|
366
|
+
const held = (await config.store.load())?.pending;
|
|
367
|
+
const elsewhere = held?.invoice.id === invoice.id ? differs(held.invoice) : undefined;
|
|
368
|
+
throw held && elsewhere
|
|
369
|
+
? stranded(held, elsewhere)
|
|
370
|
+
: new Error(
|
|
371
|
+
held?.invoice.id === invoice.id && refused.aboveMaxAmount
|
|
372
|
+
? `${refused.reason}; the wallet holds this payment and pays nothing else until it goes through: raise the wallet's maxAmount to send it`
|
|
373
|
+
: refused.reason,
|
|
374
|
+
);
|
|
375
|
+
}
|
|
376
|
+
const amount = BigInt(invoice.amount);
|
|
377
|
+
let state = (await config.store.load()) ?? {
|
|
378
|
+
entries: [],
|
|
379
|
+
sequence: "0",
|
|
380
|
+
payments: {},
|
|
381
|
+
};
|
|
382
|
+
const paid = state.payments[invoice.id];
|
|
383
|
+
if (paid) {
|
|
384
|
+
if (
|
|
385
|
+
invoiceMessage(paid.invoice, paid.receipt.body) !==
|
|
386
|
+
invoiceMessage(invoice, paid.receipt.body)
|
|
387
|
+
)
|
|
388
|
+
throw new Error("invoice id reused with different terms");
|
|
389
|
+
return paid;
|
|
390
|
+
}
|
|
391
|
+
if (
|
|
392
|
+
state.pending &&
|
|
393
|
+
invoiceMessage(state.pending.invoice, state.pending.body) !==
|
|
394
|
+
invoiceMessage(invoice, state.pending.body)
|
|
395
|
+
) {
|
|
396
|
+
const elsewhere = differs(state.pending.invoice);
|
|
397
|
+
throw elsewhere ? stranded(state.pending, elsewhere) : held("payment_held", state.pending);
|
|
398
|
+
}
|
|
399
|
+
// A payment sent again has been sent before.
|
|
400
|
+
const resent = state.pending !== undefined;
|
|
401
|
+
if (!state.pending) {
|
|
402
|
+
const context = await request<{
|
|
403
|
+
reference: string;
|
|
404
|
+
network: string;
|
|
405
|
+
settlement: Hex;
|
|
406
|
+
asset: Hex;
|
|
407
|
+
operator: Hex;
|
|
408
|
+
epoch: string;
|
|
409
|
+
anchor: Hex;
|
|
410
|
+
}>("/context").catch(async (error: unknown) => {
|
|
411
|
+
throw unavailable(error)
|
|
412
|
+
? await closed(error)
|
|
413
|
+
: error instanceof ServiceRefused && [401, 403, 404].includes(error.status)
|
|
414
|
+
? new Error(
|
|
415
|
+
`the clearing service answered ${error.status} for /context, so serviceUrl is not the clearing service the facilitator's /supported names (extra.serviceUrl); nothing was paid and nothing is held: set serviceUrl to it`,
|
|
416
|
+
{ cause: error },
|
|
417
|
+
)
|
|
418
|
+
: new Error(
|
|
419
|
+
`the clearing service did not give its epoch (${messageOf(error)}); nothing was paid and nothing is held: send the request again`,
|
|
420
|
+
{ cause: error },
|
|
421
|
+
);
|
|
422
|
+
});
|
|
423
|
+
if (
|
|
424
|
+
context.reference !== CLEARING_REFERENCE ||
|
|
425
|
+
context.network !== config.network ||
|
|
426
|
+
context.settlement.toLowerCase() !== config.settlement.toLowerCase() ||
|
|
427
|
+
context.asset.toLowerCase() !== config.asset.toLowerCase()
|
|
428
|
+
)
|
|
429
|
+
throw new Error("unsupported clearing service deployment");
|
|
430
|
+
const epoch = BigInt(context.epoch);
|
|
431
|
+
const [registered, onchain, fault, admitted, block, unit] =
|
|
432
|
+
await onChain(Promise.all([
|
|
433
|
+
chain.readContract({
|
|
434
|
+
address: config.settlement,
|
|
435
|
+
abi: clearingAbi,
|
|
436
|
+
functionName: "registered",
|
|
437
|
+
}),
|
|
438
|
+
chain.readContract({
|
|
439
|
+
address: config.settlement,
|
|
440
|
+
abi: clearingAbi,
|
|
441
|
+
functionName: "contextOf",
|
|
442
|
+
args: [epoch],
|
|
443
|
+
}),
|
|
444
|
+
chain.readContract({
|
|
445
|
+
address: config.settlement,
|
|
446
|
+
abi: clearingAbi,
|
|
447
|
+
functionName: "fault",
|
|
448
|
+
}),
|
|
449
|
+
chain.readContract({
|
|
450
|
+
address: config.settlement,
|
|
451
|
+
abi: clearingAbi,
|
|
452
|
+
functionName: "admittedCount",
|
|
453
|
+
}),
|
|
454
|
+
chain.getBlock(),
|
|
455
|
+
chain.readContract({
|
|
456
|
+
address: config.settlement,
|
|
457
|
+
abi: clearingAbi,
|
|
458
|
+
functionName: "unit",
|
|
459
|
+
}),
|
|
460
|
+
]));
|
|
461
|
+
if (fault) throw faulted();
|
|
462
|
+
// The contract has not registered the service's epoch yet, or has
|
|
463
|
+
// closed it to payments.
|
|
464
|
+
if (
|
|
465
|
+
!registered ||
|
|
466
|
+
admitted !== epoch ||
|
|
467
|
+
block.timestamp >= onchain.admissionDeadline
|
|
468
|
+
)
|
|
469
|
+
throw betweenEpochs();
|
|
470
|
+
if (
|
|
471
|
+
onchain.anchor !== context.anchor ||
|
|
472
|
+
onchain.operatorKey !== context.operator
|
|
473
|
+
)
|
|
474
|
+
throw new Error("clearing epoch is not registered");
|
|
475
|
+
if (state.epoch !== context.epoch)
|
|
476
|
+
state = { ...state, epoch: context.epoch, sequence: "0", entries: [] };
|
|
477
|
+
const entries = state.entries.map((e) => ({ ...e }));
|
|
478
|
+
let entry = entries.find(
|
|
479
|
+
(e) => e.recipient.toLowerCase() === invoice.recipient.toLowerCase(),
|
|
480
|
+
);
|
|
481
|
+
if (!entry) {
|
|
482
|
+
entry = { recipient: invoice.recipient, cumulative: "0", count: "0" };
|
|
483
|
+
entries.push(entry);
|
|
484
|
+
}
|
|
485
|
+
// The invoice is in token base units; the vector counts clearing units.
|
|
486
|
+
entry.cumulative = (
|
|
487
|
+
BigInt(entry.cumulative) + clearingUnits(amount, unit)
|
|
488
|
+
).toString();
|
|
489
|
+
entry.count = (BigInt(entry.count) + 1n).toString();
|
|
490
|
+
// The service takes at most this many recipients in one payment's vector.
|
|
491
|
+
if (entries.length > MAX_ENTRIES)
|
|
492
|
+
throw new Error(
|
|
493
|
+
`this wallet has paid ${MAX_ENTRIES} recipients in epoch ${context.epoch}, the most one payment's vector holds; nothing was paid and nothing is held: send the request again after the next epoch`,
|
|
494
|
+
);
|
|
495
|
+
entries.sort((a, b) =>
|
|
496
|
+
a.recipient.toLowerCase().localeCompare(b.recipient.toLowerCase()),
|
|
497
|
+
);
|
|
498
|
+
const vector = entries.map((e) => ({
|
|
499
|
+
...e,
|
|
500
|
+
cumulative: BigInt(e.cumulative),
|
|
501
|
+
count: BigInt(e.count),
|
|
502
|
+
}));
|
|
503
|
+
const body = encodeSend({
|
|
504
|
+
anchor: context.anchor,
|
|
505
|
+
epoch,
|
|
506
|
+
payer: config.signer.publicKey,
|
|
507
|
+
sequence: BigInt(state.sequence),
|
|
508
|
+
debit: vector.reduce((sum, e) => sum + e.cumulative, 0n),
|
|
509
|
+
vector: outgoingRoot(vector),
|
|
510
|
+
});
|
|
511
|
+
state = {
|
|
512
|
+
...state,
|
|
513
|
+
pending: {
|
|
514
|
+
invoice,
|
|
515
|
+
body,
|
|
516
|
+
entries,
|
|
517
|
+
payerSignature: await config.signer.sign(
|
|
518
|
+
"_COMMONWARE_CLEARING_VECTOR_SEND",
|
|
519
|
+
body,
|
|
520
|
+
),
|
|
521
|
+
invoiceSignature: await config.signer.sign(
|
|
522
|
+
"_METAL_CLEARING_INVOICE",
|
|
523
|
+
invoiceMessage(invoice, body),
|
|
524
|
+
),
|
|
525
|
+
url,
|
|
526
|
+
},
|
|
527
|
+
};
|
|
528
|
+
await save(state);
|
|
529
|
+
}
|
|
530
|
+
const pending = state.pending!;
|
|
531
|
+
let result: ClearingPaid;
|
|
532
|
+
let operator: Hex;
|
|
533
|
+
try {
|
|
534
|
+
result = await request<ClearingPaid>("/payments", {
|
|
535
|
+
invoice: pending.invoice,
|
|
536
|
+
body: pending.body,
|
|
537
|
+
payerSignature: pending.payerSignature,
|
|
538
|
+
invoiceSignature: pending.invoiceSignature,
|
|
539
|
+
entries: pending.entries,
|
|
540
|
+
} satisfies ClearingPayment);
|
|
541
|
+
operator = await chain.readContract({
|
|
542
|
+
address: config.settlement,
|
|
543
|
+
abi: clearingAbi,
|
|
544
|
+
functionName: "operatorKey",
|
|
545
|
+
});
|
|
546
|
+
} catch (error) {
|
|
547
|
+
if (error instanceof ServiceRefused && error.rejected) {
|
|
548
|
+
await save({ ...state, pending: undefined });
|
|
549
|
+
throw await rejection(decodeSend(pending.body).epoch, invoice, error);
|
|
550
|
+
}
|
|
551
|
+
// Once the deployment has faulted, recovery takes the account's
|
|
552
|
+
// funds whatever became of the payment.
|
|
553
|
+
const fault = await chain
|
|
554
|
+
.readContract({ address: config.settlement, abi: clearingAbi, functionName: "fault" })
|
|
555
|
+
.catch(() => 0);
|
|
556
|
+
if (fault) throw faulted(error, true);
|
|
557
|
+
const kept = resent ? await retained(pending) : undefined;
|
|
558
|
+
if (kept === "released") {
|
|
559
|
+
// The vector it extended belongs to an epoch that is closed or gone.
|
|
560
|
+
await save({ ...state, epoch: undefined, entries: [], sequence: "0", pending: undefined });
|
|
561
|
+
throw new Error(
|
|
562
|
+
`the clearing service has no record of this payment, ${describe(pending)}, and the epoch it was signed for has closed or is no longer the live one, so it can never be taken: it is released; nothing was paid and nothing is held: send the request again`,
|
|
563
|
+
{ cause: error },
|
|
564
|
+
);
|
|
565
|
+
}
|
|
566
|
+
if (!kept) throw held("answer_lost", pending, error);
|
|
567
|
+
({ paid: result, operator } = kept);
|
|
568
|
+
}
|
|
569
|
+
let verified = false;
|
|
570
|
+
try {
|
|
571
|
+
verified =
|
|
572
|
+
result.receipt.payerSignature === pending.payerSignature &&
|
|
573
|
+
invoiceMessage(result.invoice, pending.body) === invoiceMessage(invoice, pending.body) &&
|
|
574
|
+
verifyReceipt(result.receipt, operator);
|
|
575
|
+
} catch {
|
|
576
|
+
// An answer that is no receipt does not verify.
|
|
577
|
+
}
|
|
578
|
+
if (!verified) throw held("unverified", pending);
|
|
579
|
+
const settled = {
|
|
580
|
+
invoice,
|
|
581
|
+
receipt: result.receipt,
|
|
582
|
+
invoiceSignature: pending.invoiceSignature,
|
|
583
|
+
entries: pending.entries,
|
|
584
|
+
...(result.previous ? { previous: result.previous } : {}),
|
|
585
|
+
};
|
|
586
|
+
await save({
|
|
587
|
+
...state,
|
|
588
|
+
entries: pending.entries,
|
|
589
|
+
sequence: (decodeSend(pending.body).sequence + 1n).toString(),
|
|
590
|
+
pending: undefined,
|
|
591
|
+
payments: { ...state.payments, [invoice.id]: settled },
|
|
592
|
+
});
|
|
593
|
+
return settled;
|
|
594
|
+
}
|
|
595
|
+
return {
|
|
596
|
+
account: config.signer.publicKey,
|
|
597
|
+
async pendingInvoice() {
|
|
598
|
+
return (await config.store.load())?.pending?.invoice ?? null;
|
|
599
|
+
},
|
|
600
|
+
/** Why this wallet does not pay `invoice`, or undefined when it does. */
|
|
601
|
+
unpayable: refusal,
|
|
602
|
+
/** Pay `invoice`; `url`, the request it pays for, is kept with a
|
|
603
|
+
* payment whose outcome is unknown, so a refusal can name it. A payment
|
|
604
|
+
* the service does not take throws a `ClearingRefusal`. */
|
|
605
|
+
pay(invoice: ClearingInvoice, url?: string) {
|
|
606
|
+
const result = tail.then(() => pay(invoice, url));
|
|
607
|
+
tail = result.catch(() => {});
|
|
608
|
+
return result;
|
|
609
|
+
},
|
|
610
|
+
request,
|
|
611
|
+
};
|
|
612
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { clearingUnits, decodeSend, outgoingRoot, verifyReceipt } from "./clearing-codec.js";
|
|
4
|
+
|
|
5
|
+
const fixture = JSON.parse(
|
|
6
|
+
readFileSync(new URL("./clearing-fixture.json", import.meta.url), "utf8"),
|
|
7
|
+
);
|
|
8
|
+
|
|
9
|
+
describe("native Bajillion wire parity", () => {
|
|
10
|
+
it("verifies a receipt produced by the pinned Rust implementation", () => {
|
|
11
|
+
expect(verifyReceipt(fixture.receipt, fixture.operator)).toBe(true);
|
|
12
|
+
const body = decodeSend(fixture.receipt.body);
|
|
13
|
+
expect(body.debit).toBe(600n);
|
|
14
|
+
expect(
|
|
15
|
+
outgoingRoot([
|
|
16
|
+
{
|
|
17
|
+
recipient: fixture.receipt.entry.recipient,
|
|
18
|
+
cumulative: 600n,
|
|
19
|
+
count: 1n,
|
|
20
|
+
},
|
|
21
|
+
]),
|
|
22
|
+
).toBe(body.vector);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("rejects tampered bodies and operator keys", () => {
|
|
26
|
+
expect(
|
|
27
|
+
verifyReceipt(
|
|
28
|
+
{ ...fixture.receipt, body: fixture.receipt.body.slice(0, -2) + "00" },
|
|
29
|
+
fixture.operator,
|
|
30
|
+
),
|
|
31
|
+
).toBe(false);
|
|
32
|
+
expect(verifyReceipt(fixture.receipt, fixture.receipt.payer)).toBe(false);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("rejects noncanonical vectors and out-of-range amounts", () => {
|
|
36
|
+
const entry = {
|
|
37
|
+
recipient: fixture.receipt.entry.recipient,
|
|
38
|
+
cumulative: 600n,
|
|
39
|
+
count: 1n,
|
|
40
|
+
};
|
|
41
|
+
expect(() => outgoingRoot([entry, entry])).toThrow();
|
|
42
|
+
expect(() => outgoingRoot([{ ...entry, cumulative: 1n << 64n }])).toThrow();
|
|
43
|
+
expect(() => outgoingRoot([{ ...entry, cumulative: 0n }])).toThrow();
|
|
44
|
+
expect(() =>
|
|
45
|
+
decodeSend((fixture.receipt.body + "00") as `0x${string}`),
|
|
46
|
+
).toThrow();
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
describe("clearing units", () => {
|
|
51
|
+
it("counts an amount in a deployment's units and refuses a fraction of one", () => {
|
|
52
|
+
expect(clearingUnits(5n * 10n ** 12n, 10n ** 12n)).toBe(5n);
|
|
53
|
+
expect(clearingUnits(7n, 1n)).toBe(7n);
|
|
54
|
+
expect(() => clearingUnits(10n ** 12n + 1n, 10n ** 12n)).toThrow(/whole number of clearing units/);
|
|
55
|
+
expect(() => clearingUnits(5n, 0n)).toThrow();
|
|
56
|
+
});
|
|
57
|
+
});
|