@metalntwx/metal-agent 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 +78 -52
- package/dist/chain.js +1 -1
- package/dist/commands/mandate.js +564 -131
- package/dist/commands/pay.js +16 -7
- package/dist/help.js +119 -0
- package/dist/index.js +52 -96
- package/dist/keystore.js +6 -10
- package/dist/mcp.js +59 -39
- package/package.json +7 -7
- package/skills/metal-agent/SKILL.md +53 -27
package/dist/commands/mandate.js
CHANGED
|
@@ -1,23 +1,26 @@
|
|
|
1
1
|
// `mandate …`: the limits an account's owner sets for an agent, enforced by
|
|
2
|
-
// Metal. The owner (the principal) issues a credential naming the
|
|
3
|
-
//
|
|
2
|
+
// Metal. The owner (the principal) issues a credential naming the mandate
|
|
3
|
+
// key, the most per payment, a budget, an expiry and any payees, then
|
|
4
4
|
// binds it through the weighted quorum of its MetalAccount.
|
|
5
|
-
// From then on the agent pays from that account with its
|
|
5
|
+
// From then on the agent pays from that account with its mandate key alone, and
|
|
6
6
|
// Mandate Binding refuses anything outside the mandate. Every command takes
|
|
7
7
|
// the credential file: the account, the mandate's reference and its terms all
|
|
8
8
|
// derive from it.
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
9
|
+
import { randomUUID } from "node:crypto";
|
|
10
|
+
import { link, mkdir, readFile, readdir, rm, writeFile } from "node:fs/promises";
|
|
11
|
+
import { dirname, join } from "node:path";
|
|
12
|
+
import { FEE_PER_GAS, assertMetalAccount, authorizeMandateCall, metalAccountFactoryAbi, MANDATE_MAX_VALIDITY_SECONDS, MANDATE_VALID_AFTER_LEAD_SECONDS, REFUSALS, UNCAPPED_OCCURRENCES, agentKeyOfJwk, bindCall, createCredential, encodeMandateBlob, describeRefusal, encodeAgentKey, extractTerms, hashCredential, ingestCredential, mandateBindingAbi, mandateReference, p256DigestSigner, parseCredential, privateKeyHex, publicMessage, pullCall, readBoundAgentKey, readCheck, readMandateState, reasonName, replaceCall, revokeCall, sameAgentKey, sendMandateCall, signL3a, transactionIdOf, trustedIssuer, verifyMandate, verifyMandatePayment, } from "@metalntwx/metal-pay/mandates";
|
|
11
13
|
import { USDZ, formatUsdz, usdzBaseUnits } from "@metalntwx/metal-pay";
|
|
12
|
-
import { BaseError, createWalletClient, custom, decodeErrorResult, encodeFunctionData, encodeAbiParameters, parseAbiParameters, getAddress, isAddress, isHash, parseEventLogs, slice, } from "viem";
|
|
14
|
+
import { BaseError, erc20Abi, getAbiItem, parseAbi, parseAbiItem, HttpRequestError, TimeoutError, createWalletClient, custom, decodeErrorResult, encodeFunctionData, encodeAbiParameters, parseAbiParameters, getAddress, isAddress, isAddressEqual, isHash, parseEventLogs, slice, } from "viem";
|
|
13
15
|
import { rpcClient } from "../chain.js";
|
|
14
|
-
import {
|
|
16
|
+
import { fund } from "./fund.js";
|
|
17
|
+
import { exists, mandateKeyPath, pendingPullsPath, publicKeyPath, readKey, readOrCreateIssuerKeys, readOrCreateKey, readOrCreateP256Key, readP256Key, readTrustedIssuer, writePublicKeyFile, } from "../keystore.js";
|
|
15
18
|
export async function loadMandate(credentialPath) {
|
|
16
19
|
const credential = parseCredential(JSON.parse(await readFile(credentialPath, "utf8")));
|
|
17
20
|
const terms = extractTerms(credential);
|
|
18
21
|
return { credential, terms, reference: mandateReference(terms.principal, terms.agentKey, hashCredential(credential)) };
|
|
19
22
|
}
|
|
20
|
-
/** A decimal `*USDZ` amount
|
|
23
|
+
/** A decimal `*USDZ` amount to the cent, as a credential states its cap and budget. */
|
|
21
24
|
export function parseCents(text, option) {
|
|
22
25
|
if (text === undefined || !/^\d+(\.\d{1,2})?$/.test(text) || Number(text) <= 0) {
|
|
23
26
|
throw new Error(`${option} must be a positive ${USDZ.symbol} amount with at most 2 decimals; got "${text ?? ""}"`);
|
|
@@ -29,6 +32,19 @@ export function parseAddress(text, option) {
|
|
|
29
32
|
throw new Error(`${option} must be an address; got "${text ?? ""}"`);
|
|
30
33
|
return getAddress(text);
|
|
31
34
|
}
|
|
35
|
+
/** `<address>[,<name>[,<website>]]`, as `--payee` takes it. */
|
|
36
|
+
export function parsePayee(text, option) {
|
|
37
|
+
const [address, name, website, ...rest] = (text ?? "").split(",").map((part) => part.trim());
|
|
38
|
+
if (address === undefined || !isAddress(address))
|
|
39
|
+
throw new Error(`${option} must start with an address, then optionally a name and a website, comma-separated; got "${text ?? ""}"`);
|
|
40
|
+
if (rest.length > 0)
|
|
41
|
+
throw new Error(`${option} takes at most an address, a name and a website; got "${text ?? ""}"`);
|
|
42
|
+
if (name === "")
|
|
43
|
+
throw new Error(`${option}'s name is empty; leave it out to show the address; got "${text ?? ""}"`);
|
|
44
|
+
if (website !== undefined && !/^https?:\/\/[^\s]+$/.test(website))
|
|
45
|
+
throw new Error(`${option}'s website must be an http or https URL; got "${website}"`);
|
|
46
|
+
return { address: getAddress(address), ...(name !== undefined && { name }), ...(website !== undefined && { website }) };
|
|
47
|
+
}
|
|
32
48
|
export function parseTxHash(text, option) {
|
|
33
49
|
if (!isHash(text))
|
|
34
50
|
throw new Error(`${option} must be a transaction hash; got "${text}"`);
|
|
@@ -46,19 +62,32 @@ export function parseCount(text, option) {
|
|
|
46
62
|
const usdz = (units) => `${formatUsdz(units)} ${USDZ.symbol}`;
|
|
47
63
|
const utc = (seconds) => new Date(Number(seconds) * 1000).toISOString();
|
|
48
64
|
const day = (seconds) => new Date(seconds * 1000).toISOString().slice(0, 10);
|
|
49
|
-
|
|
65
|
+
/**
|
|
66
|
+
* Text from a credential or a facilitator is shown with control, line-separator, bidi and invisible characters replaced, so it cannot
|
|
67
|
+
* start a line, reorder one or hide text; the joiners scripts and emoji need are kept.
|
|
68
|
+
*/
|
|
69
|
+
const UNSHOWN = /[\p{Cc}\p{Zl}\p{Zp}\u061c\u200b\u200e\u200f\u202a-\u202e\u2060\u2066-\u2069\ufeff\u{e0000}-\u{e007f}]/gu;
|
|
70
|
+
function printTerms({ principal, agentKey, constraints: c, terms, descriptive }, print) {
|
|
71
|
+
if (typeof descriptive.promptSummary === "string")
|
|
72
|
+
print(`description ${descriptive.promptSummary.replace(UNSHOWN, "?")}`);
|
|
50
73
|
print(`principal ${principal}`);
|
|
51
|
-
print(`
|
|
74
|
+
print(`mandate-key ${encodeAgentKey(agentKey)} (P-256)`);
|
|
52
75
|
print(`token ${c.token}`);
|
|
53
76
|
print(`per-payment-cap ${usdz(c.perPaymentCap)}`);
|
|
54
77
|
print(`budget ${usdz(c.budget)} until expiry`);
|
|
78
|
+
print(`starts ${c.recurrenceStart} (${utc(c.recurrenceStart)})`);
|
|
55
79
|
print(`expiry ${c.expiry} (${utc(c.expiry)})`);
|
|
56
80
|
if (c.maxOccurrences !== UNCAPPED_OCCURRENCES)
|
|
57
81
|
print(`max-occurrences ${c.maxOccurrences}`);
|
|
58
82
|
if (terms.payees.length === 0)
|
|
59
83
|
print("payees any");
|
|
60
|
-
|
|
61
|
-
|
|
84
|
+
const shown = Array.isArray(descriptive.payees) ? descriptive.payees : [];
|
|
85
|
+
for (const payee of terms.payees) {
|
|
86
|
+
const object = shown.find((o) => typeof o.id === "string" && o.id.toLowerCase() === payee.account.toLowerCase());
|
|
87
|
+
const name = typeof object?.name === "string" && object.name.toLowerCase() !== payee.account.toLowerCase() ? ` ${object.name}` : "";
|
|
88
|
+
const website = typeof object?.website === "string" && /^https?:/.test(object.website) ? ` ${object.website}` : "";
|
|
89
|
+
print(`payee ${payee.account}${name}${website}`.replace(UNSHOWN, "?"));
|
|
90
|
+
}
|
|
62
91
|
}
|
|
63
92
|
function walletFor(publicClient, account) {
|
|
64
93
|
if (!publicClient.chain)
|
|
@@ -82,54 +111,66 @@ function printSent(target, receipt, print) {
|
|
|
82
111
|
}
|
|
83
112
|
/**
|
|
84
113
|
* Issues the credential a mandate is bound from, the developer signing it as
|
|
85
|
-
* its own issuer; with no payees it lets the agent pay any address within
|
|
86
|
-
*
|
|
87
|
-
*
|
|
88
|
-
*
|
|
89
|
-
*
|
|
90
|
-
* expiry.
|
|
114
|
+
* its own issuer; with no payees it lets the agent pay any address within the
|
|
115
|
+
* other limits. The mandate key it names is written beside the credential,
|
|
116
|
+
* unless `mandateKeyFile` names one that exists. The issuer's keys are
|
|
117
|
+
* created on first use. The budget covers the credential's whole life, from
|
|
118
|
+
* now to its expiry.
|
|
91
119
|
*/
|
|
92
120
|
export async function credentialCreate(target, args, print) {
|
|
93
121
|
if (await exists(args.out))
|
|
94
122
|
throw new Error(`${args.out} already exists; pass another --out`);
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
123
|
+
if (args.startsIn !== undefined && args.startsIn >= args.expiresIn)
|
|
124
|
+
throw new Error(`--starts-in must be less than --expires-in, ${args.expiresIn} seconds`);
|
|
125
|
+
const keyPath = args.mandateKeyFile ?? mandateKeyPath(args.out);
|
|
126
|
+
if (args.mandateKeyFile === undefined && (await exists(keyPath))) {
|
|
127
|
+
throw new Error(`${keyPath} already holds a key; pass --mandate-key-file ${keyPath} to name it, or another --out`);
|
|
128
|
+
}
|
|
129
|
+
if (args.mandateKeyFile !== undefined && !(await exists(keyPath)))
|
|
130
|
+
throw new Error(`no mandate key at ${keyPath}; pass --mandate-key-file with a mandate-key-file credential create printed`);
|
|
131
|
+
const mandateKey = await readOrCreateP256Key(keyPath);
|
|
132
|
+
const { keys, created } = await readOrCreateIssuerKeys(args.issuerKeyPath).catch(async (error) => {
|
|
133
|
+
if (mandateKey.created)
|
|
134
|
+
await rm(keyPath);
|
|
135
|
+
throw error;
|
|
136
|
+
});
|
|
103
137
|
const now = Math.floor(Date.now() / 1000);
|
|
104
138
|
const expiresAt = now + args.expiresIn;
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
139
|
+
let credential;
|
|
140
|
+
let ingested;
|
|
141
|
+
try {
|
|
142
|
+
credential = await createCredential({
|
|
143
|
+
issuer: keys.issuer,
|
|
144
|
+
principal: args.principal,
|
|
145
|
+
principalKey: keys.principalKey,
|
|
146
|
+
agentKey: mandateKey.key.publicJwk,
|
|
147
|
+
perPaymentCap: args.cap,
|
|
148
|
+
budget: args.budget,
|
|
149
|
+
payees: args.payees.map(({ address, name, website }) => ({ address, name: name ?? address, website: website ?? `eip155:${target.network.chainId}:${address}` })),
|
|
150
|
+
recurrence: {
|
|
151
|
+
frequency: "ADHO",
|
|
152
|
+
startDate: args.startsIn === undefined ? day(now) : new Date((now + args.startsIn) * 1000).toISOString().replace(".000Z", "Z"),
|
|
153
|
+
endDate: day(expiresAt),
|
|
154
|
+
...(args.maxOccurrences !== undefined && { maxOccurrences: args.maxOccurrences }),
|
|
155
|
+
},
|
|
156
|
+
issuedAt: now,
|
|
157
|
+
expiresAt,
|
|
158
|
+
description: args.description,
|
|
159
|
+
});
|
|
160
|
+
ingested = await ingestCredential(credential, { trustedIssuers: [trustedIssuer(keys.issuer)], account: args.principal, now });
|
|
161
|
+
}
|
|
162
|
+
catch (error) {
|
|
163
|
+
// The keys this run created name nothing yet; removing them lets the corrected command run again.
|
|
164
|
+
await Promise.all([mandateKey.created && rm(keyPath), created && rm(args.issuerKeyPath)]);
|
|
165
|
+
throw error;
|
|
166
|
+
}
|
|
124
167
|
await writeFile(args.out, `${JSON.stringify(credential, null, 2)}\n`, { flag: "wx" });
|
|
125
168
|
const issuerPublicPath = publicKeyPath(args.issuerKeyPath);
|
|
126
169
|
await writePublicKeyFile(issuerPublicPath, trustedIssuer(keys.issuer));
|
|
127
170
|
print(`credential ${args.out}`);
|
|
128
|
-
print(`
|
|
129
|
-
printTerms(ingested, print);
|
|
130
|
-
print(`
|
|
131
|
-
print(`gas-key-file ${gasPath}${gas.created ? " (created)" : ""}`);
|
|
132
|
-
print(`gas-key-address ${gas.address}`);
|
|
171
|
+
print(`mandate ${ingested.reference}`);
|
|
172
|
+
printTerms(ingested.extracted, print);
|
|
173
|
+
print(`mandate-key-file ${keyPath}${mandateKey.created ? " (created)" : ""}`);
|
|
133
174
|
print(`issuer-key-file ${args.issuerKeyPath}${created ? " (created)" : ""}`);
|
|
134
175
|
print(`issuer-public-file ${issuerPublicPath}`);
|
|
135
176
|
}
|
|
@@ -150,46 +191,58 @@ async function ingestFor(args) {
|
|
|
150
191
|
/** The one-owner account `owner` controls: its config for the factory, and the address the factory gives it. */
|
|
151
192
|
async function ownerAccount(client, factory, owner) {
|
|
152
193
|
const config = [[owner], [1n], 1n];
|
|
153
|
-
|
|
154
|
-
if (factoryCode === undefined || factoryCode === "0x")
|
|
194
|
+
if (!(await hasCode(client, factory)))
|
|
155
195
|
throw new Error(`no MetalAccountFactory at ${factory}; on a devnet run just deploy, or pass --factory`);
|
|
156
196
|
const address = await client.readContract({ address: factory, abi: metalAccountFactoryAbi, functionName: "predictAccount", args: config });
|
|
157
197
|
return { config, address };
|
|
158
198
|
}
|
|
159
|
-
/**
|
|
160
|
-
* `out` names a file to create
|
|
161
|
-
*
|
|
162
|
-
*
|
|
199
|
+
/** Creates the owner's one-owner MetalAccount on chain, writing the owner's
|
|
200
|
+
* key first when `out` names a file to create. The factory gives each owner
|
|
201
|
+
* one address, so an account that exists is named and nothing is sent. An
|
|
202
|
+
* owner with no gas is funded first, from the faucet on testnet. Mixed-key
|
|
203
|
+
* and weighted accounts use the same factory through the SDK. */
|
|
163
204
|
export async function accountCreate(target, args, print) {
|
|
164
205
|
const keyPath = "keyPath" in args ? args.keyPath : args.out;
|
|
165
206
|
const created = "out" in args ? (await readOrCreateKey(args.out)).created : false;
|
|
166
207
|
const owner = await readKey(keyPath);
|
|
167
208
|
const client = rpcClient(target);
|
|
168
|
-
const { address } = await ownerAccount(client, args.accountFactory, owner.address);
|
|
169
|
-
const code = await client.getCode({ address });
|
|
209
|
+
const { config, address } = await ownerAccount(client, args.accountFactory, owner.address);
|
|
170
210
|
print(`account ${address}`);
|
|
171
|
-
print(`deployed ${code === undefined || code === "0x" ? "no, the first bind creates it" : "yes"}`);
|
|
172
211
|
print(`owner ${owner.address}`);
|
|
173
212
|
print(`keyfile ${keyPath}${created ? " (created)" : ""}`);
|
|
213
|
+
if (await hasCode(client, address)) {
|
|
214
|
+
print("deployed yes");
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
if ((await client.getBalance({ address: owner.address })) === 0n) {
|
|
218
|
+
// Only the grant's summary: its transaction lines would read as the account's.
|
|
219
|
+
const funding = [];
|
|
220
|
+
await fund(target, owner.address, undefined, (line) => funding.push(line));
|
|
221
|
+
if (funding[0] !== undefined)
|
|
222
|
+
print(funding[0]);
|
|
223
|
+
}
|
|
224
|
+
const receipt = await send(client, owner, { to: args.accountFactory, data: encodeFunctionData({ abi: metalAccountFactoryAbi, functionName: "createAccount", args: config }), gas: 600000n, maxFeePerGas: FEE_PER_GAS, maxPriorityFeePerGas: FEE_PER_GAS });
|
|
225
|
+
print("deployed yes");
|
|
226
|
+
printSent(target, receipt, print);
|
|
174
227
|
}
|
|
175
|
-
async function
|
|
228
|
+
async function hasCode(client, address) {
|
|
229
|
+
const code = await client.getCode({ address });
|
|
230
|
+
return code !== undefined && code !== "0x";
|
|
231
|
+
}
|
|
232
|
+
async function sendOwnerCall(client, args, call) {
|
|
233
|
+
const account = (Array.isArray(call) ? call[0] : call)?.to;
|
|
234
|
+
if (account === undefined)
|
|
235
|
+
throw new Error("no account call to send");
|
|
176
236
|
const sponsor = await readKey(args.keyPath);
|
|
177
237
|
const signers = await Promise.all((args.signerPaths.length === 0 ? [args.keyPath] : args.signerPaths).map(readKey));
|
|
178
238
|
signers.sort((a, b) => a.address.toLowerCase().localeCompare(b.address.toLowerCase()));
|
|
179
239
|
if (new Set(signers.map(s => s.address.toLowerCase())).size !== signers.length)
|
|
180
240
|
throw new Error("duplicate account signer");
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
throw new Error(`${call.to} has no code: create the account first, through MetalAccountFactory.createAccount with its signers, weights and threshold; only a one-owner account is created by its owner's first bind`);
|
|
187
|
-
}
|
|
188
|
-
if (code === undefined || code === "0x") {
|
|
189
|
-
const { config, address } = await ownerAccount(client, args.accountFactory, sponsor.address);
|
|
190
|
-
if (address.toLowerCase() !== call.to.toLowerCase())
|
|
191
|
-
throw new Error(`${call.to} is not the account of ${sponsor.address}; it has no code, and only its owner's first bind creates it`);
|
|
192
|
-
await send(client, sponsor, { to: args.accountFactory, data: encodeFunctionData({ abi: metalAccountFactoryAbi, functionName: "createAccount", args: config }), gas: 600000n, maxFeePerGas: FEE_PER_GAS, maxPriorityFeePerGas: FEE_PER_GAS });
|
|
241
|
+
if (!(await hasCode(client, account))) {
|
|
242
|
+
const own = signers.length === 1 && isAddressEqual(signers[0].address, sponsor.address) && isAddressEqual((await ownerAccount(client, args.accountFactory, sponsor.address)).address, account);
|
|
243
|
+
if (own)
|
|
244
|
+
throw new Error(`${account} has no account: create it with metal-agent account create`);
|
|
245
|
+
throw new Error(`${account} has no account, and is not ${sponsor.address}'s one-owner account: create it through MetalAccountFactory.createAccount with its owners, weights and threshold`);
|
|
193
246
|
}
|
|
194
247
|
const authorized = await authorizeMandateCall(client, args.accountFactory, call, async (digest) => encodeAbiParameters(parseAbiParameters("bytes[], bytes[]"), [signers.map(s => s.address), await Promise.all(signers.map(s => s.sign({ hash: digest })))]));
|
|
195
248
|
return send(client, sponsor, authorized);
|
|
@@ -197,15 +250,24 @@ async function sendOwnerCall(client, args, call, createAccount = false) {
|
|
|
197
250
|
export async function mandateBind(target, args, print) {
|
|
198
251
|
const { ingested } = await ingestFor(args);
|
|
199
252
|
const publicClient = rpcClient(target);
|
|
200
|
-
const receipt = await sendOwnerCall(publicClient, args, bindCall(ingested)
|
|
253
|
+
const receipt = await sendOwnerCall(publicClient, args, bindCall(ingested));
|
|
201
254
|
print(`bound ${ingested.reference}`);
|
|
202
255
|
printSent(target, receipt, print);
|
|
203
256
|
}
|
|
257
|
+
/** Checks that the mandate a replace or renew ends is bound and not yet revoked, which the account's revert would not say. */
|
|
258
|
+
async function assertLive(publicClient, mandate, path) {
|
|
259
|
+
const state = await publicClient.readContract({ address: mandate.terms.principal, abi: mandateBindingAbi, functionName: "mandates", args: [mandate.reference] });
|
|
260
|
+
if (!isAddressEqual(state.principal, mandate.terms.principal))
|
|
261
|
+
throw new Error(`${path} is not bound on ${mandate.terms.principal}; bind the new credential with mandate bind`);
|
|
262
|
+
if (state.revoked)
|
|
263
|
+
throw new Error(`${path} is already revoked; bind the new credential with mandate bind`);
|
|
264
|
+
}
|
|
204
265
|
/** Binds the credential as the successor of the mandate `replacesPath` describes, which is revoked in the same transaction; the successor carries its spend. */
|
|
205
266
|
export async function mandateReplace(target, args, print) {
|
|
206
267
|
const { ingested } = await ingestFor(args);
|
|
207
268
|
const predecessor = await loadMandate(args.replacesPath);
|
|
208
269
|
const publicClient = rpcClient(target);
|
|
270
|
+
await assertLive(publicClient, predecessor, args.replacesPath);
|
|
209
271
|
const carried = await readMandateState(publicClient, predecessor.terms.principal, predecessor.reference);
|
|
210
272
|
if (carried.spent >= ingested.constraints.budget)
|
|
211
273
|
throw new Error(`the replacement's budget of ${usdz(ingested.constraints.budget)} is not above the ${usdz(carried.spent)} already spent, which it carries`);
|
|
@@ -218,6 +280,28 @@ export async function mandateReplace(target, args, print) {
|
|
|
218
280
|
print(`spent ${usdz(state.spent)} of ${usdz(ingested.constraints.budget)}`);
|
|
219
281
|
printSent(target, receipt, print);
|
|
220
282
|
}
|
|
283
|
+
/**
|
|
284
|
+
* Binds the credential as a fresh mandate and revokes the one `replacesPath`
|
|
285
|
+
* describes, in one account batch: the new budget starts at zero spent, as a
|
|
286
|
+
* budget never resets on its own. `replace` carries the spend over instead.
|
|
287
|
+
*/
|
|
288
|
+
export async function mandateRenew(target, args, print) {
|
|
289
|
+
const { ingested } = await ingestFor(args);
|
|
290
|
+
const predecessor = await loadMandate(args.replacesPath);
|
|
291
|
+
if (predecessor.reference === ingested.reference)
|
|
292
|
+
throw new Error(`${args.replacesPath} is the mandate being bound; to end it, use mandate revoke`);
|
|
293
|
+
if (predecessor.terms.principal.toLowerCase() !== ingested.principal.toLowerCase()) {
|
|
294
|
+
throw new Error(`${args.replacesPath} is a mandate on ${predecessor.terms.principal}, not on ${ingested.principal}`);
|
|
295
|
+
}
|
|
296
|
+
const publicClient = rpcClient(target);
|
|
297
|
+
await assertLive(publicClient, predecessor, args.replacesPath);
|
|
298
|
+
const receipt = await sendOwnerCall(publicClient, args, [bindCall(ingested), revokeCall(ingested.principal, predecessor.reference)]);
|
|
299
|
+
const state = await readMandateState(publicClient, ingested.principal, ingested.reference);
|
|
300
|
+
print(`revoked ${predecessor.reference}`);
|
|
301
|
+
print(`bound ${ingested.reference}`);
|
|
302
|
+
print(`spent ${usdz(state.spent)} of ${usdz(ingested.constraints.budget)}`);
|
|
303
|
+
printSent(target, receipt, print);
|
|
304
|
+
}
|
|
221
305
|
export async function mandateRevoke(target, args, print) {
|
|
222
306
|
const { reference, terms } = await loadMandate(args.credentialPath);
|
|
223
307
|
const receipt = await sendOwnerCall(rpcClient(target), args, revokeCall(terms.principal, reference));
|
|
@@ -228,11 +312,10 @@ export async function mandateRevoke(target, args, print) {
|
|
|
228
312
|
export async function mandateStatus(target, credentialPath, accountFactory, print) {
|
|
229
313
|
const { terms, reference } = await loadMandate(credentialPath);
|
|
230
314
|
const publicClient = rpcClient(target);
|
|
231
|
-
print(`
|
|
315
|
+
print(`mandate ${reference}`);
|
|
232
316
|
printTerms(terms, print);
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
print("account-type none yet; the owner's first bind creates it");
|
|
317
|
+
if (!(await hasCode(publicClient, terms.principal))) {
|
|
318
|
+
print("account-type none; create the account before binding: metal-agent account create for one owner");
|
|
236
319
|
print("bound no");
|
|
237
320
|
return;
|
|
238
321
|
}
|
|
@@ -250,7 +333,7 @@ export async function mandateStatus(target, credentialPath, accountFactory, prin
|
|
|
250
333
|
/**
|
|
251
334
|
* Verifies the mandate and its payments from the chain alone: the binding on
|
|
252
335
|
* the principal's account, the credential against it and under its issuer,
|
|
253
|
-
* and each payment and refusal as the
|
|
336
|
+
* and each payment and refusal as the mandate key's own per-payment mandate,
|
|
254
337
|
* rebuilt from the transaction that carried it. Any party holding the
|
|
255
338
|
* credential can run it; Metal is not asked.
|
|
256
339
|
*/
|
|
@@ -263,25 +346,47 @@ export async function mandateVerify(target, args, print) {
|
|
|
263
346
|
const v = args.tx === undefined
|
|
264
347
|
? await verifyMandate(publicClient, opts)
|
|
265
348
|
: await verifyMandatePayment(publicClient, { ...opts, tx: args.tx, ...(args.signedOffer === undefined ? {} : { checkoutMandate: args.signedOffer }) });
|
|
266
|
-
print(`
|
|
349
|
+
print(`mandate ${reference}`);
|
|
267
350
|
for (const step of v.steps) {
|
|
268
351
|
print(`step ${step.step} ${step.outcome}: ${step.title}`);
|
|
269
352
|
print(` ${step.detail}`);
|
|
270
353
|
}
|
|
271
354
|
for (const p of v.payments) {
|
|
272
355
|
const what = p.kind === "payment" ? "payment" : `refusal (${p.reason === undefined || p.reason === "None" ? "?" : REFUSALS[p.reason].reason})`;
|
|
273
|
-
|
|
274
|
-
print(`${what} ${p.txHash} block ${p.block}: ${usdz(p.amount)} to ${p.to}, ${how}${p.problems.length === 0 ? "" : `: ${p.problems.join(", ")}`}`);
|
|
356
|
+
print(`${what} ${p.txHash} block ${p.block}: ${usdz(p.amount)} to ${p.to}, nonce ${p.nonce ?? "?"}${p.problems.length === 0 ? "" : `: ${p.problems.join(", ")}`}`);
|
|
275
357
|
}
|
|
276
358
|
print(`verified ${v.ok ? "yes" : "no"}`);
|
|
277
359
|
return v.ok ? "verified" : "failed";
|
|
278
360
|
}
|
|
279
|
-
/**
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
361
|
+
/**
|
|
362
|
+
* Signs with the mandate's key: `keyFile` when given, or else the key file in
|
|
363
|
+
* the credential's directory that the credential names, so a replacement
|
|
364
|
+
* written beside its predecessor signs with the predecessor's key.
|
|
365
|
+
*/
|
|
366
|
+
export async function mandateAgentSigner(mandate, credentialPath, keyFile) {
|
|
367
|
+
const names = (key) => sameAgentKey(agentKeyOfJwk(key.publicJwk), mandate.terms.agentKey);
|
|
368
|
+
if (keyFile !== undefined) {
|
|
369
|
+
const key = await readP256Key(keyFile).catch((error) => {
|
|
370
|
+
throw error.code === "ENOENT" ? new Error(`no mandate key at ${keyFile}; pass --mandate-key-file with the mandate-key-file credential create printed`) : error;
|
|
371
|
+
});
|
|
372
|
+
if (!names(key))
|
|
373
|
+
throw new Error(`${keyFile} is not this mandate's key`);
|
|
374
|
+
return p256DigestSigner(privateKeyHex(key));
|
|
375
|
+
}
|
|
376
|
+
const dir = dirname(credentialPath);
|
|
377
|
+
const own = mandateKeyPath(credentialPath);
|
|
378
|
+
const beside = (await readdir(dir).catch(() => [])).filter((name) => name.endsWith(".key.json")).map((name) => join(dir, name));
|
|
379
|
+
for (const path of [own, ...beside.filter((path) => path !== own)]) {
|
|
380
|
+
// The credential's own key file must read; another file that is not a P-256 key is not this mandate's.
|
|
381
|
+
const key = await readP256Key(path).catch((error) => {
|
|
382
|
+
if (path === own && error.code !== "ENOENT")
|
|
383
|
+
throw error;
|
|
384
|
+
return undefined;
|
|
385
|
+
});
|
|
386
|
+
if (key && names(key))
|
|
387
|
+
return p256DigestSigner(privateKeyHex(key));
|
|
388
|
+
}
|
|
389
|
+
throw new Error(`no key beside ${credentialPath} is this mandate's; pass --mandate-key-file with the mandate-key-file credential create printed`);
|
|
285
390
|
}
|
|
286
391
|
/** A revert's Mandate Binding error name (`NonceAlreadyUsed`, `BadAgentSignature`, …), or the node's message. */
|
|
287
392
|
function revertName(error) {
|
|
@@ -299,75 +404,403 @@ function revertName(error) {
|
|
|
299
404
|
return error.shortMessage;
|
|
300
405
|
}
|
|
301
406
|
/**
|
|
302
|
-
* A direct payment under the mandate, with no seller: the agent signs it and
|
|
303
|
-
* the `--key` account submits it to `pull` on the
|
|
304
|
-
* pays the gas.
|
|
305
|
-
*
|
|
306
|
-
*
|
|
407
|
+
* A direct payment under the mandate, with no seller: the agent signs it, and
|
|
408
|
+
* the facilitator, or the `--key` account, submits it to `pull` on the
|
|
409
|
+
* principal's account and pays the gas. One the mandate refuses when checked
|
|
410
|
+
* is reported and not sent, unless `recordRefusal` asks for Mandate Binding to
|
|
411
|
+
* record the refusal on chain.
|
|
412
|
+
*
|
|
413
|
+
* A pull that has left can land until its window ends, whatever the
|
|
414
|
+
* facilitator answers, so its outcome is read from the chain alone. One with
|
|
415
|
+
* no outcome yet is kept beside the credential, and the same command sends
|
|
416
|
+
* that same pull again, which the account pays at most once; once it can no
|
|
417
|
+
* longer land, the command signs a new one.
|
|
307
418
|
*/
|
|
308
419
|
export async function mandatePull(target, args, print) {
|
|
309
420
|
const mandate = await loadMandate(args.credentialPath);
|
|
310
|
-
const signDigest = await mandateAgentSigner(mandate, args.
|
|
311
|
-
const
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
421
|
+
const signDigest = await mandateAgentSigner(mandate, args.credentialPath, args.mandateKeyFile);
|
|
422
|
+
const keyPath = args.keyPath;
|
|
423
|
+
if (args.recordRefusal && keyPath === undefined)
|
|
424
|
+
throw new Error("--record-refusal sends the refused pull itself: pass --key <funded key file> to submit it and pay its gas");
|
|
425
|
+
const facilitatorProtocol = URL.canParse(target.facilitatorUrl) ? new URL(target.facilitatorUrl).protocol : "";
|
|
426
|
+
if (keyPath === undefined && facilitatorProtocol !== "http:" && facilitatorProtocol !== "https:") {
|
|
427
|
+
throw new Error(`the facilitator URL ${target.facilitatorUrl} is not an http or https URL, so the pull was not sent`);
|
|
428
|
+
}
|
|
429
|
+
const sender = keyPath === undefined
|
|
430
|
+
? undefined
|
|
431
|
+
: await readKey(keyPath).catch((error) => {
|
|
432
|
+
if (error.code === "ENOENT")
|
|
433
|
+
throw new Error(`no key at ${keyPath} to submit the pull and pay its gas; pass --key <funded key file>, or leave it out and the facilitator submits the pull`);
|
|
434
|
+
throw error;
|
|
435
|
+
});
|
|
316
436
|
const { terms, reference } = mandate;
|
|
317
|
-
const
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
437
|
+
const publicClient = rpcClient(target);
|
|
438
|
+
// A mandate not yet bound, or before its dates, can stop refusing; these are
|
|
439
|
+
// checked before the agent signs, so a pull reported unsent can never pay later.
|
|
440
|
+
const [bound, block, gas] = await Promise.all([
|
|
441
|
+
hasCode(publicClient, terms.principal).then((deployed) => (deployed ? readBoundAgentKey(publicClient, terms.principal, reference) : undefined)),
|
|
442
|
+
publicClient.getBlock(),
|
|
443
|
+
sender === undefined ? undefined : publicClient.getBalance({ address: sender.address }),
|
|
444
|
+
]);
|
|
445
|
+
if (bound === undefined)
|
|
446
|
+
throw new Error(`the mandate ${reference} is not bound on ${terms.principal}; the owner binds it with mandate bind`);
|
|
447
|
+
if (sender !== undefined && gas === 0n)
|
|
448
|
+
throw new Error(`${sender.address}, which submits the pull, holds no gas: fund it, or leave out --key and the facilitator submits the pull`);
|
|
449
|
+
const keptPath = join(pendingPullsPath(args.credentialPath), `${reference}-${args.to.toLowerCase()}-${args.amount}`);
|
|
450
|
+
const end = await chainEnd(keptPath);
|
|
451
|
+
let attempt;
|
|
452
|
+
// The pull a new one follows in the chain; the chain's first when there is none.
|
|
453
|
+
let follows;
|
|
454
|
+
let checked;
|
|
455
|
+
if (end !== undefined && (await exists(join(keptPath, `${end.pull.nonce}.done`)))) {
|
|
456
|
+
// Its outcome is reported: this is a new payment.
|
|
457
|
+
follows = end.pull.nonce;
|
|
458
|
+
}
|
|
459
|
+
else if (end !== undefined) {
|
|
460
|
+
attempt = end;
|
|
461
|
+
const { pull } = attempt;
|
|
462
|
+
if (!isAddressEqual(pull.to, args.to) || pull.value !== args.amount || !isAddressEqual(pull.token, terms.constraints.token) || slice(pull.nonce, 0, 12) !== slice(reference, 0, 12)) {
|
|
463
|
+
throw new Error(`${keptPath} holds a pull for another payment than this mandate's ${usdz(args.amount)} to ${args.to}`);
|
|
464
|
+
}
|
|
465
|
+
const found = await outcomeOnChain(publicClient, terms, attempt);
|
|
466
|
+
if (found.kind === "landed")
|
|
467
|
+
return settle(keptPath, attempt, printPull(target, terms, attempt.pull, found.receipt, print));
|
|
468
|
+
if (found.kind === "open" && found.landed)
|
|
469
|
+
return printPending(print, "it has landed, and its log cannot be read yet", attempt, keptPath);
|
|
470
|
+
// One that can never land is followed by a new pull, signed and checked below.
|
|
471
|
+
if (found.kind === "never") {
|
|
472
|
+
follows = attempt.pull.nonce;
|
|
473
|
+
attempt = undefined;
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
if (attempt === undefined) {
|
|
477
|
+
if (block.timestamp < terms.constraints.recurrenceStart && !args.recordRefusal)
|
|
478
|
+
return printUnsent("OutsideRecurrence", print);
|
|
479
|
+
const now = BigInt(Math.floor(Date.now() / 1000));
|
|
480
|
+
// What a seller's offer would be; the facilitator settles the pull against it.
|
|
481
|
+
const requirements = {
|
|
329
482
|
scheme: "exact",
|
|
330
|
-
network: `eip155:${chainId}`,
|
|
483
|
+
network: `eip155:${target.network.chainId}`,
|
|
331
484
|
amount: args.amount.toString(),
|
|
332
485
|
asset: terms.constraints.token,
|
|
333
486
|
payTo: args.to,
|
|
334
487
|
maxTimeoutSeconds: MANDATE_MAX_VALIDITY_SECONDS,
|
|
335
|
-
}
|
|
336
|
-
|
|
337
|
-
|
|
488
|
+
};
|
|
489
|
+
const signed = await signL3a({
|
|
490
|
+
chainId: target.network.chainId,
|
|
491
|
+
ref: reference,
|
|
492
|
+
token: terms.constraints.token,
|
|
493
|
+
terms: terms.terms,
|
|
494
|
+
to: args.to,
|
|
495
|
+
value: args.amount,
|
|
496
|
+
validAfter: now - BigInt(MANDATE_VALID_AFTER_LEAD_SECONDS),
|
|
497
|
+
validBefore: now + BigInt(MANDATE_MAX_VALIDITY_SECONDS),
|
|
498
|
+
transactionId: transactionIdOf(requirements),
|
|
499
|
+
iss: terms.terms.kid,
|
|
500
|
+
signDigest,
|
|
501
|
+
});
|
|
502
|
+
const fresh = { pull: signed.pull, fields: signed.fields, requirements, fromBlock: block.number };
|
|
503
|
+
const reason = await readCheck(publicClient, terms.principal, fresh.pull, fresh.fields);
|
|
504
|
+
if (reason !== "None" && !args.recordRefusal)
|
|
505
|
+
return printUnsent(reason, print);
|
|
506
|
+
await simulatePull(publicClient, terms, fresh, sender);
|
|
507
|
+
checked = fresh.pull.nonce;
|
|
508
|
+
// Kept before it leaves: from here on it can land, and the same command sends it again.
|
|
509
|
+
// A run that kept one first wins, and this run sends that one.
|
|
510
|
+
attempt = await keepFirst(join(keptPath, follows === undefined ? "first.json" : `${follows}.next.json`), fresh);
|
|
511
|
+
}
|
|
512
|
+
if (attempt.pull.nonce !== checked) {
|
|
513
|
+
const rejected = await simulatePull(publicClient, terms, attempt, sender).then(() => undefined, (error) => error);
|
|
514
|
+
if (rejected !== undefined) {
|
|
515
|
+
// A pull another run kept, or one kept before, can have landed since it was read.
|
|
516
|
+
const found = await outcomeOnChain(publicClient, terms, attempt);
|
|
517
|
+
if (found.kind === "landed") {
|
|
518
|
+
return settle(keptPath, attempt, printPull(target, terms, attempt.pull, found.receipt, print));
|
|
519
|
+
}
|
|
520
|
+
if (found.kind === "open" && found.landed)
|
|
521
|
+
return printPending(print, "it has landed, and its log cannot be read yet", attempt, keptPath);
|
|
522
|
+
throw rejected;
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
const call = pullCall(terms.principal, attempt.pull, attempt.fields);
|
|
526
|
+
const sent = sender === undefined ? await sendToFacilitator(target, terms, attempt) : await sendFromKey(publicClient, sender, call);
|
|
527
|
+
if (sent.kind === "unsent")
|
|
528
|
+
throw new Error(`the pull was not sent: ${sent.cause}; it is kept at ${keptPath}, and the same command sends it`);
|
|
529
|
+
const found = await outcomeOnChain(publicClient, terms, attempt, sent.transaction);
|
|
530
|
+
if (found.kind === "landed") {
|
|
531
|
+
return settle(keptPath, attempt, printPull(target, terms, attempt.pull, found.receipt, print));
|
|
532
|
+
}
|
|
533
|
+
if (found.kind === "never") {
|
|
534
|
+
print("outcome refused, not paid");
|
|
535
|
+
print(`reason ${describeRefusal(found.reason)}`);
|
|
536
|
+
return settle(keptPath, attempt, "refused");
|
|
537
|
+
}
|
|
538
|
+
// A transaction whose receipt was read and does not carry this pull is not named as its.
|
|
539
|
+
if (sent.transaction !== undefined && found.unreadReceipt)
|
|
540
|
+
print(`transaction ${sent.transaction}`);
|
|
541
|
+
if (found.landed)
|
|
542
|
+
return printPending(print, "it has landed, and its log cannot be read yet", attempt, keptPath);
|
|
543
|
+
return printPending(print, found.unread === undefined ? sent.why : `${sent.why}; the chain could not be read: ${found.unread}`, attempt, keptPath);
|
|
544
|
+
}
|
|
545
|
+
/**
|
|
546
|
+
* Marks a kept pull's outcome reported once it is printed, so the same
|
|
547
|
+
* command makes a new payment next; a run killed between the two leaves it
|
|
548
|
+
* for the next run to report.
|
|
549
|
+
*/
|
|
550
|
+
async function settle(keptPath, kept, outcome) {
|
|
551
|
+
await writeFile(join(keptPath, `${kept.pull.nonce}.done`), "", { flag: "wx", mode: 0o600 }).catch((error) => {
|
|
552
|
+
if (error.code !== "EEXIST")
|
|
553
|
+
throw error;
|
|
338
554
|
});
|
|
339
|
-
|
|
340
|
-
|
|
555
|
+
return outcome;
|
|
556
|
+
}
|
|
557
|
+
/**
|
|
558
|
+
* The last pull in a payee and amount's chain: its first, then each pull kept
|
|
559
|
+
* after one that can never land or whose outcome is reported. Pulls are only
|
|
560
|
+
* ever added to a chain, each once, so runs at once read the same end.
|
|
561
|
+
*/
|
|
562
|
+
async function chainEnd(keptPath) {
|
|
563
|
+
let kept = await readKept(join(keptPath, "first.json"));
|
|
564
|
+
for (let next = kept; next !== undefined; next = await readKept(join(keptPath, `${next.pull.nonce}.next.json`)))
|
|
565
|
+
kept = next;
|
|
566
|
+
return kept;
|
|
567
|
+
}
|
|
568
|
+
function printPending(print, reason, kept, keptPath) {
|
|
569
|
+
print("outcome pending");
|
|
570
|
+
print(`reason ${reason.replace(UNSHOWN, "?").slice(0, 300)}`);
|
|
571
|
+
print(`can-land-until ${utc(kept.pull.validBefore)}`);
|
|
572
|
+
print(`kept ${keptPath}: run the same command again to send the same pull, which pays at most once`);
|
|
573
|
+
return "pending";
|
|
574
|
+
}
|
|
575
|
+
const BIGINTS = ["value", "validAfter", "validBefore", "fromBlock"];
|
|
576
|
+
async function readKept(path) {
|
|
577
|
+
const text = await readFile(path, "utf8").catch((error) => {
|
|
578
|
+
if (error.code === "ENOENT")
|
|
579
|
+
return undefined;
|
|
580
|
+
throw error;
|
|
581
|
+
});
|
|
582
|
+
if (text === undefined)
|
|
583
|
+
return undefined;
|
|
341
584
|
try {
|
|
342
|
-
|
|
343
|
-
await publicClient.call({ account: sender.address, to: call.to, data: call.data });
|
|
585
|
+
return JSON.parse(text, (key, value) => (BIGINTS.includes(key) && typeof value === "string" ? BigInt(value) : value));
|
|
344
586
|
}
|
|
345
587
|
catch (error) {
|
|
588
|
+
throw new Error(`${path} cannot be read: ${error instanceof Error ? error.message : String(error)}; it holds a pull that can land until ten minutes after it was signed`);
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
/**
|
|
592
|
+
* Keeps `pull` at `path` unless a pull is kept there already, and returns the
|
|
593
|
+
* one kept. The whole file is written beside it and linked into place, which
|
|
594
|
+
* fails when the path exists, so two runs at once keep and send one pull.
|
|
595
|
+
*/
|
|
596
|
+
async function keepFirst(path, pull) {
|
|
597
|
+
await mkdir(dirname(path), { recursive: true, mode: 0o700 });
|
|
598
|
+
const draft = await writeDraft(path, pull);
|
|
599
|
+
try {
|
|
600
|
+
for (;;) {
|
|
601
|
+
const linked = await link(draft, path).then(() => true, (error) => {
|
|
602
|
+
if (error.code !== "EEXIST")
|
|
603
|
+
throw error;
|
|
604
|
+
return false;
|
|
605
|
+
});
|
|
606
|
+
if (linked)
|
|
607
|
+
return pull;
|
|
608
|
+
const kept = await readKept(path);
|
|
609
|
+
if (kept !== undefined)
|
|
610
|
+
return kept;
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
finally {
|
|
614
|
+
await rm(draft, { force: true });
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
async function writeDraft(path, pull) {
|
|
618
|
+
const draft = `${path}.${randomUUID()}.tmp`;
|
|
619
|
+
await writeFile(draft, `${JSON.stringify(pull, (_, value) => (typeof value === "bigint" ? value.toString() : value), 2)}\n`, { mode: 0o600 });
|
|
620
|
+
return draft;
|
|
621
|
+
}
|
|
622
|
+
/** The pull's own call at the node's gas ceiling; what it costs is sendMandateCall's to estimate. */
|
|
623
|
+
async function simulatePull(publicClient, terms, kept, sender) {
|
|
624
|
+
const call = pullCall(terms.principal, kept.pull, kept.fields);
|
|
625
|
+
try {
|
|
626
|
+
await publicClient.call({ ...(sender && { account: sender.address }), to: call.to, data: call.data });
|
|
627
|
+
}
|
|
628
|
+
catch (error) {
|
|
629
|
+
if (lostInTransport(error))
|
|
630
|
+
throw new Error(`the pull could not be checked: ${publicMessage(error)}`);
|
|
346
631
|
throw new Error(`Mandate Binding rejects the pull and records nothing: ${revertName(error)}`);
|
|
347
632
|
}
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
633
|
+
}
|
|
634
|
+
/** Above the facilitator's own 60 s wait for a receipt, so its answer is not cut short. */
|
|
635
|
+
const FACILITATOR_TIMEOUT_MS = 120_000;
|
|
636
|
+
/**
|
|
637
|
+
* The codes a connection fails with before any request is sent: the host is
|
|
638
|
+
* not there, or is not who its certificate says. A failure after that, or a
|
|
639
|
+
* timeout, leaves open whether the request arrived.
|
|
640
|
+
*/
|
|
641
|
+
const NEVER_REACHED = new Set(["ECONNREFUSED", "ENOTFOUND", "EAI_AGAIN", "EHOSTUNREACH", "ENETUNREACH", "ERR_TLS_CERT_ALTNAME_INVALID", "UNABLE_TO_VERIFY_LEAF_SIGNATURE", "CERT_HAS_EXPIRED", "DEPTH_ZERO_SELF_SIGNED_CERT", "SELF_SIGNED_CERT_IN_CHAIN"]);
|
|
642
|
+
/** The code under which `fetch` never reached its host, or undefined when the request may have arrived. */
|
|
643
|
+
function neverReached(error) {
|
|
644
|
+
const cause = error.cause;
|
|
645
|
+
const code = typeof cause?.code === "string" ? cause.code : undefined;
|
|
646
|
+
return code !== undefined && NEVER_REACHED.has(code) ? code : undefined;
|
|
647
|
+
}
|
|
648
|
+
/**
|
|
649
|
+
* Hands the pull to the facilitator as the payment a seller would settle,
|
|
650
|
+
* `exact` to the payee; it checks the pull against the mandate, submits it and
|
|
651
|
+
* pays the gas. Its answer gives the transaction it sent, if any, and its
|
|
652
|
+
* reason for not settling; whether the pull paid is the chain's to say.
|
|
653
|
+
*/
|
|
654
|
+
async function sendToFacilitator(target, terms, kept) {
|
|
655
|
+
const paymentPayload = {
|
|
656
|
+
x402Version: 2,
|
|
657
|
+
accepted: kept.requirements,
|
|
658
|
+
payload: {
|
|
659
|
+
authorization: {
|
|
660
|
+
from: terms.principal,
|
|
661
|
+
to: kept.pull.to,
|
|
662
|
+
value: kept.pull.value.toString(),
|
|
663
|
+
validAfter: kept.pull.validAfter.toString(),
|
|
664
|
+
validBefore: kept.pull.validBefore.toString(),
|
|
665
|
+
nonce: kept.pull.nonce,
|
|
666
|
+
},
|
|
667
|
+
signature: encodeMandateBlob(kept.pull, kept.fields),
|
|
668
|
+
},
|
|
669
|
+
};
|
|
670
|
+
let response;
|
|
671
|
+
try {
|
|
672
|
+
response = await fetch(`${target.facilitatorUrl}/settle`, {
|
|
673
|
+
method: "POST",
|
|
674
|
+
headers: { "content-type": "application/json" },
|
|
675
|
+
body: JSON.stringify({ x402Version: 2, paymentPayload, paymentRequirements: kept.requirements }),
|
|
676
|
+
signal: AbortSignal.timeout(FACILITATOR_TIMEOUT_MS),
|
|
677
|
+
});
|
|
678
|
+
}
|
|
679
|
+
catch (error) {
|
|
680
|
+
const code = neverReached(error);
|
|
681
|
+
if (code !== undefined)
|
|
682
|
+
return { kind: "unsent", cause: `the facilitator at ${new URL(target.facilitatorUrl).host} could not be reached (${code})` };
|
|
683
|
+
return { kind: "sent", transaction: undefined, why: "the facilitator did not answer" };
|
|
684
|
+
}
|
|
685
|
+
const answer = await response.json().catch(() => undefined);
|
|
686
|
+
const settled = (typeof answer === "object" && answer !== null ? answer : {});
|
|
687
|
+
const transaction = typeof settled.transaction === "string" && isHash(settled.transaction) ? settled.transaction : undefined;
|
|
688
|
+
const said = [settled.errorReason, settled.errorMessage].filter((part) => typeof part === "string" && part !== "").join(": ");
|
|
689
|
+
const why = said !== "" ? `the facilitator says ${said}` : settled.success === true ? "the facilitator says it settled" : `the facilitator answered HTTP ${response.status}`;
|
|
690
|
+
return { kind: "sent", transaction, why: why.replace(UNSHOWN, "?").slice(0, 300) };
|
|
691
|
+
}
|
|
692
|
+
/**
|
|
693
|
+
* Submits the pull from the `--key` account, which pays the gas. A node that
|
|
694
|
+
* refuses the transaction, or an estimate that fails, sent nothing; a request
|
|
695
|
+
* lost on its way to the node may have.
|
|
696
|
+
*/
|
|
697
|
+
async function sendFromKey(publicClient, sender, call) {
|
|
698
|
+
return sendMandateCall(publicClient, walletFor(publicClient, sender), call).then((transaction) => ({ kind: "sent", transaction, why: "its transaction has no receipt" }), (error) => (lostInTransport(error) ? { kind: "sent", transaction: undefined, why: `sending it failed: ${publicMessage(error)}` } : { kind: "unsent", cause: `sending it failed: ${publicMessage(error)}` }));
|
|
699
|
+
}
|
|
700
|
+
const PULL_OUTCOMES = [getAbiItem({ abi: mandateBindingAbi, name: "MandatePayment" }), getAbiItem({ abi: mandateBindingAbi, name: "MandateRefused" })];
|
|
701
|
+
/** Blocks per log search, under the node's own limit on one filter's range. */
|
|
702
|
+
const LOG_CHUNK = 10000n;
|
|
703
|
+
/**
|
|
704
|
+
* The reasons a bound mandate refuses a pull for good: none of them can turn
|
|
705
|
+
* into a payment later. A closed window is read from the block itself; a
|
|
706
|
+
* window or mandate this node has not reached yet is not final.
|
|
707
|
+
*/
|
|
708
|
+
const FINAL = ["Revoked", "Expired", "BadSignature", "PayeeNotAllowed", "OverPerPaymentCap", "OverBudget", "OverOccurrences"];
|
|
709
|
+
/**
|
|
710
|
+
* Where a pull stands on chain, read at one block: landed, in `transaction`
|
|
711
|
+
* when one is named or in the account's own log between the block it was
|
|
712
|
+
* signed at and the end of its window; never to land, once its window has
|
|
713
|
+
* closed or the mandate refuses it for good; or still open. A read that fails
|
|
714
|
+
* leaves it open.
|
|
715
|
+
*/
|
|
716
|
+
async function outcomeOnChain(publicClient, terms, kept, transaction) {
|
|
717
|
+
const digest = slice(kept.pull.nonce, 12);
|
|
718
|
+
let unreadReceipt = transaction !== undefined;
|
|
719
|
+
try {
|
|
720
|
+
if (transaction !== undefined) {
|
|
721
|
+
const receipt = await publicClient.waitForTransactionReceipt({ hash: transaction, pollingInterval: 250 });
|
|
722
|
+
unreadReceipt = false;
|
|
723
|
+
if (pullEventOf(terms, digest, receipt) !== undefined)
|
|
724
|
+
return { kind: "landed", receipt };
|
|
725
|
+
}
|
|
726
|
+
const head = await publicClient.getBlock();
|
|
727
|
+
// A pull lands only before its window ends, so the search stops at the first range that reaches it.
|
|
728
|
+
for (let from = kept.fromBlock; from <= head.number; from += LOG_CHUNK) {
|
|
729
|
+
const to = from + LOG_CHUNK - 1n < head.number ? from + LOG_CHUNK - 1n : head.number;
|
|
730
|
+
const logs = await publicClient.getLogs({ address: terms.principal, events: PULL_OUTCOMES, fromBlock: from, toBlock: to });
|
|
731
|
+
const log = logs.find((entry) => entry.args.l3aDigest === digest);
|
|
732
|
+
if (log !== undefined)
|
|
733
|
+
return { kind: "landed", receipt: await publicClient.getTransactionReceipt({ hash: log.transactionHash }) };
|
|
734
|
+
if (to === head.number || (await publicClient.getBlock({ blockNumber: to })).timestamp >= kept.pull.validBefore)
|
|
735
|
+
break;
|
|
736
|
+
}
|
|
737
|
+
// The token records the authorization a paid pull spends, whatever the mandate's state is since.
|
|
738
|
+
const spent = await publicClient.readContract({ address: kept.pull.token, abi: AUTHORIZATION_STATE, functionName: "authorizationState", args: [terms.principal, kept.pull.nonce], blockNumber: head.number });
|
|
739
|
+
if (spent)
|
|
740
|
+
return { kind: "open", landed: true };
|
|
741
|
+
if (head.timestamp >= kept.pull.validBefore)
|
|
742
|
+
return { kind: "never", reason: "OutsideWindow" };
|
|
743
|
+
const reason = await readCheck(publicClient, terms.principal, kept.pull, kept.fields, head.number);
|
|
744
|
+
// After its end a mandate refuses for good; before its start it does only until then.
|
|
745
|
+
// A used digest with no log for it is a pull that landed, as a payment or a recorded refusal.
|
|
746
|
+
if (reason === "NonceUsed")
|
|
747
|
+
return { kind: "open", landed: true };
|
|
748
|
+
const final = FINAL.includes(reason) || (reason === "OutsideRecurrence" && head.timestamp >= terms.constraints.recurrenceStart);
|
|
749
|
+
return final ? { kind: "never", reason: reason } : { kind: "open" };
|
|
750
|
+
}
|
|
751
|
+
catch (error) {
|
|
752
|
+
return { kind: "open", unread: publicMessage(error), unreadReceipt };
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
const AUTHORIZATION_STATE = parseAbi(["function authorizationState(address authorizer, bytes32 nonce) view returns (bool)"]);
|
|
756
|
+
/** Whether the request failed on its way, so the node may or may not have acted on it. */
|
|
757
|
+
function lostInTransport(error) {
|
|
758
|
+
return error instanceof BaseError && error.walk((e) => e instanceof HttpRequestError || e instanceof TimeoutError) !== null;
|
|
759
|
+
}
|
|
760
|
+
function printUnsent(reason, print) {
|
|
761
|
+
print("outcome refused, not sent");
|
|
762
|
+
print(`reason ${describeRefusal(reason)}`);
|
|
763
|
+
print("recorded no");
|
|
764
|
+
return "refused";
|
|
765
|
+
}
|
|
766
|
+
function pullEventOf(terms, l3aDigest, receipt) {
|
|
767
|
+
return parseEventLogs({ abi: mandateBindingAbi, logs: receipt.logs, eventName: ["MandatePayment", "MandateRefused"] }).find((log) => isAddressEqual(log.address, terms.principal) && log.args.l3aDigest === l3aDigest);
|
|
768
|
+
}
|
|
769
|
+
/** Where a token sends a credit the payee's receive policy refuses: its escrow (IMIP20.sol). */
|
|
770
|
+
const RECEIVE_POLICY = "0x0000000000000000000000000000000000000f4d";
|
|
771
|
+
const AUTHORIZATION_USED = parseAbiItem("event AuthorizationUsed(address indexed authorizer, bytes32 indexed nonce)");
|
|
772
|
+
function printPull(target, terms, pull, receipt, print) {
|
|
773
|
+
const outcome = pullEventOf(terms, slice(pull.nonce, 12), receipt);
|
|
774
|
+
if (outcome === undefined)
|
|
775
|
+
throw new Error(`transaction ${receipt.transactionHash} carries neither a payment nor a refusal for the pull`);
|
|
776
|
+
let result = "refused";
|
|
777
|
+
print(`transaction ${receipt.transactionHash}`);
|
|
778
|
+
if (outcome.eventName === "MandatePayment") {
|
|
779
|
+
// The transfer the pull's authorization makes is the token's next one from the account after it.
|
|
780
|
+
const used = parseEventLogs({ abi: [AUTHORIZATION_USED], logs: receipt.logs }).find((log) => isAddressEqual(log.address, pull.token) && isAddressEqual(log.args.authorizer, terms.principal) && log.args.nonce === pull.nonce);
|
|
781
|
+
const moved = used && parseEventLogs({ abi: erc20Abi, eventName: "Transfer", logs: receipt.logs }).find((log) => isAddressEqual(log.address, pull.token) && log.logIndex > used.logIndex && isAddressEqual(log.args.from, terms.principal) && !isAddressEqual(log.args.to, pull.token));
|
|
782
|
+
result = moved !== undefined && isAddressEqual(moved.args.to, RECEIVE_POLICY) ? "held" : "paid";
|
|
783
|
+
print(`outcome ${result}`);
|
|
784
|
+
if (result === "held")
|
|
785
|
+
print("reason the payee's receive policy refused the payment, and the token holds it in escrow");
|
|
352
786
|
print(`spent ${usdz(outcome.args.spent)} of ${usdz(terms.constraints.budget)}`);
|
|
353
787
|
}
|
|
354
|
-
else
|
|
788
|
+
else {
|
|
355
789
|
print("outcome refused");
|
|
356
790
|
const reason = reasonName(outcome.args.reason);
|
|
357
791
|
if (reason === "None")
|
|
358
792
|
throw new Error(`transaction ${receipt.transactionHash} records a refusal with no reason`);
|
|
359
793
|
print(`reason ${describeRefusal(reason)}`);
|
|
360
794
|
}
|
|
361
|
-
else {
|
|
362
|
-
throw new Error(`transaction ${receipt.transactionHash} carries neither a payment nor a refusal for the pull`);
|
|
363
|
-
}
|
|
364
795
|
print(`payer ${terms.principal}`);
|
|
365
|
-
print(`payee ${
|
|
366
|
-
print(`amount ${usdz(
|
|
367
|
-
|
|
368
|
-
|
|
796
|
+
print(`payee ${outcome.args.to}`);
|
|
797
|
+
print(`amount ${usdz(outcome.args.amount)}`);
|
|
798
|
+
if (target.explorerUrl)
|
|
799
|
+
print(`explorer ${target.explorerUrl}/tx/${receipt.transactionHash}`);
|
|
800
|
+
print(`gas ${receipt.gasUsed}`);
|
|
801
|
+
return result;
|
|
369
802
|
}
|
|
370
|
-
/** A decimal `*USDZ` amount a mandate can pay: positive,
|
|
803
|
+
/** A decimal `*USDZ` amount a mandate can pay: positive, to the cent. */
|
|
371
804
|
export function parsePullAmount(text, option) {
|
|
372
805
|
return BigInt(usdzBaseUnits(parseCents(text, option)));
|
|
373
806
|
}
|