clanker-wallet 0.4.0 → 0.4.2

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
@@ -14,6 +14,8 @@ npx clanker-wallet
14
14
  bunx clanker-wallet
15
15
  ```
16
16
 
17
+ Running in a sandbox (e.g. Claude Code)? Allow a WebSocket to the relay, `wss://relay.clanker-wallet.xyz/ws` (the `relay` URL in the pairing JSON), or `tx` fails with exit code 3. If the sandbox only lets traffic out through `HTTPS_PROXY`, run the CLI as `NODE_USE_ENV_PROXY=1 clanker-wallet ...` (Node 24+); Node ignores the proxy otherwise. `pair` writes `~/.clanker-wallet/` and the other commands write there too, so allow writes (see [State](#state)).
18
+
17
19
  ## Quick Start
18
20
 
19
21
  ```bash
@@ -22,8 +24,9 @@ bunx clanker-wallet
22
24
  # 2. Pair (one-time) — extract the JSON from the prompt
23
25
  clanker-wallet pair '{"version":1,"pubkey":"ab12...","relay":"wss://...","wallet":"0x..."}'
24
26
 
25
- # 3. Send a transaction (blocks until human approves or rejects)
26
- clanker-wallet tx --chain 1 --to 0xRecipient --value 1000000000000000000 --reason "swap ETH for USDC"
27
+ # 3. Send a transaction (blocks until human approves or rejects; 1 h by default)
28
+ # 0.001 ETH on Base Sepolia (84532) to the burn address. --value is in wei.
29
+ clanker-wallet tx --chain 84532 --to 0x000000000000000000000000000000000000dEaD --value 1000000000000000 --reason "send 0.001 ETH on base sepolia"
27
30
 
28
31
  # 4. Check status anytime
29
32
  clanker-wallet status
@@ -33,7 +36,9 @@ clanker-wallet status
33
36
 
34
37
  ### `pair`
35
38
 
36
- Pair with a human's web app. Run once — identity and pairing are saved to `~/.clanker-wallet/`.
39
+ Pair with a human's web app. Run once — `pair` writes identity and pairing to `~/.clanker-wallet/`.
40
+
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.
37
42
 
38
43
  ```bash
39
44
  clanker-wallet pair '<pairing JSON from web app>'
@@ -42,33 +47,36 @@ clanker-wallet pair '<pairing JSON from web app>'
42
47
 
43
48
  ### `tx`
44
49
 
45
- Send a transaction request. Blocks until the human approves or rejects.
50
+ Send a transaction request. Blocks until the human approves or rejects, for up to 1 hour by default; `--timeout <ms>` changes that. If your shell tool kills long commands, run `tx` in the background and read its output when it exits.
46
51
 
47
52
  ```bash
48
53
  clanker-wallet tx \
49
- --chain 1 \
50
- --to 0xRecipient \
51
- --value 1000000000000000000 \
54
+ --chain 84532 \
55
+ --to 0x000000000000000000000000000000000000dEaD \
56
+ --value 1000000000000000 \
52
57
  --data 0x \
53
- --reason "swap ETH for USDC" \
58
+ --reason "send 0.001 ETH on base sepolia" \
54
59
  --urgency medium \
55
- --timeout 120000
60
+ --timeout 600000
56
61
  # approved → {"tx_hash":"0x...","status":"approved"}
57
62
  # rejected → {"status":"rejected","error":"..."} (exit code 1)
58
63
  # timeout → {"status":"timeout","error":"..."} (exit code 2)
64
+ # bad flag → "invalid --to: ..." on stderr, nothing sent (exit code 5)
59
65
  ```
60
66
 
67
+ `--to` must be `0x` + 40 hex characters (mixed case must pass the EIP-55 checksum), `--value` a whole number of wei, `--data` even-length hex. See [SKILL.md](./SKILL.md) for ERC-20 examples.
68
+
61
69
  **Flags:**
62
70
  | Flag | Required | Default | Description |
63
71
  |------|----------|---------|-------------|
64
- | `--chain <id>` | yes | — | Chain ID (1=Ethereum, 137=Polygon, 8453=Base) |
72
+ | `--chain <id>` | yes | — | Chain ID (1=Ethereum, 8453=Base, 84532=Base Sepolia) |
65
73
  | `--to <address>` | yes | — | Recipient address |
66
74
  | `--value <wei>` | no | `0` | Value in wei (1 ETH = `1000000000000000000`) |
67
75
  | `--data <hex>` | no | `0x` | Calldata hex |
68
76
  | `--reason <text>` | no | — | Shown to human |
69
77
  | `--urgency <level>` | no | — | `low`, `medium`, or `high` |
70
78
  | `--expected-outcome <text>` | no | — | What will happen |
71
- | `--timeout <ms>` | no | `3600000` | How long to wait |
79
+ | `--timeout <ms>` | no | `3600000` (1 h) | How long `tx` blocks waiting for the human |
72
80
 
73
81
  ### `status`
74
82
 
@@ -97,7 +105,7 @@ clanker-wallet whoami
97
105
  | 2 | Timeout |
98
106
  | 3 | Connection/relay error |
99
107
  | 4 | Not paired |
100
- | 5 | Invalid arguments |
108
+ | 5 | Invalid arguments (bad or missing flag; nothing sent) |
101
109
 
102
110
  ## State
103
111
 
@@ -119,6 +127,7 @@ Identity and pairing are persisted in `~/.clanker-wallet/`:
119
127
  | Base | 8453 |
120
128
  | Optimism | 10 |
121
129
  | Sepolia (testnet) | 11155111 |
130
+ | Base Sepolia (testnet) | 84532 |
122
131
 
123
132
  ---
124
133
 
@@ -136,17 +145,22 @@ import { ClankerWallet } from 'clanker-wallet'
136
145
  const wallet = new ClankerWallet({ agentName: 'my-bot' })
137
146
  wallet.pair(pairingJson)
138
147
 
148
+ // 0.001 ETH on Base Sepolia; value is wei (string or bigint)
139
149
  const txHash = await wallet.requestTx({
140
- chain_id: 1,
141
- transaction: { to: '0x...', value: '1000000000000000000', data: '0x' },
142
- context: { reason: 'Buy tokens', urgency: 'medium' },
150
+ chain_id: 84532,
151
+ transaction: { to: '0x000000000000000000000000000000000000dEaD', value: '1000000000000000', data: '0x' },
152
+ context: { reason: 'send 0.001 ETH on base sepolia', urgency: 'medium' },
143
153
  })
144
154
  ```
145
155
 
156
+ `requestTx` throws `InvalidTxError` (with `field` and `reason`) before anything is sent if `chain_id`, `to`, `value` or `data` is malformed.
157
+
146
158
  See full API: [`src/`](./src/)
147
159
 
148
160
  ## Python SDK
149
161
 
162
+ Requires Python 3.10+.
163
+
150
164
  ```bash
151
165
  pip install clanker-wallet
152
166
  ```
@@ -158,9 +172,9 @@ agent = ClankerWallet()
158
172
  agent.pair(pairing_json)
159
173
 
160
174
  tx_hash = await agent.request_tx(RequestTxOptions(
161
- chain_id=1,
162
- transaction=Transaction(to="0x...", value="1000000000000000000", data="0x"),
163
- context=TxContext(reason="Buy tokens"),
175
+ chain_id=84532, # base sepolia
176
+ transaction=Transaction(to="0x000000000000000000000000000000000000dEaD", value="1000000000000000", data="0x"),
177
+ context=TxContext(reason="send 0.001 ETH on base sepolia"),
164
178
  ))
165
179
  ```
166
180
 
package/SKILL.md CHANGED
@@ -16,8 +16,26 @@ npm install -g clanker-wallet
16
16
 
17
17
  Or use without installing: `npx clanker-wallet` / `bunx clanker-wallet`
18
18
 
19
+ ### Running in a sandbox
20
+
21
+ If your commands run in a sandbox (e.g. Claude Code), the CLI needs:
22
+
23
+ - **network:** a WebSocket to the relay, `wss://relay.clanker-wallet.xyz/ws` (the `relay` URL in the pairing JSON). Without it `tx` fails with exit code 3.
24
+ - **the sandbox proxy:** if the sandbox only lets traffic out through `HTTPS_PROXY` (Claude Code does), Node ignores it unless you run the CLI as `NODE_USE_ENV_PROXY=1 clanker-wallet ...` (Node 24+). The exit-3 error says so when this applies.
25
+ - **files:** `pair` writes your identity and the pairing to `~/.clanker-wallet/`, and the other commands write there too (they fix file permissions and create the identity if it is missing). Allow writes, not just reads.
26
+
27
+ Ask the human to allow the host and the directory before you pair.
28
+
19
29
  ## Pairing (one-time)
20
30
 
31
+ Before you can send transactions, check if you're already paired:
32
+
33
+ ```bash
34
+ clanker-wallet status
35
+ ```
36
+
37
+ If `paired: true`, skip pairing.
38
+
21
39
  The human will paste a prompt block from https://clanker-wallet.xyz that looks like this:
22
40
 
23
41
  > read the setup guide first: https://clanker-wallet.xyz/llms.txt
@@ -33,7 +51,9 @@ clanker-wallet pair '{"version":1,"pubkey":"ab12...","relay":"wss://...","wallet
33
51
 
34
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.
35
53
 
36
- This saves identity and 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.
55
+
56
+ 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.
37
57
 
38
58
  ### Trust (first request)
39
59
 
@@ -53,7 +73,7 @@ If they agree, **every time you send a `tx` command**, immediately send a messag
53
73
 
54
74
  Example notification:
55
75
  > Transaction awaiting your approval
56
- > I'm requesting to send 0.001 ETH on Sepolia to a test address.
76
+ > I'm requesting to send 0.001 ETH on Base Sepolia to a test address.
57
77
  > Urgency: normal
58
78
  > [Approve or reject here](https://clanker-wallet.xyz)
59
79
 
@@ -61,37 +81,41 @@ Example notification:
61
81
 
62
82
  ```bash
63
83
  clanker-wallet tx \
64
- --chain 1 \
65
- --to 0xRecipientAddress \
84
+ --chain 84532 \
85
+ --to 0x000000000000000000000000000000000000dEaD \
66
86
  --value 1000000000000000 \
67
- --reason "Send 0.001 ETH to recipient" \
87
+ --reason "Send 0.001 ETH on Base Sepolia to a test address" \
68
88
  --urgency medium \
69
- --timeout 120000
89
+ --timeout 600000
70
90
  ```
71
91
 
72
- The command **blocks until the human approves or rejects**. Output is JSON:
92
+ This sends 0.001 ETH (`1000000000000000` wei) on Base Sepolia (chain `84532`) to the burn address `0x000000000000000000000000000000000000dEaD`.
93
+
94
+ The command **blocks until the human approves or rejects**, for up to 1 hour by default. Pass `--timeout <ms>` to wait a different time (`600000` = 10 minutes). If your shell tool kills commands after a few minutes, run `tx` in the background and read its output when it exits. Output is JSON:
73
95
  - Approved: `{"tx_hash":"0x...","status":"approved"}` (exit code 0)
74
96
  - Rejected: `{"status":"rejected","error":"..."}` (exit code 1)
75
97
  - Timeout: `{"status":"timeout","error":"..."}` (exit code 2)
98
+ - Invalid `--chain`, `--to`, `--value`, `--data` or `--timeout`: a one-line `invalid --<flag>: ...` message on stderr and nothing is sent (exit code 5)
76
99
 
77
100
  ## Critical Details
78
101
 
79
- - **value is in WEI, not ETH.** 1 ETH = `1000000000000000000`. Use string, not number.
80
- - **data is hex-encoded calldata.** Use `0x` for simple ETH transfers. For contract calls, encode the function call.
102
+ - **value is in WEI, not ETH.** 1 ETH = `1000000000000000000`. A whole number: no decimals, no `1e18`.
103
+ - **data is hex-encoded calldata.** Use `0x` for simple ETH transfers. For contract calls, encode the function call (see ERC-20 transfer below). Never put placeholder text inside the hex.
104
+ - **to must be a real address**: `0x` + 40 hex characters. Mixed-case addresses must carry a valid EIP-55 checksum.
81
105
  - **The human must be online to approve.** Set a reasonable `--timeout`. Always notify the human through your communication channel when you send a request (if they opted in).
82
106
 
83
107
  ## tx Flags
84
108
 
85
109
  | Flag | Required | Default | Description |
86
110
  |------|----------|---------|-------------|
87
- | `--chain <id>` | yes | — | Chain ID (1=Ethereum, 137=Polygon, 8453=Base) |
111
+ | `--chain <id>` | yes | — | Chain ID (1=Ethereum, 8453=Base, 84532=Base Sepolia) |
88
112
  | `--to <address>` | yes | — | Recipient address |
89
113
  | `--value <wei>` | no | `0` | Value in wei |
90
114
  | `--data <hex>` | no | `0x` | Calldata hex |
91
115
  | `--reason <text>` | no | — | Shown to human |
92
116
  | `--urgency <level>` | no | — | `low`, `medium`, or `high` |
93
117
  | `--expected-outcome <text>` | no | — | What will happen |
94
- | `--timeout <ms>` | no | `3600000` | How long to wait |
118
+ | `--timeout <ms>` | no | `3600000` (1 h) | How long `tx` blocks waiting for the human |
95
119
 
96
120
  ## Common Chain IDs
97
121
 
@@ -103,33 +127,37 @@ The command **blocks until the human approves or rejects**. Output is JSON:
103
127
  | Base | 8453 |
104
128
  | Optimism | 10 |
105
129
  | Sepolia (testnet) | 11155111 |
130
+ | Base Sepolia (testnet) | 84532 |
106
131
 
107
132
  ## Common Patterns
108
133
 
109
134
  ### Simple ETH transfer
110
135
  ```bash
111
- clanker-wallet tx --chain 1 --to 0xRecipientAddress --value 1000000000000000 --reason "Send 0.001 ETH"
136
+ clanker-wallet tx --chain 84532 --to 0x000000000000000000000000000000000000dEaD --value 1000000000000000 --reason "Send 0.001 ETH"
112
137
  ```
113
138
 
114
139
  ### ERC-20 token transfer
140
+ Calldata is the 4-byte selector followed by each argument as a 32-byte word: the address left-padded with zeros, the amount as big-endian hex in the token's smallest unit. This sends 0.001 WETH (`1000000000000000` = `0x38d7ea4c68000`, 18 decimals) on Base Sepolia, where WETH is `0x4200000000000000000000000000000000000006`:
141
+
115
142
  ```bash
116
143
  # transfer(address to, uint256 amount) — selector 0xa9059cbb
117
- # encode to + amount as 32-byte padded hex
118
144
  clanker-wallet tx \
119
- --chain 1 \
120
- --to 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48 \
121
- --data 0xa9059cbb000000000000000000000000RecipientAddr00000000000000000000000000000000000000000000000000000000000f4240 \
122
- --reason "Transfer 1 USDC"
145
+ --chain 84532 \
146
+ --to 0x4200000000000000000000000000000000000006 \
147
+ --data 0xa9059cbb000000000000000000000000000000000000000000000000000000000000dead00000000000000000000000000000000000000000000000000038d7ea4c68000 \
148
+ --reason "Transfer 0.001 WETH to a test address"
123
149
  ```
124
150
 
151
+ Encode it with a library rather than by hand, e.g. `cast calldata "transfer(address,uint256)" 0x000000000000000000000000000000000000dEaD 1000000000000000` (foundry) or viem `encodeFunctionData({ abi: erc20Abi, functionName: 'transfer', args: [to, amount] })`.
152
+
125
153
  ### ERC-20 approval
126
154
  ```bash
127
155
  # approve(address spender, uint256 amount) — selector 0x095ea7b3
128
156
  clanker-wallet tx \
129
- --chain 1 \
130
- --to 0xTokenContractAddress \
131
- --data 0x095ea7b3000000000000000000000000SpenderAddr0000000000000000000000000000000000000000000000000000000000000f4240 \
132
- --reason "Approve 1 USDC spend for DeFi protocol"
157
+ --chain 84532 \
158
+ --to 0x4200000000000000000000000000000000000006 \
159
+ --data 0x095ea7b3000000000000000000000000000000000000000000000000000000000000dead00000000000000000000000000000000000000000000000000038d7ea4c68000 \
160
+ --reason "Approve a test address to spend 0.001 WETH"
133
161
  ```
134
162
 
135
163
  ## Other Commands
@@ -146,7 +174,8 @@ clanker-wallet whoami
146
174
 
147
175
  ## Troubleshooting
148
176
 
177
+ - **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
149
178
  - **exit code 4 / "not paired"** — run `clanker-wallet pair '<JSON>'` first
150
179
  - **exit code 2 / timeout** — human didn't respond in time, increase `--timeout` or ask them to check the web app
151
180
  - **exit code 1 / "rejected"** — human clicked reject. Explain why the tx is needed and try again if appropriate.
152
- - **exit code 3 / connection error** — relay may be down, try again in a few seconds
181
+ - **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 } from '../dist/index.js'
2
+ import { ClankerWallet, InvalidTxError, 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'
@@ -10,6 +10,9 @@ const IDENTITY_FILE = join(STATE_DIR, 'identity.json')
10
10
  const PAIRING_FILE = join(STATE_DIR, 'pairing.json')
11
11
 
12
12
  const EXIT = { OK: 0, REJECTED: 1, TIMEOUT: 2, CONNECTION: 3, NOT_PAIRED: 4, BAD_ARGS: 5 }
13
+ const DEFAULT_TIMEOUT_MS = 3_600_000
14
+ /** validateTx field -> the tx flag that sets it */
15
+ const FIELD_FLAGS = { chain_id: '--chain', to: '--to', value: '--value', data: '--data' }
13
16
 
14
17
  // ── helpers ──────────────────────────────────────────────────────────
15
18
  const hex = (bytes) => Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join('')
@@ -135,12 +138,20 @@ async function cmdTx(flags) {
135
138
  if (!chain) die('missing --chain', EXIT.BAD_ARGS)
136
139
  if (!to) die('missing --to', EXIT.BAD_ARGS)
137
140
 
138
- const chainId = parseInt(chain, 10)
139
- if (isNaN(chainId)) die('--chain must be a number', EXIT.BAD_ARGS)
141
+ if (!/^[0-9]+$/.test(chain)) die(`invalid --chain: must be a positive integer chain id, got ${JSON.stringify(chain)}`, EXIT.BAD_ARGS)
142
+ if (flags.timeout !== undefined && !/^[1-9][0-9]*$/.test(flags.timeout)) {
143
+ die(`invalid --timeout: must be a positive number of milliseconds, got ${JSON.stringify(flags.timeout)}`, EXIT.BAD_ARGS)
144
+ }
145
+ const timeout = flags.timeout ? Number(flags.timeout) : DEFAULT_TIMEOUT_MS
140
146
 
141
- const value = flags.value || '0'
142
- const data = flags.data || '0x'
143
- const timeout = flags.timeout ? parseInt(flags.timeout, 10) : 3_600_000
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
+ }
144
155
 
145
156
  const context = {}
146
157
  if (flags.reason) context.reason = flags.reason
@@ -159,8 +170,8 @@ async function cmdTx(flags) {
159
170
 
160
171
  try {
161
172
  const txHash = await wallet.requestTx({
162
- chain_id: chainId,
163
- transaction: { to, value, data },
173
+ chain_id: request.chain_id,
174
+ transaction: request.transaction,
164
175
  context: Object.keys(context).length > 0 ? context : undefined,
165
176
  timeout_ms: timeout,
166
177
  })
@@ -202,6 +213,11 @@ function cmdWhoami() {
202
213
  })
203
214
  }
204
215
 
216
+ function cmdVersion() {
217
+ const { version } = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'))
218
+ process.stdout.write(version + '\n')
219
+ }
220
+
205
221
  function showHelp() {
206
222
  process.stderr.write(`clanker-wallet — human-approved transactions for AI agents
207
223
 
@@ -212,30 +228,31 @@ commands:
212
228
  whoami show agent identity
213
229
 
214
230
  tx flags:
215
- --chain <id> chain ID (required) — 1, 137, 8453, etc.
216
- --to <address> recipient (required)
217
- --value <wei> value in wei (default: 0)
218
- --data <hex> calldata (default: 0x)
219
- --reason <text> shown to human
220
- --urgency <low|med|high> priority hint
221
- --expected-outcome <text> what will happen
222
- --timeout <ms> wait time (default: 3600000)
231
+ --chain <id> chain ID (required) — 1, 8453, 84532 (base sepolia), etc.
232
+ --to <address> recipient (required)
233
+ --value <wei> value in wei, not ETH (default: 0; 1 ETH = 1000000000000000000)
234
+ --data <hex> calldata (default: 0x)
235
+ --reason <text> shown to human
236
+ --urgency <low|medium|high> priority hint
237
+ --expected-outcome <text> what will happen
238
+ --timeout <ms> how long tx blocks waiting for the human (default: 3600000 = 1 h)
223
239
 
224
240
  exit codes:
225
241
  0 success
226
242
  1 rejected by human
227
243
  2 timeout
228
- 3 connection error
244
+ 3 connection error (in a sandbox or behind a firewall, allow the relay host, wss://relay.clanker-wallet.xyz/ws by default;
245
+ behind HTTPS_PROXY run with NODE_USE_ENV_PROXY=1)
229
246
  4 not paired
230
247
  5 invalid arguments
231
248
 
232
249
  examples:
233
250
  clanker-wallet pair '{"version":1,"pubkey":"ab12...","relay":"wss://...","wallet":"0x..."}'
234
- clanker-wallet tx --chain 1 --to 0xRecipient --value 1000000000000000000 --reason "swap ETH"
251
+ clanker-wallet tx --chain 84532 --to 0x000000000000000000000000000000000000dEaD --value 1000000000000000 --reason "send 0.001 ETH on base sepolia"
235
252
  clanker-wallet status
236
253
  clanker-wallet whoami
237
254
 
238
- docs: https://github.com/almogdepaz/clanker-wallet
255
+ docs: https://clanker-wallet.xyz/SKILL.md
239
256
  web: https://clanker-wallet.xyz
240
257
  `)
241
258
  }
@@ -256,6 +273,10 @@ switch (cmd) {
256
273
  case 'whoami':
257
274
  cmdWhoami()
258
275
  break
276
+ case '--version':
277
+ case '-v':
278
+ cmdVersion()
279
+ break
259
280
  case 'help':
260
281
  case '--help':
261
282
  case '-h':
package/dist/client.js CHANGED
@@ -1,6 +1,17 @@
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
4
  const DEFAULT_TIMEOUT_MS = 3_600_000; // 1 hour
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
+ /** 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 */
7
+ function proxyHint() {
8
+ const env = globalThis.process?.env;
9
+ if (!env || env.NODE_USE_ENV_PROXY)
10
+ return '';
11
+ return env.HTTPS_PROXY || env.https_proxy || env.HTTP_PROXY || env.http_proxy
12
+ ? '; HTTPS_PROXY is set but node ignores it, rerun with NODE_USE_ENV_PROXY=1 (node 24+)'
13
+ : '';
14
+ }
4
15
  export class ClankerWallet extends TypedEmitter {
5
16
  publicKey;
6
17
  secretKey;
@@ -57,6 +68,8 @@ export class ClankerWallet extends TypedEmitter {
57
68
  if (!this.pairing) {
58
69
  throw new Error('not paired — call pair() first');
59
70
  }
71
+ // reject malformed requests before anything reaches the relay
72
+ const { chain_id, transaction } = validateTx(options.chain_id, options.transaction);
60
73
  const { humanPubkey, humanPubkeyHex, relayUrl, humanWallet, mode } = this.pairing;
61
74
  const timeoutMs = options.timeout_ms ?? DEFAULT_TIMEOUT_MS;
62
75
  const requestId = `req_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
@@ -64,8 +77,8 @@ export class ClankerWallet extends TypedEmitter {
64
77
  request_id: requestId,
65
78
  agent_id: this.agentId,
66
79
  timestamp: new Date().toISOString(),
67
- chain_id: options.chain_id,
68
- transaction: options.transaction,
80
+ chain_id,
81
+ transaction,
69
82
  context: options.context,
70
83
  signature: '', // set below after signing
71
84
  };
@@ -100,8 +113,9 @@ export class ClankerWallet extends TypedEmitter {
100
113
  return;
101
114
  }
102
115
  ws.addEventListener('error', () => {
116
+ // node's WebSocket error event carries no cause, so name the url: a sandbox or firewall blocking it looks the same as an outage
103
117
  if (!settled)
104
- fail('relay connection error');
118
+ fail(`relay connection error: could not reach ${relayUrl} (${UNREACHABLE_HINT}${proxyHint()})`);
105
119
  });
106
120
  ws.addEventListener('close', () => {
107
121
  this.emit('disconnected', {});
@@ -140,10 +154,10 @@ export class ClankerWallet extends TypedEmitter {
140
154
  if (mode === 'managed') {
141
155
  wsMsg.simulate = {
142
156
  from: humanWallet || '',
143
- to: options.transaction.to,
144
- value: options.transaction.value,
145
- data: options.transaction.data,
146
- chainId: options.chain_id,
157
+ to: transaction.to,
158
+ value: transaction.value,
159
+ data: transaction.data,
160
+ chainId: chain_id,
147
161
  };
148
162
  }
149
163
  ws.send(JSON.stringify(wsMsg));
package/dist/index.d.ts CHANGED
@@ -3,4 +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 type { AgentEnvelope, PairingData, RequestTxOptions, Transaction, TxContext, TxRequest, TxResponse, } from './types.js';
6
+ export { InvalidTxError, isChecksumAddress, validateTx } from './validate.js';
7
+ export type { TxField } from './validate.js';
8
+ export type { AgentEnvelope, PairingData, RequestTxOptions, Transaction, TxContext, TxInput, TxRequest, TxResponse, } from './types.js';
package/dist/index.js CHANGED
@@ -1,3 +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';
@@ -0,0 +1,5 @@
1
+ /**
2
+ * keccak-256 (the pre-NIST padding ethereum uses; NIST sha3-256 differs), only for
3
+ * EIP-55 address checksums. inputs are a few dozen bytes, so lanes are plain bigints.
4
+ */
5
+ export declare function keccak256(input: Uint8Array): Uint8Array;
package/dist/keccak.js ADDED
@@ -0,0 +1,82 @@
1
+ /**
2
+ * keccak-256 (the pre-NIST padding ethereum uses; NIST sha3-256 differs), only for
3
+ * EIP-55 address checksums. inputs are a few dozen bytes, so lanes are plain bigints.
4
+ */
5
+ const MASK64 = (1n << 64n) - 1n;
6
+ const RATE_BYTES = 136;
7
+ const rotl = (x, n) => n === 0 ? x : ((x << BigInt(n)) | (x >> BigInt(64 - n))) & MASK64;
8
+ // round constants from the degree-8 LFSR in the keccak reference
9
+ const ROUND_CONSTANTS = (() => {
10
+ const out = [];
11
+ let r = 1;
12
+ for (let round = 0; round < 24; round++) {
13
+ let rc = 0n;
14
+ for (let j = 0; j < 7; j++) {
15
+ if (r & 1)
16
+ rc |= 1n << BigInt((1 << j) - 1);
17
+ r = r & 0x80 ? ((r << 1) ^ 0x71) & 0xff : (r << 1) & 0xff;
18
+ }
19
+ out.push(rc);
20
+ }
21
+ return out;
22
+ })();
23
+ // rho offsets, indexed by x + 5y
24
+ const ROTATIONS = (() => {
25
+ const out = new Array(25).fill(0);
26
+ let x = 1;
27
+ let y = 0;
28
+ for (let t = 0; t < 24; t++) {
29
+ out[x + 5 * y] = (((t + 1) * (t + 2)) / 2) % 64;
30
+ [x, y] = [y, (2 * x + 3 * y) % 5];
31
+ }
32
+ return out;
33
+ })();
34
+ function keccakF(a) {
35
+ const b = new Array(25);
36
+ const c = new Array(5);
37
+ for (let round = 0; round < 24; round++) {
38
+ // theta
39
+ for (let x = 0; x < 5; x++)
40
+ c[x] = a[x] ^ a[x + 5] ^ a[x + 10] ^ a[x + 15] ^ a[x + 20];
41
+ for (let x = 0; x < 5; x++) {
42
+ const d = c[(x + 4) % 5] ^ rotl(c[(x + 1) % 5], 1);
43
+ for (let y = 0; y < 25; y += 5)
44
+ a[x + y] ^= d;
45
+ }
46
+ // rho + pi
47
+ for (let x = 0; x < 5; x++) {
48
+ for (let y = 0; y < 5; y++) {
49
+ b[y + 5 * ((2 * x + 3 * y) % 5)] = rotl(a[x + 5 * y], ROTATIONS[x + 5 * y]);
50
+ }
51
+ }
52
+ // chi
53
+ for (let y = 0; y < 25; y += 5) {
54
+ for (let x = 0; x < 5; x++) {
55
+ a[x + y] = b[x + y] ^ (~b[((x + 1) % 5) + y] & MASK64 & b[((x + 2) % 5) + y]);
56
+ }
57
+ }
58
+ // iota
59
+ a[0] ^= ROUND_CONSTANTS[round];
60
+ }
61
+ }
62
+ export function keccak256(input) {
63
+ // pad10*1 with keccak's 0x01 domain byte
64
+ const padded = new Uint8Array(Math.floor(input.length / RATE_BYTES + 1) * RATE_BYTES);
65
+ padded.set(input);
66
+ padded[input.length] ^= 0x01;
67
+ padded[padded.length - 1] ^= 0x80;
68
+ const state = new Array(25).fill(0n);
69
+ for (let off = 0; off < padded.length; off += RATE_BYTES) {
70
+ for (let i = 0; i < RATE_BYTES / 8; i++) {
71
+ let lane = 0n;
72
+ for (let k = 7; k >= 0; k--)
73
+ lane = (lane << 8n) | BigInt(padded[off + i * 8 + k]);
74
+ state[i] ^= lane;
75
+ }
76
+ keccakF(state);
77
+ }
78
+ const out = new Uint8Array(32);
79
+ for (let i = 0; i < 32; i++)
80
+ out[i] = Number((state[i >> 3] >> BigInt(8 * (i & 7))) & 0xffn);
81
+ return out;
82
+ }
package/dist/types.d.ts CHANGED
@@ -4,6 +4,12 @@ export interface Transaction {
4
4
  value: string;
5
5
  data: string;
6
6
  }
7
+ /** transaction as passed to requestTx: value in wei, data defaults to "0x" */
8
+ export interface TxInput {
9
+ to: string;
10
+ value: string | bigint;
11
+ data?: string;
12
+ }
7
13
  /** optional context about the transaction */
8
14
  export interface TxContext {
9
15
  reason?: string;
@@ -46,7 +52,8 @@ export interface PairingData {
46
52
  /** options for requestTx */
47
53
  export interface RequestTxOptions {
48
54
  chain_id: number;
49
- transaction: Transaction;
55
+ /** checked before sending; an invalid field throws InvalidTxError */
56
+ transaction: TxInput;
50
57
  context?: TxContext;
51
58
  /** timeout in ms (default: 3_600_000 = 1hr) */
52
59
  timeout_ms?: number;
@@ -0,0 +1,19 @@
1
+ import type { Transaction, TxInput } from './types.js';
2
+ export type TxField = 'chain_id' | 'to' | 'value' | 'data';
3
+ /** a tx request field failed validation; nothing was sent */
4
+ export declare class InvalidTxError extends Error {
5
+ readonly field: TxField;
6
+ readonly reason: string;
7
+ constructor(field: TxField, reason: string);
8
+ }
9
+ /** EIP-55: a mixed-case address must carry the checksum; single-case addresses are unchecksummed */
10
+ export declare function isChecksumAddress(address: string): boolean;
11
+ /**
12
+ * check a tx request before anything is signed or sent, and normalize it to the wire
13
+ * format (value as a decimal wei string, data defaulting to "0x").
14
+ * throws InvalidTxError naming the first bad field.
15
+ */
16
+ export declare function validateTx(chainId: unknown, tx: TxInput | Record<string, unknown>): {
17
+ chain_id: number;
18
+ transaction: Transaction;
19
+ };
@@ -0,0 +1,65 @@
1
+ import { keccak256 } from './keccak.js';
2
+ /** a tx request field failed validation; nothing was sent */
3
+ export class InvalidTxError extends Error {
4
+ field;
5
+ reason;
6
+ constructor(field, reason) {
7
+ super(`invalid ${field}: ${reason}`);
8
+ this.field = field;
9
+ this.reason = reason;
10
+ this.name = 'InvalidTxError';
11
+ }
12
+ }
13
+ const ADDRESS_RE = /^0x[0-9a-fA-F]{40}$/;
14
+ const DECIMAL_RE = /^[0-9]+$/;
15
+ const CALLDATA_RE = /^0x(?:[0-9a-fA-F]{2})*$/;
16
+ /** short, quoted rendering of a rejected input for error messages */
17
+ function shown(v) {
18
+ const s = typeof v === 'bigint' ? `${v}n` : JSON.stringify(v) ?? String(v);
19
+ return s.length > 70 ? `${s.slice(0, 67)}...` : s;
20
+ }
21
+ /** EIP-55: a mixed-case address must carry the checksum; single-case addresses are unchecksummed */
22
+ export function isChecksumAddress(address) {
23
+ if (!ADDRESS_RE.test(address))
24
+ return false;
25
+ const body = address.slice(2);
26
+ if (body === body.toLowerCase() || body === body.toUpperCase())
27
+ return true;
28
+ const lower = body.toLowerCase();
29
+ const hash = keccak256(new TextEncoder().encode(lower));
30
+ for (let i = 0; i < 40; i++) {
31
+ const nibble = (hash[i >> 1] >> (i % 2 === 0 ? 4 : 0)) & 0xf;
32
+ const expected = nibble >= 8 ? lower[i].toUpperCase() : lower[i];
33
+ if (body[i] !== expected)
34
+ return false;
35
+ }
36
+ return true;
37
+ }
38
+ /**
39
+ * check a tx request before anything is signed or sent, and normalize it to the wire
40
+ * format (value as a decimal wei string, data defaulting to "0x").
41
+ * throws InvalidTxError naming the first bad field.
42
+ */
43
+ export function validateTx(chainId, tx) {
44
+ if (typeof chainId !== 'number' || !Number.isSafeInteger(chainId) || chainId <= 0) {
45
+ throw new InvalidTxError('chain_id', `must be a positive integer, got ${shown(chainId)}`);
46
+ }
47
+ const { to, value, data = '0x' } = tx;
48
+ if (typeof to !== 'string' || !ADDRESS_RE.test(to)) {
49
+ throw new InvalidTxError('to', `must be 0x followed by 40 hex characters, got ${shown(to)}`);
50
+ }
51
+ if (!isChecksumAddress(to)) {
52
+ throw new InvalidTxError('to', `mixed-case address fails its EIP-55 checksum, got ${shown(to)}`);
53
+ }
54
+ let wei;
55
+ if (typeof value === 'bigint' && value >= 0n)
56
+ wei = value.toString();
57
+ else if (typeof value === 'string' && DECIMAL_RE.test(value))
58
+ wei = value;
59
+ else
60
+ throw new InvalidTxError('value', `must be a non-negative integer amount of wei (decimal string or bigint), got ${shown(value)}`);
61
+ if (typeof data !== 'string' || !CALLDATA_RE.test(data)) {
62
+ throw new InvalidTxError('data', `must be 0x followed by an even number of hex characters, got ${shown(data)}`);
63
+ }
64
+ return { chain_id: chainId, transaction: { to, value: wei, data } };
65
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clanker-wallet",
3
- "version": "0.4.0",
3
+ "version": "0.4.2",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",