@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.
@@ -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 agent's
3
- // P-256 key, the most per payment, a budget, an expiry and any payees, then
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 own key alone, and
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 { readFile, readdir, rm, writeFile } from "node:fs/promises";
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, readBoundAgentKey, readCheck, readMandateState, reasonName, replaceCall, revokeCall, sameAgentKey, sendMandateCall, signL3a, transactionIdOf, trustedIssuer, verifyMandate, verifyMandatePayment, } from "@metalntwx/metal-pay/mandates";
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 { agentKeyPath, exists, gasKeyPath, publicKeyPath, readKey, readOrCreateIssuerKeys, readOrCreateKey, readOrCreateP256Key, readP256Key, readTrustedIssuer, writePublicKeyFile, } from "../keystore.js";
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(/[\p{Cc}\p{Zl}\p{Zp}\u061c\u200b\u200e\u200f\u202a-\u202e\u2060\u2066-\u2069\ufeff\u{e0000}-\u{e007f}]/gu, "?")}`);
72
+ print(`description ${descriptive.promptSummary.replace(UNSHOWN, "?")}`);
68
73
  print(`principal ${principal}`);
69
- print(`agent-key ${encodeAgentKey(agentKey)} (P-256)`);
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(/[\p{Cc}\p{Zl}\p{Zp}\u061c\u200b\u200e\u200f\u202a-\u202e\u2060\u2066-\u2069\ufeff\u{e0000}-\u{e007f}]/gu, "?"));
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
- * the other limits. The agent's keys are written beside the credential unless
110
- * `--agent-key` names a mandate key it already has: the mandate key, and the
111
- * key that pays for its pulls beside that. The issuer's keys are created on
112
- * first use. The budget covers the credential's whole life, from now to its
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
- const agentPath = args.agentKeyPath ?? agentKeyPath(args.out);
119
- if (args.agentKeyPath === undefined && (await exists(agentPath))) {
120
- throw new Error(`${agentPath} already holds a key; pass --agent-key ${agentPath} to name it, or another --out`);
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
- const agent = await readOrCreateP256Key(agentPath);
123
- const gasPath = gasKeyPath(agentPath);
124
- const gas = await readOrCreateKey(gasPath);
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
- await Promise.all([agent.created && rm(agentPath), gas.created && rm(gasPath)]);
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: agent.key.publicJwk,
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([agent.created && rm(agentPath), gas.created && rm(gasPath), created && rm(args.issuerKeyPath)]);
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(`reference ${ingested.reference}`);
171
+ print(`mandate ${ingested.reference}`);
164
172
  printTerms(ingested.extracted, print);
165
- print(`agent-key-file ${agentPath}${agent.created ? " (created)" : ""}`);
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
- const factoryCode = await client.getCode({ address: factory });
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
- /** Names the owner's one-owner MetalAccount, writing the owner's key first when
195
- * `out` names a file to create; the factory gives each owner one address, and
196
- * the account is created on chain by the owner's first `bind`. Mixed-key and
197
- * weighted accounts use the same factory through the SDK. */
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, args.accountFactory, owner.address);
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, createAccount = false) {
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
- // A one-owner account is created by its owner's first bind; the sponsor pays.
220
- // One with several signers is created by the factory beforehand, with its
221
- // signers, weights and threshold, which these key files alone do not name.
222
- const code = createAccount ? await client.getCode({ address: account }) : "0x01";
223
- if ((code === undefined || code === "0x") && args.signerPaths.length > 0) {
224
- throw new Error(`${account} 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`);
225
- }
226
- if (code === undefined || code === "0x") {
227
- const { config, address } = await ownerAccount(client, args.accountFactory, sponsor.address);
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), true);
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(`reference ${reference}`);
339
+ print(`mandate ${reference}`);
301
340
  printTerms(terms, print);
302
- const code = await publicClient.getCode({ address: terms.principal });
303
- if (code === undefined || code === "0x") {
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 agent key's own per-payment mandate,
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(`reference ${reference}`);
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
- const how = p.direct ? "pull" : `nonce ${p.nonce ?? "?"}`;
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
- /** The agent's P-256 key, checked to be the one the mandate names. */
349
- export async function mandateAgentSigner(mandate, agentKeyPath) {
350
- const agent = await readP256Key(agentKeyPath).catch(async (error) => {
351
- if (error.code !== "ENOENT")
352
- throw error;
353
- // A replacement credential names the agent key it was made with, which sits beside the first credential.
354
- const dir = dirname(agentKeyPath);
355
- for (const file of (await readdir(dir)).filter((name) => name.endsWith(".agent-key.json"))) {
356
- const key = await readP256Key(join(dir, file)).catch(() => undefined);
357
- if (key && sameAgentKey(agentKeyOfJwk(key.publicJwk), mandate.terms.agentKey))
358
- throw new Error(`no agent key at ${agentKeyPath}; this mandate's agent key is ${join(dir, file)}: pass --agent-key ${join(dir, file)}`);
359
- }
360
- throw new Error(`no agent key at ${agentKeyPath}; pass --agent-key with the agent-key-file that credential create printed for it`);
361
- });
362
- if (!sameAgentKey(agentKeyOfJwk(agent.publicJwk), mandate.terms.agentKey))
363
- throw new Error(`${agentKeyPath} is not the mandate's agent key`);
364
- return p256DigestSigner(privateKeyHex(agent));
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 principal's account and
384
- * pays the gas. One the mandate refuses when checked is reported and not
385
- * sent, as an x402 payment is refused before anything is sent, unless
386
- * `recordRefusal` asks for Mandate Binding to record the refusal on chain.
387
- * The transaction is printed as soon as it is sent, so a pull whose receipt
388
- * is lost is still found. The outcome is returned so a refusal can fail the
389
- * command.
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.agentKeyPath);
394
- const sender = await readKey(args.keyPath).catch((error) => {
395
- if (error.code === "ENOENT")
396
- throw new Error(`no key at ${args.keyPath} to submit the pull and pay its gas; pass --key <funded key file>`);
397
- throw error;
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.getCode({ address: terms.principal }).then((code) => (code === undefined || code === "0x" ? undefined : readBoundAgentKey(publicClient, terms.principal, reference))),
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 (block.timestamp < terms.constraints.recurrenceStart && !args.recordRefusal)
411
- return printUnsent("OutsideRecurrence", print);
412
- if (gas === 0n)
413
- throw new Error(`${sender.address}, which submits the pull, holds no gas: fund it, the gas-key-address credential create printed, and run the pull again`);
414
- const chainId = target.network.chainId;
415
- const now = BigInt(Math.floor(Date.now() / 1000));
416
- const signed = await signL3a({
417
- chainId,
418
- ref: reference,
419
- token: terms.constraints.token,
420
- terms: terms.terms,
421
- to: args.to,
422
- value: args.amount,
423
- validAfter: now - BigInt(MANDATE_VALID_AFTER_LEAD_SECONDS),
424
- validBefore: now + BigInt(MANDATE_MAX_VALIDITY_SECONDS),
425
- transactionId: transactionIdOf({
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
- iss: terms.terms.kid,
434
- signDigest,
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
- const call = pullCall(terms.principal, signed.pull, signed.fields);
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
- // At the node's own gas ceiling: what the pull costs is sendMandateCall's to estimate.
439
- await publicClient.call({ account: sender.address, to: call.to, data: call.data });
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 printPull(target, terms, l3aDigest, receipt, print) {
468
- const outcome = parseEventLogs({ abi: mandateBindingAbi, logs: receipt.logs, eventName: ["MandatePayment", "MandateRefused"] }).find((log) => log.address.toLowerCase() === terms.principal.toLowerCase() && log.args.l3aDigest === l3aDigest);
469
- if (outcome?.eventName === "MandatePayment") {
470
- print("outcome paid");
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 if (outcome?.eventName === "MandateRefused") {
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 outcome.eventName === "MandatePayment" ? "paid" : "refused";
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
+ }