clanker-wallet 0.4.2 → 0.5.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 |
@@ -155,6 +180,19 @@ const txHash = await wallet.requestTx({
155
180
 
156
181
  `requestTx` throws `InvalidTxError` (with `field` and `reason`) before anything is sent if `chain_id`, `to`, `value` or `data` is malformed.
157
182
 
183
+ With a bitcoin or chia pairing, use `requestTransfer`; the family comes from the pairing:
184
+
185
+ ```typescript
186
+ // 1000 mojos on chia testnet11; amount and fee are integer strings or bigints
187
+ const txId = await wallet.requestTransfer({
188
+ chain_network: 'testnet11',
189
+ transfer: { asset: 'xch', to: 'txch1qqqsyqcyq5rqwzqfpg9scrgwpugpzysnzs23v9ccrydpk8qarc0sw0amhg', amount: '1000', fee: '0' },
190
+ context: { reason: 'send 1000 mojos on chia testnet11' },
191
+ })
192
+ ```
193
+
194
+ 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"`.
195
+
158
196
  See full API: [`src/`](./src/)
159
197
 
160
198
  ## Python SDK
@@ -176,14 +214,22 @@ tx_hash = await agent.request_tx(RequestTxOptions(
176
214
  transaction=Transaction(to="0x000000000000000000000000000000000000dEaD", value="1000000000000000", data="0x"),
177
215
  context=TxContext(reason="send 0.001 ETH on base sepolia"),
178
216
  ))
217
+
218
+ # with a bitcoin pairing: 1000 sats on bitcoin testnet
219
+ from clanker_wallet import RequestTransferOptions, Transfer
220
+
221
+ txid = await agent.request_transfer(RequestTransferOptions(
222
+ chain_network="testnet",
223
+ transfer=Transfer(asset="btc", to="tb1qw508d6qejxtdg4y5r3zarvary0c5xw7kxpjzsx", amount=1000),
224
+ ))
179
225
  ```
180
226
 
181
227
  ## How It Works
182
228
 
183
229
  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
230
  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
231
+ 3. Agent runs `clanker-wallet tx` (or `send` for bitcoin/chia) — encrypts the request with nacl.box, sends via WebSocket relay
232
+ 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
233
  5. Human approves → wallet signs + broadcasts → agent gets the tx hash
188
234
 
189
235
  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,7 +182,9 @@ 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
@@ -175,7 +194,7 @@ clanker-wallet whoami
175
194
  ## Troubleshooting
176
195
 
177
196
  - **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
197
+ - **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
198
  - **exit code 2 / timeout** — human didn't respond in time, increase `--timeout` or ask them to check the web app
180
199
  - **exit code 1 / "rejected"** — human clicked reject. Explain why the tx is needed and try again if appropriate.
181
200
  - **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,5 +1,5 @@
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
3
  import { readFileSync, writeFileSync, mkdirSync, existsSync, chmodSync } from 'fs'
4
4
  import { join } from 'path'
5
5
  import { homedir } from 'os'
@@ -7,12 +7,21 @@ import { homedir } from 'os'
7
7
  // ── constants ────────────────────────────────────────────────────────
8
8
  const STATE_DIR = join(homedir(), '.clanker-wallet')
9
9
  const IDENTITY_FILE = join(STATE_DIR, 'identity.json')
10
- const PAIRING_FILE = join(STATE_DIR, 'pairing.json')
10
+ // one pairing per chain family: pairings/<family>.json. 0.4.x wrote pairing.json (evm only); it is still read as the
11
+ // evm pairing and never moved, rewritten or deleted
12
+ const PAIRINGS_DIR = join(STATE_DIR, 'pairings')
13
+ const LEGACY_PAIRING_FILE = join(STATE_DIR, 'pairing.json')
14
+ const FAMILIES = ['evm', 'bitcoin', 'chia']
11
15
 
12
16
  const EXIT = { OK: 0, REJECTED: 1, TIMEOUT: 2, CONNECTION: 3, NOT_PAIRED: 4, BAD_ARGS: 5 }
13
17
  const DEFAULT_TIMEOUT_MS = 3_600_000
14
18
  /** validateTx field -> the tx flag that sets it */
15
19
  const FIELD_FLAGS = { chain_id: '--chain', to: '--to', value: '--value', data: '--data' }
20
+ /** per send network flag (named after the asset it sends): the pairing family it uses and its amount/fee flags */
21
+ const SEND_FAMILIES = {
22
+ btc: { family: 'bitcoin', amountFlag: 'sats', networks: 'mainnet, testnet or signet' },
23
+ xch: { family: 'chia', amountFlag: 'mojos', feeFlag: 'fee-mojos', networks: 'mainnet or testnet11' },
24
+ }
16
25
 
17
26
  // ── helpers ──────────────────────────────────────────────────────────
18
27
  const hex = (bytes) => Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join('')
@@ -50,15 +59,20 @@ function saveIdentity(data) {
50
59
  chmodSync(IDENTITY_FILE, 0o600)
51
60
  }
52
61
 
53
- function loadPairing() {
54
- if (!existsSync(PAIRING_FILE)) return null
55
- try { return JSON.parse(readFileSync(PAIRING_FILE, 'utf8')) }
62
+ function readJson(path) {
63
+ if (!existsSync(path)) return null
64
+ try { return JSON.parse(readFileSync(path, 'utf8')) }
56
65
  catch { return null }
57
66
  }
58
67
 
59
- function savePairing(data) {
68
+ function loadPairing(family) {
69
+ return readJson(join(PAIRINGS_DIR, `${family}.json`)) ?? (family === 'evm' ? readJson(LEGACY_PAIRING_FILE) : null)
70
+ }
71
+
72
+ function savePairing(family, data) {
60
73
  ensureDir()
61
- writeFileSync(PAIRING_FILE, JSON.stringify(data, null, 2) + '\n')
74
+ if (!existsSync(PAIRINGS_DIR)) mkdirSync(PAIRINGS_DIR, { mode: 0o700 })
75
+ writeFileSync(join(PAIRINGS_DIR, `${family}.json`), JSON.stringify(data, null, 2) + '\n')
62
76
  }
63
77
 
64
78
  /** restore or create a ClankerWallet with persisted identity */
@@ -114,11 +128,19 @@ function cmdPair(positional) {
114
128
  die(`pair failed: ${err.message}`, EXIT.BAD_ARGS)
115
129
  }
116
130
 
117
- savePairing({
131
+ // the family names the pairing file, so only the known ones are accepted
132
+ const family = pairingData.chain_family || 'evm'
133
+ if (!FAMILIES.includes(family)) {
134
+ die(`pair failed: unsupported chain_family ${JSON.stringify(family)} (expected ${FAMILIES.join(', ')})`, EXIT.BAD_ARGS)
135
+ }
136
+
137
+ savePairing(family, {
118
138
  pubkey: pairingData.pubkey,
119
139
  relay: pairingData.relay,
120
140
  wallet: pairingData.wallet || null,
121
141
  mode: pairingData.mode || null,
142
+ chain_family: pairingData.chain_family,
143
+ chain_network: pairingData.chain_network,
122
144
  })
123
145
 
124
146
  out({
@@ -126,55 +148,44 @@ function cmdPair(positional) {
126
148
  status: 'paired',
127
149
  wallet: pairingData.wallet || null,
128
150
  relay: pairingData.relay,
151
+ ...(family !== 'evm' && { chain_family: family }),
129
152
  })
130
153
  }
131
154
 
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)
155
+ function parseTimeout(flags) {
142
156
  if (flags.timeout !== undefined && !/^[1-9][0-9]*$/.test(flags.timeout)) {
143
157
  die(`invalid --timeout: must be a positive number of milliseconds, got ${JSON.stringify(flags.timeout)}`, EXIT.BAD_ARGS)
144
158
  }
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
- }
159
+ return flags.timeout ? Number(flags.timeout) : DEFAULT_TIMEOUT_MS
160
+ }
155
161
 
162
+ function contextFrom(flags) {
156
163
  const context = {}
157
164
  if (flags.reason) context.reason = flags.reason
158
165
  if (flags.urgency) context.urgency = flags.urgency
159
166
  if (flags['expected-outcome']) context.expected_outcome = flags['expected-outcome']
167
+ return Object.keys(context).length > 0 ? context : undefined
168
+ }
160
169
 
170
+ /** a ClankerWallet paired from a saved pairing */
171
+ function pairedWallet(pairing) {
161
172
  const wallet = getWallet()
162
- const pairingJson = JSON.stringify({
173
+ wallet.pair(JSON.stringify({
163
174
  version: 1,
164
175
  pubkey: pairing.pubkey,
165
176
  relay: pairing.relay,
166
177
  wallet: pairing.wallet,
167
178
  mode: pairing.mode || undefined,
168
- })
169
- wallet.pair(pairingJson)
179
+ chain_family: pairing.chain_family,
180
+ chain_network: pairing.chain_network,
181
+ }))
182
+ return wallet
183
+ }
170
184
 
185
+ /** wait for the human's answer and exit with its code */
186
+ async function exitWithResult(request) {
171
187
  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
- })
188
+ const txHash = await request
178
189
  out({ tx_hash: txHash, status: 'approved' })
179
190
  process.exit(EXIT.OK)
180
191
  } catch (err) {
@@ -192,15 +203,99 @@ async function cmdTx(flags) {
192
203
  }
193
204
  }
194
205
 
206
+ async function cmdTx(flags) {
207
+ const pairing = loadPairing('evm')
208
+ if (!pairing) die('not paired — run: clanker-wallet pair \'<JSON>\'', EXIT.NOT_PAIRED)
209
+
210
+ const chain = flags.chain
211
+ const to = flags.to
212
+ if (!chain) die('missing --chain', EXIT.BAD_ARGS)
213
+ if (!to) die('missing --to', EXIT.BAD_ARGS)
214
+
215
+ if (!/^[0-9]+$/.test(chain)) die(`invalid --chain: must be a positive integer chain id, got ${JSON.stringify(chain)}`, EXIT.BAD_ARGS)
216
+ const timeout = parseTimeout(flags)
217
+
218
+ // same checks requestTx runs, done here so a bad flag exits 5 instead of surfacing as a connection error
219
+ let request
220
+ try {
221
+ request = validateTx(Number(chain), { to, value: flags.value ?? '0', data: flags.data ?? '0x' })
222
+ } catch (err) {
223
+ if (!(err instanceof InvalidTxError)) throw err
224
+ die(`invalid ${FIELD_FLAGS[err.field]}: ${err.reason}`, EXIT.BAD_ARGS)
225
+ }
226
+
227
+ const wallet = pairedWallet(pairing)
228
+ await exitWithResult(wallet.requestTx({
229
+ chain_id: request.chain_id,
230
+ transaction: request.transaction,
231
+ context: contextFrom(flags),
232
+ timeout_ms: timeout,
233
+ }))
234
+ }
235
+
236
+ async function cmdSend(flags) {
237
+ const picked = Object.keys(SEND_FAMILIES).filter((flag) => flags[flag] !== undefined)
238
+ if (picked.length !== 1) die('invalid --btc/--xch: give exactly one of --btc <network> or --xch <network>', EXIT.BAD_ARGS)
239
+ const networkFlag = picked[0]
240
+ const { family, amountFlag, feeFlag, networks } = SEND_FAMILIES[networkFlag]
241
+ const network = flags[networkFlag]
242
+ if (network === true) die(`invalid --${networkFlag}: give the network, ${networks}`, EXIT.BAD_ARGS)
243
+
244
+ // another family's amount/fee flag is a mistake, not something to ignore
245
+ for (const flag of ['sats', 'mojos', 'fee-mojos']) {
246
+ if (flag !== amountFlag && flag !== feeFlag && flags[flag] !== undefined) {
247
+ die(`invalid --${flag}: not a --${networkFlag} flag (${family} amounts are --${amountFlag}${feeFlag ? `, fee --${feeFlag}` : ', and the wallet sets the fee'})`, EXIT.BAD_ARGS)
248
+ }
249
+ }
250
+ for (const flag of ['to', amountFlag, feeFlag]) {
251
+ if (flag && flags[flag] === true) die(`invalid --${flag}: missing value`, EXIT.BAD_ARGS)
252
+ }
253
+
254
+ const pairing = loadPairing(family)
255
+ 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)
256
+
257
+ if (!flags.to) die('missing --to', EXIT.BAD_ARGS)
258
+ if (flags[amountFlag] === undefined) die(`missing --${amountFlag}`, EXIT.BAD_ARGS)
259
+ const timeout = parseTimeout(flags)
260
+
261
+ const transfer = { asset: networkFlag, to: flags.to, amount: flags[amountFlag] }
262
+ if (feeFlag && flags[feeFlag] !== undefined) transfer.fee = flags[feeFlag]
263
+ const flagFor = { chain_family: networkFlag, chain_network: networkFlag, asset: networkFlag, to: 'to', amount: amountFlag, fee: feeFlag }
264
+ const badArg = (err) => die(`invalid --${flagFor[err.field]}: ${err.reason}`, EXIT.BAD_ARGS)
265
+
266
+ // same checks requestTransfer runs, done here so a bad flag exits 5 instead of surfacing as a connection error
267
+ try {
268
+ validateTransfer(family, network, transfer)
269
+ } catch (err) {
270
+ if (!(err instanceof InvalidTxError)) throw err
271
+ badArg(err)
272
+ }
273
+
274
+ const wallet = pairedWallet(pairing)
275
+ // requestTransfer also refuses a network the pairing isn't for; that is a bad flag too
276
+ const request = wallet.requestTransfer({ chain_network: network, transfer, context: contextFrom(flags), timeout_ms: timeout })
277
+ await request.catch((err) => { if (err instanceof InvalidTxError) badArg(err) })
278
+ await exitWithResult(request)
279
+ }
280
+
195
281
  function cmdStatus() {
196
282
  const identity = loadIdentity()
197
- const pairing = loadPairing()
283
+ const pairings = {}
284
+ for (const family of FAMILIES) {
285
+ const pairing = loadPairing(family)
286
+ if (pairing) {
287
+ pairings[family] = { wallet: pairing.wallet || null, relay: pairing.relay || null, mode: pairing.mode || null, chain_network: pairing.chain_network || null }
288
+ }
289
+ }
290
+ const evm = pairings.evm
198
291
 
292
+ // wallet/relay stay the evm pairing's, as in 0.4.x
199
293
  out({
200
294
  agent_id: identity?.agent_id || null,
201
- paired: !!pairing,
202
- wallet: pairing?.wallet || null,
203
- relay: pairing?.relay || null,
295
+ paired: Object.keys(pairings).length > 0,
296
+ wallet: evm?.wallet || null,
297
+ relay: evm?.relay || null,
298
+ pairings,
204
299
  })
205
300
  }
206
301
 
@@ -223,8 +318,9 @@ function showHelp() {
223
318
 
224
319
  commands:
225
320
  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
321
+ tx send an evm transaction (blocks until approved/rejected)
322
+ send send btc or xch; the human's wallet builds it (blocks until approved/rejected)
323
+ status check pairing status (one pairing per chain family: evm, bitcoin, chia)
228
324
  whoami show agent identity
229
325
 
230
326
  tx flags:
@@ -237,18 +333,29 @@ tx flags:
237
333
  --expected-outcome <text> what will happen
238
334
  --timeout <ms> how long tx blocks waiting for the human (default: 3600000 = 1 h)
239
335
 
336
+ send flags (pair the web app's bitcoin or chia tab first):
337
+ send --btc <mainnet|testnet|signet> --to <address> --sats <n>
338
+ send --xch <mainnet|testnet11> --to <address> --mojos <n> [--fee-mojos <n>]
339
+ --to <address> recipient: segwit bc1/tb1 for bitcoin, xch1/txch1 for chia (required)
340
+ --sats <n> bitcoin amount in sats (1 BTC = 100000000); the wallet sets the fee
341
+ --mojos <n> chia amount in mojos (1 XCH = 1000000000000)
342
+ --fee-mojos <n> chia fee in mojos (default: 0), shown to the human
343
+ --reason, --urgency, --expected-outcome, --timeout as for tx
344
+
240
345
  exit codes:
241
346
  0 success
242
347
  1 rejected by human
243
348
  2 timeout
244
349
  3 connection error (in a sandbox or behind a firewall, allow the relay host, wss://relay.clanker-wallet.xyz/ws by default;
245
350
  behind HTTPS_PROXY run with NODE_USE_ENV_PROXY=1)
246
- 4 not paired
351
+ 4 not paired (send: not paired for that chain family)
247
352
  5 invalid arguments
248
353
 
249
354
  examples:
250
355
  clanker-wallet pair '{"version":1,"pubkey":"ab12...","relay":"wss://...","wallet":"0x..."}'
251
356
  clanker-wallet tx --chain 84532 --to 0x000000000000000000000000000000000000dEaD --value 1000000000000000 --reason "send 0.001 ETH on base sepolia"
357
+ clanker-wallet send --btc testnet --to tb1qw508d6qejxtdg4y5r3zarvary0c5xw7kxpjzsx --sats 1000 --reason "test payment"
358
+ clanker-wallet send --xch testnet11 --to txch1qqqsyqcyq5rqwzqfpg9scrgwpugpzysnzs23v9ccrydpk8qarc0sw0amhg --mojos 1000 --fee-mojos 0
252
359
  clanker-wallet status
253
360
  clanker-wallet whoami
254
361
 
@@ -267,6 +374,9 @@ switch (cmd) {
267
374
  case 'tx':
268
375
  await cmdTx(flags)
269
376
  break
377
+ case 'send':
378
+ await cmdSend(flags)
379
+ break
270
380
  case 'status':
271
381
  cmdStatus()
272
382
  break
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Bech32 / Bech32m decoding — a port of the BIP-173 / BIP-350 reference
3
+ * implementation (github.com/sipa/bech32 ref/python/segwit_addr.py).
4
+ * Used to check transfer recipients: bitcoin segwit addresses and chia
5
+ * puzzle-hash addresses (bech32m, hrp xch / txch).
6
+ */
7
+ export type Bech32Encoding = 'bech32' | 'bech32m';
8
+ /** validate a bech32/bech32m string; returns its hrp, data (checksum stripped) and encoding */
9
+ export declare function bech32Decode(bech: string): {
10
+ hrp: string;
11
+ data: number[];
12
+ encoding: Bech32Encoding;
13
+ } | null;
14
+ /** general power-of-2 base conversion */
15
+ export declare function convertBits(data: number[], fromBits: number, toBits: number, pad: boolean): number[] | null;
16
+ /** decode a segwit address for the given hrp ('bc' mainnet, 'tb' testnet/signet) */
17
+ export declare function decodeSegwitAddress(hrp: string, addr: string): {
18
+ version: number;
19
+ program: number[];
20
+ } | null;
21
+ /**
22
+ * a segwit address a wallet can pay today: v0 p2wpkh/p2wsh (20/32 bytes) or v1 p2tr (32 bytes).
23
+ * other versions and v1 lengths decode but are unspendable or anyone-can-spend, so transfers refuse them
24
+ */
25
+ export declare function isStandardSegwitAddress(hrp: string, addr: string): boolean;
26
+ /** decode a chia address (bech32m of a 32-byte puzzle hash) for the given hrp ('xch' / 'txch') */
27
+ export declare function decodeChiaAddress(hrp: string, addr: string): number[] | null;
package/dist/bech32.js ADDED
@@ -0,0 +1,111 @@
1
+ /**
2
+ * Bech32 / Bech32m decoding — a port of the BIP-173 / BIP-350 reference
3
+ * implementation (github.com/sipa/bech32 ref/python/segwit_addr.py).
4
+ * Used to check transfer recipients: bitcoin segwit addresses and chia
5
+ * puzzle-hash addresses (bech32m, hrp xch / txch).
6
+ */
7
+ const CHARSET = 'qpzry9x8gf2tvdw0s3jn54khce6mua7l';
8
+ const BECH32M_CONST = 0x2bc830a3;
9
+ const GENERATOR = [0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3];
10
+ function polymod(values) {
11
+ let chk = 1;
12
+ for (const value of values) {
13
+ const top = chk >>> 25;
14
+ chk = (((chk & 0x1ffffff) << 5) ^ value) >>> 0;
15
+ for (let i = 0; i < 5; i++) {
16
+ if ((top >>> i) & 1)
17
+ chk = (chk ^ GENERATOR[i]) >>> 0;
18
+ }
19
+ }
20
+ return chk;
21
+ }
22
+ function hrpExpand(hrp) {
23
+ const codes = Array.from(hrp, (c) => c.charCodeAt(0));
24
+ return [...codes.map((c) => c >> 5), 0, ...codes.map((c) => c & 31)];
25
+ }
26
+ /** validate a bech32/bech32m string; returns its hrp, data (checksum stripped) and encoding */
27
+ export function bech32Decode(bech) {
28
+ for (const c of bech) {
29
+ const code = c.charCodeAt(0);
30
+ if (code < 33 || code > 126)
31
+ return null;
32
+ }
33
+ if (bech.toLowerCase() !== bech && bech.toUpperCase() !== bech)
34
+ return null;
35
+ const lower = bech.toLowerCase();
36
+ const pos = lower.lastIndexOf('1');
37
+ if (pos < 1 || pos + 7 > lower.length || lower.length > 90)
38
+ return null;
39
+ const data = [];
40
+ for (const c of lower.slice(pos + 1)) {
41
+ const d = CHARSET.indexOf(c);
42
+ if (d === -1)
43
+ return null;
44
+ data.push(d);
45
+ }
46
+ const hrp = lower.slice(0, pos);
47
+ const check = polymod([...hrpExpand(hrp), ...data]);
48
+ const encoding = check === 1 ? 'bech32' : check === BECH32M_CONST ? 'bech32m' : null;
49
+ if (!encoding)
50
+ return null;
51
+ return { hrp, data: data.slice(0, -6), encoding };
52
+ }
53
+ /** general power-of-2 base conversion */
54
+ export function convertBits(data, fromBits, toBits, pad) {
55
+ let acc = 0;
56
+ let bits = 0;
57
+ const ret = [];
58
+ const maxv = (1 << toBits) - 1;
59
+ const maxAcc = (1 << (fromBits + toBits - 1)) - 1;
60
+ for (const value of data) {
61
+ if (value < 0 || value >> fromBits)
62
+ return null;
63
+ acc = ((acc << fromBits) | value) & maxAcc;
64
+ bits += fromBits;
65
+ while (bits >= toBits) {
66
+ bits -= toBits;
67
+ ret.push((acc >> bits) & maxv);
68
+ }
69
+ }
70
+ if (pad) {
71
+ if (bits)
72
+ ret.push((acc << (toBits - bits)) & maxv);
73
+ }
74
+ else if (bits >= fromBits || ((acc << (toBits - bits)) & maxv)) {
75
+ return null;
76
+ }
77
+ return ret;
78
+ }
79
+ /** decode a segwit address for the given hrp ('bc' mainnet, 'tb' testnet/signet) */
80
+ export function decodeSegwitAddress(hrp, addr) {
81
+ const decoded = bech32Decode(addr);
82
+ if (!decoded || decoded.hrp !== hrp || decoded.data.length === 0)
83
+ return null;
84
+ const [version, ...rest] = decoded.data;
85
+ const program = convertBits(rest, 5, 8, false);
86
+ if (!program || program.length < 2 || program.length > 40)
87
+ return null;
88
+ if (version > 16)
89
+ return null;
90
+ if (version === 0 && program.length !== 20 && program.length !== 32)
91
+ return null;
92
+ if ((version === 0) !== (decoded.encoding === 'bech32'))
93
+ return null;
94
+ return { version, program };
95
+ }
96
+ /**
97
+ * a segwit address a wallet can pay today: v0 p2wpkh/p2wsh (20/32 bytes) or v1 p2tr (32 bytes).
98
+ * other versions and v1 lengths decode but are unspendable or anyone-can-spend, so transfers refuse them
99
+ */
100
+ export function isStandardSegwitAddress(hrp, addr) {
101
+ const decoded = decodeSegwitAddress(hrp, addr);
102
+ return !!decoded && (decoded.version === 0 || (decoded.version === 1 && decoded.program.length === 32));
103
+ }
104
+ /** decode a chia address (bech32m of a 32-byte puzzle hash) for the given hrp ('xch' / 'txch') */
105
+ export function decodeChiaAddress(hrp, addr) {
106
+ const decoded = bech32Decode(addr);
107
+ if (!decoded || decoded.hrp !== hrp || decoded.encoding !== 'bech32m')
108
+ return null;
109
+ const puzzleHash = convertBits(decoded.data, 5, 8, false);
110
+ return puzzleHash && puzzleHash.length === 32 ? puzzleHash : null;
111
+ }
package/dist/client.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { TypedEmitter } from './events.js';
2
- import type { PairingData, RequestTxOptions } from './types.js';
2
+ import type { PairingData, RequestTransferOptions, RequestTxOptions } from './types.js';
3
3
  export interface ClankerWalletOptions {
4
4
  /** agent display name (informational) */
5
5
  agentName?: string;
@@ -27,4 +27,13 @@ export declare class ClankerWallet extends TypedEmitter {
27
27
  * throws on rejection, timeout, or connection error.
28
28
  */
29
29
  requestTx(options: RequestTxOptions): Promise<string>;
30
+ /**
31
+ * send a bitcoin / chia transfer to the paired human and wait for their response.
32
+ * the family comes from the pairing; the human's wallet builds, signs and broadcasts
33
+ * the send. resolves with the txid on approval.
34
+ * throws InvalidTxError (before anything is sent), or on rejection, timeout, or connection error.
35
+ */
36
+ requestTransfer(options: RequestTransferOptions): Promise<string>;
37
+ /** encrypt a signed request to the paired human, send it through the relay, and wait for the answer */
38
+ private send;
30
39
  }
package/dist/client.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { decryptMessage, encryptMessage, fromHex, generateKeypair, keypairFromSecret, signMessage, signingKeypairFromSeed, toHex } from './crypto.js';
2
2
  import { TypedEmitter } from './events.js';
3
- import { validateTx } from './validate.js';
3
+ import { InvalidTxError, validateTransfer, validateTx } from './validate.js';
4
4
  const DEFAULT_TIMEOUT_MS = 3_600_000; // 1 hour
5
5
  const UNREACHABLE_HINT = 'if you run in a sandbox or behind a firewall, allow this host; otherwise the relay may be down or rate limiting, retry in a few seconds';
6
6
  /** node's WebSocket ignores HTTPS_PROXY unless NODE_USE_ENV_PROXY is set at startup, and sandboxes (claude code) only let traffic out through that proxy */
@@ -55,6 +55,8 @@ export class ClankerWallet extends TypedEmitter {
55
55
  relayUrl: data.relay,
56
56
  humanWallet: data.wallet || null,
57
57
  mode: data.mode || 'encrypted',
58
+ chainFamily: data.chain_family || null,
59
+ chainNetwork: data.chain_network || null,
58
60
  };
59
61
  this.emit('paired', { relay: data.relay, wallet: data.wallet || '' });
60
62
  return data;
@@ -68,13 +70,16 @@ export class ClankerWallet extends TypedEmitter {
68
70
  if (!this.pairing) {
69
71
  throw new Error('not paired — call pair() first');
70
72
  }
73
+ // a bitcoin or chia pairing takes transfers only: its wallet can't sign an evm tx
74
+ const { chainFamily } = this.pairing;
75
+ if (chainFamily && chainFamily !== 'evm') {
76
+ throw new InvalidTxError('chain_family', `this pairing is for ${chainFamily}: use requestTransfer for bitcoin or chia`);
77
+ }
71
78
  // reject malformed requests before anything reaches the relay
72
79
  const { chain_id, transaction } = validateTx(options.chain_id, options.transaction);
73
- const { humanPubkey, humanPubkeyHex, relayUrl, humanWallet, mode } = this.pairing;
74
- const timeoutMs = options.timeout_ms ?? DEFAULT_TIMEOUT_MS;
75
- const requestId = `req_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
80
+ const { humanWallet, mode } = this.pairing;
76
81
  const txRequest = {
77
- request_id: requestId,
82
+ request_id: newRequestId(),
78
83
  agent_id: this.agentId,
79
84
  timestamp: new Date().toISOString(),
80
85
  chain_id,
@@ -83,6 +88,46 @@ export class ClankerWallet extends TypedEmitter {
83
88
  signature: '', // set below after signing
84
89
  };
85
90
  txRequest.signature = signMessage(JSON.stringify(txRequest), this.signingSecretKey);
91
+ // managed mode: relay gets plaintext tx params so it can simulate
92
+ const simulate = mode === 'managed'
93
+ ? { from: humanWallet || '', to: transaction.to, value: transaction.value, data: transaction.data, chainId: chain_id }
94
+ : undefined;
95
+ return this.send(txRequest, options.timeout_ms ?? DEFAULT_TIMEOUT_MS, simulate);
96
+ }
97
+ /**
98
+ * send a bitcoin / chia transfer to the paired human and wait for their response.
99
+ * the family comes from the pairing; the human's wallet builds, signs and broadcasts
100
+ * the send. resolves with the txid on approval.
101
+ * throws InvalidTxError (before anything is sent), or on rejection, timeout, or connection error.
102
+ */
103
+ async requestTransfer(options) {
104
+ if (!this.pairing) {
105
+ throw new Error('not paired — call pair() first');
106
+ }
107
+ const { chainFamily, chainNetwork } = this.pairing;
108
+ // reject malformed requests before anything reaches the relay
109
+ const { chain_family, chain_network, transfer } = validateTransfer(chainFamily, options.chain_network, options.transfer);
110
+ if (chainNetwork && chainNetwork !== chain_network) {
111
+ throw new InvalidTxError('chain_network', `this pairing is for ${chain_family} ${chainNetwork}, got "${chain_network}"`);
112
+ }
113
+ const request = {
114
+ request_id: newRequestId(),
115
+ agent_id: this.agentId,
116
+ timestamp: new Date().toISOString(),
117
+ chain_family,
118
+ chain_network,
119
+ transfer,
120
+ context: options.context,
121
+ signature: '', // set below after signing
122
+ };
123
+ request.signature = signMessage(JSON.stringify(request), this.signingSecretKey);
124
+ // the relay simulator is evm-only: no simulate object for transfers, even on a managed pairing
125
+ return this.send(request, options.timeout_ms ?? DEFAULT_TIMEOUT_MS);
126
+ }
127
+ /** encrypt a signed request to the paired human, send it through the relay, and wait for the answer */
128
+ send(request, timeoutMs, simulate) {
129
+ const { humanPubkey, humanPubkeyHex, relayUrl } = this.pairing;
130
+ const requestId = request.request_id;
86
131
  return new Promise((resolve, reject) => {
87
132
  let ws;
88
133
  let timer;
@@ -139,7 +184,7 @@ export class ClankerWallet extends TypedEmitter {
139
184
  }
140
185
  if (msg.type === 'joined') {
141
186
  this.emit('connected', { room: humanPubkeyHex });
142
- const ciphertext = encryptMessage(JSON.stringify(txRequest), humanPubkey, this.secretKey);
187
+ const ciphertext = encryptMessage(JSON.stringify(request), humanPubkey, this.secretKey);
143
188
  const envelope = {
144
189
  sender_pubkey: this.agentPubkeyHex,
145
190
  ciphertext,
@@ -150,16 +195,8 @@ export class ClankerWallet extends TypedEmitter {
150
195
  room: humanPubkeyHex,
151
196
  payload: JSON.stringify(envelope),
152
197
  };
153
- // managed mode: relay gets plaintext tx params so it can simulate
154
- if (mode === 'managed') {
155
- wsMsg.simulate = {
156
- from: humanWallet || '',
157
- to: transaction.to,
158
- value: transaction.value,
159
- data: transaction.data,
160
- chainId: chain_id,
161
- };
162
- }
198
+ if (simulate)
199
+ wsMsg.simulate = simulate;
163
200
  ws.send(JSON.stringify(wsMsg));
164
201
  this.emit('request_sent', { request_id: requestId });
165
202
  return;
@@ -207,3 +244,6 @@ export class ClankerWallet extends TypedEmitter {
207
244
  });
208
245
  }
209
246
  }
247
+ function newRequestId() {
248
+ return `req_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
249
+ }
package/dist/index.d.ts CHANGED
@@ -3,6 +3,6 @@ export type { ClankerWalletOptions } from './client.js';
3
3
  export { base64ToBytes, bytesToBase64, decryptMessage, encryptMessage, fromHex, generateKeypair, keypairFromSecret, signMessage, signingKeypairFromSeed, toHex, verifySignature } from './crypto.js';
4
4
  export { TypedEmitter } from './events.js';
5
5
  export type { EventMap } from './events.js';
6
- export { InvalidTxError, isChecksumAddress, validateTx } from './validate.js';
6
+ export { InvalidTxError, isChecksumAddress, validateTransfer, validateTx } from './validate.js';
7
7
  export type { TxField } from './validate.js';
8
- export type { AgentEnvelope, PairingData, RequestTxOptions, Transaction, TxContext, TxInput, TxRequest, TxResponse, } from './types.js';
8
+ export type { AgentEnvelope, PairingData, RequestTransferOptions, RequestTxOptions, Transaction, Transfer, TransferFamily, TransferInput, TransferRequest, TxContext, TxInput, TxRequest, TxResponse, } from './types.js';
package/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
1
  export { ClankerWallet } from './client.js';
2
2
  export { base64ToBytes, bytesToBase64, decryptMessage, encryptMessage, fromHex, generateKeypair, keypairFromSecret, signMessage, signingKeypairFromSeed, toHex, verifySignature } from './crypto.js';
3
3
  export { TypedEmitter } from './events.js';
4
- export { InvalidTxError, isChecksumAddress, validateTx } from './validate.js';
4
+ export { InvalidTxError, isChecksumAddress, validateTransfer, validateTx } from './validate.js';
package/dist/types.d.ts CHANGED
@@ -48,6 +48,10 @@ export interface PairingData {
48
48
  wallet: string;
49
49
  /** "encrypted" (default): relay sees only ciphertext. "managed": relay also receives tx params for simulation. */
50
50
  mode?: 'encrypted' | 'managed';
51
+ /** "bitcoin" or "chia" for a non-evm wallet; absent for evm */
52
+ chain_family?: string;
53
+ /** the wallet's network, when the pairing names one */
54
+ chain_network?: string;
51
55
  }
52
56
  /** options for requestTx */
53
57
  export interface RequestTxOptions {
@@ -58,3 +62,43 @@ export interface RequestTxOptions {
58
62
  /** timeout in ms (default: 3_600_000 = 1hr) */
59
63
  timeout_ms?: number;
60
64
  }
65
+ /** wallet families a transfer can go to */
66
+ export type TransferFamily = 'bitcoin' | 'chia';
67
+ /** an intent-level send: the human's wallet builds and signs it */
68
+ export interface Transfer {
69
+ asset: 'btc' | 'xch';
70
+ /** bech32 / bech32m address on the request's network */
71
+ to: string;
72
+ /** integer string: sats for btc, mojos for xch */
73
+ amount: string;
74
+ /** chia only, in mojos (defaults to "0"); bitcoin wallets set their own fee */
75
+ fee?: string;
76
+ }
77
+ /** transfer as passed to requestTransfer: amounts as decimal strings or bigints */
78
+ export interface TransferInput {
79
+ asset: 'btc' | 'xch';
80
+ to: string;
81
+ amount: string | bigint;
82
+ fee?: string | bigint;
83
+ }
84
+ /** bitcoin / chia request sent to the human: no chain_id, no transaction */
85
+ export interface TransferRequest {
86
+ request_id: string;
87
+ agent_id: string;
88
+ timestamp: string;
89
+ chain_family: TransferFamily;
90
+ chain_network: string;
91
+ transfer: Transfer;
92
+ context?: TxContext;
93
+ signature: string;
94
+ }
95
+ /** options for requestTransfer; the family comes from the pairing */
96
+ export interface RequestTransferOptions {
97
+ /** bitcoin: mainnet | testnet | signet; chia: mainnet | testnet11 */
98
+ chain_network: string;
99
+ /** checked before sending; an invalid field throws InvalidTxError */
100
+ transfer: TransferInput;
101
+ context?: TxContext;
102
+ /** timeout in ms (default: 3_600_000 = 1hr) */
103
+ timeout_ms?: number;
104
+ }
@@ -1,5 +1,5 @@
1
- import type { Transaction, TxInput } from './types.js';
2
- export type TxField = 'chain_id' | 'to' | 'value' | 'data';
1
+ import type { Transaction, Transfer, TransferFamily, TransferInput, TxInput } from './types.js';
2
+ export type TxField = 'chain_id' | 'to' | 'value' | 'data' | 'chain_family' | 'chain_network' | 'asset' | 'amount' | 'fee';
3
3
  /** a tx request field failed validation; nothing was sent */
4
4
  export declare class InvalidTxError extends Error {
5
5
  readonly field: TxField;
@@ -17,3 +17,14 @@ export declare function validateTx(chainId: unknown, tx: TxInput | Record<string
17
17
  chain_id: number;
18
18
  transaction: Transaction;
19
19
  };
20
+ /**
21
+ * check a bitcoin / chia transfer before anything is signed or sent, and normalize it
22
+ * to the wire format (amounts as decimal strings, chia fee defaulting to "0").
23
+ * the recipient must decode as an address on `chainNetwork`. as strict as the web app,
24
+ * so a request it would refuse never leaves the agent. throws InvalidTxError.
25
+ */
26
+ export declare function validateTransfer(chainFamily: unknown, chainNetwork: unknown, transfer: TransferInput | Record<string, unknown>): {
27
+ chain_family: TransferFamily;
28
+ chain_network: string;
29
+ transfer: Transfer;
30
+ };
package/dist/validate.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { decodeChiaAddress, isStandardSegwitAddress } from './bech32.js';
1
2
  import { keccak256 } from './keccak.js';
2
3
  /** a tx request field failed validation; nothing was sent */
3
4
  export class InvalidTxError extends Error {
@@ -63,3 +64,65 @@ export function validateTx(chainId, tx) {
63
64
  }
64
65
  return { chain_id: chainId, transaction: { to, value: wei, data } };
65
66
  }
67
+ const TRANSFER_ASSET = { bitcoin: 'btc', chia: 'xch' };
68
+ /** address hrp per network; the web app checks the same table */
69
+ const ADDRESS_HRP = {
70
+ bitcoin: { mainnet: 'bc', testnet: 'tb', signet: 'tb' },
71
+ chia: { mainnet: 'xch', testnet11: 'txch' },
72
+ };
73
+ const NETWORK_NAMES = {
74
+ bitcoin: 'mainnet, testnet or signet',
75
+ chia: 'mainnet or testnet11',
76
+ };
77
+ const POSITIVE_INT_RE = /^[1-9][0-9]*$/;
78
+ /** a decimal string or bigint amount as a decimal string, or null */
79
+ function units(v, allowZero) {
80
+ if (typeof v === 'bigint')
81
+ return v > 0n || (allowZero && v === 0n) ? v.toString() : null;
82
+ if (typeof v !== 'string')
83
+ return null;
84
+ return POSITIVE_INT_RE.test(v) || (allowZero && v === '0') ? v : null;
85
+ }
86
+ /**
87
+ * check a bitcoin / chia transfer before anything is signed or sent, and normalize it
88
+ * to the wire format (amounts as decimal strings, chia fee defaulting to "0").
89
+ * the recipient must decode as an address on `chainNetwork`. as strict as the web app,
90
+ * so a request it would refuse never leaves the agent. throws InvalidTxError.
91
+ */
92
+ export function validateTransfer(chainFamily, chainNetwork, transfer) {
93
+ if (chainFamily !== 'bitcoin' && chainFamily !== 'chia') {
94
+ throw new InvalidTxError('chain_family', `transfers need a bitcoin or chia pairing, got ${shown(chainFamily ?? 'evm')}`);
95
+ }
96
+ const family = chainFamily;
97
+ const hrp = typeof chainNetwork === 'string' ? ADDRESS_HRP[family][chainNetwork] : undefined;
98
+ if (!hrp)
99
+ throw new InvalidTxError('chain_network', `must be ${NETWORK_NAMES[family]}, got ${shown(chainNetwork)}`);
100
+ const network = chainNetwork;
101
+ const asset = TRANSFER_ASSET[family];
102
+ if (transfer.asset !== asset)
103
+ throw new InvalidTxError('asset', `${family} transfers must be ${shown(asset)}, got ${shown(transfer.asset)}`);
104
+ const unit = family === 'bitcoin' ? 'sats' : 'mojos';
105
+ const amount = units(transfer.amount, false);
106
+ if (!amount)
107
+ throw new InvalidTxError('amount', `must be a positive integer amount of ${unit} (decimal string or bigint), got ${shown(transfer.amount)}`);
108
+ const { to } = transfer;
109
+ if (family === 'bitcoin') {
110
+ if (transfer.fee !== undefined)
111
+ throw new InvalidTxError('fee', 'bitcoin transfers take no fee; the wallet sets it');
112
+ // the wallet api takes sats as a js number
113
+ if (BigInt(amount) > BigInt(Number.MAX_SAFE_INTEGER)) {
114
+ throw new InvalidTxError('amount', `must be at most ${Number.MAX_SAFE_INTEGER} sats, got ${shown(amount)}`);
115
+ }
116
+ if (typeof to !== 'string' || !isStandardSegwitAddress(hrp, to)) {
117
+ throw new InvalidTxError('to', `not a bitcoin ${network} address (segwit, ${hrp}1...), got ${shown(to)}`);
118
+ }
119
+ return { chain_family: family, chain_network: network, transfer: { asset, to, amount } };
120
+ }
121
+ const fee = transfer.fee === undefined ? '0' : units(transfer.fee, true);
122
+ if (!fee)
123
+ throw new InvalidTxError('fee', `must be a non-negative integer amount of mojos (decimal string or bigint), got ${shown(transfer.fee)}`);
124
+ if (typeof to !== 'string' || !decodeChiaAddress(hrp, to)) {
125
+ throw new InvalidTxError('to', `not a chia ${network} address (${hrp}1...), got ${shown(to)}`);
126
+ }
127
+ return { chain_family: family, chain_network: network, transfer: { asset, to, amount, fee } };
128
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clanker-wallet",
3
- "version": "0.4.2",
3
+ "version": "0.5.0",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",