clanker-wallet 0.4.2 → 0.6.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  # clanker-wallet
2
2
 
3
- Human-approved blockchain transactions for AI agents. Agent proposes a tx, human reviews and signs it from the [web app](https://clanker-wallet.xyz). All communication is end-to-end encrypted.
3
+ Human-approved blockchain transactions for AI agents. Agent proposes a tx, human reviews and signs it from the [web app](https://clanker-wallet.xyz). All communication is end-to-end encrypted. EVM chains take raw transactions; bitcoin and chia take transfers (network, recipient, amount) that the human's wallet builds.
4
4
 
5
5
  ## Install
6
6
 
@@ -36,7 +36,7 @@ clanker-wallet status
36
36
 
37
37
  ### `pair`
38
38
 
39
- Pair with a human's web app. Run once — `pair` writes identity and pairing to `~/.clanker-wallet/`.
39
+ Pair with a human's web app. Run once per chain family — `pair` writes identity and pairing to `~/.clanker-wallet/`. The web app's bitcoin and chia tabs give pairing JSON with `"chain_family":"bitcoin"` / `"chain_family":"chia"`; each family is kept in its own file, so pairing one never replaces another.
40
40
 
41
41
  If the pairing JSON contains `"mode":"managed"`, the relay simulates each transaction for the human's review screen, so `tx` also sends the plain transaction fields to it. Otherwise the relay only sees ciphertext. Pair with the JSON as given.
42
42
 
@@ -78,13 +78,33 @@ clanker-wallet tx \
78
78
  | `--expected-outcome <text>` | no | — | What will happen |
79
79
  | `--timeout <ms>` | no | `3600000` (1 h) | How long `tx` blocks waiting for the human |
80
80
 
81
+ ### `send`
82
+
83
+ Send bitcoin or chia. The human's wallet builds, signs and broadcasts the transaction; you give the network, recipient and amount. Needs a pairing from the web app's bitcoin or chia tab. Same blocking, `--reason`/`--urgency`/`--expected-outcome`/`--timeout`, output and exit codes as `tx`; exit code 4 means that chain family isn't paired.
84
+
85
+ ```bash
86
+ clanker-wallet send --btc testnet --to tb1qw508d6qejxtdg4y5r3zarvary0c5xw7kxpjzsx --sats 1000 --reason "send 1000 sats on bitcoin testnet"
87
+ clanker-wallet send --xch testnet11 --to txch1qqqsyqcyq5rqwzqfpg9scrgwpugpzysnzs23v9ccrydpk8qarc0sw0amhg --mojos 1000 --fee-mojos 0 --reason "send 1000 mojos on chia testnet11"
88
+ # approved → {"tx_hash":"<bitcoin txid or chia transaction id>","status":"approved"}
89
+ ```
90
+
91
+ | Flag | Required | Default | Description |
92
+ |------|----------|---------|-------------|
93
+ | `--btc <network>` | one of `--btc`/`--xch` | — | `mainnet`, `testnet` or `signet` |
94
+ | `--xch <network>` | one of `--btc`/`--xch` | — | `mainnet` or `testnet11` |
95
+ | `--to <address>` | yes | — | On that network: segwit `bc1...`/`tb1...` (no legacy `1...`/`3...`), or `xch1...`/`txch1...` |
96
+ | `--sats <n>` | with `--btc` | — | Whole sats (1 BTC = `100000000`); the wallet sets the fee |
97
+ | `--mojos <n>` | with `--xch` | — | Whole mojos (1 XCH = `1000000000000`) |
98
+ | `--fee-mojos <n>` | no (chia only) | `0` | Chia fee, shown to the human |
99
+
81
100
  ### `status`
82
101
 
83
- Check current pairing status.
102
+ Check current pairing status. `wallet` and `relay` are the evm pairing's; `pairings` lists every paired chain family.
84
103
 
85
104
  ```bash
86
105
  clanker-wallet status
87
- # → {"agent_id":"agent_abc123","paired":true,"wallet":"0x...","relay":"wss://..."}
106
+ # → {"agent_id":"agent_abc123","paired":true,"wallet":"0x...","relay":"wss://...",
107
+ # "pairings":{"evm":{"wallet":"0x...","relay":"wss://...","mode":null,"chain_network":null}}}
88
108
  ```
89
109
 
90
110
  ### `whoami`
@@ -104,7 +124,7 @@ clanker-wallet whoami
104
124
  | 1 | Rejected by human |
105
125
  | 2 | Timeout |
106
126
  | 3 | Connection/relay error |
107
- | 4 | Not paired |
127
+ | 4 | Not paired (`send`: not paired for that chain family) |
108
128
  | 5 | Invalid arguments (bad or missing flag; nothing sent) |
109
129
 
110
130
  ## State
@@ -113,10 +133,15 @@ Identity and pairing are persisted in `~/.clanker-wallet/`:
113
133
 
114
134
  ```
115
135
  ~/.clanker-wallet/
116
- identity.json # agent keypair (auto-generated on first use)
117
- pairing.json # human's public key + relay URL
136
+ identity.json # agent keypair (auto-generated on first use)
137
+ pairings/evm.json # per chain family: human's public key + relay URL
138
+ pairings/bitcoin.json
139
+ pairings/chia.json
140
+ pairing.json # written by 0.4.x; still read as the evm pairing, never changed
118
141
  ```
119
142
 
143
+ `~/.clanker-wallet/pairings/<family>.json` takes precedence over a 0.4.x `pairing.json`, which is never moved, rewritten or deleted.
144
+
120
145
  ## Common Chain IDs
121
146
 
122
147
  | Chain | ID |
@@ -129,6 +154,35 @@ Identity and pairing are persisted in `~/.clanker-wallet/`:
129
154
  | Sepolia (testnet) | 11155111 |
130
155
  | Base Sepolia (testnet) | 84532 |
131
156
 
157
+ ## MCP (assistants)
158
+
159
+ `clanker-wallet mcp` runs an MCP server over stdio, so an assistant (Claude Desktop, Claude Code, Cursor, or another MCP host that runs local commands) can ask for transactions as tool calls. The human approves every request on their own device; the server holds no wallet keys. It uses the CLI's identity and pairings in `~/.clanker-wallet/`, so pair first (`clanker-wallet pair '<JSON>'`, once per chain family). In a sandbox the server needs the same relay access and `~/.clanker-wallet/` writes as the CLI.
160
+
161
+ Claude Code:
162
+
163
+ ```bash
164
+ claude mcp add clanker-wallet -- npx -y clanker-wallet mcp
165
+ ```
166
+
167
+ Claude Desktop (`claude_desktop_config.json`), Cursor (`~/.cursor/mcp.json`), and any other MCP host that runs local stdio servers (command `npx`, args `-y clanker-wallet mcp`):
168
+
169
+ ```json
170
+ {
171
+ "mcpServers": {
172
+ "clanker-wallet": { "command": "npx", "args": ["-y", "clanker-wallet", "mcp"] }
173
+ }
174
+ }
175
+ ```
176
+
177
+ | Tool | Arguments | Returns |
178
+ |------|-----------|---------|
179
+ | `request_evm_tx` | `chain_id`, `to`, `value` (wei, integer string), `data` (default `0x`), `reason`, optional `urgency` / `expected_outcome` | `{"request_id":"req_...","status":"pending","expires_at":"..."}` |
180
+ | `request_transfer` | `family` (`bitcoin`), `network` (`mainnet`, `testnet`, `signet`), `to`, `amount` (sats, integer string), `reason`, optional `urgency` / `expected_outcome` | as above |
181
+ | `get_request` | `request_id`, optional `wait_seconds` (at most 50) | `status`: `pending`, `approved` (with `tx_hash`), `rejected`, `failed` (with `error`), `unknown` (with `error`) or `expired` |
182
+ | `list_pairings` | none | `agent_id`, `fingerprint`, and the paired families (`evm`, `bitcoin`) with network, wallet and mode |
183
+
184
+ Requests are asynchronous: a request tool returns once the relay has the request, then the assistant calls `get_request` (with `wait_seconds` to wait for an answer) until the status is no longer `pending`. Invalid input or a missing pairing is a tool error, and nothing is sent. Pending requests live in the server process: after 1 h the server stops waiting and reports `expired`, and a restarted server no longer knows them (`get_request` then reports an unknown `request_id`). In both cases the human may still approve the request in their wallet, so ask them before sending it again. `unknown` means the request was delivered but the server lost its relay connection: the human may still approve it, so ask them before sending it again. Chia is not offered over MCP yet.
185
+
132
186
  ---
133
187
 
134
188
  ## Library Usage (Advanced)
@@ -155,6 +209,19 @@ const txHash = await wallet.requestTx({
155
209
 
156
210
  `requestTx` throws `InvalidTxError` (with `field` and `reason`) before anything is sent if `chain_id`, `to`, `value` or `data` is malformed.
157
211
 
212
+ With a bitcoin or chia pairing, use `requestTransfer`; the family comes from the pairing:
213
+
214
+ ```typescript
215
+ // 1000 mojos on chia testnet11; amount and fee are integer strings or bigints
216
+ const txId = await wallet.requestTransfer({
217
+ chain_network: 'testnet11',
218
+ transfer: { asset: 'xch', to: 'txch1qqqsyqcyq5rqwzqfpg9scrgwpugpzysnzs23v9ccrydpk8qarc0sw0amhg', amount: '1000', fee: '0' },
219
+ context: { reason: 'send 1000 mojos on chia testnet11' },
220
+ })
221
+ ```
222
+
223
+ It throws `InvalidTxError` before sending for an evm pairing, an unknown network, a recipient from another network, a non-integer amount, or a bitcoin `fee` (the wallet sets bitcoin fees). Chia `fee` defaults to `"0"`.
224
+
158
225
  See full API: [`src/`](./src/)
159
226
 
160
227
  ## Python SDK
@@ -176,14 +243,22 @@ tx_hash = await agent.request_tx(RequestTxOptions(
176
243
  transaction=Transaction(to="0x000000000000000000000000000000000000dEaD", value="1000000000000000", data="0x"),
177
244
  context=TxContext(reason="send 0.001 ETH on base sepolia"),
178
245
  ))
246
+
247
+ # with a bitcoin pairing: 1000 sats on bitcoin testnet
248
+ from clanker_wallet import RequestTransferOptions, Transfer
249
+
250
+ txid = await agent.request_transfer(RequestTransferOptions(
251
+ chain_network="testnet",
252
+ transfer=Transfer(asset="btc", to="tb1qw508d6qejxtdg4y5r3zarvary0c5xw7kxpjzsx", amount=1000),
253
+ ))
179
254
  ```
180
255
 
181
256
  ## How It Works
182
257
 
183
258
  1. Human opens [clanker-wallet.xyz](https://clanker-wallet.xyz), connects wallet, clicks "copy prompt for agent" — gets a paste-ready prompt that bundles intent, the setup guide URL, and the pairing JSON
184
259
  2. Human pastes the prompt to their AI agent. Agent extracts the JSON and runs `clanker-wallet pair` — establishes E2E encrypted channel
185
- 3. Agent runs `clanker-wallet tx` — encrypts tx with nacl.box, sends via WebSocket relay
186
- 4. Human reviews tx details, simulation, risk flags in the web app. A new agent shows up as unverified with its fingerprint; auto-approve rules only apply after the human trusts it. Contract rules (address + methods) don't limit token amounts or recipients
260
+ 3. Agent runs `clanker-wallet tx` (or `send` for bitcoin/chia) — encrypts the request with nacl.box, sends via WebSocket relay
261
+ 4. Human reviews tx details, simulation, risk flags in the web app. A new agent shows up as unverified with its fingerprint; auto-approve rules only apply after the human trusts it. Trust applies on every chain family; each chain family has its own policy (rules and remote URL), edited while that chain is selected. Value and daily limits are wei for evm and sats for bitcoin (the sent amount; the wallet's fee isn't counted); a send on another family never matches a rule that sets one, and chia has none. On bitcoin the wallet still confirms each auto-approved send. Contract rules (address + methods) don't limit token amounts or recipients
187
262
  5. Human approves → wallet signs + broadcasts → agent gets the tx hash
188
263
 
189
264
  Request and response payloads are encrypted end-to-end; the relay only forwards them. Exception: when paired in managed mode, the agent also sends the transaction fields (from, to, value, data, chainId) to the relay for simulation.
package/SKILL.md CHANGED
@@ -51,13 +51,15 @@ clanker-wallet pair '{"version":1,"pubkey":"ab12...","relay":"wss://...","wallet
51
51
 
52
52
  If the human only pastes raw JSON (no prompt prose), pair with it directly. If they only paste the URL, ask them to click "copy prompt for agent" on the pairing screen.
53
53
 
54
- `pair` writes your identity and the pairing to `~/.clanker-wallet/`. You only need to do this once.
54
+ `pair` writes your identity and the pairing to `~/.clanker-wallet/`. You only need to do this once per chain family.
55
+
56
+ The web app has an evm, a bitcoin and a chia tab, and each tab gives its own pairing JSON (bitcoin and chia add `"chain_family":"bitcoin"` / `"chain_family":"chia"`). Pair each one you need: every family is kept in its own file, `~/.clanker-wallet/pairings/<family>.json` (`evm`, `bitcoin`, `chia`), so pairing one never replaces another. A `~/.clanker-wallet/pairing.json` written by clanker-wallet 0.4.x is still read as the evm pairing and is left untouched.
55
57
 
56
58
  If the JSON contains `"mode":"managed"`, the human chose managed mode: the relay simulates each transaction for the review screen, so `tx` also sends the plain transaction fields (from, to, value, data, chainId) to the relay, which shares them with its simulation provider. Without it (or with `"mode":"encrypted"`) the relay only sees ciphertext. Pair with the JSON exactly as given either way.
57
59
 
58
60
  ### Trust (first request)
59
61
 
60
- The web app lists a new agent as unverified and shows its fingerprint (e.g. `ab12 cd34 ef56 7890`). Until the human compares it with the `fingerprint` from `clanker-wallet whoami` and clicks "trust this agent", none of their auto-approve rules apply: every tx waits for manual approval. Contract rules (contract address + allowed methods) do not limit token amounts or recipients: a rule allowing `transfer` on a token auto-approves any amount to any recipient. If asked, run `clanker-wallet whoami` and share the fingerprint.
62
+ The web app lists a new agent as unverified and shows its fingerprint (e.g. `ab12 cd34 ef56 7890`). Until the human compares it with the `fingerprint` from `clanker-wallet whoami` and clicks "trust this agent", none of their auto-approve rules apply: every tx waits for manual approval. Trust applies on every chain family; each chain family has its own policy (rules and remote URL), edited while that chain is selected. Value and daily limits come in two units: wei limits apply to evm only, sats limits to bitcoin only (they count the sent amount, not the fee the wallet adds); a `send` on any other family never matches a rule that sets one and waits for the human. Chia has no value limits. On bitcoin the wallet still asks the human to confirm each auto-approved `send`. Contract rules (contract address + allowed methods) do not limit token amounts or recipients: a rule allowing `transfer` on a token auto-approves any amount to any recipient. If asked, run `clanker-wallet whoami` and share the fingerprint.
61
63
 
62
64
  ### Transaction notifications
63
65
 
@@ -117,6 +119,21 @@ The command **blocks until the human approves or rejects**, for up to 1 hour by
117
119
  | `--expected-outcome <text>` | no | — | What will happen |
118
120
  | `--timeout <ms>` | no | `3600000` (1 h) | How long `tx` blocks waiting for the human |
119
121
 
122
+ ## Sending Bitcoin or Chia
123
+
124
+ Bitcoin and chia sends are transfers: you name the network, recipient and amount, and the human's wallet builds, signs and broadcasts the transaction. Pair the web app's bitcoin or chia tab first.
125
+
126
+ ```bash
127
+ clanker-wallet send --btc testnet --to tb1qw508d6qejxtdg4y5r3zarvary0c5xw7kxpjzsx --sats 1000 --reason "Send 1000 sats on bitcoin testnet"
128
+ clanker-wallet send --xch testnet11 --to txch1qqqsyqcyq5rqwzqfpg9scrgwpugpzysnzs23v9ccrydpk8qarc0sw0amhg --mojos 1000 --fee-mojos 0 --reason "Send 1000 mojos on chia testnet11"
129
+ ```
130
+
131
+ - **network:** `--btc mainnet|testnet|signet` or `--xch mainnet|testnet11`. There is no default network; the recipient must belong to it (`bc1...` on bitcoin mainnet, `tb1...` on testnet/signet, `xch1...` / `txch1...` on chia).
132
+ - **bitcoin recipients are segwit addresses only** (`bc1q...`, `bc1p...`, `tb1...`); legacy `1...`/`3...` addresses are refused.
133
+ - **amounts are whole base units**: `--sats` (1 BTC = `100000000`), `--mojos` (1 XCH = `1000000000000`). No decimals.
134
+ - **fees:** bitcoin fees are set in the human's wallet. Chia takes `--fee-mojos` (default `0`), which the human sees before approving.
135
+ - `--reason`, `--urgency`, `--expected-outcome` and `--timeout` work as for `tx`, and so do the output and exit codes. On approval `tx_hash` is the bitcoin txid or the chia transaction id. Exit code 4 means that chain family isn't paired.
136
+
120
137
  ## Common Chain IDs
121
138
 
122
139
  | Chain | ID |
@@ -165,17 +182,50 @@ clanker-wallet tx \
165
182
  ```bash
166
183
  # Check pairing status
167
184
  clanker-wallet status
168
- # → {"agent_id":"agent_abc123","paired":true,"wallet":"0x...","relay":"wss://..."}
185
+ # → {"agent_id":"agent_abc123","paired":true,"wallet":"0x...","relay":"wss://...",
186
+ # "pairings":{"evm":{"wallet":"0x...","relay":"wss://...","mode":null,"chain_network":null},"chia":{"wallet":"xch1...",...}}}
187
+ # wallet and relay are the evm pairing's; pairings lists every paired chain family
169
188
 
170
189
  # Show agent identity
171
190
  clanker-wallet whoami
172
191
  # → {"agent_id":"agent_abc123","public_key":"ab12cd34ef567890...","fingerprint":"ab12 cd34 ef56 7890"}
173
192
  ```
174
193
 
194
+ ## MCP (assistants)
195
+
196
+ `clanker-wallet mcp` runs an MCP server over stdio, so an assistant (Claude Desktop, Claude Code, Cursor, or another MCP host that runs local commands) can ask for transactions as tool calls. The human approves every request on their own device; the server holds no wallet keys. It uses the CLI's identity and pairings in `~/.clanker-wallet/`, so pair first (`clanker-wallet pair '<JSON>'`, once per chain family). In a sandbox the server needs the same relay access and `~/.clanker-wallet/` writes as the CLI.
197
+
198
+ If your host supports MCP, the human can add the server instead of you running `tx` / `send`:
199
+
200
+ Claude Code:
201
+
202
+ ```bash
203
+ claude mcp add clanker-wallet -- npx -y clanker-wallet mcp
204
+ ```
205
+
206
+ Claude Desktop (`claude_desktop_config.json`), Cursor (`~/.cursor/mcp.json`), and any other MCP host that runs local stdio servers (command `npx`, args `-y clanker-wallet mcp`):
207
+
208
+ ```json
209
+ {
210
+ "mcpServers": {
211
+ "clanker-wallet": { "command": "npx", "args": ["-y", "clanker-wallet", "mcp"] }
212
+ }
213
+ }
214
+ ```
215
+
216
+ | Tool | Arguments | Returns |
217
+ |------|-----------|---------|
218
+ | `request_evm_tx` | `chain_id`, `to`, `value` (wei, integer string), `data` (default `0x`), `reason`, optional `urgency` / `expected_outcome` | `{"request_id":"req_...","status":"pending","expires_at":"..."}` |
219
+ | `request_transfer` | `family` (`bitcoin`), `network` (`mainnet`, `testnet`, `signet`), `to`, `amount` (sats, integer string), `reason`, optional `urgency` / `expected_outcome` | as above |
220
+ | `get_request` | `request_id`, optional `wait_seconds` (at most 50) | `status`: `pending`, `approved` (with `tx_hash`), `rejected`, `failed` (with `error`), `unknown` (with `error`) or `expired` |
221
+ | `list_pairings` | none | `agent_id`, `fingerprint`, and the paired families (`evm`, `bitcoin`) with network, wallet and mode |
222
+
223
+ Flow: call `list_pairings` to see which chains you can use, call `request_evm_tx` or `request_transfer` (same rules as `tx` / `send`: wei and sats as integer strings, real addresses), tell the human a request is waiting (see "Transaction notifications"), then call `get_request` with `wait_seconds` until the status is no longer `pending`. Invalid input or a missing pairing comes back as a tool error and nothing is sent. After 1 h the server stops waiting and reports `expired`, and a restarted server no longer knows its earlier requests (`get_request` then reports an unknown `request_id`). In both cases the human may still approve the request in their wallet, so ask them before sending it again. `unknown` means the request was delivered but the server lost its relay connection: the human may still approve it, so ask them before sending it again. Chia is not offered over MCP yet.
224
+
175
225
  ## Troubleshooting
176
226
 
177
227
  - **exit code 5 / "invalid --flag: ..."** — fix the named flag; nothing was sent. Usually a placeholder `--to`, a `--value` in ETH instead of wei, or `--data` that isn't even-length hex
178
- - **exit code 4 / "not paired"** — run `clanker-wallet pair '<JSON>'` first
228
+ - **exit code 4 / "not paired"** — run `clanker-wallet pair '<JSON>'` first (for `send`, with the JSON from the web app's bitcoin or chia tab)
179
229
  - **exit code 2 / timeout** — human didn't respond in time, increase `--timeout` or ask them to check the web app
180
230
  - **exit code 1 / "rejected"** — human clicked reject. Explain why the tx is needed and try again if appropriate.
181
231
  - **exit code 3 / "relay connection error: could not reach wss://..."** — if you run in a sandbox or behind a firewall, allow that host (`relay.clanker-wallet.xyz` by default) and, if the error mentions it, rerun with `NODE_USE_ENV_PROXY=1` (see "Running in a sandbox"); otherwise the relay may be down or rate limiting, try again in a few seconds
package/bin/cli.js CHANGED
@@ -1,18 +1,29 @@
1
1
  #!/usr/bin/env node
2
- import { ClankerWallet, InvalidTxError, validateTx } from '../dist/index.js'
2
+ import { ClankerWallet, InvalidTxError, validateTransfer, validateTx } from '../dist/index.js'
3
+ import { createMcpServer, MAX_WAIT_SECONDS, REQUEST_STATUSES } from '../dist/mcp.js'
3
4
  import { readFileSync, writeFileSync, mkdirSync, existsSync, chmodSync } from 'fs'
4
5
  import { join } from 'path'
5
6
  import { homedir } from 'os'
7
+ import { createInterface } from 'readline'
6
8
 
7
9
  // ── constants ────────────────────────────────────────────────────────
8
10
  const STATE_DIR = join(homedir(), '.clanker-wallet')
9
11
  const IDENTITY_FILE = join(STATE_DIR, 'identity.json')
10
- const PAIRING_FILE = join(STATE_DIR, 'pairing.json')
12
+ // one pairing per chain family: pairings/<family>.json. 0.4.x wrote pairing.json (evm only); it is still read as the
13
+ // evm pairing and never moved, rewritten or deleted
14
+ const PAIRINGS_DIR = join(STATE_DIR, 'pairings')
15
+ const LEGACY_PAIRING_FILE = join(STATE_DIR, 'pairing.json')
16
+ const FAMILIES = ['evm', 'bitcoin', 'chia']
11
17
 
12
18
  const EXIT = { OK: 0, REJECTED: 1, TIMEOUT: 2, CONNECTION: 3, NOT_PAIRED: 4, BAD_ARGS: 5 }
13
19
  const DEFAULT_TIMEOUT_MS = 3_600_000
14
20
  /** validateTx field -> the tx flag that sets it */
15
21
  const FIELD_FLAGS = { chain_id: '--chain', to: '--to', value: '--value', data: '--data' }
22
+ /** per send network flag (named after the asset it sends): the pairing family it uses and its amount/fee flags */
23
+ const SEND_FAMILIES = {
24
+ btc: { family: 'bitcoin', amountFlag: 'sats', networks: 'mainnet, testnet or signet' },
25
+ xch: { family: 'chia', amountFlag: 'mojos', feeFlag: 'fee-mojos', networks: 'mainnet or testnet11' },
26
+ }
16
27
 
17
28
  // ── helpers ──────────────────────────────────────────────────────────
18
29
  const hex = (bytes) => Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join('')
@@ -50,15 +61,20 @@ function saveIdentity(data) {
50
61
  chmodSync(IDENTITY_FILE, 0o600)
51
62
  }
52
63
 
53
- function loadPairing() {
54
- if (!existsSync(PAIRING_FILE)) return null
55
- try { return JSON.parse(readFileSync(PAIRING_FILE, 'utf8')) }
64
+ function readJson(path) {
65
+ if (!existsSync(path)) return null
66
+ try { return JSON.parse(readFileSync(path, 'utf8')) }
56
67
  catch { return null }
57
68
  }
58
69
 
59
- function savePairing(data) {
70
+ function loadPairing(family) {
71
+ return readJson(join(PAIRINGS_DIR, `${family}.json`)) ?? (family === 'evm' ? readJson(LEGACY_PAIRING_FILE) : null)
72
+ }
73
+
74
+ function savePairing(family, data) {
60
75
  ensureDir()
61
- writeFileSync(PAIRING_FILE, JSON.stringify(data, null, 2) + '\n')
76
+ if (!existsSync(PAIRINGS_DIR)) mkdirSync(PAIRINGS_DIR, { mode: 0o700 })
77
+ writeFileSync(join(PAIRINGS_DIR, `${family}.json`), JSON.stringify(data, null, 2) + '\n')
62
78
  }
63
79
 
64
80
  /** restore or create a ClankerWallet with persisted identity */
@@ -114,11 +130,19 @@ function cmdPair(positional) {
114
130
  die(`pair failed: ${err.message}`, EXIT.BAD_ARGS)
115
131
  }
116
132
 
117
- savePairing({
133
+ // the family names the pairing file, so only the known ones are accepted
134
+ const family = pairingData.chain_family || 'evm'
135
+ if (!FAMILIES.includes(family)) {
136
+ die(`pair failed: unsupported chain_family ${JSON.stringify(family)} (expected ${FAMILIES.join(', ')})`, EXIT.BAD_ARGS)
137
+ }
138
+
139
+ savePairing(family, {
118
140
  pubkey: pairingData.pubkey,
119
141
  relay: pairingData.relay,
120
142
  wallet: pairingData.wallet || null,
121
143
  mode: pairingData.mode || null,
144
+ chain_family: pairingData.chain_family,
145
+ chain_network: pairingData.chain_network,
122
146
  })
123
147
 
124
148
  out({
@@ -126,55 +150,43 @@ function cmdPair(positional) {
126
150
  status: 'paired',
127
151
  wallet: pairingData.wallet || null,
128
152
  relay: pairingData.relay,
153
+ ...(family !== 'evm' && { chain_family: family }),
129
154
  })
130
155
  }
131
156
 
132
- async function cmdTx(flags) {
133
- const pairing = loadPairing()
134
- if (!pairing) die('not paired — run: clanker-wallet pair \'<JSON>\'', EXIT.NOT_PAIRED)
135
-
136
- const chain = flags.chain
137
- const to = flags.to
138
- if (!chain) die('missing --chain', EXIT.BAD_ARGS)
139
- if (!to) die('missing --to', EXIT.BAD_ARGS)
140
-
141
- if (!/^[0-9]+$/.test(chain)) die(`invalid --chain: must be a positive integer chain id, got ${JSON.stringify(chain)}`, EXIT.BAD_ARGS)
157
+ function parseTimeout(flags) {
142
158
  if (flags.timeout !== undefined && !/^[1-9][0-9]*$/.test(flags.timeout)) {
143
159
  die(`invalid --timeout: must be a positive number of milliseconds, got ${JSON.stringify(flags.timeout)}`, EXIT.BAD_ARGS)
144
160
  }
145
- const timeout = flags.timeout ? Number(flags.timeout) : DEFAULT_TIMEOUT_MS
146
-
147
- // same checks requestTx runs, done here so a bad flag exits 5 instead of surfacing as a connection error
148
- let request
149
- try {
150
- request = validateTx(Number(chain), { to, value: flags.value ?? '0', data: flags.data ?? '0x' })
151
- } catch (err) {
152
- if (!(err instanceof InvalidTxError)) throw err
153
- die(`invalid ${FIELD_FLAGS[err.field]}: ${err.reason}`, EXIT.BAD_ARGS)
154
- }
161
+ return flags.timeout ? Number(flags.timeout) : DEFAULT_TIMEOUT_MS
162
+ }
155
163
 
164
+ function contextFrom(flags) {
156
165
  const context = {}
157
166
  if (flags.reason) context.reason = flags.reason
158
167
  if (flags.urgency) context.urgency = flags.urgency
159
168
  if (flags['expected-outcome']) context.expected_outcome = flags['expected-outcome']
169
+ return Object.keys(context).length > 0 ? context : undefined
170
+ }
160
171
 
161
- const wallet = getWallet()
162
- const pairingJson = JSON.stringify({
172
+ /** a ClankerWallet (the saved identity by default) paired from a saved pairing */
173
+ function pairedWallet(pairing, wallet = getWallet()) {
174
+ wallet.pair(JSON.stringify({
163
175
  version: 1,
164
176
  pubkey: pairing.pubkey,
165
177
  relay: pairing.relay,
166
178
  wallet: pairing.wallet,
167
179
  mode: pairing.mode || undefined,
168
- })
169
- wallet.pair(pairingJson)
180
+ chain_family: pairing.chain_family,
181
+ chain_network: pairing.chain_network,
182
+ }))
183
+ return wallet
184
+ }
170
185
 
186
+ /** wait for the human's answer and exit with its code */
187
+ async function exitWithResult(request) {
171
188
  try {
172
- const txHash = await wallet.requestTx({
173
- chain_id: request.chain_id,
174
- transaction: request.transaction,
175
- context: Object.keys(context).length > 0 ? context : undefined,
176
- timeout_ms: timeout,
177
- })
189
+ const txHash = await request
178
190
  out({ tx_hash: txHash, status: 'approved' })
179
191
  process.exit(EXIT.OK)
180
192
  } catch (err) {
@@ -192,15 +204,99 @@ async function cmdTx(flags) {
192
204
  }
193
205
  }
194
206
 
207
+ async function cmdTx(flags) {
208
+ const pairing = loadPairing('evm')
209
+ if (!pairing) die('not paired — run: clanker-wallet pair \'<JSON>\'', EXIT.NOT_PAIRED)
210
+
211
+ const chain = flags.chain
212
+ const to = flags.to
213
+ if (!chain) die('missing --chain', EXIT.BAD_ARGS)
214
+ if (!to) die('missing --to', EXIT.BAD_ARGS)
215
+
216
+ if (!/^[0-9]+$/.test(chain)) die(`invalid --chain: must be a positive integer chain id, got ${JSON.stringify(chain)}`, EXIT.BAD_ARGS)
217
+ const timeout = parseTimeout(flags)
218
+
219
+ // same checks requestTx runs, done here so a bad flag exits 5 instead of surfacing as a connection error
220
+ let request
221
+ try {
222
+ request = validateTx(Number(chain), { to, value: flags.value ?? '0', data: flags.data ?? '0x' })
223
+ } catch (err) {
224
+ if (!(err instanceof InvalidTxError)) throw err
225
+ die(`invalid ${FIELD_FLAGS[err.field]}: ${err.reason}`, EXIT.BAD_ARGS)
226
+ }
227
+
228
+ const wallet = pairedWallet(pairing)
229
+ await exitWithResult(wallet.requestTx({
230
+ chain_id: request.chain_id,
231
+ transaction: request.transaction,
232
+ context: contextFrom(flags),
233
+ timeout_ms: timeout,
234
+ }))
235
+ }
236
+
237
+ async function cmdSend(flags) {
238
+ const picked = Object.keys(SEND_FAMILIES).filter((flag) => flags[flag] !== undefined)
239
+ if (picked.length !== 1) die('invalid --btc/--xch: give exactly one of --btc <network> or --xch <network>', EXIT.BAD_ARGS)
240
+ const networkFlag = picked[0]
241
+ const { family, amountFlag, feeFlag, networks } = SEND_FAMILIES[networkFlag]
242
+ const network = flags[networkFlag]
243
+ if (network === true) die(`invalid --${networkFlag}: give the network, ${networks}`, EXIT.BAD_ARGS)
244
+
245
+ // another family's amount/fee flag is a mistake, not something to ignore
246
+ for (const flag of ['sats', 'mojos', 'fee-mojos']) {
247
+ if (flag !== amountFlag && flag !== feeFlag && flags[flag] !== undefined) {
248
+ die(`invalid --${flag}: not a --${networkFlag} flag (${family} amounts are --${amountFlag}${feeFlag ? `, fee --${feeFlag}` : ', and the wallet sets the fee'})`, EXIT.BAD_ARGS)
249
+ }
250
+ }
251
+ for (const flag of ['to', amountFlag, feeFlag]) {
252
+ if (flag && flags[flag] === true) die(`invalid --${flag}: missing value`, EXIT.BAD_ARGS)
253
+ }
254
+
255
+ const pairing = loadPairing(family)
256
+ if (!pairing) die(`not paired with a ${family} wallet — open the web app's ${family} tab and run: clanker-wallet pair '<JSON>'`, EXIT.NOT_PAIRED)
257
+
258
+ if (!flags.to) die('missing --to', EXIT.BAD_ARGS)
259
+ if (flags[amountFlag] === undefined) die(`missing --${amountFlag}`, EXIT.BAD_ARGS)
260
+ const timeout = parseTimeout(flags)
261
+
262
+ const transfer = { asset: networkFlag, to: flags.to, amount: flags[amountFlag] }
263
+ if (feeFlag && flags[feeFlag] !== undefined) transfer.fee = flags[feeFlag]
264
+ const flagFor = { chain_family: networkFlag, chain_network: networkFlag, asset: networkFlag, to: 'to', amount: amountFlag, fee: feeFlag }
265
+ const badArg = (err) => die(`invalid --${flagFor[err.field]}: ${err.reason}`, EXIT.BAD_ARGS)
266
+
267
+ // same checks requestTransfer runs, done here so a bad flag exits 5 instead of surfacing as a connection error
268
+ try {
269
+ validateTransfer(family, network, transfer)
270
+ } catch (err) {
271
+ if (!(err instanceof InvalidTxError)) throw err
272
+ badArg(err)
273
+ }
274
+
275
+ const wallet = pairedWallet(pairing)
276
+ // requestTransfer also refuses a network the pairing isn't for; that is a bad flag too
277
+ const request = wallet.requestTransfer({ chain_network: network, transfer, context: contextFrom(flags), timeout_ms: timeout })
278
+ await request.catch((err) => { if (err instanceof InvalidTxError) badArg(err) })
279
+ await exitWithResult(request)
280
+ }
281
+
195
282
  function cmdStatus() {
196
283
  const identity = loadIdentity()
197
- const pairing = loadPairing()
284
+ const pairings = {}
285
+ for (const family of FAMILIES) {
286
+ const pairing = loadPairing(family)
287
+ if (pairing) {
288
+ pairings[family] = { wallet: pairing.wallet || null, relay: pairing.relay || null, mode: pairing.mode || null, chain_network: pairing.chain_network || null }
289
+ }
290
+ }
291
+ const evm = pairings.evm
198
292
 
293
+ // wallet/relay stay the evm pairing's, as in 0.4.x
199
294
  out({
200
295
  agent_id: identity?.agent_id || null,
201
- paired: !!pairing,
202
- wallet: pairing?.wallet || null,
203
- relay: pairing?.relay || null,
296
+ paired: Object.keys(pairings).length > 0,
297
+ wallet: evm?.wallet || null,
298
+ relay: evm?.relay || null,
299
+ pairings,
204
300
  })
205
301
  }
206
302
 
@@ -213,9 +309,55 @@ function cmdWhoami() {
213
309
  })
214
310
  }
215
311
 
312
+ function packageVersion() {
313
+ return JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version
314
+ }
315
+
216
316
  function cmdVersion() {
217
- const { version } = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'))
218
- process.stdout.write(version + '\n')
317
+ process.stdout.write(packageVersion() + '\n')
318
+ }
319
+
320
+ function showMcpHelp() {
321
+ process.stderr.write(`clanker-wallet mcp — MCP server over stdio for assistants (Claude Desktop, Claude Code, Cursor, ...)
322
+
323
+ uses this agent's identity and pairings (~/.clanker-wallet/); pair first with: clanker-wallet pair '<JSON>'
324
+ every request waits for the human to approve it on their own device.
325
+
326
+ tools:
327
+ request_evm_tx ask for an evm transaction (value in wei, integer string) -> { request_id }
328
+ request_transfer ask for a bitcoin send (amount in sats, integer string) -> { request_id }
329
+ get_request ${REQUEST_STATUSES.join(' | ')}; wait_seconds up to ${MAX_WAIT_SECONDS}
330
+ list_pairings paired chain families, networks, wallets, and the agent fingerprint
331
+
332
+ claude code: claude mcp add clanker-wallet -- npx -y clanker-wallet mcp
333
+ other hosts: {"mcpServers":{"clanker-wallet":{"command":"npx","args":["-y","clanker-wallet","mcp"]}}}
334
+
335
+ requests are kept in memory: after 1 h the server stops waiting (expired), and a restart forgets pending ones.
336
+ expired, unknown (relay lost after delivery) or forgotten: the human may still approve it, so ask before sending it again.
337
+ docs: https://clanker-wallet.xyz/SKILL.md
338
+ `)
339
+ }
340
+
341
+ /** serve MCP on stdin/stdout until stdin ends; stdout carries only protocol messages */
342
+ async function cmdMcp(flags) {
343
+ if (flags.help) return showMcpHelp()
344
+
345
+ const identity = getWallet()
346
+ const server = createMcpServer({
347
+ version: packageVersion(),
348
+ identity: { agent_id: identity.agentId, fingerprint: fingerprint(hex(identity.publicKey)) },
349
+ loadPairing,
350
+ // the identity loaded at startup, which list_pairings shows: re-reading identity.json per request could switch agents mid-run
351
+ wallet: (pairing) => pairedWallet(pairing, new ClankerWallet({ secretKey: identity.secretKey })),
352
+ })
353
+ const lines = createInterface({ input: process.stdin, crlfDelay: Infinity })
354
+ // each line is answered when it settles, so a get_request that waits never holds up the others
355
+ lines.on('line', (line) => {
356
+ server.handleLine(line).then((reply) => { if (reply) process.stdout.write(reply + '\n') })
357
+ })
358
+ await new Promise((resolve) => lines.on('close', resolve))
359
+ // the host is gone: drop pending requests (their relay sockets would keep the process alive)
360
+ process.exit(EXIT.OK)
219
361
  }
220
362
 
221
363
  function showHelp() {
@@ -223,9 +365,11 @@ function showHelp() {
223
365
 
224
366
  commands:
225
367
  pair '<JSON>' pair with human's web app (one-time setup)
226
- tx send a transaction (blocks until approved/rejected)
227
- status check pairing status
368
+ tx send an evm transaction (blocks until approved/rejected)
369
+ send send btc or xch; the human's wallet builds it (blocks until approved/rejected)
370
+ status check pairing status (one pairing per chain family: evm, bitcoin, chia)
228
371
  whoami show agent identity
372
+ mcp run an MCP server over stdio for assistants (Claude Desktop/Code, Cursor, ...); mcp --help
229
373
 
230
374
  tx flags:
231
375
  --chain <id> chain ID (required) — 1, 8453, 84532 (base sepolia), etc.
@@ -237,18 +381,29 @@ tx flags:
237
381
  --expected-outcome <text> what will happen
238
382
  --timeout <ms> how long tx blocks waiting for the human (default: 3600000 = 1 h)
239
383
 
384
+ send flags (pair the web app's bitcoin or chia tab first):
385
+ send --btc <mainnet|testnet|signet> --to <address> --sats <n>
386
+ send --xch <mainnet|testnet11> --to <address> --mojos <n> [--fee-mojos <n>]
387
+ --to <address> recipient: segwit bc1/tb1 for bitcoin, xch1/txch1 for chia (required)
388
+ --sats <n> bitcoin amount in sats (1 BTC = 100000000); the wallet sets the fee
389
+ --mojos <n> chia amount in mojos (1 XCH = 1000000000000)
390
+ --fee-mojos <n> chia fee in mojos (default: 0), shown to the human
391
+ --reason, --urgency, --expected-outcome, --timeout as for tx
392
+
240
393
  exit codes:
241
394
  0 success
242
395
  1 rejected by human
243
396
  2 timeout
244
397
  3 connection error (in a sandbox or behind a firewall, allow the relay host, wss://relay.clanker-wallet.xyz/ws by default;
245
398
  behind HTTPS_PROXY run with NODE_USE_ENV_PROXY=1)
246
- 4 not paired
399
+ 4 not paired (send: not paired for that chain family)
247
400
  5 invalid arguments
248
401
 
249
402
  examples:
250
403
  clanker-wallet pair '{"version":1,"pubkey":"ab12...","relay":"wss://...","wallet":"0x..."}'
251
404
  clanker-wallet tx --chain 84532 --to 0x000000000000000000000000000000000000dEaD --value 1000000000000000 --reason "send 0.001 ETH on base sepolia"
405
+ clanker-wallet send --btc testnet --to tb1qw508d6qejxtdg4y5r3zarvary0c5xw7kxpjzsx --sats 1000 --reason "test payment"
406
+ clanker-wallet send --xch testnet11 --to txch1qqqsyqcyq5rqwzqfpg9scrgwpugpzysnzs23v9ccrydpk8qarc0sw0amhg --mojos 1000 --fee-mojos 0
252
407
  clanker-wallet status
253
408
  clanker-wallet whoami
254
409
 
@@ -267,12 +422,18 @@ switch (cmd) {
267
422
  case 'tx':
268
423
  await cmdTx(flags)
269
424
  break
425
+ case 'send':
426
+ await cmdSend(flags)
427
+ break
270
428
  case 'status':
271
429
  cmdStatus()
272
430
  break
273
431
  case 'whoami':
274
432
  cmdWhoami()
275
433
  break
434
+ case 'mcp':
435
+ await cmdMcp(flags)
436
+ break
276
437
  case '--version':
277
438
  case '-v':
278
439
  cmdVersion()