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