@metalntwx/metal-agent 0.2.0 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +65 -39
- package/dist/chain.js +1 -1
- package/dist/commands/fund.js +28 -14
- package/dist/commands/key.js +2 -2
- package/dist/commands/mandate.js +616 -142
- package/dist/commands/pay.js +86 -13
- package/dist/help.js +20 -19
- package/dist/index.js +40 -14
- package/dist/keystore.js +30 -18
- package/dist/mcp.js +273 -31
- package/package.json +6 -6
- package/skills/metal-agent/SKILL.md +93 -51
package/dist/commands/pay.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { decodePaymentRequiredHeader, decodePaymentResponseHeader } from "@x402/core/http";
|
|
2
|
-
import { DEFAULT_MAX_PAYMENT, createMetalPaymentClient, formatUsdz, USDZ, usdzBaseUnits, } from "@metalntwx/metal-pay";
|
|
2
|
+
import { DEFAULT_MAX_PAYMENT, createMetalPaymentClient, formatUsdz, metalNetwork, USDZ, usdzBaseUnits, } from "@metalntwx/metal-pay";
|
|
3
3
|
import { createMandatePaymentClient, describeRefusal, refusalOf, signedOfferOf } from "@metalntwx/metal-pay/mandates";
|
|
4
4
|
import { loadMandate, mandateAgentSigner } from "./mandate.js";
|
|
5
5
|
/** A decimal `*USDZ` limit per payment, `DEFAULT_MAX_PAYMENT` when absent. */
|
|
@@ -59,11 +59,76 @@ export async function pay(target, account, url, limit, print, request = {}) {
|
|
|
59
59
|
*/
|
|
60
60
|
export async function payUnderMandate(target, payer, url, print, request = {}) {
|
|
61
61
|
const mandate = await loadMandate(payer.credentialPath);
|
|
62
|
-
const signDigest = await mandateAgentSigner(mandate, payer.
|
|
62
|
+
const signDigest = await mandateAgentSigner(mandate, payer.credentialPath, payer.mandateKeyFile);
|
|
63
63
|
const paying = createMandatePaymentClient({ credential: mandate.credential, signDigest, network: target.network });
|
|
64
64
|
print(`mandate ${paying.reference}`);
|
|
65
65
|
await paidRequest(target, { ...paying, payer: paying.principal }, url, print, request);
|
|
66
66
|
}
|
|
67
|
+
/** A payment refused with nothing paid, before settlement or at it; `reason` is the facilitator's or the seller's, when it gave one. */
|
|
68
|
+
export class PaymentRefused extends Error {
|
|
69
|
+
reason;
|
|
70
|
+
constructor(message, reason) {
|
|
71
|
+
super(message);
|
|
72
|
+
this.reason = reason;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
/** A payment that was signed and may have settled: paying again can pay twice. */
|
|
76
|
+
export class PaymentInDoubt extends Error {
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* The facilitator's settlement reasons that mean nothing moved
|
|
80
|
+
* (apps/x402-facilitator/src/facilitator.ts `check`, `settle`, `failed` and
|
|
81
|
+
* routes.ts): the payment was never sent, was refused or reverted. Any other
|
|
82
|
+
* answer, such as a seller's own `fetch failed` when the facilitator's answer
|
|
83
|
+
* to it was lost, or `already_settled`, `nonce_already_used` or
|
|
84
|
+
* `payment_quarantined`, which name a transaction that moved it, leaves open
|
|
85
|
+
* whether it settled.
|
|
86
|
+
*/
|
|
87
|
+
const NOTHING_SETTLED = new Set([
|
|
88
|
+
"unsupported_x402_version",
|
|
89
|
+
"unsupported_network",
|
|
90
|
+
"unsupported_scheme",
|
|
91
|
+
"unsupported_asset",
|
|
92
|
+
"settlement_unavailable",
|
|
93
|
+
"invalid_payload",
|
|
94
|
+
"bad_signature",
|
|
95
|
+
"rpc_unreachable",
|
|
96
|
+
"authorization_window",
|
|
97
|
+
"rate_limited",
|
|
98
|
+
"daily_limit_reached",
|
|
99
|
+
"recipient_refuses",
|
|
100
|
+
"payment_failed",
|
|
101
|
+
"payment_reverted",
|
|
102
|
+
]);
|
|
103
|
+
function nothingSettled(reason) {
|
|
104
|
+
if (reason === undefined)
|
|
105
|
+
return false;
|
|
106
|
+
const mandate = refusalOf(reason);
|
|
107
|
+
// A used digest is a payment that was made, or refused, already.
|
|
108
|
+
if (mandate !== undefined)
|
|
109
|
+
return mandate !== "NonceUsed";
|
|
110
|
+
return NOTHING_SETTLED.has(reason) || reason.startsWith("payment_mismatch:");
|
|
111
|
+
}
|
|
112
|
+
/** The networks an offer named when none is the one paid on, read from @x402/core's error for an offer with no option it can sign. */
|
|
113
|
+
function offeredElsewhere(message, target) {
|
|
114
|
+
const marker = "Failed to create payment payload: No network/scheme registered";
|
|
115
|
+
if (!message.startsWith(marker) || !message.includes("{"))
|
|
116
|
+
return undefined;
|
|
117
|
+
try {
|
|
118
|
+
const { paymentRequirements } = JSON.parse(message.slice(message.indexOf("{")));
|
|
119
|
+
const networks = [...new Set((paymentRequirements ?? []).map(({ network }) => network).filter((network) => typeof network === "string"))];
|
|
120
|
+
if (networks.length === 0 || networks.includes(target.network.network))
|
|
121
|
+
return undefined;
|
|
122
|
+
const named = networks.map((network) => {
|
|
123
|
+
const metal = metalNetwork(network);
|
|
124
|
+
return metal === undefined ? network : `${metal.name} (${network})`;
|
|
125
|
+
});
|
|
126
|
+
return `the offer is on ${named.join(", ")}, not ${target.network.name} (${target.network.network})`;
|
|
127
|
+
}
|
|
128
|
+
catch {
|
|
129
|
+
return undefined;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
67
132
|
/** A refusal's reason as `pay` prints it: a mandate's with what it means, any other as the facilitator named it. */
|
|
68
133
|
function describeRefusalReason(reason) {
|
|
69
134
|
if (reason === undefined)
|
|
@@ -102,10 +167,10 @@ async function paidRequest(target, { fetchWithPayment, client, payer, raise }, u
|
|
|
102
167
|
const how = raise !== undefined && aborted[1].endsWith("limit per payment") ? `; ${raise}` : "";
|
|
103
168
|
throw new Error(`payment refused before signing: ${aborted[1]}${how}`);
|
|
104
169
|
}
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
throw
|
|
170
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
171
|
+
if (signed !== undefined)
|
|
172
|
+
throw new PaymentInDoubt(`${message}; a payment was signed and may have settled; look it up by its nonce before paying again`);
|
|
173
|
+
throw new Error(`${offeredElsewhere(message, target) ?? message}; nothing was signed`);
|
|
109
174
|
});
|
|
110
175
|
const body = await response.text();
|
|
111
176
|
const paymentRequired = response.headers.get("payment-required");
|
|
@@ -114,16 +179,24 @@ async function paidRequest(target, { fetchWithPayment, client, payer, raise }, u
|
|
|
114
179
|
const required = decodePaymentRequiredHeader(paymentRequired);
|
|
115
180
|
const offer = required.accepts[0];
|
|
116
181
|
const priced = offer ? `${formatUsdz(offer.amount)} ${USDZ.symbol} on ${offer.network}` : "no offer";
|
|
117
|
-
|
|
182
|
+
// Refused before settlement: the seller settles only a payment its check passed.
|
|
183
|
+
throw new PaymentRefused(`payment refused: ${describeRefusalReason(required.error)} (offer: ${priced}); nothing was paid`, required.error);
|
|
118
184
|
}
|
|
119
185
|
const settlement = paymentResponse ? decodePaymentResponseHeader(paymentResponse) : undefined;
|
|
120
|
-
if (settlement !== undefined && !settlement.success)
|
|
121
|
-
|
|
186
|
+
if (settlement !== undefined && !settlement.success) {
|
|
187
|
+
// A settlement that is not known to have moved nothing can land all the same, so paying again can pay twice.
|
|
188
|
+
if (!nothingSettled(settlement.errorReason))
|
|
189
|
+
throw new PaymentInDoubt(`payment in doubt at settlement: ${settleRefusal(settlement)}; it may have settled: do not pay again; look it up by its nonce`);
|
|
190
|
+
throw new PaymentRefused(`payment refused at settlement: ${settleRefusal(settlement)}; nothing was paid`, settlement.errorReason);
|
|
191
|
+
}
|
|
122
192
|
// A redirect is reported, not an error: the payment may have settled, and `location` says where it points.
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
193
|
+
// A settled payment is reported paid, whatever status the seller answers with.
|
|
194
|
+
if (response.status >= 400 && settlement?.success !== true) {
|
|
195
|
+
// A seller that fails after the payment was signed, or answers that another request holds it, may have settled it first.
|
|
196
|
+
const answered = `${url} answered ${response.status}: ${body}`;
|
|
197
|
+
if (signed !== undefined && (response.status >= 500 || response.status === 409))
|
|
198
|
+
throw new PaymentInDoubt(`${answered}; a payment was signed and may have settled; look it up by its nonce before paying again`);
|
|
199
|
+
throw new Error(answered);
|
|
127
200
|
}
|
|
128
201
|
print(`status ${response.status}`);
|
|
129
202
|
if (settlement !== undefined) {
|
package/dist/help.js
CHANGED
|
@@ -1,10 +1,9 @@
|
|
|
1
|
-
import { DEFAULT_KEY_PATH } from "./keystore.js";
|
|
2
1
|
const NETWORK = "--network devnet|testnet";
|
|
3
2
|
const COMMANDS = [
|
|
4
3
|
{
|
|
5
4
|
name: "key create",
|
|
6
5
|
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,
|
|
6
|
+
about: `Generates the agent's key and writes it to a key file only its owner can read, ~/.metal-agent/key.json unless --out names another. It never overwrites a key file.`,
|
|
8
7
|
},
|
|
9
8
|
{
|
|
10
9
|
name: "key import",
|
|
@@ -18,8 +17,8 @@ const COMMANDS = [
|
|
|
18
17
|
},
|
|
19
18
|
{
|
|
20
19
|
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} [--
|
|
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
|
|
20
|
+
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} [--mandate-key-file <file>]`,
|
|
21
|
+
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 .key.json file in the credential's directory whose key the credential names, unless --mandate-key-file 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
22
|
},
|
|
24
23
|
{
|
|
25
24
|
name: "status",
|
|
@@ -28,23 +27,23 @@ const COMMANDS = [
|
|
|
28
27
|
},
|
|
29
28
|
{
|
|
30
29
|
name: "mcp",
|
|
31
|
-
usage: "metal-agent mcp [--key <file>] [--max <usdz>]\nmetal-agent mcp --mandate <credential> [--
|
|
32
|
-
about: "Serves pay, fund and status as MCP tools over stdio. What pay
|
|
30
|
+
usage: "metal-agent mcp [--key <file>] [--max <usdz>]\nmetal-agent mcp --mandate <credential> [--mandate-key-file <file>]",
|
|
31
|
+
about: "Serves pay, fund and status as MCP tools over stdio; under a mandate, mandate_pull, mandate_status and mandate_verify too, which read the chain through the server's METAL_RPC_URL, or devnet's default endpoint. What pay and mandate_pull spend is set here, by whoever starts the server: a key file and --max, or one mandate's credential file, which each call reads. 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
32
|
},
|
|
34
33
|
{
|
|
35
34
|
name: "account create",
|
|
36
|
-
usage: `metal-agent account create --out <owner key file
|
|
37
|
-
about: "
|
|
35
|
+
usage: `metal-agent account create --out <owner key file> | --key <owner key file> ${NETWORK}`,
|
|
36
|
+
about: "Creates the owner's Metal account, a smart account the owner's key controls, and prints its address, the --principal of the owner's credentials. The owner's key pays the gas; one that holds too little is funded first, from the faucet on testnet. When a later step fails, such as a faucet refusal, the error names the key file, and the same command continues from it. An account that exists is named, and nothing is sent. Keep the owner's key where no agent can read it.",
|
|
38
37
|
},
|
|
39
38
|
{
|
|
40
39
|
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>] [--
|
|
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
|
|
40
|
+
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>] [--starts-in <seconds>] [--max-occurrences <n>]\n [--description <text>] [--mandate-key-file <file>]`,
|
|
41
|
+
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), a start (--starts-in, by default 00:00 UTC on the day of issue) 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 mandate key the agent signs with is written beside the credential, mandate.key.json for --out mandate.json, unless --mandate-key-file names one that exists, as for a replacement. 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
42
|
},
|
|
44
43
|
{
|
|
45
44
|
name: "mandate bind",
|
|
46
45
|
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
|
|
46
|
+
about: "Binds the credential on the owner's account, which then pays under it. The account's owners sign: --key pays the gas and is the sole signer unless repeated --signer-key names the owners' keys. --issuer names the issuers whose credentials the owner accepts, the <key>.issuer.public.json beside the owner's key by default.",
|
|
48
47
|
},
|
|
49
48
|
{
|
|
50
49
|
name: "mandate replace",
|
|
@@ -68,24 +67,26 @@ const COMMANDS = [
|
|
|
68
67
|
},
|
|
69
68
|
{
|
|
70
69
|
name: "mandate pull",
|
|
71
|
-
usage: `metal-agent mandate pull --credential <file> --to <address> --amount <usdz> ${NETWORK}\n [--key <file>] [--
|
|
72
|
-
about: "Pays an address the mandate allows, with no seller. The
|
|
70
|
+
usage: `metal-agent mandate pull --credential <file> --to <address> --amount <usdz> ${NETWORK}\n [--payment-id <id>] [--key <file>] [--mandate-key-file <file>] [--record-refusal]`,
|
|
71
|
+
about: "Pays an address the mandate allows, with no seller. The facilitator submits it and pays its gas, unless --key names a funded key to submit it. A payout the mandate would refuse is reported and not sent, and the command exits 1; with --key, --record-refusal sends it so the refusal is recorded on chain. The outcome is read from the chain: paid (exit 0), refused (exit 1), held in the payee's escrow (exit 4), or pending (exit 3); an error also exits 1. Each pull is kept under ~/.metal-agent/pulls, and running the same command again sends a pending pull again, which pays at most once. --payment-id names the payment, such as a refund's id: two payouts of one amount to one payee are two payments under two ids, an id whose payout was paid or held reports it again, and one the account refused may be sent with other values. Without it, a payout is known by its payee and amount. Either is known only under this home directory and this credential: a retry from another machine, or under a credential that renewed or replaced it, signs a new payout.",
|
|
73
72
|
},
|
|
74
73
|
{
|
|
75
74
|
name: "mandate verify",
|
|
76
75
|
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
|
|
76
|
+
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 mandate 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, as does an event under the mandate the SDK does not decode.",
|
|
78
77
|
},
|
|
79
78
|
];
|
|
80
|
-
const SHARED = `Chain commands also take --rpc-url and --faucet-url,
|
|
81
|
-
|
|
79
|
+
const SHARED = `Chain commands also take --rpc-url and --faucet-url, and mandate pull
|
|
80
|
+
--facilitator-url, which override METAL_RPC_URL, METAL_FAUCET_URL and
|
|
81
|
+
METAL_FACILITATOR_URL, which override each network's defaults.
|
|
82
82
|
On testnet, status, account create and the mandate commands other than
|
|
83
83
|
credential create need an RPC endpoint of your own:
|
|
84
|
-
sign up in the Metal console and create one. account create and
|
|
85
|
-
|
|
84
|
+
sign up in the Metal console and create one. account create and mandate bind,
|
|
85
|
+
replace, renew, revoke, status and verify take --factory <address> when the network's MetalAccountFactory is
|
|
86
86
|
not the one metal-agent knows.
|
|
87
87
|
|
|
88
|
-
metal-agent <command> --help explains one command
|
|
88
|
+
metal-agent <command> --help explains one command.
|
|
89
|
+
metal-agent --version prints the version.`;
|
|
89
90
|
/** The whole command list, or the commands the given words name, with what each does. */
|
|
90
91
|
export function helpFor(words) {
|
|
91
92
|
// The longest run of leading words that names a command or a group: arguments after it are not part of the name.
|
package/dist/index.js
CHANGED
|
@@ -1,17 +1,20 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
2
3
|
import { parseArgs } from "node:util";
|
|
3
4
|
import { accountFactoryFor } from "@metalntwx/metal-pay";
|
|
4
5
|
import { redactUrls } from "@metalntwx/metal-pay/mandates";
|
|
5
6
|
import { target } from "./chain.js";
|
|
6
7
|
import { fund } from "./commands/fund.js";
|
|
7
8
|
import { keyCreate, keyImport } from "./commands/key.js";
|
|
8
|
-
import { credentialCreate, accountCreate, mandateBind, mandatePull, mandateRenew, mandateReplace, mandateRevoke, mandateStatus, mandateVerify, parseAddress, parseCents, parseCount, parsePayee, parseTxHash, parsePullAmount, } from "./commands/mandate.js";
|
|
9
|
+
import { credentialCreate, accountCreate, mandateBind, mandatePull, mandateRenew, mandateReplace, mandateRevoke, mandateStatus, mandateVerify, parseAddress, parseCents, parseCount, parsePayee, parseTxHash, parsePaymentId, parsePullAmount, } from "./commands/mandate.js";
|
|
9
10
|
import { parseHeaders, parseMax, pay, payUnderMandate } from "./commands/pay.js";
|
|
10
11
|
import { status } from "./commands/status.js";
|
|
11
12
|
import { helpFor } from "./help.js";
|
|
12
|
-
import {
|
|
13
|
+
import { defaultKeyPath, issuerKeyPath, publicKeyPath, readKey } from "./keystore.js";
|
|
13
14
|
import { mcpAuthority, serveMcp } from "./mcp.js";
|
|
14
15
|
const print = (line) => console.log(line);
|
|
16
|
+
/** `mandate pull`'s exit code per outcome: a wrapper that retries on `pending` alone sends the same pull, and never pays a `held` one into escrow again. */
|
|
17
|
+
const PULL_EXIT = { paid: 0, refused: 1, pending: 3, held: 4 };
|
|
15
18
|
async function main(argv) {
|
|
16
19
|
const { values, positionals } = parseArgs({
|
|
17
20
|
args: argv,
|
|
@@ -20,6 +23,7 @@ async function main(argv) {
|
|
|
20
23
|
network: { type: "string" },
|
|
21
24
|
"rpc-url": { type: "string" },
|
|
22
25
|
"faucet-url": { type: "string" },
|
|
26
|
+
"facilitator-url": { type: "string" },
|
|
23
27
|
key: { type: "string" },
|
|
24
28
|
factory: { type: "string" },
|
|
25
29
|
"signer-key": { type: "string", multiple: true },
|
|
@@ -33,23 +37,34 @@ async function main(argv) {
|
|
|
33
37
|
credential: { type: "string" },
|
|
34
38
|
replaces: { type: "string" },
|
|
35
39
|
issuer: { type: "string", multiple: true },
|
|
36
|
-
"
|
|
40
|
+
"mandate-key-file": { type: "string" },
|
|
37
41
|
"issuer-key": { type: "string" },
|
|
38
42
|
principal: { type: "string" },
|
|
39
43
|
payee: { type: "string", multiple: true },
|
|
40
44
|
cap: { type: "string" },
|
|
41
45
|
budget: { type: "string" },
|
|
42
46
|
"expires-in": { type: "string" },
|
|
47
|
+
"starts-in": { type: "string" },
|
|
43
48
|
"max-occurrences": { type: "string" },
|
|
44
49
|
tx: { type: "string" },
|
|
45
50
|
"signed-offer": { type: "string" },
|
|
46
51
|
description: { type: "string" },
|
|
47
52
|
to: { type: "string" },
|
|
53
|
+
"payment-id": { type: "string" },
|
|
48
54
|
"record-refusal": { type: "boolean" },
|
|
49
55
|
help: { type: "boolean", short: "h" },
|
|
56
|
+
version: { type: "boolean" },
|
|
50
57
|
},
|
|
51
58
|
});
|
|
59
|
+
if (values.version) {
|
|
60
|
+
print(JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version);
|
|
61
|
+
return 0;
|
|
62
|
+
}
|
|
52
63
|
const [command, sub] = positionals;
|
|
64
|
+
const extra = positionals.slice(argumentCount(command, sub));
|
|
65
|
+
if (extra.length > 0) {
|
|
66
|
+
throw new Error(`unexpected argument${extra.length > 1 ? "s" : ""} ${extra.map((a) => JSON.stringify(a)).join(" ")}; quote a value that holds a space`);
|
|
67
|
+
}
|
|
53
68
|
if (values.help || command === undefined || command === "help") {
|
|
54
69
|
print(helpFor(command === "help" ? positionals.slice(1) : positionals.slice(0, 3)));
|
|
55
70
|
return command === undefined && !values.help ? 2 : 0;
|
|
@@ -57,6 +72,7 @@ async function main(argv) {
|
|
|
57
72
|
const on = () => target(values.network, {
|
|
58
73
|
rpcUrl: values["rpc-url"],
|
|
59
74
|
faucetUrl: values["faucet-url"],
|
|
75
|
+
facilitatorUrl: values["facilitator-url"],
|
|
60
76
|
});
|
|
61
77
|
switch (command) {
|
|
62
78
|
case "key":
|
|
@@ -74,21 +90,23 @@ async function main(argv) {
|
|
|
74
90
|
const request = { method: values.method, body: values.body, headers: parseHeaders(values.header, "--header") };
|
|
75
91
|
if (values.mandate !== undefined) {
|
|
76
92
|
if (values.max !== undefined || values.key !== undefined) {
|
|
77
|
-
throw new Error("pay --mandate takes neither --max nor --key: the mandate sets the limit, and the agent
|
|
93
|
+
throw new Error("pay --mandate takes neither --max nor --key: the mandate sets the limit, and the agent signs with its mandate key");
|
|
78
94
|
}
|
|
79
|
-
const payer = { credentialPath: values.mandate,
|
|
95
|
+
const payer = { credentialPath: values.mandate, mandateKeyFile: values["mandate-key-file"] };
|
|
80
96
|
await payUnderMandate(on(), payer, sub, print, request);
|
|
81
97
|
return 0;
|
|
82
98
|
}
|
|
99
|
+
if (values["mandate-key-file"] !== undefined)
|
|
100
|
+
throw new Error("--mandate-key-file goes with --mandate");
|
|
83
101
|
const limit = { maxAmount: parseMax(values.max, "--max"), raise: "pass --max to allow it" };
|
|
84
|
-
await pay(on(), await readKey(values.key ??
|
|
102
|
+
await pay(on(), await readKey(values.key ?? defaultKeyPath()), sub, limit, print, request);
|
|
85
103
|
return 0;
|
|
86
104
|
}
|
|
87
105
|
case "account": {
|
|
88
106
|
if (sub !== "create")
|
|
89
107
|
throw new Error(`unknown account subcommand "${sub ?? ""}"; try: account create`);
|
|
90
108
|
if ((values.key === undefined) === (values.out === undefined))
|
|
91
|
-
throw new Error("account create needs --out, the owner key file
|
|
109
|
+
throw new Error("account create needs --out, the owner key file, created when it does not exist, or --key, one that exists");
|
|
92
110
|
const factory = accountFactoryFor(on().network, values.factory === undefined ? undefined : parseAddress(values.factory, "--factory"), "pass --factory");
|
|
93
111
|
await accountCreate(on(), { accountFactory: factory, ...(values.key === undefined ? { out: values.out } : { keyPath: values.key }) }, print);
|
|
94
112
|
return 0;
|
|
@@ -116,10 +134,11 @@ async function main(argv) {
|
|
|
116
134
|
cap: parseCents(values.cap, "--cap"),
|
|
117
135
|
budget: parseCents(values.budget, "--budget"),
|
|
118
136
|
expiresIn: parseCount(values["expires-in"], "--expires-in") ?? 30 * 86_400,
|
|
137
|
+
startsIn: parseCount(values["starts-in"], "--starts-in"),
|
|
119
138
|
maxOccurrences: parseCount(values["max-occurrences"], "--max-occurrences"),
|
|
120
139
|
description: values.description ?? "A mandate issued by metal-agent",
|
|
121
140
|
out: need(values.out, "--out"),
|
|
122
|
-
|
|
141
|
+
mandateKeyFile: values["mandate-key-file"],
|
|
123
142
|
// The keys that sign the owner's credentials are as private as the owner's key: they live beside it.
|
|
124
143
|
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")),
|
|
125
144
|
}, print);
|
|
@@ -145,17 +164,16 @@ async function main(argv) {
|
|
|
145
164
|
await mandateStatus(on(), need(values.credential, "--credential"), accountFactory(), print);
|
|
146
165
|
}
|
|
147
166
|
else if (sub === "pull") {
|
|
148
|
-
const credentialPath = need(values.credential, "--credential");
|
|
149
|
-
const agentKey = values["agent-key"] ?? agentKeyPath(credentialPath);
|
|
150
167
|
const args = {
|
|
151
|
-
credentialPath,
|
|
152
|
-
|
|
168
|
+
credentialPath: need(values.credential, "--credential"),
|
|
169
|
+
mandateKeyFile: values["mandate-key-file"],
|
|
153
170
|
to: parseAddress(values.to, "--to"),
|
|
154
171
|
amount: parsePullAmount(values.amount, "--amount"),
|
|
155
|
-
|
|
172
|
+
paymentId: parsePaymentId(values["payment-id"], "--payment-id"),
|
|
173
|
+
keyPath: values.key,
|
|
156
174
|
recordRefusal: values["record-refusal"] === true,
|
|
157
175
|
};
|
|
158
|
-
return
|
|
176
|
+
return PULL_EXIT[await mandatePull(on(), args, print)];
|
|
159
177
|
}
|
|
160
178
|
else if (sub === "verify") {
|
|
161
179
|
const tx = values.tx === undefined ? undefined : parseTxHash(values.tx, "--tx");
|
|
@@ -181,6 +199,14 @@ async function main(argv) {
|
|
|
181
199
|
throw new Error(`unknown command "${command}"\n\n${helpFor([])}`);
|
|
182
200
|
}
|
|
183
201
|
}
|
|
202
|
+
/** How many words a command takes before its options: `key import <file|hex>` and `mandate credential create` three, `mcp` one, the other commands two; any number for `help` and for a command the dispatch then names unknown. */
|
|
203
|
+
function argumentCount(command, sub) {
|
|
204
|
+
if ((command === "key" && sub === "import") || (command === "mandate" && sub === "credential"))
|
|
205
|
+
return 3;
|
|
206
|
+
if (command === "mcp")
|
|
207
|
+
return 1;
|
|
208
|
+
return ["key", "fund", "pay", "account", "mandate", "status"].includes(command ?? "") ? 2 : Infinity;
|
|
209
|
+
}
|
|
184
210
|
main(process.argv.slice(2)).then((code) => process.exit(code), (error) => {
|
|
185
211
|
// An endpoint's URL can carry its credential, and this output often lands in an agent's transcript.
|
|
186
212
|
console.error(`error: ${redactUrls(error instanceof Error ? error.message : String(error))}`);
|
package/dist/keystore.js
CHANGED
|
@@ -1,29 +1,33 @@
|
|
|
1
|
-
import { createECDH } from "node:crypto";
|
|
2
|
-
import { access, chmod, mkdir, readFile, writeFile } from "node:fs/promises";
|
|
1
|
+
import { createECDH, randomUUID } from "node:crypto";
|
|
2
|
+
import { access, chmod, link, mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
3
3
|
import { homedir } from "node:os";
|
|
4
|
-
import { dirname, extname, join } from "node:path";
|
|
4
|
+
import { dirname, extname, isAbsolute, join } from "node:path";
|
|
5
5
|
import { createEs256Key, jwkThumbprint, parseP256Jwk } from "@metalntwx/metal-pay/mandates";
|
|
6
6
|
import { isHex } from "viem";
|
|
7
7
|
import { generatePrivateKey, privateKeyToAccount } from "viem/accounts";
|
|
8
8
|
export function isPrivateKey(value) {
|
|
9
9
|
return typeof value === "string" && isHex(value) && value.length === 66;
|
|
10
10
|
}
|
|
11
|
-
|
|
11
|
+
/** `~/.metal-agent`, where metal-agent keeps its own files. An empty HOME names no directory, and they would land under whichever one the command runs in. */
|
|
12
|
+
function agentHome() {
|
|
13
|
+
const home = homedir();
|
|
14
|
+
if (!isAbsolute(home))
|
|
15
|
+
throw new Error(`HOME is ${home === "" ? "empty" : `${JSON.stringify(home)}, not an absolute path`}; set it to your home directory, where metal-agent keeps its key and its pulls`);
|
|
16
|
+
return join(home, ".metal-agent");
|
|
17
|
+
}
|
|
18
|
+
/** The agent's key file when no option names one: `~/.metal-agent/key.json`. */
|
|
19
|
+
export const defaultKeyPath = () => join(agentHome(), "key.json");
|
|
12
20
|
/** `foo.json` to `foo.<suffix>.json`: a file that belongs beside another. */
|
|
13
21
|
function beside(path, suffix) {
|
|
14
22
|
const ext = extname(path);
|
|
15
23
|
return `${path.slice(0, path.length - ext.length)}.${suffix}${ext}`;
|
|
16
24
|
}
|
|
17
|
-
/** The
|
|
18
|
-
export const
|
|
25
|
+
/** The mandate key `credential create` writes beside the credential at `credentialPath`: `mandate.json` to `mandate.key.json`. */
|
|
26
|
+
export const mandateKeyPath = (credentialPath) => beside(credentialPath, "key");
|
|
27
|
+
/** The directory of the pulls `mandate pull` sends under the mandate `reference`, one directory per payment: `~/.metal-agent/pulls/<reference>`. */
|
|
28
|
+
export const pendingPullsPath = (reference) => join(agentHome(), "pulls", reference);
|
|
19
29
|
/** 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
30
|
export const issuerKeyPath = (ownerKeyPath) => beside(ownerKeyPath, "issuer");
|
|
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
|
-
export function gasKeyPath(agentKeyPath) {
|
|
23
|
-
const ext = extname(agentKeyPath);
|
|
24
|
-
const stem = agentKeyPath.slice(0, agentKeyPath.length - ext.length);
|
|
25
|
-
return `${stem.endsWith(".agent-key") ? stem.slice(0, -".agent-key".length) : stem}.gas-key${ext}`;
|
|
26
|
-
}
|
|
27
31
|
/** The secp256k1 key at `path`, generated and written there first when the file does not exist. */
|
|
28
32
|
export async function readOrCreateKey(path) {
|
|
29
33
|
if (await exists(path))
|
|
@@ -33,14 +37,22 @@ export async function readOrCreateKey(path) {
|
|
|
33
37
|
await writeKey(path, { address, privateKey });
|
|
34
38
|
return { address, created: true };
|
|
35
39
|
}
|
|
40
|
+
/** Writes a key file that does not exist: whole, beside it, then linked into place, so a failed write leaves no part-written key for a later run to read. */
|
|
36
41
|
export async function writeKey(path, key) {
|
|
37
42
|
await mkdir(dirname(path), { recursive: true, mode: 0o700 });
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
43
|
+
const draft = `${path}.${randomUUID()}.tmp`;
|
|
44
|
+
try {
|
|
45
|
+
await writeFile(draft, `${JSON.stringify(key, null, 2)}\n`, { mode: 0o600, flag: "wx" });
|
|
46
|
+
await chmod(draft, 0o600);
|
|
47
|
+
await link(draft, path).catch((error) => {
|
|
48
|
+
if (error.code === "EEXIST")
|
|
49
|
+
throw new Error(`${path} already holds a key; pass --out <file> to write another`);
|
|
50
|
+
throw error;
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
finally {
|
|
54
|
+
await rm(draft, { force: true });
|
|
55
|
+
}
|
|
44
56
|
}
|
|
45
57
|
export async function readKey(path) {
|
|
46
58
|
const parsed = JSON.parse(await readFile(path, "utf8"));
|