@metalntwx/metal-agent 0.3.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -6,12 +6,12 @@
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 { randomUUID } from "node:crypto";
9
+ import { createHash, randomUUID } from "node:crypto";
10
10
  import { link, mkdir, readFile, readdir, rm, writeFile } from "node:fs/promises";
11
11
  import { dirname, join } from "node:path";
12
- import { FEE_PER_GAS, assertMetalAccount, authorizeMandateCall, metalAccountFactoryAbi, MANDATE_MAX_VALIDITY_SECONDS, MANDATE_VALID_AFTER_LEAD_SECONDS, REFUSALS, UNCAPPED_OCCURRENCES, agentKeyOfJwk, bindCall, createCredential, encodeMandateBlob, describeRefusal, encodeAgentKey, extractTerms, hashCredential, ingestCredential, mandateBindingAbi, mandateReference, p256DigestSigner, parseCredential, privateKeyHex, publicMessage, pullCall, readBoundAgentKey, readCheck, readMandateState, reasonName, replaceCall, revokeCall, sameAgentKey, sendMandateCall, signL3a, transactionIdOf, trustedIssuer, verifyMandate, verifyMandatePayment, } from "@metalntwx/metal-pay/mandates";
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";
13
13
  import { USDZ, formatUsdz, usdzBaseUnits } from "@metalntwx/metal-pay";
14
- import { BaseError, erc20Abi, getAbiItem, parseAbi, parseAbiItem, 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";
15
15
  import { rpcClient } from "../chain.js";
16
16
  import { fund } from "./fund.js";
17
17
  import { exists, mandateKeyPath, pendingPullsPath, publicKeyPath, readKey, readOrCreateIssuerKeys, readOrCreateKey, readOrCreateP256Key, readP256Key, readTrustedIssuer, writePublicKeyFile, } from "../keystore.js";
@@ -199,14 +199,20 @@ async function ownerAccount(client, factory, owner) {
199
199
  /** Creates the owner's one-owner MetalAccount on chain, writing the owner's
200
200
  * key first when `out` names a file to create. The factory gives each owner
201
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
202
+ * owner with too little gas is funded first, from the faucet on testnet. Mixed-key
203
203
  * and weighted accounts use the same factory through the SDK. */
204
204
  export async function accountCreate(target, args, print) {
205
205
  const keyPath = "keyPath" in args ? args.keyPath : args.out;
206
206
  const created = "out" in args ? (await readOrCreateKey(args.out)).created : false;
207
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) {
208
214
  const client = rpcClient(target);
209
- const { config, address } = await ownerAccount(client, args.accountFactory, owner.address);
215
+ const { config, address } = await ownerAccount(client, accountFactory, owner.address);
210
216
  print(`account ${address}`);
211
217
  print(`owner ${owner.address}`);
212
218
  print(`keyfile ${keyPath}${created ? " (created)" : ""}`);
@@ -214,14 +220,30 @@ export async function accountCreate(target, args, print) {
214
220
  print("deployed yes");
215
221
  return;
216
222
  }
217
- if ((await client.getBalance({ address: owner.address })) === 0n) {
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) {
218
226
  // Only the grant's summary: its transaction lines would read as the account's.
219
227
  const funding = [];
220
- await fund(target, owner.address, undefined, (line) => funding.push(line));
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);
221
230
  if (funding[0] !== undefined)
222
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
+ }
223
238
  }
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 });
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`);
225
247
  print("deployed yes");
226
248
  printSent(target, receipt, print);
227
249
  }
@@ -244,7 +266,9 @@ async function sendOwnerCall(client, args, call) {
244
266
  throw new Error(`${account} has no account: create it with metal-agent account create`);
245
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`);
246
268
  }
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 })))]));
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 })))]));
248
272
  return send(client, sponsor, authorized);
249
273
  }
250
274
  export async function mandateBind(target, args, print) {
@@ -317,18 +341,19 @@ export async function mandateStatus(target, credentialPath, accountFactory, prin
317
341
  if (!(await hasCode(publicClient, terms.principal))) {
318
342
  print("account-type none; create the account before binding: metal-agent account create for one owner");
319
343
  print("bound no");
320
- return;
344
+ return "unbound";
321
345
  }
322
346
  await assertMetalAccount(publicClient, terms.principal, accountFactory);
323
347
  print("account-type MetalAccount");
324
348
  const bound = (await publicClient.readContract({ address: terms.principal, abi: mandateBindingAbi, functionName: "mandates", args: [reference] })).principal.toLowerCase() === terms.principal.toLowerCase();
325
349
  print(`bound ${bound ? "yes" : "no"}`);
326
350
  if (!bound)
327
- return;
351
+ return "unbound";
328
352
  const state = await readMandateState(publicClient, terms.principal, reference);
329
353
  print(`revoked ${state.revoked ? "yes" : "no"}`);
330
354
  print(`spent ${usdz(state.spent)} of ${usdz(terms.constraints.budget)}`);
331
355
  print(`payments ${state.occurrences}`);
356
+ return state.revoked ? "revoked" : "live";
332
357
  }
333
358
  /**
334
359
  * Verifies the mandate and its payments from the chain alone: the binding on
@@ -411,10 +436,12 @@ function revertName(error) {
411
436
  * record the refusal on chain.
412
437
  *
413
438
  * 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.
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.
418
445
  */
419
446
  export async function mandatePull(target, args, print) {
420
447
  const mandate = await loadMandate(args.credentialPath);
@@ -422,10 +449,6 @@ export async function mandatePull(target, args, print) {
422
449
  const keyPath = args.keyPath;
423
450
  if (args.recordRefusal && keyPath === undefined)
424
451
  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
452
  const sender = keyPath === undefined
430
453
  ? undefined
431
454
  : await readKey(keyPath).catch((error) => {
@@ -438,44 +461,89 @@ export async function mandatePull(target, args, print) {
438
461
  // A mandate not yet bound, or before its dates, can stop refusing; these are
439
462
  // checked before the agent signs, so a pull reported unsent can never pay later.
440
463
  const [bound, block, gas] = await Promise.all([
441
- hasCode(publicClient, terms.principal).then((deployed) => (deployed ? readBoundAgentKey(publicClient, terms.principal, reference) : undefined)),
464
+ hasCode(publicClient, terms.principal).then((deployed) => (deployed ? publicClient.readContract({ address: terms.principal, abi: mandateBindingAbi, functionName: "mandates", args: [reference] }) : undefined)),
442
465
  publicClient.getBlock(),
443
466
  sender === undefined ? undefined : publicClient.getBalance({ address: sender.address }),
444
467
  ]);
445
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))
446
471
  throw new Error(`the mandate ${reference} is not bound on ${terms.principal}; the owner binds it with mandate bind`);
447
472
  if (sender !== undefined && gas === 0n)
448
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`);
449
- const keptPath = join(pendingPullsPath(args.credentialPath), `${reference}-${args.to.toLowerCase()}-${args.amount}`);
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")}`);
450
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
+ };
451
501
  let attempt;
452
502
  // The pull a new one follows in the chain; the chain's first when there is none.
453
503
  let follows;
454
504
  let checked;
455
- if (end !== undefined && (await exists(join(keptPath, `${end.pull.nonce}.done`)))) {
456
- // Its outcome is reported: this is a new payment.
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.
457
521
  follows = end.pull.nonce;
458
522
  }
459
523
  else if (end !== undefined) {
460
524
  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);
525
+ const found = await outcomeOnChain(publicClient, terms, reference, attempt);
526
+ if (found.kind === "undecoded")
527
+ throw undecodedPull(terms, found);
466
528
  if (found.kind === "landed")
467
- return settle(keptPath, attempt, printPull(target, terms, attempt.pull, found.receipt, print));
529
+ return settle(keptPath, attempt, printPull(target, terms, attempt.pull, found.receipt, show), shown, args, print);
468
530
  if (found.kind === "open" && found.landed)
469
- return printPending(print, "it has landed, and its log cannot be read yet", attempt, keptPath);
531
+ return printPending(print, "it has landed, and its log cannot be read yet", attempt, args);
470
532
  // One that can never land is followed by a new pull, signed and checked below.
471
533
  if (found.kind === "never") {
472
534
  follows = attempt.pull.nonce;
473
535
  attempt = undefined;
474
536
  }
475
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
+ }
476
543
  if (attempt === undefined) {
544
+ // Mandate Binding names the mandate's state before its dates; one that has not started cannot have expired.
477
545
  if (block.timestamp < terms.constraints.recurrenceStart && !args.recordRefusal)
478
- return printUnsent("OutsideRecurrence", print);
546
+ return printUnsent(bound.revoked ? "Revoked" : "OutsideRecurrence", print);
479
547
  const now = BigInt(Math.floor(Date.now() / 1000));
480
548
  // What a seller's offer would be; the facilitator settles the pull against it.
481
549
  const requirements = {
@@ -505,7 +573,7 @@ export async function mandatePull(target, args, print) {
505
573
  return printUnsent(reason, print);
506
574
  await simulatePull(publicClient, terms, fresh, sender);
507
575
  checked = fresh.pull.nonce;
508
- // Kept before it leaves: from here on it can land, and the same command sends it again.
576
+ // Kept before it leaves: from here on it can land, and the same pull sends it again.
509
577
  // A run that kept one first wins, and this run sends that one.
510
578
  attempt = await keepFirst(join(keptPath, follows === undefined ? "first.json" : `${follows}.next.json`), fresh);
511
579
  }
@@ -513,47 +581,97 @@ export async function mandatePull(target, args, print) {
513
581
  const rejected = await simulatePull(publicClient, terms, attempt, sender).then(() => undefined, (error) => error);
514
582
  if (rejected !== undefined) {
515
583
  // A pull another run kept, or one kept before, can have landed since it was read.
516
- const found = await outcomeOnChain(publicClient, terms, attempt);
584
+ const found = await outcomeOnChain(publicClient, terms, reference, attempt);
585
+ if (found.kind === "undecoded")
586
+ throw undecodedPull(terms, found);
517
587
  if (found.kind === "landed") {
518
- return settle(keptPath, attempt, printPull(target, terms, attempt.pull, found.receipt, print));
588
+ return settle(keptPath, attempt, printPull(target, terms, attempt.pull, found.receipt, show), shown, args, print);
519
589
  }
520
590
  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;
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)}`);
523
593
  }
524
594
  }
525
595
  const call = pullCall(terms.principal, attempt.pull, attempt.fields);
526
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);
527
599
  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);
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);
530
604
  if (found.kind === "landed") {
531
- return settle(keptPath, attempt, printPull(target, terms, attempt.pull, found.receipt, print));
605
+ return settle(keptPath, attempt, printPull(target, terms, attempt.pull, found.receipt, show), shown, args, print);
532
606
  }
533
607
  if (found.kind === "never") {
534
- print("outcome refused, not paid");
535
- print(`reason ${describeRefusal(found.reason)}`);
536
- return settle(keptPath, attempt, "refused");
608
+ show("outcome refused, not paid");
609
+ show(`reason ${describeRefusal(found.reason)}`);
610
+ return settle(keptPath, attempt, "refused", shown, args, print);
537
611
  }
538
612
  // A transaction whose receipt was read and does not carry this pull is not named as its.
539
- if (sent.transaction !== undefined && found.unreadReceipt)
613
+ if (sent.kind === "sent" && sent.transaction !== undefined && found.unreadReceipt)
540
614
  print(`transaction ${sent.transaction}`);
541
615
  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);
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);
544
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"];
545
635
  /**
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
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
548
639
  * for the next run to report.
549
640
  */
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;
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`);
554
645
  });
555
646
  return outcome;
556
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`;
651
+ try {
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
+ }
557
675
  /**
558
676
  * The last pull in a payee and amount's chain: its first, then each pull kept
559
677
  * after one that can never land or whose outcome is reported. Pulls are only
@@ -565,11 +683,11 @@ async function chainEnd(keptPath) {
565
683
  kept = next;
566
684
  return kept;
567
685
  }
568
- function printPending(print, reason, kept, keptPath) {
686
+ function printPending(print, reason, kept, args) {
569
687
  print("outcome pending");
570
688
  print(`reason ${reason.replace(UNSHOWN, "?").slice(0, 300)}`);
571
689
  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`);
690
+ print(`kept ${keptAs(args)}: the same pull sends it again, which pays at most once`);
573
691
  return "pending";
574
692
  }
575
693
  const BIGINTS = ["value", "validAfter", "validBefore", "fromBlock"];
@@ -585,7 +703,7 @@ async function readKept(path) {
585
703
  return JSON.parse(text, (key, value) => (BIGINTS.includes(key) && typeof value === "string" ? BigInt(value) : value));
586
704
  }
587
705
  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`);
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`);
589
707
  }
590
708
  }
591
709
  /**
@@ -633,6 +751,8 @@ async function simulatePull(publicClient, terms, kept, sender) {
633
751
  }
634
752
  /** Above the facilitator's own 60 s wait for a receipt, so its answer is not cut short. */
635
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"]);
636
756
  /**
637
757
  * The codes a connection fails with before any request is sent: the host is
638
758
  * not there, or is not who its certificate says. A failure after that, or a
@@ -649,7 +769,10 @@ function neverReached(error) {
649
769
  * Hands the pull to the facilitator as the payment a seller would settle,
650
770
  * `exact` to the payee; it checks the pull against the mandate, submits it and
651
771
  * 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.
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.
653
776
  */
654
777
  async function sendToFacilitator(target, terms, kept) {
655
778
  const paymentPayload = {
@@ -686,6 +809,14 @@ async function sendToFacilitator(target, terms, kept) {
686
809
  const settled = (typeof answer === "object" && answer !== null ? answer : {});
687
810
  const transaction = typeof settled.transaction === "string" && isHash(settled.transaction) ? settled.transaction : undefined;
688
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
+ }
689
820
  const why = said !== "" ? `the facilitator says ${said}` : settled.success === true ? "the facilitator says it settled" : `the facilitator answered HTTP ${response.status}`;
690
821
  return { kind: "sent", transaction, why: why.replace(UNSHOWN, "?").slice(0, 300) };
691
822
  }
@@ -697,7 +828,22 @@ async function sendToFacilitator(target, terms, kept) {
697
828
  async function sendFromKey(publicClient, sender, call) {
698
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)}` }));
699
830
  }
700
- const PULL_OUTCOMES = [getAbiItem({ abi: mandateBindingAbi, name: "MandatePayment" }), getAbiItem({ abi: mandateBindingAbi, name: "MandateRefused" })];
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
+ }
701
847
  /** Blocks per log search, under the node's own limit on one filter's range. */
702
848
  const LOG_CHUNK = 10000n;
703
849
  /**
@@ -711,9 +857,10 @@ const FINAL = ["Revoked", "Expired", "BadSignature", "PayeeNotAllowed", "OverPer
711
857
  * when one is named or in the account's own log between the block it was
712
858
  * signed at and the end of its window; never to land, once its window has
713
859
  * closed or the mandate refuses it for good; or still open. A read that fails
714
- * leaves it open.
860
+ * leaves it open. An event under the mandate that does not decode leaves the
861
+ * outcome unreadable by this SDK.
715
862
  */
716
- async function outcomeOnChain(publicClient, terms, kept, transaction) {
863
+ async function outcomeOnChain(publicClient, terms, reference, kept, transaction) {
717
864
  const digest = slice(kept.pull.nonce, 12);
718
865
  let unreadReceipt = transaction !== undefined;
719
866
  try {
@@ -722,15 +869,24 @@ async function outcomeOnChain(publicClient, terms, kept, transaction) {
722
869
  unreadReceipt = false;
723
870
  if (pullEventOf(terms, digest, receipt) !== undefined)
724
871
  return { kind: "landed", receipt };
872
+ const topic = undecodedTopic(terms.principal, reference, receipt.logs);
873
+ if (topic !== undefined)
874
+ return { kind: "undecoded", topic, transaction };
725
875
  }
726
876
  const head = await publicClient.getBlock();
727
877
  // A pull lands only before its window ends, so the search stops at the first range that reaches it.
728
878
  for (let from = kept.fromBlock; from <= head.number; from += LOG_CHUNK) {
729
879
  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 });
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 });
731
884
  const log = logs.find((entry) => entry.args.l3aDigest === digest);
732
885
  if (log !== undefined)
733
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 };
734
890
  if (to === head.number || (await publicClient.getBlock({ blockNumber: to })).timestamp >= kept.pull.validBefore)
735
891
  break;
736
892
  }
@@ -804,3 +960,9 @@ function printPull(target, terms, pull, receipt, print) {
804
960
  export function parsePullAmount(text, option) {
805
961
  return BigInt(usdzBaseUnits(parseCents(text, option)));
806
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
+ }
@@ -1,5 +1,5 @@
1
1
  import { decodePaymentRequiredHeader, decodePaymentResponseHeader } from "@x402/core/http";
2
- import { DEFAULT_MAX_PAYMENT, createMetalPaymentClient, formatUsdz, USDZ, usdzBaseUnits, } from "@metalntwx/metal-pay";
2
+ import { DEFAULT_MAX_PAYMENT, createMetalPaymentClient, formatUsdz, metalNetwork, USDZ, usdzBaseUnits, } from "@metalntwx/metal-pay";
3
3
  import { createMandatePaymentClient, describeRefusal, refusalOf, signedOfferOf } from "@metalntwx/metal-pay/mandates";
4
4
  import { loadMandate, mandateAgentSigner } from "./mandate.js";
5
5
  /** A decimal `*USDZ` limit per payment, `DEFAULT_MAX_PAYMENT` when absent. */
@@ -64,6 +64,71 @@ export async function payUnderMandate(target, payer, url, print, request = {}) {
64
64
  print(`mandate ${paying.reference}`);
65
65
  await paidRequest(target, { ...paying, payer: paying.principal }, url, print, request);
66
66
  }
67
+ /** A payment refused with nothing paid, before settlement or at it; `reason` is the facilitator's or the seller's, when it gave one. */
68
+ export class PaymentRefused extends Error {
69
+ reason;
70
+ constructor(message, reason) {
71
+ super(message);
72
+ this.reason = reason;
73
+ }
74
+ }
75
+ /** A payment that was signed and may have settled: paying again can pay twice. */
76
+ export class PaymentInDoubt extends Error {
77
+ }
78
+ /**
79
+ * The facilitator's settlement reasons that mean nothing moved
80
+ * (apps/x402-facilitator/src/facilitator.ts `check`, `settle`, `failed` and
81
+ * routes.ts): the payment was never sent, was refused or reverted. Any other
82
+ * answer, such as a seller's own `fetch failed` when the facilitator's answer
83
+ * to it was lost, or `already_settled`, `nonce_already_used` or
84
+ * `payment_quarantined`, which name a transaction that moved it, leaves open
85
+ * whether it settled.
86
+ */
87
+ const NOTHING_SETTLED = new Set([
88
+ "unsupported_x402_version",
89
+ "unsupported_network",
90
+ "unsupported_scheme",
91
+ "unsupported_asset",
92
+ "settlement_unavailable",
93
+ "invalid_payload",
94
+ "bad_signature",
95
+ "rpc_unreachable",
96
+ "authorization_window",
97
+ "rate_limited",
98
+ "daily_limit_reached",
99
+ "recipient_refuses",
100
+ "payment_failed",
101
+ "payment_reverted",
102
+ ]);
103
+ function nothingSettled(reason) {
104
+ if (reason === undefined)
105
+ return false;
106
+ const mandate = refusalOf(reason);
107
+ // A used digest is a payment that was made, or refused, already.
108
+ if (mandate !== undefined)
109
+ return mandate !== "NonceUsed";
110
+ return NOTHING_SETTLED.has(reason) || reason.startsWith("payment_mismatch:");
111
+ }
112
+ /** The networks an offer named when none is the one paid on, read from @x402/core's error for an offer with no option it can sign. */
113
+ function offeredElsewhere(message, target) {
114
+ const marker = "Failed to create payment payload: No network/scheme registered";
115
+ if (!message.startsWith(marker) || !message.includes("{"))
116
+ return undefined;
117
+ try {
118
+ const { paymentRequirements } = JSON.parse(message.slice(message.indexOf("{")));
119
+ const networks = [...new Set((paymentRequirements ?? []).map(({ network }) => network).filter((network) => typeof network === "string"))];
120
+ if (networks.length === 0 || networks.includes(target.network.network))
121
+ return undefined;
122
+ const named = networks.map((network) => {
123
+ const metal = metalNetwork(network);
124
+ return metal === undefined ? network : `${metal.name} (${network})`;
125
+ });
126
+ return `the offer is on ${named.join(", ")}, not ${target.network.name} (${target.network.network})`;
127
+ }
128
+ catch {
129
+ return undefined;
130
+ }
131
+ }
67
132
  /** A refusal's reason as `pay` prints it: a mandate's with what it means, any other as the facilitator named it. */
68
133
  function describeRefusalReason(reason) {
69
134
  if (reason === undefined)
@@ -102,10 +167,10 @@ async function paidRequest(target, { fetchWithPayment, client, payer, raise }, u
102
167
  const how = raise !== undefined && aborted[1].endsWith("limit per payment") ? `; ${raise}` : "";
103
168
  throw new Error(`payment refused before signing: ${aborted[1]}${how}`);
104
169
  }
105
- if (signed !== undefined) {
106
- throw new Error(`${error instanceof Error ? error.message : String(error)}; a payment was signed and may have settled; look it up by its nonce before paying again`);
107
- }
108
- throw error;
170
+ const message = error instanceof Error ? error.message : String(error);
171
+ if (signed !== undefined)
172
+ throw new PaymentInDoubt(`${message}; a payment was signed and may have settled; look it up by its nonce before paying again`);
173
+ throw new Error(`${offeredElsewhere(message, target) ?? message}; nothing was signed`);
109
174
  });
110
175
  const body = await response.text();
111
176
  const paymentRequired = response.headers.get("payment-required");
@@ -114,16 +179,24 @@ async function paidRequest(target, { fetchWithPayment, client, payer, raise }, u
114
179
  const required = decodePaymentRequiredHeader(paymentRequired);
115
180
  const offer = required.accepts[0];
116
181
  const priced = offer ? `${formatUsdz(offer.amount)} ${USDZ.symbol} on ${offer.network}` : "no offer";
117
- throw new Error(`payment refused: ${describeRefusalReason(required.error)} (offer: ${priced})`);
182
+ // Refused before settlement: the seller settles only a payment its check passed.
183
+ throw new PaymentRefused(`payment refused: ${describeRefusalReason(required.error)} (offer: ${priced}); nothing was paid`, required.error);
118
184
  }
119
185
  const settlement = paymentResponse ? decodePaymentResponseHeader(paymentResponse) : undefined;
120
- if (settlement !== undefined && !settlement.success)
121
- throw new Error(`payment refused at settlement: ${settleRefusal(settlement)}`);
186
+ if (settlement !== undefined && !settlement.success) {
187
+ // A settlement that is not known to have moved nothing can land all the same, so paying again can pay twice.
188
+ if (!nothingSettled(settlement.errorReason))
189
+ throw new PaymentInDoubt(`payment in doubt at settlement: ${settleRefusal(settlement)}; it may have settled: do not pay again; look it up by its nonce`);
190
+ throw new PaymentRefused(`payment refused at settlement: ${settleRefusal(settlement)}; nothing was paid`, settlement.errorReason);
191
+ }
122
192
  // A redirect is reported, not an error: the payment may have settled, and `location` says where it points.
123
- if (response.status >= 400) {
124
- // A seller that fails after the payment was signed may have settled it first.
125
- const doubt = signed !== undefined && response.status >= 500 ? "; a payment was signed and may have settled; look it up by its nonce before paying again" : "";
126
- throw new Error(`${url} answered ${response.status}: ${body}${doubt}`);
193
+ // A settled payment is reported paid, whatever status the seller answers with.
194
+ if (response.status >= 400 && settlement?.success !== true) {
195
+ // A seller that fails after the payment was signed, or answers that another request holds it, may have settled it first.
196
+ const answered = `${url} answered ${response.status}: ${body}`;
197
+ if (signed !== undefined && (response.status >= 500 || response.status === 409))
198
+ throw new PaymentInDoubt(`${answered}; a payment was signed and may have settled; look it up by its nonce before paying again`);
199
+ throw new Error(answered);
127
200
  }
128
201
  print(`status ${response.status}`);
129
202
  if (settlement !== undefined) {