@metalntwx/metal-agent 0.0.0-stage → 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Nexus Networks
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,177 @@
1
- # Temporary Holding Version
1
+ # @metalntwx/metal-agent
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
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.
6
+
7
+ ```bash
8
+ npm install -g @metalntwx/metal-agent
9
+
10
+ metal-agent key create
11
+ metal-agent key import <file|hex>
12
+ metal-agent fund <address> --network testnet
13
+ metal-agent pay https://x402-demo-testnet.metalntwx.com/quote --network testnet
14
+ metal-agent status <txHash> --network testnet --rpc-url <endpoint>
15
+ metal-agent mcp
16
+ ```
17
+
18
+ `npx -y @metalntwx/metal-agent <command>` runs one command without
19
+ installing.
20
+
21
+ Every command that touches the chain names `--network devnet` or
22
+ `--network testnet`; there is no default. Each network's facilitator URL,
23
+ its RPC (devnet's default endpoint, or the console that issues testnet
24
+ endpoints) and the `*USDZ` address come from `@metalntwx/metal-pay`, and
25
+ `networks.json` holds its demo payee URL and, on testnet, its faucet and
26
+ explorer URLs.
27
+ `METAL_RPC_URL` and `METAL_FAUCET_URL` override those defaults, and
28
+ `--rpc-url` and `--faucet-url` override both.
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,
32
+ https://console-testnet.metalntwx.com, create an endpoint, and pass it with
33
+ `--rpc-url` or `METAL_RPC_URL`. On testnet, `pay` and `fund` print
34
+ an `explorer` line with the transaction's page on
35
+ https://testnet.metalntwx.com.
36
+
37
+ The key file defaults to `~/.metal-agent/key.json` and holds the private key
38
+ in the clear, owner-readable only. `key import` takes a hex key (with or
39
+ without `0x`), a file holding one, or a key file, and prints only the
40
+ address.
41
+
42
+ `fund --network testnet` asks the public faucet's `POST /api/fund`, and the
43
+ faucet sets the amount. An address that already holds a full grant counts
44
+ as funded. A rate-limited request fails with what to do next: retry after
45
+ the stated wait, try tomorrow, or import a key someone funded for you.
46
+ `fund --network devnet [--amount <n>]` spends from Foundry test account #0,
47
+ which the devnet funds at genesis.
48
+
49
+ `pay` refuses, before it signs, an offer in any token but `*USDZ` and any
50
+ offer above `--max`, 1 `*USDZ` per payment by default. A refusal at
51
+ verification prints `payment refused: <reason>`.
52
+ A refusal at settlement prints `payment refused at settlement: <reason>`,
53
+ with the transaction hash when there is one.
54
+
55
+ `pay` sends `--method` (GET by default, POST with a body), `--body` (as `application/json`
56
+ unless a `--header` names a content type) and each repeated
57
+ `--header 'name: value'`, and sends them again with the payment. A JSON-RPC
58
+ call to an RPC endpoint sold over x402, for example:
59
+
60
+ ```bash
61
+ metal-agent pay <rpc url> --network testnet --method POST \
62
+ --body '{"jsonrpc":"2.0","id":1,"method":"eth_blockNumber","params":[]}'
63
+ ```
64
+
65
+ `pay` prints the response headers before the body, as
66
+ `header <name>: <value>` lines, leaving out transport, caching and CORS
67
+ headers and the payment it reports above. An RPC seller such as Metal's
68
+ answers with `rpc-aas-endpoint`, the endpoint the payment bought: send
69
+ further JSON-RPC calls there, without paying again, until its credit runs
70
+ out.
71
+
72
+ ## Spending limits
73
+
74
+ A mandate lets an agent pay from its owner's account with a P-256 key of its
75
+ own, within limits Metal enforces on chain: the most per payment, a budget
76
+ for the mandate's whole life, an expiry and, when `--payee` names any, the
77
+ addresses it may pay; with none named it may pay any address.
78
+
79
+ ```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 bind --credential ./mandate.json --key <owner key file> --network testnet --rpc-url <endpoint>
83
+ metal-agent pay <url> --mandate ./mandate.json --network testnet
84
+ metal-agent mandate status --credential ./mandate.json --network testnet --rpc-url <endpoint>
85
+ metal-agent mandate replace --credential ./wider.json --replaces ./mandate.json --key <owner key file> --network testnet --rpc-url <endpoint>
86
+ 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
+ ```
91
+
92
+ `credential create` writes the limits into a credential file, a Verifiable
93
+ Intent mandate, and the agent's keys beside it: its mandate key in
94
+ `mandate.agent-key.json`, and in `mandate.gas-key.json` the key that pays
95
+ 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
+ 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.
102
+
103
+ `account create --out <owner key file>` writes the owner's key and names the
104
+ owner's Metal account, a smart account that key controls with threshold one,
105
+ which the owner's first `bind` creates on chain; `--key <file>` uses a key
106
+ that exists. Fund the account and name it as the credential's `--principal`.
107
+ The account factory is the one `@metalntwx/metal-pay` names for the network;
108
+ 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.
111
+
112
+ `bind`, `replace` and `revoke` execute self-calls through the account's weighted
113
+ quorum. `--key` names the gas sponsor and defaults as the sole signer; repeat
114
+ `--signer-key <file>` to supply the EOA signers of an existing multisig instead.
115
+ The SDK's `authorizeMandateCall` accepts a quorum-signing callback for mixed
116
+ keys and passkeys. Keep owner keys outside the agent's reach: quorum-approved
117
+ transfers remain independent of mandates. `replace` revokes the mandate
118
+ `--replaces` names and carries its spend into the new one.
119
+
120
+ 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.
123
+
124
+ `pay --mandate <credential>` pays from the owner's account, signed by the
125
+ 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
128
+ payment's `nonce` as soon as it signs; the facilitator's
129
+ `/settlements?payTo=<payee>` lists a settled payment by an id ending in it.
130
+
131
+ `mandate pull` pays an address the mandate allows directly, with no seller:
132
+ the agent's mandate key signs, and the `--key` account, the agent's gas key
133
+ 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.
136
+
137
+ `mandate verify` checks the mandate and every payment under it from the
138
+ chain alone, through any RPC endpoint: the binding on the owner's account,
139
+ the credential against it and under its issuer, and each payment and
140
+ 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>`
142
+ verifies one transaction, with `--signed-offer <text>` the offer the agent
143
+ signed for it, and a check that fails exits 1. A seller that asks for the
144
+ agent's signed order (`requireSignedOffer` in `@metalntwx/metal-pay/mandates`,
145
+ with `extra.signedOffer: "required"` on the route) is paid with it, and
146
+ `pay` prints it. From code, `verifyMandate` and `verifyMandatePayment` do
147
+ the same, the latter for a payee that holds no credential.
148
+
149
+ `skills/metal-agent/SKILL.md` is an installable skill: the setup steps for
150
+ an agent, then the commands.
151
+
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:
158
+
159
+ ```json
160
+ { "command": "npx", "args": ["-y", "@metalntwx/metal-agent", "mcp"] }
161
+ ```
162
+
163
+ ## Develop
164
+
165
+ In the `mx56db/metal` repository, `just agent <command>` runs the CLI from
166
+ source. Devnet `fund` signs through
167
+ metal-ts's wasm codec; build it once with `just metal-ts-wasm`.
168
+
169
+ `METAL_RPC_URL=<endpoint> just agent-testnet-smoke`
170
+ (`scripts/testnet-smoke.ts`) runs the pay quickstart against the testnet
171
+ services: it checks the
172
+ facilitator's `/supported`, funds a throwaway key through the faucet (or uses
173
+ `METAL_SMOKE_PRIVATE_KEY`), pays the demo payee, confirms the
174
+ `MIP20Transfer` on chain, and exits non-zero on any failure.
175
+ `METAL_FACILITATOR_URL` and `METAL_DEMO_PAYEE_URL` point it elsewhere.
176
+
177
+ Run `pnpm test` and `pnpm typecheck`.
package/dist/chain.js ADDED
@@ -0,0 +1,55 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { createPublicClient, defineChain, http } from "viem";
3
+ import { USDZ, metalNetwork, rpcUrlFor } from "@metalntwx/metal-pay";
4
+ const NETWORKS_FILE = new URL("../networks.json", import.meta.url);
5
+ export function readNetworksFile() {
6
+ const parsed = JSON.parse(readFileSync(NETWORKS_FILE, "utf8"));
7
+ const settings = (name) => {
8
+ const entry = parsed[name];
9
+ if (!entry?.demoPayeeUrl) {
10
+ throw new Error(`networks file ${NETWORKS_FILE.pathname} has no complete "${name}" entry`);
11
+ }
12
+ return {
13
+ demoPayeeUrl: entry.demoPayeeUrl,
14
+ ...(entry.faucetUrl !== undefined && { faucetUrl: entry.faucetUrl }),
15
+ ...(entry.explorerUrl !== undefined && { explorerUrl: entry.explorerUrl }),
16
+ };
17
+ };
18
+ return { devnet: settings("devnet"), testnet: settings("testnet") };
19
+ }
20
+ export function resolveSettings(network, file, flags, env) {
21
+ const rpcUrl = flags.rpcUrl ?? env["METAL_RPC_URL"];
22
+ const faucetUrl = flags.faucetUrl ?? env["METAL_FAUCET_URL"] ?? file.faucetUrl;
23
+ return {
24
+ ...(rpcUrl !== undefined && { rpcUrl }),
25
+ facilitatorUrl: env["METAL_FACILITATOR_URL"] ?? network.facilitatorUrl,
26
+ demoPayeeUrl: env["METAL_DEMO_PAYEE_URL"] ?? file.demoPayeeUrl,
27
+ ...(faucetUrl !== undefined && { faucetUrl }),
28
+ ...(file.explorerUrl !== undefined && { explorerUrl: file.explorerUrl }),
29
+ nusd: USDZ.address,
30
+ };
31
+ }
32
+ export function target(networkFlag, flags = {}, env = process.env) {
33
+ if (!networkFlag)
34
+ throw new Error("--network devnet|testnet is required; there is no default network");
35
+ const network = metalNetwork(networkFlag);
36
+ if (!network || network.name !== networkFlag) {
37
+ throw new Error(`unknown network "${networkFlag}"; use devnet or testnet`);
38
+ }
39
+ return { ...resolveSettings(network, readNetworksFile()[network.name], flags, env), network };
40
+ }
41
+ /**
42
+ * A client for the target's JSON-RPC endpoint: --rpc-url, then METAL_RPC_URL,
43
+ * then the network default. On a network without a default it throws, saying
44
+ * how to get an endpoint and, using `how`, how to pass it.
45
+ */
46
+ export function rpcClient({ network, rpcUrl }, how = "pass --rpc-url or set METAL_RPC_URL") {
47
+ const url = rpcUrlFor(network, rpcUrl, how);
48
+ const chain = defineChain({
49
+ id: network.chainId,
50
+ name: `Metal ${network.name}`,
51
+ nativeCurrency: { name: "Gas", symbol: "GAS", decimals: 18 },
52
+ rpcUrls: { default: { http: [url] } },
53
+ });
54
+ return createPublicClient({ chain, transport: http(url) });
55
+ }
@@ -0,0 +1,128 @@
1
+ import { createWalletClient, custom, erc20Abi, getAddress, isAddress } from "viem";
2
+ import { privateKeyToAccount } from "viem/accounts";
3
+ import { USDZ, formatUsdz, usdzBaseUnits } from "@metalntwx/metal-pay";
4
+ import { rpcClient } from "../chain.js";
5
+ // Foundry test account #0, funded at genesis by `just devnet`. The key is
6
+ // public, so it only ever signs on devnet.
7
+ const DEVNET_PRINCIPAL_KEY = "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80";
8
+ // Gas and the 1-per-gas fee are explicit for the reason
9
+ // `buildSubmitAuthorizationTx` gives.
10
+ const FUND_GAS_LIMIT = 200000n;
11
+ export async function fund(target, addressArg, amountArg, print, fetchImpl = fetch) {
12
+ if (!addressArg || !isAddress(addressArg))
13
+ throw new Error("fund needs the agent's address");
14
+ const to = getAddress(addressArg);
15
+ if (target.network.name === "testnet") {
16
+ if (amountArg !== undefined) {
17
+ throw new Error("on testnet the faucet sets the amount; run fund without --amount");
18
+ }
19
+ await fundFromFaucet(target, to, print, fetchImpl);
20
+ return;
21
+ }
22
+ const publicClient = rpcClient(target);
23
+ await fundFromDevnetPrincipal(target, publicClient, to, BigInt(usdzBaseUnits(amountArg ?? "10")), print);
24
+ const balance = await publicClient.readContract({
25
+ address: target.nusd,
26
+ abi: erc20Abi,
27
+ functionName: "balanceOf",
28
+ args: [to],
29
+ });
30
+ print(`balance ${formatUsdz(balance)} ${USDZ.symbol}`);
31
+ }
32
+ async function fundFromFaucet(target, to, print, fetchImpl) {
33
+ if (!target.faucetUrl)
34
+ throw new Error(`no faucet is configured for ${target.network.name}`);
35
+ const url = new URL("/api/fund", target.faucetUrl);
36
+ const response = await fetchImpl(url, {
37
+ method: "POST",
38
+ headers: { "content-type": "application/json" },
39
+ body: JSON.stringify({ address: to, symbol: "USDZ" }),
40
+ });
41
+ const text = await response.text();
42
+ if (response.ok) {
43
+ const grant = safeJson(text);
44
+ if (!grant?.txHash || !grant.amount)
45
+ throw new Error(unexpectedAnswer(url, response, "a transaction"));
46
+ // The faucet answers 200 only once the grant's receipt shows success.
47
+ print(`funded ${to} with ${formatUsdz(grant.amount)} ${USDZ.symbol} from the testnet faucet`);
48
+ print(`transaction ${grant.txHash}`);
49
+ if (target.explorerUrl)
50
+ print(`explorer ${target.explorerUrl}/tx/${grant.txHash}`);
51
+ return;
52
+ }
53
+ const error = faucetError(text);
54
+ if (error?.code === "already_funded") {
55
+ print(`${to} already holds a full ${USDZ.symbol} grant from the testnet faucet`);
56
+ return;
57
+ }
58
+ if (!error && response.status === 429) {
59
+ throw new Error("faucet answered 429: the faucet's edge rate limit refused this client; retry shortly");
60
+ }
61
+ if (!error)
62
+ throw new Error(unexpectedAnswer(url, response, "an error code"));
63
+ throw new Error(`faucet answered ${response.status} ${error.code}: ${faucetRefusal(error)}`);
64
+ }
65
+ // Describes the response instead of quoting it, because a proxy in front of
66
+ // the faucet answers with a full HTML document.
67
+ function unexpectedAnswer(url, response, missing) {
68
+ const mediaType = response.headers.get("content-type")?.split(";")[0]?.trim() || "no content type";
69
+ return `faucet answered ${response.status} ${mediaType} from ${url} without ${missing}`;
70
+ }
71
+ function faucetRefusal(error) {
72
+ const keyImport = "or load a funded key with `metal-agent key import`";
73
+ switch (error.code) {
74
+ case "client_rate_limited":
75
+ return `this client asked the faucet too recently; ${error.retryAfter === undefined ? "retry shortly" : `retry in ${error.retryAfter} s`}`;
76
+ case "client_daily_limit_reached":
77
+ return `this client reached its daily faucet limit; try tomorrow, ${keyImport}`;
78
+ case "daily_limit_reached":
79
+ return `the faucet reached its daily grant limit for every client; try tomorrow, ${keyImport}`;
80
+ default:
81
+ return error.message;
82
+ }
83
+ }
84
+ function faucetError(text) {
85
+ const body = safeJson(text);
86
+ if (typeof body !== "object" || body === null)
87
+ return undefined;
88
+ const { code, message, retryAfter } = body;
89
+ if (typeof code !== "string" || typeof message !== "string")
90
+ return undefined;
91
+ return { code, message, ...(typeof retryAfter === "number" && { retryAfter }) };
92
+ }
93
+ async function fundFromDevnetPrincipal(target, publicClient, to, amount, print) {
94
+ const principal = privateKeyToAccount(DEVNET_PRINCIPAL_KEY);
95
+ const walletClient = createWalletClient({
96
+ account: principal,
97
+ chain: publicClient.chain,
98
+ transport: custom(publicClient),
99
+ });
100
+ // The SDK root loads its wasm codec, a build output only devnet funding
101
+ // needs; importing it here keeps every other command working without it.
102
+ const { writeMip20Contract } = await import("@metalntwx/metal-ts");
103
+ const hash = await writeMip20Contract({
104
+ walletClient,
105
+ publicClient,
106
+ abi: erc20Abi,
107
+ address: target.nusd,
108
+ functionName: "transfer",
109
+ args: [to, amount],
110
+ gasToken: target.nusd,
111
+ gasFallback: FUND_GAS_LIMIT,
112
+ gasLimit: FUND_GAS_LIMIT,
113
+ maxFeePerGas: 1n,
114
+ });
115
+ const receipt = await publicClient.waitForTransactionReceipt({ hash });
116
+ if (receipt.status !== "success")
117
+ throw new Error(`funding transaction ${hash} reverted`);
118
+ print(`funded ${to} with ${formatUsdz(amount)} ${USDZ.symbol} from ${principal.address}`);
119
+ print(`transaction ${hash}`);
120
+ }
121
+ function safeJson(text) {
122
+ try {
123
+ return JSON.parse(text);
124
+ }
125
+ catch {
126
+ return undefined;
127
+ }
128
+ }
@@ -0,0 +1,38 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { generatePrivateKey, privateKeyToAccount } from "viem/accounts";
3
+ import { DEFAULT_KEY_PATH, isPrivateKey, writeKey } from "../keystore.js";
4
+ export async function keyCreate(out, print) {
5
+ await store(generatePrivateKey(), out, print);
6
+ }
7
+ export async function keyImport(source, out, print) {
8
+ if (!source)
9
+ throw new Error("key import needs a hex private key or a file holding one");
10
+ const privateKey = asPrivateKey(source) ?? asPrivateKey(await readKeySource(source));
11
+ if (!privateKey)
12
+ throw new Error("key import found no 32-byte hex private key in the source");
13
+ await store(privateKey, out, print);
14
+ }
15
+ async function store(privateKey, out, print) {
16
+ const path = out ?? DEFAULT_KEY_PATH;
17
+ const { address } = privateKeyToAccount(privateKey);
18
+ await writeKey(path, { address, privateKey });
19
+ print(`address ${address}`);
20
+ print(`keyfile ${path}`);
21
+ }
22
+ async function readKeySource(path) {
23
+ const text = (await readFile(path, "utf8").catch(() => {
24
+ throw new Error("key import: the argument is neither a 32-byte hex private key nor a readable file");
25
+ })).trim();
26
+ try {
27
+ const parsed = JSON.parse(text);
28
+ return typeof parsed.privateKey === "string" ? parsed.privateKey : "";
29
+ }
30
+ catch {
31
+ return text;
32
+ }
33
+ }
34
+ function asPrivateKey(value) {
35
+ const trimmed = value.trim();
36
+ const hex = trimmed.startsWith("0x") ? trimmed : `0x${trimmed}`;
37
+ return isPrivateKey(hex) ? hex : undefined;
38
+ }