@metalntwx/metal-agent 0.3.0 → 0.3.2
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 +41 -19
- package/dist/commands/fund.js +28 -14
- package/dist/commands/key.js +2 -2
- package/dist/commands/mandate.js +221 -59
- package/dist/commands/pay.js +85 -12
- package/dist/help.js +9 -9
- package/dist/index.js +14 -4
- package/dist/keystore.js +28 -12
- package/dist/mcp.js +269 -27
- package/package.json +2 -2
- package/skills/metal-agent/SKILL.md +81 -46
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
|
|
56
|
-
with the transaction hash when
|
|
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
|
|
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
|
|
124
|
-
|
|
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
|
|
150
|
-
receive policy sends it to escrow, or `pending
|
|
151
|
-
|
|
152
|
-
running the same command again sends
|
|
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
|
|
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
|
|
173
|
-
`--max` (the default key file and 1 `*USDZ` when
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
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
|
-
{
|
|
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
|
package/dist/commands/fund.js
CHANGED
|
@@ -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
|
|
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(
|
|
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
|
|
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
|
|
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}
|
|
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
|
|
90
|
+
return `this client reached its daily faucet limit; try tomorrow${console}${orKeyImport}`;
|
|
78
91
|
case "daily_limit_reached":
|
|
79
|
-
|
|
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
|
}
|
package/dist/commands/key.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { readFile } from "node:fs/promises";
|
|
2
2
|
import { generatePrivateKey, privateKeyToAccount } from "viem/accounts";
|
|
3
|
-
import {
|
|
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 ??
|
|
16
|
+
const path = out ?? defaultKeyPath();
|
|
17
17
|
const { address } = privateKeyToAccount(privateKey);
|
|
18
18
|
await writeKey(path, { address, privateKey });
|
|
19
19
|
print(`address ${address}`);
|