@metalntwx/metal-agent 0.1.1 → 0.2.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.
package/README.md CHANGED
@@ -1,8 +1,8 @@
1
1
  # @metalntwx/metal-agent
2
2
 
3
3
  The `metal-agent` CLI an agent pays x402 payees on Metal with: create or
4
- import a key, fund it, pay a URL, look up a settlement, or serve the same
5
- commands as MCP tools.
4
+ import a key, fund it, pay a URL, look up a settlement, or serve `pay`,
5
+ `fund` and `status` as MCP tools.
6
6
 
7
7
  ```bash
8
8
  npm install -g @metalntwx/metal-agent
@@ -27,8 +27,9 @@ explorer URLs.
27
27
  `METAL_RPC_URL` and `METAL_FAUCET_URL` override those defaults, and
28
28
  `--rpc-url` and `--faucet-url` override both.
29
29
 
30
- On testnet, `pay` and `fund` need no RPC. `status --network testnet` needs
31
- an endpoint of your own: sign up in the Metal console,
30
+ On testnet, `pay` and `fund` need no RPC. `status`, `account create` and
31
+ every `mandate` command but `credential create` need an endpoint of your
32
+ own: sign up in the Metal console,
32
33
  https://console-testnet.metalntwx.com, create an endpoint, and pass it with
33
34
  `--rpc-url` or `METAL_RPC_URL`. On testnet, `pay` and `fund` print
34
35
  an `explorer` line with the transaction's page on
@@ -48,7 +49,8 @@ which the devnet funds at genesis.
48
49
 
49
50
  `pay` refuses, before it signs, an offer in any token but `*USDZ` and any
50
51
  offer above `--max`, 1 `*USDZ` per payment by default. A refusal at
51
- verification prints `payment refused: <reason>`.
52
+ verification, by the facilitator or the seller's own check, prints
53
+ `payment refused: <reason>`.
52
54
  A refusal at settlement prints `payment refused at settlement: <reason>`,
53
55
  with the transaction hash when there is one.
54
56
 
@@ -77,28 +79,33 @@ for the mandate's whole life, an expiry and, when `--payee` names any, the
77
79
  addresses it may pay; with none named it may pay any address.
78
80
 
79
81
  ```bash
80
- metal-agent mandate credential create --principal <MetalAccount address> [--payee <address>]... \
81
- --cap 5 --budget 10 --out ./mandate.json --network testnet
82
+ metal-agent mandate credential create --principal <MetalAccount address> [--payee <address>[,<name>[,<website>]]]... \
83
+ --cap 5 --budget 10 [--description <text>] --key <owner key file> --out ./mandate.json --network testnet
82
84
  metal-agent mandate bind --credential ./mandate.json --key <owner key file> --network testnet --rpc-url <endpoint>
83
85
  metal-agent pay <url> --mandate ./mandate.json --network testnet
84
86
  metal-agent mandate status --credential ./mandate.json --network testnet --rpc-url <endpoint>
87
+ metal-agent mandate pull --credential ./mandate.json --to <address> --amount 2.50 --network testnet --rpc-url <endpoint>
88
+ metal-agent mandate verify --credential ./mandate.json --issuer <issuer public key file> --network testnet --rpc-url <endpoint>
89
+ metal-agent mandate verify --credential ./mandate.json --issuer <issuer public key file> --tx <hash> --signed-offer <text> --network testnet --rpc-url <endpoint>
85
90
  metal-agent mandate replace --credential ./wider.json --replaces ./mandate.json --key <owner key file> --network testnet --rpc-url <endpoint>
91
+ metal-agent mandate renew --credential ./next.json --replaces ./mandate.json --key <owner key file> --network testnet --rpc-url <endpoint>
86
92
  metal-agent mandate revoke --credential ./wider.json --key <owner key file> --network testnet --rpc-url <endpoint>
87
- metal-agent mandate pull --credential ./mandate.json --to <address> --amount 2.50 --network testnet --rpc-url <endpoint>
88
- metal-agent mandate verify --credential ./mandate.json --network testnet --rpc-url <endpoint>
89
- metal-agent mandate verify --credential ./mandate.json --tx <hash> --signed-offer <text> --network testnet --rpc-url <endpoint>
90
93
  ```
91
94
 
92
95
  `credential create` writes the limits into a credential file, a Verifiable
93
96
  Intent mandate, and the agent's keys beside it: its mandate key in
94
97
  `mandate.agent-key.json`, and in `mandate.gas-key.json` the key that pays
95
98
  for its pulls. `--agent-key <file>` names a mandate key the agent already
96
- has instead, with its gas key beside that. The issuer keys that sign the
97
- credential are created on first use (`~/.metal-agent/issuer.json`, with its
98
- public half beside it in `issuer.public.json`). Every other command takes
99
+ has instead, with its gas key beside that. The keys that sign the
100
+ credential are the owner's: they live beside the owner's key file, created
101
+ on first use (`owner-key.issuer.json`, with its public half in
102
+ `owner-key.issuer.public.json`), unless `--issuer-key` names their file.
103
+ `--description` says what the mandate is for, and `--payee` takes a name
104
+ and a website after the address. Every other command takes
99
105
  that credential file: the owner's account, the mandate's reference, its
100
- terms and the agent's key files derive from it. Amounts are whole cents; a
101
- mandate lasts 30 days unless `--expires-in` says otherwise.
106
+ terms and the agent's key files derive from it. Amounts are in `*USDZ`, to the cent; a
107
+ mandate lasts 30 days unless `--expires-in` says otherwise, and its budget
108
+ never resets.
102
109
 
103
110
  `account create --out <owner key file>` writes the owner's key and names the
104
111
  owner's Metal account, a smart account that key controls with threshold one,
@@ -106,8 +113,7 @@ which the owner's first `bind` creates on chain; `--key <file>` uses a key
106
113
  that exists. Fund the account and name it as the credential's `--principal`.
107
114
  The account factory is the one `@metalntwx/metal-pay` names for the network;
108
115
  on a devnet `just deploy` deploys it there, and `--factory <address>` names
109
- another. The testnet's factory is redeployed with each reset, and
110
- `@metalntwx/metal-pay` names the current one.
116
+ another. `@metalntwx/metal-pay` names the testnet's.
111
117
 
112
118
  `bind`, `replace` and `revoke` execute self-calls through the account's weighted
113
119
  quorum. `--key` names the gas sponsor and defaults as the sole signer; repeat
@@ -115,30 +121,38 @@ quorum. `--key` names the gas sponsor and defaults as the sole signer; repeat
115
121
  The SDK's `authorizeMandateCall` accepts a quorum-signing callback for mixed
116
122
  keys and passkeys. Keep owner keys outside the agent's reach: quorum-approved
117
123
  transfers remain independent of mandates. `replace` revokes the mandate
118
- `--replaces` names and carries its spend into the new one.
124
+ `--replaces` names and carries its spend into the new one; `renew` binds the
125
+ new one and revokes the old in one batch, starting its spend at nothing, which
126
+ is how a budget starts over.
127
+ `bind`, `replace` and `renew` trust the issuer beside `--key` unless
128
+ `--issuer` names others.
119
129
 
120
130
  Verification trusts that factory, never one a payer names: it checks the
121
- account's immutable clone code against the factory's implementation at the
122
- binding and payment blocks, and checks successful token-transfer receipts.
131
+ account's immutable clone code against the factory's current
132
+ implementation, and checks successful token-transfer receipts.
123
133
 
124
134
  `pay --mandate <credential>` pays from the owner's account, signed by the
125
135
  agent's mandate key, and takes neither `--key` nor `--max`. A payment
126
- outside the mandate is refused at the facilitator's simulation, which names
127
- the limit: `payment refused: mandate_cap_exceeded (…)`. `pay` prints the
136
+ outside the mandate is refused and moves nothing, usually with a
137
+ `mandate_*` reason: `payment refused: mandate_cap_exceeded (…)`. `pay` prints the
128
138
  payment's `nonce` as soon as it signs; the facilitator's
129
139
  `/settlements?payTo=<payee>` lists a settled payment by an id ending in it.
130
140
 
131
141
  `mandate pull` pays an address the mandate allows directly, with no seller:
132
142
  the agent's mandate key signs, and the `--key` account, the agent's gas key
133
143
  by default, submits the transaction and pays its gas, so fund it with a
134
- little `*USDZ`. A pull outside the mandate is recorded on chain as a
135
- refusal, in a transaction that succeeds and moves nothing, and exits 1.
144
+ little `*USDZ`. A pull the mandate refuses when checked is not sent: it reports the
145
+ reason and exits 1. `--record-refusal` sends it, so the refusal is recorded
146
+ on chain in a transaction that succeeds and moves nothing. A pull prints
147
+ `transaction <hash>` as soon as it is sent; if its receipt never arrives,
148
+ check that transaction with `metal-agent status <hash>` before pulling again.
136
149
 
137
150
  `mandate verify` checks the mandate and every payment under it from the
138
151
  chain alone, through any RPC endpoint: the binding on the owner's account,
139
152
  the credential against it and under its issuer, and each payment and
140
153
  refusal as the agent key's own signed per-payment mandate, rebuilt from its
141
- transaction. Anyone holding the credential can run it; `--tx <hash>`
154
+ transaction. Anyone holding the credential and the issuer's public file,
155
+ passed as `--issuer`, can run it; `--tx <hash>`
142
156
  verifies one transaction, with `--signed-offer <text>` the offer the agent
143
157
  signed for it, and a check that fails exits 1. A seller that asks for the
144
158
  agent's signed order (`requireSignedOffer` in `@metalntwx/metal-pay/mandates`,
@@ -149,17 +163,25 @@ the same, the latter for a payee that holds no credential.
149
163
  `skills/metal-agent/SKILL.md` is an installable skill: the setup steps for
150
164
  an agent, then the commands.
151
165
 
152
- `metal-agent mcp` serves `pay`, `fund` and `status` over stdio,
153
- each taking a `network`, and `pay` takes the same limit as an optional
154
- `max`, the request as optional `method`, `body` and `headers`, and a
155
- credential file as an optional `mandate`, with its `agentKey`. Keys
156
- enter only through the CLI, and so do the mandate commands: the server has
157
- no tool that sets or changes a mandate. An MCP host starts it with:
166
+ `metal-agent mcp` serves `pay`, `fund` and `status` over stdio, each taking
167
+ a `network`, and `pay` the request as optional `method`, `body` and
168
+ `headers`. What `pay` spends is fixed when the server starts: its key and
169
+ `--max` (the default key file and 1 `*USDZ` when omitted), or one mandate
170
+ with `--mandate <credential>`. The model calling the tools chooses no key,
171
+ no mandate and no higher limit; under a key, a `max` it passes can only
172
+ lower the server's. The mandate commands enter only through the CLI. An MCP host
173
+ starts it with:
158
174
 
159
175
  ```json
160
176
  { "command": "npx", "args": ["-y", "@metalntwx/metal-agent", "mcp"] }
161
177
  ```
162
178
 
179
+ or, under one mandate:
180
+
181
+ ```json
182
+ { "command": "npx", "args": ["-y", "@metalntwx/metal-agent", "mcp", "--mandate", "/path/to/mandate.json"] }
183
+ ```
184
+
163
185
  ## Develop
164
186
 
165
187
  In the `mx56db/metal` repository, `just agent <command>` runs the CLI from
@@ -6,10 +6,11 @@
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, writeFile } from "node:fs/promises";
10
- 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, pullCall, readMandateState, reasonName, replaceCall, revokeCall, sameAgentKey, sendMandateCall, signL3a, transactionIdOf, trustedIssuer, verifyMandate, verifyMandatePayment, } from "@metalntwx/metal-pay/mandates";
9
+ import { readFile, readdir, rm, writeFile } from "node:fs/promises";
10
+ 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";
11
12
  import { USDZ, formatUsdz, usdzBaseUnits } from "@metalntwx/metal-pay";
12
- import { BaseError, createWalletClient, custom, decodeErrorResult, encodeFunctionData, encodeAbiParameters, parseAbiParameters, getAddress, isAddress, isHash, parseEventLogs, slice, } from "viem";
13
+ import { BaseError, HttpRequestError, TimeoutError, createWalletClient, custom, decodeErrorResult, encodeFunctionData, encodeAbiParameters, parseAbiParameters, getAddress, isAddress, isAddressEqual, isHash, parseEventLogs, slice, } from "viem";
13
14
  import { rpcClient } from "../chain.js";
14
15
  import { agentKeyPath, exists, gasKeyPath, publicKeyPath, readKey, readOrCreateIssuerKeys, readOrCreateKey, readOrCreateP256Key, readP256Key, readTrustedIssuer, writePublicKeyFile, } from "../keystore.js";
15
16
  export async function loadMandate(credentialPath) {
@@ -17,7 +18,7 @@ export async function loadMandate(credentialPath) {
17
18
  const terms = extractTerms(credential);
18
19
  return { credential, terms, reference: mandateReference(terms.principal, terms.agentKey, hashCredential(credential)) };
19
20
  }
20
- /** A decimal `*USDZ` amount in whole cents, as a credential states its cap and budget. */
21
+ /** A decimal `*USDZ` amount to the cent, as a credential states its cap and budget. */
21
22
  export function parseCents(text, option) {
22
23
  if (text === undefined || !/^\d+(\.\d{1,2})?$/.test(text) || Number(text) <= 0) {
23
24
  throw new Error(`${option} must be a positive ${USDZ.symbol} amount with at most 2 decimals; got "${text ?? ""}"`);
@@ -29,6 +30,19 @@ export function parseAddress(text, option) {
29
30
  throw new Error(`${option} must be an address; got "${text ?? ""}"`);
30
31
  return getAddress(text);
31
32
  }
33
+ /** `<address>[,<name>[,<website>]]`, as `--payee` takes it. */
34
+ export function parsePayee(text, option) {
35
+ const [address, name, website, ...rest] = (text ?? "").split(",").map((part) => part.trim());
36
+ if (address === undefined || !isAddress(address))
37
+ throw new Error(`${option} must start with an address, then optionally a name and a website, comma-separated; got "${text ?? ""}"`);
38
+ if (rest.length > 0)
39
+ throw new Error(`${option} takes at most an address, a name and a website; got "${text ?? ""}"`);
40
+ if (name === "")
41
+ throw new Error(`${option}'s name is empty; leave it out to show the address; got "${text ?? ""}"`);
42
+ if (website !== undefined && !/^https?:\/\/[^\s]+$/.test(website))
43
+ throw new Error(`${option}'s website must be an http or https URL; got "${website}"`);
44
+ return { address: getAddress(address), ...(name !== undefined && { name }), ...(website !== undefined && { website }) };
45
+ }
32
46
  export function parseTxHash(text, option) {
33
47
  if (!isHash(text))
34
48
  throw new Error(`${option} must be a transaction hash; got "${text}"`);
@@ -46,7 +60,11 @@ export function parseCount(text, option) {
46
60
  const usdz = (units) => `${formatUsdz(units)} ${USDZ.symbol}`;
47
61
  const utc = (seconds) => new Date(Number(seconds) * 1000).toISOString();
48
62
  const day = (seconds) => new Date(seconds * 1000).toISOString().slice(0, 10);
49
- function printTerms({ principal, agentKey, constraints: c, terms }, print) {
63
+ 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
+ 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, "?")}`);
50
68
  print(`principal ${principal}`);
51
69
  print(`agent-key ${encodeAgentKey(agentKey)} (P-256)`);
52
70
  print(`token ${c.token}`);
@@ -57,8 +75,13 @@ function printTerms({ principal, agentKey, constraints: c, terms }, print) {
57
75
  print(`max-occurrences ${c.maxOccurrences}`);
58
76
  if (terms.payees.length === 0)
59
77
  print("payees any");
60
- for (const payee of terms.payees)
61
- print(`payee ${payee.account}`);
78
+ const shown = Array.isArray(descriptive.payees) ? descriptive.payees : [];
79
+ for (const payee of terms.payees) {
80
+ const object = shown.find((o) => typeof o.id === "string" && o.id.toLowerCase() === payee.account.toLowerCase());
81
+ const name = typeof object?.name === "string" && object.name.toLowerCase() !== payee.account.toLowerCase() ? ` ${object.name}` : "";
82
+ 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, "?"));
84
+ }
62
85
  }
63
86
  function walletFor(publicClient, account) {
64
87
  if (!publicClient.chain)
@@ -99,34 +122,46 @@ export async function credentialCreate(target, args, print) {
99
122
  const agent = await readOrCreateP256Key(agentPath);
100
123
  const gasPath = gasKeyPath(agentPath);
101
124
  const gas = await readOrCreateKey(gasPath);
102
- const { keys, created } = await readOrCreateIssuerKeys(args.issuerKeyPath);
125
+ const { keys, created } = await readOrCreateIssuerKeys(args.issuerKeyPath).catch(async (error) => {
126
+ await Promise.all([agent.created && rm(agentPath), gas.created && rm(gasPath)]);
127
+ throw error;
128
+ });
103
129
  const now = Math.floor(Date.now() / 1000);
104
130
  const expiresAt = now + args.expiresIn;
105
- const credential = await createCredential({
106
- issuer: keys.issuer,
107
- principal: args.principal,
108
- principalKey: keys.principalKey,
109
- agentKey: agent.key.publicJwk,
110
- perPaymentCap: args.cap,
111
- budget: args.budget,
112
- payees: args.payees.map((address) => ({ address, name: address, website: `eip155:${target.network.chainId}:${address}` })),
113
- recurrence: {
114
- frequency: "ADHO",
115
- startDate: day(now),
116
- endDate: day(expiresAt),
117
- ...(args.maxOccurrences !== undefined && { maxOccurrences: args.maxOccurrences }),
118
- },
119
- issuedAt: now,
120
- expiresAt,
121
- description: args.description,
122
- });
123
- const ingested = await ingestCredential(credential, { trustedIssuers: [trustedIssuer(keys.issuer)], account: args.principal, now });
131
+ let credential;
132
+ let ingested;
133
+ try {
134
+ credential = await createCredential({
135
+ issuer: keys.issuer,
136
+ principal: args.principal,
137
+ principalKey: keys.principalKey,
138
+ agentKey: agent.key.publicJwk,
139
+ perPaymentCap: args.cap,
140
+ budget: args.budget,
141
+ payees: args.payees.map(({ address, name, website }) => ({ address, name: name ?? address, website: website ?? `eip155:${target.network.chainId}:${address}` })),
142
+ recurrence: {
143
+ frequency: "ADHO",
144
+ startDate: day(now),
145
+ endDate: day(expiresAt),
146
+ ...(args.maxOccurrences !== undefined && { maxOccurrences: args.maxOccurrences }),
147
+ },
148
+ issuedAt: now,
149
+ expiresAt,
150
+ description: args.description,
151
+ });
152
+ ingested = await ingestCredential(credential, { trustedIssuers: [trustedIssuer(keys.issuer)], account: args.principal, now });
153
+ }
154
+ catch (error) {
155
+ // 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)]);
157
+ throw error;
158
+ }
124
159
  await writeFile(args.out, `${JSON.stringify(credential, null, 2)}\n`, { flag: "wx" });
125
160
  const issuerPublicPath = publicKeyPath(args.issuerKeyPath);
126
161
  await writePublicKeyFile(issuerPublicPath, trustedIssuer(keys.issuer));
127
162
  print(`credential ${args.out}`);
128
163
  print(`reference ${ingested.reference}`);
129
- printTerms(ingested, print);
164
+ printTerms(ingested.extracted, print);
130
165
  print(`agent-key-file ${agentPath}${agent.created ? " (created)" : ""}`);
131
166
  print(`gas-key-file ${gasPath}${gas.created ? " (created)" : ""}`);
132
167
  print(`gas-key-address ${gas.address}`);
@@ -173,6 +208,9 @@ export async function accountCreate(target, args, print) {
173
208
  print(`keyfile ${keyPath}${created ? " (created)" : ""}`);
174
209
  }
175
210
  async function sendOwnerCall(client, args, call, createAccount = false) {
211
+ const account = (Array.isArray(call) ? call[0] : call)?.to;
212
+ if (account === undefined)
213
+ throw new Error("no account call to send");
176
214
  const sponsor = await readKey(args.keyPath);
177
215
  const signers = await Promise.all((args.signerPaths.length === 0 ? [args.keyPath] : args.signerPaths).map(readKey));
178
216
  signers.sort((a, b) => a.address.toLowerCase().localeCompare(b.address.toLowerCase()));
@@ -181,14 +219,14 @@ async function sendOwnerCall(client, args, call, createAccount = false) {
181
219
  // A one-owner account is created by its owner's first bind; the sponsor pays.
182
220
  // One with several signers is created by the factory beforehand, with its
183
221
  // signers, weights and threshold, which these key files alone do not name.
184
- const code = createAccount ? await client.getCode({ address: call.to }) : "0x01";
222
+ const code = createAccount ? await client.getCode({ address: account }) : "0x01";
185
223
  if ((code === undefined || code === "0x") && args.signerPaths.length > 0) {
186
- throw new Error(`${call.to} 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`);
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`);
187
225
  }
188
226
  if (code === undefined || code === "0x") {
189
227
  const { config, address } = await ownerAccount(client, args.accountFactory, sponsor.address);
190
- if (address.toLowerCase() !== call.to.toLowerCase())
191
- throw new Error(`${call.to} is not the account of ${sponsor.address}; it has no code, and only its owner's first bind creates it`);
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`);
192
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 });
193
231
  }
194
232
  const authorized = await authorizeMandateCall(client, args.accountFactory, call, async (digest) => encodeAbiParameters(parseAbiParameters("bytes[], bytes[]"), [signers.map(s => s.address), await Promise.all(signers.map(s => s.sign({ hash: digest })))]));
@@ -201,11 +239,20 @@ export async function mandateBind(target, args, print) {
201
239
  print(`bound ${ingested.reference}`);
202
240
  printSent(target, receipt, print);
203
241
  }
242
+ /** Checks that the mandate a replace or renew ends is bound and not yet revoked, which the account's revert would not say. */
243
+ async function assertLive(publicClient, mandate, path) {
244
+ const state = await publicClient.readContract({ address: mandate.terms.principal, abi: mandateBindingAbi, functionName: "mandates", args: [mandate.reference] });
245
+ if (!isAddressEqual(state.principal, mandate.terms.principal))
246
+ throw new Error(`${path} is not bound on ${mandate.terms.principal}; bind the new credential with mandate bind`);
247
+ if (state.revoked)
248
+ throw new Error(`${path} is already revoked; bind the new credential with mandate bind`);
249
+ }
204
250
  /** Binds the credential as the successor of the mandate `replacesPath` describes, which is revoked in the same transaction; the successor carries its spend. */
205
251
  export async function mandateReplace(target, args, print) {
206
252
  const { ingested } = await ingestFor(args);
207
253
  const predecessor = await loadMandate(args.replacesPath);
208
254
  const publicClient = rpcClient(target);
255
+ await assertLive(publicClient, predecessor, args.replacesPath);
209
256
  const carried = await readMandateState(publicClient, predecessor.terms.principal, predecessor.reference);
210
257
  if (carried.spent >= ingested.constraints.budget)
211
258
  throw new Error(`the replacement's budget of ${usdz(ingested.constraints.budget)} is not above the ${usdz(carried.spent)} already spent, which it carries`);
@@ -218,6 +265,28 @@ export async function mandateReplace(target, args, print) {
218
265
  print(`spent ${usdz(state.spent)} of ${usdz(ingested.constraints.budget)}`);
219
266
  printSent(target, receipt, print);
220
267
  }
268
+ /**
269
+ * Binds the credential as a fresh mandate and revokes the one `replacesPath`
270
+ * describes, in one account batch: the new budget starts at zero spent, as a
271
+ * budget never resets on its own. `replace` carries the spend over instead.
272
+ */
273
+ export async function mandateRenew(target, args, print) {
274
+ const { ingested } = await ingestFor(args);
275
+ const predecessor = await loadMandate(args.replacesPath);
276
+ if (predecessor.reference === ingested.reference)
277
+ throw new Error(`${args.replacesPath} is the mandate being bound; to end it, use mandate revoke`);
278
+ if (predecessor.terms.principal.toLowerCase() !== ingested.principal.toLowerCase()) {
279
+ throw new Error(`${args.replacesPath} is a mandate on ${predecessor.terms.principal}, not on ${ingested.principal}`);
280
+ }
281
+ const publicClient = rpcClient(target);
282
+ await assertLive(publicClient, predecessor, args.replacesPath);
283
+ const receipt = await sendOwnerCall(publicClient, args, [bindCall(ingested), revokeCall(ingested.principal, predecessor.reference)]);
284
+ const state = await readMandateState(publicClient, ingested.principal, ingested.reference);
285
+ print(`revoked ${predecessor.reference}`);
286
+ print(`bound ${ingested.reference}`);
287
+ print(`spent ${usdz(state.spent)} of ${usdz(ingested.constraints.budget)}`);
288
+ printSent(target, receipt, print);
289
+ }
221
290
  export async function mandateRevoke(target, args, print) {
222
291
  const { reference, terms } = await loadMandate(args.credentialPath);
223
292
  const receipt = await sendOwnerCall(rpcClient(target), args, revokeCall(terms.principal, reference));
@@ -278,7 +347,18 @@ export async function mandateVerify(target, args, print) {
278
347
  }
279
348
  /** The agent's P-256 key, checked to be the one the mandate names. */
280
349
  export async function mandateAgentSigner(mandate, agentKeyPath) {
281
- const agent = await readP256Key(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
+ });
282
362
  if (!sameAgentKey(agentKeyOfJwk(agent.publicJwk), mandate.terms.agentKey))
283
363
  throw new Error(`${agentKeyPath} is not the mandate's agent key`);
284
364
  return p256DigestSigner(privateKeyHex(agent));
@@ -301,9 +381,12 @@ function revertName(error) {
301
381
  /**
302
382
  * A direct payment under the mandate, with no seller: the agent signs it and
303
383
  * the `--key` account submits it to `pull` on the principal's account and
304
- * pays the gas. It settles, or Mandate Binding records its refusal with the
305
- * reason, in a transaction that succeeds; the outcome is returned so a
306
- * refusal can fail the command.
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.
307
390
  */
308
391
  export async function mandatePull(target, args, print) {
309
392
  const mandate = await loadMandate(args.credentialPath);
@@ -314,6 +397,20 @@ export async function mandatePull(target, args, print) {
314
397
  throw error;
315
398
  });
316
399
  const { terms, reference } = mandate;
400
+ const publicClient = rpcClient(target);
401
+ // A mandate not yet bound, or before its dates, can stop refusing; these are
402
+ // checked before the agent signs, so a pull reported unsent can never pay later.
403
+ 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))),
405
+ publicClient.getBlock(),
406
+ publicClient.getBalance({ address: sender.address }),
407
+ ]);
408
+ if (bound === undefined)
409
+ 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`);
317
414
  const chainId = target.network.chainId;
318
415
  const now = BigInt(Math.floor(Date.now() / 1000));
319
416
  const signed = await signL3a({
@@ -336,17 +433,39 @@ export async function mandatePull(target, args, print) {
336
433
  iss: terms.terms.kid,
337
434
  signDigest,
338
435
  });
339
- const publicClient = rpcClient(target);
340
436
  const call = pullCall(terms.principal, signed.pull, signed.fields);
341
437
  try {
342
438
  // At the node's own gas ceiling: what the pull costs is sendMandateCall's to estimate.
343
439
  await publicClient.call({ account: sender.address, to: call.to, data: call.data });
344
440
  }
345
441
  catch (error) {
442
+ if (lostInTransport(error))
443
+ throw new Error(`the pull could not be checked: ${publicMessage(error)}`);
346
444
  throw new Error(`Mandate Binding rejects the pull and records nothing: ${revertName(error)}`);
347
445
  }
348
- const receipt = await send(publicClient, sender, call);
349
- const outcome = parseEventLogs({ abi: mandateBindingAbi, logs: receipt.logs, eventName: ["MandatePayment", "MandateRefused"] }).find((log) => log.address.toLowerCase() === terms.principal.toLowerCase() && log.args.l3aDigest === slice(signed.pull.nonce, 12));
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
+ }
457
+ /** Whether the request failed on its way, so the node may or may not have acted on it. */
458
+ function lostInTransport(error) {
459
+ return error instanceof BaseError && error.walk((e) => e instanceof HttpRequestError || e instanceof TimeoutError) !== null;
460
+ }
461
+ function printUnsent(reason, print) {
462
+ print("outcome refused, not sent");
463
+ print(`reason ${describeRefusal(reason)}`);
464
+ print("recorded no");
465
+ return "refused";
466
+ }
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);
350
469
  if (outcome?.eventName === "MandatePayment") {
351
470
  print("outcome paid");
352
471
  print(`spent ${usdz(outcome.args.spent)} of ${usdz(terms.constraints.budget)}`);
@@ -362,12 +481,14 @@ export async function mandatePull(target, args, print) {
362
481
  throw new Error(`transaction ${receipt.transactionHash} carries neither a payment nor a refusal for the pull`);
363
482
  }
364
483
  print(`payer ${terms.principal}`);
365
- print(`payee ${signed.pull.to}`);
366
- print(`amount ${usdz(signed.pull.value)}`);
367
- printSent(target, receipt, print);
484
+ print(`payee ${outcome.args.to}`);
485
+ print(`amount ${usdz(outcome.args.amount)}`);
486
+ if (target.explorerUrl)
487
+ print(`explorer ${target.explorerUrl}/tx/${receipt.transactionHash}`);
488
+ print(`gas ${receipt.gasUsed}`);
368
489
  return outcome.eventName === "MandatePayment" ? "paid" : "refused";
369
490
  }
370
- /** A decimal `*USDZ` amount a mandate can pay: positive, in whole cents. */
491
+ /** A decimal `*USDZ` amount a mandate can pay: positive, to the cent. */
371
492
  export function parsePullAmount(text, option) {
372
493
  return BigInt(usdzBaseUnits(parseCents(text, option)));
373
494
  }
@@ -102,6 +102,9 @@ async function paidRequest(target, { fetchWithPayment, client, payer, raise }, u
102
102
  const how = raise !== undefined && aborted[1].endsWith("limit per payment") ? `; ${raise}` : "";
103
103
  throw new Error(`payment refused before signing: ${aborted[1]}${how}`);
104
104
  }
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
+ }
105
108
  throw error;
106
109
  });
107
110
  const body = await response.text();
@@ -113,14 +116,17 @@ async function paidRequest(target, { fetchWithPayment, client, payer, raise }, u
113
116
  const priced = offer ? `${formatUsdz(offer.amount)} ${USDZ.symbol} on ${offer.network}` : "no offer";
114
117
  throw new Error(`payment refused: ${describeRefusalReason(required.error)} (offer: ${priced})`);
115
118
  }
116
- if (response.status === 402 && paymentResponse) {
117
- throw new Error(`payment refused at settlement: ${settleRefusal(decodePaymentResponseHeader(paymentResponse))}`);
119
+ const settlement = paymentResponse ? decodePaymentResponseHeader(paymentResponse) : undefined;
120
+ if (settlement !== undefined && !settlement.success)
121
+ throw new Error(`payment refused at settlement: ${settleRefusal(settlement)}`);
122
+ // 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}`);
118
127
  }
119
- if (!response.ok)
120
- throw new Error(`${url} answered ${response.status}: ${body}`);
121
128
  print(`status ${response.status}`);
122
- if (paymentResponse) {
123
- const settlement = decodePaymentResponseHeader(paymentResponse);
129
+ if (settlement !== undefined) {
124
130
  print(`payer ${settlement.payer ?? payer}`);
125
131
  if (signed) {
126
132
  print(`payee ${signed.payTo}`);
@@ -131,6 +137,9 @@ async function paidRequest(target, { fetchWithPayment, client, payer, raise }, u
131
137
  if (target.explorerUrl)
132
138
  print(`explorer ${target.explorerUrl}/tx/${settlement.transaction}`);
133
139
  }
140
+ else if (signed !== undefined) {
141
+ print("payment unconfirmed (signed, but the seller returned no settlement; look it up by its nonce)");
142
+ }
134
143
  else {
135
144
  print("payment none (the resource did not ask for one)");
136
145
  }
package/dist/help.js ADDED
@@ -0,0 +1,118 @@
1
+ import { DEFAULT_KEY_PATH } from "./keystore.js";
2
+ const NETWORK = "--network devnet|testnet";
3
+ const COMMANDS = [
4
+ {
5
+ name: "key create",
6
+ 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.`,
8
+ },
9
+ {
10
+ name: "key import",
11
+ usage: "metal-agent key import <file|hex> [--out <file>]",
12
+ about: "Loads a key someone funded for you: a hex key, a file holding one, or a key file. Prints the address, never the key.",
13
+ },
14
+ {
15
+ name: "fund",
16
+ usage: `metal-agent fund <address> --network devnet [--amount <usdz>]\nmetal-agent fund <address> --network testnet`,
17
+ about: "On testnet, asks the public faucet, which sets the amount. On devnet, moves *USDZ from the devnet's pre-funded account, 10 by default.",
18
+ },
19
+ {
20
+ 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.",
23
+ },
24
+ {
25
+ name: "status",
26
+ usage: `metal-agent status <txHash> ${NETWORK} [--rpc-url <url>]`,
27
+ about: "Shows a settlement from chain data: status, block, submitter, gas, and the transfer it carried. On testnet it needs an RPC endpoint of your own.",
28
+ },
29
+ {
30
+ 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.",
33
+ },
34
+ {
35
+ 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.",
38
+ },
39
+ {
40
+ 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.",
43
+ },
44
+ {
45
+ name: "mandate bind",
46
+ 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.",
48
+ },
49
+ {
50
+ name: "mandate replace",
51
+ usage: `metal-agent mandate replace --credential <file> --replaces <file> --key <owner key file> ${NETWORK}\n [--issuer <file>]... [--signer-key <file>]...`,
52
+ about: "Binds the credential in place of the mandate --replaces names, which is revoked in the same transaction. What was spent carries over into the new budget.",
53
+ },
54
+ {
55
+ name: "mandate renew",
56
+ usage: `metal-agent mandate renew --credential <file> --replaces <file> --key <owner key file> ${NETWORK}\n [--issuer <file>]... [--signer-key <file>]...`,
57
+ about: "Binds the credential as a fresh mandate and revokes the one --replaces names, in one transaction. The new budget starts with nothing spent; this is how a budget starts over, since it never resets on its own.",
58
+ },
59
+ {
60
+ name: "mandate revoke",
61
+ usage: `metal-agent mandate revoke --credential <file> --key <owner key file> ${NETWORK} [--signer-key <file>]...`,
62
+ about: "Revokes the mandate: every later payment under it is refused.",
63
+ },
64
+ {
65
+ name: "mandate status",
66
+ usage: `metal-agent mandate status --credential <file> ${NETWORK}`,
67
+ about: "Shows the mandate's limits and where it stands: bound or not, revoked or not, what is spent of the budget and how many payments were made.",
68
+ },
69
+ {
70
+ 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.",
73
+ },
74
+ {
75
+ name: "mandate verify",
76
+ 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.",
78
+ },
79
+ ];
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.
82
+ On testnet, status, account create and the mandate commands other than
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
86
+ not the one metal-agent knows.
87
+
88
+ metal-agent <command> --help explains one command.`;
89
+ /** The whole command list, or the commands the given words name, with what each does. */
90
+ export function helpFor(words) {
91
+ // The longest run of leading words that names a command or a group: arguments after it are not part of the name.
92
+ for (let n = words.length; n > 0; n--) {
93
+ const asked = words.slice(0, n).join(" ");
94
+ const exact = COMMANDS.find((c) => c.name === asked);
95
+ if (exact)
96
+ return `${exact.usage}\n\n${wrap(exact.about)}`;
97
+ const group = COMMANDS.filter((c) => c.name.startsWith(`${asked} `));
98
+ if (group.length > 0)
99
+ return group.map((c) => `${c.usage}\n\n${wrap(c.about)}`).join("\n\n");
100
+ }
101
+ return `metal-agent: pay x402 sellers on Metal, and set spending limits Metal enforces for an agent\n\n${COMMANDS.map((c) => c.usage).join("\n")}\n\n${SHARED}`;
102
+ }
103
+ function wrap(text, width = 78) {
104
+ const lines = [];
105
+ let line = "";
106
+ for (const word of text.split(" ")) {
107
+ if (line !== "" && line.length + 1 + word.length > width) {
108
+ lines.push(line);
109
+ line = word;
110
+ }
111
+ else {
112
+ line = line === "" ? word : `${line} ${word}`;
113
+ }
114
+ }
115
+ if (line !== "")
116
+ lines.push(line);
117
+ return lines.join("\n");
118
+ }
package/dist/index.js CHANGED
@@ -5,82 +5,12 @@ import { redactUrls } from "@metalntwx/metal-pay/mandates";
5
5
  import { target } from "./chain.js";
6
6
  import { fund } from "./commands/fund.js";
7
7
  import { keyCreate, keyImport } from "./commands/key.js";
8
- import { credentialCreate, accountCreate, mandateBind, mandatePull, mandateReplace, mandateRevoke, mandateStatus, mandateVerify, parseAddress, parseCents, parseCount, parseTxHash, parsePullAmount, } from "./commands/mandate.js";
8
+ import { credentialCreate, accountCreate, mandateBind, mandatePull, mandateRenew, mandateReplace, mandateRevoke, mandateStatus, mandateVerify, parseAddress, parseCents, parseCount, parsePayee, parseTxHash, parsePullAmount, } from "./commands/mandate.js";
9
9
  import { parseHeaders, parseMax, pay, payUnderMandate } from "./commands/pay.js";
10
10
  import { status } from "./commands/status.js";
11
- import { DEFAULT_ISSUER_KEY_PATH, DEFAULT_KEY_PATH, agentKeyPath, gasKeyPath, publicKeyPath, readKey } from "./keystore.js";
12
- import { serveMcp } from "./mcp.js";
13
- const USAGE = `metal-agent: pay x402 sellers on Metal
14
-
15
- metal-agent key create [--out <file>]
16
- metal-agent key import <file|hex> [--out <file>]
17
- metal-agent fund <address> --network devnet [--amount <usdz>]
18
- metal-agent fund <address> --network testnet from the public faucet, which sets the amount
19
- metal-agent pay <url> --network devnet|testnet [--key <file>] [--max <usdz>]
20
- [--method <method>] [--body <text>] [--header 'name: value']...
21
- metal-agent pay <url> --mandate <credential> --network devnet|testnet [--agent-key <file>]
22
- metal-agent status <txHash> --network devnet|testnet [--rpc-url <url>]
23
- metal-agent mcp serve pay, fund and status as MCP tools over stdio
24
-
25
- Spending limits an account's owner sets for an agent, which Metal enforces:
26
-
27
- metal-agent account create --out <owner key file to create> | --key <owner key file> --network devnet|testnet
28
-
29
- metal-agent mandate credential create --principal <address> --cap <usdz> --budget <usdz> --out <file>
30
- --network devnet|testnet [--payee <address>]... [--expires-in <seconds>] [--max-occurrences <n>]
31
- [--description <text>] [--agent-key <file>] [--issuer-key <file>]
32
- metal-agent mandate bind --credential <file> --key <owner key file> --network devnet|testnet [--issuer <file>]...
33
- metal-agent mandate replace --credential <file> --replaces <file> --key <owner key file> --network devnet|testnet
34
- [--issuer <file>]...
35
- metal-agent mandate revoke --credential <file> --key <owner key file> --network devnet|testnet
36
- metal-agent mandate status --credential <file> --network devnet|testnet
37
- metal-agent mandate pull --credential <file> --to <address> --amount <usdz> --network devnet|testnet
38
- [--key <file>] [--agent-key <file>]
39
- metal-agent mandate verify --credential <file> --network devnet|testnet [--tx <hash> [--signed-offer <text>]]
40
- [--issuer <file>]...
41
-
42
- Chain commands also take --rpc-url and --faucet-url, which override
43
- METAL_RPC_URL and METAL_FAUCET_URL, which override each network's defaults:
44
- the RPC from @metalntwx/metal-pay, the faucet from the shipped networks.json.
45
- status reads the chain: on devnet the network default applies; on testnet,
46
- sign up in the Metal console, create an endpoint, and pass it.
47
-
48
- pay refuses, before signing, any offer above --max *USDZ (1 by default). It
49
- sends --method (GET by default, POST with a --body), --body (as
50
- application/json unless a --header names a content type) and each --header,
51
- and sends them again with the payment.
52
-
53
- A mandate lets an agent pay from its owner's account (the principal) with a
54
- key of its own, within limits: the most per payment (--cap), a budget for
55
- the mandate's whole life (--budget), an expiry (--expires-in, 30 days by
56
- default) and, when --payee names any, the addresses it may pay; with none
57
- named it may pay any address. credential create writes those into a
58
- credential file, <out>.json, and the agent's keys beside it: its mandate key,
59
- <out>.agent-key.json, and the key that pays for its pulls, <out>.gas-key.json;
60
- --agent-key names a mandate key the agent already has instead, with its gas
61
- key beside that. The issuer keys that sign the credential are created on
62
- first use. account create names the owner's Metal account, a smart account
63
- the owner's key controls, created on chain by the first bind, and its address
64
- is --principal. bind, replace and
65
- revoke are signed by the account's quorum: --key pays gas and is the default
66
- sole signer; repeated --signer-key supplies EOA quorum signers. The account
67
- factory is the network's; --factory names another on any of these commands.
68
- Keep owner keys apart from the agent's keys. pay --mandate
69
- pays from the principal's account, signed by the agent's key; a payment
70
- outside the mandate is refused on chain and the refusal names the limit.
71
- pull makes a payment with no seller, such as moving funds to an allowed
72
- address; the --key account, the agent's gas key by default, submits it and
73
- pays its gas, and a refused pull exits 1. verify checks the mandate and every
74
- payment under it from the chain alone, or one --tx: the binding, the
75
- credential against it and under its issuer, and each payment as the agent
76
- key's own signed per-payment mandate, rebuilt from its transaction; with
77
- --signed-offer, the offer the agent signed for that payment, as pay printed
78
- it, against the payment. A check that fails exits 1. A seller that asks for
79
- the agent's signed order gets it with the payment; pay prints it. Amounts under a mandate are whole cents. The mandate
80
- commands read and write the chain, so on testnet they need an endpoint.
81
-
82
- The key file defaults to ${DEFAULT_KEY_PATH}, the agent's keys to the files
83
- beside its credential, and the issuer keys to ${DEFAULT_ISSUER_KEY_PATH}.`;
11
+ import { helpFor } from "./help.js";
12
+ import { DEFAULT_KEY_PATH, agentKeyPath, gasKeyPath, issuerKeyPath, publicKeyPath, readKey } from "./keystore.js";
13
+ import { mcpAuthority, serveMcp } from "./mcp.js";
84
14
  const print = (line) => console.log(line);
85
15
  async function main(argv) {
86
16
  const { values, positionals } = parseArgs({
@@ -115,12 +45,13 @@ async function main(argv) {
115
45
  "signed-offer": { type: "string" },
116
46
  description: { type: "string" },
117
47
  to: { type: "string" },
48
+ "record-refusal": { type: "boolean" },
118
49
  help: { type: "boolean", short: "h" },
119
50
  },
120
51
  });
121
52
  const [command, sub] = positionals;
122
53
  if (values.help || command === undefined || command === "help") {
123
- print(USAGE);
54
+ print(helpFor(command === "help" ? positionals.slice(1) : positionals.slice(0, 3)));
124
55
  return command === undefined && !values.help ? 2 : 0;
125
56
  }
126
57
  const on = () => target(values.network, {
@@ -167,14 +98,18 @@ async function main(argv) {
167
98
  const OWNER_KEY = "--key, the account owner's key file";
168
99
  const need = (value, option) => {
169
100
  if (value === undefined)
170
- throw new Error(`mandate ${sub ?? ""} needs ${option}`);
101
+ throw new Error(`mandate ${sub === "credential" ? "credential create" : (sub ?? "")} needs ${option}`);
171
102
  return value;
172
103
  };
173
104
  const accountFactory = () => accountFactoryFor(on().network, values.factory === undefined ? undefined : parseAddress(values.factory, "--factory"), "pass --factory");
174
105
  const owner = () => ({ accountFactory: accountFactory(), signerPaths: values["signer-key"] ?? [] });
175
- const issuerPaths = values.issuer ?? [publicKeyPath(DEFAULT_ISSUER_KEY_PATH)];
106
+ // The issuers the owner accepts: theirs, beside their key, unless --issuer names others.
107
+ const ownerIssuers = () => values.issuer ?? [publicKeyPath(issuerKeyPath(need(values.key, OWNER_KEY)))];
176
108
  if (sub === "credential" && positionals[2] === "create") {
177
- const payees = (values.payee ?? []).map((payee) => parseAddress(payee, "--payee"));
109
+ const payees = (values.payee ?? []).map((payee) => parsePayee(payee, "--payee"));
110
+ // The owner's key must be there: a mistyped path would put new issuer keys beside nothing.
111
+ if (values["issuer-key"] === undefined && values.key !== undefined)
112
+ await readKey(values.key);
178
113
  await credentialCreate(on(), {
179
114
  principal: parseAddress(values.principal, "--principal"),
180
115
  payees,
@@ -185,22 +120,23 @@ async function main(argv) {
185
120
  description: values.description ?? "A mandate issued by metal-agent",
186
121
  out: need(values.out, "--out"),
187
122
  agentKeyPath: values["agent-key"],
188
- issuerKeyPath: values["issuer-key"] ?? DEFAULT_ISSUER_KEY_PATH,
123
+ // The keys that sign the owner's credentials are as private as the owner's key: they live beside it.
124
+ 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")),
189
125
  }, print);
190
126
  }
191
127
  else if (sub === "bind") {
192
- const args = { ...owner(), credentialPath: need(values.credential, "--credential"), issuerPaths, keyPath: need(values.key, OWNER_KEY) };
128
+ const args = { ...owner(), credentialPath: need(values.credential, "--credential"), issuerPaths: ownerIssuers(), keyPath: need(values.key, OWNER_KEY) };
193
129
  await mandateBind(on(), args, print);
194
130
  }
195
- else if (sub === "replace") {
131
+ else if (sub === "replace" || sub === "renew") {
196
132
  const args = {
197
133
  credentialPath: need(values.credential, "--credential"),
198
134
  replacesPath: need(values.replaces, "--replaces"),
199
- issuerPaths,
135
+ issuerPaths: ownerIssuers(),
200
136
  ...owner(),
201
137
  keyPath: need(values.key, OWNER_KEY),
202
138
  };
203
- await mandateReplace(on(), args, print);
139
+ await (sub === "replace" ? mandateReplace : mandateRenew)(on(), args, print);
204
140
  }
205
141
  else if (sub === "revoke") {
206
142
  await mandateRevoke(on(), { ...owner(), credentialPath: need(values.credential, "--credential"), keyPath: need(values.key, OWNER_KEY) }, print);
@@ -217,16 +153,20 @@ async function main(argv) {
217
153
  to: parseAddress(values.to, "--to"),
218
154
  amount: parsePullAmount(values.amount, "--amount"),
219
155
  keyPath: values.key ?? gasKeyPath(agentKey),
156
+ recordRefusal: values["record-refusal"] === true,
220
157
  };
221
158
  return (await mandatePull(on(), args, print)) === "paid" ? 0 : 1;
222
159
  }
223
160
  else if (sub === "verify") {
224
161
  const tx = values.tx === undefined ? undefined : parseTxHash(values.tx, "--tx");
162
+ const issuerPaths = values.issuer ?? [];
163
+ if (issuerPaths.length === 0)
164
+ throw new Error("mandate verify needs --issuer: the issuer-public-file credential create printed, or another issuer you trust");
225
165
  const args = { accountFactory: accountFactory(), credentialPath: need(values.credential, "--credential"), issuerPaths, tx, signedOffer: values["signed-offer"] };
226
166
  return (await mandateVerify(on(), args, print)) === "verified" ? 0 : 1;
227
167
  }
228
168
  else {
229
- throw new Error(`unknown mandate subcommand "${sub ?? ""}"; try: credential create, bind, replace, revoke, status, pull, verify`);
169
+ throw new Error(`unknown mandate subcommand "${sub ?? ""}"; try: credential create, bind, replace, renew, revoke, status, pull, verify`);
230
170
  }
231
171
  return 0;
232
172
  }
@@ -234,11 +174,11 @@ async function main(argv) {
234
174
  await status(on(), sub, print);
235
175
  return 0;
236
176
  case "mcp":
237
- await serveMcp();
177
+ await serveMcp(mcpAuthority(values));
238
178
  await new Promise(() => { });
239
179
  return 0;
240
180
  default:
241
- throw new Error(`unknown command "${command}"\n\n${USAGE}`);
181
+ throw new Error(`unknown command "${command}"\n\n${helpFor([])}`);
242
182
  }
243
183
  }
244
184
  main(process.argv.slice(2)).then((code) => process.exit(code), (error) => {
package/dist/keystore.js CHANGED
@@ -9,8 +9,6 @@ export function isPrivateKey(value) {
9
9
  return typeof value === "string" && isHex(value) && value.length === 66;
10
10
  }
11
11
  export const DEFAULT_KEY_PATH = join(homedir(), ".metal-agent", "key.json");
12
- /** The keys that sign a credential `mandate credential create` issues. */
13
- export const DEFAULT_ISSUER_KEY_PATH = join(homedir(), ".metal-agent", "issuer.json");
14
12
  /** `foo.json` to `foo.<suffix>.json`: a file that belongs beside another. */
15
13
  function beside(path, suffix) {
16
14
  const ext = extname(path);
@@ -18,6 +16,8 @@ function beside(path, suffix) {
18
16
  }
19
17
  /** The P-256 key an agent signs payments with under the mandate at `credentialPath`: `mandate.json` to `mandate.agent-key.json`. */
20
18
  export const agentKeyPath = (credentialPath) => beside(credentialPath, "agent-key");
19
+ /** 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
+ export const issuerKeyPath = (ownerKeyPath) => beside(ownerKeyPath, "issuer");
21
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
22
  export function gasKeyPath(agentKeyPath) {
23
23
  const ext = extname(agentKeyPath);
package/dist/mcp.js CHANGED
@@ -6,8 +6,23 @@ import { target } from "./chain.js";
6
6
  import { fund } from "./commands/fund.js";
7
7
  import { parseMax, pay, payUnderMandate } from "./commands/pay.js";
8
8
  import { status } from "./commands/status.js";
9
+ import { loadMandate, mandateAgentSigner } from "./commands/mandate.js";
9
10
  import { DEFAULT_KEY_PATH, agentKeyPath, readKey } from "./keystore.js";
11
+ import { USDZ, formatUsdz } from "@metalntwx/metal-pay";
10
12
  import { redactUrls } from "@metalntwx/metal-pay/mandates";
13
+ /** The authority `metal-agent mcp` serves under, from its options. */
14
+ export function mcpAuthority(options) {
15
+ if (options.mandate !== undefined) {
16
+ if (options.key !== undefined || options.max !== undefined) {
17
+ throw new Error("mcp --mandate takes neither --key nor --max: the mandate sets the limit, and the agent's key is --agent-key");
18
+ }
19
+ return { kind: "mandate", credentialPath: options.mandate, agentKeyPath: options["agent-key"] ?? agentKeyPath(options.mandate) };
20
+ }
21
+ if (options["agent-key"] !== undefined)
22
+ throw new Error("--agent-key goes with --mandate");
23
+ return { kind: "key", keyPath: options.key ?? DEFAULT_KEY_PATH, maxAmount: parseMax(options.max, "--max") };
24
+ }
25
+ const usdz = (units) => `${formatUsdz(units)} ${USDZ.symbol}`;
11
26
  const network = z.enum(["devnet", "testnet"]).describe("Which Metal network; there is no default");
12
27
  const rpcUrl = z
13
28
  .string()
@@ -15,45 +30,46 @@ const rpcUrl = z
15
30
  .optional()
16
31
  .describe("JSON-RPC endpoint; on devnet the network's default when omitted. On testnet, status needs an endpoint of the user's own, created after signing up in the Metal console");
17
32
  const RPC_HOW = "pass rpcUrl or set METAL_RPC_URL in the server's environment";
18
- export function createMcpServer() {
33
+ export function createMcpServer(authority) {
19
34
  const { version } = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
20
35
  const server = new McpServer({ name: "metal-agent", version });
21
- server.registerTool("pay", {
22
- description: "Request a URL and pay its x402 challenge in *USDZ on Metal with the agent's key. Returns the settlement transaction hash, the response headers and the body. With mandate, the payment comes from the mandate's account within the limits its owner set, and a payment outside them is refused on chain with the limit named. To buy Metal RPC, POST a JSON-RPC body to the RPC URL; the rpc-aas-endpoint header is the endpoint the payment bought, so send further RPC calls there without paying again.",
23
- inputSchema: {
24
- url: z.string().url(),
25
- network,
26
- key: z.string().optional().describe(`Agent key file; ${DEFAULT_KEY_PATH} by default`),
27
- max: z
28
- .string()
29
- .optional()
30
- .describe("The most this payment may cost, in decimal *USDZ; 1 by default. A higher offer is refused before signing"),
31
- mandate: z
32
- .string()
33
- .optional()
34
- .describe("Credential file of a mandate to pay under. The mandate's limits apply in place of key and max, and only its owner can change them"),
35
- agentKey: z
36
- .string()
37
- .optional()
38
- .describe("The agent's mandate key file; the <credential>.agent-key.json beside the credential by default"),
39
- method: z.string().optional().describe("HTTP method; GET by default, POST with a body"),
40
- body: z.string().optional().describe("Request body, sent as application/json unless headers names a content type"),
41
- headers: z.record(z.string(), z.string()).optional().describe("Request headers, by name"),
42
- },
43
- }, ({ url, network: net, key, max, mandate, agentKey, method, body, headers }) => collect(async (print) => {
44
- const request = { method, body, headers };
45
- if (mandate !== undefined) {
46
- if (max !== undefined || key !== undefined) {
47
- throw new Error("with mandate, pass neither max nor key: the mandate sets the limit, and the agent's key is agentKey");
36
+ const request = {
37
+ url: z.string().url(),
38
+ network,
39
+ method: z.string().optional().describe("HTTP method; GET by default, POST with a body"),
40
+ body: z.string().optional().describe("Request body, sent as application/json unless headers names a content type"),
41
+ headers: z.record(z.string(), z.string()).optional().describe("Request headers, by name"),
42
+ };
43
+ const BUY_RPC = "To buy Metal RPC, POST a JSON-RPC body to the RPC URL; the rpc-aas-endpoint header is the endpoint the payment bought, so send further RPC calls there without paying again.";
44
+ if (authority.kind === "mandate") {
45
+ server.registerTool("pay", {
46
+ description: `Request a URL and pay its x402 challenge in *USDZ on Metal under this server's mandate: the payment comes from the mandate's account, signed by the agent's key, within the limits its owner set, and a payment outside them is refused with the limit named. Only the owner can change the limits. Returns the settlement transaction hash, the response headers and the body. ${BUY_RPC}`,
47
+ // Strict: a key or limit the model names is refused, not dropped.
48
+ inputSchema: z.object(request).strict(),
49
+ }, ({ url, network: net, method, body, headers }) => collect((print) => payUnderMandate(target(net), authority, url, print, { method, body, headers })));
50
+ }
51
+ else {
52
+ const limit = usdz(authority.maxAmount);
53
+ server.registerTool("pay", {
54
+ description: `Request a URL and pay its x402 challenge in *USDZ on Metal with this server's key, refusing before signing any offer above ${limit}. Returns the settlement transaction hash, the response headers and the body. ${BUY_RPC}`,
55
+ inputSchema: z
56
+ .object({
57
+ ...request,
58
+ max: z
59
+ .string()
60
+ .optional()
61
+ .describe(`A lower limit for this payment, in decimal *USDZ; it can only lower the server's ${limit}`),
62
+ })
63
+ .strict(),
64
+ }, ({ url, network: net, max, method, body, headers }) => collect(async (print) => {
65
+ const maxAmount = max === undefined ? authority.maxAmount : parseMax(max, "max");
66
+ if (maxAmount > authority.maxAmount) {
67
+ throw new Error(`max can only lower this server's limit of ${limit} per payment; its --max sets the limit`);
48
68
  }
49
- const payer = { credentialPath: mandate, agentKeyPath: agentKey ?? agentKeyPath(mandate) };
50
- await payUnderMandate(target(net), payer, url, print, request);
51
- return;
52
- }
53
- const maxAmount = parseMax(max, "max");
54
- const account = await readKey(key ?? DEFAULT_KEY_PATH);
55
- await pay(target(net), account, url, { maxAmount, raise: "raise max to allow it" }, print, request);
56
- }));
69
+ const raise = `the server's limit is ${limit}, set by its --max`;
70
+ await pay(target(net), await readKey(authority.keyPath), url, { maxAmount, raise }, print, { method, body, headers });
71
+ }));
72
+ }
57
73
  server.registerTool("fund", {
58
74
  description: "Fund an address with *USDZ. On devnet, from the devnet's pre-funded principal account; on testnet, from the public faucet, which sets the amount itself and reports an address that already holds a full grant as funded.",
59
75
  inputSchema: {
@@ -81,6 +97,10 @@ async function collect(run) {
81
97
  return { content: [{ type: "text", text: lines.join("\n") }], isError: true };
82
98
  }
83
99
  }
84
- export async function serveMcp() {
85
- await createMcpServer().connect(new StdioServerTransport());
100
+ /** Serves over stdio. A mandate and its agent key are checked first, so a wrong path fails the host's launch; a key is read when pay needs
101
+ * it, so fund and status work without one. */
102
+ export async function serveMcp(authority) {
103
+ if (authority.kind === "mandate")
104
+ await mandateAgentSigner(await loadMandate(authority.credentialPath), authority.agentKeyPath);
105
+ await createMcpServer(authority).connect(new StdioServerTransport());
86
106
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@metalntwx/metal-agent",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "description": "The `metal-agent` CLI and MCP server: create or import an agent key, fund it on Metal devnet or testnet, pay x402 offers in *USDZ, look up a settlement, and set spending limits for an agent that Metal enforces.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -18,8 +18,8 @@
18
18
  "skills"
19
19
  ],
20
20
  "dependencies": {
21
- "@metalntwx/metal-ts": "^0.3.1",
22
- "@metalntwx/metal-pay": "^0.1.1",
21
+ "@metalntwx/metal-ts": "^0.3.2",
22
+ "@metalntwx/metal-pay": "^0.2.0",
23
23
  "@modelcontextprotocol/sdk": "1.31.0",
24
24
  "@x402/core": "2.21.0",
25
25
  "viem": "^2.54.6",
@@ -12,8 +12,8 @@ Every command that touches the chain takes `--network testnet`. Metal's
12
12
  demo seller is at `https://x402-demo-testnet.metalntwx.com/quote` and the
13
13
  block explorer at `https://testnet.metalntwx.com`.
14
14
 
15
- `key`, `fund` and `pay` need no RPC endpoint. `status` and the `mandate`
16
- commands need the user's own: ask for it, unless `METAL_RPC_URL` is already
15
+ `key`, `fund` and `pay` need no RPC endpoint. `status`, `account create` and
16
+ the `mandate` commands other than `credential create` need the user's own: ask for it, unless `METAL_RPC_URL` is already
17
17
  set, and pass it with `--rpc-url`. A user without one creates it as
18
18
  `https://docs.metalntwx.com/network/endpoints` describes.
19
19
 
@@ -64,6 +64,10 @@ Run the steps in order. After each step, show the user its output.
64
64
  }
65
65
  ```
66
66
 
67
+ The server pays with the default key file. If the user's key is in
68
+ another file, or the agent pays under a mandate, add `--key <file>` or
69
+ `--mandate <credential>` after `mcp`.
70
+
67
71
  The tools appear after the host reloads. If you cannot reload it, tell
68
72
  the user to, and carry on with the CLI.
69
73
 
@@ -100,7 +104,7 @@ Run the steps in order. After each step, show the user its output.
100
104
  - Before you pass a `--max` above the default of 1 `*USDZ`.
101
105
  - Before you import a key, or write a key anywhere but the default file.
102
106
  - Before you run `account create`, `mandate credential create`, `bind`,
103
- `replace` or `revoke`. They are the account owner's to run.
107
+ `replace`, `renew` or `revoke`. They are the account owner's to run.
104
108
 
105
109
  Never print a private key or the contents of a key file. Never move, copy
106
110
  or delete a key file. If the owner's key file is within your reach, tell
@@ -183,11 +187,15 @@ A refusal exits 1 with the reason:
183
187
  `offer signs for the EIP-712 domain …` means the offer asks for something
184
188
  other than `*USDZ`. Tell the user; a larger `--max` does not change it.
185
189
  - `error: payment refused: <reason>` means the facilitator refused at
186
- verification.
190
+ verification, or the seller's own check refused.
187
191
  - `error: payment refused at settlement: <reason>` means the facilitator
188
192
  refused at settlement, with the transaction hash when there is one. On
189
- `daily_limit_reached`, retry later. On `settlement_in_doubt`, run
190
- `status` on the hash before paying again.
193
+ `daily_limit_reached`, retry later.
194
+ - On `settlement_in_doubt`, or any error after the `nonce` line that is
195
+ not a `payment refused` line, such as `fetch failed` or an HTTP status
196
+ from the seller, the payment may have settled. Do not pay again. Tell
197
+ the user, with the `nonce` line `pay` printed; when the error names a
198
+ transaction hash, `status` on it shows whether it landed.
191
199
 
192
200
  ## `pay --mandate`
193
201
 
@@ -205,11 +213,13 @@ metal-agent pay https://example.com/report --mandate ./mandate.json --network te
205
213
  apply. A payment outside the mandate exits 1 with
206
214
  `error: payment refused: <reason> (…)`, a reason starting `mandate_`. Tell
207
215
  the user which limit it was; only the owner can change a mandate. After
208
- `mandate_revoked`, ask the user for the credential that replaced it.
216
+ `mandate_revoked`, ask the user for the credential that replaced it, and
217
+ pay with that. If the error says there is no agent key, pass the
218
+ `--agent-key` it names.
209
219
 
210
220
  A seller that asks for your signed order gets it with the payment, and
211
221
  `pay` prints it as `signed-offer <text>`. Keep it, and pass it to
212
- `mandate verify --tx <hash> --signed-offer <text>` to check the two
222
+ `mandate verify --issuer <file> --tx <hash> --signed-offer <text>` to check the two
213
223
  together.
214
224
 
215
225
  `mandate status` shows what is left: read `spent <amount> of <budget>`,
@@ -221,8 +231,11 @@ metal-agent mandate status --credential ./mandate.json --network testnet --rpc-u
221
231
 
222
232
  `mandate pull` pays an address the mandate allows, with no seller. The gas
223
233
  key beside the credential, `mandate.gas-key.json`, submits it and pays the
224
- gas. `outcome paid` or `outcome refused` with a `reason` line says what
225
- happened.
234
+ gas, so ask the user to fund its `gas-key-address` first. `outcome paid`, or `outcome refused, not sent` with `reason` and `recorded no` lines,
235
+ says what happened; a pull refused when checked sends nothing and costs no gas. Pass
236
+ `--record-refusal` only when the user asks for the refusal to be recorded
237
+ on chain. If a pull fails after printing `transaction <hash>`, run
238
+ `metal-agent status <hash>` before pulling again; it may have paid.
226
239
 
227
240
  ```bash
228
241
  metal-agent mandate pull --credential ./mandate.json --to <payee address> --amount 2.50 --network testnet --rpc-url <endpoint>
@@ -231,12 +244,17 @@ metal-agent mandate pull --credential ./mandate.json --to <payee address> --amou
231
244
  `mandate verify` checks the mandate and every payment under it from the
232
245
  chain alone, or one `--tx <hash>`, and exits 1 when a check fails. Run it
233
246
  when the user asks whether a payment was within the mandate; read
234
- `verified yes|no`, and a failed `step` line names what differs.
247
+ `verified yes|no`, and a failed `step` line names what differs. It needs
248
+ the issuer's public key file, the `issuer-public-file` that `credential
249
+ create` printed; ask the user for it.
235
250
 
236
251
  ```bash
237
- metal-agent mandate verify --credential ./mandate.json --network testnet --rpc-url <endpoint>
252
+ metal-agent mandate verify --credential ./mandate.json --issuer <issuer public key file> --network testnet --rpc-url <endpoint>
238
253
  ```
239
254
 
255
+ `--issuer` is the issuer's public key file the user gave you with the
256
+ credential; ask for it if you do not have it.
257
+
240
258
  ## status
241
259
 
242
260
  Show a settlement from chain data: status, block, submitter, gas paid in
@@ -251,13 +269,14 @@ metal-agent status <transaction hash> --network testnet --rpc-url <endpoint>
251
269
  ## mcp
252
270
 
253
271
  Serve `pay`, `fund` and `status` as MCP tools, each taking a `network`, over
254
- stdio for an MCP host. `pay` takes the same limit as an optional `max`,
255
- `"1"` by default, the request as optional `method`, `body` and `headers`,
256
- and a credential file as `mandate` to pay under it, with an optional
257
- `agentKey`. `status` takes the endpoint as `rpcUrl`, which it requires.
258
- Keys are created and imported through the CLI; `pay` takes an existing
259
- key file as `key`.
272
+ stdio for an MCP host. What `pay` spends is fixed when the server starts,
273
+ by its key and `--max`, or by one mandate; `pay` takes the request as
274
+ optional `method`, `body` and `headers`, and under a key a `max` that can
275
+ only lower the server's limit. `status` takes the endpoint as `rpcUrl`, which testnet
276
+ needs unless the server's environment sets `METAL_RPC_URL`. No tool chooses a key, a mandate or a higher limit, and none
277
+ sets or changes a mandate.
260
278
 
261
279
  ```bash
262
280
  metal-agent mcp
281
+ metal-agent mcp --mandate /path/to/mandate.json
263
282
  ```