@metalntwx/metal-agent 0.1.0 → 0.2.0
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 +53 -31
- package/dist/commands/mandate.js +164 -43
- package/dist/commands/pay.js +15 -6
- package/dist/help.js +118 -0
- package/dist/index.js +26 -86
- package/dist/keystore.js +2 -2
- package/dist/mcp.js +58 -38
- package/package.json +3 -3
- package/skills/metal-agent/SKILL.md +37 -18
package/README.md
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
# @metalntwx/metal-agent
|
|
2
2
|
|
|
3
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
|
|
5
|
-
|
|
4
|
+
import a key, fund it, pay a URL, look up a settlement, or serve `pay`,
|
|
5
|
+
`fund` and `status` as MCP tools.
|
|
6
6
|
|
|
7
7
|
```bash
|
|
8
8
|
npm install -g @metalntwx/metal-agent
|
|
@@ -27,8 +27,9 @@ explorer URLs.
|
|
|
27
27
|
`METAL_RPC_URL` and `METAL_FAUCET_URL` override those defaults, and
|
|
28
28
|
`--rpc-url` and `--faucet-url` override both.
|
|
29
29
|
|
|
30
|
-
On testnet, `pay` and `fund` need no RPC. `status
|
|
31
|
-
|
|
30
|
+
On testnet, `pay` and `fund` need no RPC. `status`, `account create` and
|
|
31
|
+
every `mandate` command but `credential create` need an endpoint of your
|
|
32
|
+
own: sign up in the Metal console,
|
|
32
33
|
https://console-testnet.metalntwx.com, create an endpoint, and pass it with
|
|
33
34
|
`--rpc-url` or `METAL_RPC_URL`. On testnet, `pay` and `fund` print
|
|
34
35
|
an `explorer` line with the transaction's page on
|
|
@@ -48,7 +49,8 @@ which the devnet funds at genesis.
|
|
|
48
49
|
|
|
49
50
|
`pay` refuses, before it signs, an offer in any token but `*USDZ` and any
|
|
50
51
|
offer above `--max`, 1 `*USDZ` per payment by default. A refusal at
|
|
51
|
-
verification
|
|
52
|
+
verification, by the facilitator or the seller's own check, prints
|
|
53
|
+
`payment refused: <reason>`.
|
|
52
54
|
A refusal at settlement prints `payment refused at settlement: <reason>`,
|
|
53
55
|
with the transaction hash when there is one.
|
|
54
56
|
|
|
@@ -77,28 +79,33 @@ for the mandate's whole life, an expiry and, when `--payee` names any, the
|
|
|
77
79
|
addresses it may pay; with none named it may pay any address.
|
|
78
80
|
|
|
79
81
|
```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 credential create --principal <MetalAccount address> [--payee <address>[,<name>[,<website>]]]... \
|
|
83
|
+
--cap 5 --budget 10 [--description <text>] --key <owner key file> --out ./mandate.json --network testnet
|
|
82
84
|
metal-agent mandate bind --credential ./mandate.json --key <owner key file> --network testnet --rpc-url <endpoint>
|
|
83
85
|
metal-agent pay <url> --mandate ./mandate.json --network testnet
|
|
84
86
|
metal-agent mandate status --credential ./mandate.json --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 --issuer <issuer public key file> --network testnet --rpc-url <endpoint>
|
|
89
|
+
metal-agent mandate verify --credential ./mandate.json --issuer <issuer public key file> --tx <hash> --signed-offer <text> --network testnet --rpc-url <endpoint>
|
|
85
90
|
metal-agent mandate replace --credential ./wider.json --replaces ./mandate.json --key <owner key file> --network testnet --rpc-url <endpoint>
|
|
91
|
+
metal-agent mandate renew --credential ./next.json --replaces ./mandate.json --key <owner key file> --network testnet --rpc-url <endpoint>
|
|
86
92
|
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
93
|
```
|
|
91
94
|
|
|
92
95
|
`credential create` writes the limits into a credential file, a Verifiable
|
|
93
96
|
Intent mandate, and the agent's keys beside it: its mandate key in
|
|
94
97
|
`mandate.agent-key.json`, and in `mandate.gas-key.json` the key that pays
|
|
95
98
|
for its pulls. `--agent-key <file>` names a mandate key the agent already
|
|
96
|
-
has instead, with its gas key beside that. The
|
|
97
|
-
credential are
|
|
98
|
-
|
|
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
|
+
on first use (`owner-key.issuer.json`, with its public half in
|
|
102
|
+
`owner-key.issuer.public.json`), unless `--issuer-key` names their file.
|
|
103
|
+
`--description` says what the mandate is for, and `--payee` takes a name
|
|
104
|
+
and a website after the address. Every other command takes
|
|
99
105
|
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
|
|
101
|
-
mandate lasts 30 days unless `--expires-in` says otherwise
|
|
106
|
+
terms and the agent's key files derive from it. Amounts are in `*USDZ`, to the cent; a
|
|
107
|
+
mandate lasts 30 days unless `--expires-in` says otherwise, and its budget
|
|
108
|
+
never resets.
|
|
102
109
|
|
|
103
110
|
`account create --out <owner key file>` writes the owner's key and names the
|
|
104
111
|
owner's Metal account, a smart account that key controls with threshold one,
|
|
@@ -106,8 +113,7 @@ which the owner's first `bind` creates on chain; `--key <file>` uses a key
|
|
|
106
113
|
that exists. Fund the account and name it as the credential's `--principal`.
|
|
107
114
|
The account factory is the one `@metalntwx/metal-pay` names for the network;
|
|
108
115
|
on a devnet `just deploy` deploys it there, and `--factory <address>` names
|
|
109
|
-
another.
|
|
110
|
-
`@metalntwx/metal-pay` names the current one.
|
|
116
|
+
another. `@metalntwx/metal-pay` names the testnet's.
|
|
111
117
|
|
|
112
118
|
`bind`, `replace` and `revoke` execute self-calls through the account's weighted
|
|
113
119
|
quorum. `--key` names the gas sponsor and defaults as the sole signer; repeat
|
|
@@ -115,30 +121,38 @@ quorum. `--key` names the gas sponsor and defaults as the sole signer; repeat
|
|
|
115
121
|
The SDK's `authorizeMandateCall` accepts a quorum-signing callback for mixed
|
|
116
122
|
keys and passkeys. Keep owner keys outside the agent's reach: quorum-approved
|
|
117
123
|
transfers remain independent of mandates. `replace` revokes the mandate
|
|
118
|
-
`--replaces` names and carries its spend into the new one
|
|
124
|
+
`--replaces` names and carries its spend into the new one; `renew` binds the
|
|
125
|
+
new one and revokes the old in one batch, starting its spend at nothing, which
|
|
126
|
+
is how a budget starts over.
|
|
127
|
+
`bind`, `replace` and `renew` trust the issuer beside `--key` unless
|
|
128
|
+
`--issuer` names others.
|
|
119
129
|
|
|
120
130
|
Verification trusts that factory, never one a payer names: it checks the
|
|
121
|
-
account's immutable clone code against the factory's
|
|
122
|
-
|
|
131
|
+
account's immutable clone code against the factory's current
|
|
132
|
+
implementation, and checks successful token-transfer receipts.
|
|
123
133
|
|
|
124
134
|
`pay --mandate <credential>` pays from the owner's account, signed by the
|
|
125
135
|
agent's mandate key, and takes neither `--key` nor `--max`. A payment
|
|
126
|
-
outside the mandate is refused
|
|
127
|
-
|
|
136
|
+
outside the mandate is refused and moves nothing, usually with a
|
|
137
|
+
`mandate_*` reason: `payment refused: mandate_cap_exceeded (…)`. `pay` prints the
|
|
128
138
|
payment's `nonce` as soon as it signs; the facilitator's
|
|
129
139
|
`/settlements?payTo=<payee>` lists a settled payment by an id ending in it.
|
|
130
140
|
|
|
131
141
|
`mandate pull` pays an address the mandate allows directly, with no seller:
|
|
132
142
|
the agent's mandate key signs, and the `--key` account, the agent's gas key
|
|
133
143
|
by default, submits the transaction and pays its gas, so fund it with a
|
|
134
|
-
little `*USDZ`. A pull
|
|
135
|
-
|
|
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.
|
|
136
149
|
|
|
137
150
|
`mandate verify` checks the mandate and every payment under it from the
|
|
138
151
|
chain alone, through any RPC endpoint: the binding on the owner's account,
|
|
139
152
|
the credential against it and under its issuer, and each payment and
|
|
140
153
|
refusal as the agent key's own signed per-payment mandate, rebuilt from its
|
|
141
|
-
transaction. Anyone holding the credential
|
|
154
|
+
transaction. Anyone holding the credential and the issuer's public file,
|
|
155
|
+
passed as `--issuer`, can run it; `--tx <hash>`
|
|
142
156
|
verifies one transaction, with `--signed-offer <text>` the offer the agent
|
|
143
157
|
signed for it, and a check that fails exits 1. A seller that asks for the
|
|
144
158
|
agent's signed order (`requireSignedOffer` in `@metalntwx/metal-pay/mandates`,
|
|
@@ -149,17 +163,25 @@ the same, the latter for a payee that holds no credential.
|
|
|
149
163
|
`skills/metal-agent/SKILL.md` is an installable skill: the setup steps for
|
|
150
164
|
an agent, then the commands.
|
|
151
165
|
|
|
152
|
-
`metal-agent mcp` serves `pay`, `fund` and `status` over stdio,
|
|
153
|
-
|
|
154
|
-
`
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
no
|
|
166
|
+
`metal-agent mcp` serves `pay`, `fund` and `status` over stdio, each taking
|
|
167
|
+
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:
|
|
158
174
|
|
|
159
175
|
```json
|
|
160
176
|
{ "command": "npx", "args": ["-y", "@metalntwx/metal-agent", "mcp"] }
|
|
161
177
|
```
|
|
162
178
|
|
|
179
|
+
or, under one mandate:
|
|
180
|
+
|
|
181
|
+
```json
|
|
182
|
+
{ "command": "npx", "args": ["-y", "@metalntwx/metal-agent", "mcp", "--mandate", "/path/to/mandate.json"] }
|
|
183
|
+
```
|
|
184
|
+
|
|
163
185
|
## Develop
|
|
164
186
|
|
|
165
187
|
In the `mx56db/metal` repository, `just agent <command>` runs the CLI from
|
package/dist/commands/mandate.js
CHANGED
|
@@ -6,10 +6,11 @@
|
|
|
6
6
|
// Mandate Binding refuses anything outside the mandate. Every command takes
|
|
7
7
|
// the credential file: the account, the mandate's reference and its terms all
|
|
8
8
|
// derive from it.
|
|
9
|
-
import { readFile, writeFile } from "node:fs/promises";
|
|
10
|
-
import {
|
|
9
|
+
import { readFile, readdir, rm, writeFile } from "node:fs/promises";
|
|
10
|
+
import { dirname, join } from "node:path";
|
|
11
|
+
import { FEE_PER_GAS, assertMetalAccount, authorizeMandateCall, metalAccountFactoryAbi, MANDATE_MAX_VALIDITY_SECONDS, MANDATE_VALID_AFTER_LEAD_SECONDS, REFUSALS, UNCAPPED_OCCURRENCES, agentKeyOfJwk, bindCall, createCredential, describeRefusal, encodeAgentKey, extractTerms, hashCredential, ingestCredential, mandateBindingAbi, mandateReference, p256DigestSigner, parseCredential, privateKeyHex, publicMessage, pullCall, readBoundAgentKey, readCheck, readMandateState, reasonName, replaceCall, revokeCall, sameAgentKey, sendMandateCall, signL3a, transactionIdOf, trustedIssuer, verifyMandate, verifyMandatePayment, } from "@metalntwx/metal-pay/mandates";
|
|
11
12
|
import { USDZ, formatUsdz, usdzBaseUnits } from "@metalntwx/metal-pay";
|
|
12
|
-
import { BaseError, createWalletClient, custom, decodeErrorResult, encodeFunctionData, encodeAbiParameters, parseAbiParameters, getAddress, isAddress, isHash, parseEventLogs, slice, } from "viem";
|
|
13
|
+
import { BaseError, HttpRequestError, TimeoutError, createWalletClient, custom, decodeErrorResult, encodeFunctionData, encodeAbiParameters, parseAbiParameters, getAddress, isAddress, isAddressEqual, isHash, parseEventLogs, slice, } from "viem";
|
|
13
14
|
import { rpcClient } from "../chain.js";
|
|
14
15
|
import { agentKeyPath, exists, gasKeyPath, publicKeyPath, readKey, readOrCreateIssuerKeys, readOrCreateKey, readOrCreateP256Key, readP256Key, readTrustedIssuer, writePublicKeyFile, } from "../keystore.js";
|
|
15
16
|
export async function loadMandate(credentialPath) {
|
|
@@ -17,7 +18,7 @@ export async function loadMandate(credentialPath) {
|
|
|
17
18
|
const terms = extractTerms(credential);
|
|
18
19
|
return { credential, terms, reference: mandateReference(terms.principal, terms.agentKey, hashCredential(credential)) };
|
|
19
20
|
}
|
|
20
|
-
/** A decimal `*USDZ` amount
|
|
21
|
+
/** A decimal `*USDZ` amount to the cent, as a credential states its cap and budget. */
|
|
21
22
|
export function parseCents(text, option) {
|
|
22
23
|
if (text === undefined || !/^\d+(\.\d{1,2})?$/.test(text) || Number(text) <= 0) {
|
|
23
24
|
throw new Error(`${option} must be a positive ${USDZ.symbol} amount with at most 2 decimals; got "${text ?? ""}"`);
|
|
@@ -29,6 +30,19 @@ export function parseAddress(text, option) {
|
|
|
29
30
|
throw new Error(`${option} must be an address; got "${text ?? ""}"`);
|
|
30
31
|
return getAddress(text);
|
|
31
32
|
}
|
|
33
|
+
/** `<address>[,<name>[,<website>]]`, as `--payee` takes it. */
|
|
34
|
+
export function parsePayee(text, option) {
|
|
35
|
+
const [address, name, website, ...rest] = (text ?? "").split(",").map((part) => part.trim());
|
|
36
|
+
if (address === undefined || !isAddress(address))
|
|
37
|
+
throw new Error(`${option} must start with an address, then optionally a name and a website, comma-separated; got "${text ?? ""}"`);
|
|
38
|
+
if (rest.length > 0)
|
|
39
|
+
throw new Error(`${option} takes at most an address, a name and a website; got "${text ?? ""}"`);
|
|
40
|
+
if (name === "")
|
|
41
|
+
throw new Error(`${option}'s name is empty; leave it out to show the address; got "${text ?? ""}"`);
|
|
42
|
+
if (website !== undefined && !/^https?:\/\/[^\s]+$/.test(website))
|
|
43
|
+
throw new Error(`${option}'s website must be an http or https URL; got "${website}"`);
|
|
44
|
+
return { address: getAddress(address), ...(name !== undefined && { name }), ...(website !== undefined && { website }) };
|
|
45
|
+
}
|
|
32
46
|
export function parseTxHash(text, option) {
|
|
33
47
|
if (!isHash(text))
|
|
34
48
|
throw new Error(`${option} must be a transaction hash; got "${text}"`);
|
|
@@ -46,7 +60,11 @@ export function parseCount(text, option) {
|
|
|
46
60
|
const usdz = (units) => `${formatUsdz(units)} ${USDZ.symbol}`;
|
|
47
61
|
const utc = (seconds) => new Date(Number(seconds) * 1000).toISOString();
|
|
48
62
|
const day = (seconds) => new Date(seconds * 1000).toISOString().slice(0, 10);
|
|
49
|
-
function printTerms({ principal, agentKey, constraints: c, terms }, print) {
|
|
63
|
+
function printTerms({ principal, agentKey, constraints: c, terms, descriptive }, print) {
|
|
64
|
+
// The credential's own text is shown with control, line-separator, bidi and invisible characters replaced, so it cannot start a line,
|
|
65
|
+
// reorder one or hide text; the joiners scripts and emoji need are kept.
|
|
66
|
+
if (typeof descriptive.promptSummary === "string")
|
|
67
|
+
print(`description ${descriptive.promptSummary.replace(/[\p{Cc}\p{Zl}\p{Zp}\u061c\u200b\u200e\u200f\u202a-\u202e\u2060\u2066-\u2069\ufeff\u{e0000}-\u{e007f}]/gu, "?")}`);
|
|
50
68
|
print(`principal ${principal}`);
|
|
51
69
|
print(`agent-key ${encodeAgentKey(agentKey)} (P-256)`);
|
|
52
70
|
print(`token ${c.token}`);
|
|
@@ -57,8 +75,13 @@ function printTerms({ principal, agentKey, constraints: c, terms }, print) {
|
|
|
57
75
|
print(`max-occurrences ${c.maxOccurrences}`);
|
|
58
76
|
if (terms.payees.length === 0)
|
|
59
77
|
print("payees any");
|
|
60
|
-
|
|
61
|
-
|
|
78
|
+
const shown = Array.isArray(descriptive.payees) ? descriptive.payees : [];
|
|
79
|
+
for (const payee of terms.payees) {
|
|
80
|
+
const object = shown.find((o) => typeof o.id === "string" && o.id.toLowerCase() === payee.account.toLowerCase());
|
|
81
|
+
const name = typeof object?.name === "string" && object.name.toLowerCase() !== payee.account.toLowerCase() ? ` ${object.name}` : "";
|
|
82
|
+
const website = typeof object?.website === "string" && /^https?:/.test(object.website) ? ` ${object.website}` : "";
|
|
83
|
+
print(`payee ${payee.account}${name}${website}`.replace(/[\p{Cc}\p{Zl}\p{Zp}\u061c\u200b\u200e\u200f\u202a-\u202e\u2060\u2066-\u2069\ufeff\u{e0000}-\u{e007f}]/gu, "?"));
|
|
84
|
+
}
|
|
62
85
|
}
|
|
63
86
|
function walletFor(publicClient, account) {
|
|
64
87
|
if (!publicClient.chain)
|
|
@@ -99,34 +122,46 @@ export async function credentialCreate(target, args, print) {
|
|
|
99
122
|
const agent = await readOrCreateP256Key(agentPath);
|
|
100
123
|
const gasPath = gasKeyPath(agentPath);
|
|
101
124
|
const gas = await readOrCreateKey(gasPath);
|
|
102
|
-
const { keys, created } = await readOrCreateIssuerKeys(args.issuerKeyPath)
|
|
125
|
+
const { keys, created } = await readOrCreateIssuerKeys(args.issuerKeyPath).catch(async (error) => {
|
|
126
|
+
await Promise.all([agent.created && rm(agentPath), gas.created && rm(gasPath)]);
|
|
127
|
+
throw error;
|
|
128
|
+
});
|
|
103
129
|
const now = Math.floor(Date.now() / 1000);
|
|
104
130
|
const expiresAt = now + args.expiresIn;
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
131
|
+
let credential;
|
|
132
|
+
let ingested;
|
|
133
|
+
try {
|
|
134
|
+
credential = await createCredential({
|
|
135
|
+
issuer: keys.issuer,
|
|
136
|
+
principal: args.principal,
|
|
137
|
+
principalKey: keys.principalKey,
|
|
138
|
+
agentKey: agent.key.publicJwk,
|
|
139
|
+
perPaymentCap: args.cap,
|
|
140
|
+
budget: args.budget,
|
|
141
|
+
payees: args.payees.map(({ address, name, website }) => ({ address, name: name ?? address, website: website ?? `eip155:${target.network.chainId}:${address}` })),
|
|
142
|
+
recurrence: {
|
|
143
|
+
frequency: "ADHO",
|
|
144
|
+
startDate: day(now),
|
|
145
|
+
endDate: day(expiresAt),
|
|
146
|
+
...(args.maxOccurrences !== undefined && { maxOccurrences: args.maxOccurrences }),
|
|
147
|
+
},
|
|
148
|
+
issuedAt: now,
|
|
149
|
+
expiresAt,
|
|
150
|
+
description: args.description,
|
|
151
|
+
});
|
|
152
|
+
ingested = await ingestCredential(credential, { trustedIssuers: [trustedIssuer(keys.issuer)], account: args.principal, now });
|
|
153
|
+
}
|
|
154
|
+
catch (error) {
|
|
155
|
+
// The keys this run created name nothing yet; removing them lets the corrected command run again.
|
|
156
|
+
await Promise.all([agent.created && rm(agentPath), gas.created && rm(gasPath), created && rm(args.issuerKeyPath)]);
|
|
157
|
+
throw error;
|
|
158
|
+
}
|
|
124
159
|
await writeFile(args.out, `${JSON.stringify(credential, null, 2)}\n`, { flag: "wx" });
|
|
125
160
|
const issuerPublicPath = publicKeyPath(args.issuerKeyPath);
|
|
126
161
|
await writePublicKeyFile(issuerPublicPath, trustedIssuer(keys.issuer));
|
|
127
162
|
print(`credential ${args.out}`);
|
|
128
163
|
print(`reference ${ingested.reference}`);
|
|
129
|
-
printTerms(ingested, print);
|
|
164
|
+
printTerms(ingested.extracted, print);
|
|
130
165
|
print(`agent-key-file ${agentPath}${agent.created ? " (created)" : ""}`);
|
|
131
166
|
print(`gas-key-file ${gasPath}${gas.created ? " (created)" : ""}`);
|
|
132
167
|
print(`gas-key-address ${gas.address}`);
|
|
@@ -173,6 +208,9 @@ export async function accountCreate(target, args, print) {
|
|
|
173
208
|
print(`keyfile ${keyPath}${created ? " (created)" : ""}`);
|
|
174
209
|
}
|
|
175
210
|
async function sendOwnerCall(client, args, call, createAccount = false) {
|
|
211
|
+
const account = (Array.isArray(call) ? call[0] : call)?.to;
|
|
212
|
+
if (account === undefined)
|
|
213
|
+
throw new Error("no account call to send");
|
|
176
214
|
const sponsor = await readKey(args.keyPath);
|
|
177
215
|
const signers = await Promise.all((args.signerPaths.length === 0 ? [args.keyPath] : args.signerPaths).map(readKey));
|
|
178
216
|
signers.sort((a, b) => a.address.toLowerCase().localeCompare(b.address.toLowerCase()));
|
|
@@ -181,14 +219,14 @@ async function sendOwnerCall(client, args, call, createAccount = false) {
|
|
|
181
219
|
// A one-owner account is created by its owner's first bind; the sponsor pays.
|
|
182
220
|
// One with several signers is created by the factory beforehand, with its
|
|
183
221
|
// signers, weights and threshold, which these key files alone do not name.
|
|
184
|
-
const code = createAccount ? await client.getCode({ address:
|
|
222
|
+
const code = createAccount ? await client.getCode({ address: account }) : "0x01";
|
|
185
223
|
if ((code === undefined || code === "0x") && args.signerPaths.length > 0) {
|
|
186
|
-
throw new Error(`${
|
|
224
|
+
throw new Error(`${account} has no code: create the account first, through MetalAccountFactory.createAccount with its signers, weights and threshold; only a one-owner account is created by its owner's first bind`);
|
|
187
225
|
}
|
|
188
226
|
if (code === undefined || code === "0x") {
|
|
189
227
|
const { config, address } = await ownerAccount(client, args.accountFactory, sponsor.address);
|
|
190
|
-
if (address.toLowerCase() !==
|
|
191
|
-
throw new Error(`${
|
|
228
|
+
if (address.toLowerCase() !== account.toLowerCase())
|
|
229
|
+
throw new Error(`${account} is not the account of ${sponsor.address}; it has no code, and only its owner's first bind creates it`);
|
|
192
230
|
await send(client, sponsor, { to: args.accountFactory, data: encodeFunctionData({ abi: metalAccountFactoryAbi, functionName: "createAccount", args: config }), gas: 600000n, maxFeePerGas: FEE_PER_GAS, maxPriorityFeePerGas: FEE_PER_GAS });
|
|
193
231
|
}
|
|
194
232
|
const authorized = await authorizeMandateCall(client, args.accountFactory, call, async (digest) => encodeAbiParameters(parseAbiParameters("bytes[], bytes[]"), [signers.map(s => s.address), await Promise.all(signers.map(s => s.sign({ hash: digest })))]));
|
|
@@ -201,11 +239,20 @@ export async function mandateBind(target, args, print) {
|
|
|
201
239
|
print(`bound ${ingested.reference}`);
|
|
202
240
|
printSent(target, receipt, print);
|
|
203
241
|
}
|
|
242
|
+
/** Checks that the mandate a replace or renew ends is bound and not yet revoked, which the account's revert would not say. */
|
|
243
|
+
async function assertLive(publicClient, mandate, path) {
|
|
244
|
+
const state = await publicClient.readContract({ address: mandate.terms.principal, abi: mandateBindingAbi, functionName: "mandates", args: [mandate.reference] });
|
|
245
|
+
if (!isAddressEqual(state.principal, mandate.terms.principal))
|
|
246
|
+
throw new Error(`${path} is not bound on ${mandate.terms.principal}; bind the new credential with mandate bind`);
|
|
247
|
+
if (state.revoked)
|
|
248
|
+
throw new Error(`${path} is already revoked; bind the new credential with mandate bind`);
|
|
249
|
+
}
|
|
204
250
|
/** Binds the credential as the successor of the mandate `replacesPath` describes, which is revoked in the same transaction; the successor carries its spend. */
|
|
205
251
|
export async function mandateReplace(target, args, print) {
|
|
206
252
|
const { ingested } = await ingestFor(args);
|
|
207
253
|
const predecessor = await loadMandate(args.replacesPath);
|
|
208
254
|
const publicClient = rpcClient(target);
|
|
255
|
+
await assertLive(publicClient, predecessor, args.replacesPath);
|
|
209
256
|
const carried = await readMandateState(publicClient, predecessor.terms.principal, predecessor.reference);
|
|
210
257
|
if (carried.spent >= ingested.constraints.budget)
|
|
211
258
|
throw new Error(`the replacement's budget of ${usdz(ingested.constraints.budget)} is not above the ${usdz(carried.spent)} already spent, which it carries`);
|
|
@@ -218,6 +265,28 @@ export async function mandateReplace(target, args, print) {
|
|
|
218
265
|
print(`spent ${usdz(state.spent)} of ${usdz(ingested.constraints.budget)}`);
|
|
219
266
|
printSent(target, receipt, print);
|
|
220
267
|
}
|
|
268
|
+
/**
|
|
269
|
+
* Binds the credential as a fresh mandate and revokes the one `replacesPath`
|
|
270
|
+
* describes, in one account batch: the new budget starts at zero spent, as a
|
|
271
|
+
* budget never resets on its own. `replace` carries the spend over instead.
|
|
272
|
+
*/
|
|
273
|
+
export async function mandateRenew(target, args, print) {
|
|
274
|
+
const { ingested } = await ingestFor(args);
|
|
275
|
+
const predecessor = await loadMandate(args.replacesPath);
|
|
276
|
+
if (predecessor.reference === ingested.reference)
|
|
277
|
+
throw new Error(`${args.replacesPath} is the mandate being bound; to end it, use mandate revoke`);
|
|
278
|
+
if (predecessor.terms.principal.toLowerCase() !== ingested.principal.toLowerCase()) {
|
|
279
|
+
throw new Error(`${args.replacesPath} is a mandate on ${predecessor.terms.principal}, not on ${ingested.principal}`);
|
|
280
|
+
}
|
|
281
|
+
const publicClient = rpcClient(target);
|
|
282
|
+
await assertLive(publicClient, predecessor, args.replacesPath);
|
|
283
|
+
const receipt = await sendOwnerCall(publicClient, args, [bindCall(ingested), revokeCall(ingested.principal, predecessor.reference)]);
|
|
284
|
+
const state = await readMandateState(publicClient, ingested.principal, ingested.reference);
|
|
285
|
+
print(`revoked ${predecessor.reference}`);
|
|
286
|
+
print(`bound ${ingested.reference}`);
|
|
287
|
+
print(`spent ${usdz(state.spent)} of ${usdz(ingested.constraints.budget)}`);
|
|
288
|
+
printSent(target, receipt, print);
|
|
289
|
+
}
|
|
221
290
|
export async function mandateRevoke(target, args, print) {
|
|
222
291
|
const { reference, terms } = await loadMandate(args.credentialPath);
|
|
223
292
|
const receipt = await sendOwnerCall(rpcClient(target), args, revokeCall(terms.principal, reference));
|
|
@@ -278,7 +347,18 @@ export async function mandateVerify(target, args, print) {
|
|
|
278
347
|
}
|
|
279
348
|
/** The agent's P-256 key, checked to be the one the mandate names. */
|
|
280
349
|
export async function mandateAgentSigner(mandate, agentKeyPath) {
|
|
281
|
-
const agent = await readP256Key(agentKeyPath)
|
|
350
|
+
const agent = await readP256Key(agentKeyPath).catch(async (error) => {
|
|
351
|
+
if (error.code !== "ENOENT")
|
|
352
|
+
throw error;
|
|
353
|
+
// A replacement credential names the agent key it was made with, which sits beside the first credential.
|
|
354
|
+
const dir = dirname(agentKeyPath);
|
|
355
|
+
for (const file of (await readdir(dir)).filter((name) => name.endsWith(".agent-key.json"))) {
|
|
356
|
+
const key = await readP256Key(join(dir, file)).catch(() => undefined);
|
|
357
|
+
if (key && sameAgentKey(agentKeyOfJwk(key.publicJwk), mandate.terms.agentKey))
|
|
358
|
+
throw new Error(`no agent key at ${agentKeyPath}; this mandate's agent key is ${join(dir, file)}: pass --agent-key ${join(dir, file)}`);
|
|
359
|
+
}
|
|
360
|
+
throw new Error(`no agent key at ${agentKeyPath}; pass --agent-key with the agent-key-file that credential create printed for it`);
|
|
361
|
+
});
|
|
282
362
|
if (!sameAgentKey(agentKeyOfJwk(agent.publicJwk), mandate.terms.agentKey))
|
|
283
363
|
throw new Error(`${agentKeyPath} is not the mandate's agent key`);
|
|
284
364
|
return p256DigestSigner(privateKeyHex(agent));
|
|
@@ -301,9 +381,12 @@ function revertName(error) {
|
|
|
301
381
|
/**
|
|
302
382
|
* A direct payment under the mandate, with no seller: the agent signs it and
|
|
303
383
|
* the `--key` account submits it to `pull` on the principal's account and
|
|
304
|
-
* pays the gas.
|
|
305
|
-
*
|
|
306
|
-
*
|
|
384
|
+
* pays the gas. One the mandate refuses when checked is reported and not
|
|
385
|
+
* sent, as an x402 payment is refused before anything is sent, unless
|
|
386
|
+
* `recordRefusal` asks for Mandate Binding to record the refusal on chain.
|
|
387
|
+
* The transaction is printed as soon as it is sent, so a pull whose receipt
|
|
388
|
+
* is lost is still found. The outcome is returned so a refusal can fail the
|
|
389
|
+
* command.
|
|
307
390
|
*/
|
|
308
391
|
export async function mandatePull(target, args, print) {
|
|
309
392
|
const mandate = await loadMandate(args.credentialPath);
|
|
@@ -314,6 +397,20 @@ export async function mandatePull(target, args, print) {
|
|
|
314
397
|
throw error;
|
|
315
398
|
});
|
|
316
399
|
const { terms, reference } = mandate;
|
|
400
|
+
const publicClient = rpcClient(target);
|
|
401
|
+
// A mandate not yet bound, or before its dates, can stop refusing; these are
|
|
402
|
+
// checked before the agent signs, so a pull reported unsent can never pay later.
|
|
403
|
+
const [bound, block, gas] = await Promise.all([
|
|
404
|
+
publicClient.getCode({ address: terms.principal }).then((code) => (code === undefined || code === "0x" ? undefined : readBoundAgentKey(publicClient, terms.principal, reference))),
|
|
405
|
+
publicClient.getBlock(),
|
|
406
|
+
publicClient.getBalance({ address: sender.address }),
|
|
407
|
+
]);
|
|
408
|
+
if (bound === undefined)
|
|
409
|
+
throw new Error(`the mandate ${reference} is not bound on ${terms.principal}; the owner binds it with mandate bind`);
|
|
410
|
+
if (block.timestamp < terms.constraints.recurrenceStart && !args.recordRefusal)
|
|
411
|
+
return printUnsent("OutsideRecurrence", print);
|
|
412
|
+
if (gas === 0n)
|
|
413
|
+
throw new Error(`${sender.address}, which submits the pull, holds no gas: fund it, the gas-key-address credential create printed, and run the pull again`);
|
|
317
414
|
const chainId = target.network.chainId;
|
|
318
415
|
const now = BigInt(Math.floor(Date.now() / 1000));
|
|
319
416
|
const signed = await signL3a({
|
|
@@ -336,17 +433,39 @@ export async function mandatePull(target, args, print) {
|
|
|
336
433
|
iss: terms.terms.kid,
|
|
337
434
|
signDigest,
|
|
338
435
|
});
|
|
339
|
-
const publicClient = rpcClient(target);
|
|
340
436
|
const call = pullCall(terms.principal, signed.pull, signed.fields);
|
|
341
437
|
try {
|
|
342
438
|
// At the node's own gas ceiling: what the pull costs is sendMandateCall's to estimate.
|
|
343
439
|
await publicClient.call({ account: sender.address, to: call.to, data: call.data });
|
|
344
440
|
}
|
|
345
441
|
catch (error) {
|
|
442
|
+
if (lostInTransport(error))
|
|
443
|
+
throw new Error(`the pull could not be checked: ${publicMessage(error)}`);
|
|
346
444
|
throw new Error(`Mandate Binding rejects the pull and records nothing: ${revertName(error)}`);
|
|
347
445
|
}
|
|
348
|
-
const
|
|
349
|
-
|
|
446
|
+
const reason = await readCheck(publicClient, terms.principal, signed.pull, signed.fields);
|
|
447
|
+
if (reason !== "None" && !args.recordRefusal)
|
|
448
|
+
return printUnsent(reason, print);
|
|
449
|
+
const hash = await sendMandateCall(publicClient, walletFor(publicClient, sender), call);
|
|
450
|
+
// Printed before the wait: a pull whose receipt never arrives is still found by its transaction.
|
|
451
|
+
print(`transaction ${hash}`);
|
|
452
|
+
const receipt = await confirm(publicClient, hash).catch((error) => {
|
|
453
|
+
throw new Error(`${error instanceof Error ? error.message : String(error)}; the pull was sent in transaction ${hash}: check it with metal-agent status ${hash} before pulling again`);
|
|
454
|
+
});
|
|
455
|
+
return printPull(target, terms, slice(signed.pull.nonce, 12), receipt, print);
|
|
456
|
+
}
|
|
457
|
+
/** Whether the request failed on its way, so the node may or may not have acted on it. */
|
|
458
|
+
function lostInTransport(error) {
|
|
459
|
+
return error instanceof BaseError && error.walk((e) => e instanceof HttpRequestError || e instanceof TimeoutError) !== null;
|
|
460
|
+
}
|
|
461
|
+
function printUnsent(reason, print) {
|
|
462
|
+
print("outcome refused, not sent");
|
|
463
|
+
print(`reason ${describeRefusal(reason)}`);
|
|
464
|
+
print("recorded no");
|
|
465
|
+
return "refused";
|
|
466
|
+
}
|
|
467
|
+
function printPull(target, terms, l3aDigest, receipt, print) {
|
|
468
|
+
const outcome = parseEventLogs({ abi: mandateBindingAbi, logs: receipt.logs, eventName: ["MandatePayment", "MandateRefused"] }).find((log) => log.address.toLowerCase() === terms.principal.toLowerCase() && log.args.l3aDigest === l3aDigest);
|
|
350
469
|
if (outcome?.eventName === "MandatePayment") {
|
|
351
470
|
print("outcome paid");
|
|
352
471
|
print(`spent ${usdz(outcome.args.spent)} of ${usdz(terms.constraints.budget)}`);
|
|
@@ -362,12 +481,14 @@ export async function mandatePull(target, args, print) {
|
|
|
362
481
|
throw new Error(`transaction ${receipt.transactionHash} carries neither a payment nor a refusal for the pull`);
|
|
363
482
|
}
|
|
364
483
|
print(`payer ${terms.principal}`);
|
|
365
|
-
print(`payee ${
|
|
366
|
-
print(`amount ${usdz(
|
|
367
|
-
|
|
484
|
+
print(`payee ${outcome.args.to}`);
|
|
485
|
+
print(`amount ${usdz(outcome.args.amount)}`);
|
|
486
|
+
if (target.explorerUrl)
|
|
487
|
+
print(`explorer ${target.explorerUrl}/tx/${receipt.transactionHash}`);
|
|
488
|
+
print(`gas ${receipt.gasUsed}`);
|
|
368
489
|
return outcome.eventName === "MandatePayment" ? "paid" : "refused";
|
|
369
490
|
}
|
|
370
|
-
/** A decimal `*USDZ` amount a mandate can pay: positive,
|
|
491
|
+
/** A decimal `*USDZ` amount a mandate can pay: positive, to the cent. */
|
|
371
492
|
export function parsePullAmount(text, option) {
|
|
372
493
|
return BigInt(usdzBaseUnits(parseCents(text, option)));
|
|
373
494
|
}
|
package/dist/commands/pay.js
CHANGED
|
@@ -102,6 +102,9 @@ async function paidRequest(target, { fetchWithPayment, client, payer, raise }, u
|
|
|
102
102
|
const how = raise !== undefined && aborted[1].endsWith("limit per payment") ? `; ${raise}` : "";
|
|
103
103
|
throw new Error(`payment refused before signing: ${aborted[1]}${how}`);
|
|
104
104
|
}
|
|
105
|
+
if (signed !== undefined) {
|
|
106
|
+
throw new Error(`${error instanceof Error ? error.message : String(error)}; a payment was signed and may have settled; look it up by its nonce before paying again`);
|
|
107
|
+
}
|
|
105
108
|
throw error;
|
|
106
109
|
});
|
|
107
110
|
const body = await response.text();
|
|
@@ -113,14 +116,17 @@ async function paidRequest(target, { fetchWithPayment, client, payer, raise }, u
|
|
|
113
116
|
const priced = offer ? `${formatUsdz(offer.amount)} ${USDZ.symbol} on ${offer.network}` : "no offer";
|
|
114
117
|
throw new Error(`payment refused: ${describeRefusalReason(required.error)} (offer: ${priced})`);
|
|
115
118
|
}
|
|
116
|
-
|
|
117
|
-
|
|
119
|
+
const settlement = paymentResponse ? decodePaymentResponseHeader(paymentResponse) : undefined;
|
|
120
|
+
if (settlement !== undefined && !settlement.success)
|
|
121
|
+
throw new Error(`payment refused at settlement: ${settleRefusal(settlement)}`);
|
|
122
|
+
// A redirect is reported, not an error: the payment may have settled, and `location` says where it points.
|
|
123
|
+
if (response.status >= 400) {
|
|
124
|
+
// A seller that fails after the payment was signed may have settled it first.
|
|
125
|
+
const doubt = signed !== undefined && response.status >= 500 ? "; a payment was signed and may have settled; look it up by its nonce before paying again" : "";
|
|
126
|
+
throw new Error(`${url} answered ${response.status}: ${body}${doubt}`);
|
|
118
127
|
}
|
|
119
|
-
if (!response.ok)
|
|
120
|
-
throw new Error(`${url} answered ${response.status}: ${body}`);
|
|
121
128
|
print(`status ${response.status}`);
|
|
122
|
-
if (
|
|
123
|
-
const settlement = decodePaymentResponseHeader(paymentResponse);
|
|
129
|
+
if (settlement !== undefined) {
|
|
124
130
|
print(`payer ${settlement.payer ?? payer}`);
|
|
125
131
|
if (signed) {
|
|
126
132
|
print(`payee ${signed.payTo}`);
|
|
@@ -131,6 +137,9 @@ async function paidRequest(target, { fetchWithPayment, client, payer, raise }, u
|
|
|
131
137
|
if (target.explorerUrl)
|
|
132
138
|
print(`explorer ${target.explorerUrl}/tx/${settlement.transaction}`);
|
|
133
139
|
}
|
|
140
|
+
else if (signed !== undefined) {
|
|
141
|
+
print("payment unconfirmed (signed, but the seller returned no settlement; look it up by its nonce)");
|
|
142
|
+
}
|
|
134
143
|
else {
|
|
135
144
|
print("payment none (the resource did not ask for one)");
|
|
136
145
|
}
|
package/dist/help.js
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { DEFAULT_KEY_PATH } from "./keystore.js";
|
|
2
|
+
const NETWORK = "--network devnet|testnet";
|
|
3
|
+
const COMMANDS = [
|
|
4
|
+
{
|
|
5
|
+
name: "key create",
|
|
6
|
+
usage: "metal-agent key create [--out <file>]",
|
|
7
|
+
about: `Generates the agent's key and writes it to a key file only its owner can read, ${DEFAULT_KEY_PATH} unless --out names another. It never overwrites a key file.`,
|
|
8
|
+
},
|
|
9
|
+
{
|
|
10
|
+
name: "key import",
|
|
11
|
+
usage: "metal-agent key import <file|hex> [--out <file>]",
|
|
12
|
+
about: "Loads a key someone funded for you: a hex key, a file holding one, or a key file. Prints the address, never the key.",
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
name: "fund",
|
|
16
|
+
usage: `metal-agent fund <address> --network devnet [--amount <usdz>]\nmetal-agent fund <address> --network testnet`,
|
|
17
|
+
about: "On testnet, asks the public faucet, which sets the amount. On devnet, moves *USDZ from the devnet's pre-funded account, 10 by default.",
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
name: "pay",
|
|
21
|
+
usage: `metal-agent pay <url> ${NETWORK} [--key <file>] [--max <usdz>]\n [--method <method>] [--body <text>] [--header 'name: value']...\nmetal-agent pay <url> --mandate <credential> ${NETWORK} [--agent-key <file>]`,
|
|
22
|
+
about: "Requests the URL and pays its x402 offer in *USDZ. With --key, the agent pays from its own key and refuses, before signing, any offer above --max (1 *USDZ by default). With --mandate, it pays from the owner's account within the mandate's limits, signed by the agent's mandate key, the <credential>.agent-key.json beside the credential unless --agent-key names another. --method (GET by default, POST with a --body), --body (application/json unless a --header names a content type) and each --header are sent again with the payment.",
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
name: "status",
|
|
26
|
+
usage: `metal-agent status <txHash> ${NETWORK} [--rpc-url <url>]`,
|
|
27
|
+
about: "Shows a settlement from chain data: status, block, submitter, gas, and the transfer it carried. On testnet it needs an RPC endpoint of your own.",
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
name: "mcp",
|
|
31
|
+
usage: "metal-agent mcp [--key <file>] [--max <usdz>]\nmetal-agent mcp --mandate <credential> [--agent-key <file>]",
|
|
32
|
+
about: "Serves pay, fund and status as MCP tools over stdio. What pay spends is fixed here, by whoever starts the server: its key and --max, or one mandate. The model calling the tools reads pages and offers anyone can write, so it can choose no other key, no mandate and no higher limit; under a key, a max it passes can only lower the server's.",
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
name: "account create",
|
|
36
|
+
usage: `metal-agent account create --out <owner key file to create> | --key <owner key file> ${NETWORK}`,
|
|
37
|
+
about: "Names the owner's Metal account, a smart account the owner's key controls, and prints its address, the --principal of the owner's credentials. It sends nothing: the owner's first bind creates the account on chain. Keep the owner's key where no agent can read it.",
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
name: "mandate credential create",
|
|
41
|
+
usage: `metal-agent mandate credential create --principal <address> --cap <usdz> --budget <usdz> --out <file>\n --key <owner key file> | --issuer-key <file> ${NETWORK}\n [--payee <address>[,<name>[,<website>]]]... [--expires-in <seconds>] [--max-occurrences <n>]\n [--description <text>] [--agent-key <file>]`,
|
|
42
|
+
about: "Writes the limits an agent pays within into a credential file: the most per payment (--cap), the most in total over the mandate's whole life (--budget, which never resets), an expiry (--expires-in, 30 days by default) and, when --payee names any, the addresses it may pay, each with an optional name and website. Amounts are in *USDZ, to the cent. The agent's mandate key and the gas key for its pulls are written beside the credential unless --agent-key names a mandate key the agent already has. The keys that sign the credential are the owner's: they live beside the owner's key file, <key>.issuer.json, created on first use, unless --issuer-key names their file.",
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
name: "mandate bind",
|
|
46
|
+
usage: `metal-agent mandate bind --credential <file> --key <owner key file> ${NETWORK} [--issuer <file>]... [--signer-key <file>]...`,
|
|
47
|
+
about: "Binds the credential on the owner's account, which then pays under it. The account's quorum signs: --key pays the gas and is the sole signer unless repeated --signer-key names the quorum's keys. --issuer names the issuers whose credentials the owner accepts, the <key>.issuer.public.json beside the owner's key by default.",
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
name: "mandate replace",
|
|
51
|
+
usage: `metal-agent mandate replace --credential <file> --replaces <file> --key <owner key file> ${NETWORK}\n [--issuer <file>]... [--signer-key <file>]...`,
|
|
52
|
+
about: "Binds the credential in place of the mandate --replaces names, which is revoked in the same transaction. What was spent carries over into the new budget.",
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
name: "mandate renew",
|
|
56
|
+
usage: `metal-agent mandate renew --credential <file> --replaces <file> --key <owner key file> ${NETWORK}\n [--issuer <file>]... [--signer-key <file>]...`,
|
|
57
|
+
about: "Binds the credential as a fresh mandate and revokes the one --replaces names, in one transaction. The new budget starts with nothing spent; this is how a budget starts over, since it never resets on its own.",
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
name: "mandate revoke",
|
|
61
|
+
usage: `metal-agent mandate revoke --credential <file> --key <owner key file> ${NETWORK} [--signer-key <file>]...`,
|
|
62
|
+
about: "Revokes the mandate: every later payment under it is refused.",
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
name: "mandate status",
|
|
66
|
+
usage: `metal-agent mandate status --credential <file> ${NETWORK}`,
|
|
67
|
+
about: "Shows the mandate's limits and where it stands: bound or not, revoked or not, what is spent of the budget and how many payments were made.",
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
name: "mandate pull",
|
|
71
|
+
usage: `metal-agent mandate pull --credential <file> --to <address> --amount <usdz> ${NETWORK}\n [--key <file>] [--agent-key <file>] [--record-refusal]`,
|
|
72
|
+
about: "Pays an address the mandate allows, with no seller. The --key account, the agent's gas key by default, submits it and pays its gas. A payout the mandate would refuse is reported and not sent, and the command exits 1; --record-refusal sends it so the refusal is recorded on chain. It prints the transaction as soon as it is sent; if the receipt never arrives, check it with metal-agent status before pulling again.",
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
name: "mandate verify",
|
|
76
|
+
usage: `metal-agent mandate verify --credential <file> --issuer <file>... ${NETWORK} [--tx <hash> [--signed-offer <text>]]`,
|
|
77
|
+
about: "Checks the mandate and every payment under it from the chain alone, or one --tx: the binding, the credential against it and under an --issuer you trust, and each payment as the agent key's signed per-payment mandate. With --signed-offer, the order the agent signed for that payment, as pay printed it. A check that fails exits 1.",
|
|
78
|
+
},
|
|
79
|
+
];
|
|
80
|
+
const SHARED = `Chain commands also take --rpc-url and --faucet-url, which override
|
|
81
|
+
METAL_RPC_URL and METAL_FAUCET_URL, which override each network's defaults.
|
|
82
|
+
On testnet, status, account create and the mandate commands other than
|
|
83
|
+
credential create need an RPC endpoint of your own:
|
|
84
|
+
sign up in the Metal console and create one. account create and the mandate
|
|
85
|
+
commands take --factory <address> when the network's MetalAccountFactory is
|
|
86
|
+
not the one metal-agent knows.
|
|
87
|
+
|
|
88
|
+
metal-agent <command> --help explains one command.`;
|
|
89
|
+
/** The whole command list, or the commands the given words name, with what each does. */
|
|
90
|
+
export function helpFor(words) {
|
|
91
|
+
// The longest run of leading words that names a command or a group: arguments after it are not part of the name.
|
|
92
|
+
for (let n = words.length; n > 0; n--) {
|
|
93
|
+
const asked = words.slice(0, n).join(" ");
|
|
94
|
+
const exact = COMMANDS.find((c) => c.name === asked);
|
|
95
|
+
if (exact)
|
|
96
|
+
return `${exact.usage}\n\n${wrap(exact.about)}`;
|
|
97
|
+
const group = COMMANDS.filter((c) => c.name.startsWith(`${asked} `));
|
|
98
|
+
if (group.length > 0)
|
|
99
|
+
return group.map((c) => `${c.usage}\n\n${wrap(c.about)}`).join("\n\n");
|
|
100
|
+
}
|
|
101
|
+
return `metal-agent: pay x402 sellers on Metal, and set spending limits Metal enforces for an agent\n\n${COMMANDS.map((c) => c.usage).join("\n")}\n\n${SHARED}`;
|
|
102
|
+
}
|
|
103
|
+
function wrap(text, width = 78) {
|
|
104
|
+
const lines = [];
|
|
105
|
+
let line = "";
|
|
106
|
+
for (const word of text.split(" ")) {
|
|
107
|
+
if (line !== "" && line.length + 1 + word.length > width) {
|
|
108
|
+
lines.push(line);
|
|
109
|
+
line = word;
|
|
110
|
+
}
|
|
111
|
+
else {
|
|
112
|
+
line = line === "" ? word : `${line} ${word}`;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
if (line !== "")
|
|
116
|
+
lines.push(line);
|
|
117
|
+
return lines.join("\n");
|
|
118
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -5,82 +5,12 @@ import { redactUrls } from "@metalntwx/metal-pay/mandates";
|
|
|
5
5
|
import { target } from "./chain.js";
|
|
6
6
|
import { fund } from "./commands/fund.js";
|
|
7
7
|
import { keyCreate, keyImport } from "./commands/key.js";
|
|
8
|
-
import { credentialCreate, accountCreate, mandateBind, mandatePull, mandateReplace, mandateRevoke, mandateStatus, mandateVerify, parseAddress, parseCents, parseCount, parseTxHash, parsePullAmount, } from "./commands/mandate.js";
|
|
8
|
+
import { credentialCreate, accountCreate, mandateBind, mandatePull, mandateRenew, mandateReplace, mandateRevoke, mandateStatus, mandateVerify, parseAddress, parseCents, parseCount, parsePayee, parseTxHash, parsePullAmount, } from "./commands/mandate.js";
|
|
9
9
|
import { parseHeaders, parseMax, pay, payUnderMandate } from "./commands/pay.js";
|
|
10
10
|
import { status } from "./commands/status.js";
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
metal-agent key create [--out <file>]
|
|
16
|
-
metal-agent key import <file|hex> [--out <file>]
|
|
17
|
-
metal-agent fund <address> --network devnet [--amount <usdz>]
|
|
18
|
-
metal-agent fund <address> --network testnet from the public faucet, which sets the amount
|
|
19
|
-
metal-agent pay <url> --network devnet|testnet [--key <file>] [--max <usdz>]
|
|
20
|
-
[--method <method>] [--body <text>] [--header 'name: value']...
|
|
21
|
-
metal-agent pay <url> --mandate <credential> --network devnet|testnet [--agent-key <file>]
|
|
22
|
-
metal-agent status <txHash> --network devnet|testnet [--rpc-url <url>]
|
|
23
|
-
metal-agent mcp serve pay, fund and status as MCP tools over stdio
|
|
24
|
-
|
|
25
|
-
Spending limits an account's owner sets for an agent, which Metal enforces:
|
|
26
|
-
|
|
27
|
-
metal-agent account create --out <owner key file to create> | --key <owner key file> --network devnet|testnet
|
|
28
|
-
|
|
29
|
-
metal-agent mandate credential create --principal <address> --cap <usdz> --budget <usdz> --out <file>
|
|
30
|
-
--network devnet|testnet [--payee <address>]... [--expires-in <seconds>] [--max-occurrences <n>]
|
|
31
|
-
[--description <text>] [--agent-key <file>] [--issuer-key <file>]
|
|
32
|
-
metal-agent mandate bind --credential <file> --key <owner key file> --network devnet|testnet [--issuer <file>]...
|
|
33
|
-
metal-agent mandate replace --credential <file> --replaces <file> --key <owner key file> --network devnet|testnet
|
|
34
|
-
[--issuer <file>]...
|
|
35
|
-
metal-agent mandate revoke --credential <file> --key <owner key file> --network devnet|testnet
|
|
36
|
-
metal-agent mandate status --credential <file> --network devnet|testnet
|
|
37
|
-
metal-agent mandate pull --credential <file> --to <address> --amount <usdz> --network devnet|testnet
|
|
38
|
-
[--key <file>] [--agent-key <file>]
|
|
39
|
-
metal-agent mandate verify --credential <file> --network devnet|testnet [--tx <hash> [--signed-offer <text>]]
|
|
40
|
-
[--issuer <file>]...
|
|
41
|
-
|
|
42
|
-
Chain commands also take --rpc-url and --faucet-url, which override
|
|
43
|
-
METAL_RPC_URL and METAL_FAUCET_URL, which override each network's defaults:
|
|
44
|
-
the RPC from @metalntwx/metal-pay, the faucet from the shipped networks.json.
|
|
45
|
-
status reads the chain: on devnet the network default applies; on testnet,
|
|
46
|
-
sign up in the Metal console, create an endpoint, and pass it.
|
|
47
|
-
|
|
48
|
-
pay refuses, before signing, any offer above --max *USDZ (1 by default). It
|
|
49
|
-
sends --method (GET by default, POST with a --body), --body (as
|
|
50
|
-
application/json unless a --header names a content type) and each --header,
|
|
51
|
-
and sends them again with the payment.
|
|
52
|
-
|
|
53
|
-
A mandate lets an agent pay from its owner's account (the principal) with a
|
|
54
|
-
key of its own, within limits: the most per payment (--cap), a budget for
|
|
55
|
-
the mandate's whole life (--budget), an expiry (--expires-in, 30 days by
|
|
56
|
-
default) and, when --payee names any, the addresses it may pay; with none
|
|
57
|
-
named it may pay any address. credential create writes those into a
|
|
58
|
-
credential file, <out>.json, and the agent's keys beside it: its mandate key,
|
|
59
|
-
<out>.agent-key.json, and the key that pays for its pulls, <out>.gas-key.json;
|
|
60
|
-
--agent-key names a mandate key the agent already has instead, with its gas
|
|
61
|
-
key beside that. The issuer keys that sign the credential are created on
|
|
62
|
-
first use. account create names the owner's Metal account, a smart account
|
|
63
|
-
the owner's key controls, created on chain by the first bind, and its address
|
|
64
|
-
is --principal. bind, replace and
|
|
65
|
-
revoke are signed by the account's quorum: --key pays gas and is the default
|
|
66
|
-
sole signer; repeated --signer-key supplies EOA quorum signers. The account
|
|
67
|
-
factory is the network's; --factory names another on any of these commands.
|
|
68
|
-
Keep owner keys apart from the agent's keys. pay --mandate
|
|
69
|
-
pays from the principal's account, signed by the agent's key; a payment
|
|
70
|
-
outside the mandate is refused on chain and the refusal names the limit.
|
|
71
|
-
pull makes a payment with no seller, such as moving funds to an allowed
|
|
72
|
-
address; the --key account, the agent's gas key by default, submits it and
|
|
73
|
-
pays its gas, and a refused pull exits 1. verify checks the mandate and every
|
|
74
|
-
payment under it from the chain alone, or one --tx: the binding, the
|
|
75
|
-
credential against it and under its issuer, and each payment as the agent
|
|
76
|
-
key's own signed per-payment mandate, rebuilt from its transaction; with
|
|
77
|
-
--signed-offer, the offer the agent signed for that payment, as pay printed
|
|
78
|
-
it, against the payment. A check that fails exits 1. A seller that asks for
|
|
79
|
-
the agent's signed order gets it with the payment; pay prints it. Amounts under a mandate are whole cents. The mandate
|
|
80
|
-
commands read and write the chain, so on testnet they need an endpoint.
|
|
81
|
-
|
|
82
|
-
The key file defaults to ${DEFAULT_KEY_PATH}, the agent's keys to the files
|
|
83
|
-
beside its credential, and the issuer keys to ${DEFAULT_ISSUER_KEY_PATH}.`;
|
|
11
|
+
import { helpFor } from "./help.js";
|
|
12
|
+
import { DEFAULT_KEY_PATH, agentKeyPath, gasKeyPath, issuerKeyPath, publicKeyPath, readKey } from "./keystore.js";
|
|
13
|
+
import { mcpAuthority, serveMcp } from "./mcp.js";
|
|
84
14
|
const print = (line) => console.log(line);
|
|
85
15
|
async function main(argv) {
|
|
86
16
|
const { values, positionals } = parseArgs({
|
|
@@ -115,12 +45,13 @@ async function main(argv) {
|
|
|
115
45
|
"signed-offer": { type: "string" },
|
|
116
46
|
description: { type: "string" },
|
|
117
47
|
to: { type: "string" },
|
|
48
|
+
"record-refusal": { type: "boolean" },
|
|
118
49
|
help: { type: "boolean", short: "h" },
|
|
119
50
|
},
|
|
120
51
|
});
|
|
121
52
|
const [command, sub] = positionals;
|
|
122
53
|
if (values.help || command === undefined || command === "help") {
|
|
123
|
-
print(
|
|
54
|
+
print(helpFor(command === "help" ? positionals.slice(1) : positionals.slice(0, 3)));
|
|
124
55
|
return command === undefined && !values.help ? 2 : 0;
|
|
125
56
|
}
|
|
126
57
|
const on = () => target(values.network, {
|
|
@@ -167,14 +98,18 @@ async function main(argv) {
|
|
|
167
98
|
const OWNER_KEY = "--key, the account owner's key file";
|
|
168
99
|
const need = (value, option) => {
|
|
169
100
|
if (value === undefined)
|
|
170
|
-
throw new Error(`mandate ${sub ?? ""} needs ${option}`);
|
|
101
|
+
throw new Error(`mandate ${sub === "credential" ? "credential create" : (sub ?? "")} needs ${option}`);
|
|
171
102
|
return value;
|
|
172
103
|
};
|
|
173
104
|
const accountFactory = () => accountFactoryFor(on().network, values.factory === undefined ? undefined : parseAddress(values.factory, "--factory"), "pass --factory");
|
|
174
105
|
const owner = () => ({ accountFactory: accountFactory(), signerPaths: values["signer-key"] ?? [] });
|
|
175
|
-
|
|
106
|
+
// The issuers the owner accepts: theirs, beside their key, unless --issuer names others.
|
|
107
|
+
const ownerIssuers = () => values.issuer ?? [publicKeyPath(issuerKeyPath(need(values.key, OWNER_KEY)))];
|
|
176
108
|
if (sub === "credential" && positionals[2] === "create") {
|
|
177
|
-
const payees = (values.payee ?? []).map((payee) =>
|
|
109
|
+
const payees = (values.payee ?? []).map((payee) => parsePayee(payee, "--payee"));
|
|
110
|
+
// The owner's key must be there: a mistyped path would put new issuer keys beside nothing.
|
|
111
|
+
if (values["issuer-key"] === undefined && values.key !== undefined)
|
|
112
|
+
await readKey(values.key);
|
|
178
113
|
await credentialCreate(on(), {
|
|
179
114
|
principal: parseAddress(values.principal, "--principal"),
|
|
180
115
|
payees,
|
|
@@ -185,22 +120,23 @@ async function main(argv) {
|
|
|
185
120
|
description: values.description ?? "A mandate issued by metal-agent",
|
|
186
121
|
out: need(values.out, "--out"),
|
|
187
122
|
agentKeyPath: values["agent-key"],
|
|
188
|
-
|
|
123
|
+
// The keys that sign the owner's credentials are as private as the owner's key: they live beside it.
|
|
124
|
+
issuerKeyPath: values["issuer-key"] ?? issuerKeyPath(need(values.key, "--key, the account owner's key file, beside which the keys that sign its credentials live, or --issuer-key")),
|
|
189
125
|
}, print);
|
|
190
126
|
}
|
|
191
127
|
else if (sub === "bind") {
|
|
192
|
-
const args = { ...owner(), credentialPath: need(values.credential, "--credential"), issuerPaths, keyPath: need(values.key, OWNER_KEY) };
|
|
128
|
+
const args = { ...owner(), credentialPath: need(values.credential, "--credential"), issuerPaths: ownerIssuers(), keyPath: need(values.key, OWNER_KEY) };
|
|
193
129
|
await mandateBind(on(), args, print);
|
|
194
130
|
}
|
|
195
|
-
else if (sub === "replace") {
|
|
131
|
+
else if (sub === "replace" || sub === "renew") {
|
|
196
132
|
const args = {
|
|
197
133
|
credentialPath: need(values.credential, "--credential"),
|
|
198
134
|
replacesPath: need(values.replaces, "--replaces"),
|
|
199
|
-
issuerPaths,
|
|
135
|
+
issuerPaths: ownerIssuers(),
|
|
200
136
|
...owner(),
|
|
201
137
|
keyPath: need(values.key, OWNER_KEY),
|
|
202
138
|
};
|
|
203
|
-
await mandateReplace(on(), args, print);
|
|
139
|
+
await (sub === "replace" ? mandateReplace : mandateRenew)(on(), args, print);
|
|
204
140
|
}
|
|
205
141
|
else if (sub === "revoke") {
|
|
206
142
|
await mandateRevoke(on(), { ...owner(), credentialPath: need(values.credential, "--credential"), keyPath: need(values.key, OWNER_KEY) }, print);
|
|
@@ -217,16 +153,20 @@ async function main(argv) {
|
|
|
217
153
|
to: parseAddress(values.to, "--to"),
|
|
218
154
|
amount: parsePullAmount(values.amount, "--amount"),
|
|
219
155
|
keyPath: values.key ?? gasKeyPath(agentKey),
|
|
156
|
+
recordRefusal: values["record-refusal"] === true,
|
|
220
157
|
};
|
|
221
158
|
return (await mandatePull(on(), args, print)) === "paid" ? 0 : 1;
|
|
222
159
|
}
|
|
223
160
|
else if (sub === "verify") {
|
|
224
161
|
const tx = values.tx === undefined ? undefined : parseTxHash(values.tx, "--tx");
|
|
162
|
+
const issuerPaths = values.issuer ?? [];
|
|
163
|
+
if (issuerPaths.length === 0)
|
|
164
|
+
throw new Error("mandate verify needs --issuer: the issuer-public-file credential create printed, or another issuer you trust");
|
|
225
165
|
const args = { accountFactory: accountFactory(), credentialPath: need(values.credential, "--credential"), issuerPaths, tx, signedOffer: values["signed-offer"] };
|
|
226
166
|
return (await mandateVerify(on(), args, print)) === "verified" ? 0 : 1;
|
|
227
167
|
}
|
|
228
168
|
else {
|
|
229
|
-
throw new Error(`unknown mandate subcommand "${sub ?? ""}"; try: credential create, bind, replace, revoke, status, pull, verify`);
|
|
169
|
+
throw new Error(`unknown mandate subcommand "${sub ?? ""}"; try: credential create, bind, replace, renew, revoke, status, pull, verify`);
|
|
230
170
|
}
|
|
231
171
|
return 0;
|
|
232
172
|
}
|
|
@@ -234,11 +174,11 @@ async function main(argv) {
|
|
|
234
174
|
await status(on(), sub, print);
|
|
235
175
|
return 0;
|
|
236
176
|
case "mcp":
|
|
237
|
-
await serveMcp();
|
|
177
|
+
await serveMcp(mcpAuthority(values));
|
|
238
178
|
await new Promise(() => { });
|
|
239
179
|
return 0;
|
|
240
180
|
default:
|
|
241
|
-
throw new Error(`unknown command "${command}"\n\n${
|
|
181
|
+
throw new Error(`unknown command "${command}"\n\n${helpFor([])}`);
|
|
242
182
|
}
|
|
243
183
|
}
|
|
244
184
|
main(process.argv.slice(2)).then((code) => process.exit(code), (error) => {
|
package/dist/keystore.js
CHANGED
|
@@ -9,8 +9,6 @@ export function isPrivateKey(value) {
|
|
|
9
9
|
return typeof value === "string" && isHex(value) && value.length === 66;
|
|
10
10
|
}
|
|
11
11
|
export const DEFAULT_KEY_PATH = join(homedir(), ".metal-agent", "key.json");
|
|
12
|
-
/** The keys that sign a credential `mandate credential create` issues. */
|
|
13
|
-
export const DEFAULT_ISSUER_KEY_PATH = join(homedir(), ".metal-agent", "issuer.json");
|
|
14
12
|
/** `foo.json` to `foo.<suffix>.json`: a file that belongs beside another. */
|
|
15
13
|
function beside(path, suffix) {
|
|
16
14
|
const ext = extname(path);
|
|
@@ -18,6 +16,8 @@ function beside(path, suffix) {
|
|
|
18
16
|
}
|
|
19
17
|
/** The P-256 key an agent signs payments with under the mandate at `credentialPath`: `mandate.json` to `mandate.agent-key.json`. */
|
|
20
18
|
export const agentKeyPath = (credentialPath) => beside(credentialPath, "agent-key");
|
|
19
|
+
/** The keys that sign the owner's credentials, beside the owner's key file and as private as it: `owner-key.json` to `owner-key.issuer.json`. */
|
|
20
|
+
export const issuerKeyPath = (ownerKeyPath) => beside(ownerKeyPath, "issuer");
|
|
21
21
|
/** The funded key that submits the agent's direct pulls and pays their gas, beside its mandate key: `mandate.agent-key.json` to `mandate.gas-key.json`, `shared.json` to `shared.gas-key.json`. */
|
|
22
22
|
export function gasKeyPath(agentKeyPath) {
|
|
23
23
|
const ext = extname(agentKeyPath);
|
package/dist/mcp.js
CHANGED
|
@@ -6,8 +6,23 @@ import { target } from "./chain.js";
|
|
|
6
6
|
import { fund } from "./commands/fund.js";
|
|
7
7
|
import { parseMax, pay, payUnderMandate } from "./commands/pay.js";
|
|
8
8
|
import { status } from "./commands/status.js";
|
|
9
|
+
import { loadMandate, mandateAgentSigner } from "./commands/mandate.js";
|
|
9
10
|
import { DEFAULT_KEY_PATH, agentKeyPath, readKey } from "./keystore.js";
|
|
11
|
+
import { USDZ, formatUsdz } from "@metalntwx/metal-pay";
|
|
10
12
|
import { redactUrls } from "@metalntwx/metal-pay/mandates";
|
|
13
|
+
/** The authority `metal-agent mcp` serves under, from its options. */
|
|
14
|
+
export function mcpAuthority(options) {
|
|
15
|
+
if (options.mandate !== undefined) {
|
|
16
|
+
if (options.key !== undefined || options.max !== undefined) {
|
|
17
|
+
throw new Error("mcp --mandate takes neither --key nor --max: the mandate sets the limit, and the agent's key is --agent-key");
|
|
18
|
+
}
|
|
19
|
+
return { kind: "mandate", credentialPath: options.mandate, agentKeyPath: options["agent-key"] ?? agentKeyPath(options.mandate) };
|
|
20
|
+
}
|
|
21
|
+
if (options["agent-key"] !== undefined)
|
|
22
|
+
throw new Error("--agent-key goes with --mandate");
|
|
23
|
+
return { kind: "key", keyPath: options.key ?? DEFAULT_KEY_PATH, maxAmount: parseMax(options.max, "--max") };
|
|
24
|
+
}
|
|
25
|
+
const usdz = (units) => `${formatUsdz(units)} ${USDZ.symbol}`;
|
|
11
26
|
const network = z.enum(["devnet", "testnet"]).describe("Which Metal network; there is no default");
|
|
12
27
|
const rpcUrl = z
|
|
13
28
|
.string()
|
|
@@ -15,45 +30,46 @@ const rpcUrl = z
|
|
|
15
30
|
.optional()
|
|
16
31
|
.describe("JSON-RPC endpoint; on devnet the network's default when omitted. On testnet, status needs an endpoint of the user's own, created after signing up in the Metal console");
|
|
17
32
|
const RPC_HOW = "pass rpcUrl or set METAL_RPC_URL in the server's environment";
|
|
18
|
-
export function createMcpServer() {
|
|
33
|
+
export function createMcpServer(authority) {
|
|
19
34
|
const { version } = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
|
|
20
35
|
const server = new McpServer({ name: "metal-agent", version });
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
mandate:
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
36
|
+
const request = {
|
|
37
|
+
url: z.string().url(),
|
|
38
|
+
network,
|
|
39
|
+
method: z.string().optional().describe("HTTP method; GET by default, POST with a body"),
|
|
40
|
+
body: z.string().optional().describe("Request body, sent as application/json unless headers names a content type"),
|
|
41
|
+
headers: z.record(z.string(), z.string()).optional().describe("Request headers, by name"),
|
|
42
|
+
};
|
|
43
|
+
const BUY_RPC = "To buy Metal RPC, POST a JSON-RPC body to the RPC URL; the rpc-aas-endpoint header is the endpoint the payment bought, so send further RPC calls there without paying again.";
|
|
44
|
+
if (authority.kind === "mandate") {
|
|
45
|
+
server.registerTool("pay", {
|
|
46
|
+
description: `Request a URL and pay its x402 challenge in *USDZ on Metal under this server's mandate: the payment comes from the mandate's account, signed by the agent's key, within the limits its owner set, and a payment outside them is refused with the limit named. Only the owner can change the limits. Returns the settlement transaction hash, the response headers and the body. ${BUY_RPC}`,
|
|
47
|
+
// Strict: a key or limit the model names is refused, not dropped.
|
|
48
|
+
inputSchema: z.object(request).strict(),
|
|
49
|
+
}, ({ url, network: net, method, body, headers }) => collect((print) => payUnderMandate(target(net), authority, url, print, { method, body, headers })));
|
|
50
|
+
}
|
|
51
|
+
else {
|
|
52
|
+
const limit = usdz(authority.maxAmount);
|
|
53
|
+
server.registerTool("pay", {
|
|
54
|
+
description: `Request a URL and pay its x402 challenge in *USDZ on Metal with this server's key, refusing before signing any offer above ${limit}. Returns the settlement transaction hash, the response headers and the body. ${BUY_RPC}`,
|
|
55
|
+
inputSchema: z
|
|
56
|
+
.object({
|
|
57
|
+
...request,
|
|
58
|
+
max: z
|
|
59
|
+
.string()
|
|
60
|
+
.optional()
|
|
61
|
+
.describe(`A lower limit for this payment, in decimal *USDZ; it can only lower the server's ${limit}`),
|
|
62
|
+
})
|
|
63
|
+
.strict(),
|
|
64
|
+
}, ({ url, network: net, max, method, body, headers }) => collect(async (print) => {
|
|
65
|
+
const maxAmount = max === undefined ? authority.maxAmount : parseMax(max, "max");
|
|
66
|
+
if (maxAmount > authority.maxAmount) {
|
|
67
|
+
throw new Error(`max can only lower this server's limit of ${limit} per payment; its --max sets the limit`);
|
|
48
68
|
}
|
|
49
|
-
const
|
|
50
|
-
await
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
const maxAmount = parseMax(max, "max");
|
|
54
|
-
const account = await readKey(key ?? DEFAULT_KEY_PATH);
|
|
55
|
-
await pay(target(net), account, url, { maxAmount, raise: "raise max to allow it" }, print, request);
|
|
56
|
-
}));
|
|
69
|
+
const raise = `the server's limit is ${limit}, set by its --max`;
|
|
70
|
+
await pay(target(net), await readKey(authority.keyPath), url, { maxAmount, raise }, print, { method, body, headers });
|
|
71
|
+
}));
|
|
72
|
+
}
|
|
57
73
|
server.registerTool("fund", {
|
|
58
74
|
description: "Fund an address with *USDZ. On devnet, from the devnet's pre-funded principal account; on testnet, from the public faucet, which sets the amount itself and reports an address that already holds a full grant as funded.",
|
|
59
75
|
inputSchema: {
|
|
@@ -81,6 +97,10 @@ async function collect(run) {
|
|
|
81
97
|
return { content: [{ type: "text", text: lines.join("\n") }], isError: true };
|
|
82
98
|
}
|
|
83
99
|
}
|
|
84
|
-
|
|
85
|
-
|
|
100
|
+
/** Serves over stdio. A mandate and its agent key are checked first, so a wrong path fails the host's launch; a key is read when pay needs
|
|
101
|
+
* it, so fund and status work without one. */
|
|
102
|
+
export async function serveMcp(authority) {
|
|
103
|
+
if (authority.kind === "mandate")
|
|
104
|
+
await mandateAgentSigner(await loadMandate(authority.credentialPath), authority.agentKeyPath);
|
|
105
|
+
await createMcpServer(authority).connect(new StdioServerTransport());
|
|
86
106
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@metalntwx/metal-agent",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "The `metal-agent` CLI and MCP server: create or import an agent key, fund it on Metal devnet or testnet, pay x402 offers in *USDZ, look up a settlement, and set spending limits for an agent that Metal enforces.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -18,8 +18,8 @@
|
|
|
18
18
|
"skills"
|
|
19
19
|
],
|
|
20
20
|
"dependencies": {
|
|
21
|
-
"@metalntwx/metal-ts": "^0.3.
|
|
22
|
-
"@metalntwx/metal-pay": "^0.
|
|
21
|
+
"@metalntwx/metal-ts": "^0.3.2",
|
|
22
|
+
"@metalntwx/metal-pay": "^0.2.0",
|
|
23
23
|
"@modelcontextprotocol/sdk": "1.31.0",
|
|
24
24
|
"@x402/core": "2.21.0",
|
|
25
25
|
"viem": "^2.54.6",
|
|
@@ -12,8 +12,8 @@ Every command that touches the chain takes `--network testnet`. Metal's
|
|
|
12
12
|
demo seller is at `https://x402-demo-testnet.metalntwx.com/quote` and the
|
|
13
13
|
block explorer at `https://testnet.metalntwx.com`.
|
|
14
14
|
|
|
15
|
-
`key`, `fund` and `pay` need no RPC endpoint. `status` and
|
|
16
|
-
commands need the user's own: ask for it, unless `METAL_RPC_URL` is already
|
|
15
|
+
`key`, `fund` and `pay` need no RPC endpoint. `status`, `account create` and
|
|
16
|
+
the `mandate` commands other than `credential create` need the user's own: ask for it, unless `METAL_RPC_URL` is already
|
|
17
17
|
set, and pass it with `--rpc-url`. A user without one creates it as
|
|
18
18
|
`https://docs.metalntwx.com/network/endpoints` describes.
|
|
19
19
|
|
|
@@ -64,6 +64,10 @@ Run the steps in order. After each step, show the user its output.
|
|
|
64
64
|
}
|
|
65
65
|
```
|
|
66
66
|
|
|
67
|
+
The server pays with the default key file. If the user's key is in
|
|
68
|
+
another file, or the agent pays under a mandate, add `--key <file>` or
|
|
69
|
+
`--mandate <credential>` after `mcp`.
|
|
70
|
+
|
|
67
71
|
The tools appear after the host reloads. If you cannot reload it, tell
|
|
68
72
|
the user to, and carry on with the CLI.
|
|
69
73
|
|
|
@@ -100,7 +104,7 @@ Run the steps in order. After each step, show the user its output.
|
|
|
100
104
|
- Before you pass a `--max` above the default of 1 `*USDZ`.
|
|
101
105
|
- Before you import a key, or write a key anywhere but the default file.
|
|
102
106
|
- Before you run `account create`, `mandate credential create`, `bind`,
|
|
103
|
-
`replace` or `revoke`. They are the account owner's to run.
|
|
107
|
+
`replace`, `renew` or `revoke`. They are the account owner's to run.
|
|
104
108
|
|
|
105
109
|
Never print a private key or the contents of a key file. Never move, copy
|
|
106
110
|
or delete a key file. If the owner's key file is within your reach, tell
|
|
@@ -183,11 +187,15 @@ A refusal exits 1 with the reason:
|
|
|
183
187
|
`offer signs for the EIP-712 domain …` means the offer asks for something
|
|
184
188
|
other than `*USDZ`. Tell the user; a larger `--max` does not change it.
|
|
185
189
|
- `error: payment refused: <reason>` means the facilitator refused at
|
|
186
|
-
verification.
|
|
190
|
+
verification, or the seller's own check refused.
|
|
187
191
|
- `error: payment refused at settlement: <reason>` means the facilitator
|
|
188
192
|
refused at settlement, with the transaction hash when there is one. On
|
|
189
|
-
`daily_limit_reached`, retry later.
|
|
190
|
-
|
|
193
|
+
`daily_limit_reached`, retry later.
|
|
194
|
+
- On `settlement_in_doubt`, or any error after the `nonce` line that is
|
|
195
|
+
not a `payment refused` line, such as `fetch failed` or an HTTP status
|
|
196
|
+
from the seller, the payment may have settled. Do not pay again. Tell
|
|
197
|
+
the user, with the `nonce` line `pay` printed; when the error names a
|
|
198
|
+
transaction hash, `status` on it shows whether it landed.
|
|
191
199
|
|
|
192
200
|
## `pay --mandate`
|
|
193
201
|
|
|
@@ -205,11 +213,13 @@ metal-agent pay https://example.com/report --mandate ./mandate.json --network te
|
|
|
205
213
|
apply. A payment outside the mandate exits 1 with
|
|
206
214
|
`error: payment refused: <reason> (…)`, a reason starting `mandate_`. Tell
|
|
207
215
|
the user which limit it was; only the owner can change a mandate. After
|
|
208
|
-
`mandate_revoked`, ask the user for the credential that replaced it
|
|
216
|
+
`mandate_revoked`, ask the user for the credential that replaced it, and
|
|
217
|
+
pay with that. If the error says there is no agent key, pass the
|
|
218
|
+
`--agent-key` it names.
|
|
209
219
|
|
|
210
220
|
A seller that asks for your signed order gets it with the payment, and
|
|
211
221
|
`pay` prints it as `signed-offer <text>`. Keep it, and pass it to
|
|
212
|
-
`mandate verify --tx <hash> --signed-offer <text>` to check the two
|
|
222
|
+
`mandate verify --issuer <file> --tx <hash> --signed-offer <text>` to check the two
|
|
213
223
|
together.
|
|
214
224
|
|
|
215
225
|
`mandate status` shows what is left: read `spent <amount> of <budget>`,
|
|
@@ -221,8 +231,11 @@ metal-agent mandate status --credential ./mandate.json --network testnet --rpc-u
|
|
|
221
231
|
|
|
222
232
|
`mandate pull` pays an address the mandate allows, with no seller. The gas
|
|
223
233
|
key beside the credential, `mandate.gas-key.json`, submits it and pays the
|
|
224
|
-
gas. `outcome paid
|
|
225
|
-
happened.
|
|
234
|
+
gas, so ask the user to fund its `gas-key-address` first. `outcome paid`, or `outcome refused, not sent` with `reason` and `recorded no` lines,
|
|
235
|
+
says what happened; a pull refused when checked sends nothing and costs no gas. Pass
|
|
236
|
+
`--record-refusal` only when the user asks for the refusal to be recorded
|
|
237
|
+
on chain. If a pull fails after printing `transaction <hash>`, run
|
|
238
|
+
`metal-agent status <hash>` before pulling again; it may have paid.
|
|
226
239
|
|
|
227
240
|
```bash
|
|
228
241
|
metal-agent mandate pull --credential ./mandate.json --to <payee address> --amount 2.50 --network testnet --rpc-url <endpoint>
|
|
@@ -231,12 +244,17 @@ metal-agent mandate pull --credential ./mandate.json --to <payee address> --amou
|
|
|
231
244
|
`mandate verify` checks the mandate and every payment under it from the
|
|
232
245
|
chain alone, or one `--tx <hash>`, and exits 1 when a check fails. Run it
|
|
233
246
|
when the user asks whether a payment was within the mandate; read
|
|
234
|
-
`verified yes|no`, and a failed `step` line names what differs.
|
|
247
|
+
`verified yes|no`, and a failed `step` line names what differs. It needs
|
|
248
|
+
the issuer's public key file, the `issuer-public-file` that `credential
|
|
249
|
+
create` printed; ask the user for it.
|
|
235
250
|
|
|
236
251
|
```bash
|
|
237
|
-
metal-agent mandate verify --credential ./mandate.json --network testnet --rpc-url <endpoint>
|
|
252
|
+
metal-agent mandate verify --credential ./mandate.json --issuer <issuer public key file> --network testnet --rpc-url <endpoint>
|
|
238
253
|
```
|
|
239
254
|
|
|
255
|
+
`--issuer` is the issuer's public key file the user gave you with the
|
|
256
|
+
credential; ask for it if you do not have it.
|
|
257
|
+
|
|
240
258
|
## status
|
|
241
259
|
|
|
242
260
|
Show a settlement from chain data: status, block, submitter, gas paid in
|
|
@@ -251,13 +269,14 @@ metal-agent status <transaction hash> --network testnet --rpc-url <endpoint>
|
|
|
251
269
|
## mcp
|
|
252
270
|
|
|
253
271
|
Serve `pay`, `fund` and `status` as MCP tools, each taking a `network`, over
|
|
254
|
-
stdio for an MCP host. `pay`
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
272
|
+
stdio for an MCP host. What `pay` spends is fixed when the server starts,
|
|
273
|
+
by its key and `--max`, or by one mandate; `pay` takes the request as
|
|
274
|
+
optional `method`, `body` and `headers`, and under a key a `max` that can
|
|
275
|
+
only lower the server's limit. `status` takes the endpoint as `rpcUrl`, which testnet
|
|
276
|
+
needs unless the server's environment sets `METAL_RPC_URL`. No tool chooses a key, a mandate or a higher limit, and none
|
|
277
|
+
sets or changes a mandate.
|
|
260
278
|
|
|
261
279
|
```bash
|
|
262
280
|
metal-agent mcp
|
|
281
|
+
metal-agent mcp --mandate /path/to/mandate.json
|
|
263
282
|
```
|