clanker-wallet 0.3.0 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -17,12 +17,14 @@ bunx clanker-wallet
17
17
  ## Quick Start
18
18
 
19
19
  ```bash
20
- # 1. Human goes to https://clanker-wallet.xyz, connects wallet, copies pairing JSON
21
- # 2. Pair (one-time)
20
+ # 1. Human goes to https://clanker-wallet.xyz, connects wallet, clicks "copy prompt for agent",
21
+ # pastes the prompt to you. The prompt contains a JSON pairing code.
22
+ # 2. Pair (one-time) — extract the JSON from the prompt
22
23
  clanker-wallet pair '{"version":1,"pubkey":"ab12...","relay":"wss://...","wallet":"0x..."}'
23
24
 
24
- # 3. Send a transaction (blocks until human approves or rejects)
25
- clanker-wallet tx --chain 1 --to 0xRecipient --value 1000000000000000000 --reason "swap ETH for USDC"
25
+ # 3. Send a transaction (blocks until human approves or rejects; 1 h by default)
26
+ # 0.001 ETH on Base Sepolia (84532) to the burn address. --value is in wei.
27
+ clanker-wallet tx --chain 84532 --to 0x000000000000000000000000000000000000dEaD --value 1000000000000000 --reason "send 0.001 ETH on base sepolia"
26
28
 
27
29
  # 4. Check status anytime
28
30
  clanker-wallet status
@@ -41,33 +43,36 @@ clanker-wallet pair '<pairing JSON from web app>'
41
43
 
42
44
  ### `tx`
43
45
 
44
- Send a transaction request. Blocks until the human approves or rejects.
46
+ Send a transaction request. Blocks until the human approves or rejects, for up to 1 hour by default; `--timeout <ms>` changes that.
45
47
 
46
48
  ```bash
47
49
  clanker-wallet tx \
48
- --chain 1 \
49
- --to 0xRecipient \
50
- --value 1000000000000000000 \
50
+ --chain 84532 \
51
+ --to 0x000000000000000000000000000000000000dEaD \
52
+ --value 1000000000000000 \
51
53
  --data 0x \
52
- --reason "swap ETH for USDC" \
54
+ --reason "send 0.001 ETH on base sepolia" \
53
55
  --urgency medium \
54
- --timeout 120000
56
+ --timeout 600000
55
57
  # approved → {"tx_hash":"0x...","status":"approved"}
56
58
  # rejected → {"status":"rejected","error":"..."} (exit code 1)
57
59
  # timeout → {"status":"timeout","error":"..."} (exit code 2)
60
+ # bad flag → "invalid --to: ..." on stderr, nothing sent (exit code 5)
58
61
  ```
59
62
 
63
+ `--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.
64
+
60
65
  **Flags:**
61
66
  | Flag | Required | Default | Description |
62
67
  |------|----------|---------|-------------|
63
- | `--chain <id>` | yes | — | Chain ID (1=Ethereum, 137=Polygon, 8453=Base) |
68
+ | `--chain <id>` | yes | — | Chain ID (1=Ethereum, 8453=Base, 84532=Base Sepolia) |
64
69
  | `--to <address>` | yes | — | Recipient address |
65
70
  | `--value <wei>` | no | `0` | Value in wei (1 ETH = `1000000000000000000`) |
66
71
  | `--data <hex>` | no | `0x` | Calldata hex |
67
72
  | `--reason <text>` | no | — | Shown to human |
68
73
  | `--urgency <level>` | no | — | `low`, `medium`, or `high` |
69
74
  | `--expected-outcome <text>` | no | — | What will happen |
70
- | `--timeout <ms>` | no | `3600000` | How long to wait |
75
+ | `--timeout <ms>` | no | `3600000` (1 h) | How long `tx` blocks waiting for the human |
71
76
 
72
77
  ### `status`
73
78
 
@@ -80,11 +85,11 @@ clanker-wallet status
80
85
 
81
86
  ### `whoami`
82
87
 
83
- Show agent identity.
88
+ Show agent identity. `fingerprint` (first 16 hex chars of the public key) is what the web app shows for this agent; the human compares it before trusting the agent.
84
89
 
85
90
  ```bash
86
91
  clanker-wallet whoami
87
- # → {"agent_id":"agent_abc123","public_key":"ab12cd..."}
92
+ # → {"agent_id":"agent_abc123","public_key":"ab12cd34ef567890...","fingerprint":"ab12 cd34 ef56 7890"}
88
93
  ```
89
94
 
90
95
  ## Exit Codes
@@ -96,7 +101,7 @@ clanker-wallet whoami
96
101
  | 2 | Timeout |
97
102
  | 3 | Connection/relay error |
98
103
  | 4 | Not paired |
99
- | 5 | Invalid arguments |
104
+ | 5 | Invalid arguments (bad or missing flag; nothing sent) |
100
105
 
101
106
  ## State
102
107
 
@@ -118,6 +123,7 @@ Identity and pairing are persisted in `~/.clanker-wallet/`:
118
123
  | Base | 8453 |
119
124
  | Optimism | 10 |
120
125
  | Sepolia (testnet) | 11155111 |
126
+ | Base Sepolia (testnet) | 84532 |
121
127
 
122
128
  ---
123
129
 
@@ -135,17 +141,22 @@ import { ClankerWallet } from 'clanker-wallet'
135
141
  const wallet = new ClankerWallet({ agentName: 'my-bot' })
136
142
  wallet.pair(pairingJson)
137
143
 
144
+ // 0.001 ETH on Base Sepolia; value is wei (string or bigint)
138
145
  const txHash = await wallet.requestTx({
139
- chain_id: 1,
140
- transaction: { to: '0x...', value: '1000000000000000000', data: '0x' },
141
- context: { reason: 'Buy tokens', urgency: 'medium' },
146
+ chain_id: 84532,
147
+ transaction: { to: '0x000000000000000000000000000000000000dEaD', value: '1000000000000000', data: '0x' },
148
+ context: { reason: 'send 0.001 ETH on base sepolia', urgency: 'medium' },
142
149
  })
143
150
  ```
144
151
 
152
+ `requestTx` throws `InvalidTxError` (with `field` and `reason`) before anything is sent if `chain_id`, `to`, `value` or `data` is malformed.
153
+
145
154
  See full API: [`src/`](./src/)
146
155
 
147
156
  ## Python SDK
148
157
 
158
+ Requires Python 3.10+.
159
+
149
160
  ```bash
150
161
  pip install clanker-wallet
151
162
  ```
@@ -157,21 +168,21 @@ agent = ClankerWallet()
157
168
  agent.pair(pairing_json)
158
169
 
159
170
  tx_hash = await agent.request_tx(RequestTxOptions(
160
- chain_id=1,
161
- transaction=Transaction(to="0x...", value="1000000000000000000", data="0x"),
162
- context=TxContext(reason="Buy tokens"),
171
+ chain_id=84532, # base sepolia
172
+ transaction=Transaction(to="0x000000000000000000000000000000000000dEaD", value="1000000000000000", data="0x"),
173
+ context=TxContext(reason="send 0.001 ETH on base sepolia"),
163
174
  ))
164
175
  ```
165
176
 
166
177
  ## How It Works
167
178
 
168
- 1. Human opens [clanker-wallet.xyz](https://clanker-wallet.xyz), connects wallet, gets a pairing code
169
- 2. Agent runs `clanker-wallet pair` — establishes E2E encrypted channel
179
+ 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
180
+ 2. Human pastes the prompt to their AI agent. Agent extracts the JSON and runs `clanker-wallet pair` — establishes E2E encrypted channel
170
181
  3. Agent runs `clanker-wallet tx` — encrypts tx with nacl.box, sends via WebSocket relay
171
- 4. Human reviews tx details, simulation, risk flags in the web app
182
+ 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
172
183
  5. Human approves → wallet signs + broadcasts → agent gets the tx hash
173
184
 
174
- All messages are encrypted end-to-end. The relay server never sees plaintext.
185
+ 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.
175
186
 
176
187
  ## License
177
188
 
package/SKILL.md CHANGED
@@ -18,22 +18,35 @@ Or use without installing: `npx clanker-wallet` / `bunx clanker-wallet`
18
18
 
19
19
  ## Pairing (one-time)
20
20
 
21
- Before you can send transactions, you must pair with the human. Ask them:
21
+ Before you can send transactions, check if you're already paired:
22
22
 
23
- > "I need to send blockchain transactions on your behalf. Please go to https://clanker-wallet.xyz, connect your wallet, and give me the pairing JSON."
24
-
25
- The human will give you a JSON string like:
26
- ```json
27
- {"version":1,"pubkey":"ab12cd...","relay":"wss://clanker-wallet-relay-625052130878.us-east1.run.app/ws","wallet":"0x..."}
23
+ ```bash
24
+ clanker-wallet status
28
25
  ```
29
26
 
30
- Pair with it:
27
+ If `paired: true`, skip pairing.
28
+
29
+ The human will paste a prompt block from https://clanker-wallet.xyz that looks like this:
30
+
31
+ > read the setup guide first: https://clanker-wallet.xyz/llms.txt
32
+ >
33
+ > then pair using this code:
34
+ > `{"version":1,"pubkey":"ab12cd...","relay":"wss://...","wallet":"0x..."}`
35
+
36
+ Extract the JSON object (the line starting with `{`) and pair:
37
+
31
38
  ```bash
32
39
  clanker-wallet pair '{"version":1,"pubkey":"ab12...","relay":"wss://...","wallet":"0x..."}'
33
40
  ```
34
41
 
42
+ 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.
43
+
35
44
  This saves identity and pairing to `~/.clanker-wallet/`. You only need to do this once.
36
45
 
46
+ ### Trust (first request)
47
+
48
+ 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.
49
+
37
50
  ### Transaction notifications
38
51
 
39
52
  After pairing, ask the human:
@@ -48,7 +61,7 @@ If they agree, **every time you send a `tx` command**, immediately send a messag
48
61
 
49
62
  Example notification:
50
63
  > Transaction awaiting your approval
51
- > I'm requesting to send 0.001 ETH on Sepolia to a test address.
64
+ > I'm requesting to send 0.001 ETH on Base Sepolia to a test address.
52
65
  > Urgency: normal
53
66
  > [Approve or reject here](https://clanker-wallet.xyz)
54
67
 
@@ -56,37 +69,41 @@ Example notification:
56
69
 
57
70
  ```bash
58
71
  clanker-wallet tx \
59
- --chain 1 \
60
- --to 0xRecipientAddress \
72
+ --chain 84532 \
73
+ --to 0x000000000000000000000000000000000000dEaD \
61
74
  --value 1000000000000000 \
62
- --reason "Send 0.001 ETH to recipient" \
75
+ --reason "Send 0.001 ETH on Base Sepolia to a test address" \
63
76
  --urgency medium \
64
- --timeout 120000
77
+ --timeout 600000
65
78
  ```
66
79
 
67
- The command **blocks until the human approves or rejects**. Output is JSON:
80
+ This sends 0.001 ETH (`1000000000000000` wei) on Base Sepolia (chain `84532`) to the burn address `0x000000000000000000000000000000000000dEaD`.
81
+
82
+ 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). Output is JSON:
68
83
  - Approved: `{"tx_hash":"0x...","status":"approved"}` (exit code 0)
69
84
  - Rejected: `{"status":"rejected","error":"..."}` (exit code 1)
70
85
  - Timeout: `{"status":"timeout","error":"..."}` (exit code 2)
86
+ - Invalid `--chain`, `--to`, `--value`, `--data` or `--timeout`: a one-line `invalid --<flag>: ...` message on stderr and nothing is sent (exit code 5)
71
87
 
72
88
  ## Critical Details
73
89
 
74
- - **value is in WEI, not ETH.** 1 ETH = `1000000000000000000`. Use string, not number.
75
- - **data is hex-encoded calldata.** Use `0x` for simple ETH transfers. For contract calls, encode the function call.
90
+ - **value is in WEI, not ETH.** 1 ETH = `1000000000000000000`. A whole number: no decimals, no `1e18`.
91
+ - **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.
92
+ - **to must be a real address**: `0x` + 40 hex characters. Mixed-case addresses must carry a valid EIP-55 checksum.
76
93
  - **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).
77
94
 
78
95
  ## tx Flags
79
96
 
80
97
  | Flag | Required | Default | Description |
81
98
  |------|----------|---------|-------------|
82
- | `--chain <id>` | yes | — | Chain ID (1=Ethereum, 137=Polygon, 8453=Base) |
99
+ | `--chain <id>` | yes | — | Chain ID (1=Ethereum, 8453=Base, 84532=Base Sepolia) |
83
100
  | `--to <address>` | yes | — | Recipient address |
84
101
  | `--value <wei>` | no | `0` | Value in wei |
85
102
  | `--data <hex>` | no | `0x` | Calldata hex |
86
103
  | `--reason <text>` | no | — | Shown to human |
87
104
  | `--urgency <level>` | no | — | `low`, `medium`, or `high` |
88
105
  | `--expected-outcome <text>` | no | — | What will happen |
89
- | `--timeout <ms>` | no | `3600000` | How long to wait |
106
+ | `--timeout <ms>` | no | `3600000` (1 h) | How long `tx` blocks waiting for the human |
90
107
 
91
108
  ## Common Chain IDs
92
109
 
@@ -98,33 +115,37 @@ The command **blocks until the human approves or rejects**. Output is JSON:
98
115
  | Base | 8453 |
99
116
  | Optimism | 10 |
100
117
  | Sepolia (testnet) | 11155111 |
118
+ | Base Sepolia (testnet) | 84532 |
101
119
 
102
120
  ## Common Patterns
103
121
 
104
122
  ### Simple ETH transfer
105
123
  ```bash
106
- clanker-wallet tx --chain 1 --to 0xRecipientAddress --value 1000000000000000 --reason "Send 0.001 ETH"
124
+ clanker-wallet tx --chain 84532 --to 0x000000000000000000000000000000000000dEaD --value 1000000000000000 --reason "Send 0.001 ETH"
107
125
  ```
108
126
 
109
127
  ### ERC-20 token transfer
128
+ 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`:
129
+
110
130
  ```bash
111
131
  # transfer(address to, uint256 amount) — selector 0xa9059cbb
112
- # encode to + amount as 32-byte padded hex
113
132
  clanker-wallet tx \
114
- --chain 1 \
115
- --to 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48 \
116
- --data 0xa9059cbb000000000000000000000000RecipientAddr00000000000000000000000000000000000000000000000000000000000f4240 \
117
- --reason "Transfer 1 USDC"
133
+ --chain 84532 \
134
+ --to 0x4200000000000000000000000000000000000006 \
135
+ --data 0xa9059cbb000000000000000000000000000000000000000000000000000000000000dead00000000000000000000000000000000000000000000000000038d7ea4c68000 \
136
+ --reason "Transfer 0.001 WETH to a test address"
118
137
  ```
119
138
 
139
+ 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] })`.
140
+
120
141
  ### ERC-20 approval
121
142
  ```bash
122
143
  # approve(address spender, uint256 amount) — selector 0x095ea7b3
123
144
  clanker-wallet tx \
124
- --chain 1 \
125
- --to 0xTokenContractAddress \
126
- --data 0x095ea7b3000000000000000000000000SpenderAddr0000000000000000000000000000000000000000000000000000000000000f4240 \
127
- --reason "Approve 1 USDC spend for DeFi protocol"
145
+ --chain 84532 \
146
+ --to 0x4200000000000000000000000000000000000006 \
147
+ --data 0x095ea7b3000000000000000000000000000000000000000000000000000000000000dead00000000000000000000000000000000000000000000000000038d7ea4c68000 \
148
+ --reason "Approve a test address to spend 0.001 WETH"
128
149
  ```
129
150
 
130
151
  ## Other Commands
@@ -136,11 +157,12 @@ clanker-wallet status
136
157
 
137
158
  # Show agent identity
138
159
  clanker-wallet whoami
139
- # → {"agent_id":"agent_abc123","public_key":"ab12cd..."}
160
+ # → {"agent_id":"agent_abc123","public_key":"ab12cd34ef567890...","fingerprint":"ab12 cd34 ef56 7890"}
140
161
  ```
141
162
 
142
163
  ## Troubleshooting
143
164
 
165
+ - **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
144
166
  - **exit code 4 / "not paired"** — run `clanker-wallet pair '<JSON>'` first
145
167
  - **exit code 2 / timeout** — human didn't respond in time, increase `--timeout` or ask them to check the web app
146
168
  - **exit code 1 / "rejected"** — human clicked reject. Explain why the tx is needed and try again if appropriate.
package/bin/cli.js CHANGED
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
- import { ClankerWallet } from '../dist/index.js'
3
- import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'fs'
2
+ import { ClankerWallet, InvalidTxError, validateTx } from '../dist/index.js'
3
+ import { readFileSync, writeFileSync, mkdirSync, existsSync, chmodSync } from 'fs'
4
4
  import { join } from 'path'
5
5
  import { homedir } from 'os'
6
6
 
@@ -10,10 +10,15 @@ 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('')
16
19
  const fromHexStr = (h) => new Uint8Array(h.match(/.{2}/g).map(b => parseInt(b, 16)))
20
+ /** short id the human compares in the web app: first 16 hex (64 bits) of the x25519 pubkey, "ab12 cd34 ef56 7890" */
21
+ const fingerprint = (pubkeyHex) => pubkeyHex.slice(0, 16).toLowerCase().match(/.{1,4}/g).join(' ')
17
22
 
18
23
  function die(msg, code) {
19
24
  process.stderr.write(msg + '\n')
@@ -24,19 +29,25 @@ function out(obj) {
24
29
  process.stdout.write(JSON.stringify(obj) + '\n')
25
30
  }
26
31
 
32
+ // owner-only; an existing dir from an older version is tightened too
27
33
  function ensureDir() {
28
- if (!existsSync(STATE_DIR)) mkdirSync(STATE_DIR, { recursive: true })
34
+ if (!existsSync(STATE_DIR)) mkdirSync(STATE_DIR, { recursive: true, mode: 0o700 })
35
+ else chmodSync(STATE_DIR, 0o700)
29
36
  }
30
37
 
38
+ // identity.json holds the agent secret key: owner-only, tightened if an older version left it readable
31
39
  function loadIdentity() {
32
40
  if (!existsSync(IDENTITY_FILE)) return null
41
+ ensureDir()
42
+ chmodSync(IDENTITY_FILE, 0o600)
33
43
  try { return JSON.parse(readFileSync(IDENTITY_FILE, 'utf8')) }
34
44
  catch { return null }
35
45
  }
36
46
 
37
47
  function saveIdentity(data) {
38
48
  ensureDir()
39
- writeFileSync(IDENTITY_FILE, JSON.stringify(data, null, 2) + '\n')
49
+ writeFileSync(IDENTITY_FILE, JSON.stringify(data, null, 2) + '\n', { mode: 0o600 })
50
+ chmodSync(IDENTITY_FILE, 0o600)
40
51
  }
41
52
 
42
53
  function loadPairing() {
@@ -127,12 +138,20 @@ async function cmdTx(flags) {
127
138
  if (!chain) die('missing --chain', EXIT.BAD_ARGS)
128
139
  if (!to) die('missing --to', EXIT.BAD_ARGS)
129
140
 
130
- const chainId = parseInt(chain, 10)
131
- 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
132
146
 
133
- const value = flags.value || '0'
134
- const data = flags.data || '0x'
135
- 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
+ }
136
155
 
137
156
  const context = {}
138
157
  if (flags.reason) context.reason = flags.reason
@@ -145,13 +164,14 @@ async function cmdTx(flags) {
145
164
  pubkey: pairing.pubkey,
146
165
  relay: pairing.relay,
147
166
  wallet: pairing.wallet,
167
+ mode: pairing.mode || undefined,
148
168
  })
149
169
  wallet.pair(pairingJson)
150
170
 
151
171
  try {
152
172
  const txHash = await wallet.requestTx({
153
- chain_id: chainId,
154
- transaction: { to, value, data },
173
+ chain_id: request.chain_id,
174
+ transaction: request.transaction,
155
175
  context: Object.keys(context).length > 0 ? context : undefined,
156
176
  timeout_ms: timeout,
157
177
  })
@@ -189,9 +209,15 @@ function cmdWhoami() {
189
209
  out({
190
210
  agent_id: wallet.agentId,
191
211
  public_key: hex(wallet.publicKey),
212
+ fingerprint: fingerprint(hex(wallet.publicKey)),
192
213
  })
193
214
  }
194
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
+
195
221
  function showHelp() {
196
222
  process.stderr.write(`clanker-wallet — human-approved transactions for AI agents
197
223
 
@@ -202,14 +228,14 @@ commands:
202
228
  whoami show agent identity
203
229
 
204
230
  tx flags:
205
- --chain <id> chain ID (required) — 1, 137, 8453, etc.
206
- --to <address> recipient (required)
207
- --value <wei> value in wei (default: 0)
208
- --data <hex> calldata (default: 0x)
209
- --reason <text> shown to human
210
- --urgency <low|med|high> priority hint
211
- --expected-outcome <text> what will happen
212
- --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)
213
239
 
214
240
  exit codes:
215
241
  0 success
@@ -221,11 +247,11 @@ exit codes:
221
247
 
222
248
  examples:
223
249
  clanker-wallet pair '{"version":1,"pubkey":"ab12...","relay":"wss://...","wallet":"0x..."}'
224
- clanker-wallet tx --chain 1 --to 0xRecipient --value 1000000000000000000 --reason "swap ETH"
250
+ clanker-wallet tx --chain 84532 --to 0x000000000000000000000000000000000000dEaD --value 1000000000000000 --reason "send 0.001 ETH on base sepolia"
225
251
  clanker-wallet status
226
252
  clanker-wallet whoami
227
253
 
228
- docs: https://github.com/almogdepaz/clanker-wallet
254
+ docs: https://clanker-wallet.xyz/SKILL.md
229
255
  web: https://clanker-wallet.xyz
230
256
  `)
231
257
  }
@@ -246,6 +272,10 @@ switch (cmd) {
246
272
  case 'whoami':
247
273
  cmdWhoami()
248
274
  break
275
+ case '--version':
276
+ case '-v':
277
+ cmdVersion()
278
+ break
249
279
  case 'help':
250
280
  case '--help':
251
281
  case '-h':
package/dist/client.js CHANGED
@@ -1,5 +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
4
  const DEFAULT_TIMEOUT_MS = 3_600_000; // 1 hour
4
5
  export class ClankerWallet extends TypedEmitter {
5
6
  publicKey;
@@ -43,6 +44,7 @@ export class ClankerWallet extends TypedEmitter {
43
44
  humanPubkeyHex: data.pubkey,
44
45
  relayUrl: data.relay,
45
46
  humanWallet: data.wallet || null,
47
+ mode: data.mode || 'encrypted',
46
48
  };
47
49
  this.emit('paired', { relay: data.relay, wallet: data.wallet || '' });
48
50
  return data;
@@ -56,15 +58,17 @@ export class ClankerWallet extends TypedEmitter {
56
58
  if (!this.pairing) {
57
59
  throw new Error('not paired — call pair() first');
58
60
  }
59
- const { humanPubkey, humanPubkeyHex, relayUrl } = this.pairing;
61
+ // reject malformed requests before anything reaches the relay
62
+ const { chain_id, transaction } = validateTx(options.chain_id, options.transaction);
63
+ const { humanPubkey, humanPubkeyHex, relayUrl, humanWallet, mode } = this.pairing;
60
64
  const timeoutMs = options.timeout_ms ?? DEFAULT_TIMEOUT_MS;
61
65
  const requestId = `req_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
62
66
  const txRequest = {
63
67
  request_id: requestId,
64
68
  agent_id: this.agentId,
65
69
  timestamp: new Date().toISOString(),
66
- chain_id: options.chain_id,
67
- transaction: options.transaction,
70
+ chain_id,
71
+ transaction,
68
72
  context: options.context,
69
73
  signature: '', // set below after signing
70
74
  };
@@ -130,18 +134,32 @@ export class ClankerWallet extends TypedEmitter {
130
134
  ciphertext,
131
135
  signing_pubkey: this.signingPubkeyHex,
132
136
  };
133
- ws.send(JSON.stringify({
137
+ const wsMsg = {
134
138
  type: 'message',
135
139
  room: humanPubkeyHex,
136
140
  payload: JSON.stringify(envelope),
137
- }));
141
+ };
142
+ // managed mode: relay gets plaintext tx params so it can simulate
143
+ if (mode === 'managed') {
144
+ wsMsg.simulate = {
145
+ from: humanWallet || '',
146
+ to: transaction.to,
147
+ value: transaction.value,
148
+ data: transaction.data,
149
+ chainId: chain_id,
150
+ };
151
+ }
152
+ ws.send(JSON.stringify(wsMsg));
138
153
  this.emit('request_sent', { request_id: requestId });
139
154
  return;
140
155
  }
141
156
  if (msg.type === 'message') {
142
157
  try {
143
158
  const envelope = JSON.parse(msg.payload);
144
- const plaintext = decryptMessage(envelope.ciphertext, fromHex(envelope.sender_pubkey), this.secretKey);
159
+ // only the paired human may answer
160
+ if (envelope.sender_pubkey?.toLowerCase() !== humanPubkeyHex.toLowerCase())
161
+ return;
162
+ const plaintext = decryptMessage(envelope.ciphertext, humanPubkey, this.secretKey);
145
163
  if (!plaintext)
146
164
  return;
147
165
  const response = JSON.parse(plaintext);
package/dist/crypto.js CHANGED
@@ -20,7 +20,13 @@ export function encryptMessage(message, recipientPublicKey, senderSecretKey) {
20
20
  }
21
21
  /** decrypt a nacl.box message. payload = base64(nonce + ciphertext). */
22
22
  export function decryptMessage(base64Payload, senderPublicKey, recipientSecretKey) {
23
- const raw = base64ToBytes(base64Payload);
23
+ let raw;
24
+ try {
25
+ raw = base64ToBytes(base64Payload);
26
+ }
27
+ catch {
28
+ return null;
29
+ }
24
30
  if (raw.length <= nacl.box.nonceLength)
25
31
  return null;
26
32
  const nonce = raw.slice(0, nacl.box.nonceLength);
@@ -38,7 +44,13 @@ export function toHex(bytes) {
38
44
  }
39
45
  /** hex string → Uint8Array (strips 0x prefix if present) */
40
46
  export function fromHex(hex) {
41
- const clean = hex.startsWith('0x') ? hex.slice(2) : hex;
47
+ const clean = hex.startsWith('0x') || hex.startsWith('0X') ? hex.slice(2) : hex;
48
+ if (clean.length % 2 !== 0) {
49
+ throw new Error('invalid hex string: length must be even');
50
+ }
51
+ if (!/^[0-9a-fA-F]*$/.test(clean)) {
52
+ throw new Error('invalid hex string: contains non-hex characters');
53
+ }
42
54
  const bytes = new Uint8Array(clean.length / 2);
43
55
  for (let i = 0; i < bytes.length; i++) {
44
56
  bytes[i] = parseInt(clean.slice(i * 2, i * 2 + 2), 16);
@@ -47,7 +59,11 @@ export function fromHex(hex) {
47
59
  }
48
60
  /** derive an Ed25519 signing keypair from a 32-byte seed */
49
61
  export function signingKeypairFromSeed(seed) {
50
- return nacl.sign.keyPair.fromSeed(seed);
62
+ if (seed.length !== nacl.sign.seedLength) {
63
+ throw new Error(`invalid seed length: expected ${nacl.sign.seedLength} bytes, got ${seed.length}`);
64
+ }
65
+ // copy to keep derivation independent of caller-owned mutable buffers
66
+ return nacl.sign.keyPair.fromSeed(new Uint8Array(seed));
51
67
  }
52
68
  /** sign a message string with an Ed25519 secret key. returns base64(signature). */
53
69
  export function signMessage(message, signingSecretKey) {
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;
@@ -40,11 +46,14 @@ export interface PairingData {
40
46
  pubkey: string;
41
47
  relay: string;
42
48
  wallet: string;
49
+ /** "encrypted" (default): relay sees only ciphertext. "managed": relay also receives tx params for simulation. */
50
+ mode?: 'encrypted' | 'managed';
43
51
  }
44
52
  /** options for requestTx */
45
53
  export interface RequestTxOptions {
46
54
  chain_id: number;
47
- transaction: Transaction;
55
+ /** checked before sending; an invalid field throws InvalidTxError */
56
+ transaction: TxInput;
48
57
  context?: TxContext;
49
58
  /** timeout in ms (default: 3_600_000 = 1hr) */
50
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.3.0",
3
+ "version": "0.4.1",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -13,7 +13,13 @@
13
13
  "bin": {
14
14
  "clanker-wallet": "bin/cli.js"
15
15
  },
16
- "files": ["dist", "bin", "SKILL.md", "README.md", "LICENSE"],
16
+ "files": [
17
+ "dist",
18
+ "bin",
19
+ "SKILL.md",
20
+ "README.md",
21
+ "LICENSE"
22
+ ],
17
23
  "license": "MIT",
18
24
  "author": "Clanker Wallet Contributors",
19
25
  "repository": {
@@ -21,7 +27,27 @@
21
27
  "url": "git+https://github.com/almogdepaz/clanker-wallet.git"
22
28
  },
23
29
  "homepage": "https://github.com/almogdepaz/clanker-wallet#readme",
24
- "keywords": ["wallet", "ai-agent", "ethereum", "e2e-encrypted", "transaction-approval"],
30
+ "keywords": [
31
+ "wallet",
32
+ "ai-agent",
33
+ "ethereum",
34
+ "e2e-encrypted",
35
+ "transaction-approval",
36
+ "blockchain",
37
+ "web3",
38
+ "langchain",
39
+ "human-in-the-loop",
40
+ "evm",
41
+ "agent-wallet",
42
+ "llm",
43
+ "typescript",
44
+ "base",
45
+ "polygon",
46
+ "transaction",
47
+ "approval",
48
+ "defi",
49
+ "vercel-ai-sdk"
50
+ ],
25
51
  "scripts": {
26
52
  "build": "tsc -p tsconfig.build.json",
27
53
  "dev": "tsc --watch",