@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.
package/README.md CHANGED
@@ -2,7 +2,8 @@
2
2
 
3
3
  The `metal-agent` CLI an agent pays x402 payees on Metal with: create or
4
4
  import a key, fund it, pay a URL, look up a settlement, or serve `pay`,
5
- `fund` and `status` as MCP tools.
5
+ `fund` and `status` as MCP tools, and under a mandate its payouts, status
6
+ and verification.
6
7
 
7
8
  ```bash
8
9
  npm install -g @metalntwx/metal-agent
@@ -25,7 +26,8 @@ endpoints) and the `*USDZ` address come from `@metalntwx/metal-pay`, and
25
26
  `networks.json` holds its demo payee URL and, on testnet, its faucet and
26
27
  explorer URLs.
27
28
  `METAL_RPC_URL` and `METAL_FAUCET_URL` override those defaults, and
28
- `--rpc-url` and `--faucet-url` override both.
29
+ `--rpc-url` and `--faucet-url` override both; `METAL_FACILITATOR_URL` and
30
+ `--facilitator-url` do the same for `mandate pull`.
29
31
 
30
32
  On testnet, `pay` and `fund` need no RPC. `status`, `account create` and
31
33
  every `mandate` command but `credential create` need an endpoint of your
@@ -51,8 +53,11 @@ which the devnet funds at genesis.
51
53
  offer above `--max`, 1 `*USDZ` per payment by default. A refusal at
52
54
  verification, by the facilitator or the seller's own check, prints
53
55
  `payment refused: <reason>`.
54
- A refusal at settlement prints `payment refused at settlement: <reason>`,
55
- with the transaction hash when there is one.
56
+ A refusal at settlement for a reason that moves nothing prints
57
+ `payment refused at settlement: <reason>`, with the transaction hash when
58
+ there is one; both refusals end `nothing was paid`. Any other failed
59
+ settlement, such as one whose answer the seller lost, prints
60
+ `payment in doubt at settlement: <reason>`: it may have settled.
56
61
 
57
62
  `pay` sends `--method` (GET by default, POST with a body), `--body` (as `application/json`
58
63
  unless a `--header` names a content type) and each repeated
@@ -93,34 +98,36 @@ metal-agent mandate revoke --credential ./wider.json --key <owner key file> --ne
93
98
  ```
94
99
 
95
100
  `credential create` writes the limits into a credential file, a Verifiable
96
- Intent mandate, and the agent's keys beside it: its mandate key in
97
- `mandate.agent-key.json`, and in `mandate.gas-key.json` the key that pays
98
- for its pulls. `--agent-key <file>` names a mandate key the agent already
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
+ Intent mandate, and beside it the mandate key the agent signs with:
102
+ `mandate.key.json` for `mandate.json`. `--mandate-key-file <file>` names a
103
+ mandate key that exists instead, as a replacement does. The keys
104
+ that sign the credential are the owner's: they live beside the owner's key file, created
101
105
  on first use (`owner-key.issuer.json`, with its public half in
102
106
  `owner-key.issuer.public.json`), unless `--issuer-key` names their file.
103
107
  `--description` says what the mandate is for, and `--payee` takes a name
104
108
  and a website after the address. Every other command takes
105
- that credential file: the owner's account, the mandate's reference, its
106
- terms and the agent's key files derive from it. Amounts are in `*USDZ`, to the cent; a
109
+ that credential file: the owner's account, the mandate's id and its
110
+ terms derive from it. Amounts are in `*USDZ`, to the cent; a
107
111
  mandate lasts 30 days unless `--expires-in` says otherwise, and its budget
108
112
  never resets.
109
113
 
110
- `account create --out <owner key file>` writes the owner's key and names the
111
- owner's Metal account, a smart account that key controls with threshold one,
112
- which the owner's first `bind` creates on chain; `--key <file>` uses a key
113
- that exists. Fund the account and name it as the credential's `--principal`.
114
+ `account create --out <owner key file>` writes the owner's key and creates the
115
+ owner's Metal account on chain, a smart account that key controls with
116
+ threshold one; `--key <file>` uses a key that exists. The owner's key pays the
117
+ gas, and one that holds too little is funded first. When a later step
118
+ fails, such as a faucet refusal, the error says why and names the key file; the same command continues from it. Fund the account and name it as
119
+ the credential's `--principal`; `bind` refuses an address with no account.
114
120
  The account factory is the one `@metalntwx/metal-pay` names for the network;
115
121
  on a devnet `just deploy` deploys it there, and `--factory <address>` names
116
122
  another. `@metalntwx/metal-pay` names the testnet's.
117
123
 
118
- `bind`, `replace` and `revoke` execute self-calls through the account's weighted
119
- quorum. `--key` names the gas sponsor and defaults as the sole signer; repeat
120
- `--signer-key <file>` to supply the EOA signers of an existing multisig instead.
121
- The SDK's `authorizeMandateCall` accepts a quorum-signing callback for mixed
122
- keys and passkeys. Keep owner keys outside the agent's reach: quorum-approved
123
- transfers remain independent of mandates. `replace` revokes the mandate
124
+ `bind`, `replace` and `revoke` are signed by the account's owners, as many as
125
+ its threshold needs. `--key` names the gas sponsor and defaults as the sole
126
+ signer; repeat `--signer-key <file>` to supply the owners' keys of an account
127
+ with several instead. The SDK's `authorizeMandateCall` accepts a signing
128
+ callback for mixed keys and passkeys; the CLI's signatures expire fifteen
129
+ minutes after signing. Keep owner keys outside the agent's reach: an owner key
130
+ moves the account's funds outside any mandate's limits. `replace` revokes the mandate
124
131
  `--replaces` names and carries its spend into the new one; `renew` binds the
125
132
  new one and revokes the old in one batch, starting its spend at nothing, which
126
133
  is how a budget starts over.
@@ -132,29 +139,40 @@ account's immutable clone code against the factory's current
132
139
  implementation, and checks successful token-transfer receipts.
133
140
 
134
141
  `pay --mandate <credential>` pays from the owner's account, signed by the
135
- agent's mandate key, and takes neither `--key` nor `--max`. A payment
142
+ mandate key beside the credential, or the one `--mandate-key-file` names.
143
+ It takes neither `--key` nor `--max`. A payment
136
144
  outside the mandate is refused and moves nothing, usually with a
137
145
  `mandate_*` reason: `payment refused: mandate_cap_exceeded (…)`. `pay` prints the
138
146
  payment's `nonce` as soon as it signs; the facilitator's
139
147
  `/settlements?payTo=<payee>` lists a settled payment by an id ending in it.
140
148
 
141
149
  `mandate pull` pays an address the mandate allows directly, with no seller:
142
- the agent's mandate key signs, and the `--key` account, the agent's gas key
143
- by default, submits the transaction and pays its gas, so fund it with a
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.
150
+ the agent's mandate key signs, and the facilitator submits the transaction
151
+ and pays its gas; `--key` names a funded key to submit it instead. A pull
152
+ the mandate refuses when checked is not sent: it reports the reason and
153
+ exits 1. With `--key`, `--record-refusal` sends it, so the refusal is
154
+ recorded on chain in a transaction that succeeds and moves nothing. The
155
+ outcome is read from the chain: `paid` (exit 0), `refused` (exit 1), `held`
156
+ (exit 4) when the payee's receive policy sends it to escrow, or `pending`
157
+ (exit 3); an `error:` line also exits 1. Each pull is kept under `~/.metal-agent/pulls/<mandate>/`, and
158
+ running the same command again sends a pending pull again, which pays at
159
+ most once. `--payment-id <id>` names the payment: two payouts of one amount
160
+ to one payee are two payments under two ids, an id whose payout was paid or
161
+ held reports it again, after an `already paid` or `already held` line, and
162
+ sends nothing, and one the account refused may be sent with other values.
163
+ Without it, a payout is known by its payee and amount. A retry from another
164
+ home directory, or under a credential that renewed or replaced this one,
165
+ signs a new payout.
149
166
 
150
167
  `mandate verify` checks the mandate and every payment under it from the
151
168
  chain alone, through any RPC endpoint: the binding on the owner's account,
152
169
  the credential against it and under its issuer, and each payment and
153
- refusal as the agent key's own signed per-payment mandate, rebuilt from its
170
+ refusal as the mandate key's own signed per-payment mandate, rebuilt from its
154
171
  transaction. Anyone holding the credential and the issuer's public file,
155
172
  passed as `--issuer`, can run it; `--tx <hash>`
156
173
  verifies one transaction, with `--signed-offer <text>` the offer the agent
157
- signed for it, and a check that fails exits 1. A seller that asks for the
174
+ signed for it, and a check that fails exits 1, as does an event under the
175
+ mandate that the SDK does not decode, as an account of another version emits. A seller that asks for the
158
176
  agent's signed order (`requireSignedOffer` in `@metalntwx/metal-pay/mandates`,
159
177
  with `extra.signedOffer: "required"` on the route) is paid with it, and
160
178
  `pay` prints it. From code, `verifyMandate` and `verifyMandatePayment` do
@@ -165,21 +183,29 @@ an agent, then the commands.
165
183
 
166
184
  `metal-agent mcp` serves `pay`, `fund` and `status` over stdio, each taking
167
185
  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:
186
+ `headers`. What `pay` spends is set when the server starts, by whoever
187
+ starts it: a key file and `--max` (the default key file and 1 `*USDZ` when
188
+ omitted), or one mandate's credential file with `--mandate <credential>`,
189
+ which each call reads. Under a mandate it also serves `mandate_pull`,
190
+ `mandate_status` and `mandate_verify`, which run those commands on the
191
+ server's mandate and read the chain through its `METAL_RPC_URL`, or devnet's
192
+ default endpoint. The model calling the tools chooses no key, no mandate and
193
+ no higher limit; under a key, a `max` it passes can only lower the server's.
194
+ The other mandate commands enter only through the CLI. An MCP host starts it
195
+ with:
174
196
 
175
197
  ```json
176
198
  { "command": "npx", "args": ["-y", "@metalntwx/metal-agent", "mcp"] }
177
199
  ```
178
200
 
179
- or, under one mandate:
201
+ or, under one mandate, with the endpoint the mandate tools read on testnet:
180
202
 
181
203
  ```json
182
- { "command": "npx", "args": ["-y", "@metalntwx/metal-agent", "mcp", "--mandate", "/path/to/mandate.json"] }
204
+ {
205
+ "command": "npx",
206
+ "args": ["-y", "@metalntwx/metal-agent", "mcp", "--mandate", "/path/to/mandate.json"],
207
+ "env": { "METAL_RPC_URL": "<endpoint>" }
208
+ }
183
209
  ```
184
210
 
185
211
  ## Develop
package/dist/chain.js CHANGED
@@ -22,7 +22,7 @@ export function resolveSettings(network, file, flags, env) {
22
22
  const faucetUrl = flags.faucetUrl ?? env["METAL_FAUCET_URL"] ?? file.faucetUrl;
23
23
  return {
24
24
  ...(rpcUrl !== undefined && { rpcUrl }),
25
- facilitatorUrl: env["METAL_FACILITATOR_URL"] ?? network.facilitatorUrl,
25
+ facilitatorUrl: flags.facilitatorUrl ?? env["METAL_FACILITATOR_URL"] ?? network.facilitatorUrl,
26
26
  demoPayeeUrl: env["METAL_DEMO_PAYEE_URL"] ?? file.demoPayeeUrl,
27
27
  ...(faucetUrl !== undefined && { faucetUrl }),
28
28
  ...(file.explorerUrl !== undefined && { explorerUrl: file.explorerUrl }),
@@ -8,7 +8,7 @@ const DEVNET_PRINCIPAL_KEY = "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efc
8
8
  // Gas and the 1-per-gas fee are explicit for the reason
9
9
  // `buildSubmitAuthorizationTx` gives.
10
10
  const FUND_GAS_LIMIT = 200000n;
11
- export async function fund(target, addressArg, amountArg, print, fetchImpl = fetch) {
11
+ export async function fund(target, addressArg, amountArg, print, fetchImpl = fetch, keyImport = true) {
12
12
  if (!addressArg || !isAddress(addressArg))
13
13
  throw new Error("fund needs the agent's address");
14
14
  const to = getAddress(addressArg);
@@ -16,7 +16,7 @@ export async function fund(target, addressArg, amountArg, print, fetchImpl = fet
16
16
  if (amountArg !== undefined) {
17
17
  throw new Error("on testnet the faucet sets the amount; run fund without --amount");
18
18
  }
19
- await fundFromFaucet(target, to, print, fetchImpl);
19
+ await fundFromFaucet(target, to, print, fetchImpl, keyImport);
20
20
  return;
21
21
  }
22
22
  const publicClient = rpcClient(target);
@@ -29,20 +29,24 @@ export async function fund(target, addressArg, amountArg, print, fetchImpl = fet
29
29
  });
30
30
  print(`balance ${formatUsdz(balance)} ${USDZ.symbol}`);
31
31
  }
32
- async function fundFromFaucet(target, to, print, fetchImpl) {
32
+ async function fundFromFaucet(target, to, print, fetchImpl, keyImport) {
33
33
  if (!target.faucetUrl)
34
34
  throw new Error(`no faucet is configured for ${target.network.name}`);
35
35
  const url = new URL("/api/fund", target.faucetUrl);
36
+ // The Metal console funds through the same faucet, so an unreachable one is not sent there.
36
37
  const response = await fetchImpl(url, {
37
38
  method: "POST",
38
39
  headers: { "content-type": "application/json" },
39
40
  body: JSON.stringify({ address: to, symbol: "USDZ" }),
41
+ }).catch((error) => {
42
+ const cause = error.cause;
43
+ throw new Error(`the faucet at ${url} could not be reached (${typeof cause?.code === "string" ? cause.code : error instanceof Error ? error.message : String(error)}); retry shortly`);
40
44
  });
41
45
  const text = await response.text();
42
46
  if (response.ok) {
43
47
  const grant = safeJson(text);
44
48
  if (!grant?.txHash || !grant.amount)
45
- throw new Error(unexpectedAnswer(url, response, "a transaction"));
49
+ throw new Error(`${unexpectedAnswer(url, response)} without a transaction`);
46
50
  // The faucet answers 200 only once the grant's receipt shows success.
47
51
  print(`funded ${to} with ${formatUsdz(grant.amount)} ${USDZ.symbol} from the testnet faucet`);
48
52
  print(`transaction ${grant.txHash}`);
@@ -56,27 +60,37 @@ async function fundFromFaucet(target, to, print, fetchImpl) {
56
60
  return;
57
61
  }
58
62
  if (!error && response.status === 429) {
59
- throw new Error("faucet answered 429: the faucet's edge rate limit refused this client; retry shortly");
63
+ throw new Error(`faucet answered 429: the faucet's edge rate limit refused this client; retry shortly${consoleFaucet(target)}`);
60
64
  }
65
+ if (!error && response.status >= 500)
66
+ throw new Error(`${unexpectedAnswer(url, response)}: the faucet could not be reached; retry shortly`);
61
67
  if (!error)
62
- throw new Error(unexpectedAnswer(url, response, "an error code"));
63
- throw new Error(`faucet answered ${response.status} ${error.code}: ${faucetRefusal(error)}`);
68
+ throw new Error(`${unexpectedAnswer(url, response)} without an error code`);
69
+ throw new Error(`faucet answered ${response.status} ${error.code}: ${faucetRefusal(error, consoleFaucet(target), keyImport ? ", or load a funded key with `metal-agent key import`" : "")}`);
70
+ }
71
+ // The public faucet limits every client behind one IP address together; the
72
+ // Metal console's faucet grants per signed-in user, so a refused client is sent there.
73
+ function consoleFaucet(target) {
74
+ if (!("consoleUrl" in target.network.rpc))
75
+ return "";
76
+ // The console's host alone, so a URL redacted to its host still reads.
77
+ return `, or fund it from the Faucet page of the Metal console at ${target.network.rpc.consoleUrl}, which grants per signed-in user`;
64
78
  }
65
79
  // Describes the response instead of quoting it, because a proxy in front of
66
80
  // the faucet answers with a full HTML document.
67
- function unexpectedAnswer(url, response, missing) {
81
+ function unexpectedAnswer(url, response) {
68
82
  const mediaType = response.headers.get("content-type")?.split(";")[0]?.trim() || "no content type";
69
- return `faucet answered ${response.status} ${mediaType} from ${url} without ${missing}`;
83
+ return `faucet answered ${response.status} ${mediaType} from ${url}`;
70
84
  }
71
- function faucetRefusal(error) {
72
- const keyImport = "or load a funded key with `metal-agent key import`";
85
+ function faucetRefusal(error, console, orKeyImport) {
73
86
  switch (error.code) {
74
87
  case "client_rate_limited":
75
- return `this client asked the faucet too recently; ${error.retryAfter === undefined ? "retry shortly" : `retry in ${error.retryAfter} s`}`;
88
+ return `this client asked the faucet too recently; ${error.retryAfter === undefined ? "retry shortly" : `retry in ${error.retryAfter} s`}${console}`;
76
89
  case "client_daily_limit_reached":
77
- return `this client reached its daily faucet limit; try tomorrow, ${keyImport}`;
90
+ return `this client reached its daily faucet limit; try tomorrow${console}${orKeyImport}`;
78
91
  case "daily_limit_reached":
79
- return `the faucet reached its daily grant limit for every client; try tomorrow, ${keyImport}`;
92
+ // The Metal console's faucet counts against the same daily limit.
93
+ return `the faucet reached its daily grant limit for every client; try tomorrow${orKeyImport}`;
80
94
  default:
81
95
  return error.message;
82
96
  }
@@ -1,6 +1,6 @@
1
1
  import { readFile } from "node:fs/promises";
2
2
  import { generatePrivateKey, privateKeyToAccount } from "viem/accounts";
3
- import { DEFAULT_KEY_PATH, isPrivateKey, writeKey } from "../keystore.js";
3
+ import { defaultKeyPath, isPrivateKey, writeKey } from "../keystore.js";
4
4
  export async function keyCreate(out, print) {
5
5
  await store(generatePrivateKey(), out, print);
6
6
  }
@@ -13,7 +13,7 @@ export async function keyImport(source, out, print) {
13
13
  await store(privateKey, out, print);
14
14
  }
15
15
  async function store(privateKey, out, print) {
16
- const path = out ?? DEFAULT_KEY_PATH;
16
+ const path = out ?? defaultKeyPath();
17
17
  const { address } = privateKeyToAccount(privateKey);
18
18
  await writeKey(path, { address, privateKey });
19
19
  print(`address ${address}`);