@metalntwx/metal-agent 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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 { 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, readBoundAgentKey, 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, getAbiItem, parseAbi, parseAbiItem, HttpRequestError, TimeoutError, createWalletClient, custom, decodeErrorResult, encodeFunctionData, encodeAbiParameters, parseAbiParameters, getAddress, isAddress, isAddressEqual, isHash, 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,45 @@ 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 no 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);
202
208
  const client = rpcClient(target);
203
- const { address } = await ownerAccount(client, args.accountFactory, owner.address);
204
- const code = await client.getCode({ address });
209
+ const { config, address } = await ownerAccount(client, args.accountFactory, owner.address);
205
210
  print(`account ${address}`);
206
- print(`deployed ${code === undefined || code === "0x" ? "no, the first bind creates it" : "yes"}`);
207
211
  print(`owner ${owner.address}`);
208
212
  print(`keyfile ${keyPath}${created ? " (created)" : ""}`);
213
+ if (await hasCode(client, address)) {
214
+ print("deployed yes");
215
+ return;
216
+ }
217
+ if ((await client.getBalance({ address: owner.address })) === 0n) {
218
+ // Only the grant's summary: its transaction lines would read as the account's.
219
+ const funding = [];
220
+ await fund(target, owner.address, undefined, (line) => funding.push(line));
221
+ if (funding[0] !== undefined)
222
+ print(funding[0]);
223
+ }
224
+ const receipt = await send(client, owner, { to: args.accountFactory, data: encodeFunctionData({ abi: metalAccountFactoryAbi, functionName: "createAccount", args: config }), gas: 600000n, maxFeePerGas: FEE_PER_GAS, maxPriorityFeePerGas: FEE_PER_GAS });
225
+ print("deployed yes");
226
+ printSent(target, receipt, print);
227
+ }
228
+ async function hasCode(client, address) {
229
+ const code = await client.getCode({ address });
230
+ return code !== undefined && code !== "0x";
209
231
  }
210
- async function sendOwnerCall(client, args, call, createAccount = false) {
232
+ async function sendOwnerCall(client, args, call) {
211
233
  const account = (Array.isArray(call) ? call[0] : call)?.to;
212
234
  if (account === undefined)
213
235
  throw new Error("no account call to send");
@@ -216,18 +238,11 @@ async function sendOwnerCall(client, args, call, createAccount = false) {
216
238
  signers.sort((a, b) => a.address.toLowerCase().localeCompare(b.address.toLowerCase()));
217
239
  if (new Set(signers.map(s => s.address.toLowerCase())).size !== signers.length)
218
240
  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 });
241
+ if (!(await hasCode(client, account))) {
242
+ const own = signers.length === 1 && isAddressEqual(signers[0].address, sponsor.address) && isAddressEqual((await ownerAccount(client, args.accountFactory, sponsor.address)).address, account);
243
+ if (own)
244
+ throw new Error(`${account} has no account: create it with metal-agent account create`);
245
+ throw new Error(`${account} has no account, and is not ${sponsor.address}'s one-owner account: create it through MetalAccountFactory.createAccount with its owners, weights and threshold`);
231
246
  }
232
247
  const authorized = await authorizeMandateCall(client, args.accountFactory, call, async (digest) => encodeAbiParameters(parseAbiParameters("bytes[], bytes[]"), [signers.map(s => s.address), await Promise.all(signers.map(s => s.sign({ hash: digest })))]));
233
248
  return send(client, sponsor, authorized);
@@ -235,7 +250,7 @@ async function sendOwnerCall(client, args, call, createAccount = false) {
235
250
  export async function mandateBind(target, args, print) {
236
251
  const { ingested } = await ingestFor(args);
237
252
  const publicClient = rpcClient(target);
238
- const receipt = await sendOwnerCall(publicClient, args, bindCall(ingested), true);
253
+ const receipt = await sendOwnerCall(publicClient, args, bindCall(ingested));
239
254
  print(`bound ${ingested.reference}`);
240
255
  printSent(target, receipt, print);
241
256
  }
@@ -297,11 +312,10 @@ export async function mandateRevoke(target, args, print) {
297
312
  export async function mandateStatus(target, credentialPath, accountFactory, print) {
298
313
  const { terms, reference } = await loadMandate(credentialPath);
299
314
  const publicClient = rpcClient(target);
300
- print(`reference ${reference}`);
315
+ print(`mandate ${reference}`);
301
316
  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");
317
+ if (!(await hasCode(publicClient, terms.principal))) {
318
+ print("account-type none; create the account before binding: metal-agent account create for one owner");
305
319
  print("bound no");
306
320
  return;
307
321
  }
@@ -319,7 +333,7 @@ export async function mandateStatus(target, credentialPath, accountFactory, prin
319
333
  /**
320
334
  * Verifies the mandate and its payments from the chain alone: the binding on
321
335
  * 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,
336
+ * and each payment and refusal as the mandate key's own per-payment mandate,
323
337
  * rebuilt from the transaction that carried it. Any party holding the
324
338
  * credential can run it; Metal is not asked.
325
339
  */
@@ -332,36 +346,47 @@ export async function mandateVerify(target, args, print) {
332
346
  const v = args.tx === undefined
333
347
  ? await verifyMandate(publicClient, opts)
334
348
  : await verifyMandatePayment(publicClient, { ...opts, tx: args.tx, ...(args.signedOffer === undefined ? {} : { checkoutMandate: args.signedOffer }) });
335
- print(`reference ${reference}`);
349
+ print(`mandate ${reference}`);
336
350
  for (const step of v.steps) {
337
351
  print(`step ${step.step} ${step.outcome}: ${step.title}`);
338
352
  print(` ${step.detail}`);
339
353
  }
340
354
  for (const p of v.payments) {
341
355
  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(", ")}`}`);
356
+ print(`${what} ${p.txHash} block ${p.block}: ${usdz(p.amount)} to ${p.to}, nonce ${p.nonce ?? "?"}${p.problems.length === 0 ? "" : `: ${p.problems.join(", ")}`}`);
344
357
  }
345
358
  print(`verified ${v.ok ? "yes" : "no"}`);
346
359
  return v.ok ? "verified" : "failed";
347
360
  }
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));
361
+ /**
362
+ * Signs with the mandate's key: `keyFile` when given, or else the key file in
363
+ * the credential's directory that the credential names, so a replacement
364
+ * written beside its predecessor signs with the predecessor's key.
365
+ */
366
+ export async function mandateAgentSigner(mandate, credentialPath, keyFile) {
367
+ const names = (key) => sameAgentKey(agentKeyOfJwk(key.publicJwk), mandate.terms.agentKey);
368
+ if (keyFile !== undefined) {
369
+ const key = await readP256Key(keyFile).catch((error) => {
370
+ throw error.code === "ENOENT" ? new Error(`no mandate key at ${keyFile}; pass --mandate-key-file with the mandate-key-file credential create printed`) : error;
371
+ });
372
+ if (!names(key))
373
+ throw new Error(`${keyFile} is not this mandate's key`);
374
+ return p256DigestSigner(privateKeyHex(key));
375
+ }
376
+ const dir = dirname(credentialPath);
377
+ const own = mandateKeyPath(credentialPath);
378
+ const beside = (await readdir(dir).catch(() => [])).filter((name) => name.endsWith(".key.json")).map((name) => join(dir, name));
379
+ for (const path of [own, ...beside.filter((path) => path !== own)]) {
380
+ // The credential's own key file must read; another file that is not a P-256 key is not this mandate's.
381
+ const key = await readP256Key(path).catch((error) => {
382
+ if (path === own && error.code !== "ENOENT")
383
+ throw error;
384
+ return undefined;
385
+ });
386
+ if (key && names(key))
387
+ return p256DigestSigner(privateKeyHex(key));
388
+ }
389
+ throw new Error(`no key beside ${credentialPath} is this mandate's; pass --mandate-key-file with the mandate-key-file credential create printed`);
365
390
  }
366
391
  /** A revert's Mandate Binding error name (`NonceAlreadyUsed`, `BadAgentSignature`, …), or the node's message. */
367
392
  function revertName(error) {
@@ -379,81 +404,355 @@ function revertName(error) {
379
404
  return error.shortMessage;
380
405
  }
381
406
  /**
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.
407
+ * A direct payment under the mandate, with no seller: the agent signs it, and
408
+ * the facilitator, or the `--key` account, submits it to `pull` on the
409
+ * principal's account and pays the gas. One the mandate refuses when checked
410
+ * is reported and not sent, unless `recordRefusal` asks for Mandate Binding to
411
+ * record the refusal on chain.
412
+ *
413
+ * A pull that has left can land until its window ends, whatever the
414
+ * facilitator answers, so its outcome is read from the chain alone. One with
415
+ * no outcome yet is kept beside the credential, and the same command sends
416
+ * that same pull again, which the account pays at most once; once it can no
417
+ * longer land, the command signs a new one.
390
418
  */
391
419
  export async function mandatePull(target, args, print) {
392
420
  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
- });
421
+ const signDigest = await mandateAgentSigner(mandate, args.credentialPath, args.mandateKeyFile);
422
+ const keyPath = args.keyPath;
423
+ if (args.recordRefusal && keyPath === undefined)
424
+ throw new Error("--record-refusal sends the refused pull itself: pass --key <funded key file> to submit it and pay its gas");
425
+ const facilitatorProtocol = URL.canParse(target.facilitatorUrl) ? new URL(target.facilitatorUrl).protocol : "";
426
+ if (keyPath === undefined && facilitatorProtocol !== "http:" && facilitatorProtocol !== "https:") {
427
+ throw new Error(`the facilitator URL ${target.facilitatorUrl} is not an http or https URL, so the pull was not sent`);
428
+ }
429
+ const sender = keyPath === undefined
430
+ ? undefined
431
+ : await readKey(keyPath).catch((error) => {
432
+ if (error.code === "ENOENT")
433
+ throw new Error(`no key at ${keyPath} to submit the pull and pay its gas; pass --key <funded key file>, or leave it out and the facilitator submits the pull`);
434
+ throw error;
435
+ });
399
436
  const { terms, reference } = mandate;
400
437
  const publicClient = rpcClient(target);
401
438
  // A mandate not yet bound, or before its dates, can stop refusing; these are
402
439
  // checked before the agent signs, so a pull reported unsent can never pay later.
403
440
  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))),
441
+ hasCode(publicClient, terms.principal).then((deployed) => (deployed ? readBoundAgentKey(publicClient, terms.principal, reference) : undefined)),
405
442
  publicClient.getBlock(),
406
- publicClient.getBalance({ address: sender.address }),
443
+ sender === undefined ? undefined : publicClient.getBalance({ address: sender.address }),
407
444
  ]);
408
445
  if (bound === undefined)
409
446
  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({
447
+ if (sender !== undefined && gas === 0n)
448
+ throw new Error(`${sender.address}, which submits the pull, holds no gas: fund it, or leave out --key and the facilitator submits the pull`);
449
+ const keptPath = join(pendingPullsPath(args.credentialPath), `${reference}-${args.to.toLowerCase()}-${args.amount}`);
450
+ const end = await chainEnd(keptPath);
451
+ let attempt;
452
+ // The pull a new one follows in the chain; the chain's first when there is none.
453
+ let follows;
454
+ let checked;
455
+ if (end !== undefined && (await exists(join(keptPath, `${end.pull.nonce}.done`)))) {
456
+ // Its outcome is reported: this is a new payment.
457
+ follows = end.pull.nonce;
458
+ }
459
+ else if (end !== undefined) {
460
+ attempt = end;
461
+ const { pull } = attempt;
462
+ if (!isAddressEqual(pull.to, args.to) || pull.value !== args.amount || !isAddressEqual(pull.token, terms.constraints.token) || slice(pull.nonce, 0, 12) !== slice(reference, 0, 12)) {
463
+ throw new Error(`${keptPath} holds a pull for another payment than this mandate's ${usdz(args.amount)} to ${args.to}`);
464
+ }
465
+ const found = await outcomeOnChain(publicClient, terms, attempt);
466
+ if (found.kind === "landed")
467
+ return settle(keptPath, attempt, printPull(target, terms, attempt.pull, found.receipt, print));
468
+ if (found.kind === "open" && found.landed)
469
+ return printPending(print, "it has landed, and its log cannot be read yet", attempt, keptPath);
470
+ // One that can never land is followed by a new pull, signed and checked below.
471
+ if (found.kind === "never") {
472
+ follows = attempt.pull.nonce;
473
+ attempt = undefined;
474
+ }
475
+ }
476
+ if (attempt === undefined) {
477
+ if (block.timestamp < terms.constraints.recurrenceStart && !args.recordRefusal)
478
+ return printUnsent("OutsideRecurrence", print);
479
+ const now = BigInt(Math.floor(Date.now() / 1000));
480
+ // What a seller's offer would be; the facilitator settles the pull against it.
481
+ const requirements = {
426
482
  scheme: "exact",
427
- network: `eip155:${chainId}`,
483
+ network: `eip155:${target.network.chainId}`,
428
484
  amount: args.amount.toString(),
429
485
  asset: terms.constraints.token,
430
486
  payTo: args.to,
431
487
  maxTimeoutSeconds: MANDATE_MAX_VALIDITY_SECONDS,
432
- }),
433
- iss: terms.terms.kid,
434
- signDigest,
488
+ };
489
+ const signed = await signL3a({
490
+ chainId: target.network.chainId,
491
+ ref: reference,
492
+ token: terms.constraints.token,
493
+ terms: terms.terms,
494
+ to: args.to,
495
+ value: args.amount,
496
+ validAfter: now - BigInt(MANDATE_VALID_AFTER_LEAD_SECONDS),
497
+ validBefore: now + BigInt(MANDATE_MAX_VALIDITY_SECONDS),
498
+ transactionId: transactionIdOf(requirements),
499
+ iss: terms.terms.kid,
500
+ signDigest,
501
+ });
502
+ const fresh = { pull: signed.pull, fields: signed.fields, requirements, fromBlock: block.number };
503
+ const reason = await readCheck(publicClient, terms.principal, fresh.pull, fresh.fields);
504
+ if (reason !== "None" && !args.recordRefusal)
505
+ return printUnsent(reason, print);
506
+ await simulatePull(publicClient, terms, fresh, sender);
507
+ checked = fresh.pull.nonce;
508
+ // Kept before it leaves: from here on it can land, and the same command sends it again.
509
+ // A run that kept one first wins, and this run sends that one.
510
+ attempt = await keepFirst(join(keptPath, follows === undefined ? "first.json" : `${follows}.next.json`), fresh);
511
+ }
512
+ if (attempt.pull.nonce !== checked) {
513
+ const rejected = await simulatePull(publicClient, terms, attempt, sender).then(() => undefined, (error) => error);
514
+ if (rejected !== undefined) {
515
+ // A pull another run kept, or one kept before, can have landed since it was read.
516
+ const found = await outcomeOnChain(publicClient, terms, attempt);
517
+ if (found.kind === "landed") {
518
+ return settle(keptPath, attempt, printPull(target, terms, attempt.pull, found.receipt, print));
519
+ }
520
+ if (found.kind === "open" && found.landed)
521
+ return printPending(print, "it has landed, and its log cannot be read yet", attempt, keptPath);
522
+ throw rejected;
523
+ }
524
+ }
525
+ const call = pullCall(terms.principal, attempt.pull, attempt.fields);
526
+ const sent = sender === undefined ? await sendToFacilitator(target, terms, attempt) : await sendFromKey(publicClient, sender, call);
527
+ if (sent.kind === "unsent")
528
+ throw new Error(`the pull was not sent: ${sent.cause}; it is kept at ${keptPath}, and the same command sends it`);
529
+ const found = await outcomeOnChain(publicClient, terms, attempt, sent.transaction);
530
+ if (found.kind === "landed") {
531
+ return settle(keptPath, attempt, printPull(target, terms, attempt.pull, found.receipt, print));
532
+ }
533
+ if (found.kind === "never") {
534
+ print("outcome refused, not paid");
535
+ print(`reason ${describeRefusal(found.reason)}`);
536
+ return settle(keptPath, attempt, "refused");
537
+ }
538
+ // A transaction whose receipt was read and does not carry this pull is not named as its.
539
+ if (sent.transaction !== undefined && found.unreadReceipt)
540
+ print(`transaction ${sent.transaction}`);
541
+ if (found.landed)
542
+ return printPending(print, "it has landed, and its log cannot be read yet", attempt, keptPath);
543
+ return printPending(print, found.unread === undefined ? sent.why : `${sent.why}; the chain could not be read: ${found.unread}`, attempt, keptPath);
544
+ }
545
+ /**
546
+ * Marks a kept pull's outcome reported once it is printed, so the same
547
+ * command makes a new payment next; a run killed between the two leaves it
548
+ * for the next run to report.
549
+ */
550
+ async function settle(keptPath, kept, outcome) {
551
+ await writeFile(join(keptPath, `${kept.pull.nonce}.done`), "", { flag: "wx", mode: 0o600 }).catch((error) => {
552
+ if (error.code !== "EEXIST")
553
+ throw error;
554
+ });
555
+ return outcome;
556
+ }
557
+ /**
558
+ * The last pull in a payee and amount's chain: its first, then each pull kept
559
+ * after one that can never land or whose outcome is reported. Pulls are only
560
+ * ever added to a chain, each once, so runs at once read the same end.
561
+ */
562
+ async function chainEnd(keptPath) {
563
+ let kept = await readKept(join(keptPath, "first.json"));
564
+ for (let next = kept; next !== undefined; next = await readKept(join(keptPath, `${next.pull.nonce}.next.json`)))
565
+ kept = next;
566
+ return kept;
567
+ }
568
+ function printPending(print, reason, kept, keptPath) {
569
+ print("outcome pending");
570
+ print(`reason ${reason.replace(UNSHOWN, "?").slice(0, 300)}`);
571
+ print(`can-land-until ${utc(kept.pull.validBefore)}`);
572
+ print(`kept ${keptPath}: run the same command again to send the same pull, which pays at most once`);
573
+ return "pending";
574
+ }
575
+ const BIGINTS = ["value", "validAfter", "validBefore", "fromBlock"];
576
+ async function readKept(path) {
577
+ const text = await readFile(path, "utf8").catch((error) => {
578
+ if (error.code === "ENOENT")
579
+ return undefined;
580
+ throw error;
435
581
  });
436
- const call = pullCall(terms.principal, signed.pull, signed.fields);
582
+ if (text === undefined)
583
+ return undefined;
437
584
  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 });
585
+ return JSON.parse(text, (key, value) => (BIGINTS.includes(key) && typeof value === "string" ? BigInt(value) : value));
586
+ }
587
+ catch (error) {
588
+ throw new Error(`${path} cannot be read: ${error instanceof Error ? error.message : String(error)}; it holds a pull that can land until ten minutes after it was signed`);
589
+ }
590
+ }
591
+ /**
592
+ * Keeps `pull` at `path` unless a pull is kept there already, and returns the
593
+ * one kept. The whole file is written beside it and linked into place, which
594
+ * fails when the path exists, so two runs at once keep and send one pull.
595
+ */
596
+ async function keepFirst(path, pull) {
597
+ await mkdir(dirname(path), { recursive: true, mode: 0o700 });
598
+ const draft = await writeDraft(path, pull);
599
+ try {
600
+ for (;;) {
601
+ const linked = await link(draft, path).then(() => true, (error) => {
602
+ if (error.code !== "EEXIST")
603
+ throw error;
604
+ return false;
605
+ });
606
+ if (linked)
607
+ return pull;
608
+ const kept = await readKept(path);
609
+ if (kept !== undefined)
610
+ return kept;
611
+ }
612
+ }
613
+ finally {
614
+ await rm(draft, { force: true });
615
+ }
616
+ }
617
+ async function writeDraft(path, pull) {
618
+ const draft = `${path}.${randomUUID()}.tmp`;
619
+ await writeFile(draft, `${JSON.stringify(pull, (_, value) => (typeof value === "bigint" ? value.toString() : value), 2)}\n`, { mode: 0o600 });
620
+ return draft;
621
+ }
622
+ /** The pull's own call at the node's gas ceiling; what it costs is sendMandateCall's to estimate. */
623
+ async function simulatePull(publicClient, terms, kept, sender) {
624
+ const call = pullCall(terms.principal, kept.pull, kept.fields);
625
+ try {
626
+ await publicClient.call({ ...(sender && { account: sender.address }), to: call.to, data: call.data });
440
627
  }
441
628
  catch (error) {
442
629
  if (lostInTransport(error))
443
630
  throw new Error(`the pull could not be checked: ${publicMessage(error)}`);
444
631
  throw new Error(`Mandate Binding rejects the pull and records nothing: ${revertName(error)}`);
445
632
  }
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
633
  }
634
+ /** Above the facilitator's own 60 s wait for a receipt, so its answer is not cut short. */
635
+ const FACILITATOR_TIMEOUT_MS = 120_000;
636
+ /**
637
+ * The codes a connection fails with before any request is sent: the host is
638
+ * not there, or is not who its certificate says. A failure after that, or a
639
+ * timeout, leaves open whether the request arrived.
640
+ */
641
+ const NEVER_REACHED = new Set(["ECONNREFUSED", "ENOTFOUND", "EAI_AGAIN", "EHOSTUNREACH", "ENETUNREACH", "ERR_TLS_CERT_ALTNAME_INVALID", "UNABLE_TO_VERIFY_LEAF_SIGNATURE", "CERT_HAS_EXPIRED", "DEPTH_ZERO_SELF_SIGNED_CERT", "SELF_SIGNED_CERT_IN_CHAIN"]);
642
+ /** The code under which `fetch` never reached its host, or undefined when the request may have arrived. */
643
+ function neverReached(error) {
644
+ const cause = error.cause;
645
+ const code = typeof cause?.code === "string" ? cause.code : undefined;
646
+ return code !== undefined && NEVER_REACHED.has(code) ? code : undefined;
647
+ }
648
+ /**
649
+ * Hands the pull to the facilitator as the payment a seller would settle,
650
+ * `exact` to the payee; it checks the pull against the mandate, submits it and
651
+ * pays the gas. Its answer gives the transaction it sent, if any, and its
652
+ * reason for not settling; whether the pull paid is the chain's to say.
653
+ */
654
+ async function sendToFacilitator(target, terms, kept) {
655
+ const paymentPayload = {
656
+ x402Version: 2,
657
+ accepted: kept.requirements,
658
+ payload: {
659
+ authorization: {
660
+ from: terms.principal,
661
+ to: kept.pull.to,
662
+ value: kept.pull.value.toString(),
663
+ validAfter: kept.pull.validAfter.toString(),
664
+ validBefore: kept.pull.validBefore.toString(),
665
+ nonce: kept.pull.nonce,
666
+ },
667
+ signature: encodeMandateBlob(kept.pull, kept.fields),
668
+ },
669
+ };
670
+ let response;
671
+ try {
672
+ response = await fetch(`${target.facilitatorUrl}/settle`, {
673
+ method: "POST",
674
+ headers: { "content-type": "application/json" },
675
+ body: JSON.stringify({ x402Version: 2, paymentPayload, paymentRequirements: kept.requirements }),
676
+ signal: AbortSignal.timeout(FACILITATOR_TIMEOUT_MS),
677
+ });
678
+ }
679
+ catch (error) {
680
+ const code = neverReached(error);
681
+ if (code !== undefined)
682
+ return { kind: "unsent", cause: `the facilitator at ${new URL(target.facilitatorUrl).host} could not be reached (${code})` };
683
+ return { kind: "sent", transaction: undefined, why: "the facilitator did not answer" };
684
+ }
685
+ const answer = await response.json().catch(() => undefined);
686
+ const settled = (typeof answer === "object" && answer !== null ? answer : {});
687
+ const transaction = typeof settled.transaction === "string" && isHash(settled.transaction) ? settled.transaction : undefined;
688
+ const said = [settled.errorReason, settled.errorMessage].filter((part) => typeof part === "string" && part !== "").join(": ");
689
+ const why = said !== "" ? `the facilitator says ${said}` : settled.success === true ? "the facilitator says it settled" : `the facilitator answered HTTP ${response.status}`;
690
+ return { kind: "sent", transaction, why: why.replace(UNSHOWN, "?").slice(0, 300) };
691
+ }
692
+ /**
693
+ * Submits the pull from the `--key` account, which pays the gas. A node that
694
+ * refuses the transaction, or an estimate that fails, sent nothing; a request
695
+ * lost on its way to the node may have.
696
+ */
697
+ async function sendFromKey(publicClient, sender, call) {
698
+ return sendMandateCall(publicClient, walletFor(publicClient, sender), call).then((transaction) => ({ kind: "sent", transaction, why: "its transaction has no receipt" }), (error) => (lostInTransport(error) ? { kind: "sent", transaction: undefined, why: `sending it failed: ${publicMessage(error)}` } : { kind: "unsent", cause: `sending it failed: ${publicMessage(error)}` }));
699
+ }
700
+ const PULL_OUTCOMES = [getAbiItem({ abi: mandateBindingAbi, name: "MandatePayment" }), getAbiItem({ abi: mandateBindingAbi, name: "MandateRefused" })];
701
+ /** Blocks per log search, under the node's own limit on one filter's range. */
702
+ const LOG_CHUNK = 10000n;
703
+ /**
704
+ * The reasons a bound mandate refuses a pull for good: none of them can turn
705
+ * into a payment later. A closed window is read from the block itself; a
706
+ * window or mandate this node has not reached yet is not final.
707
+ */
708
+ const FINAL = ["Revoked", "Expired", "BadSignature", "PayeeNotAllowed", "OverPerPaymentCap", "OverBudget", "OverOccurrences"];
709
+ /**
710
+ * Where a pull stands on chain, read at one block: landed, in `transaction`
711
+ * when one is named or in the account's own log between the block it was
712
+ * signed at and the end of its window; never to land, once its window has
713
+ * closed or the mandate refuses it for good; or still open. A read that fails
714
+ * leaves it open.
715
+ */
716
+ async function outcomeOnChain(publicClient, terms, kept, transaction) {
717
+ const digest = slice(kept.pull.nonce, 12);
718
+ let unreadReceipt = transaction !== undefined;
719
+ try {
720
+ if (transaction !== undefined) {
721
+ const receipt = await publicClient.waitForTransactionReceipt({ hash: transaction, pollingInterval: 250 });
722
+ unreadReceipt = false;
723
+ if (pullEventOf(terms, digest, receipt) !== undefined)
724
+ return { kind: "landed", receipt };
725
+ }
726
+ const head = await publicClient.getBlock();
727
+ // A pull lands only before its window ends, so the search stops at the first range that reaches it.
728
+ for (let from = kept.fromBlock; from <= head.number; from += LOG_CHUNK) {
729
+ const to = from + LOG_CHUNK - 1n < head.number ? from + LOG_CHUNK - 1n : head.number;
730
+ const logs = await publicClient.getLogs({ address: terms.principal, events: PULL_OUTCOMES, fromBlock: from, toBlock: to });
731
+ const log = logs.find((entry) => entry.args.l3aDigest === digest);
732
+ if (log !== undefined)
733
+ return { kind: "landed", receipt: await publicClient.getTransactionReceipt({ hash: log.transactionHash }) };
734
+ if (to === head.number || (await publicClient.getBlock({ blockNumber: to })).timestamp >= kept.pull.validBefore)
735
+ break;
736
+ }
737
+ // The token records the authorization a paid pull spends, whatever the mandate's state is since.
738
+ const spent = await publicClient.readContract({ address: kept.pull.token, abi: AUTHORIZATION_STATE, functionName: "authorizationState", args: [terms.principal, kept.pull.nonce], blockNumber: head.number });
739
+ if (spent)
740
+ return { kind: "open", landed: true };
741
+ if (head.timestamp >= kept.pull.validBefore)
742
+ return { kind: "never", reason: "OutsideWindow" };
743
+ const reason = await readCheck(publicClient, terms.principal, kept.pull, kept.fields, head.number);
744
+ // After its end a mandate refuses for good; before its start it does only until then.
745
+ // A used digest with no log for it is a pull that landed, as a payment or a recorded refusal.
746
+ if (reason === "NonceUsed")
747
+ return { kind: "open", landed: true };
748
+ const final = FINAL.includes(reason) || (reason === "OutsideRecurrence" && head.timestamp >= terms.constraints.recurrenceStart);
749
+ return final ? { kind: "never", reason: reason } : { kind: "open" };
750
+ }
751
+ catch (error) {
752
+ return { kind: "open", unread: publicMessage(error), unreadReceipt };
753
+ }
754
+ }
755
+ const AUTHORIZATION_STATE = parseAbi(["function authorizationState(address authorizer, bytes32 nonce) view returns (bool)"]);
457
756
  /** Whether the request failed on its way, so the node may or may not have acted on it. */
458
757
  function lostInTransport(error) {
459
758
  return error instanceof BaseError && error.walk((e) => e instanceof HttpRequestError || e instanceof TimeoutError) !== null;
@@ -464,29 +763,42 @@ function printUnsent(reason, print) {
464
763
  print("recorded no");
465
764
  return "refused";
466
765
  }
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");
766
+ function pullEventOf(terms, l3aDigest, receipt) {
767
+ return parseEventLogs({ abi: mandateBindingAbi, logs: receipt.logs, eventName: ["MandatePayment", "MandateRefused"] }).find((log) => isAddressEqual(log.address, terms.principal) && log.args.l3aDigest === l3aDigest);
768
+ }
769
+ /** Where a token sends a credit the payee's receive policy refuses: its escrow (IMIP20.sol). */
770
+ const RECEIVE_POLICY = "0x0000000000000000000000000000000000000f4d";
771
+ const AUTHORIZATION_USED = parseAbiItem("event AuthorizationUsed(address indexed authorizer, bytes32 indexed nonce)");
772
+ function printPull(target, terms, pull, receipt, print) {
773
+ const outcome = pullEventOf(terms, slice(pull.nonce, 12), receipt);
774
+ if (outcome === undefined)
775
+ throw new Error(`transaction ${receipt.transactionHash} carries neither a payment nor a refusal for the pull`);
776
+ let result = "refused";
777
+ print(`transaction ${receipt.transactionHash}`);
778
+ if (outcome.eventName === "MandatePayment") {
779
+ // The transfer the pull's authorization makes is the token's next one from the account after it.
780
+ const used = parseEventLogs({ abi: [AUTHORIZATION_USED], logs: receipt.logs }).find((log) => isAddressEqual(log.address, pull.token) && isAddressEqual(log.args.authorizer, terms.principal) && log.args.nonce === pull.nonce);
781
+ const moved = used && parseEventLogs({ abi: erc20Abi, eventName: "Transfer", logs: receipt.logs }).find((log) => isAddressEqual(log.address, pull.token) && log.logIndex > used.logIndex && isAddressEqual(log.args.from, terms.principal) && !isAddressEqual(log.args.to, pull.token));
782
+ result = moved !== undefined && isAddressEqual(moved.args.to, RECEIVE_POLICY) ? "held" : "paid";
783
+ print(`outcome ${result}`);
784
+ if (result === "held")
785
+ print("reason the payee's receive policy refused the payment, and the token holds it in escrow");
471
786
  print(`spent ${usdz(outcome.args.spent)} of ${usdz(terms.constraints.budget)}`);
472
787
  }
473
- else if (outcome?.eventName === "MandateRefused") {
788
+ else {
474
789
  print("outcome refused");
475
790
  const reason = reasonName(outcome.args.reason);
476
791
  if (reason === "None")
477
792
  throw new Error(`transaction ${receipt.transactionHash} records a refusal with no reason`);
478
793
  print(`reason ${describeRefusal(reason)}`);
479
794
  }
480
- else {
481
- throw new Error(`transaction ${receipt.transactionHash} carries neither a payment nor a refusal for the pull`);
482
- }
483
795
  print(`payer ${terms.principal}`);
484
796
  print(`payee ${outcome.args.to}`);
485
797
  print(`amount ${usdz(outcome.args.amount)}`);
486
798
  if (target.explorerUrl)
487
799
  print(`explorer ${target.explorerUrl}/tx/${receipt.transactionHash}`);
488
800
  print(`gas ${receipt.gasUsed}`);
489
- return outcome.eventName === "MandatePayment" ? "paid" : "refused";
801
+ return result;
490
802
  }
491
803
  /** A decimal `*USDZ` amount a mandate can pay: positive, to the cent. */
492
804
  export function parsePullAmount(text, option) {