clanker-wallet 0.2.0 → 0.3.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,93 +1,177 @@
1
1
  # clanker-wallet
2
2
 
3
- TypeScript SDK for AI agents to request human-approved Ethereum transactions over an E2E encrypted relay.
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.
4
4
 
5
5
  ## Install
6
6
 
7
7
  ```bash
8
- npm install clanker-wallet
8
+ npm install -g clanker-wallet
9
9
  ```
10
10
 
11
- ## Quickstart
11
+ Or run without installing:
12
+ ```bash
13
+ npx clanker-wallet
14
+ bunx clanker-wallet
15
+ ```
12
16
 
13
- ```typescript
14
- import { ClankerWallet } from 'clanker-wallet'
17
+ ## Quick Start
15
18
 
16
- const wallet = new ClankerWallet({ agentName: 'my-trading-bot' })
19
+ ```bash
20
+ # 1. Human goes to https://clanker-wallet.xyz, connects wallet, copies pairing JSON
21
+ # 2. Pair (one-time)
22
+ clanker-wallet pair '{"version":1,"pubkey":"ab12...","relay":"wss://...","wallet":"0x..."}'
17
23
 
18
- // Pair with human's web app (scan QR code to get pairing JSON)
19
- wallet.pair(pairingJson)
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"
20
26
 
21
- // Request a transaction — blocks until human approves or rejects
22
- const txHash = await wallet.requestTx({
23
- chain_id: 1,
24
- transaction: { to: '0x...', value: '1000000000000000000', data: '0x' },
25
- context: { reason: 'Buy tokens', urgency: 'medium' },
26
- })
27
+ # 4. Check status anytime
28
+ clanker-wallet status
29
+ ```
30
+
31
+ ## Commands
27
32
 
28
- console.log('tx hash:', txHash)
33
+ ### `pair`
34
+
35
+ Pair with a human's web app. Run once — identity and pairing are saved to `~/.clanker-wallet/`.
36
+
37
+ ```bash
38
+ clanker-wallet pair '<pairing JSON from web app>'
39
+ # → {"agent_id":"agent_abc123","status":"paired","wallet":"0x...","relay":"wss://..."}
29
40
  ```
30
41
 
31
- ## How It Works
42
+ ### `tx`
32
43
 
33
- - **E2E encrypted** — agent and human derive an X25519 shared secret. The relay only sees ciphertext.
34
- - **Relay transport** — lightweight WebSocket relay forwards encrypted messages between agent and human. No private keys touch the server.
35
- - **Human approval** — the human reviews transaction details in the Clanker Wallet web app and signs with their own wallet (MetaMask, WalletConnect, etc).
44
+ Send a transaction request. Blocks until the human approves or rejects.
36
45
 
37
- ## API
46
+ ```bash
47
+ clanker-wallet tx \
48
+ --chain 1 \
49
+ --to 0xRecipient \
50
+ --value 1000000000000000000 \
51
+ --data 0x \
52
+ --reason "swap ETH for USDC" \
53
+ --urgency medium \
54
+ --timeout 120000
55
+ # approved → {"tx_hash":"0x...","status":"approved"}
56
+ # rejected → {"status":"rejected","error":"..."} (exit code 1)
57
+ # timeout → {"status":"timeout","error":"..."} (exit code 2)
58
+ ```
38
59
 
39
- ### `new ClankerWallet(options?)`
60
+ **Flags:**
61
+ | Flag | Required | Default | Description |
62
+ |------|----------|---------|-------------|
63
+ | `--chain <id>` | yes | — | Chain ID (1=Ethereum, 137=Polygon, 8453=Base) |
64
+ | `--to <address>` | yes | — | Recipient address |
65
+ | `--value <wei>` | no | `0` | Value in wei (1 ETH = `1000000000000000000`) |
66
+ | `--data <hex>` | no | `0x` | Calldata hex |
67
+ | `--reason <text>` | no | — | Shown to human |
68
+ | `--urgency <level>` | no | — | `low`, `medium`, or `high` |
69
+ | `--expected-outcome <text>` | no | — | What will happen |
70
+ | `--timeout <ms>` | no | `3600000` | How long to wait |
40
71
 
41
- | Option | Type | Description |
42
- |--------|------|-------------|
43
- | `agentName` | `string` | Display name shown to human (optional) |
44
- | `secretKey` | `Uint8Array` | 32-byte secret key to restore identity. Omit to generate new keypair. |
72
+ ### `status`
45
73
 
46
- ### Properties
74
+ Check current pairing status.
47
75
 
48
- | Property | Type | Description |
49
- |----------|------|-------------|
50
- | `publicKey` | `Uint8Array` | Agent's X25519 public key |
51
- | `secretKey` | `Uint8Array` | Agent's X25519 secret key (save this to persist identity) |
52
- | `agentId` | `string` | Derived agent identifier (`agent_<pubkey_prefix>`) |
53
- | `paired` | `boolean` | Whether `pair()` has been called |
76
+ ```bash
77
+ clanker-wallet status
78
+ # → {"agent_id":"agent_abc123","paired":true,"wallet":"0x...","relay":"wss://..."}
79
+ ```
80
+
81
+ ### `whoami`
54
82
 
55
- ### `wallet.pair(qrData: string): PairingData`
83
+ Show agent identity.
56
84
 
57
- Parse pairing JSON (from the web app's QR code) and store the human's public key and relay URL. Returns the parsed `PairingData`.
85
+ ```bash
86
+ clanker-wallet whoami
87
+ # → {"agent_id":"agent_abc123","public_key":"ab12cd..."}
88
+ ```
58
89
 
59
- ### `wallet.requestTx(options): Promise<string>`
90
+ ## Exit Codes
60
91
 
61
- Send a transaction request and wait for human response. Resolves with the transaction hash on approval. Throws on rejection, timeout, or connection error.
92
+ | Code | Meaning |
93
+ |------|---------|
94
+ | 0 | Success |
95
+ | 1 | Rejected by human |
96
+ | 2 | Timeout |
97
+ | 3 | Connection/relay error |
98
+ | 4 | Not paired |
99
+ | 5 | Invalid arguments |
62
100
 
63
- | Option | Type | Description |
64
- |--------|------|-------------|
65
- | `chain_id` | `number` | Target chain (1 = mainnet, 137 = polygon, etc) |
66
- | `transaction` | `{ to, value, data }` | Transaction parameters (`value` in wei) |
67
- | `context` | `{ reason?, urgency?, expected_outcome? }` | Optional context shown to human |
68
- | `timeout_ms` | `number` | Timeout in ms (default: 1 hour) |
101
+ ## State
69
102
 
70
- ## Persisting Agent Identity
103
+ Identity and pairing are persisted in `~/.clanker-wallet/`:
71
104
 
72
- Save `wallet.secretKey` between sessions to keep the same agent identity:
105
+ ```
106
+ ~/.clanker-wallet/
107
+ identity.json # agent keypair (auto-generated on first use)
108
+ pairing.json # human's public key + relay URL
109
+ ```
110
+
111
+ ## Common Chain IDs
112
+
113
+ | Chain | ID |
114
+ |-------|----|
115
+ | Ethereum | 1 |
116
+ | Polygon | 137 |
117
+ | Arbitrum | 42161 |
118
+ | Base | 8453 |
119
+ | Optimism | 10 |
120
+ | Sepolia (testnet) | 11155111 |
121
+
122
+ ---
123
+
124
+ ## Library Usage (Advanced)
125
+
126
+ The CLI wraps a TypeScript library. For programmatic use:
127
+
128
+ ```bash
129
+ npm install clanker-wallet
130
+ ```
73
131
 
74
132
  ```typescript
75
- import { writeFileSync, readFileSync, existsSync } from 'fs'
133
+ import { ClankerWallet } from 'clanker-wallet'
134
+
135
+ const wallet = new ClankerWallet({ agentName: 'my-bot' })
136
+ wallet.pair(pairingJson)
137
+
138
+ const txHash = await wallet.requestTx({
139
+ chain_id: 1,
140
+ transaction: { to: '0x...', value: '1000000000000000000', data: '0x' },
141
+ context: { reason: 'Buy tokens', urgency: 'medium' },
142
+ })
143
+ ```
76
144
 
77
- // Restore or create
78
- const secretKey = existsSync('.agent-key')
79
- ? new Uint8Array(readFileSync('.agent-key'))
80
- : undefined
145
+ See full API: [`src/`](./src/)
81
146
 
82
- const wallet = new ClankerWallet({ secretKey })
147
+ ## Python SDK
83
148
 
84
- // Save for next time
85
- if (!secretKey) writeFileSync('.agent-key', wallet.secretKey)
149
+ ```bash
150
+ pip install clanker-wallet
86
151
  ```
87
152
 
88
- ## Web App
153
+ ```python
154
+ from clanker_wallet import ClankerWallet, Transaction, RequestTxOptions, TxContext
155
+
156
+ agent = ClankerWallet()
157
+ agent.pair(pairing_json)
158
+
159
+ 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"),
163
+ ))
164
+ ```
165
+
166
+ ## How It Works
167
+
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
170
+ 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
172
+ 5. Human approves → wallet signs + broadcasts → agent gets the tx hash
89
173
 
90
- The human-facing web app is at [clanker-wallet.vercel.app](https://clanker-wallet.vercel.app). Connect a wallet, scan the QR code from your agent, and approve transactions.
174
+ All messages are encrypted end-to-end. The relay server never sees plaintext.
91
175
 
92
176
  ## License
93
177
 
package/SKILL.md ADDED
@@ -0,0 +1,147 @@
1
+ ---
2
+ name: clanker-wallet
3
+ description: "Send blockchain transactions through a human-approved wallet. You propose tx, human reviews and signs. No private keys needed."
4
+ homepage: https://clanker-wallet.xyz
5
+ ---
6
+
7
+ # Clanker Wallet — Human-Approved Transactions
8
+
9
+ You are an AI agent that needs to send blockchain transactions. You do NOT have a private key. Instead, you propose transactions and a human approves them from their own wallet.
10
+
11
+ ## Setup
12
+
13
+ ```bash
14
+ npm install -g clanker-wallet
15
+ ```
16
+
17
+ Or use without installing: `npx clanker-wallet` / `bunx clanker-wallet`
18
+
19
+ ## Pairing (one-time)
20
+
21
+ Before you can send transactions, you must pair with the human. Ask them:
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..."}
28
+ ```
29
+
30
+ Pair with it:
31
+ ```bash
32
+ clanker-wallet pair '{"version":1,"pubkey":"ab12...","relay":"wss://...","wallet":"0x..."}'
33
+ ```
34
+
35
+ This saves identity and pairing to `~/.clanker-wallet/`. You only need to do this once.
36
+
37
+ ### Transaction notifications
38
+
39
+ After pairing, ask the human:
40
+
41
+ > "Would you like me to notify you here when I need a transaction approved? I'll send you a message with a direct link to approve it."
42
+
43
+ If they agree, **every time you send a `tx` command**, immediately send a message through your current communication channel with:
44
+
45
+ 1. A short description of what the tx does (from the `--reason`)
46
+ 2. A direct link to the web app: **https://clanker-wallet.xyz**
47
+ 3. The urgency level
48
+
49
+ Example notification:
50
+ > Transaction awaiting your approval
51
+ > I'm requesting to send 0.001 ETH on Sepolia to a test address.
52
+ > Urgency: normal
53
+ > [Approve or reject here](https://clanker-wallet.xyz)
54
+
55
+ ## Sending a Transaction
56
+
57
+ ```bash
58
+ clanker-wallet tx \
59
+ --chain 1 \
60
+ --to 0xRecipientAddress \
61
+ --value 1000000000000000 \
62
+ --reason "Send 0.001 ETH to recipient" \
63
+ --urgency medium \
64
+ --timeout 120000
65
+ ```
66
+
67
+ The command **blocks until the human approves or rejects**. Output is JSON:
68
+ - Approved: `{"tx_hash":"0x...","status":"approved"}` (exit code 0)
69
+ - Rejected: `{"status":"rejected","error":"..."}` (exit code 1)
70
+ - Timeout: `{"status":"timeout","error":"..."}` (exit code 2)
71
+
72
+ ## Critical Details
73
+
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.
76
+ - **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
+
78
+ ## tx Flags
79
+
80
+ | Flag | Required | Default | Description |
81
+ |------|----------|---------|-------------|
82
+ | `--chain <id>` | yes | — | Chain ID (1=Ethereum, 137=Polygon, 8453=Base) |
83
+ | `--to <address>` | yes | — | Recipient address |
84
+ | `--value <wei>` | no | `0` | Value in wei |
85
+ | `--data <hex>` | no | `0x` | Calldata hex |
86
+ | `--reason <text>` | no | — | Shown to human |
87
+ | `--urgency <level>` | no | — | `low`, `medium`, or `high` |
88
+ | `--expected-outcome <text>` | no | — | What will happen |
89
+ | `--timeout <ms>` | no | `3600000` | How long to wait |
90
+
91
+ ## Common Chain IDs
92
+
93
+ | Chain | ID |
94
+ |-------|----|
95
+ | Ethereum | 1 |
96
+ | Polygon | 137 |
97
+ | Arbitrum | 42161 |
98
+ | Base | 8453 |
99
+ | Optimism | 10 |
100
+ | Sepolia (testnet) | 11155111 |
101
+
102
+ ## Common Patterns
103
+
104
+ ### Simple ETH transfer
105
+ ```bash
106
+ clanker-wallet tx --chain 1 --to 0xRecipientAddress --value 1000000000000000 --reason "Send 0.001 ETH"
107
+ ```
108
+
109
+ ### ERC-20 token transfer
110
+ ```bash
111
+ # transfer(address to, uint256 amount) — selector 0xa9059cbb
112
+ # encode to + amount as 32-byte padded hex
113
+ clanker-wallet tx \
114
+ --chain 1 \
115
+ --to 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48 \
116
+ --data 0xa9059cbb000000000000000000000000RecipientAddr00000000000000000000000000000000000000000000000000000000000f4240 \
117
+ --reason "Transfer 1 USDC"
118
+ ```
119
+
120
+ ### ERC-20 approval
121
+ ```bash
122
+ # approve(address spender, uint256 amount) — selector 0x095ea7b3
123
+ clanker-wallet tx \
124
+ --chain 1 \
125
+ --to 0xTokenContractAddress \
126
+ --data 0x095ea7b3000000000000000000000000SpenderAddr0000000000000000000000000000000000000000000000000000000000000f4240 \
127
+ --reason "Approve 1 USDC spend for DeFi protocol"
128
+ ```
129
+
130
+ ## Other Commands
131
+
132
+ ```bash
133
+ # Check pairing status
134
+ clanker-wallet status
135
+ # → {"agent_id":"agent_abc123","paired":true,"wallet":"0x...","relay":"wss://..."}
136
+
137
+ # Show agent identity
138
+ clanker-wallet whoami
139
+ # → {"agent_id":"agent_abc123","public_key":"ab12cd..."}
140
+ ```
141
+
142
+ ## Troubleshooting
143
+
144
+ - **exit code 4 / "not paired"** — run `clanker-wallet pair '<JSON>'` first
145
+ - **exit code 2 / timeout** — human didn't respond in time, increase `--timeout` or ask them to check the web app
146
+ - **exit code 1 / "rejected"** — human clicked reject. Explain why the tx is needed and try again if appropriate.
147
+ - **exit code 3 / connection error** — relay may be down, try again in a few seconds
package/bin/cli.js ADDED
@@ -0,0 +1,257 @@
1
+ #!/usr/bin/env node
2
+ import { ClankerWallet } from '../dist/index.js'
3
+ import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'fs'
4
+ import { join } from 'path'
5
+ import { homedir } from 'os'
6
+
7
+ // ── constants ────────────────────────────────────────────────────────
8
+ const STATE_DIR = join(homedir(), '.clanker-wallet')
9
+ const IDENTITY_FILE = join(STATE_DIR, 'identity.json')
10
+ const PAIRING_FILE = join(STATE_DIR, 'pairing.json')
11
+
12
+ const EXIT = { OK: 0, REJECTED: 1, TIMEOUT: 2, CONNECTION: 3, NOT_PAIRED: 4, BAD_ARGS: 5 }
13
+
14
+ // ── helpers ──────────────────────────────────────────────────────────
15
+ const hex = (bytes) => Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join('')
16
+ const fromHexStr = (h) => new Uint8Array(h.match(/.{2}/g).map(b => parseInt(b, 16)))
17
+
18
+ function die(msg, code) {
19
+ process.stderr.write(msg + '\n')
20
+ process.exit(code)
21
+ }
22
+
23
+ function out(obj) {
24
+ process.stdout.write(JSON.stringify(obj) + '\n')
25
+ }
26
+
27
+ function ensureDir() {
28
+ if (!existsSync(STATE_DIR)) mkdirSync(STATE_DIR, { recursive: true })
29
+ }
30
+
31
+ function loadIdentity() {
32
+ if (!existsSync(IDENTITY_FILE)) return null
33
+ try { return JSON.parse(readFileSync(IDENTITY_FILE, 'utf8')) }
34
+ catch { return null }
35
+ }
36
+
37
+ function saveIdentity(data) {
38
+ ensureDir()
39
+ writeFileSync(IDENTITY_FILE, JSON.stringify(data, null, 2) + '\n')
40
+ }
41
+
42
+ function loadPairing() {
43
+ if (!existsSync(PAIRING_FILE)) return null
44
+ try { return JSON.parse(readFileSync(PAIRING_FILE, 'utf8')) }
45
+ catch { return null }
46
+ }
47
+
48
+ function savePairing(data) {
49
+ ensureDir()
50
+ writeFileSync(PAIRING_FILE, JSON.stringify(data, null, 2) + '\n')
51
+ }
52
+
53
+ /** restore or create a ClankerWallet with persisted identity */
54
+ function getWallet() {
55
+ const identity = loadIdentity()
56
+ const secretKey = identity ? fromHexStr(identity.secret_key) : undefined
57
+ const wallet = new ClankerWallet({ secretKey })
58
+ if (!identity) {
59
+ saveIdentity({ secret_key: hex(wallet.secretKey), agent_id: wallet.agentId })
60
+ }
61
+ return wallet
62
+ }
63
+
64
+ // ── arg parsing ──────────────────────────────────────────────────────
65
+ function parseArgs(argv) {
66
+ const args = argv.slice(2)
67
+ const cmd = args[0]
68
+ const positional = []
69
+ const flags = {}
70
+
71
+ for (let i = 1; i < args.length; i++) {
72
+ const a = args[i]
73
+ if (a.startsWith('--')) {
74
+ const key = a.slice(2)
75
+ const next = args[i + 1]
76
+ if (next && !next.startsWith('--')) {
77
+ flags[key] = next
78
+ i++
79
+ } else {
80
+ flags[key] = true
81
+ }
82
+ } else {
83
+ positional.push(a)
84
+ }
85
+ }
86
+
87
+ return { cmd, positional, flags }
88
+ }
89
+
90
+ // ── commands ─────────────────────────────────────────────────────────
91
+ function cmdPair(positional) {
92
+ const jsonStr = positional[0]
93
+ if (!jsonStr) {
94
+ die('usage: clanker-wallet pair \'<pairing JSON from web app>\'', EXIT.BAD_ARGS)
95
+ }
96
+
97
+ const wallet = getWallet()
98
+
99
+ let pairingData
100
+ try {
101
+ pairingData = wallet.pair(jsonStr)
102
+ } catch (err) {
103
+ die(`pair failed: ${err.message}`, EXIT.BAD_ARGS)
104
+ }
105
+
106
+ savePairing({
107
+ pubkey: pairingData.pubkey,
108
+ relay: pairingData.relay,
109
+ wallet: pairingData.wallet || null,
110
+ mode: pairingData.mode || null,
111
+ })
112
+
113
+ out({
114
+ agent_id: wallet.agentId,
115
+ status: 'paired',
116
+ wallet: pairingData.wallet || null,
117
+ relay: pairingData.relay,
118
+ })
119
+ }
120
+
121
+ async function cmdTx(flags) {
122
+ const pairing = loadPairing()
123
+ if (!pairing) die('not paired — run: clanker-wallet pair \'<JSON>\'', EXIT.NOT_PAIRED)
124
+
125
+ const chain = flags.chain
126
+ const to = flags.to
127
+ if (!chain) die('missing --chain', EXIT.BAD_ARGS)
128
+ if (!to) die('missing --to', EXIT.BAD_ARGS)
129
+
130
+ const chainId = parseInt(chain, 10)
131
+ if (isNaN(chainId)) die('--chain must be a number', EXIT.BAD_ARGS)
132
+
133
+ const value = flags.value || '0'
134
+ const data = flags.data || '0x'
135
+ const timeout = flags.timeout ? parseInt(flags.timeout, 10) : 3_600_000
136
+
137
+ const context = {}
138
+ if (flags.reason) context.reason = flags.reason
139
+ if (flags.urgency) context.urgency = flags.urgency
140
+ if (flags['expected-outcome']) context.expected_outcome = flags['expected-outcome']
141
+
142
+ const wallet = getWallet()
143
+ const pairingJson = JSON.stringify({
144
+ version: 1,
145
+ pubkey: pairing.pubkey,
146
+ relay: pairing.relay,
147
+ wallet: pairing.wallet,
148
+ })
149
+ wallet.pair(pairingJson)
150
+
151
+ try {
152
+ const txHash = await wallet.requestTx({
153
+ chain_id: chainId,
154
+ transaction: { to, value, data },
155
+ context: Object.keys(context).length > 0 ? context : undefined,
156
+ timeout_ms: timeout,
157
+ })
158
+ out({ tx_hash: txHash, status: 'approved' })
159
+ process.exit(EXIT.OK)
160
+ } catch (err) {
161
+ const msg = err.message || String(err)
162
+ if (msg.includes('rejected')) {
163
+ out({ status: 'rejected', error: msg })
164
+ process.exit(EXIT.REJECTED)
165
+ } else if (msg.includes('timed out')) {
166
+ out({ status: 'timeout', error: msg })
167
+ process.exit(EXIT.TIMEOUT)
168
+ } else {
169
+ out({ status: 'error', error: msg })
170
+ process.exit(EXIT.CONNECTION)
171
+ }
172
+ }
173
+ }
174
+
175
+ function cmdStatus() {
176
+ const identity = loadIdentity()
177
+ const pairing = loadPairing()
178
+
179
+ out({
180
+ agent_id: identity?.agent_id || null,
181
+ paired: !!pairing,
182
+ wallet: pairing?.wallet || null,
183
+ relay: pairing?.relay || null,
184
+ })
185
+ }
186
+
187
+ function cmdWhoami() {
188
+ const wallet = getWallet()
189
+ out({
190
+ agent_id: wallet.agentId,
191
+ public_key: hex(wallet.publicKey),
192
+ })
193
+ }
194
+
195
+ function showHelp() {
196
+ process.stderr.write(`clanker-wallet — human-approved transactions for AI agents
197
+
198
+ commands:
199
+ pair '<JSON>' pair with human's web app (one-time setup)
200
+ tx send a transaction (blocks until approved/rejected)
201
+ status check pairing status
202
+ whoami show agent identity
203
+
204
+ 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)
213
+
214
+ exit codes:
215
+ 0 success
216
+ 1 rejected by human
217
+ 2 timeout
218
+ 3 connection error
219
+ 4 not paired
220
+ 5 invalid arguments
221
+
222
+ examples:
223
+ clanker-wallet pair '{"version":1,"pubkey":"ab12...","relay":"wss://...","wallet":"0x..."}'
224
+ clanker-wallet tx --chain 1 --to 0xRecipient --value 1000000000000000000 --reason "swap ETH"
225
+ clanker-wallet status
226
+ clanker-wallet whoami
227
+
228
+ docs: https://github.com/almogdepaz/clanker-wallet
229
+ web: https://clanker-wallet.xyz
230
+ `)
231
+ }
232
+
233
+ // ── main ─────────────────────────────────────────────────────────────
234
+ const { cmd, positional, flags } = parseArgs(process.argv)
235
+
236
+ switch (cmd) {
237
+ case 'pair':
238
+ cmdPair(positional)
239
+ break
240
+ case 'tx':
241
+ await cmdTx(flags)
242
+ break
243
+ case 'status':
244
+ cmdStatus()
245
+ break
246
+ case 'whoami':
247
+ cmdWhoami()
248
+ break
249
+ case 'help':
250
+ case '--help':
251
+ case '-h':
252
+ showHelp()
253
+ break
254
+ default:
255
+ showHelp()
256
+ break
257
+ }
package/dist/client.d.ts CHANGED
@@ -13,11 +13,9 @@ export declare class ClankerWallet extends TypedEmitter {
13
13
  /** Ed25519 public key for signing TxRequests */
14
14
  readonly signingPublicKey: Uint8Array;
15
15
  private readonly signingSecretKey;
16
- private humanPubkey;
17
- private humanPubkeyHex;
18
- private relayUrl;
19
- private humanWallet;
20
- private mode;
16
+ private readonly agentPubkeyHex;
17
+ private readonly signingPubkeyHex;
18
+ private pairing;
21
19
  constructor(options?: ClankerWalletOptions);
22
20
  /** whether this agent has been paired with a human */
23
21
  get paired(): boolean;
package/dist/client.js CHANGED
@@ -1,4 +1,4 @@
1
- import { encryptMessage, decryptMessage, toHex, fromHex, generateKeypair, keypairFromSecret, signingKeypairFromSeed, signMessage } from './crypto.js';
1
+ import { decryptMessage, encryptMessage, fromHex, generateKeypair, keypairFromSecret, signMessage, signingKeypairFromSeed, toHex } from './crypto.js';
2
2
  import { TypedEmitter } from './events.js';
3
3
  const DEFAULT_TIMEOUT_MS = 3_600_000; // 1 hour
4
4
  export class ClankerWallet extends TypedEmitter {
@@ -8,11 +8,9 @@ export class ClankerWallet extends TypedEmitter {
8
8
  /** Ed25519 public key for signing TxRequests */
9
9
  signingPublicKey;
10
10
  signingSecretKey;
11
- humanPubkey = null;
12
- humanPubkeyHex = null;
13
- relayUrl = null;
14
- humanWallet = null;
15
- mode = 'encrypted';
11
+ agentPubkeyHex;
12
+ signingPubkeyHex;
13
+ pairing = null;
16
14
  constructor(options = {}) {
17
15
  super();
18
16
  const kp = options.secretKey
@@ -21,14 +19,15 @@ export class ClankerWallet extends TypedEmitter {
21
19
  this.publicKey = kp.publicKey;
22
20
  this.secretKey = kp.secretKey;
23
21
  this.agentId = `agent_${toHex(kp.publicKey).slice(0, 12)}`;
24
- // derive Ed25519 signing keypair from the x25519 secret key (first 32 bytes = seed)
22
+ this.agentPubkeyHex = toHex(kp.publicKey);
25
23
  const signingKp = signingKeypairFromSeed(kp.secretKey);
26
24
  this.signingPublicKey = signingKp.publicKey;
27
25
  this.signingSecretKey = signingKp.secretKey;
26
+ this.signingPubkeyHex = toHex(signingKp.publicKey);
28
27
  }
29
28
  /** whether this agent has been paired with a human */
30
29
  get paired() {
31
- return this.humanPubkey !== null;
30
+ return this.pairing !== null;
32
31
  }
33
32
  /** parse QR/pairing data and store the human's pubkey + relay url */
34
33
  pair(qrData) {
@@ -39,11 +38,12 @@ export class ClankerWallet extends TypedEmitter {
39
38
  if (!data.pubkey || !data.relay) {
40
39
  throw new Error('invalid pairing data: missing pubkey or relay');
41
40
  }
42
- this.humanPubkey = fromHex(data.pubkey);
43
- this.humanPubkeyHex = data.pubkey;
44
- this.relayUrl = data.relay;
45
- this.humanWallet = data.wallet || null;
46
- this.mode = data.mode || 'encrypted';
41
+ this.pairing = {
42
+ humanPubkey: fromHex(data.pubkey),
43
+ humanPubkeyHex: data.pubkey,
44
+ relayUrl: data.relay,
45
+ humanWallet: data.wallet || null,
46
+ };
47
47
  this.emit('paired', { relay: data.relay, wallet: data.wallet || '' });
48
48
  return data;
49
49
  }
@@ -53,9 +53,10 @@ export class ClankerWallet extends TypedEmitter {
53
53
  * throws on rejection, timeout, or connection error.
54
54
  */
55
55
  async requestTx(options) {
56
- if (!this.humanPubkey || !this.humanPubkeyHex || !this.relayUrl) {
56
+ if (!this.pairing) {
57
57
  throw new Error('not paired — call pair() first');
58
58
  }
59
+ const { humanPubkey, humanPubkeyHex, relayUrl } = this.pairing;
59
60
  const timeoutMs = options.timeout_ms ?? DEFAULT_TIMEOUT_MS;
60
61
  const requestId = `req_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
61
62
  const txRequest = {
@@ -67,17 +68,7 @@ export class ClankerWallet extends TypedEmitter {
67
68
  context: options.context,
68
69
  signature: '', // set below after signing
69
70
  };
70
- // sign the request (signature field empty during signing, then filled)
71
- const sigPayload = JSON.stringify(txRequest);
72
- txRequest.signature = signMessage(sigPayload, this.signingSecretKey);
73
- const humanPubkey = this.humanPubkey;
74
- const humanPubkeyHex = this.humanPubkeyHex;
75
- const relayUrl = this.relayUrl;
76
- const secretKey = this.secretKey;
77
- const agentPubkeyHex = toHex(this.publicKey);
78
- const signingPubkeyHex = toHex(this.signingPublicKey);
79
- const mode = this.mode;
80
- const humanWallet = this.humanWallet;
71
+ txRequest.signature = signMessage(JSON.stringify(txRequest), this.signingSecretKey);
81
72
  return new Promise((resolve, reject) => {
82
73
  let ws;
83
74
  let timer;
@@ -89,11 +80,14 @@ export class ClankerWallet extends TypedEmitter {
89
80
  ws.close();
90
81
  }
91
82
  };
83
+ const fail = (message) => {
84
+ cleanup();
85
+ this.emit('error', { message });
86
+ reject(new Error(message));
87
+ };
92
88
  timer = setTimeout(() => {
93
89
  if (!settled) {
94
- cleanup();
95
- this.emit('error', { message: `request ${requestId} timed out after ${timeoutMs}ms` });
96
- reject(new Error(`request ${requestId} timed out after ${timeoutMs}ms`));
90
+ fail(`request ${requestId} timed out after ${timeoutMs}ms`);
97
91
  }
98
92
  }, timeoutMs);
99
93
  this.emit('connecting', { relay: relayUrl });
@@ -101,17 +95,12 @@ export class ClankerWallet extends TypedEmitter {
101
95
  ws = new WebSocket(relayUrl);
102
96
  }
103
97
  catch (err) {
104
- cleanup();
105
- this.emit('error', { message: `failed to connect to relay: ${err}` });
106
- reject(new Error(`failed to connect to relay: ${err}`));
98
+ fail(`failed to connect to relay: ${err}`);
107
99
  return;
108
100
  }
109
101
  ws.addEventListener('error', () => {
110
- if (!settled) {
111
- cleanup();
112
- this.emit('error', { message: 'relay connection error' });
113
- reject(new Error('relay connection error'));
114
- }
102
+ if (!settled)
103
+ fail('relay connection error');
115
104
  });
116
105
  ws.addEventListener('close', () => {
117
106
  this.emit('disconnected', {});
@@ -121,7 +110,6 @@ export class ClankerWallet extends TypedEmitter {
121
110
  }
122
111
  });
123
112
  ws.addEventListener('open', () => {
124
- // join the human's room
125
113
  ws.send(JSON.stringify({ type: 'join', room: humanPubkeyHex }));
126
114
  });
127
115
  ws.addEventListener('message', (event) => {
@@ -132,46 +120,33 @@ export class ClankerWallet extends TypedEmitter {
132
120
  msg = JSON.parse(event.data);
133
121
  }
134
122
  catch {
135
- return; // ignore non-JSON
123
+ return;
136
124
  }
137
125
  if (msg.type === 'joined') {
138
126
  this.emit('connected', { room: humanPubkeyHex });
139
- // room joined — send the encrypted tx request
140
- const plaintext = JSON.stringify(txRequest);
141
- const ciphertext = encryptMessage(plaintext, humanPubkey, secretKey);
127
+ const ciphertext = encryptMessage(JSON.stringify(txRequest), humanPubkey, this.secretKey);
142
128
  const envelope = {
143
- sender_pubkey: agentPubkeyHex,
129
+ sender_pubkey: this.agentPubkeyHex,
144
130
  ciphertext,
145
- signing_pubkey: signingPubkeyHex,
131
+ signing_pubkey: this.signingPubkeyHex,
146
132
  };
147
- const wsMsg = {
133
+ ws.send(JSON.stringify({
148
134
  type: 'message',
149
135
  room: humanPubkeyHex,
150
136
  payload: JSON.stringify(envelope),
151
- };
152
- if (mode === 'managed') {
153
- wsMsg.simulate = {
154
- from: humanWallet || '',
155
- to: options.transaction.to,
156
- value: options.transaction.value,
157
- data: options.transaction.data,
158
- chainId: options.chain_id,
159
- };
160
- }
161
- ws.send(JSON.stringify(wsMsg));
137
+ }));
162
138
  this.emit('request_sent', { request_id: requestId });
163
139
  return;
164
140
  }
165
141
  if (msg.type === 'message') {
166
- // try to decrypt as TxResponse
167
142
  try {
168
143
  const envelope = JSON.parse(msg.payload);
169
- const plaintext = decryptMessage(envelope.ciphertext, fromHex(envelope.sender_pubkey), secretKey);
144
+ const plaintext = decryptMessage(envelope.ciphertext, fromHex(envelope.sender_pubkey), this.secretKey);
170
145
  if (!plaintext)
171
- return; // not for us or decryption failed
146
+ return;
172
147
  const response = JSON.parse(plaintext);
173
148
  if (response.request_id !== requestId)
174
- return; // not our response
149
+ return;
175
150
  this.emit('response', {
176
151
  request_id: response.request_id,
177
152
  status: response.status,
@@ -194,11 +169,10 @@ export class ClankerWallet extends TypedEmitter {
194
169
  catch {
195
170
  // ignore messages that aren't valid envelopes
196
171
  }
172
+ return;
197
173
  }
198
174
  if (msg.type === 'error') {
199
- cleanup();
200
- this.emit('error', { message: `relay error: ${msg.message}` });
201
- reject(new Error(`relay error: ${msg.message}`));
175
+ fail(`relay error: ${msg.message}`);
202
176
  }
203
177
  });
204
178
  });
package/dist/crypto.d.ts CHANGED
@@ -17,4 +17,7 @@ export declare function signingKeypairFromSeed(seed: Uint8Array): nacl.SignKeyPa
17
17
  export declare function signMessage(message: string, signingSecretKey: Uint8Array): string;
18
18
  /** verify a detached Ed25519 signature. signature is base64-encoded. */
19
19
  export declare function verifySignature(message: string, base64Signature: string, signingPublicKey: Uint8Array): boolean;
20
+ /** base64 string → Uint8Array */
21
+ export declare function base64ToBytes(b64: string): Uint8Array;
22
+ /** Uint8Array → base64 string */
20
23
  export declare function bytesToBase64(bytes: Uint8Array): string;
package/dist/crypto.js CHANGED
@@ -1,4 +1,6 @@
1
1
  import nacl from 'tweetnacl';
2
+ const encoder = new TextEncoder();
3
+ const decoder = new TextDecoder();
2
4
  /** generate a random x25519 keypair for the agent's identity */
3
5
  export function generateKeypair() {
4
6
  return nacl.box.keyPair();
@@ -10,8 +12,7 @@ export function keypairFromSecret(secretKey) {
10
12
  /** encrypt a message using nacl.box. returns base64(nonce + ciphertext). */
11
13
  export function encryptMessage(message, recipientPublicKey, senderSecretKey) {
12
14
  const nonce = nacl.randomBytes(nacl.box.nonceLength);
13
- const encoded = new TextEncoder().encode(message);
14
- const ciphertext = nacl.box(encoded, nonce, recipientPublicKey, senderSecretKey);
15
+ const ciphertext = nacl.box(encoder.encode(message), nonce, recipientPublicKey, senderSecretKey);
15
16
  const combined = new Uint8Array(nonce.length + ciphertext.length);
16
17
  combined.set(nonce);
17
18
  combined.set(ciphertext, nonce.length);
@@ -27,7 +28,7 @@ export function decryptMessage(base64Payload, senderPublicKey, recipientSecretKe
27
28
  const plaintext = nacl.box.open(ciphertext, nonce, senderPublicKey, recipientSecretKey);
28
29
  if (!plaintext)
29
30
  return null;
30
- return new TextDecoder().decode(plaintext);
31
+ return decoder.decode(plaintext);
31
32
  }
32
33
  /** hex-encode bytes (no 0x prefix) */
33
34
  export function toHex(bytes) {
@@ -50,17 +51,16 @@ export function signingKeypairFromSeed(seed) {
50
51
  }
51
52
  /** sign a message string with an Ed25519 secret key. returns base64(signature). */
52
53
  export function signMessage(message, signingSecretKey) {
53
- const encoded = new TextEncoder().encode(message);
54
- const signature = nacl.sign.detached(encoded, signingSecretKey);
54
+ const signature = nacl.sign.detached(encoder.encode(message), signingSecretKey);
55
55
  return bytesToBase64(signature);
56
56
  }
57
57
  /** verify a detached Ed25519 signature. signature is base64-encoded. */
58
58
  export function verifySignature(message, base64Signature, signingPublicKey) {
59
- const encoded = new TextEncoder().encode(message);
60
59
  const signature = base64ToBytes(base64Signature);
61
- return nacl.sign.detached.verify(encoded, signature, signingPublicKey);
60
+ return nacl.sign.detached.verify(encoder.encode(message), signature, signingPublicKey);
62
61
  }
63
- function base64ToBytes(b64) {
62
+ /** base64 string → Uint8Array */
63
+ export function base64ToBytes(b64) {
64
64
  const binary = atob(b64);
65
65
  const bytes = new Uint8Array(binary.length);
66
66
  for (let i = 0; i < binary.length; i++) {
@@ -68,6 +68,7 @@ function base64ToBytes(b64) {
68
68
  }
69
69
  return bytes;
70
70
  }
71
+ /** Uint8Array → base64 string */
71
72
  export function bytesToBase64(bytes) {
72
73
  let binary = '';
73
74
  for (let i = 0; i < bytes.length; i++) {
package/dist/events.js CHANGED
@@ -1,12 +1,13 @@
1
1
  export class TypedEmitter {
2
2
  _listeners = new Map();
3
3
  on(event, handler) {
4
- let set = this._listeners.get(event);
5
- if (!set) {
6
- set = new Set();
7
- this._listeners.set(event, set);
4
+ const existing = this._listeners.get(event);
5
+ if (existing) {
6
+ existing.add(handler);
7
+ }
8
+ else {
9
+ this._listeners.set(event, new Set([handler]));
8
10
  }
9
- set.add(handler);
10
11
  return this;
11
12
  }
12
13
  off(event, handler) {
package/dist/index.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  export { ClankerWallet } from './client.js';
2
2
  export type { ClankerWalletOptions } from './client.js';
3
+ export { base64ToBytes, bytesToBase64, decryptMessage, encryptMessage, fromHex, generateKeypair, keypairFromSecret, signMessage, signingKeypairFromSeed, toHex, verifySignature } from './crypto.js';
3
4
  export { TypedEmitter } from './events.js';
4
5
  export type { EventMap } from './events.js';
5
- export { generateKeypair, keypairFromSecret, encryptMessage, decryptMessage, toHex, fromHex, signingKeypairFromSeed, signMessage, verifySignature, bytesToBase64 } from './crypto.js';
6
- export type { Transaction, TxContext, TxRequest, TxResponse, AgentEnvelope, PairingData, RequestTxOptions, } from './types.js';
6
+ export type { AgentEnvelope, PairingData, RequestTxOptions, Transaction, TxContext, TxRequest, TxResponse, } from './types.js';
package/dist/index.js CHANGED
@@ -1,3 +1,3 @@
1
1
  export { ClankerWallet } from './client.js';
2
+ export { base64ToBytes, bytesToBase64, decryptMessage, encryptMessage, fromHex, generateKeypair, keypairFromSecret, signMessage, signingKeypairFromSeed, toHex, verifySignature } from './crypto.js';
2
3
  export { TypedEmitter } from './events.js';
3
- export { generateKeypair, keypairFromSecret, encryptMessage, decryptMessage, toHex, fromHex, signingKeypairFromSeed, signMessage, verifySignature, bytesToBase64 } from './crypto.js';
package/dist/types.d.ts CHANGED
@@ -40,8 +40,6 @@ export interface PairingData {
40
40
  pubkey: string;
41
41
  relay: string;
42
42
  wallet: string;
43
- /** 'managed' = relay simulates server-side; 'encrypted' = user provides own keys */
44
- mode?: 'encrypted' | 'managed';
45
43
  }
46
44
  /** options for requestTx */
47
45
  export interface RequestTxOptions {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clanker-wallet",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -10,7 +10,10 @@
10
10
  "types": "./dist/index.d.ts"
11
11
  }
12
12
  },
13
- "files": ["dist", "README.md", "LICENSE"],
13
+ "bin": {
14
+ "clanker-wallet": "bin/cli.js"
15
+ },
16
+ "files": ["dist", "bin", "SKILL.md", "README.md", "LICENSE"],
14
17
  "license": "MIT",
15
18
  "author": "Clanker Wallet Contributors",
16
19
  "repository": {
@@ -31,9 +34,7 @@
31
34
  },
32
35
  "devDependencies": {
33
36
  "@types/bun": "^1.3.8",
34
- "@types/ws": "^8.18.1",
35
37
  "typescript": "^5.4.0",
36
- "vitest": "^2.1.0",
37
- "ws": "^8.19.0"
38
+ "vitest": "^2.1.0"
38
39
  }
39
40
  }