@metalntwx/metal-pay 0.1.1 → 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 +82 -39
- 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 +13 -13
- package/dist/client.js +45 -18
- package/dist/index.d.ts +4 -1
- package/dist/index.js +4 -1
- package/dist/mandates/account.d.ts +5 -21
- package/dist/mandates/account.js +28 -10
- 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 +10 -6
- 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 +19 -12
- 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
package/dist/clearing-client.js
CHANGED
|
@@ -1,580 +1,207 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
// const clearing = new ClearingDebitScheme(signer, {
|
|
6
|
-
// facilitator: "https://<facilitator host>/x402/facilitator",
|
|
7
|
-
// });
|
|
8
|
-
// const client = new x402Client(clearing.prefer)
|
|
9
|
-
// .register(network, new ExactEvmScheme(signer))
|
|
10
|
-
// .register(network, clearing);
|
|
11
|
-
// const pay = wrapFetchWithPayment(clearing.wrapFetch(fetch), client);
|
|
12
|
-
//
|
|
13
|
-
// A payment is a signed cumulative Debit: "I have now spent X in total this
|
|
14
|
-
// epoch". It draws on funds the payer holds in the Clearing contract, which
|
|
15
|
-
// the payer (or anyone funding it) puts there by calling the contract's
|
|
16
|
-
// `deposit`; `depositToClearing` does that. While custody is short the
|
|
17
|
-
// selector takes the standard offer instead.
|
|
18
|
-
//
|
|
19
|
-
// Where the next Debit starts comes from the facilitator the payer names
|
|
20
|
-
// here, never from the seller: the client asks it, with a request it signs,
|
|
21
|
-
// about every 20 seconds while it pays and after any refusal. A seller
|
|
22
|
-
// therefore cannot make the payer sign for more than it spent. The client
|
|
23
|
-
// also keeps its own record of the highest total it has signed each epoch
|
|
24
|
-
// (`storage`; in memory by default, `FileClearingPositionStorage` from
|
|
25
|
-
// `@metalntwx/metal-pay/file-storage` for one that survives a restart) and refuses to sign past a total the facilitator
|
|
26
|
-
// reports above it.
|
|
27
|
-
//
|
|
28
|
-
// When a seller cannot say whether a Debit was admitted, or its answer never
|
|
29
|
-
// reaches the payer (`wrapFetch` sees the request fail, or a server error
|
|
30
|
-
// that says nothing about the Debit), the client presents that same Debit
|
|
31
|
-
// again rather than signing a new one, so the payer is not charged twice for
|
|
32
|
-
// one payment.
|
|
33
|
-
import { decodePaymentSignatureHeader } from "@x402/core/http";
|
|
34
|
-
import { createPublicClient, createWalletClient, encodeFunctionData, erc20Abi, http, } from "viem";
|
|
35
|
-
import { ACCOUNT_REQUEST_TYPES, CLEARING_DEBIT, clearingDomain, DEBIT_TYPES, EXIT_TYPES, } from "./clearing.js";
|
|
36
|
-
/** `next`, with `highestSigned` no lower than the one already stored. */
|
|
37
|
-
export function keepHighest(existing, next) {
|
|
38
|
-
if (!existing)
|
|
39
|
-
return next;
|
|
40
|
-
return BigInt(existing.highestSigned) > BigInt(next.highestSigned)
|
|
41
|
-
? { ...next, highestSigned: existing.highestSigned }
|
|
42
|
-
: next;
|
|
1
|
+
import { createPublicClient, http } from "viem";
|
|
2
|
+
import { clearingAbi } from "./clearing-abi.js";
|
|
3
|
+
import { CLEARING_REFERENCE, clearingUnits, decodeSend, encodeSend, invoiceMessage, outgoingRoot, verifyReceipt, } from "./clearing-codec.js";
|
|
4
|
+
class PaymentRejected extends Error {
|
|
43
5
|
}
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
}
|
|
53
|
-
}
|
|
54
|
-
function clearingExtraOf(requirements) {
|
|
55
|
-
const clearing = requirements.extra?.["clearing"];
|
|
56
|
-
if (!clearing?.contract || !clearing.ref)
|
|
57
|
-
throw new Error("clearing offer carries no contract or ref");
|
|
58
|
-
return clearing;
|
|
59
|
-
}
|
|
60
|
-
const chainIdOf = (network) => {
|
|
61
|
-
const chainId = Number(network.slice("eip155:".length));
|
|
62
|
-
if (!network.startsWith("eip155:") || !Number.isSafeInteger(chainId))
|
|
63
|
-
throw new Error(`not an EVM network: ${network}`);
|
|
64
|
-
return chainId;
|
|
65
|
-
};
|
|
66
|
-
/** A request this account signs to ask the facilitator about itself. */
|
|
67
|
-
async function accountRequest(signer, network, contract, nowMs) {
|
|
68
|
-
const deadline = BigInt(Math.floor(nowMs / 1000) + 60);
|
|
69
|
-
const signature = await signer.signTypedData({
|
|
70
|
-
domain: clearingDomain(chainIdOf(network), contract),
|
|
71
|
-
types: ACCOUNT_REQUEST_TYPES,
|
|
72
|
-
primaryType: "AccountRequest",
|
|
73
|
-
message: { account: signer.address, deadline },
|
|
74
|
-
});
|
|
75
|
-
return {
|
|
76
|
-
account: signer.address,
|
|
77
|
-
network,
|
|
78
|
-
deadline: deadline.toString(),
|
|
79
|
-
signature,
|
|
80
|
-
};
|
|
81
|
-
}
|
|
82
|
-
async function post(doFetch, url, body) {
|
|
83
|
-
const res = await doFetch(url, {
|
|
84
|
-
method: "POST",
|
|
85
|
-
headers: { "content-type": "application/json" },
|
|
86
|
-
body: JSON.stringify(body),
|
|
87
|
-
});
|
|
88
|
-
const json = (await res.json().catch(() => ({})));
|
|
89
|
-
if (!res.ok)
|
|
90
|
-
throw new Error(`facilitator refused: ${json.error ?? res.status}`);
|
|
91
|
-
return json;
|
|
92
|
-
}
|
|
93
|
-
/** Ask the facilitator where `signer`'s clearing position stands, with a
|
|
94
|
-
* request `signer` signs. */
|
|
95
|
-
async function fetchPosition(doFetch, facilitator, signer, network, contract, nowMs) {
|
|
96
|
-
const answer = await post(doFetch, `${facilitator}/clearing/position`, await accountRequest(signer, network, contract, nowMs));
|
|
97
|
-
if (answer.contract.toLowerCase() !== contract.toLowerCase())
|
|
98
|
-
throw new Error("the seller names a different Clearing contract than the facilitator");
|
|
99
|
-
return answer;
|
|
100
|
-
}
|
|
101
|
-
const CUSTODY_SHORT = "clearing custody is short of the price: deposit into the Clearing contract first (see depositToClearing)";
|
|
102
|
-
const SELLER_REFUSES_CLEARING = "this seller cannot take clearing payments (clearing_requires_seller_token) and offers no standard payment";
|
|
103
|
-
/** How long a position from the facilitator is used before asking again:
|
|
104
|
-
* well inside an epoch, so the client rarely signs for one that has closed. */
|
|
105
|
-
const POSITION_TTL_MS = 20_000;
|
|
106
|
-
/** How long the client pays the standard way after being told custody is
|
|
107
|
-
* short, before trying clearing again. */
|
|
108
|
-
const SHORT_MS = 60_000;
|
|
109
|
-
/** What tells one Debit from another: its signature. */
|
|
110
|
-
const signatureOf = (payload) => payload.signature;
|
|
111
|
-
/** Reasons a seller gives that say a Debit may yet have been admitted. */
|
|
112
|
-
const IN_DOUBT = new Set(["settlement_in_doubt", "payment_in_flight"]);
|
|
113
|
-
export class ClearingDebitScheme {
|
|
114
|
-
signer;
|
|
115
|
-
scheme = CLEARING_DEBIT;
|
|
116
|
-
schemeHooks;
|
|
117
|
-
facilitator;
|
|
118
|
-
storage;
|
|
119
|
-
doFetch;
|
|
120
|
-
now;
|
|
121
|
-
/** Per payer and contract: the facilitator's last answer, adjusted for
|
|
122
|
-
* what this client has signed since. */
|
|
123
|
-
positions = new Map();
|
|
124
|
-
/** Per payer and contract: until when to pay the standard way. */
|
|
125
|
-
shortUntil = new Map();
|
|
126
|
-
/** Sellers the facilitator settles no clearing for, by network, contract
|
|
127
|
-
* and payee: a seller without Metal's seller token. That holds for as long
|
|
128
|
-
* as the client runs, so they are paid the standard way from then on. */
|
|
129
|
-
standardOnly = new Set();
|
|
130
|
-
/** Per payer and contract: the Debits whose outcome is in doubt, each to
|
|
131
|
-
* be presented again for its own offer. Only an answer about that Debit
|
|
132
|
-
* clears it. */
|
|
133
|
-
unresolved = new Map();
|
|
134
|
-
constructor(signer, options) {
|
|
135
|
-
this.signer = signer;
|
|
136
|
-
this.facilitator = options.facilitator.replace(/\/$/, "");
|
|
137
|
-
this.storage = options.storage ?? new InMemoryClearingPositionStorage();
|
|
138
|
-
this.doFetch = options.fetch ?? globalThis.fetch;
|
|
139
|
-
this.now = options.now ?? (() => Date.now());
|
|
140
|
-
this.schemeHooks = {
|
|
141
|
-
// How a paid request ended decides the retry the transport makes.
|
|
142
|
-
onPaymentResponse: async (ctx) => {
|
|
143
|
-
if (ctx.requirements.scheme !== CLEARING_DEBIT)
|
|
144
|
-
return;
|
|
145
|
-
const key = this.key(ctx.requirements);
|
|
146
|
-
const payload = ctx.paymentPayload.payload;
|
|
147
|
-
if (ctx.settleResponse?.success) {
|
|
148
|
-
// Settled by clearing: custody covered it, whatever was thought.
|
|
149
|
-
this.release(key, payload);
|
|
150
|
-
this.shortUntil.delete(key);
|
|
151
|
-
return;
|
|
152
|
-
}
|
|
153
|
-
// No word about the Debit: `wrapFetch` holds it if it may have been
|
|
154
|
-
// admitted, and where the next one starts is asked afresh.
|
|
155
|
-
if (!ctx.settleResponse && !ctx.paymentRequired) {
|
|
156
|
-
this.positions.delete(key);
|
|
157
|
-
return;
|
|
158
|
-
}
|
|
159
|
-
const reason = ctx.settleResponse?.errorReason ?? ctx.paymentRequired?.error ?? "";
|
|
160
|
-
// Perhaps admitted: the retry presents the same Debit again.
|
|
161
|
-
if (IN_DOUBT.has(reason)) {
|
|
162
|
-
this.hold(key, this.offerId(ctx.requirements), payload);
|
|
163
|
-
return { recovered: true };
|
|
164
|
-
}
|
|
165
|
-
this.release(key, payload);
|
|
166
|
-
this.positions.delete(key);
|
|
167
|
-
if (reason === "insufficient_custody") {
|
|
168
|
-
this.shortUntil.set(key, this.now() + SHORT_MS);
|
|
169
|
-
return { recovered: true };
|
|
170
|
-
}
|
|
171
|
-
if (reason === "clearing_requires_seller_token") {
|
|
172
|
-
this.standardOnly.add(this.sellerOf(ctx.requirements));
|
|
173
|
-
return { recovered: true };
|
|
174
|
-
}
|
|
175
|
-
if (reason === "debit_out_of_sequence")
|
|
176
|
-
return { recovered: true };
|
|
177
|
-
return undefined;
|
|
178
|
-
},
|
|
179
|
-
};
|
|
180
|
-
}
|
|
181
|
-
/** The selector to give `new x402Client(...)`: the clearing offer, unless
|
|
182
|
-
* custody was lately found short or the seller cannot take clearing, in
|
|
183
|
-
* which case the standard one. A Debit whose outcome is in doubt is always
|
|
184
|
-
* presented again by clearing. */
|
|
185
|
-
prefer = (_x402Version, accepts) => {
|
|
186
|
-
const clearing = accepts.find((a) => a.scheme === CLEARING_DEBIT);
|
|
187
|
-
const standard = accepts.find((a) => a.scheme !== CLEARING_DEBIT);
|
|
188
|
-
if (clearing && this.standardOnly.has(this.sellerOf(clearing))) {
|
|
189
|
-
if (standard)
|
|
190
|
-
return standard;
|
|
191
|
-
throw new Error(SELLER_REFUSES_CLEARING);
|
|
6
|
+
export function createClearingClient(config) {
|
|
7
|
+
const fetcher = config.fetch ?? globalThis.fetch;
|
|
8
|
+
const chain = createPublicClient({ transport: http(config.rpcUrl) });
|
|
9
|
+
let tail = Promise.resolve();
|
|
10
|
+
let broken = false;
|
|
11
|
+
async function save(state) {
|
|
12
|
+
try {
|
|
13
|
+
await config.store.save(state);
|
|
192
14
|
}
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
const short = !this.unresolved.get(key)?.some((held) => held.offer === offer) &&
|
|
197
|
-
(this.shortUntil.get(key) ?? 0) > this.now();
|
|
198
|
-
if (!short)
|
|
199
|
-
return clearing;
|
|
200
|
-
if (standard)
|
|
201
|
-
return standard;
|
|
202
|
-
throw new Error(CUSTODY_SHORT);
|
|
15
|
+
catch (error) {
|
|
16
|
+
broken = true;
|
|
17
|
+
throw error;
|
|
203
18
|
}
|
|
204
|
-
if (!standard)
|
|
205
|
-
throw new Error("no payable offer");
|
|
206
|
-
return standard;
|
|
207
|
-
};
|
|
208
|
-
/** One payment at a time per payer and contract: reading the position,
|
|
209
|
-
* signing and recording it must not interleave, or two payments would sign
|
|
210
|
-
* the same index. */
|
|
211
|
-
queues = new Map();
|
|
212
|
-
exclusive(key, job) {
|
|
213
|
-
const run = (this.queues.get(key) ?? Promise.resolve()).then(job, job);
|
|
214
|
-
this.queues.set(key, run.catch(() => undefined));
|
|
215
|
-
return run;
|
|
216
19
|
}
|
|
217
|
-
|
|
218
|
-
const
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
20
|
+
async function request(path, body) {
|
|
21
|
+
const response = await fetcher(new URL(path, config.serviceUrl), {
|
|
22
|
+
method: body ? "POST" : "GET",
|
|
23
|
+
...(body
|
|
24
|
+
? {
|
|
25
|
+
headers: { "content-type": "application/json" },
|
|
26
|
+
body: JSON.stringify(body),
|
|
27
|
+
}
|
|
28
|
+
: {}),
|
|
29
|
+
signal: AbortSignal.timeout(30_000),
|
|
30
|
+
});
|
|
31
|
+
if (!response.ok) {
|
|
32
|
+
const result = (await response.json().catch(() => null));
|
|
33
|
+
if (result?.rejected === true)
|
|
34
|
+
throw new PaymentRejected("clearing payment rejected before acknowledgment");
|
|
35
|
+
throw new Error(`clearing service returned ${response.status}`);
|
|
36
|
+
}
|
|
37
|
+
return (await response.json());
|
|
38
|
+
}
|
|
39
|
+
/** Why this wallet does not pay `invoice`, or undefined when it does. */
|
|
40
|
+
function refusal(invoice) {
|
|
41
|
+
if (invoice.network !== config.network ||
|
|
42
|
+
invoice.asset.toLowerCase() !== config.asset.toLowerCase() ||
|
|
43
|
+
invoice.settlement.toLowerCase() !== config.settlement.toLowerCase())
|
|
44
|
+
return "clearing invoice deployment mismatch";
|
|
45
|
+
const amount = BigInt(invoice.amount);
|
|
46
|
+
if (amount <= 0n || amount > config.maxAmount)
|
|
47
|
+
return "clearing payment exceeds spending limit";
|
|
48
|
+
}
|
|
49
|
+
async function pay(invoice) {
|
|
50
|
+
if (broken)
|
|
51
|
+
throw new Error("clearing wallet persistence failed; reopen the wallet");
|
|
52
|
+
const refused = refusal(invoice);
|
|
53
|
+
if (refused)
|
|
54
|
+
throw new Error(refused);
|
|
55
|
+
const amount = BigInt(invoice.amount);
|
|
56
|
+
let state = (await config.store.load()) ?? {
|
|
57
|
+
entries: [],
|
|
58
|
+
sequence: "0",
|
|
59
|
+
payments: {},
|
|
252
60
|
};
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
paid
|
|
61
|
+
const paid = state.payments[invoice.id];
|
|
62
|
+
if (paid) {
|
|
63
|
+
if (invoiceMessage(paid.invoice, paid.receipt.body) !==
|
|
64
|
+
invoiceMessage(invoice, paid.receipt.body))
|
|
65
|
+
throw new Error("invoice id reused with different terms");
|
|
66
|
+
return paid;
|
|
259
67
|
}
|
|
260
|
-
|
|
261
|
-
|
|
68
|
+
if (state.pending &&
|
|
69
|
+
invoiceMessage(state.pending.invoice, state.pending.body) !==
|
|
70
|
+
invoiceMessage(invoice, state.pending.body))
|
|
71
|
+
throw new Error("an earlier clearing payment has an unknown outcome; retry that invoice first");
|
|
72
|
+
if (!state.pending) {
|
|
73
|
+
const context = await request("/context");
|
|
74
|
+
if (context.reference !== CLEARING_REFERENCE ||
|
|
75
|
+
context.network !== config.network ||
|
|
76
|
+
context.settlement.toLowerCase() !== config.settlement.toLowerCase() ||
|
|
77
|
+
context.asset.toLowerCase() !== config.asset.toLowerCase())
|
|
78
|
+
throw new Error("unsupported clearing service deployment");
|
|
79
|
+
const epoch = BigInt(context.epoch);
|
|
80
|
+
const [registered, onchain, fault, chainId, admitted, block, unit] = await Promise.all([
|
|
81
|
+
chain.readContract({
|
|
82
|
+
address: config.settlement,
|
|
83
|
+
abi: clearingAbi,
|
|
84
|
+
functionName: "registered",
|
|
85
|
+
}),
|
|
86
|
+
chain.readContract({
|
|
87
|
+
address: config.settlement,
|
|
88
|
+
abi: clearingAbi,
|
|
89
|
+
functionName: "contextOf",
|
|
90
|
+
args: [epoch],
|
|
91
|
+
}),
|
|
92
|
+
chain.readContract({
|
|
93
|
+
address: config.settlement,
|
|
94
|
+
abi: clearingAbi,
|
|
95
|
+
functionName: "fault",
|
|
96
|
+
}),
|
|
97
|
+
chain.getChainId(),
|
|
98
|
+
chain.readContract({
|
|
99
|
+
address: config.settlement,
|
|
100
|
+
abi: clearingAbi,
|
|
101
|
+
functionName: "admittedCount",
|
|
102
|
+
}),
|
|
103
|
+
chain.getBlock(),
|
|
104
|
+
chain.readContract({
|
|
105
|
+
address: config.settlement,
|
|
106
|
+
abi: clearingAbi,
|
|
107
|
+
functionName: "unit",
|
|
108
|
+
}),
|
|
109
|
+
]);
|
|
110
|
+
if (!registered ||
|
|
111
|
+
fault ||
|
|
112
|
+
admitted !== epoch ||
|
|
113
|
+
block.timestamp >= onchain.admissionDeadline ||
|
|
114
|
+
`eip155:${chainId}` !== config.network ||
|
|
115
|
+
onchain.anchor !== context.anchor ||
|
|
116
|
+
onchain.operatorKey !== context.operator)
|
|
117
|
+
throw new Error("clearing epoch is not registered");
|
|
118
|
+
if (state.epoch !== context.epoch)
|
|
119
|
+
state = { ...state, epoch: context.epoch, sequence: "0", entries: [] };
|
|
120
|
+
const entries = state.entries.map((e) => ({ ...e }));
|
|
121
|
+
let entry = entries.find((e) => e.recipient.toLowerCase() === invoice.recipient.toLowerCase());
|
|
122
|
+
if (!entry) {
|
|
123
|
+
entry = { recipient: invoice.recipient, cumulative: "0", count: "0" };
|
|
124
|
+
entries.push(entry);
|
|
125
|
+
}
|
|
126
|
+
// The invoice is in token base units; the vector counts clearing units.
|
|
127
|
+
entry.cumulative = (BigInt(entry.cumulative) + clearingUnits(amount, unit)).toString();
|
|
128
|
+
entry.count = (BigInt(entry.count) + 1n).toString();
|
|
129
|
+
entries.sort((a, b) => a.recipient.toLowerCase().localeCompare(b.recipient.toLowerCase()));
|
|
130
|
+
const vector = entries.map((e) => ({
|
|
131
|
+
...e,
|
|
132
|
+
cumulative: BigInt(e.cumulative),
|
|
133
|
+
count: BigInt(e.count),
|
|
134
|
+
}));
|
|
135
|
+
const body = encodeSend({
|
|
136
|
+
anchor: context.anchor,
|
|
137
|
+
epoch,
|
|
138
|
+
payer: config.signer.publicKey,
|
|
139
|
+
sequence: BigInt(state.sequence),
|
|
140
|
+
debit: vector.reduce((sum, e) => sum + e.cumulative, 0n),
|
|
141
|
+
vector: outgoingRoot(vector),
|
|
142
|
+
});
|
|
143
|
+
state = {
|
|
144
|
+
...state,
|
|
145
|
+
pending: {
|
|
146
|
+
invoice,
|
|
147
|
+
body,
|
|
148
|
+
entries,
|
|
149
|
+
payerSignature: await config.signer.sign("_COMMONWARE_CLEARING_VECTOR_SEND", body),
|
|
150
|
+
invoiceSignature: await config.signer.sign("_METAL_CLEARING_INVOICE", invoiceMessage(invoice, body)),
|
|
151
|
+
},
|
|
152
|
+
};
|
|
153
|
+
await save(state);
|
|
262
154
|
}
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
const held = this.unresolved.get(key) ?? [];
|
|
268
|
-
const same = held.find((h) => signatureOf(h.payload) === signatureOf(payload));
|
|
269
|
-
if (same)
|
|
270
|
-
same.unasked = true;
|
|
271
|
-
else
|
|
272
|
-
this.unresolved.set(key, [...held, { offer, payload, unasked: true }]);
|
|
273
|
-
}
|
|
274
|
-
/** Clears a Debit once a seller has answered about it. */
|
|
275
|
-
release(key, payload) {
|
|
276
|
-
const held = (this.unresolved.get(key) ?? []).filter((h) => signatureOf(h.payload) !== signatureOf(payload));
|
|
277
|
-
if (held.length)
|
|
278
|
-
this.unresolved.set(key, held);
|
|
279
|
-
else
|
|
280
|
-
this.unresolved.delete(key);
|
|
281
|
-
}
|
|
282
|
-
/** What makes two offers the same payment: a Debit signed for one can be
|
|
283
|
-
* presented again for the other. */
|
|
284
|
-
offerId(requirements) {
|
|
285
|
-
const clearing = requirements.extra?.["clearing"];
|
|
286
|
-
return [
|
|
287
|
-
requirements.network,
|
|
288
|
-
requirements.asset,
|
|
289
|
-
requirements.payTo,
|
|
290
|
-
requirements.amount,
|
|
291
|
-
clearing?.contract,
|
|
292
|
-
clearing?.ref,
|
|
293
|
-
]
|
|
294
|
-
.map((part) => String(part).toLowerCase())
|
|
295
|
-
.join("|");
|
|
296
|
-
}
|
|
297
|
-
/** Where the next Debit starts: the facilitator's answer while it is
|
|
298
|
-
* fresh, else a new one. */
|
|
299
|
-
async position(requirements, clearing) {
|
|
300
|
-
const key = this.key(requirements);
|
|
301
|
-
const known = this.positions.get(key);
|
|
302
|
-
if (known && this.now() - known.fetchedAt < POSITION_TTL_MS)
|
|
303
|
-
return known;
|
|
304
|
-
const answer = await fetchPosition(this.doFetch, this.facilitator, this.signer, requirements.network, clearing.contract, this.now());
|
|
305
|
-
const position = {
|
|
306
|
-
epoch: BigInt(answer.epoch),
|
|
307
|
-
index: BigInt(answer.index),
|
|
308
|
-
cumulative: BigInt(answer.cumulativeDebit),
|
|
309
|
-
available: BigInt(answer.available),
|
|
310
|
-
fetchedAt: this.now(),
|
|
311
|
-
};
|
|
312
|
-
// Every Debit this client signed it recorded first, so the true total
|
|
313
|
-
// cannot be above the highest it signed. A lower one is fine: a Debit
|
|
314
|
-
// that was never admitted.
|
|
315
|
-
const record = await this.storage.get(`${key}:${position.epoch}`);
|
|
316
|
-
if (record && position.cumulative > BigInt(record.highestSigned))
|
|
317
|
-
throw new Error("the facilitator reports a clearing total above what this client has signed; refusing to sign");
|
|
318
|
-
this.positions.set(key, position);
|
|
319
|
-
return position;
|
|
320
|
-
}
|
|
321
|
-
createPaymentPayload(x402Version, requirements) {
|
|
322
|
-
return this.exclusive(this.key(requirements), () => this.sign(x402Version, requirements));
|
|
323
|
-
}
|
|
324
|
-
async sign(x402Version, requirements) {
|
|
325
|
-
const key = this.key(requirements);
|
|
326
|
-
const offer = this.offerId(requirements);
|
|
327
|
-
// A Debit whose outcome is in doubt is presented again as it is: if it
|
|
328
|
-
// was admitted, the seller's facilitator answers with its receipt.
|
|
329
|
-
const held = this.unresolved.get(key) ?? [];
|
|
330
|
-
const pending = held.find((h) => h.offer === offer);
|
|
331
|
-
if (pending)
|
|
332
|
-
return { x402Version, payload: pending.payload };
|
|
333
|
-
// A different payment now. Whether a Debit held since the position was
|
|
334
|
-
// last asked for was admitted shows in a fresh one; it stays held for
|
|
335
|
-
// its own offer.
|
|
336
|
-
if (held.some((h) => h.unasked)) {
|
|
337
|
-
this.positions.delete(key);
|
|
338
|
-
for (const h of held)
|
|
339
|
-
h.unasked = false;
|
|
155
|
+
const pending = state.pending;
|
|
156
|
+
let result;
|
|
157
|
+
try {
|
|
158
|
+
result = await request("/payments", pending);
|
|
340
159
|
}
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
// Signed all the same: the seller refuses it as short, and the
|
|
346
|
-
// transport's retry takes the standard offer.
|
|
347
|
-
this.shortUntil.set(key, this.now() + SHORT_MS);
|
|
160
|
+
catch (error) {
|
|
161
|
+
if (error instanceof PaymentRejected)
|
|
162
|
+
await save({ ...state, pending: undefined });
|
|
163
|
+
throw error;
|
|
348
164
|
}
|
|
349
|
-
const
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
index: position.index,
|
|
354
|
-
cumulativeDebit: position.cumulative + price,
|
|
355
|
-
ref: clearing.ref,
|
|
356
|
-
};
|
|
357
|
-
// Recorded before it is released: a Debit the client has no record of
|
|
358
|
-
// signing would weaken every later check.
|
|
359
|
-
const recordKey = `${key}:${debit.epoch}`;
|
|
360
|
-
const record = await this.storage.get(recordKey);
|
|
361
|
-
const highest = record && BigInt(record.highestSigned) > debit.cumulativeDebit
|
|
362
|
-
? BigInt(record.highestSigned)
|
|
363
|
-
: debit.cumulativeDebit;
|
|
364
|
-
await this.storage.set(recordKey, {
|
|
365
|
-
epoch: debit.epoch.toString(),
|
|
366
|
-
index: (debit.index + 1n).toString(),
|
|
367
|
-
cumulativeDebit: debit.cumulativeDebit.toString(),
|
|
368
|
-
highestSigned: highest.toString(),
|
|
369
|
-
});
|
|
370
|
-
const signature = await this.signer.signTypedData({
|
|
371
|
-
domain: clearingDomain(chainIdOf(requirements.network), clearing.contract),
|
|
372
|
-
types: DEBIT_TYPES,
|
|
373
|
-
primaryType: "Debit",
|
|
374
|
-
message: debit,
|
|
375
|
-
});
|
|
376
|
-
// The next Debit starts where this one ends, if it is admitted; if it is
|
|
377
|
-
// not, the seller's refusal says so and the position is asked again.
|
|
378
|
-
this.positions.set(key, {
|
|
379
|
-
...position,
|
|
380
|
-
index: debit.index + 1n,
|
|
381
|
-
cumulative: debit.cumulativeDebit,
|
|
382
|
-
available: position.available - price,
|
|
165
|
+
const operator = await chain.readContract({
|
|
166
|
+
address: config.settlement,
|
|
167
|
+
abi: clearingAbi,
|
|
168
|
+
functionName: "operatorKey",
|
|
383
169
|
});
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
170
|
+
if (result.receipt.body !== pending.body ||
|
|
171
|
+
result.receipt.payerSignature !== pending.payerSignature ||
|
|
172
|
+
invoiceMessage(result.invoice, pending.body) !==
|
|
173
|
+
invoiceMessage(invoice, pending.body) ||
|
|
174
|
+
!verifyReceipt(result.receipt, operator))
|
|
175
|
+
throw new Error("invalid clearing acknowledgment; retain pending payment");
|
|
176
|
+
const settled = {
|
|
177
|
+
invoice,
|
|
178
|
+
receipt: result.receipt,
|
|
179
|
+
invoiceSignature: pending.invoiceSignature,
|
|
180
|
+
entries: pending.entries,
|
|
181
|
+
...(result.previous ? { previous: result.previous } : {}),
|
|
394
182
|
};
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
name: "deposit",
|
|
402
|
-
stateMutability: "nonpayable",
|
|
403
|
-
inputs: [
|
|
404
|
-
{ name: "account", type: "address" },
|
|
405
|
-
{ name: "amount", type: "uint256" },
|
|
406
|
-
],
|
|
407
|
-
outputs: [],
|
|
408
|
-
},
|
|
409
|
-
];
|
|
410
|
-
/** Put funds into clearing custody. `from` pays and sends the transactions:
|
|
411
|
-
* the agent's own wallet, or anyone funding it, in which case `account` is
|
|
412
|
-
* the agent's address. The tokens go from `from` straight into custody under
|
|
413
|
-
* `account`; only `account`'s signature can spend or withdraw them.
|
|
414
|
-
*
|
|
415
|
-
* Two transactions (an approval for exactly this amount, unless that much
|
|
416
|
-
* is already approved, then the deposit), paid for in the network's fee token by `from`. Gas and
|
|
417
|
-
* fees are set here because a Metal node cannot be asked to estimate them.
|
|
418
|
-
*
|
|
419
|
-
* The operator counts a deposit some seconds after its receipt, and refuses
|
|
420
|
-
* clearing payments against it until then. Given `facilitator`, this waits
|
|
421
|
-
* until the facilitator reports the deposit in the payer's position, so the
|
|
422
|
-
* next payment can draw on it. */
|
|
423
|
-
export async function depositToClearing(opts) {
|
|
424
|
-
if (opts.amount <= 0n)
|
|
425
|
-
throw new Error("deposit amount must be positive");
|
|
426
|
-
const credited = opts.account ?? opts.from.address;
|
|
427
|
-
if (opts.facilitator && credited.toLowerCase() !== opts.from.address.toLowerCase())
|
|
428
|
-
throw new Error("only the account itself can ask the facilitator about its custody; deposit without `facilitator` to fund another account");
|
|
429
|
-
const network = `eip155:${opts.chainId}`;
|
|
430
|
-
const facilitator = opts.facilitator?.replace(/\/$/, "");
|
|
431
|
-
const doFetch = opts.fetch ?? globalThis.fetch;
|
|
432
|
-
// What custody holds before this epoch's debits: `available` plus the
|
|
433
|
-
// epoch's running total. Paying by clearing moves value between the two,
|
|
434
|
-
// so only a deposit raises it.
|
|
435
|
-
const custody = async () => {
|
|
436
|
-
const position = await fetchPosition(doFetch, facilitator, opts.from, network, opts.contract, Date.now());
|
|
437
|
-
const available = BigInt(position.available);
|
|
438
|
-
return { epoch: position.epoch, available, held: available + BigInt(position.cumulativeDebit) };
|
|
439
|
-
};
|
|
440
|
-
// Read before depositing: an unreachable facilitator fails before any funds move.
|
|
441
|
-
let last = facilitator ? await custody() : undefined;
|
|
442
|
-
let before = last?.held ?? 0n;
|
|
443
|
-
const transport = http(opts.rpcUrl);
|
|
444
|
-
const pub = createPublicClient({ transport });
|
|
445
|
-
const wallet = createWalletClient({ account: opts.from, transport });
|
|
446
|
-
const send = async (to, data) => {
|
|
447
|
-
const [block, nonce] = await Promise.all([
|
|
448
|
-
pub.getBlock({ blockTag: "latest" }),
|
|
449
|
-
pub.getTransactionCount({
|
|
450
|
-
address: opts.from.address,
|
|
451
|
-
blockTag: "pending",
|
|
452
|
-
}),
|
|
453
|
-
]);
|
|
454
|
-
const hash = await wallet.sendTransaction({
|
|
455
|
-
chain: null,
|
|
456
|
-
chainId: opts.chainId,
|
|
457
|
-
to,
|
|
458
|
-
data,
|
|
459
|
-
value: 0n,
|
|
460
|
-
gas: 500000n,
|
|
461
|
-
maxFeePerGas: (block.baseFeePerGas ?? 0n) * 2n + 1n,
|
|
462
|
-
maxPriorityFeePerGas: 1n,
|
|
463
|
-
nonce,
|
|
464
|
-
type: "eip1559",
|
|
183
|
+
await save({
|
|
184
|
+
...state,
|
|
185
|
+
entries: pending.entries,
|
|
186
|
+
sequence: (decodeSend(pending.body).sequence + 1n).toString(),
|
|
187
|
+
pending: undefined,
|
|
188
|
+
payments: { ...state.payments, [invoice.id]: settled },
|
|
465
189
|
});
|
|
466
|
-
|
|
467
|
-
hash,
|
|
468
|
-
timeout: 60_000,
|
|
469
|
-
});
|
|
470
|
-
if (receipt.status !== "success")
|
|
471
|
-
throw new Error(`transaction reverted: ${hash}`);
|
|
472
|
-
return hash;
|
|
473
|
-
};
|
|
474
|
-
const allowance = await pub.readContract({
|
|
475
|
-
address: opts.asset,
|
|
476
|
-
abi: erc20Abi,
|
|
477
|
-
functionName: "allowance",
|
|
478
|
-
args: [opts.from.address, opts.contract],
|
|
479
|
-
});
|
|
480
|
-
const approveTx = allowance >= opts.amount
|
|
481
|
-
? null
|
|
482
|
-
: await send(opts.asset, encodeFunctionData({
|
|
483
|
-
abi: erc20Abi,
|
|
484
|
-
functionName: "approve",
|
|
485
|
-
args: [opts.contract, opts.amount],
|
|
486
|
-
}));
|
|
487
|
-
const deposit = {
|
|
488
|
-
abi: CLEARING_DEPOSIT_ABI,
|
|
489
|
-
functionName: "deposit",
|
|
490
|
-
args: [credited, opts.amount],
|
|
491
|
-
};
|
|
492
|
-
// Simulated first, so a deposit the contract would refuse (a short
|
|
493
|
-
// balance, a halted contract) fails with its reason and costs no gas.
|
|
494
|
-
await pub.simulateContract({
|
|
495
|
-
...deposit,
|
|
496
|
-
account: opts.from,
|
|
497
|
-
address: opts.contract,
|
|
498
|
-
gas: 500000n,
|
|
499
|
-
});
|
|
500
|
-
const depositTx = await send(opts.contract, encodeFunctionData(deposit));
|
|
501
|
-
if (!facilitator)
|
|
502
|
-
return { approveTx, depositTx };
|
|
503
|
-
const deadline = Date.now() + (opts.timeoutMs ?? 60_000);
|
|
504
|
-
for (;;) {
|
|
505
|
-
const now = await custody();
|
|
506
|
-
// A close settles the epoch's debits out of custody: what the last
|
|
507
|
-
// reading had available is the new base.
|
|
508
|
-
if (last && now.epoch !== last.epoch)
|
|
509
|
-
before = last.available;
|
|
510
|
-
last = now;
|
|
511
|
-
if (now.held >= before + opts.amount)
|
|
512
|
-
return { approveTx, depositTx, available: now.available };
|
|
513
|
-
if (Date.now() >= deadline)
|
|
514
|
-
throw new Error(`deposit ${depositTx} landed, but the facilitator has not counted it yet; clearing payments are refused until it does`);
|
|
515
|
-
await new Promise((resolve) => setTimeout(resolve, opts.pollMs ?? 1_000));
|
|
190
|
+
return settled;
|
|
516
191
|
}
|
|
517
|
-
}
|
|
518
|
-
/** Take funds out of clearing custody: a payer's unused deposit, or what a
|
|
519
|
-
* seller has been paid. It asks the facilitator what can be withdrawn, signs
|
|
520
|
-
* an Exit for that amount, and submits it; Metal's operator sends the two
|
|
521
|
-
* transactions (request, then release) and pays their gas.
|
|
522
|
-
*
|
|
523
|
-
* Only this wallet's signature moves its funds, and only to the destination
|
|
524
|
-
* it names, which defaults to the wallet itself. */
|
|
525
|
-
export async function withdrawFromClearing(signer, facilitator, options) {
|
|
526
|
-
const doFetch = options.fetch ?? globalThis.fetch;
|
|
527
|
-
const base = facilitator.replace(/\/$/, "");
|
|
528
|
-
const now = options.now?.() ?? Date.now();
|
|
529
|
-
const listed = await doFetch(`${base}/supported`);
|
|
530
|
-
if (!listed.ok)
|
|
531
|
-
throw new Error(`facilitator refused: ${listed.status}`);
|
|
532
|
-
const supported = (await listed.json());
|
|
533
|
-
const kind = supported.kinds.find((item) => item.scheme === CLEARING_DEBIT && item.network === options.network);
|
|
534
|
-
const contract = kind?.extra?.contract;
|
|
535
|
-
if (!contract)
|
|
536
|
-
throw new Error(`no clearing on ${options.network} here`);
|
|
537
|
-
const quote = await post(doFetch, `${base}/clearing/exit/quote`, await accountRequest(signer, options.network, contract, now));
|
|
538
|
-
if (BigInt(quote.deadline) > BigInt(Math.floor(now / 1000) + 86_400))
|
|
539
|
-
throw new Error("the quoted deadline is too far away to sign");
|
|
540
|
-
const available = BigInt(quote.amount);
|
|
541
|
-
const amount = options.amount ?? available;
|
|
542
|
-
if (amount > available)
|
|
543
|
-
throw new Error(`only ${available} can be withdrawn now`);
|
|
544
|
-
if (amount <= 0n) {
|
|
545
|
-
// Custody becomes withdrawable once the epoch holding it has closed and
|
|
546
|
-
// passed its challenge window; until then it is only spendable.
|
|
547
|
-
const held = BigInt((await fetchPosition(doFetch, base, signer, options.network, contract, now))
|
|
548
|
-
.available);
|
|
549
|
-
throw new Error(held > 0n
|
|
550
|
-
? `nothing to withdraw yet: ${held} (base units) is in custody and can be withdrawn once the epoch it is in has closed and passed its challenge window`
|
|
551
|
-
: "nothing to withdraw");
|
|
552
|
-
}
|
|
553
|
-
const exit = {
|
|
554
|
-
account: signer.address,
|
|
555
|
-
destination: options.destination ?? signer.address,
|
|
556
|
-
amount,
|
|
557
|
-
deadline: BigInt(quote.deadline),
|
|
558
|
-
};
|
|
559
|
-
const signature = await signer.signTypedData({
|
|
560
|
-
domain: clearingDomain(chainIdOf(options.network), contract),
|
|
561
|
-
types: EXIT_TYPES,
|
|
562
|
-
primaryType: "Exit",
|
|
563
|
-
message: exit,
|
|
564
|
-
});
|
|
565
|
-
const done = await post(doFetch, `${base}/clearing/exit`, {
|
|
566
|
-
network: options.network,
|
|
567
|
-
account: exit.account,
|
|
568
|
-
destination: exit.destination,
|
|
569
|
-
amount: exit.amount.toString(),
|
|
570
|
-
deadline: exit.deadline.toString(),
|
|
571
|
-
signature,
|
|
572
|
-
});
|
|
573
192
|
return {
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
193
|
+
account: config.signer.publicKey,
|
|
194
|
+
async pendingInvoice() {
|
|
195
|
+
return (await config.store.load())?.pending?.invoice ?? null;
|
|
196
|
+
},
|
|
197
|
+
payable(invoice) {
|
|
198
|
+
return refusal(invoice) === undefined;
|
|
199
|
+
},
|
|
200
|
+
pay(invoice) {
|
|
201
|
+
const result = tail.then(() => pay(invoice));
|
|
202
|
+
tail = result.catch(() => { });
|
|
203
|
+
return result;
|
|
204
|
+
},
|
|
205
|
+
request,
|
|
579
206
|
};
|
|
580
207
|
}
|