@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
package/dist/clearing-client.js
CHANGED
|
@@ -1,580 +1,439 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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;
|
|
43
|
-
}
|
|
44
|
-
/** The default store: positions do not survive a process restart. */
|
|
45
|
-
export class InMemoryClearingPositionStorage {
|
|
46
|
-
positions = new Map();
|
|
47
|
-
async get(key) {
|
|
48
|
-
return this.positions.get(key);
|
|
49
|
-
}
|
|
50
|
-
async set(key, position) {
|
|
51
|
-
this.positions.set(key, keepHighest(this.positions.get(key), position));
|
|
1
|
+
import { BaseError, createPublicClient } from "viem";
|
|
2
|
+
import { clearingAbi } from "./clearing-abi.js";
|
|
3
|
+
import { CLEARING_REFERENCE, clearingUnits, decodeSend, encodeSend, invoiceMessage, outgoingRoot, verifyReceipt, } from "./clearing-codec.js";
|
|
4
|
+
import { formatUsdz, rpcTransport, USDZ } from "./networks.js";
|
|
5
|
+
/** A clearing payment the service did not take: `reason` says why, `retry`
|
|
6
|
+
* what to do, and the message both in words. */
|
|
7
|
+
export class ClearingRefusal extends Error {
|
|
8
|
+
reason;
|
|
9
|
+
retry;
|
|
10
|
+
name = "ClearingRefusal";
|
|
11
|
+
constructor(reason, retry, message, options) {
|
|
12
|
+
super(message, options);
|
|
13
|
+
this.reason = reason;
|
|
14
|
+
this.retry = retry;
|
|
52
15
|
}
|
|
53
16
|
}
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
17
|
+
/** The service takes no payment from an epoch's close until the next
|
|
18
|
+
* epoch's registration, a gap of a few seconds. The wait between tries,
|
|
19
|
+
* and before a held payment is sent again, is the 2 s the service waits
|
|
20
|
+
* after an epoch's admission before registering the next, plus a block to
|
|
21
|
+
* include the registration. */
|
|
22
|
+
const BETWEEN_EPOCHS_RETRY_MS = 3_000;
|
|
23
|
+
/** The most recipients the service's payment parser takes in a vector. */
|
|
24
|
+
const MAX_ENTRIES = 1024;
|
|
25
|
+
function betweenEpochs(cause) {
|
|
26
|
+
return new ClearingRefusal("between_epochs", { action: "retry_after_ms", ms: BETWEEN_EPOCHS_RETRY_MS }, `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`, cause === undefined ? {} : { cause });
|
|
59
27
|
}
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
-
};
|
|
28
|
+
/** The error for a faulted deployment; `sent` when the payment reached the
|
|
29
|
+
* service with its outcome unknown. */
|
|
30
|
+
function faulted(cause, sent = false) {
|
|
31
|
+
return new Error(`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`, cause === undefined ? {} : { cause });
|
|
81
32
|
}
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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;
|
|
33
|
+
/** A held payment, by its price and the URL it pays for. */
|
|
34
|
+
function describe(pending) {
|
|
35
|
+
return `${pending.invoice.amount} base units ${pending.url === undefined ? `under invoice ${pending.invoice.id}` : `for ${pending.url}`}`;
|
|
92
36
|
}
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
return answer;
|
|
37
|
+
function messageOf(cause) {
|
|
38
|
+
return cause instanceof BaseError
|
|
39
|
+
? cause.shortMessage
|
|
40
|
+
: cause instanceof Error
|
|
41
|
+
? cause.message
|
|
42
|
+
: String(cause);
|
|
100
43
|
}
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
const
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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
|
-
};
|
|
44
|
+
/** A refusal naming the payment the wallet holds until it goes through:
|
|
45
|
+
* one held from before, one whose answer was lost, or one whose
|
|
46
|
+
* acknowledgment did not verify, all three held alike. */
|
|
47
|
+
function held(kind, pending, cause) {
|
|
48
|
+
const payment = describe(pending);
|
|
49
|
+
const release = "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";
|
|
50
|
+
return new ClearingRefusal(kind === "payment_held" ? "payment_held" : "answer_lost", {
|
|
51
|
+
action: "repeat_same_request",
|
|
52
|
+
url: pending.url ?? null,
|
|
53
|
+
amount: pending.invoice.amount,
|
|
54
|
+
ms: BETWEEN_EPOCHS_RETRY_MS,
|
|
55
|
+
}, kind === "unverified"
|
|
56
|
+
? `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`
|
|
57
|
+
: kind === "answer_lost"
|
|
58
|
+
? `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}`
|
|
59
|
+
: `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}`, cause === undefined ? {} : { cause });
|
|
60
|
+
}
|
|
61
|
+
/** The error for a payment held for another deployment, network or asset
|
|
62
|
+
* than the wallet's: it can never be sent here. */
|
|
63
|
+
function stranded(pending, which) {
|
|
64
|
+
return new Error(`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)}`);
|
|
65
|
+
}
|
|
66
|
+
/** A clearing service's refusal; `rejected` when it never takes the payment. */
|
|
67
|
+
class ServiceRefused extends Error {
|
|
68
|
+
status;
|
|
69
|
+
rejected;
|
|
70
|
+
constructor(status, rejected) {
|
|
71
|
+
super(rejected
|
|
72
|
+
? "clearing payment rejected before acknowledgment"
|
|
73
|
+
: `clearing service returned ${status}`);
|
|
74
|
+
this.status = status;
|
|
75
|
+
this.rejected = rejected;
|
|
180
76
|
}
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
throw new Error(SELLER_REFUSES_CLEARING);
|
|
77
|
+
}
|
|
78
|
+
const unavailable = (error) => error instanceof ServiceRefused && error.status === 409 && !error.rejected;
|
|
79
|
+
export function createClearingClient(config) {
|
|
80
|
+
const fetcher = config.fetch ?? globalThis.fetch;
|
|
81
|
+
const chain = createPublicClient({ transport: rpcTransport(config.rpcUrl) });
|
|
82
|
+
let tail = Promise.resolve();
|
|
83
|
+
let broken = false;
|
|
84
|
+
async function save(state) {
|
|
85
|
+
try {
|
|
86
|
+
await config.store.save(state);
|
|
192
87
|
}
|
|
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);
|
|
88
|
+
catch (error) {
|
|
89
|
+
broken = true;
|
|
90
|
+
throw error;
|
|
203
91
|
}
|
|
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
92
|
}
|
|
217
|
-
|
|
218
|
-
const
|
|
219
|
-
|
|
93
|
+
async function request(path, body) {
|
|
94
|
+
const response = await fetcher(new URL(path, config.serviceUrl), {
|
|
95
|
+
method: body ? "POST" : "GET",
|
|
96
|
+
...(body
|
|
97
|
+
? {
|
|
98
|
+
headers: { "content-type": "application/json" },
|
|
99
|
+
body: JSON.stringify(body),
|
|
100
|
+
}
|
|
101
|
+
: {}),
|
|
102
|
+
signal: AbortSignal.timeout(30_000),
|
|
103
|
+
});
|
|
104
|
+
if (!response.ok) {
|
|
105
|
+
const result = (await response.json().catch(() => null));
|
|
106
|
+
throw new ServiceRefused(response.status, result?.rejected === true);
|
|
107
|
+
}
|
|
108
|
+
return (await response.json());
|
|
220
109
|
}
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
110
|
+
/** How `invoice` is for another network, deployment or asset than this
|
|
111
|
+
* wallet's, or undefined when it is not. */
|
|
112
|
+
function differs(invoice) {
|
|
113
|
+
if (invoice.network !== config.network)
|
|
114
|
+
return { what: "", theirs: invoice.network, ours: config.network };
|
|
115
|
+
if (invoice.settlement.toLowerCase() !== config.settlement.toLowerCase())
|
|
116
|
+
return { what: "deployment ", theirs: invoice.settlement, ours: config.settlement };
|
|
117
|
+
if (invoice.asset.toLowerCase() !== config.asset.toLowerCase())
|
|
118
|
+
return { what: "asset ", theirs: invoice.asset, ours: config.asset };
|
|
224
119
|
}
|
|
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
|
-
this.holdSent(header);
|
|
251
|
-
return response;
|
|
252
|
-
};
|
|
120
|
+
/** Why this wallet does not pay `invoice`, or undefined when it does;
|
|
121
|
+
* `aboveMaxAmount` when it is for this wallet's deployment at a price
|
|
122
|
+
* above its maxAmount. */
|
|
123
|
+
function refusal(invoice) {
|
|
124
|
+
const elsewhere = differs(invoice);
|
|
125
|
+
if (elsewhere)
|
|
126
|
+
return { reason: `offer is for ${elsewhere.what}${elsewhere.theirs}, not this wallet's ${elsewhere.ours}`, aboveMaxAmount: false };
|
|
127
|
+
if (!/^[1-9][0-9]*$/.test(invoice.amount))
|
|
128
|
+
return { reason: `offer of ${invoice.amount} base units is not a positive whole number in canonical form`, aboveMaxAmount: false };
|
|
129
|
+
const amount = BigInt(invoice.amount);
|
|
130
|
+
if (amount > config.maxAmount)
|
|
131
|
+
return {
|
|
132
|
+
reason: `offer ${formatUsdz(amount)} ${USDZ.symbol} is above this wallet's maxAmount ${formatUsdz(config.maxAmount)} ${USDZ.symbol}`,
|
|
133
|
+
aboveMaxAmount: true,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
/** Why a service that takes no payments does not: the deployment has
|
|
137
|
+
* faulted, for good, or it is between epochs. */
|
|
138
|
+
async function closed(cause) {
|
|
139
|
+
const fault = await onChain(chain.readContract({
|
|
140
|
+
address: config.settlement,
|
|
141
|
+
abi: clearingAbi,
|
|
142
|
+
functionName: "fault",
|
|
143
|
+
}));
|
|
144
|
+
return fault ? faulted(cause) : betweenEpochs(cause);
|
|
253
145
|
}
|
|
254
|
-
/**
|
|
255
|
-
|
|
256
|
-
|
|
146
|
+
/** `reads` of the Clearing contract, once the RPC is known to serve
|
|
147
|
+
* `config.network`: on another chain they fail or mean nothing. */
|
|
148
|
+
async function onChain(reads) {
|
|
149
|
+
reads.catch(() => { });
|
|
150
|
+
const chainId = await chain.getChainId();
|
|
151
|
+
if (`eip155:${chainId}` !== config.network)
|
|
152
|
+
throw new Error(`the clearing client's rpcUrl serves eip155:${chainId}, not ${config.network}; nothing was paid: give it an RPC URL for ${config.network}`);
|
|
153
|
+
return reads;
|
|
154
|
+
}
|
|
155
|
+
/** Why the service refused a payment signed for `epoch` for good: the
|
|
156
|
+
* epoch closed as it was sent, or, while it is still open, the account's
|
|
157
|
+
* deposit does not cover the payment, or neither, which the service does
|
|
158
|
+
* not say. */
|
|
159
|
+
async function rejection(epoch, invoice, cause) {
|
|
160
|
+
let covered;
|
|
161
|
+
let opened = false;
|
|
257
162
|
try {
|
|
258
|
-
|
|
163
|
+
const [context, fault] = await Promise.all([
|
|
164
|
+
request("/context"),
|
|
165
|
+
chain.readContract({
|
|
166
|
+
address: config.settlement,
|
|
167
|
+
abi: clearingAbi,
|
|
168
|
+
functionName: "fault",
|
|
169
|
+
}),
|
|
170
|
+
]);
|
|
171
|
+
if (fault)
|
|
172
|
+
return faulted(cause);
|
|
173
|
+
if (BigInt(context.epoch) !== epoch)
|
|
174
|
+
return betweenEpochs(cause);
|
|
175
|
+
opened = true;
|
|
176
|
+
const [{ amount }, unit] = await Promise.all([
|
|
177
|
+
request(`/spendable/${config.signer.publicKey}`),
|
|
178
|
+
chain.readContract({
|
|
179
|
+
address: config.settlement,
|
|
180
|
+
abi: clearingAbi,
|
|
181
|
+
functionName: "unit",
|
|
182
|
+
}),
|
|
183
|
+
]);
|
|
184
|
+
covered = BigInt(amount) >= clearingUnits(BigInt(invoice.amount), unit);
|
|
259
185
|
}
|
|
260
|
-
catch {
|
|
261
|
-
|
|
186
|
+
catch (error) {
|
|
187
|
+
if (!opened && unavailable(error))
|
|
188
|
+
return closed(cause);
|
|
189
|
+
return new Error(`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`, { cause });
|
|
262
190
|
}
|
|
263
|
-
if (
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
hold(key, offer, payload) {
|
|
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);
|
|
191
|
+
if (covered)
|
|
192
|
+
return new Error(`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`, { cause });
|
|
193
|
+
return new ClearingRefusal("insufficient_deposit", { action: "deposit" }, `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`, { cause });
|
|
281
194
|
}
|
|
282
|
-
/** What
|
|
283
|
-
*
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
195
|
+
/** What the service retained of a payment sent again without a usable
|
|
196
|
+
* answer: its receipt; `"released"` when it has no record of the payment
|
|
197
|
+
* and the epoch it was signed for is closed, or is no longer the live
|
|
198
|
+
* one on a service that is this wallet's deployment, so a later
|
|
199
|
+
* acceptance cannot overtake the answer; or nothing, when it cannot say.
|
|
200
|
+
* A refusal alone proves nothing: a proxy can answer one after the
|
|
201
|
+
* service accepted the payment. */
|
|
202
|
+
async function retained(pending) {
|
|
203
|
+
try {
|
|
204
|
+
const sent = decodeSend(pending.body);
|
|
205
|
+
// Read before the receipt: a closed epoch takes no payment after it.
|
|
206
|
+
const closed = await Promise.all([
|
|
207
|
+
chain.readContract({ address: config.settlement, abi: clearingAbi, functionName: "admittedCount" }),
|
|
208
|
+
request("/context"),
|
|
209
|
+
]).then(([admitted, context]) => context.network !== config.network ||
|
|
210
|
+
context.settlement.toLowerCase() !== config.settlement.toLowerCase() ||
|
|
211
|
+
context.asset.toLowerCase() !== config.asset.toLowerCase()
|
|
212
|
+
? undefined
|
|
213
|
+
: admitted > sent.epoch || BigInt(context.epoch) !== sent.epoch || context.anchor !== sent.anchor, () => undefined);
|
|
214
|
+
const found = await request(`/receipt/${config.signer.publicKey}/${encodeURIComponent(pending.invoice.id)}?epoch=${sent.epoch}`);
|
|
215
|
+
if (found)
|
|
216
|
+
return {
|
|
217
|
+
paid: found,
|
|
218
|
+
operator: await chain.readContract({ address: config.settlement, abi: clearingAbi, functionName: "operatorKey" }),
|
|
219
|
+
};
|
|
220
|
+
return closed ? "released" : undefined;
|
|
221
|
+
}
|
|
222
|
+
catch {
|
|
223
|
+
return undefined;
|
|
224
|
+
}
|
|
296
225
|
}
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
const
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
226
|
+
async function pay(invoice, url) {
|
|
227
|
+
if (broken)
|
|
228
|
+
throw new Error("clearing wallet persistence failed; reopen the wallet");
|
|
229
|
+
const refused = refusal(invoice);
|
|
230
|
+
if (refused) {
|
|
231
|
+
const held = (await config.store.load())?.pending;
|
|
232
|
+
const elsewhere = held?.invoice.id === invoice.id ? differs(held.invoice) : undefined;
|
|
233
|
+
throw held && elsewhere
|
|
234
|
+
? stranded(held, elsewhere)
|
|
235
|
+
: new Error(held?.invoice.id === invoice.id && refused.aboveMaxAmount
|
|
236
|
+
? `${refused.reason}; the wallet holds this payment and pays nothing else until it goes through: raise the wallet's maxAmount to send it`
|
|
237
|
+
: refused.reason);
|
|
238
|
+
}
|
|
239
|
+
const amount = BigInt(invoice.amount);
|
|
240
|
+
let state = (await config.store.load()) ?? {
|
|
241
|
+
entries: [],
|
|
242
|
+
sequence: "0",
|
|
243
|
+
payments: {},
|
|
311
244
|
};
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
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;
|
|
245
|
+
const paid = state.payments[invoice.id];
|
|
246
|
+
if (paid) {
|
|
247
|
+
if (invoiceMessage(paid.invoice, paid.receipt.body) !==
|
|
248
|
+
invoiceMessage(invoice, paid.receipt.body))
|
|
249
|
+
throw new Error("invoice id reused with different terms");
|
|
250
|
+
return paid;
|
|
340
251
|
}
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
// transport's retry takes the standard offer.
|
|
347
|
-
this.shortUntil.set(key, this.now() + SHORT_MS);
|
|
252
|
+
if (state.pending &&
|
|
253
|
+
invoiceMessage(state.pending.invoice, state.pending.body) !==
|
|
254
|
+
invoiceMessage(invoice, state.pending.body)) {
|
|
255
|
+
const elsewhere = differs(state.pending.invoice);
|
|
256
|
+
throw elsewhere ? stranded(state.pending, elsewhere) : held("payment_held", state.pending);
|
|
348
257
|
}
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
258
|
+
// A payment sent again has been sent before.
|
|
259
|
+
const resent = state.pending !== undefined;
|
|
260
|
+
if (!state.pending) {
|
|
261
|
+
const context = await request("/context").catch(async (error) => {
|
|
262
|
+
throw unavailable(error)
|
|
263
|
+
? await closed(error)
|
|
264
|
+
: error instanceof ServiceRefused && [401, 403, 404].includes(error.status)
|
|
265
|
+
? new Error(`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`, { cause: error })
|
|
266
|
+
: new Error(`the clearing service did not give its epoch (${messageOf(error)}); nothing was paid and nothing is held: send the request again`, { cause: error });
|
|
267
|
+
});
|
|
268
|
+
if (context.reference !== CLEARING_REFERENCE ||
|
|
269
|
+
context.network !== config.network ||
|
|
270
|
+
context.settlement.toLowerCase() !== config.settlement.toLowerCase() ||
|
|
271
|
+
context.asset.toLowerCase() !== config.asset.toLowerCase())
|
|
272
|
+
throw new Error("unsupported clearing service deployment");
|
|
273
|
+
const epoch = BigInt(context.epoch);
|
|
274
|
+
const [registered, onchain, fault, admitted, block, unit] = await onChain(Promise.all([
|
|
275
|
+
chain.readContract({
|
|
276
|
+
address: config.settlement,
|
|
277
|
+
abi: clearingAbi,
|
|
278
|
+
functionName: "registered",
|
|
279
|
+
}),
|
|
280
|
+
chain.readContract({
|
|
281
|
+
address: config.settlement,
|
|
282
|
+
abi: clearingAbi,
|
|
283
|
+
functionName: "contextOf",
|
|
284
|
+
args: [epoch],
|
|
285
|
+
}),
|
|
286
|
+
chain.readContract({
|
|
287
|
+
address: config.settlement,
|
|
288
|
+
abi: clearingAbi,
|
|
289
|
+
functionName: "fault",
|
|
290
|
+
}),
|
|
291
|
+
chain.readContract({
|
|
292
|
+
address: config.settlement,
|
|
293
|
+
abi: clearingAbi,
|
|
294
|
+
functionName: "admittedCount",
|
|
295
|
+
}),
|
|
296
|
+
chain.getBlock(),
|
|
297
|
+
chain.readContract({
|
|
298
|
+
address: config.settlement,
|
|
299
|
+
abi: clearingAbi,
|
|
300
|
+
functionName: "unit",
|
|
301
|
+
}),
|
|
302
|
+
]));
|
|
303
|
+
if (fault)
|
|
304
|
+
throw faulted();
|
|
305
|
+
// The contract has not registered the service's epoch yet, or has
|
|
306
|
+
// closed it to payments.
|
|
307
|
+
if (!registered ||
|
|
308
|
+
admitted !== epoch ||
|
|
309
|
+
block.timestamp >= onchain.admissionDeadline)
|
|
310
|
+
throw betweenEpochs();
|
|
311
|
+
if (onchain.anchor !== context.anchor ||
|
|
312
|
+
onchain.operatorKey !== context.operator)
|
|
313
|
+
throw new Error("clearing epoch is not registered");
|
|
314
|
+
if (state.epoch !== context.epoch)
|
|
315
|
+
state = { ...state, epoch: context.epoch, sequence: "0", entries: [] };
|
|
316
|
+
const entries = state.entries.map((e) => ({ ...e }));
|
|
317
|
+
let entry = entries.find((e) => e.recipient.toLowerCase() === invoice.recipient.toLowerCase());
|
|
318
|
+
if (!entry) {
|
|
319
|
+
entry = { recipient: invoice.recipient, cumulative: "0", count: "0" };
|
|
320
|
+
entries.push(entry);
|
|
321
|
+
}
|
|
322
|
+
// The invoice is in token base units; the vector counts clearing units.
|
|
323
|
+
entry.cumulative = (BigInt(entry.cumulative) + clearingUnits(amount, unit)).toString();
|
|
324
|
+
entry.count = (BigInt(entry.count) + 1n).toString();
|
|
325
|
+
// The service takes at most this many recipients in one payment's vector.
|
|
326
|
+
if (entries.length > MAX_ENTRIES)
|
|
327
|
+
throw new Error(`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`);
|
|
328
|
+
entries.sort((a, b) => a.recipient.toLowerCase().localeCompare(b.recipient.toLowerCase()));
|
|
329
|
+
const vector = entries.map((e) => ({
|
|
330
|
+
...e,
|
|
331
|
+
cumulative: BigInt(e.cumulative),
|
|
332
|
+
count: BigInt(e.count),
|
|
333
|
+
}));
|
|
334
|
+
const body = encodeSend({
|
|
335
|
+
anchor: context.anchor,
|
|
336
|
+
epoch,
|
|
337
|
+
payer: config.signer.publicKey,
|
|
338
|
+
sequence: BigInt(state.sequence),
|
|
339
|
+
debit: vector.reduce((sum, e) => sum + e.cumulative, 0n),
|
|
340
|
+
vector: outgoingRoot(vector),
|
|
341
|
+
});
|
|
342
|
+
state = {
|
|
343
|
+
...state,
|
|
344
|
+
pending: {
|
|
345
|
+
invoice,
|
|
346
|
+
body,
|
|
347
|
+
entries,
|
|
348
|
+
payerSignature: await config.signer.sign("_COMMONWARE_CLEARING_VECTOR_SEND", body),
|
|
349
|
+
invoiceSignature: await config.signer.sign("_METAL_CLEARING_INVOICE", invoiceMessage(invoice, body)),
|
|
350
|
+
url,
|
|
351
|
+
},
|
|
352
|
+
};
|
|
353
|
+
await save(state);
|
|
354
|
+
}
|
|
355
|
+
const pending = state.pending;
|
|
356
|
+
let result;
|
|
357
|
+
let operator;
|
|
358
|
+
try {
|
|
359
|
+
result = await request("/payments", {
|
|
360
|
+
invoice: pending.invoice,
|
|
361
|
+
body: pending.body,
|
|
362
|
+
payerSignature: pending.payerSignature,
|
|
363
|
+
invoiceSignature: pending.invoiceSignature,
|
|
364
|
+
entries: pending.entries,
|
|
365
|
+
});
|
|
366
|
+
operator = await chain.readContract({
|
|
367
|
+
address: config.settlement,
|
|
368
|
+
abi: clearingAbi,
|
|
369
|
+
functionName: "operatorKey",
|
|
370
|
+
});
|
|
371
|
+
}
|
|
372
|
+
catch (error) {
|
|
373
|
+
if (error instanceof ServiceRefused && error.rejected) {
|
|
374
|
+
await save({ ...state, pending: undefined });
|
|
375
|
+
throw await rejection(decodeSend(pending.body).epoch, invoice, error);
|
|
376
|
+
}
|
|
377
|
+
// Once the deployment has faulted, recovery takes the account's
|
|
378
|
+
// funds whatever became of the payment.
|
|
379
|
+
const fault = await chain
|
|
380
|
+
.readContract({ address: config.settlement, abi: clearingAbi, functionName: "fault" })
|
|
381
|
+
.catch(() => 0);
|
|
382
|
+
if (fault)
|
|
383
|
+
throw faulted(error, true);
|
|
384
|
+
const kept = resent ? await retained(pending) : undefined;
|
|
385
|
+
if (kept === "released") {
|
|
386
|
+
// The vector it extended belongs to an epoch that is closed or gone.
|
|
387
|
+
await save({ ...state, epoch: undefined, entries: [], sequence: "0", pending: undefined });
|
|
388
|
+
throw new Error(`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`, { cause: error });
|
|
389
|
+
}
|
|
390
|
+
if (!kept)
|
|
391
|
+
throw held("answer_lost", pending, error);
|
|
392
|
+
({ paid: result, operator } = kept);
|
|
393
|
+
}
|
|
394
|
+
let verified = false;
|
|
395
|
+
try {
|
|
396
|
+
verified =
|
|
397
|
+
result.receipt.payerSignature === pending.payerSignature &&
|
|
398
|
+
invoiceMessage(result.invoice, pending.body) === invoiceMessage(invoice, pending.body) &&
|
|
399
|
+
verifyReceipt(result.receipt, operator);
|
|
400
|
+
}
|
|
401
|
+
catch {
|
|
402
|
+
// An answer that is no receipt does not verify.
|
|
403
|
+
}
|
|
404
|
+
if (!verified)
|
|
405
|
+
throw held("unverified", pending);
|
|
406
|
+
const settled = {
|
|
407
|
+
invoice,
|
|
408
|
+
receipt: result.receipt,
|
|
409
|
+
invoiceSignature: pending.invoiceSignature,
|
|
410
|
+
entries: pending.entries,
|
|
411
|
+
...(result.previous ? { previous: result.previous } : {}),
|
|
394
412
|
};
|
|
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",
|
|
413
|
+
await save({
|
|
414
|
+
...state,
|
|
415
|
+
entries: pending.entries,
|
|
416
|
+
sequence: (decodeSend(pending.body).sequence + 1n).toString(),
|
|
417
|
+
pending: undefined,
|
|
418
|
+
payments: { ...state.payments, [invoice.id]: settled },
|
|
465
419
|
});
|
|
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));
|
|
420
|
+
return settled;
|
|
516
421
|
}
|
|
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
422
|
return {
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
423
|
+
account: config.signer.publicKey,
|
|
424
|
+
async pendingInvoice() {
|
|
425
|
+
return (await config.store.load())?.pending?.invoice ?? null;
|
|
426
|
+
},
|
|
427
|
+
/** Why this wallet does not pay `invoice`, or undefined when it does. */
|
|
428
|
+
unpayable: refusal,
|
|
429
|
+
/** Pay `invoice`; `url`, the request it pays for, is kept with a
|
|
430
|
+
* payment whose outcome is unknown, so a refusal can name it. A payment
|
|
431
|
+
* the service does not take throws a `ClearingRefusal`. */
|
|
432
|
+
pay(invoice, url) {
|
|
433
|
+
const result = tail.then(() => pay(invoice, url));
|
|
434
|
+
tail = result.catch(() => { });
|
|
435
|
+
return result;
|
|
436
|
+
},
|
|
437
|
+
request,
|
|
579
438
|
};
|
|
580
439
|
}
|