@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 +65 -39
- package/dist/chain.js +1 -1
- package/dist/commands/fund.js +28 -14
- package/dist/commands/key.js +2 -2
- package/dist/commands/mandate.js +616 -142
- package/dist/commands/pay.js +86 -13
- package/dist/help.js +20 -19
- package/dist/index.js +40 -14
- package/dist/keystore.js +30 -18
- package/dist/mcp.js +273 -31
- package/package.json +6 -6
- package/skills/metal-agent/SKILL.md +93 -51
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
|
|
55
|
-
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.
|
|
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
|
|
97
|
-
`mandate.
|
|
98
|
-
|
|
99
|
-
|
|
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
|
|
106
|
-
terms
|
|
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
|
|
111
|
-
owner's Metal account, a smart account that key controls with
|
|
112
|
-
|
|
113
|
-
that
|
|
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`
|
|
119
|
-
|
|
120
|
-
`--signer-key <file>` to supply the
|
|
121
|
-
The SDK's `authorizeMandateCall` accepts a
|
|
122
|
-
keys and passkeys
|
|
123
|
-
|
|
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
|
-
|
|
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
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
on chain in a transaction that succeeds and moves nothing.
|
|
147
|
-
|
|
148
|
-
|
|
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
|
|
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
|
|
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
|
|
169
|
-
`--max` (the default key file and 1 `*USDZ` when
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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
|
-
{
|
|
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 }),
|
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}`);
|