@metalntwx/metal-pay 0.3.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 +41 -14
- package/dist/clearing-client.d.ts +51 -4
- package/dist/clearing-client.js +277 -45
- package/dist/clearing-custody.d.ts +17 -6
- package/dist/clearing-custody.js +91 -29
- package/dist/clearing-x402.d.ts +10 -1
- package/dist/clearing-x402.js +59 -13
- package/dist/client.d.ts +7 -2
- package/dist/client.js +38 -5
- package/dist/mandates/account.d.ts +9 -4
- package/dist/mandates/account.js +15 -11
- package/dist/mandates/seller.js +3 -3
- package/dist/mandates/verify.js +107 -18
- package/dist/networks.d.ts +9 -1
- package/dist/networks.js +105 -4
- package/package.json +1 -1
- package/src/clearing-client.test.ts +850 -0
- package/src/clearing-client.ts +366 -57
- package/src/clearing-custody.test.ts +240 -11
- package/src/clearing-custody.ts +106 -28
- package/src/clearing-server.test.ts +72 -10
- package/src/clearing-x402.test.ts +1 -1
- package/src/clearing-x402.ts +61 -12
- package/src/client.test.ts +8 -0
- package/src/client.ts +46 -7
- package/src/mandates/account.ts +15 -11
- package/src/mandates/seller.ts +3 -3
- package/src/mandates/verify.ts +113 -20
- package/src/networks.test.ts +163 -2
- package/src/networks.ts +95 -5
package/README.md
CHANGED
|
@@ -15,7 +15,8 @@ client; `registerMetalNetworks(client, { signer, networks? })`
|
|
|
15
15
|
registers the scheme on an `x402Client` you already have. Both refuse,
|
|
16
16
|
before signing, a Metal `exact` offer in any token but `*USDZ` or under any
|
|
17
17
|
EIP-712 domain but USDZ's. `createMetalPaymentClient` also refuses any offer
|
|
18
|
-
above `maxAmount`, a `bigint` in `*USDZ` base units
|
|
18
|
+
above `maxAmount`, a `bigint` in `*USDZ` base units, except a clearing
|
|
19
|
+
offer, which its wallet's own `maxAmount` caps. The default is
|
|
19
20
|
`DEFAULT_MAX_PAYMENT`, 1 `*USDZ`. It works with `@x402/core`, `@x402/evm`
|
|
20
21
|
and `@x402/fetch` from 2.28 up to the next major, installed as its peers
|
|
21
22
|
(npm 7+ and pnpm do that themselves). The stock client pays only tokens in
|
|
@@ -91,14 +92,28 @@ pending authorization across transport errors. Supply trusted `serviceUrl`,
|
|
|
91
92
|
`openClearingWallet(directory)` from `@metalntwx/metal-pay/node` supplies a
|
|
92
93
|
persistent signer and store with exclusive
|
|
93
94
|
process ownership. Preserve that directory across restarts; call `close()` on
|
|
94
|
-
shutdown.
|
|
95
|
+
shutdown. A payment the service does not take throws a `ClearingRefusal`,
|
|
96
|
+
whose `reason` is `between_epochs`, `insufficient_deposit`, `answer_lost`
|
|
97
|
+
or `payment_held` and whose `retry` says what to do: wait `retry.ms`
|
|
98
|
+
(again after each further `between_epochs`), deposit, or send the held
|
|
99
|
+
request, `retry.url` at `retry.amount`, again after `retry.ms` before any
|
|
100
|
+
other. If that URL no longer offers clearing at that price, pass the
|
|
101
|
+
client's `pendingInvoice()` to its `pay`. A held payment sent again is
|
|
102
|
+
settled from the service's receipt when it kept one, and released when it
|
|
103
|
+
has none and its epoch has closed or is no longer the service's live one.
|
|
104
|
+
A wallet's record is for one Clearing deployment: open a new wallet for a
|
|
105
|
+
replacement. Through `createMetalPaymentClient`'s `fetchWithPayment` it is
|
|
106
|
+
the thrown error's `cause`.
|
|
95
107
|
|
|
96
108
|
`clearingRequirements` and `clearingInvoice` bind an invoice to a resource,
|
|
97
109
|
recipient, amount and deployment. `registerClearingClient` adds the `bajillion`
|
|
98
110
|
scheme to an official x402 client, which then pays by clearing wherever its
|
|
99
|
-
wallet can pay the offer
|
|
100
|
-
|
|
101
|
-
|
|
111
|
+
wallet can pay the offer and refuses any other request on that
|
|
112
|
+
registration's network, unless given `{ exactFallback: true }` to pay it by
|
|
113
|
+
`exact`. Behind the stock middleware,
|
|
114
|
+
Metal's facilitator verifies and deduplicates clearing payments before the
|
|
115
|
+
handler runs. A seller that verifies them itself calls
|
|
116
|
+
`verifyClearingPayment` with its own trusted
|
|
102
117
|
RPC/service configuration and deduplicates `clearingPaymentId` in durable
|
|
103
118
|
storage before delivering. The returned receipt is **preconfirmed**;
|
|
104
119
|
watch `Clearing.finalizedCount` before labeling its epoch finalized. It is not
|
|
@@ -106,19 +121,25 @@ an EVM transaction hash.
|
|
|
106
121
|
|
|
107
122
|
`depositToClearing` funds a clearing account from an EVM account, waiting
|
|
108
123
|
for the contract's deposit window; `clearingSpendable` reads the service's
|
|
109
|
-
estimate of what the account can spend, in token base units,
|
|
124
|
+
estimate of what the account can spend, in token base units, which counts
|
|
125
|
+
a deposit from the epoch after it lands and can read low around an
|
|
126
|
+
epoch's close, and
|
|
110
127
|
`clearingBalance` the finalized balance and its proof, in clearing units
|
|
111
128
|
(`Clearing.unit()` base units each, a millionth of `*USDZ`);
|
|
112
129
|
`withdrawFromClearing` queues a withdrawal of the finalized balance and,
|
|
113
|
-
once its epoch has finalized, claims it, for payer and seller alike
|
|
130
|
+
once its epoch has finalized, claims it, for payer and seller alike; it
|
|
131
|
+
answers `empty` when there is nothing finalized to claim or queue. A
|
|
132
|
+
queue or claim refused because an epoch finalized under its proof moved
|
|
133
|
+
nothing, and its error says to call again after the next epoch.
|
|
114
134
|
`clearingClose` and `clearingLogProof` are the pieces it is built from.
|
|
115
135
|
`recoverFromClearing` takes an account's funds out of a deployment whose
|
|
116
136
|
service has missed a deadline: it faults the deployment, starts recovery,
|
|
117
137
|
and takes the payout of the account's finalized withdrawal, its last
|
|
118
138
|
finalized balance and its uncounted deposits, with proofs from the service
|
|
119
|
-
or the first of several validators that retained the epoch. The
|
|
120
|
-
|
|
121
|
-
operator/validator setup, receipt retention and proof
|
|
139
|
+
or the first of several validators that retained the epoch. The clearing
|
|
140
|
+
service's README, `apps/clearing-service/README.md` in the Metal
|
|
141
|
+
repository, describes operator/validator setup, receipt retention and proof
|
|
142
|
+
endpoints.
|
|
122
143
|
|
|
123
144
|
**Mandates.** `@metalntwx/metal-pay/mandates` lets an agent pay from its
|
|
124
145
|
owner's account with a P-256 key of its own, within limits Metal enforces on
|
|
@@ -127,11 +148,13 @@ budget, how many payments, an expiry) as a Verifiable Intent mandate;
|
|
|
127
148
|
`ingestCredential` verifies one against the issuers the owner trusts, and
|
|
128
149
|
`bindCall`, `replaceCall` and `revokeCall` build the owner's transactions to its own
|
|
129
150
|
MetalAccount. Wrap those calls with `authorizeMandateCall(client, trustedFactory,
|
|
130
|
-
call, signQuorum)` to authorize an account self-call, or a list of them as one
|
|
151
|
+
call, validUntil, signQuorum)` to authorize an account self-call, or a list of them as one
|
|
131
152
|
batch that runs whole or not at all, with its existing weighted quorum. The
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
153
|
+
account refuses the signed batch after `validUntil` (unix seconds, a `bigint`), so
|
|
154
|
+
give it a short deadline. The client must name its chain: the digest the quorum
|
|
155
|
+
signs is computed from it, never read from the RPC endpoint. The callback
|
|
156
|
+
supports the same EOA, P-256 and passkey signatures as ordinary account
|
|
157
|
+
execution. Factory-created clones share an immutable VI
|
|
135
158
|
verifier; budgets and replay protection live in each account. `createMandatePaymentClient({ credential, signDigest, network })`
|
|
136
159
|
is the payer, and `MandateExactScheme` the same payer as a scheme for an
|
|
137
160
|
`x402Client` of your own: each payment is an ERC-3009 authorization from the
|
|
@@ -146,6 +169,10 @@ are decimal, to the cent. `verifyMandate` and `verifyMandatePayment` trust the
|
|
|
146
169
|
MetalAccountFactory `METAL_NETWORKS` names for the chain, or the
|
|
147
170
|
`accountFactory` given, check the account's clone code against the
|
|
148
171
|
factory's current implementation, and match the successful token transfer to the seller's requirements.
|
|
172
|
+
`verifyMandatePayment` checks a `checkoutMandate` against the payment, failing one of another
|
|
173
|
+
payment as such; its step 4 says when none was given, so no order was checked.
|
|
174
|
+
An event under the mandate that the SDK does not decode, as an account of
|
|
175
|
+
another version emits, fails the verification with its topic and the account.
|
|
149
176
|
The module runs on Node only.
|
|
150
177
|
|
|
151
178
|
**RPC.** Neither payers nor payees need one. Each network's `rpc` is its
|
|
@@ -27,10 +27,14 @@ export interface ClearingPaid {
|
|
|
27
27
|
};
|
|
28
28
|
}
|
|
29
29
|
export interface ClearingClientState {
|
|
30
|
-
epoch?: string;
|
|
30
|
+
epoch?: string | undefined;
|
|
31
31
|
entries: ClearingEntry[];
|
|
32
32
|
sequence: string;
|
|
33
|
-
|
|
33
|
+
/** The payment sent whose outcome is unknown, with the URL it pays for
|
|
34
|
+
* when `pay` was given one. */
|
|
35
|
+
pending?: (ClearingPayment & {
|
|
36
|
+
url?: string | undefined;
|
|
37
|
+
}) | undefined;
|
|
34
38
|
payments: Record<string, ClearingPaid>;
|
|
35
39
|
}
|
|
36
40
|
/** save must complete an atomic durability barrier; one process owns each payer. */
|
|
@@ -49,10 +53,53 @@ export interface ClearingClientConfig {
|
|
|
49
53
|
maxAmount: bigint;
|
|
50
54
|
fetch?: typeof globalThis.fetch;
|
|
51
55
|
}
|
|
56
|
+
/** Why a clearing payment was not made. */
|
|
57
|
+
export type ClearingRefusalReason = "between_epochs" | "insufficient_deposit" | "payment_held" | "answer_lost";
|
|
58
|
+
/** What to do after a `ClearingRefusal`. */
|
|
59
|
+
export type ClearingRetry =
|
|
60
|
+
/** Nothing was paid: send the request again after `ms`, and wait `ms`
|
|
61
|
+
* again after each further `between_epochs` refusal. */
|
|
62
|
+
{
|
|
63
|
+
action: "retry_after_ms";
|
|
64
|
+
ms: number;
|
|
65
|
+
}
|
|
66
|
+
/** Nothing was paid: deposit more, then send the request again. */
|
|
67
|
+
| {
|
|
68
|
+
action: "deposit";
|
|
69
|
+
}
|
|
70
|
+
/** The wallet holds a payment whose outcome is unknown, `amount` base
|
|
71
|
+
* units for `url` (`null` when `pay` was given none; through
|
|
72
|
+
* `fetchWithPayment`, the URL the request was sent to), and pays nothing
|
|
73
|
+
* else until it goes through: send that request again at that price
|
|
74
|
+
* after `ms`, which sends the same payment, so it is never paid twice. If
|
|
75
|
+
* that URL no longer offers clearing at that price, pass the wallet's
|
|
76
|
+
* `pendingInvoice()` to its `pay`, which sends the held payment itself
|
|
77
|
+
* once more; the wallet pays others once it goes through or is refused. */
|
|
78
|
+
| {
|
|
79
|
+
action: "repeat_same_request";
|
|
80
|
+
url: string | null;
|
|
81
|
+
amount: string;
|
|
82
|
+
ms: number;
|
|
83
|
+
};
|
|
84
|
+
/** A clearing payment the service did not take: `reason` says why, `retry`
|
|
85
|
+
* what to do, and the message both in words. */
|
|
86
|
+
export declare class ClearingRefusal extends Error {
|
|
87
|
+
readonly reason: ClearingRefusalReason;
|
|
88
|
+
readonly retry: ClearingRetry;
|
|
89
|
+
readonly name = "ClearingRefusal";
|
|
90
|
+
constructor(reason: ClearingRefusalReason, retry: ClearingRetry, message: string, options?: ErrorOptions);
|
|
91
|
+
}
|
|
52
92
|
export declare function createClearingClient(config: ClearingClientConfig): {
|
|
53
93
|
account: `0x${string}`;
|
|
54
94
|
pendingInvoice(): Promise<ClearingInvoice | null>;
|
|
55
|
-
|
|
56
|
-
|
|
95
|
+
/** Why this wallet does not pay `invoice`, or undefined when it does. */
|
|
96
|
+
unpayable: (invoice: ClearingInvoice) => {
|
|
97
|
+
reason: string;
|
|
98
|
+
aboveMaxAmount: boolean;
|
|
99
|
+
} | undefined;
|
|
100
|
+
/** Pay `invoice`; `url`, the request it pays for, is kept with a
|
|
101
|
+
* payment whose outcome is unknown, so a refusal can name it. A payment
|
|
102
|
+
* the service does not take throws a `ClearingRefusal`. */
|
|
103
|
+
pay(invoice: ClearingInvoice, url?: string): Promise<ClearingPaid>;
|
|
57
104
|
request: <T>(path: string, body?: unknown) => Promise<T>;
|
|
58
105
|
};
|
package/dist/clearing-client.js
CHANGED
|
@@ -1,11 +1,84 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { BaseError, createPublicClient } from "viem";
|
|
2
2
|
import { clearingAbi } from "./clearing-abi.js";
|
|
3
3
|
import { CLEARING_REFERENCE, clearingUnits, decodeSend, encodeSend, invoiceMessage, outgoingRoot, verifyReceipt, } from "./clearing-codec.js";
|
|
4
|
-
|
|
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;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
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 });
|
|
27
|
+
}
|
|
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 });
|
|
32
|
+
}
|
|
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}`}`;
|
|
36
|
+
}
|
|
37
|
+
function messageOf(cause) {
|
|
38
|
+
return cause instanceof BaseError
|
|
39
|
+
? cause.shortMessage
|
|
40
|
+
: cause instanceof Error
|
|
41
|
+
? cause.message
|
|
42
|
+
: String(cause);
|
|
43
|
+
}
|
|
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)}`);
|
|
5
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;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
const unavailable = (error) => error instanceof ServiceRefused && error.status === 409 && !error.rejected;
|
|
6
79
|
export function createClearingClient(config) {
|
|
7
80
|
const fetcher = config.fetch ?? globalThis.fetch;
|
|
8
|
-
const chain = createPublicClient({ transport:
|
|
81
|
+
const chain = createPublicClient({ transport: rpcTransport(config.rpcUrl) });
|
|
9
82
|
let tail = Promise.resolve();
|
|
10
83
|
let broken = false;
|
|
11
84
|
async function save(state) {
|
|
@@ -30,28 +103,139 @@ export function createClearingClient(config) {
|
|
|
30
103
|
});
|
|
31
104
|
if (!response.ok) {
|
|
32
105
|
const result = (await response.json().catch(() => null));
|
|
33
|
-
|
|
34
|
-
throw new PaymentRejected("clearing payment rejected before acknowledgment");
|
|
35
|
-
throw new Error(`clearing service returned ${response.status}`);
|
|
106
|
+
throw new ServiceRefused(response.status, result?.rejected === true);
|
|
36
107
|
}
|
|
37
108
|
return (await response.json());
|
|
38
109
|
}
|
|
39
|
-
/**
|
|
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 };
|
|
119
|
+
}
|
|
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. */
|
|
40
123
|
function refusal(invoice) {
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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 };
|
|
45
129
|
const amount = BigInt(invoice.amount);
|
|
46
|
-
if (amount
|
|
47
|
-
return
|
|
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);
|
|
48
145
|
}
|
|
49
|
-
|
|
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;
|
|
162
|
+
try {
|
|
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);
|
|
185
|
+
}
|
|
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 });
|
|
190
|
+
}
|
|
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 });
|
|
194
|
+
}
|
|
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
|
+
}
|
|
225
|
+
}
|
|
226
|
+
async function pay(invoice, url) {
|
|
50
227
|
if (broken)
|
|
51
228
|
throw new Error("clearing wallet persistence failed; reopen the wallet");
|
|
52
229
|
const refused = refusal(invoice);
|
|
53
|
-
if (refused)
|
|
54
|
-
|
|
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
|
+
}
|
|
55
239
|
const amount = BigInt(invoice.amount);
|
|
56
240
|
let state = (await config.store.load()) ?? {
|
|
57
241
|
entries: [],
|
|
@@ -67,17 +251,27 @@ export function createClearingClient(config) {
|
|
|
67
251
|
}
|
|
68
252
|
if (state.pending &&
|
|
69
253
|
invoiceMessage(state.pending.invoice, state.pending.body) !==
|
|
70
|
-
invoiceMessage(invoice, state.pending.body))
|
|
71
|
-
|
|
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);
|
|
257
|
+
}
|
|
258
|
+
// A payment sent again has been sent before.
|
|
259
|
+
const resent = state.pending !== undefined;
|
|
72
260
|
if (!state.pending) {
|
|
73
|
-
const context = await request("/context")
|
|
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
|
+
});
|
|
74
268
|
if (context.reference !== CLEARING_REFERENCE ||
|
|
75
269
|
context.network !== config.network ||
|
|
76
270
|
context.settlement.toLowerCase() !== config.settlement.toLowerCase() ||
|
|
77
271
|
context.asset.toLowerCase() !== config.asset.toLowerCase())
|
|
78
272
|
throw new Error("unsupported clearing service deployment");
|
|
79
273
|
const epoch = BigInt(context.epoch);
|
|
80
|
-
const [registered, onchain, fault,
|
|
274
|
+
const [registered, onchain, fault, admitted, block, unit] = await onChain(Promise.all([
|
|
81
275
|
chain.readContract({
|
|
82
276
|
address: config.settlement,
|
|
83
277
|
abi: clearingAbi,
|
|
@@ -94,7 +288,6 @@ export function createClearingClient(config) {
|
|
|
94
288
|
abi: clearingAbi,
|
|
95
289
|
functionName: "fault",
|
|
96
290
|
}),
|
|
97
|
-
chain.getChainId(),
|
|
98
291
|
chain.readContract({
|
|
99
292
|
address: config.settlement,
|
|
100
293
|
abi: clearingAbi,
|
|
@@ -106,13 +299,16 @@ export function createClearingClient(config) {
|
|
|
106
299
|
abi: clearingAbi,
|
|
107
300
|
functionName: "unit",
|
|
108
301
|
}),
|
|
109
|
-
]);
|
|
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.
|
|
110
307
|
if (!registered ||
|
|
111
|
-
fault ||
|
|
112
308
|
admitted !== epoch ||
|
|
113
|
-
block.timestamp >= onchain.admissionDeadline
|
|
114
|
-
|
|
115
|
-
|
|
309
|
+
block.timestamp >= onchain.admissionDeadline)
|
|
310
|
+
throw betweenEpochs();
|
|
311
|
+
if (onchain.anchor !== context.anchor ||
|
|
116
312
|
onchain.operatorKey !== context.operator)
|
|
117
313
|
throw new Error("clearing epoch is not registered");
|
|
118
314
|
if (state.epoch !== context.epoch)
|
|
@@ -126,6 +322,9 @@ export function createClearingClient(config) {
|
|
|
126
322
|
// The invoice is in token base units; the vector counts clearing units.
|
|
127
323
|
entry.cumulative = (BigInt(entry.cumulative) + clearingUnits(amount, unit)).toString();
|
|
128
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`);
|
|
129
328
|
entries.sort((a, b) => a.recipient.toLowerCase().localeCompare(b.recipient.toLowerCase()));
|
|
130
329
|
const vector = entries.map((e) => ({
|
|
131
330
|
...e,
|
|
@@ -148,31 +347,62 @@ export function createClearingClient(config) {
|
|
|
148
347
|
entries,
|
|
149
348
|
payerSignature: await config.signer.sign("_COMMONWARE_CLEARING_VECTOR_SEND", body),
|
|
150
349
|
invoiceSignature: await config.signer.sign("_METAL_CLEARING_INVOICE", invoiceMessage(invoice, body)),
|
|
350
|
+
url,
|
|
151
351
|
},
|
|
152
352
|
};
|
|
153
353
|
await save(state);
|
|
154
354
|
}
|
|
155
355
|
const pending = state.pending;
|
|
156
356
|
let result;
|
|
357
|
+
let operator;
|
|
157
358
|
try {
|
|
158
|
-
result = await request("/payments",
|
|
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
|
+
});
|
|
159
371
|
}
|
|
160
372
|
catch (error) {
|
|
161
|
-
if (error instanceof
|
|
373
|
+
if (error instanceof ServiceRefused && error.rejected) {
|
|
162
374
|
await save({ ...state, pending: undefined });
|
|
163
|
-
|
|
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);
|
|
164
393
|
}
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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);
|
|
176
406
|
const settled = {
|
|
177
407
|
invoice,
|
|
178
408
|
receipt: result.receipt,
|
|
@@ -194,11 +424,13 @@ export function createClearingClient(config) {
|
|
|
194
424
|
async pendingInvoice() {
|
|
195
425
|
return (await config.store.load())?.pending?.invoice ?? null;
|
|
196
426
|
},
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
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));
|
|
202
434
|
tail = result.catch(() => { });
|
|
203
435
|
return result;
|
|
204
436
|
},
|
|
@@ -84,8 +84,10 @@ export interface ClearingChain {
|
|
|
84
84
|
* registrations, so the deposit waits for that window and is sent again
|
|
85
85
|
* if the window closes under it, up to `timeoutMs` (default 60 s). It
|
|
86
86
|
* refuses at once a contract that holds another token, and a deposit past
|
|
87
|
-
* what the contract holds in all. A deposit
|
|
88
|
-
*
|
|
87
|
+
* what the contract holds in all. A deposit the token refuses because
|
|
88
|
+
* `from` holds less fails naming what `from` holds and what the deposit
|
|
89
|
+
* needs. A deposit whose outcome is unknown is not sent again: the error
|
|
90
|
+
* names it. */
|
|
89
91
|
export declare function depositToClearing(opts: ClearingChain & {
|
|
90
92
|
asset: Address;
|
|
91
93
|
account: Hex;
|
|
@@ -104,15 +106,22 @@ type ClearingClaim = {
|
|
|
104
106
|
};
|
|
105
107
|
export type ClearingWithdrawal =
|
|
106
108
|
/** A withdrawal is queued and `epoch`, the one that carries it, has not
|
|
107
|
-
* finalized; nothing can be sent until it does.
|
|
109
|
+
* finalized; nothing can be sent until it does. Once the service has
|
|
110
|
+
* missed a deadline, the call fails instead, naming `recoverFromClearing`. */
|
|
108
111
|
{
|
|
109
112
|
status: "waiting";
|
|
110
113
|
epoch: bigint;
|
|
111
114
|
}
|
|
115
|
+
/** Nothing to withdraw: no payout to claim, and no finalized balance.
|
|
116
|
+
* What the account holds in an epoch not yet finalized can leave once
|
|
117
|
+
* it is. */
|
|
118
|
+
| {
|
|
119
|
+
status: "empty";
|
|
120
|
+
}
|
|
112
121
|
/** What this call sent: the account's payout it claimed, to the
|
|
113
122
|
* destination the finalized withdrawal named, and the withdrawal it
|
|
114
|
-
* queued for the account's finalized balance, or `null` when
|
|
115
|
-
* is
|
|
123
|
+
* queued for the account's finalized balance, or `null` when nothing
|
|
124
|
+
* finalized is left to queue. A
|
|
116
125
|
* deferred payout is one the destination refused: it waits in the
|
|
117
126
|
* contract's `unclaimedPayout` until the destination calls
|
|
118
127
|
* `withdrawUnclaimed`. */
|
|
@@ -128,7 +137,9 @@ export type ClearingWithdrawal =
|
|
|
128
137
|
* withdrawal of whatever finalized balance remains, to `destination` (`from` itself
|
|
129
138
|
* unless given). A payer's unused deposit and a seller's earnings leave the
|
|
130
139
|
* same way. Call again after the queued withdrawal's epoch finalizes. A
|
|
131
|
-
* failure after a claim names the claims made before it.
|
|
140
|
+
* failure after a claim names the claims made before it. A queue or claim
|
|
141
|
+
* refused because an epoch finalized under its proof moved nothing: the
|
|
142
|
+
* error says to call again after the next epoch. */
|
|
132
143
|
export declare function withdrawFromClearing(opts: ClearingChain & {
|
|
133
144
|
signer: ClearingSigner;
|
|
134
145
|
serviceUrl: string;
|