@metalntwx/metal-agent 0.2.0 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +65 -39
- package/dist/chain.js +1 -1
- package/dist/commands/fund.js +28 -14
- package/dist/commands/key.js +2 -2
- package/dist/commands/mandate.js +616 -142
- package/dist/commands/pay.js +86 -13
- package/dist/help.js +20 -19
- package/dist/index.js +40 -14
- package/dist/keystore.js +30 -18
- package/dist/mcp.js +273 -31
- package/package.json +6 -6
- package/skills/metal-agent/SKILL.md +93 -51
package/dist/commands/mandate.js
CHANGED
|
@@ -1,18 +1,20 @@
|
|
|
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 {
|
|
9
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
10
|
+
import { link, mkdir, readFile, readdir, rm, writeFile } from "node:fs/promises";
|
|
10
11
|
import { dirname, join } from "node:path";
|
|
11
|
-
import { FEE_PER_GAS, assertMetalAccount, authorizeMandateCall, metalAccountFactoryAbi, MANDATE_MAX_VALIDITY_SECONDS, MANDATE_VALID_AFTER_LEAD_SECONDS, REFUSALS, UNCAPPED_OCCURRENCES, agentKeyOfJwk, bindCall, createCredential, describeRefusal, encodeAgentKey, extractTerms, hashCredential, ingestCredential, mandateBindingAbi, mandateReference, p256DigestSigner, parseCredential, privateKeyHex, publicMessage, pullCall,
|
|
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, readCheck, readMandateState, reasonName, replaceCall, revokeCall, sameAgentKey, sendMandateCall, signL3a, transactionIdOf, trustedIssuer, verifyMandate, verifyMandatePayment, } from "@metalntwx/metal-pay/mandates";
|
|
12
13
|
import { USDZ, formatUsdz, usdzBaseUnits } from "@metalntwx/metal-pay";
|
|
13
|
-
import { BaseError, HttpRequestError, TimeoutError, createWalletClient, custom, decodeErrorResult, encodeFunctionData, encodeAbiParameters, parseAbiParameters, getAddress, isAddress, isAddressEqual, isHash, parseEventLogs, slice, } from "viem";
|
|
14
|
+
import { BaseError, erc20Abi, formatLog, parseAbi, parseAbiItem, HttpRequestError, TimeoutError, createWalletClient, custom, decodeErrorResult, encodeFunctionData, encodeAbiParameters, parseAbiParameters, getAddress, isAddress, isAddressEqual, isHash, numberToHex, parseEventLogs, slice, } from "viem";
|
|
14
15
|
import { rpcClient } from "../chain.js";
|
|
15
|
-
import {
|
|
16
|
+
import { fund } from "./fund.js";
|
|
17
|
+
import { exists, mandateKeyPath, pendingPullsPath, publicKeyPath, readKey, readOrCreateIssuerKeys, readOrCreateKey, readOrCreateP256Key, readP256Key, readTrustedIssuer, writePublicKeyFile, } from "../keystore.js";
|
|
16
18
|
export async function loadMandate(credentialPath) {
|
|
17
19
|
const credential = parseCredential(JSON.parse(await readFile(credentialPath, "utf8")));
|
|
18
20
|
const terms = extractTerms(credential);
|
|
@@ -60,16 +62,20 @@ export function parseCount(text, option) {
|
|
|
60
62
|
const usdz = (units) => `${formatUsdz(units)} ${USDZ.symbol}`;
|
|
61
63
|
const utc = (seconds) => new Date(Number(seconds) * 1000).toISOString();
|
|
62
64
|
const day = (seconds) => new Date(seconds * 1000).toISOString().slice(0, 10);
|
|
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;
|
|
63
70
|
function printTerms({ principal, agentKey, constraints: c, terms, descriptive }, print) {
|
|
64
|
-
// The credential's own text is shown with control, line-separator, bidi and invisible characters replaced, so it cannot start a line,
|
|
65
|
-
// reorder one or hide text; the joiners scripts and emoji need are kept.
|
|
66
71
|
if (typeof descriptive.promptSummary === "string")
|
|
67
|
-
print(`description ${descriptive.promptSummary.replace(
|
|
72
|
+
print(`description ${descriptive.promptSummary.replace(UNSHOWN, "?")}`);
|
|
68
73
|
print(`principal ${principal}`);
|
|
69
|
-
print(`
|
|
74
|
+
print(`mandate-key ${encodeAgentKey(agentKey)} (P-256)`);
|
|
70
75
|
print(`token ${c.token}`);
|
|
71
76
|
print(`per-payment-cap ${usdz(c.perPaymentCap)}`);
|
|
72
77
|
print(`budget ${usdz(c.budget)} until expiry`);
|
|
78
|
+
print(`starts ${c.recurrenceStart} (${utc(c.recurrenceStart)})`);
|
|
73
79
|
print(`expiry ${c.expiry} (${utc(c.expiry)})`);
|
|
74
80
|
if (c.maxOccurrences !== UNCAPPED_OCCURRENCES)
|
|
75
81
|
print(`max-occurrences ${c.maxOccurrences}`);
|
|
@@ -80,7 +86,7 @@ function printTerms({ principal, agentKey, constraints: c, terms, descriptive },
|
|
|
80
86
|
const object = shown.find((o) => typeof o.id === "string" && o.id.toLowerCase() === payee.account.toLowerCase());
|
|
81
87
|
const name = typeof object?.name === "string" && object.name.toLowerCase() !== payee.account.toLowerCase() ? ` ${object.name}` : "";
|
|
82
88
|
const website = typeof object?.website === "string" && /^https?:/.test(object.website) ? ` ${object.website}` : "";
|
|
83
|
-
print(`payee ${payee.account}${name}${website}`.replace(
|
|
89
|
+
print(`payee ${payee.account}${name}${website}`.replace(UNSHOWN, "?"));
|
|
84
90
|
}
|
|
85
91
|
}
|
|
86
92
|
function walletFor(publicClient, account) {
|
|
@@ -105,25 +111,27 @@ function printSent(target, receipt, print) {
|
|
|
105
111
|
}
|
|
106
112
|
/**
|
|
107
113
|
* Issues the credential a mandate is bound from, the developer signing it as
|
|
108
|
-
* its own issuer; with no payees it lets the agent pay any address within
|
|
109
|
-
*
|
|
110
|
-
*
|
|
111
|
-
*
|
|
112
|
-
*
|
|
113
|
-
* 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.
|
|
114
119
|
*/
|
|
115
120
|
export async function credentialCreate(target, args, print) {
|
|
116
121
|
if (await exists(args.out))
|
|
117
122
|
throw new Error(`${args.out} already exists; pass another --out`);
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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`);
|
|
121
128
|
}
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
const
|
|
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);
|
|
125
132
|
const { keys, created } = await readOrCreateIssuerKeys(args.issuerKeyPath).catch(async (error) => {
|
|
126
|
-
|
|
133
|
+
if (mandateKey.created)
|
|
134
|
+
await rm(keyPath);
|
|
127
135
|
throw error;
|
|
128
136
|
});
|
|
129
137
|
const now = Math.floor(Date.now() / 1000);
|
|
@@ -135,13 +143,13 @@ export async function credentialCreate(target, args, print) {
|
|
|
135
143
|
issuer: keys.issuer,
|
|
136
144
|
principal: args.principal,
|
|
137
145
|
principalKey: keys.principalKey,
|
|
138
|
-
agentKey:
|
|
146
|
+
agentKey: mandateKey.key.publicJwk,
|
|
139
147
|
perPaymentCap: args.cap,
|
|
140
148
|
budget: args.budget,
|
|
141
149
|
payees: args.payees.map(({ address, name, website }) => ({ address, name: name ?? address, website: website ?? `eip155:${target.network.chainId}:${address}` })),
|
|
142
150
|
recurrence: {
|
|
143
151
|
frequency: "ADHO",
|
|
144
|
-
startDate: day(now),
|
|
152
|
+
startDate: args.startsIn === undefined ? day(now) : new Date((now + args.startsIn) * 1000).toISOString().replace(".000Z", "Z"),
|
|
145
153
|
endDate: day(expiresAt),
|
|
146
154
|
...(args.maxOccurrences !== undefined && { maxOccurrences: args.maxOccurrences }),
|
|
147
155
|
},
|
|
@@ -153,18 +161,16 @@ export async function credentialCreate(target, args, print) {
|
|
|
153
161
|
}
|
|
154
162
|
catch (error) {
|
|
155
163
|
// The keys this run created name nothing yet; removing them lets the corrected command run again.
|
|
156
|
-
await Promise.all([
|
|
164
|
+
await Promise.all([mandateKey.created && rm(keyPath), created && rm(args.issuerKeyPath)]);
|
|
157
165
|
throw error;
|
|
158
166
|
}
|
|
159
167
|
await writeFile(args.out, `${JSON.stringify(credential, null, 2)}\n`, { flag: "wx" });
|
|
160
168
|
const issuerPublicPath = publicKeyPath(args.issuerKeyPath);
|
|
161
169
|
await writePublicKeyFile(issuerPublicPath, trustedIssuer(keys.issuer));
|
|
162
170
|
print(`credential ${args.out}`);
|
|
163
|
-
print(`
|
|
171
|
+
print(`mandate ${ingested.reference}`);
|
|
164
172
|
printTerms(ingested.extracted, print);
|
|
165
|
-
print(`
|
|
166
|
-
print(`gas-key-file ${gasPath}${gas.created ? " (created)" : ""}`);
|
|
167
|
-
print(`gas-key-address ${gas.address}`);
|
|
173
|
+
print(`mandate-key-file ${keyPath}${mandateKey.created ? " (created)" : ""}`);
|
|
168
174
|
print(`issuer-key-file ${args.issuerKeyPath}${created ? " (created)" : ""}`);
|
|
169
175
|
print(`issuer-public-file ${issuerPublicPath}`);
|
|
170
176
|
}
|
|
@@ -185,29 +191,67 @@ async function ingestFor(args) {
|
|
|
185
191
|
/** The one-owner account `owner` controls: its config for the factory, and the address the factory gives it. */
|
|
186
192
|
async function ownerAccount(client, factory, owner) {
|
|
187
193
|
const config = [[owner], [1n], 1n];
|
|
188
|
-
|
|
189
|
-
if (factoryCode === undefined || factoryCode === "0x")
|
|
194
|
+
if (!(await hasCode(client, factory)))
|
|
190
195
|
throw new Error(`no MetalAccountFactory at ${factory}; on a devnet run just deploy, or pass --factory`);
|
|
191
196
|
const address = await client.readContract({ address: factory, abi: metalAccountFactoryAbi, functionName: "predictAccount", args: config });
|
|
192
197
|
return { config, address };
|
|
193
198
|
}
|
|
194
|
-
/**
|
|
195
|
-
* `out` names a file to create
|
|
196
|
-
*
|
|
197
|
-
*
|
|
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 too little gas is funded first, from the faucet on testnet. Mixed-key
|
|
203
|
+
* and weighted accounts use the same factory through the SDK. */
|
|
198
204
|
export async function accountCreate(target, args, print) {
|
|
199
205
|
const keyPath = "keyPath" in args ? args.keyPath : args.out;
|
|
200
206
|
const created = "out" in args ? (await readOrCreateKey(args.out)).created : false;
|
|
201
207
|
const owner = await readKey(keyPath);
|
|
208
|
+
// The owner key is written before anything is read from the chain: a failure names it, and the same command continues.
|
|
209
|
+
await deployAccount(target, args.accountFactory, keyPath, created, owner, print).catch((error) => {
|
|
210
|
+
throw new Error(`${error instanceof BaseError || !(error instanceof Error) ? publicMessage(error) : error.message}. The owner key is in ${keyPath}; the same command continues from it`);
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
async function deployAccount(target, accountFactory, keyPath, created, owner, print) {
|
|
202
214
|
const client = rpcClient(target);
|
|
203
|
-
const { address } = await ownerAccount(client,
|
|
204
|
-
const code = await client.getCode({ address });
|
|
215
|
+
const { config, address } = await ownerAccount(client, accountFactory, owner.address);
|
|
205
216
|
print(`account ${address}`);
|
|
206
|
-
print(`deployed ${code === undefined || code === "0x" ? "no, the first bind creates it" : "yes"}`);
|
|
207
217
|
print(`owner ${owner.address}`);
|
|
208
218
|
print(`keyfile ${keyPath}${created ? " (created)" : ""}`);
|
|
219
|
+
if (await hasCode(client, address)) {
|
|
220
|
+
print("deployed yes");
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
const call = { to: accountFactory, data: encodeFunctionData({ abi: metalAccountFactoryAbi, functionName: "createAccount", args: config }), gas: 600000n, maxFeePerGas: FEE_PER_GAS, maxPriorityFeePerGas: FEE_PER_GAS };
|
|
224
|
+
const needed = call.gas * FEE_PER_GAS;
|
|
225
|
+
if ((await client.getBalance({ address: owner.address })) < needed) {
|
|
226
|
+
// Only the grant's summary: its transaction lines would read as the account's.
|
|
227
|
+
const funding = [];
|
|
228
|
+
// `key import` loads the agent's key, which account create never reads.
|
|
229
|
+
const refused = await fund(target, owner.address, undefined, (line) => funding.push(line), fetch, false).then(() => undefined, (error) => error);
|
|
230
|
+
if (funding[0] !== undefined)
|
|
231
|
+
print(funding[0]);
|
|
232
|
+
// A grant the faucet could not confirm can have reached the owner all the same.
|
|
233
|
+
if (refused !== undefined) {
|
|
234
|
+
const held = await client.getBalance({ address: owner.address });
|
|
235
|
+
if (held < needed)
|
|
236
|
+
throw new Error(`the owner ${owner.address} holds ${usdz(held)}, and deploying the account needs ${usdz(needed)}, so it is not deployed: ${refused instanceof Error ? refused.message : String(refused)}`);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
const hash = await sendMandateCall(client, walletFor(client, owner), call).catch((error) => {
|
|
240
|
+
throw new Error(`the account is not deployed: ${publicMessage(error)}`);
|
|
241
|
+
});
|
|
242
|
+
const receipt = await client.waitForTransactionReceipt({ hash, pollingInterval: 250 }).catch((error) => {
|
|
243
|
+
throw new Error(`the account's deployment was sent in transaction ${hash}, and its receipt could not be read: ${publicMessage(error)}; the same command reports the account once it is deployed`);
|
|
244
|
+
});
|
|
245
|
+
if (receipt.status !== "success")
|
|
246
|
+
throw new Error(`the account is not deployed: transaction ${hash} reverted`);
|
|
247
|
+
print("deployed yes");
|
|
248
|
+
printSent(target, receipt, print);
|
|
249
|
+
}
|
|
250
|
+
async function hasCode(client, address) {
|
|
251
|
+
const code = await client.getCode({ address });
|
|
252
|
+
return code !== undefined && code !== "0x";
|
|
209
253
|
}
|
|
210
|
-
async function sendOwnerCall(client, args, call
|
|
254
|
+
async function sendOwnerCall(client, args, call) {
|
|
211
255
|
const account = (Array.isArray(call) ? call[0] : call)?.to;
|
|
212
256
|
if (account === undefined)
|
|
213
257
|
throw new Error("no account call to send");
|
|
@@ -216,26 +260,21 @@ async function sendOwnerCall(client, args, call, createAccount = false) {
|
|
|
216
260
|
signers.sort((a, b) => a.address.toLowerCase().localeCompare(b.address.toLowerCase()));
|
|
217
261
|
if (new Set(signers.map(s => s.address.toLowerCase())).size !== signers.length)
|
|
218
262
|
throw new Error("duplicate account signer");
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
if (address.toLowerCase() !== account.toLowerCase())
|
|
229
|
-
throw new Error(`${account} is not the account of ${sponsor.address}; it has no code, and only its owner's first bind creates it`);
|
|
230
|
-
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 });
|
|
231
|
-
}
|
|
232
|
-
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 })))]));
|
|
263
|
+
if (!(await hasCode(client, account))) {
|
|
264
|
+
const own = signers.length === 1 && isAddressEqual(signers[0].address, sponsor.address) && isAddressEqual((await ownerAccount(client, args.accountFactory, sponsor.address)).address, account);
|
|
265
|
+
if (own)
|
|
266
|
+
throw new Error(`${account} has no account: create it with metal-agent account create`);
|
|
267
|
+
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`);
|
|
268
|
+
}
|
|
269
|
+
// The quorum's signature is good for fifteen minutes: long enough to send, not to hold.
|
|
270
|
+
const validUntil = BigInt(Math.floor(Date.now() / 1000)) + 900n;
|
|
271
|
+
const authorized = await authorizeMandateCall(client, args.accountFactory, call, validUntil, async (digest) => encodeAbiParameters(parseAbiParameters("bytes[], bytes[]"), [signers.map(s => s.address), await Promise.all(signers.map(s => s.sign({ hash: digest })))]));
|
|
233
272
|
return send(client, sponsor, authorized);
|
|
234
273
|
}
|
|
235
274
|
export async function mandateBind(target, args, print) {
|
|
236
275
|
const { ingested } = await ingestFor(args);
|
|
237
276
|
const publicClient = rpcClient(target);
|
|
238
|
-
const receipt = await sendOwnerCall(publicClient, args, bindCall(ingested)
|
|
277
|
+
const receipt = await sendOwnerCall(publicClient, args, bindCall(ingested));
|
|
239
278
|
print(`bound ${ingested.reference}`);
|
|
240
279
|
printSent(target, receipt, print);
|
|
241
280
|
}
|
|
@@ -297,29 +336,29 @@ export async function mandateRevoke(target, args, print) {
|
|
|
297
336
|
export async function mandateStatus(target, credentialPath, accountFactory, print) {
|
|
298
337
|
const { terms, reference } = await loadMandate(credentialPath);
|
|
299
338
|
const publicClient = rpcClient(target);
|
|
300
|
-
print(`
|
|
339
|
+
print(`mandate ${reference}`);
|
|
301
340
|
printTerms(terms, print);
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
print("account-type none yet; the owner's first bind creates it");
|
|
341
|
+
if (!(await hasCode(publicClient, terms.principal))) {
|
|
342
|
+
print("account-type none; create the account before binding: metal-agent account create for one owner");
|
|
305
343
|
print("bound no");
|
|
306
|
-
return;
|
|
344
|
+
return "unbound";
|
|
307
345
|
}
|
|
308
346
|
await assertMetalAccount(publicClient, terms.principal, accountFactory);
|
|
309
347
|
print("account-type MetalAccount");
|
|
310
348
|
const bound = (await publicClient.readContract({ address: terms.principal, abi: mandateBindingAbi, functionName: "mandates", args: [reference] })).principal.toLowerCase() === terms.principal.toLowerCase();
|
|
311
349
|
print(`bound ${bound ? "yes" : "no"}`);
|
|
312
350
|
if (!bound)
|
|
313
|
-
return;
|
|
351
|
+
return "unbound";
|
|
314
352
|
const state = await readMandateState(publicClient, terms.principal, reference);
|
|
315
353
|
print(`revoked ${state.revoked ? "yes" : "no"}`);
|
|
316
354
|
print(`spent ${usdz(state.spent)} of ${usdz(terms.constraints.budget)}`);
|
|
317
355
|
print(`payments ${state.occurrences}`);
|
|
356
|
+
return state.revoked ? "revoked" : "live";
|
|
318
357
|
}
|
|
319
358
|
/**
|
|
320
359
|
* Verifies the mandate and its payments from the chain alone: the binding on
|
|
321
360
|
* the principal's account, the credential against it and under its issuer,
|
|
322
|
-
* and each payment and refusal as the
|
|
361
|
+
* and each payment and refusal as the mandate key's own per-payment mandate,
|
|
323
362
|
* rebuilt from the transaction that carried it. Any party holding the
|
|
324
363
|
* credential can run it; Metal is not asked.
|
|
325
364
|
*/
|
|
@@ -332,36 +371,47 @@ export async function mandateVerify(target, args, print) {
|
|
|
332
371
|
const v = args.tx === undefined
|
|
333
372
|
? await verifyMandate(publicClient, opts)
|
|
334
373
|
: await verifyMandatePayment(publicClient, { ...opts, tx: args.tx, ...(args.signedOffer === undefined ? {} : { checkoutMandate: args.signedOffer }) });
|
|
335
|
-
print(`
|
|
374
|
+
print(`mandate ${reference}`);
|
|
336
375
|
for (const step of v.steps) {
|
|
337
376
|
print(`step ${step.step} ${step.outcome}: ${step.title}`);
|
|
338
377
|
print(` ${step.detail}`);
|
|
339
378
|
}
|
|
340
379
|
for (const p of v.payments) {
|
|
341
380
|
const what = p.kind === "payment" ? "payment" : `refusal (${p.reason === undefined || p.reason === "None" ? "?" : REFUSALS[p.reason].reason})`;
|
|
342
|
-
|
|
343
|
-
print(`${what} ${p.txHash} block ${p.block}: ${usdz(p.amount)} to ${p.to}, ${how}${p.problems.length === 0 ? "" : `: ${p.problems.join(", ")}`}`);
|
|
381
|
+
print(`${what} ${p.txHash} block ${p.block}: ${usdz(p.amount)} to ${p.to}, nonce ${p.nonce ?? "?"}${p.problems.length === 0 ? "" : `: ${p.problems.join(", ")}`}`);
|
|
344
382
|
}
|
|
345
383
|
print(`verified ${v.ok ? "yes" : "no"}`);
|
|
346
384
|
return v.ok ? "verified" : "failed";
|
|
347
385
|
}
|
|
348
|
-
/**
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
386
|
+
/**
|
|
387
|
+
* Signs with the mandate's key: `keyFile` when given, or else the key file in
|
|
388
|
+
* the credential's directory that the credential names, so a replacement
|
|
389
|
+
* written beside its predecessor signs with the predecessor's key.
|
|
390
|
+
*/
|
|
391
|
+
export async function mandateAgentSigner(mandate, credentialPath, keyFile) {
|
|
392
|
+
const names = (key) => sameAgentKey(agentKeyOfJwk(key.publicJwk), mandate.terms.agentKey);
|
|
393
|
+
if (keyFile !== undefined) {
|
|
394
|
+
const key = await readP256Key(keyFile).catch((error) => {
|
|
395
|
+
throw error.code === "ENOENT" ? new Error(`no mandate key at ${keyFile}; pass --mandate-key-file with the mandate-key-file credential create printed`) : error;
|
|
396
|
+
});
|
|
397
|
+
if (!names(key))
|
|
398
|
+
throw new Error(`${keyFile} is not this mandate's key`);
|
|
399
|
+
return p256DigestSigner(privateKeyHex(key));
|
|
400
|
+
}
|
|
401
|
+
const dir = dirname(credentialPath);
|
|
402
|
+
const own = mandateKeyPath(credentialPath);
|
|
403
|
+
const beside = (await readdir(dir).catch(() => [])).filter((name) => name.endsWith(".key.json")).map((name) => join(dir, name));
|
|
404
|
+
for (const path of [own, ...beside.filter((path) => path !== own)]) {
|
|
405
|
+
// The credential's own key file must read; another file that is not a P-256 key is not this mandate's.
|
|
406
|
+
const key = await readP256Key(path).catch((error) => {
|
|
407
|
+
if (path === own && error.code !== "ENOENT")
|
|
408
|
+
throw error;
|
|
409
|
+
return undefined;
|
|
410
|
+
});
|
|
411
|
+
if (key && names(key))
|
|
412
|
+
return p256DigestSigner(privateKeyHex(key));
|
|
413
|
+
}
|
|
414
|
+
throw new Error(`no key beside ${credentialPath} is this mandate's; pass --mandate-key-file with the mandate-key-file credential create printed`);
|
|
365
415
|
}
|
|
366
416
|
/** A revert's Mandate Binding error name (`NonceAlreadyUsed`, `BadAgentSignature`, …), or the node's message. */
|
|
367
417
|
function revertName(error) {
|
|
@@ -379,81 +429,486 @@ function revertName(error) {
|
|
|
379
429
|
return error.shortMessage;
|
|
380
430
|
}
|
|
381
431
|
/**
|
|
382
|
-
* A direct payment under the mandate, with no seller: the agent signs it and
|
|
383
|
-
* the `--key` account submits it to `pull` on the
|
|
384
|
-
* pays the gas. One the mandate refuses when checked
|
|
385
|
-
*
|
|
386
|
-
*
|
|
387
|
-
*
|
|
388
|
-
*
|
|
389
|
-
*
|
|
432
|
+
* A direct payment under the mandate, with no seller: the agent signs it, and
|
|
433
|
+
* the facilitator, or the `--key` account, submits it to `pull` on the
|
|
434
|
+
* principal's account and pays the gas. One the mandate refuses when checked
|
|
435
|
+
* is reported and not sent, unless `recordRefusal` asks for Mandate Binding to
|
|
436
|
+
* record the refusal on chain.
|
|
437
|
+
*
|
|
438
|
+
* A pull that has left can land until its window ends, whatever the
|
|
439
|
+
* facilitator answers, so its outcome is read from the chain alone. Each is
|
|
440
|
+
* kept under `~/.metal-agent/pulls/<mandate>/`, per payment id when one is
|
|
441
|
+
* given and per payee and amount otherwise, and the same command sends the
|
|
442
|
+
* pull with no outcome yet again, which the account pays at most once; once
|
|
443
|
+
* it can no longer land, the command signs a new one. Under a payment id, a
|
|
444
|
+
* payout that was paid or held is reported again and nothing is sent.
|
|
390
445
|
*/
|
|
391
446
|
export async function mandatePull(target, args, print) {
|
|
392
447
|
const mandate = await loadMandate(args.credentialPath);
|
|
393
|
-
const signDigest = await mandateAgentSigner(mandate, args.
|
|
394
|
-
const
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
448
|
+
const signDigest = await mandateAgentSigner(mandate, args.credentialPath, args.mandateKeyFile);
|
|
449
|
+
const keyPath = args.keyPath;
|
|
450
|
+
if (args.recordRefusal && keyPath === undefined)
|
|
451
|
+
throw new Error("--record-refusal sends the refused pull itself: pass --key <funded key file> to submit it and pay its gas");
|
|
452
|
+
const sender = keyPath === undefined
|
|
453
|
+
? undefined
|
|
454
|
+
: await readKey(keyPath).catch((error) => {
|
|
455
|
+
if (error.code === "ENOENT")
|
|
456
|
+
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`);
|
|
457
|
+
throw error;
|
|
458
|
+
});
|
|
399
459
|
const { terms, reference } = mandate;
|
|
400
460
|
const publicClient = rpcClient(target);
|
|
401
461
|
// A mandate not yet bound, or before its dates, can stop refusing; these are
|
|
402
462
|
// checked before the agent signs, so a pull reported unsent can never pay later.
|
|
403
463
|
const [bound, block, gas] = await Promise.all([
|
|
404
|
-
publicClient
|
|
464
|
+
hasCode(publicClient, terms.principal).then((deployed) => (deployed ? publicClient.readContract({ address: terms.principal, abi: mandateBindingAbi, functionName: "mandates", args: [reference] }) : undefined)),
|
|
405
465
|
publicClient.getBlock(),
|
|
406
|
-
publicClient.getBalance({ address: sender.address }),
|
|
466
|
+
sender === undefined ? undefined : publicClient.getBalance({ address: sender.address }),
|
|
407
467
|
]);
|
|
408
468
|
if (bound === undefined)
|
|
469
|
+
throw new Error(`the mandate's account ${terms.principal} does not exist: the owner creates it with metal-agent account create, then binds the mandate with mandate bind`);
|
|
470
|
+
if (!isAddressEqual(bound.principal, terms.principal))
|
|
409
471
|
throw new Error(`the mandate ${reference} is not bound on ${terms.principal}; the owner binds it with mandate bind`);
|
|
410
|
-
if (
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
const
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
472
|
+
if (sender !== undefined && gas === 0n)
|
|
473
|
+
throw new Error(`${sender.address}, which submits the pull, holds no gas: fund it, or leave out --key and the facilitator submits the pull`);
|
|
474
|
+
// A hash names a payment id's directory: any text is one path segment, and ids that differ only in case stay apart.
|
|
475
|
+
const keptPath = join(pendingPullsPath(reference), args.paymentId === undefined ? `${args.to.toLowerCase()}-${args.amount}` : `payment-${createHash("sha256").update(args.paymentId).digest("hex")}`);
|
|
476
|
+
const end = await chainEnd(keptPath);
|
|
477
|
+
if (end !== undefined) {
|
|
478
|
+
const { pull } = end;
|
|
479
|
+
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)) {
|
|
480
|
+
if (args.paymentId === undefined || slice(pull.nonce, 0, 12) !== slice(reference, 0, 12)) {
|
|
481
|
+
throw new Error(`${keptPath} holds a pull for another payment than this mandate's ${usdz(args.amount)} to ${args.to}`);
|
|
482
|
+
}
|
|
483
|
+
const recorded = join(keptPath, `${pull.nonce}.done`);
|
|
484
|
+
const ended = (await exists(recorded)) ? (await readReported(recorded)).outcome : undefined;
|
|
485
|
+
// A payout the account refused paid nothing: the payment id is sent with these values, as a new pull after it.
|
|
486
|
+
if (ended !== "refused") {
|
|
487
|
+
const named = `the payout under payment id ${JSON.stringify(args.paymentId)} is ${usdz(pull.value)} to ${getAddress(pull.to)}`;
|
|
488
|
+
if (ended !== undefined)
|
|
489
|
+
throw new Error(`${named}: send it again with those values; another payment id is another payment`);
|
|
490
|
+
// One with no outcome yet can still land: another id would pay a second time.
|
|
491
|
+
throw new Error(`${named}, signed to land until ${utc(pull.validBefore)}: send it again with those values, to send it or learn how it ended; another payment id is another payment`);
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
// What a run prints from the outcome on, kept with the outcome for a payment id's later runs.
|
|
496
|
+
const shown = [];
|
|
497
|
+
const show = (line) => {
|
|
498
|
+
shown.push(line);
|
|
499
|
+
print(line);
|
|
500
|
+
};
|
|
501
|
+
let attempt;
|
|
502
|
+
// The pull a new one follows in the chain; the chain's first when there is none.
|
|
503
|
+
let follows;
|
|
504
|
+
let checked;
|
|
505
|
+
const done = end === undefined ? undefined : join(keptPath, `${end.pull.nonce}.done`);
|
|
506
|
+
if (end !== undefined && done !== undefined && (await exists(done))) {
|
|
507
|
+
// A payment id's payout that moved money is that payment: it is reported again, and nothing is sent.
|
|
508
|
+
const reported = args.paymentId === undefined ? undefined : await readReported(done);
|
|
509
|
+
if (reported?.outcome === "paid" || reported?.outcome === "held") {
|
|
510
|
+
// The token records the authorization the pull spent; a chain reset since holds none.
|
|
511
|
+
const spent = await publicClient.readContract({ address: end.pull.token, abi: AUTHORIZATION_STATE, functionName: "authorizationState", args: [terms.principal, end.pull.nonce] });
|
|
512
|
+
if (!spent)
|
|
513
|
+
throw new Error(`payment id ${JSON.stringify(args.paymentId)} is recorded as ${reported.outcome} in a transaction this chain does not hold, as after a chain reset; nothing was sent`);
|
|
514
|
+
print(`already ${reported.outcome}, nothing sent`);
|
|
515
|
+
// The spend is the mandate's now, which later payments have added to.
|
|
516
|
+
for (const line of reported.lines)
|
|
517
|
+
print(line.startsWith("spent ") ? `spent ${usdz(bound.spent)} of ${usdz(terms.constraints.budget)}` : line);
|
|
518
|
+
return reported.outcome;
|
|
519
|
+
}
|
|
520
|
+
// Its outcome is reported: this is a new payment, or under a payment id one more try at it.
|
|
521
|
+
follows = end.pull.nonce;
|
|
522
|
+
}
|
|
523
|
+
else if (end !== undefined) {
|
|
524
|
+
attempt = end;
|
|
525
|
+
const found = await outcomeOnChain(publicClient, terms, reference, attempt);
|
|
526
|
+
if (found.kind === "undecoded")
|
|
527
|
+
throw undecodedPull(terms, found);
|
|
528
|
+
if (found.kind === "landed")
|
|
529
|
+
return settle(keptPath, attempt, printPull(target, terms, attempt.pull, found.receipt, show), shown, args, print);
|
|
530
|
+
if (found.kind === "open" && found.landed)
|
|
531
|
+
return printPending(print, "it has landed, and its log cannot be read yet", attempt, args);
|
|
532
|
+
// One that can never land is followed by a new pull, signed and checked below.
|
|
533
|
+
if (found.kind === "never") {
|
|
534
|
+
follows = attempt.pull.nonce;
|
|
535
|
+
attempt = undefined;
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
const facilitatorProtocol = URL.canParse(target.facilitatorUrl) ? new URL(target.facilitatorUrl).protocol : "";
|
|
539
|
+
if (keyPath === undefined && facilitatorProtocol !== "http:" && facilitatorProtocol !== "https:") {
|
|
540
|
+
const sentNothing = `the facilitator URL ${target.facilitatorUrl} is not an http or https URL, so`;
|
|
541
|
+
throw new Error(attempt === undefined ? `${sentNothing} the pull was not sent` : `${sentNothing} this run sent nothing; ${keptBefore(attempt)}`);
|
|
542
|
+
}
|
|
543
|
+
if (attempt === undefined) {
|
|
544
|
+
// Mandate Binding names the mandate's state before its dates; one that has not started cannot have expired.
|
|
545
|
+
if (block.timestamp < terms.constraints.recurrenceStart && !args.recordRefusal)
|
|
546
|
+
return printUnsent(bound.revoked ? "Revoked" : "OutsideRecurrence", print);
|
|
547
|
+
const now = BigInt(Math.floor(Date.now() / 1000));
|
|
548
|
+
// What a seller's offer would be; the facilitator settles the pull against it.
|
|
549
|
+
const requirements = {
|
|
426
550
|
scheme: "exact",
|
|
427
|
-
network: `eip155:${chainId}`,
|
|
551
|
+
network: `eip155:${target.network.chainId}`,
|
|
428
552
|
amount: args.amount.toString(),
|
|
429
553
|
asset: terms.constraints.token,
|
|
430
554
|
payTo: args.to,
|
|
431
555
|
maxTimeoutSeconds: MANDATE_MAX_VALIDITY_SECONDS,
|
|
432
|
-
}
|
|
433
|
-
|
|
434
|
-
|
|
556
|
+
};
|
|
557
|
+
const signed = await signL3a({
|
|
558
|
+
chainId: target.network.chainId,
|
|
559
|
+
ref: reference,
|
|
560
|
+
token: terms.constraints.token,
|
|
561
|
+
terms: terms.terms,
|
|
562
|
+
to: args.to,
|
|
563
|
+
value: args.amount,
|
|
564
|
+
validAfter: now - BigInt(MANDATE_VALID_AFTER_LEAD_SECONDS),
|
|
565
|
+
validBefore: now + BigInt(MANDATE_MAX_VALIDITY_SECONDS),
|
|
566
|
+
transactionId: transactionIdOf(requirements),
|
|
567
|
+
iss: terms.terms.kid,
|
|
568
|
+
signDigest,
|
|
569
|
+
});
|
|
570
|
+
const fresh = { pull: signed.pull, fields: signed.fields, requirements, fromBlock: block.number };
|
|
571
|
+
const reason = await readCheck(publicClient, terms.principal, fresh.pull, fresh.fields);
|
|
572
|
+
if (reason !== "None" && !args.recordRefusal)
|
|
573
|
+
return printUnsent(reason, print);
|
|
574
|
+
await simulatePull(publicClient, terms, fresh, sender);
|
|
575
|
+
checked = fresh.pull.nonce;
|
|
576
|
+
// Kept before it leaves: from here on it can land, and the same pull sends it again.
|
|
577
|
+
// A run that kept one first wins, and this run sends that one.
|
|
578
|
+
attempt = await keepFirst(join(keptPath, follows === undefined ? "first.json" : `${follows}.next.json`), fresh);
|
|
579
|
+
}
|
|
580
|
+
if (attempt.pull.nonce !== checked) {
|
|
581
|
+
const rejected = await simulatePull(publicClient, terms, attempt, sender).then(() => undefined, (error) => error);
|
|
582
|
+
if (rejected !== undefined) {
|
|
583
|
+
// A pull another run kept, or one kept before, can have landed since it was read.
|
|
584
|
+
const found = await outcomeOnChain(publicClient, terms, reference, attempt);
|
|
585
|
+
if (found.kind === "undecoded")
|
|
586
|
+
throw undecodedPull(terms, found);
|
|
587
|
+
if (found.kind === "landed") {
|
|
588
|
+
return settle(keptPath, attempt, printPull(target, terms, attempt.pull, found.receipt, show), shown, args, print);
|
|
589
|
+
}
|
|
590
|
+
if (found.kind === "open" && found.landed)
|
|
591
|
+
return printPending(print, "it has landed, and its log cannot be read yet", attempt, args);
|
|
592
|
+
throw new Error(`${rejected instanceof Error ? rejected.message : String(rejected)}; ${keptBefore(attempt)}`);
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
const call = pullCall(terms.principal, attempt.pull, attempt.fields);
|
|
596
|
+
const sent = sender === undefined ? await sendToFacilitator(target, terms, attempt) : await sendFromKey(publicClient, sender, call);
|
|
597
|
+
// A pull kept before this run may have left then, and can land whatever this run's send meets.
|
|
598
|
+
const before = attempt.pull.nonce === checked ? undefined : keptBefore(attempt);
|
|
599
|
+
if (sent.kind === "unsent")
|
|
600
|
+
throw new Error(before === undefined ? `the pull was not sent: ${sent.cause}; it is kept ${keptAs(args)}, and the same pull sends it` : `this run did not send the pull: ${sent.cause}; ${before}`);
|
|
601
|
+
const found = await outcomeOnChain(publicClient, terms, reference, attempt, sent.kind === "sent" ? sent.transaction : undefined);
|
|
602
|
+
if (found.kind === "undecoded")
|
|
603
|
+
throw undecodedPull(terms, found);
|
|
604
|
+
if (found.kind === "landed") {
|
|
605
|
+
return settle(keptPath, attempt, printPull(target, terms, attempt.pull, found.receipt, show), shown, args, print);
|
|
606
|
+
}
|
|
607
|
+
if (found.kind === "never") {
|
|
608
|
+
show("outcome refused, not paid");
|
|
609
|
+
show(`reason ${describeRefusal(found.reason)}`);
|
|
610
|
+
return settle(keptPath, attempt, "refused", shown, args, print);
|
|
611
|
+
}
|
|
612
|
+
// A transaction whose receipt was read and does not carry this pull is not named as its.
|
|
613
|
+
if (sent.kind === "sent" && sent.transaction !== undefined && found.unreadReceipt)
|
|
614
|
+
print(`transaction ${sent.transaction}`);
|
|
615
|
+
if (found.landed)
|
|
616
|
+
return printPending(print, "it has landed, and its log cannot be read yet", attempt, args);
|
|
617
|
+
// A refusal that names no transaction sent none, and the chain read shows the pull not on it: waiting changes nothing for a pull no earlier run kept.
|
|
618
|
+
if (sent.kind === "refused" && found.unread === undefined) {
|
|
619
|
+
throw new Error(before === undefined
|
|
620
|
+
? `the pull was not sent: ${sent.cause}; it is kept ${keptAs(args)}: the same pull sends it again, and it is refused again until what the reason names changes`
|
|
621
|
+
: `this run did not send the pull: ${sent.cause}; ${before}`);
|
|
622
|
+
}
|
|
623
|
+
const why = sent.kind === "refused" ? sent.cause : sent.why;
|
|
624
|
+
return printPending(print, found.unread === undefined ? why : `${why}; the chain could not be read: ${found.unread}`, attempt, args);
|
|
625
|
+
}
|
|
626
|
+
/** How a payout is kept, as its retry names it: by its payment id, or else by its amount and payee. */
|
|
627
|
+
function keptAs({ paymentId, amount, to }) {
|
|
628
|
+
return paymentId === undefined ? `for ${usdz(amount)} to ${to}` : `under payment id ${JSON.stringify(paymentId)}`;
|
|
629
|
+
}
|
|
630
|
+
/** What a run that sends nothing says of a pull kept before it, which may have left then and can land until its window ends. */
|
|
631
|
+
function keptBefore(kept) {
|
|
632
|
+
return `the pull kept before may have been sent and can land until ${utc(kept.pull.validBefore)}; the same pull sends it again`;
|
|
633
|
+
}
|
|
634
|
+
const PULL_OUTCOMES = ["paid", "refused", "held", "pending"];
|
|
635
|
+
/**
|
|
636
|
+
* Marks a kept pull's outcome reported once it is printed, with the outcome
|
|
637
|
+
* and its lines, so the same command makes a new payment next, or under a
|
|
638
|
+
* payment id reports that one again; a run killed between the two leaves it
|
|
639
|
+
* for the next run to report.
|
|
640
|
+
*/
|
|
641
|
+
async function settle(keptPath, kept, outcome, lines, args, print) {
|
|
642
|
+
// The outcome is the chain's: a record that cannot be written leaves the next run to read it there again.
|
|
643
|
+
await record(join(keptPath, `${kept.pull.nonce}.done`), { outcome, lines }).catch((error) => {
|
|
644
|
+
print(`kept ${keptAs(args)}: the outcome was not recorded (${error instanceof Error ? error.message : String(error)}); the same pull reports it again`);
|
|
435
645
|
});
|
|
436
|
-
|
|
646
|
+
return outcome;
|
|
647
|
+
}
|
|
648
|
+
/** Writes the record whole beside `path` and links it into place, as a pull is kept, so no run reads a part-written record. */
|
|
649
|
+
async function record(path, outcome) {
|
|
650
|
+
const draft = `${path}.${randomUUID()}.tmp`;
|
|
437
651
|
try {
|
|
438
|
-
|
|
439
|
-
await
|
|
652
|
+
await writeFile(draft, `${JSON.stringify(outcome)}\n`, { mode: 0o600 });
|
|
653
|
+
await link(draft, path).catch((error) => {
|
|
654
|
+
if (error.code !== "EEXIST")
|
|
655
|
+
throw error;
|
|
656
|
+
});
|
|
657
|
+
}
|
|
658
|
+
finally {
|
|
659
|
+
await rm(draft, { force: true });
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
/** The outcome `settle` recorded at `path`, and the lines it printed. */
|
|
663
|
+
async function readReported(path) {
|
|
664
|
+
const text = await readFile(path, "utf8");
|
|
665
|
+
try {
|
|
666
|
+
const record = JSON.parse(text);
|
|
667
|
+
if (!PULL_OUTCOMES.includes(record.outcome) || !Array.isArray(record.lines))
|
|
668
|
+
throw new Error("not an outcome record");
|
|
669
|
+
return record;
|
|
670
|
+
}
|
|
671
|
+
catch (error) {
|
|
672
|
+
throw new Error(`${path} cannot be read: ${error instanceof Error ? error.message : String(error)}; it records how this payment id's payout ended: tell the user; deleting it lets the same pull report the payout from the chain`);
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
/**
|
|
676
|
+
* The last pull in a payee and amount's chain: its first, then each pull kept
|
|
677
|
+
* after one that can never land or whose outcome is reported. Pulls are only
|
|
678
|
+
* ever added to a chain, each once, so runs at once read the same end.
|
|
679
|
+
*/
|
|
680
|
+
async function chainEnd(keptPath) {
|
|
681
|
+
let kept = await readKept(join(keptPath, "first.json"));
|
|
682
|
+
for (let next = kept; next !== undefined; next = await readKept(join(keptPath, `${next.pull.nonce}.next.json`)))
|
|
683
|
+
kept = next;
|
|
684
|
+
return kept;
|
|
685
|
+
}
|
|
686
|
+
function printPending(print, reason, kept, args) {
|
|
687
|
+
print("outcome pending");
|
|
688
|
+
print(`reason ${reason.replace(UNSHOWN, "?").slice(0, 300)}`);
|
|
689
|
+
print(`can-land-until ${utc(kept.pull.validBefore)}`);
|
|
690
|
+
print(`kept ${keptAs(args)}: the same pull sends it again, which pays at most once`);
|
|
691
|
+
return "pending";
|
|
692
|
+
}
|
|
693
|
+
const BIGINTS = ["value", "validAfter", "validBefore", "fromBlock"];
|
|
694
|
+
async function readKept(path) {
|
|
695
|
+
const text = await readFile(path, "utf8").catch((error) => {
|
|
696
|
+
if (error.code === "ENOENT")
|
|
697
|
+
return undefined;
|
|
698
|
+
throw error;
|
|
699
|
+
});
|
|
700
|
+
if (text === undefined)
|
|
701
|
+
return undefined;
|
|
702
|
+
try {
|
|
703
|
+
return JSON.parse(text, (key, value) => (BIGINTS.includes(key) && typeof value === "string" ? BigInt(value) : value));
|
|
704
|
+
}
|
|
705
|
+
catch (error) {
|
|
706
|
+
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, about when the file was last modified: tell the user; once that has passed, deleting it lets the same pull sign a new one`);
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
/**
|
|
710
|
+
* Keeps `pull` at `path` unless a pull is kept there already, and returns the
|
|
711
|
+
* one kept. The whole file is written beside it and linked into place, which
|
|
712
|
+
* fails when the path exists, so two runs at once keep and send one pull.
|
|
713
|
+
*/
|
|
714
|
+
async function keepFirst(path, pull) {
|
|
715
|
+
await mkdir(dirname(path), { recursive: true, mode: 0o700 });
|
|
716
|
+
const draft = await writeDraft(path, pull);
|
|
717
|
+
try {
|
|
718
|
+
for (;;) {
|
|
719
|
+
const linked = await link(draft, path).then(() => true, (error) => {
|
|
720
|
+
if (error.code !== "EEXIST")
|
|
721
|
+
throw error;
|
|
722
|
+
return false;
|
|
723
|
+
});
|
|
724
|
+
if (linked)
|
|
725
|
+
return pull;
|
|
726
|
+
const kept = await readKept(path);
|
|
727
|
+
if (kept !== undefined)
|
|
728
|
+
return kept;
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
finally {
|
|
732
|
+
await rm(draft, { force: true });
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
async function writeDraft(path, pull) {
|
|
736
|
+
const draft = `${path}.${randomUUID()}.tmp`;
|
|
737
|
+
await writeFile(draft, `${JSON.stringify(pull, (_, value) => (typeof value === "bigint" ? value.toString() : value), 2)}\n`, { mode: 0o600 });
|
|
738
|
+
return draft;
|
|
739
|
+
}
|
|
740
|
+
/** The pull's own call at the node's gas ceiling; what it costs is sendMandateCall's to estimate. */
|
|
741
|
+
async function simulatePull(publicClient, terms, kept, sender) {
|
|
742
|
+
const call = pullCall(terms.principal, kept.pull, kept.fields);
|
|
743
|
+
try {
|
|
744
|
+
await publicClient.call({ ...(sender && { account: sender.address }), to: call.to, data: call.data });
|
|
440
745
|
}
|
|
441
746
|
catch (error) {
|
|
442
747
|
if (lostInTransport(error))
|
|
443
748
|
throw new Error(`the pull could not be checked: ${publicMessage(error)}`);
|
|
444
749
|
throw new Error(`Mandate Binding rejects the pull and records nothing: ${revertName(error)}`);
|
|
445
750
|
}
|
|
446
|
-
const reason = await readCheck(publicClient, terms.principal, signed.pull, signed.fields);
|
|
447
|
-
if (reason !== "None" && !args.recordRefusal)
|
|
448
|
-
return printUnsent(reason, print);
|
|
449
|
-
const hash = await sendMandateCall(publicClient, walletFor(publicClient, sender), call);
|
|
450
|
-
// Printed before the wait: a pull whose receipt never arrives is still found by its transaction.
|
|
451
|
-
print(`transaction ${hash}`);
|
|
452
|
-
const receipt = await confirm(publicClient, hash).catch((error) => {
|
|
453
|
-
throw new Error(`${error instanceof Error ? error.message : String(error)}; the pull was sent in transaction ${hash}: check it with metal-agent status ${hash} before pulling again`);
|
|
454
|
-
});
|
|
455
|
-
return printPull(target, terms, slice(signed.pull.nonce, 12), receipt, print);
|
|
456
751
|
}
|
|
752
|
+
/** Above the facilitator's own 60 s wait for a receipt, so its answer is not cut short. */
|
|
753
|
+
const FACILITATOR_TIMEOUT_MS = 120_000;
|
|
754
|
+
/** The facilitator's reasons for an answer with no transaction that leave open whether the pull, or a payment, is on its way. */
|
|
755
|
+
const IN_DOUBT = new Set(["settlement_in_doubt", "payment_in_flight"]);
|
|
756
|
+
/**
|
|
757
|
+
* The codes a connection fails with before any request is sent: the host is
|
|
758
|
+
* not there, or is not who its certificate says. A failure after that, or a
|
|
759
|
+
* timeout, leaves open whether the request arrived.
|
|
760
|
+
*/
|
|
761
|
+
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"]);
|
|
762
|
+
/** The code under which `fetch` never reached its host, or undefined when the request may have arrived. */
|
|
763
|
+
function neverReached(error) {
|
|
764
|
+
const cause = error.cause;
|
|
765
|
+
const code = typeof cause?.code === "string" ? cause.code : undefined;
|
|
766
|
+
return code !== undefined && NEVER_REACHED.has(code) ? code : undefined;
|
|
767
|
+
}
|
|
768
|
+
/**
|
|
769
|
+
* Hands the pull to the facilitator as the payment a seller would settle,
|
|
770
|
+
* `exact` to the payee; it checks the pull against the mandate, submits it and
|
|
771
|
+
* pays the gas. Its answer gives the transaction it sent, if any, and its
|
|
772
|
+
* reason for not settling; whether the pull paid is the chain's to say. One
|
|
773
|
+
* with no transaction and a reason other than doubt or a revert sent none,
|
|
774
|
+
* as did a 4xx answer that names no reason, such as from a URL it does not
|
|
775
|
+
* serve.
|
|
776
|
+
*/
|
|
777
|
+
async function sendToFacilitator(target, terms, kept) {
|
|
778
|
+
const paymentPayload = {
|
|
779
|
+
x402Version: 2,
|
|
780
|
+
accepted: kept.requirements,
|
|
781
|
+
payload: {
|
|
782
|
+
authorization: {
|
|
783
|
+
from: terms.principal,
|
|
784
|
+
to: kept.pull.to,
|
|
785
|
+
value: kept.pull.value.toString(),
|
|
786
|
+
validAfter: kept.pull.validAfter.toString(),
|
|
787
|
+
validBefore: kept.pull.validBefore.toString(),
|
|
788
|
+
nonce: kept.pull.nonce,
|
|
789
|
+
},
|
|
790
|
+
signature: encodeMandateBlob(kept.pull, kept.fields),
|
|
791
|
+
},
|
|
792
|
+
};
|
|
793
|
+
let response;
|
|
794
|
+
try {
|
|
795
|
+
response = await fetch(`${target.facilitatorUrl}/settle`, {
|
|
796
|
+
method: "POST",
|
|
797
|
+
headers: { "content-type": "application/json" },
|
|
798
|
+
body: JSON.stringify({ x402Version: 2, paymentPayload, paymentRequirements: kept.requirements }),
|
|
799
|
+
signal: AbortSignal.timeout(FACILITATOR_TIMEOUT_MS),
|
|
800
|
+
});
|
|
801
|
+
}
|
|
802
|
+
catch (error) {
|
|
803
|
+
const code = neverReached(error);
|
|
804
|
+
if (code !== undefined)
|
|
805
|
+
return { kind: "unsent", cause: `the facilitator at ${new URL(target.facilitatorUrl).host} could not be reached (${code})` };
|
|
806
|
+
return { kind: "sent", transaction: undefined, why: "the facilitator did not answer" };
|
|
807
|
+
}
|
|
808
|
+
const answer = await response.json().catch(() => undefined);
|
|
809
|
+
const settled = (typeof answer === "object" && answer !== null ? answer : {});
|
|
810
|
+
const transaction = typeof settled.transaction === "string" && isHash(settled.transaction) ? settled.transaction : undefined;
|
|
811
|
+
const said = [settled.errorReason, settled.errorMessage].filter((part) => typeof part === "string" && part !== "").join(": ");
|
|
812
|
+
const reason = typeof settled.errorReason === "string" ? settled.errorReason : "";
|
|
813
|
+
// It sent this pull before, and that transaction reverted; it never sends it again.
|
|
814
|
+
if (transaction === undefined && reason === "payment_reverted")
|
|
815
|
+
return { kind: "sent", transaction: undefined, why: "the facilitator sent this pull and it reverted" };
|
|
816
|
+
if (transaction === undefined && !IN_DOUBT.has(reason) && (reason !== "" || (response.status >= 400 && response.status < 500))) {
|
|
817
|
+
const cause = said === "" ? `the facilitator answered HTTP ${response.status}, sending no transaction` : `the facilitator refused it, sending no transaction: ${said}`;
|
|
818
|
+
return { kind: "refused", cause: cause.replace(UNSHOWN, "?").slice(0, 300) };
|
|
819
|
+
}
|
|
820
|
+
const why = said !== "" ? `the facilitator says ${said}` : settled.success === true ? "the facilitator says it settled" : `the facilitator answered HTTP ${response.status}`;
|
|
821
|
+
return { kind: "sent", transaction, why: why.replace(UNSHOWN, "?").slice(0, 300) };
|
|
822
|
+
}
|
|
823
|
+
/**
|
|
824
|
+
* Submits the pull from the `--key` account, which pays the gas. A node that
|
|
825
|
+
* refuses the transaction, or an estimate that fails, sent nothing; a request
|
|
826
|
+
* lost on its way to the node may have.
|
|
827
|
+
*/
|
|
828
|
+
async function sendFromKey(publicClient, sender, call) {
|
|
829
|
+
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)}` }));
|
|
830
|
+
}
|
|
831
|
+
/**
|
|
832
|
+
* The topic of an event the account emitted under the mandate, its reference
|
|
833
|
+
* in the first topic, that Mandate Binding's ABI does not decode: an account
|
|
834
|
+
* of another version than the SDK's. Each log is matched to its decoding by
|
|
835
|
+
* transaction and position, so one under a known topic in another layout is
|
|
836
|
+
* seen too.
|
|
837
|
+
*/
|
|
838
|
+
function undecodedTopic(principal, reference, logs) {
|
|
839
|
+
const under = logs.filter((log) => isAddressEqual(log.address, principal) && log.topics[1]?.toLowerCase() === reference.toLowerCase());
|
|
840
|
+
const decoded = new Set(parseEventLogs({ abi: mandateBindingAbi, logs: under, strict: true }).map((log) => `${log.transactionHash}:${log.logIndex}`));
|
|
841
|
+
return under.find((log) => !decoded.has(`${log.transactionHash}:${log.logIndex}`))?.topics[0];
|
|
842
|
+
}
|
|
843
|
+
function undecodedPull(terms, found) {
|
|
844
|
+
return new Error(`the account ${terms.principal} emitted an event under the mandate under topic ${found.topic}, which this SDK does not decode, so the pull may have paid: do not pay it under another payment id; the SDK must be the account's version to read it. ` +
|
|
845
|
+
`${found.transaction === undefined ? "" : `Transaction ${found.transaction} carries it. `}The same pull sends nothing while that event is under the mandate`);
|
|
846
|
+
}
|
|
847
|
+
/** Blocks per log search, under the node's own limit on one filter's range. */
|
|
848
|
+
const LOG_CHUNK = 10000n;
|
|
849
|
+
/**
|
|
850
|
+
* The reasons a bound mandate refuses a pull for good: none of them can turn
|
|
851
|
+
* into a payment later. A closed window is read from the block itself; a
|
|
852
|
+
* window or mandate this node has not reached yet is not final.
|
|
853
|
+
*/
|
|
854
|
+
const FINAL = ["Revoked", "Expired", "BadSignature", "PayeeNotAllowed", "OverPerPaymentCap", "OverBudget", "OverOccurrences"];
|
|
855
|
+
/**
|
|
856
|
+
* Where a pull stands on chain, read at one block: landed, in `transaction`
|
|
857
|
+
* when one is named or in the account's own log between the block it was
|
|
858
|
+
* signed at and the end of its window; never to land, once its window has
|
|
859
|
+
* closed or the mandate refuses it for good; or still open. A read that fails
|
|
860
|
+
* leaves it open. An event under the mandate that does not decode leaves the
|
|
861
|
+
* outcome unreadable by this SDK.
|
|
862
|
+
*/
|
|
863
|
+
async function outcomeOnChain(publicClient, terms, reference, kept, transaction) {
|
|
864
|
+
const digest = slice(kept.pull.nonce, 12);
|
|
865
|
+
let unreadReceipt = transaction !== undefined;
|
|
866
|
+
try {
|
|
867
|
+
if (transaction !== undefined) {
|
|
868
|
+
const receipt = await publicClient.waitForTransactionReceipt({ hash: transaction, pollingInterval: 250 });
|
|
869
|
+
unreadReceipt = false;
|
|
870
|
+
if (pullEventOf(terms, digest, receipt) !== undefined)
|
|
871
|
+
return { kind: "landed", receipt };
|
|
872
|
+
const topic = undecodedTopic(terms.principal, reference, receipt.logs);
|
|
873
|
+
if (topic !== undefined)
|
|
874
|
+
return { kind: "undecoded", topic, transaction };
|
|
875
|
+
}
|
|
876
|
+
const head = await publicClient.getBlock();
|
|
877
|
+
// A pull lands only before its window ends, so the search stops at the first range that reaches it.
|
|
878
|
+
for (let from = kept.fromBlock; from <= head.number; from += LOG_CHUNK) {
|
|
879
|
+
const to = from + LOG_CHUNK - 1n < head.number ? from + LOG_CHUNK - 1n : head.number;
|
|
880
|
+
// Every event under the mandate, so one this SDK does not decode is seen rather than dropped.
|
|
881
|
+
const raw = (await publicClient.request({ method: "eth_getLogs", params: [{ address: terms.principal, fromBlock: numberToHex(from), toBlock: numberToHex(to), topics: [null, reference] }] }));
|
|
882
|
+
const formatted = raw.map((log) => formatLog(log));
|
|
883
|
+
const logs = parseEventLogs({ abi: mandateBindingAbi, eventName: ["MandatePayment", "MandateRefused"], logs: formatted });
|
|
884
|
+
const log = logs.find((entry) => entry.args.l3aDigest === digest);
|
|
885
|
+
if (log !== undefined)
|
|
886
|
+
return { kind: "landed", receipt: await publicClient.getTransactionReceipt({ hash: log.transactionHash }) };
|
|
887
|
+
const topic = undecodedTopic(terms.principal, reference, formatted);
|
|
888
|
+
if (topic !== undefined)
|
|
889
|
+
return { kind: "undecoded", topic, transaction };
|
|
890
|
+
if (to === head.number || (await publicClient.getBlock({ blockNumber: to })).timestamp >= kept.pull.validBefore)
|
|
891
|
+
break;
|
|
892
|
+
}
|
|
893
|
+
// The token records the authorization a paid pull spends, whatever the mandate's state is since.
|
|
894
|
+
const spent = await publicClient.readContract({ address: kept.pull.token, abi: AUTHORIZATION_STATE, functionName: "authorizationState", args: [terms.principal, kept.pull.nonce], blockNumber: head.number });
|
|
895
|
+
if (spent)
|
|
896
|
+
return { kind: "open", landed: true };
|
|
897
|
+
if (head.timestamp >= kept.pull.validBefore)
|
|
898
|
+
return { kind: "never", reason: "OutsideWindow" };
|
|
899
|
+
const reason = await readCheck(publicClient, terms.principal, kept.pull, kept.fields, head.number);
|
|
900
|
+
// After its end a mandate refuses for good; before its start it does only until then.
|
|
901
|
+
// A used digest with no log for it is a pull that landed, as a payment or a recorded refusal.
|
|
902
|
+
if (reason === "NonceUsed")
|
|
903
|
+
return { kind: "open", landed: true };
|
|
904
|
+
const final = FINAL.includes(reason) || (reason === "OutsideRecurrence" && head.timestamp >= terms.constraints.recurrenceStart);
|
|
905
|
+
return final ? { kind: "never", reason: reason } : { kind: "open" };
|
|
906
|
+
}
|
|
907
|
+
catch (error) {
|
|
908
|
+
return { kind: "open", unread: publicMessage(error), unreadReceipt };
|
|
909
|
+
}
|
|
910
|
+
}
|
|
911
|
+
const AUTHORIZATION_STATE = parseAbi(["function authorizationState(address authorizer, bytes32 nonce) view returns (bool)"]);
|
|
457
912
|
/** Whether the request failed on its way, so the node may or may not have acted on it. */
|
|
458
913
|
function lostInTransport(error) {
|
|
459
914
|
return error instanceof BaseError && error.walk((e) => e instanceof HttpRequestError || e instanceof TimeoutError) !== null;
|
|
@@ -464,31 +919,50 @@ function printUnsent(reason, print) {
|
|
|
464
919
|
print("recorded no");
|
|
465
920
|
return "refused";
|
|
466
921
|
}
|
|
467
|
-
function
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
922
|
+
function pullEventOf(terms, l3aDigest, receipt) {
|
|
923
|
+
return parseEventLogs({ abi: mandateBindingAbi, logs: receipt.logs, eventName: ["MandatePayment", "MandateRefused"] }).find((log) => isAddressEqual(log.address, terms.principal) && log.args.l3aDigest === l3aDigest);
|
|
924
|
+
}
|
|
925
|
+
/** Where a token sends a credit the payee's receive policy refuses: its escrow (IMIP20.sol). */
|
|
926
|
+
const RECEIVE_POLICY = "0x0000000000000000000000000000000000000f4d";
|
|
927
|
+
const AUTHORIZATION_USED = parseAbiItem("event AuthorizationUsed(address indexed authorizer, bytes32 indexed nonce)");
|
|
928
|
+
function printPull(target, terms, pull, receipt, print) {
|
|
929
|
+
const outcome = pullEventOf(terms, slice(pull.nonce, 12), receipt);
|
|
930
|
+
if (outcome === undefined)
|
|
931
|
+
throw new Error(`transaction ${receipt.transactionHash} carries neither a payment nor a refusal for the pull`);
|
|
932
|
+
let result = "refused";
|
|
933
|
+
print(`transaction ${receipt.transactionHash}`);
|
|
934
|
+
if (outcome.eventName === "MandatePayment") {
|
|
935
|
+
// The transfer the pull's authorization makes is the token's next one from the account after it.
|
|
936
|
+
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);
|
|
937
|
+
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));
|
|
938
|
+
result = moved !== undefined && isAddressEqual(moved.args.to, RECEIVE_POLICY) ? "held" : "paid";
|
|
939
|
+
print(`outcome ${result}`);
|
|
940
|
+
if (result === "held")
|
|
941
|
+
print("reason the payee's receive policy refused the payment, and the token holds it in escrow");
|
|
471
942
|
print(`spent ${usdz(outcome.args.spent)} of ${usdz(terms.constraints.budget)}`);
|
|
472
943
|
}
|
|
473
|
-
else
|
|
944
|
+
else {
|
|
474
945
|
print("outcome refused");
|
|
475
946
|
const reason = reasonName(outcome.args.reason);
|
|
476
947
|
if (reason === "None")
|
|
477
948
|
throw new Error(`transaction ${receipt.transactionHash} records a refusal with no reason`);
|
|
478
949
|
print(`reason ${describeRefusal(reason)}`);
|
|
479
950
|
}
|
|
480
|
-
else {
|
|
481
|
-
throw new Error(`transaction ${receipt.transactionHash} carries neither a payment nor a refusal for the pull`);
|
|
482
|
-
}
|
|
483
951
|
print(`payer ${terms.principal}`);
|
|
484
952
|
print(`payee ${outcome.args.to}`);
|
|
485
953
|
print(`amount ${usdz(outcome.args.amount)}`);
|
|
486
954
|
if (target.explorerUrl)
|
|
487
955
|
print(`explorer ${target.explorerUrl}/tx/${receipt.transactionHash}`);
|
|
488
956
|
print(`gas ${receipt.gasUsed}`);
|
|
489
|
-
return
|
|
957
|
+
return result;
|
|
490
958
|
}
|
|
491
959
|
/** A decimal `*USDZ` amount a mandate can pay: positive, to the cent. */
|
|
492
960
|
export function parsePullAmount(text, option) {
|
|
493
961
|
return BigInt(usdzBaseUnits(parseCents(text, option)));
|
|
494
962
|
}
|
|
963
|
+
/** A payment id as `--payment-id` takes it: any text but none, or `undefined` when the option is absent. */
|
|
964
|
+
export function parsePaymentId(text, option) {
|
|
965
|
+
if (text === "")
|
|
966
|
+
throw new Error(`${option} must not be empty; leave it out to key the payout by its payee and amount`);
|
|
967
|
+
return text;
|
|
968
|
+
}
|