@metalntwx/metal-agent 0.2.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,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. */
@@ -59,11 +59,76 @@ export async function pay(target, account, url, limit, print, request = {}) {
59
59
  */
60
60
  export async function payUnderMandate(target, payer, url, print, request = {}) {
61
61
  const mandate = await loadMandate(payer.credentialPath);
62
- const signDigest = await mandateAgentSigner(mandate, payer.agentKeyPath);
62
+ const signDigest = await mandateAgentSigner(mandate, payer.credentialPath, payer.mandateKeyFile);
63
63
  const paying = createMandatePaymentClient({ credential: mandate.credential, signDigest, network: target.network });
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) {
package/dist/help.js CHANGED
@@ -1,10 +1,9 @@
1
- import { DEFAULT_KEY_PATH } from "./keystore.js";
2
1
  const NETWORK = "--network devnet|testnet";
3
2
  const COMMANDS = [
4
3
  {
5
4
  name: "key create",
6
5
  usage: "metal-agent key create [--out <file>]",
7
- about: `Generates the agent's key and writes it to a key file only its owner can read, ${DEFAULT_KEY_PATH} unless --out names another. It never overwrites a key file.`,
6
+ about: `Generates the agent's key and writes it to a key file only its owner can read, ~/.metal-agent/key.json unless --out names another. It never overwrites a key file.`,
8
7
  },
9
8
  {
10
9
  name: "key import",
@@ -18,8 +17,8 @@ const COMMANDS = [
18
17
  },
19
18
  {
20
19
  name: "pay",
21
- usage: `metal-agent pay <url> ${NETWORK} [--key <file>] [--max <usdz>]\n [--method <method>] [--body <text>] [--header 'name: value']...\nmetal-agent pay <url> --mandate <credential> ${NETWORK} [--agent-key <file>]`,
22
- about: "Requests the URL and pays its x402 offer in *USDZ. With --key, the agent pays from its own key and refuses, before signing, any offer above --max (1 *USDZ by default). With --mandate, it pays from the owner's account within the mandate's limits, signed by the agent's mandate key, the <credential>.agent-key.json beside the credential unless --agent-key names another. --method (GET by default, POST with a --body), --body (application/json unless a --header names a content type) and each --header are sent again with the payment.",
20
+ usage: `metal-agent pay <url> ${NETWORK} [--key <file>] [--max <usdz>]\n [--method <method>] [--body <text>] [--header 'name: value']...\nmetal-agent pay <url> --mandate <credential> ${NETWORK} [--mandate-key-file <file>]`,
21
+ about: "Requests the URL and pays its x402 offer in *USDZ. With --key, the agent pays from its own key and refuses, before signing, any offer above --max (1 *USDZ by default). With --mandate, it pays from the owner's account within the mandate's limits, signed by the .key.json file in the credential's directory whose key the credential names, unless --mandate-key-file names another. --method (GET by default, POST with a --body), --body (application/json unless a --header names a content type) and each --header are sent again with the payment.",
23
22
  },
24
23
  {
25
24
  name: "status",
@@ -28,23 +27,23 @@ const COMMANDS = [
28
27
  },
29
28
  {
30
29
  name: "mcp",
31
- usage: "metal-agent mcp [--key <file>] [--max <usdz>]\nmetal-agent mcp --mandate <credential> [--agent-key <file>]",
32
- about: "Serves pay, fund and status as MCP tools over stdio. What pay spends is fixed here, by whoever starts the server: its key and --max, or one mandate. The model calling the tools reads pages and offers anyone can write, so it can choose no other key, no mandate and no higher limit; under a key, a max it passes can only lower the server's.",
30
+ usage: "metal-agent mcp [--key <file>] [--max <usdz>]\nmetal-agent mcp --mandate <credential> [--mandate-key-file <file>]",
31
+ about: "Serves pay, fund and status as MCP tools over stdio; under a mandate, mandate_pull, mandate_status and mandate_verify too, which read the chain through the server's METAL_RPC_URL, or devnet's default endpoint. What pay and mandate_pull spend is set here, by whoever starts the server: a key file and --max, or one mandate's credential file, which each call reads. The model calling the tools reads pages and offers anyone can write, so it can choose no other key, no mandate and no higher limit; under a key, a max it passes can only lower the server's.",
33
32
  },
34
33
  {
35
34
  name: "account create",
36
- usage: `metal-agent account create --out <owner key file to create> | --key <owner key file> ${NETWORK}`,
37
- about: "Names the owner's Metal account, a smart account the owner's key controls, and prints its address, the --principal of the owner's credentials. It sends nothing: the owner's first bind creates the account on chain. Keep the owner's key where no agent can read it.",
35
+ usage: `metal-agent account create --out <owner key file> | --key <owner key file> ${NETWORK}`,
36
+ about: "Creates the owner's Metal account, a smart account the owner's key controls, and prints its address, the --principal of the owner's credentials. The owner's key pays the gas; one that holds too little is funded first, from the faucet on testnet. When a later step fails, such as a faucet refusal, the error names the key file, and the same command continues from it. An account that exists is named, and nothing is sent. Keep the owner's key where no agent can read it.",
38
37
  },
39
38
  {
40
39
  name: "mandate credential create",
41
- usage: `metal-agent mandate credential create --principal <address> --cap <usdz> --budget <usdz> --out <file>\n --key <owner key file> | --issuer-key <file> ${NETWORK}\n [--payee <address>[,<name>[,<website>]]]... [--expires-in <seconds>] [--max-occurrences <n>]\n [--description <text>] [--agent-key <file>]`,
42
- about: "Writes the limits an agent pays within into a credential file: the most per payment (--cap), the most in total over the mandate's whole life (--budget, which never resets), an expiry (--expires-in, 30 days by default) and, when --payee names any, the addresses it may pay, each with an optional name and website. Amounts are in *USDZ, to the cent. The agent's mandate key and the gas key for its pulls are written beside the credential unless --agent-key names a mandate key the agent already has. The keys that sign the credential are the owner's: they live beside the owner's key file, <key>.issuer.json, created on first use, unless --issuer-key names their file.",
40
+ usage: `metal-agent mandate credential create --principal <address> --cap <usdz> --budget <usdz> --out <file>\n --key <owner key file> | --issuer-key <file> ${NETWORK}\n [--payee <address>[,<name>[,<website>]]]... [--expires-in <seconds>] [--starts-in <seconds>] [--max-occurrences <n>]\n [--description <text>] [--mandate-key-file <file>]`,
41
+ about: "Writes the limits an agent pays within into a credential file: the most per payment (--cap), the most in total over the mandate's whole life (--budget, which never resets), an expiry (--expires-in, 30 days by default), a start (--starts-in, by default 00:00 UTC on the day of issue) and, when --payee names any, the addresses it may pay, each with an optional name and website. Amounts are in *USDZ, to the cent. The mandate key the agent signs with is written beside the credential, mandate.key.json for --out mandate.json, unless --mandate-key-file names one that exists, as for a replacement. The keys that sign the credential are the owner's: they live beside the owner's key file, <key>.issuer.json, created on first use, unless --issuer-key names their file.",
43
42
  },
44
43
  {
45
44
  name: "mandate bind",
46
45
  usage: `metal-agent mandate bind --credential <file> --key <owner key file> ${NETWORK} [--issuer <file>]... [--signer-key <file>]...`,
47
- about: "Binds the credential on the owner's account, which then pays under it. The account's quorum signs: --key pays the gas and is the sole signer unless repeated --signer-key names the quorum's keys. --issuer names the issuers whose credentials the owner accepts, the <key>.issuer.public.json beside the owner's key by default.",
46
+ about: "Binds the credential on the owner's account, which then pays under it. The account's owners sign: --key pays the gas and is the sole signer unless repeated --signer-key names the owners' keys. --issuer names the issuers whose credentials the owner accepts, the <key>.issuer.public.json beside the owner's key by default.",
48
47
  },
49
48
  {
50
49
  name: "mandate replace",
@@ -68,24 +67,26 @@ const COMMANDS = [
68
67
  },
69
68
  {
70
69
  name: "mandate pull",
71
- usage: `metal-agent mandate pull --credential <file> --to <address> --amount <usdz> ${NETWORK}\n [--key <file>] [--agent-key <file>] [--record-refusal]`,
72
- about: "Pays an address the mandate allows, with no seller. The --key account, the agent's gas key by default, submits it and pays its gas. A payout the mandate would refuse is reported and not sent, and the command exits 1; --record-refusal sends it so the refusal is recorded on chain. It prints the transaction as soon as it is sent; if the receipt never arrives, check it with metal-agent status before pulling again.",
70
+ usage: `metal-agent mandate pull --credential <file> --to <address> --amount <usdz> ${NETWORK}\n [--payment-id <id>] [--key <file>] [--mandate-key-file <file>] [--record-refusal]`,
71
+ about: "Pays an address the mandate allows, with no seller. The facilitator submits it and pays its gas, unless --key names a funded key to submit it. A payout the mandate would refuse is reported and not sent, and the command exits 1; with --key, --record-refusal sends it so the refusal is recorded on chain. The outcome is read from the chain: paid (exit 0), refused (exit 1), held in the payee's escrow (exit 4), or pending (exit 3); an error also exits 1. Each pull is kept under ~/.metal-agent/pulls, and running the same command again sends a pending pull again, which pays at most once. --payment-id names the payment, such as a refund's id: two payouts of one amount to one payee are two payments under two ids, an id whose payout was paid or held reports it again, and one the account refused may be sent with other values. Without it, a payout is known by its payee and amount. Either is known only under this home directory and this credential: a retry from another machine, or under a credential that renewed or replaced it, signs a new payout.",
73
72
  },
74
73
  {
75
74
  name: "mandate verify",
76
75
  usage: `metal-agent mandate verify --credential <file> --issuer <file>... ${NETWORK} [--tx <hash> [--signed-offer <text>]]`,
77
- about: "Checks the mandate and every payment under it from the chain alone, or one --tx: the binding, the credential against it and under an --issuer you trust, and each payment as the agent key's signed per-payment mandate. With --signed-offer, the order the agent signed for that payment, as pay printed it. A check that fails exits 1.",
76
+ about: "Checks the mandate and every payment under it from the chain alone, or one --tx: the binding, the credential against it and under an --issuer you trust, and each payment as the mandate key's signed per-payment mandate. With --signed-offer, the order the agent signed for that payment, as pay printed it. A check that fails exits 1, as does an event under the mandate the SDK does not decode.",
78
77
  },
79
78
  ];
80
- const SHARED = `Chain commands also take --rpc-url and --faucet-url, which override
81
- METAL_RPC_URL and METAL_FAUCET_URL, which override each network's defaults.
79
+ const SHARED = `Chain commands also take --rpc-url and --faucet-url, and mandate pull
80
+ --facilitator-url, which override METAL_RPC_URL, METAL_FAUCET_URL and
81
+ METAL_FACILITATOR_URL, which override each network's defaults.
82
82
  On testnet, status, account create and the mandate commands other than
83
83
  credential create need an RPC endpoint of your own:
84
- sign up in the Metal console and create one. account create and the mandate
85
- commands take --factory <address> when the network's MetalAccountFactory is
84
+ sign up in the Metal console and create one. account create and mandate bind,
85
+ replace, renew, revoke, status and verify take --factory <address> when the network's MetalAccountFactory is
86
86
  not the one metal-agent knows.
87
87
 
88
- metal-agent <command> --help explains one command.`;
88
+ metal-agent <command> --help explains one command.
89
+ metal-agent --version prints the version.`;
89
90
  /** The whole command list, or the commands the given words name, with what each does. */
90
91
  export function helpFor(words) {
91
92
  // The longest run of leading words that names a command or a group: arguments after it are not part of the name.
package/dist/index.js CHANGED
@@ -1,17 +1,20 @@
1
1
  #!/usr/bin/env node
2
+ import { readFileSync } from "node:fs";
2
3
  import { parseArgs } from "node:util";
3
4
  import { accountFactoryFor } from "@metalntwx/metal-pay";
4
5
  import { redactUrls } from "@metalntwx/metal-pay/mandates";
5
6
  import { target } from "./chain.js";
6
7
  import { fund } from "./commands/fund.js";
7
8
  import { keyCreate, keyImport } from "./commands/key.js";
8
- import { credentialCreate, accountCreate, mandateBind, mandatePull, mandateRenew, mandateReplace, mandateRevoke, mandateStatus, mandateVerify, parseAddress, parseCents, parseCount, parsePayee, parseTxHash, parsePullAmount, } from "./commands/mandate.js";
9
+ import { credentialCreate, accountCreate, mandateBind, mandatePull, mandateRenew, mandateReplace, mandateRevoke, mandateStatus, mandateVerify, parseAddress, parseCents, parseCount, parsePayee, parseTxHash, parsePaymentId, parsePullAmount, } from "./commands/mandate.js";
9
10
  import { parseHeaders, parseMax, pay, payUnderMandate } from "./commands/pay.js";
10
11
  import { status } from "./commands/status.js";
11
12
  import { helpFor } from "./help.js";
12
- import { DEFAULT_KEY_PATH, agentKeyPath, gasKeyPath, issuerKeyPath, publicKeyPath, readKey } from "./keystore.js";
13
+ import { defaultKeyPath, issuerKeyPath, publicKeyPath, readKey } from "./keystore.js";
13
14
  import { mcpAuthority, serveMcp } from "./mcp.js";
14
15
  const print = (line) => console.log(line);
16
+ /** `mandate pull`'s exit code per outcome: a wrapper that retries on `pending` alone sends the same pull, and never pays a `held` one into escrow again. */
17
+ const PULL_EXIT = { paid: 0, refused: 1, pending: 3, held: 4 };
15
18
  async function main(argv) {
16
19
  const { values, positionals } = parseArgs({
17
20
  args: argv,
@@ -20,6 +23,7 @@ async function main(argv) {
20
23
  network: { type: "string" },
21
24
  "rpc-url": { type: "string" },
22
25
  "faucet-url": { type: "string" },
26
+ "facilitator-url": { type: "string" },
23
27
  key: { type: "string" },
24
28
  factory: { type: "string" },
25
29
  "signer-key": { type: "string", multiple: true },
@@ -33,23 +37,34 @@ async function main(argv) {
33
37
  credential: { type: "string" },
34
38
  replaces: { type: "string" },
35
39
  issuer: { type: "string", multiple: true },
36
- "agent-key": { type: "string" },
40
+ "mandate-key-file": { type: "string" },
37
41
  "issuer-key": { type: "string" },
38
42
  principal: { type: "string" },
39
43
  payee: { type: "string", multiple: true },
40
44
  cap: { type: "string" },
41
45
  budget: { type: "string" },
42
46
  "expires-in": { type: "string" },
47
+ "starts-in": { type: "string" },
43
48
  "max-occurrences": { type: "string" },
44
49
  tx: { type: "string" },
45
50
  "signed-offer": { type: "string" },
46
51
  description: { type: "string" },
47
52
  to: { type: "string" },
53
+ "payment-id": { type: "string" },
48
54
  "record-refusal": { type: "boolean" },
49
55
  help: { type: "boolean", short: "h" },
56
+ version: { type: "boolean" },
50
57
  },
51
58
  });
59
+ if (values.version) {
60
+ print(JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version);
61
+ return 0;
62
+ }
52
63
  const [command, sub] = positionals;
64
+ const extra = positionals.slice(argumentCount(command, sub));
65
+ if (extra.length > 0) {
66
+ throw new Error(`unexpected argument${extra.length > 1 ? "s" : ""} ${extra.map((a) => JSON.stringify(a)).join(" ")}; quote a value that holds a space`);
67
+ }
53
68
  if (values.help || command === undefined || command === "help") {
54
69
  print(helpFor(command === "help" ? positionals.slice(1) : positionals.slice(0, 3)));
55
70
  return command === undefined && !values.help ? 2 : 0;
@@ -57,6 +72,7 @@ async function main(argv) {
57
72
  const on = () => target(values.network, {
58
73
  rpcUrl: values["rpc-url"],
59
74
  faucetUrl: values["faucet-url"],
75
+ facilitatorUrl: values["facilitator-url"],
60
76
  });
61
77
  switch (command) {
62
78
  case "key":
@@ -74,21 +90,23 @@ async function main(argv) {
74
90
  const request = { method: values.method, body: values.body, headers: parseHeaders(values.header, "--header") };
75
91
  if (values.mandate !== undefined) {
76
92
  if (values.max !== undefined || values.key !== undefined) {
77
- throw new Error("pay --mandate takes neither --max nor --key: the mandate sets the limit, and the agent's key is --agent-key");
93
+ throw new Error("pay --mandate takes neither --max nor --key: the mandate sets the limit, and the agent signs with its mandate key");
78
94
  }
79
- const payer = { credentialPath: values.mandate, agentKeyPath: values["agent-key"] ?? agentKeyPath(values.mandate) };
95
+ const payer = { credentialPath: values.mandate, mandateKeyFile: values["mandate-key-file"] };
80
96
  await payUnderMandate(on(), payer, sub, print, request);
81
97
  return 0;
82
98
  }
99
+ if (values["mandate-key-file"] !== undefined)
100
+ throw new Error("--mandate-key-file goes with --mandate");
83
101
  const limit = { maxAmount: parseMax(values.max, "--max"), raise: "pass --max to allow it" };
84
- await pay(on(), await readKey(values.key ?? DEFAULT_KEY_PATH), sub, limit, print, request);
102
+ await pay(on(), await readKey(values.key ?? defaultKeyPath()), sub, limit, print, request);
85
103
  return 0;
86
104
  }
87
105
  case "account": {
88
106
  if (sub !== "create")
89
107
  throw new Error(`unknown account subcommand "${sub ?? ""}"; try: account create`);
90
108
  if ((values.key === undefined) === (values.out === undefined))
91
- throw new Error("account create needs --out, the owner key file to create, or --key, one that exists");
109
+ throw new Error("account create needs --out, the owner key file, created when it does not exist, or --key, one that exists");
92
110
  const factory = accountFactoryFor(on().network, values.factory === undefined ? undefined : parseAddress(values.factory, "--factory"), "pass --factory");
93
111
  await accountCreate(on(), { accountFactory: factory, ...(values.key === undefined ? { out: values.out } : { keyPath: values.key }) }, print);
94
112
  return 0;
@@ -116,10 +134,11 @@ async function main(argv) {
116
134
  cap: parseCents(values.cap, "--cap"),
117
135
  budget: parseCents(values.budget, "--budget"),
118
136
  expiresIn: parseCount(values["expires-in"], "--expires-in") ?? 30 * 86_400,
137
+ startsIn: parseCount(values["starts-in"], "--starts-in"),
119
138
  maxOccurrences: parseCount(values["max-occurrences"], "--max-occurrences"),
120
139
  description: values.description ?? "A mandate issued by metal-agent",
121
140
  out: need(values.out, "--out"),
122
- agentKeyPath: values["agent-key"],
141
+ mandateKeyFile: values["mandate-key-file"],
123
142
  // The keys that sign the owner's credentials are as private as the owner's key: they live beside it.
124
143
  issuerKeyPath: values["issuer-key"] ?? issuerKeyPath(need(values.key, "--key, the account owner's key file, beside which the keys that sign its credentials live, or --issuer-key")),
125
144
  }, print);
@@ -145,17 +164,16 @@ async function main(argv) {
145
164
  await mandateStatus(on(), need(values.credential, "--credential"), accountFactory(), print);
146
165
  }
147
166
  else if (sub === "pull") {
148
- const credentialPath = need(values.credential, "--credential");
149
- const agentKey = values["agent-key"] ?? agentKeyPath(credentialPath);
150
167
  const args = {
151
- credentialPath,
152
- agentKeyPath: agentKey,
168
+ credentialPath: need(values.credential, "--credential"),
169
+ mandateKeyFile: values["mandate-key-file"],
153
170
  to: parseAddress(values.to, "--to"),
154
171
  amount: parsePullAmount(values.amount, "--amount"),
155
- keyPath: values.key ?? gasKeyPath(agentKey),
172
+ paymentId: parsePaymentId(values["payment-id"], "--payment-id"),
173
+ keyPath: values.key,
156
174
  recordRefusal: values["record-refusal"] === true,
157
175
  };
158
- return (await mandatePull(on(), args, print)) === "paid" ? 0 : 1;
176
+ return PULL_EXIT[await mandatePull(on(), args, print)];
159
177
  }
160
178
  else if (sub === "verify") {
161
179
  const tx = values.tx === undefined ? undefined : parseTxHash(values.tx, "--tx");
@@ -181,6 +199,14 @@ async function main(argv) {
181
199
  throw new Error(`unknown command "${command}"\n\n${helpFor([])}`);
182
200
  }
183
201
  }
202
+ /** How many words a command takes before its options: `key import <file|hex>` and `mandate credential create` three, `mcp` one, the other commands two; any number for `help` and for a command the dispatch then names unknown. */
203
+ function argumentCount(command, sub) {
204
+ if ((command === "key" && sub === "import") || (command === "mandate" && sub === "credential"))
205
+ return 3;
206
+ if (command === "mcp")
207
+ return 1;
208
+ return ["key", "fund", "pay", "account", "mandate", "status"].includes(command ?? "") ? 2 : Infinity;
209
+ }
184
210
  main(process.argv.slice(2)).then((code) => process.exit(code), (error) => {
185
211
  // An endpoint's URL can carry its credential, and this output often lands in an agent's transcript.
186
212
  console.error(`error: ${redactUrls(error instanceof Error ? error.message : String(error))}`);
package/dist/keystore.js CHANGED
@@ -1,29 +1,33 @@
1
- import { createECDH } from "node:crypto";
2
- import { access, chmod, mkdir, readFile, writeFile } from "node:fs/promises";
1
+ import { createECDH, randomUUID } from "node:crypto";
2
+ import { access, chmod, link, mkdir, readFile, rm, writeFile } from "node:fs/promises";
3
3
  import { homedir } from "node:os";
4
- import { dirname, extname, join } from "node:path";
4
+ import { dirname, extname, isAbsolute, join } from "node:path";
5
5
  import { createEs256Key, jwkThumbprint, parseP256Jwk } from "@metalntwx/metal-pay/mandates";
6
6
  import { isHex } from "viem";
7
7
  import { generatePrivateKey, privateKeyToAccount } from "viem/accounts";
8
8
  export function isPrivateKey(value) {
9
9
  return typeof value === "string" && isHex(value) && value.length === 66;
10
10
  }
11
- export const DEFAULT_KEY_PATH = join(homedir(), ".metal-agent", "key.json");
11
+ /** `~/.metal-agent`, where metal-agent keeps its own files. An empty HOME names no directory, and they would land under whichever one the command runs in. */
12
+ function agentHome() {
13
+ const home = homedir();
14
+ if (!isAbsolute(home))
15
+ throw new Error(`HOME is ${home === "" ? "empty" : `${JSON.stringify(home)}, not an absolute path`}; set it to your home directory, where metal-agent keeps its key and its pulls`);
16
+ return join(home, ".metal-agent");
17
+ }
18
+ /** The agent's key file when no option names one: `~/.metal-agent/key.json`. */
19
+ export const defaultKeyPath = () => join(agentHome(), "key.json");
12
20
  /** `foo.json` to `foo.<suffix>.json`: a file that belongs beside another. */
13
21
  function beside(path, suffix) {
14
22
  const ext = extname(path);
15
23
  return `${path.slice(0, path.length - ext.length)}.${suffix}${ext}`;
16
24
  }
17
- /** The P-256 key an agent signs payments with under the mandate at `credentialPath`: `mandate.json` to `mandate.agent-key.json`. */
18
- export const agentKeyPath = (credentialPath) => beside(credentialPath, "agent-key");
25
+ /** The mandate key `credential create` writes beside the credential at `credentialPath`: `mandate.json` to `mandate.key.json`. */
26
+ export const mandateKeyPath = (credentialPath) => beside(credentialPath, "key");
27
+ /** The directory of the pulls `mandate pull` sends under the mandate `reference`, one directory per payment: `~/.metal-agent/pulls/<reference>`. */
28
+ export const pendingPullsPath = (reference) => join(agentHome(), "pulls", reference);
19
29
  /** The keys that sign the owner's credentials, beside the owner's key file and as private as it: `owner-key.json` to `owner-key.issuer.json`. */
20
30
  export const issuerKeyPath = (ownerKeyPath) => beside(ownerKeyPath, "issuer");
21
- /** The funded key that submits the agent's direct pulls and pays their gas, beside its mandate key: `mandate.agent-key.json` to `mandate.gas-key.json`, `shared.json` to `shared.gas-key.json`. */
22
- export function gasKeyPath(agentKeyPath) {
23
- const ext = extname(agentKeyPath);
24
- const stem = agentKeyPath.slice(0, agentKeyPath.length - ext.length);
25
- return `${stem.endsWith(".agent-key") ? stem.slice(0, -".agent-key".length) : stem}.gas-key${ext}`;
26
- }
27
31
  /** The secp256k1 key at `path`, generated and written there first when the file does not exist. */
28
32
  export async function readOrCreateKey(path) {
29
33
  if (await exists(path))
@@ -33,14 +37,22 @@ export async function readOrCreateKey(path) {
33
37
  await writeKey(path, { address, privateKey });
34
38
  return { address, created: true };
35
39
  }
40
+ /** Writes a key file that does not exist: whole, beside it, then linked into place, so a failed write leaves no part-written key for a later run to read. */
36
41
  export async function writeKey(path, key) {
37
42
  await mkdir(dirname(path), { recursive: true, mode: 0o700 });
38
- await writeFile(path, `${JSON.stringify(key, null, 2)}\n`, { mode: 0o600, flag: "wx" }).catch((error) => {
39
- if (error.code === "EEXIST")
40
- throw new Error(`${path} already holds a key; pass --out <file> to write another`);
41
- throw error;
42
- });
43
- await chmod(path, 0o600);
43
+ const draft = `${path}.${randomUUID()}.tmp`;
44
+ try {
45
+ await writeFile(draft, `${JSON.stringify(key, null, 2)}\n`, { mode: 0o600, flag: "wx" });
46
+ await chmod(draft, 0o600);
47
+ await link(draft, path).catch((error) => {
48
+ if (error.code === "EEXIST")
49
+ throw new Error(`${path} already holds a key; pass --out <file> to write another`);
50
+ throw error;
51
+ });
52
+ }
53
+ finally {
54
+ await rm(draft, { force: true });
55
+ }
44
56
  }
45
57
  export async function readKey(path) {
46
58
  const parsed = JSON.parse(await readFile(path, "utf8"));