@metalntwx/metal-agent 0.3.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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
@@ -52,8 +53,11 @@ which the devnet funds at genesis.
52
53
  offer above `--max`, 1 `*USDZ` per payment by default. A refusal at
53
54
  verification, by the facilitator or the seller's own check, prints
54
55
  `payment refused: <reason>`.
55
- A refusal at settlement prints `payment refused at settlement: <reason>`,
56
- 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.
57
61
 
58
62
  `pay` sends `--method` (GET by default, POST with a body), `--body` (as `application/json`
59
63
  unless a `--header` names a content type) and each repeated
@@ -110,7 +114,8 @@ never resets.
110
114
  `account create --out <owner key file>` writes the owner's key and creates the
111
115
  owner's Metal account on chain, a smart account that key controls with
112
116
  threshold one; `--key <file>` uses a key that exists. The owner's key pays the
113
- gas, and one that holds none is funded first. Fund the account and name it as
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
114
119
  the credential's `--principal`; `bind` refuses an address with no account.
115
120
  The account factory is the one `@metalntwx/metal-pay` names for the network;
116
121
  on a devnet `just deploy` deploys it there, and `--factory <address>` names
@@ -120,8 +125,9 @@ another. `@metalntwx/metal-pay` names the testnet's.
120
125
  its threshold needs. `--key` names the gas sponsor and defaults as the sole
121
126
  signer; repeat `--signer-key <file>` to supply the owners' keys of an account
122
127
  with several instead. The SDK's `authorizeMandateCall` accepts a signing
123
- callback for mixed keys and passkeys. Keep owner keys outside the agent's
124
- reach: an owner key moves the account's funds outside any mandate's limits. `replace` revokes the mandate
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
125
131
  `--replaces` names and carries its spend into the new one; `renew` binds the
126
132
  new one and revokes the old in one batch, starting its spend at nothing, which
127
133
  is how a budget starts over.
@@ -146,10 +152,17 @@ and pays its gas; `--key` names a funded key to submit it instead. A pull
146
152
  the mandate refuses when checked is not sent: it reports the reason and
147
153
  exits 1. With `--key`, `--record-refusal` sends it, so the refusal is
148
154
  recorded on chain in a transaction that succeeds and moves nothing. The
149
- outcome is read from the chain: `paid`, `refused`, `held` when the payee's
150
- receive policy sends it to escrow, or `pending`. A pending pull is kept in
151
- the directory beside the credential, `mandate.pulls` for `mandate.json`, and
152
- running the same command again sends that same pull, which pays at most once.
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.
153
166
 
154
167
  `mandate verify` checks the mandate and every payment under it from the
155
168
  chain alone, through any RPC endpoint: the binding on the owner's account,
@@ -158,7 +171,8 @@ refusal as the mandate key's own signed per-payment mandate, rebuilt from its
158
171
  transaction. Anyone holding the credential and the issuer's public file,
159
172
  passed as `--issuer`, can run it; `--tx <hash>`
160
173
  verifies one transaction, with `--signed-offer <text>` the offer the agent
161
- 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
162
176
  agent's signed order (`requireSignedOffer` in `@metalntwx/metal-pay/mandates`,
163
177
  with `extra.signedOffer: "required"` on the route) is paid with it, and
164
178
  `pay` prints it. From code, `verifyMandate` and `verifyMandatePayment` do
@@ -169,21 +183,29 @@ an agent, then the commands.
169
183
 
170
184
  `metal-agent mcp` serves `pay`, `fund` and `status` over stdio, each taking
171
185
  a `network`, and `pay` the request as optional `method`, `body` and
172
- `headers`. What `pay` spends is fixed when the server starts: its key and
173
- `--max` (the default key file and 1 `*USDZ` when omitted), or one mandate
174
- with `--mandate <credential>`. The model calling the tools chooses no key,
175
- no mandate and no higher limit; under a key, a `max` it passes can only
176
- lower the server's. The mandate commands enter only through the CLI. An MCP host
177
- 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:
178
196
 
179
197
  ```json
180
198
  { "command": "npx", "args": ["-y", "@metalntwx/metal-agent", "mcp"] }
181
199
  ```
182
200
 
183
- or, under one mandate:
201
+ or, under one mandate, with the endpoint the mandate tools read on testnet:
184
202
 
185
203
  ```json
186
- { "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
+ }
187
209
  ```
188
210
 
189
211
  ## Develop
@@ -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}`);