clanker-wallet 0.4.2 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +84 -9
- package/SKILL.md +54 -4
- package/bin/cli.js +208 -47
- package/dist/bech32.d.ts +27 -0
- package/dist/bech32.js +111 -0
- package/dist/client.d.ts +14 -1
- package/dist/client.js +90 -20
- package/dist/index.d.ts +3 -3
- package/dist/index.js +2 -2
- package/dist/mcp.d.ts +156 -0
- package/dist/mcp.js +370 -0
- package/dist/types.d.ts +52 -0
- package/dist/validate.d.ts +13 -2
- package/dist/validate.js +63 -0
- package/package.json +2 -1
package/dist/mcp.js
ADDED
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
import { RelayError } from './client.js';
|
|
2
|
+
import { InvalidTxError, validateTransfer, validateTx } from './validate.js';
|
|
3
|
+
/**
|
|
4
|
+
* MCP server for `clanker-wallet mcp`: newline-delimited JSON-RPC 2.0 over stdio, hand-written so
|
|
5
|
+
* tweetnacl stays the only runtime dependency. the cli owns stdin/stdout and the identity and pairing
|
|
6
|
+
* files; this module answers one line at a time and keeps each request's state in memory.
|
|
7
|
+
*/
|
|
8
|
+
/** newest first; a client asking for another version gets the newest. not 2025-03-26: it requires receiving batches, which we refuse */
|
|
9
|
+
export const SUPPORTED_PROTOCOL_VERSIONS = ['2025-11-25', '2025-06-18', '2024-11-05'];
|
|
10
|
+
/** get_request waits at most this long per call; assistants time tool calls out after about a minute */
|
|
11
|
+
export const MAX_WAIT_SECONDS = 50;
|
|
12
|
+
/** the relay buffers room messages for 1 h, so a later answer can't reach this process */
|
|
13
|
+
const DEFAULT_REQUEST_TTL_MS = 3_600_000;
|
|
14
|
+
/** a relay that hasn't confirmed the join by then fails the request with nothing sent, inside the hosts' 60 s tool-call timeout */
|
|
15
|
+
const DEFAULT_JOIN_TIMEOUT_MS = 20_000;
|
|
16
|
+
const REQUEST_FAMILIES = ['evm', 'bitcoin'];
|
|
17
|
+
const URGENCIES = ['low', 'medium', 'high'];
|
|
18
|
+
const CONTEXT_PROPERTIES = {
|
|
19
|
+
reason: { type: 'string', description: 'why you want this, in plain words; shown to the human on the review card' },
|
|
20
|
+
urgency: { type: 'string', enum: URGENCIES, description: 'priority hint shown to the human' },
|
|
21
|
+
expected_outcome: { type: 'string', description: 'what will happen once it is sent' },
|
|
22
|
+
};
|
|
23
|
+
export const MCP_TOOLS = [
|
|
24
|
+
{
|
|
25
|
+
name: 'request_evm_tx',
|
|
26
|
+
description: 'Ask the human to send an EVM transaction (ETH or a contract call) from their own wallet. ' +
|
|
27
|
+
'Nothing is signed here: the human approves every request on their own device, or rejects it. ' +
|
|
28
|
+
'Returns { request_id } once the request is delivered; then call get_request for the outcome. ' +
|
|
29
|
+
'value is an integer string in wei (1 ETH = "1000000000000000000"), no decimals.',
|
|
30
|
+
inputSchema: {
|
|
31
|
+
type: 'object',
|
|
32
|
+
properties: {
|
|
33
|
+
chain_id: { type: 'integer', description: 'EVM chain id: 1 Ethereum, 8453 Base, 84532 Base Sepolia, 10 Optimism, 42161 Arbitrum, 137 Polygon' },
|
|
34
|
+
to: { type: 'string', description: 'recipient or contract: 0x followed by 40 hex characters (mixed case must be EIP-55 checksummed)' },
|
|
35
|
+
value: { type: 'string', description: 'integer string in wei; "0" for a contract call that sends no ETH' },
|
|
36
|
+
data: { type: 'string', description: 'hex calldata, default "0x" (a plain transfer)' },
|
|
37
|
+
...CONTEXT_PROPERTIES,
|
|
38
|
+
},
|
|
39
|
+
required: ['chain_id', 'to', 'value', 'reason'],
|
|
40
|
+
},
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
name: 'request_transfer',
|
|
44
|
+
description: 'Ask the human to send bitcoin from their own wallet, which builds, signs and broadcasts it. ' +
|
|
45
|
+
'Nothing is signed here: the human approves every request on their own device, or rejects it. ' +
|
|
46
|
+
'Returns { request_id } once the request is delivered; then call get_request for the outcome. ' +
|
|
47
|
+
'amount is an integer string in sats (1 BTC = "100000000"), no decimals; the wallet sets the fee.',
|
|
48
|
+
inputSchema: {
|
|
49
|
+
type: 'object',
|
|
50
|
+
properties: {
|
|
51
|
+
family: { type: 'string', enum: ['bitcoin'], description: 'chain family' },
|
|
52
|
+
network: { type: 'string', enum: ['mainnet', 'testnet', 'signet'], description: 'must match the paired wallet (see list_pairings)' },
|
|
53
|
+
to: { type: 'string', description: 'segwit address on that network: bc1... on mainnet, tb1... on testnet and signet' },
|
|
54
|
+
amount: { type: 'string', description: 'integer string in sats' },
|
|
55
|
+
...CONTEXT_PROPERTIES,
|
|
56
|
+
},
|
|
57
|
+
required: ['family', 'network', 'to', 'amount', 'reason'],
|
|
58
|
+
},
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
name: 'get_request',
|
|
62
|
+
description: 'Get the outcome of a request: pending, approved (with tx_hash), rejected, failed (with error), unknown (with error) or expired. ' +
|
|
63
|
+
`The human may take minutes to answer: pass wait_seconds (at most ${MAX_WAIT_SECONDS}) to wait for it, and call again while it is pending. ` +
|
|
64
|
+
'unknown means the request was delivered but this server lost its relay connection: the human may still approve it, so ask them before sending it again. ' +
|
|
65
|
+
'expired means this server stopped waiting, 1 hour after sending; after a restart it no longer knows earlier request ids. ' +
|
|
66
|
+
'In both cases the human may still approve it in their wallet, so ask them before sending it again.',
|
|
67
|
+
inputSchema: {
|
|
68
|
+
type: 'object',
|
|
69
|
+
properties: {
|
|
70
|
+
request_id: { type: 'string', description: 'from request_evm_tx or request_transfer' },
|
|
71
|
+
wait_seconds: { type: 'number', minimum: 0, maximum: MAX_WAIT_SECONDS, description: 'wait up to this long for an answer (default 0)' },
|
|
72
|
+
},
|
|
73
|
+
required: ['request_id'],
|
|
74
|
+
},
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
name: 'list_pairings',
|
|
78
|
+
description: 'List the wallets you can request on (chain family, network, wallet address, mode) and your agent fingerprint, ' +
|
|
79
|
+
'which the human compares in the web app before trusting you.',
|
|
80
|
+
inputSchema: { type: 'object', properties: {} },
|
|
81
|
+
},
|
|
82
|
+
];
|
|
83
|
+
const INSTRUCTIONS = 'clanker-wallet sends transaction requests to a human, who approves or rejects each one in their own wallet; ' +
|
|
84
|
+
'you never hold keys. Call list_pairings to see which chains you can use, request_evm_tx or request_transfer to ask, ' +
|
|
85
|
+
'and get_request (with wait_seconds) until the request is no longer pending.';
|
|
86
|
+
/** what get_request reports, in the order the docs list them */
|
|
87
|
+
export const REQUEST_STATUSES = ['pending', 'approved', 'rejected', 'failed', 'unknown', 'expired'];
|
|
88
|
+
const STILL_APPROVABLE = 'the request was delivered, so the human may still approve it in their wallet: ask them before sending it again';
|
|
89
|
+
/** a JSON-RPC error answered to the client */
|
|
90
|
+
class RpcError extends Error {
|
|
91
|
+
code;
|
|
92
|
+
constructor(code, message) {
|
|
93
|
+
super(message);
|
|
94
|
+
this.code = code;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
const PARSE_ERROR = -32700;
|
|
98
|
+
const INVALID_REQUEST = -32600;
|
|
99
|
+
const METHOD_NOT_FOUND = -32601;
|
|
100
|
+
const INVALID_PARAMS = -32602;
|
|
101
|
+
const INTERNAL_ERROR = -32603;
|
|
102
|
+
/** a tool's input was refused; reported as a tool result with isError, nothing sent */
|
|
103
|
+
class ToolInputError extends Error {
|
|
104
|
+
}
|
|
105
|
+
const toolResult = (body) => ({ content: [{ type: 'text', text: JSON.stringify(body) }] });
|
|
106
|
+
const toolError = (message) => ({ content: [{ type: 'text', text: message }], isError: true });
|
|
107
|
+
const isObject = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
108
|
+
/** reason (required, non-blank), urgency and expected_outcome from tool arguments */
|
|
109
|
+
function contextFrom(args) {
|
|
110
|
+
const { reason, urgency, expected_outcome } = args;
|
|
111
|
+
if (typeof reason !== 'string' || !reason.trim())
|
|
112
|
+
throw new ToolInputError('invalid reason: say why, in plain words; the human sees it');
|
|
113
|
+
const context = { reason };
|
|
114
|
+
if (urgency !== undefined) {
|
|
115
|
+
if (typeof urgency !== 'string' || !URGENCIES.includes(urgency))
|
|
116
|
+
throw new ToolInputError(`invalid urgency: must be ${URGENCIES.join(', ')}`);
|
|
117
|
+
context.urgency = urgency;
|
|
118
|
+
}
|
|
119
|
+
if (expected_outcome !== undefined) {
|
|
120
|
+
if (typeof expected_outcome !== 'string')
|
|
121
|
+
throw new ToolInputError('invalid expected_outcome: must be a string');
|
|
122
|
+
context.expected_outcome = expected_outcome;
|
|
123
|
+
}
|
|
124
|
+
return context;
|
|
125
|
+
}
|
|
126
|
+
const NOT_PAIRED = {
|
|
127
|
+
evm: 'not paired with an evm wallet: ask the human to open https://clanker-wallet.xyz, copy the pairing JSON, and run: clanker-wallet pair \'<JSON>\'',
|
|
128
|
+
bitcoin: 'not paired with a bitcoin wallet: ask the human to open the bitcoin tab at https://clanker-wallet.xyz, copy its pairing JSON, and run: clanker-wallet pair \'<JSON>\'',
|
|
129
|
+
};
|
|
130
|
+
export function createMcpServer(options) {
|
|
131
|
+
const ttl = options.requestTtlMs ?? DEFAULT_REQUEST_TTL_MS;
|
|
132
|
+
const joinTimeout = options.joinTimeoutMs ?? DEFAULT_JOIN_TIMEOUT_MS;
|
|
133
|
+
const records = new Map();
|
|
134
|
+
/** tools/call requests still being answered, by JSON-RPC id, for notifications/cancelled */
|
|
135
|
+
const inFlight = new Map();
|
|
136
|
+
/**
|
|
137
|
+
* start a request on the family's pairing and answer once the relay has it: { request_id } after
|
|
138
|
+
* the wallet sends it, or a tool error when it fails first (bad input, unreachable relay, join timeout, cancelled)
|
|
139
|
+
*/
|
|
140
|
+
function submit(family, cancelled, send) {
|
|
141
|
+
const pairing = options.loadPairing(family);
|
|
142
|
+
if (!pairing)
|
|
143
|
+
return Promise.resolve(toolError(NOT_PAIRED[family]));
|
|
144
|
+
const wallet = options.wallet(pairing);
|
|
145
|
+
const expiresAt = Date.now() + ttl;
|
|
146
|
+
// a cancel stops the wallet only before it sends: once delivered, the human can still approve, so keep waiting
|
|
147
|
+
const unsent = new AbortController();
|
|
148
|
+
return new Promise((resolve) => {
|
|
149
|
+
let record;
|
|
150
|
+
let answered;
|
|
151
|
+
// this timer decides expiry, not a Date.now() check: timers can fire a millisecond before the clock reaches their
|
|
152
|
+
// target. it is set before the wallet's own timer of the same ttl, and node runs equal delays in the order they
|
|
153
|
+
// were set, so the request is expired before the wallet's timeout rejection arrives
|
|
154
|
+
const expiry = setTimeout(() => record?.settle('expired'), ttl);
|
|
155
|
+
cancelled.addEventListener('abort', () => { if (!record)
|
|
156
|
+
unsent.abort(); });
|
|
157
|
+
wallet.on('request_sent', ({ request_id }) => {
|
|
158
|
+
record = newRecord(request_id, expiresAt);
|
|
159
|
+
records.set(request_id, record);
|
|
160
|
+
record.settled.then(() => clearTimeout(expiry));
|
|
161
|
+
resolve(toolResult({ request_id, status: 'pending', expires_at: new Date(expiresAt).toISOString() }));
|
|
162
|
+
});
|
|
163
|
+
wallet.on('response', (response) => { answered = response; });
|
|
164
|
+
send(wallet, { timeout_ms: ttl, join_timeout_ms: joinTimeout, signal: unsent.signal }).then((txHash) => record.settle('approved', { tx_hash: txHash }), (err) => {
|
|
165
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
166
|
+
if (!record) {
|
|
167
|
+
clearTimeout(expiry);
|
|
168
|
+
return resolve(toolError(message));
|
|
169
|
+
}
|
|
170
|
+
if (answered?.status === 'rejected')
|
|
171
|
+
record.settle('rejected');
|
|
172
|
+
else if (answered)
|
|
173
|
+
record.settle('failed', { error: message });
|
|
174
|
+
// the relay refused the request (rate limit, size) and dropped it: nothing reached the human
|
|
175
|
+
else if (err instanceof RelayError)
|
|
176
|
+
record.settle('failed', { error: message });
|
|
177
|
+
// delivered, then the relay connection dropped: the relay still holds the request for the human
|
|
178
|
+
// (after expiry the record is already settled and this changes nothing)
|
|
179
|
+
else
|
|
180
|
+
record.settle('unknown', { error: `${message}; ${STILL_APPROVABLE}` });
|
|
181
|
+
});
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
async function requestEvmTx(args, cancelled) {
|
|
185
|
+
const context = contextFrom(args);
|
|
186
|
+
const { chain_id, transaction } = validateTx(args.chain_id, { to: args.to, value: args.value, data: args.data ?? '0x' });
|
|
187
|
+
return submit('evm', cancelled, (wallet, limits) => wallet.requestTx({ chain_id, transaction, context, ...limits }));
|
|
188
|
+
}
|
|
189
|
+
async function requestTransfer(args, cancelled) {
|
|
190
|
+
if (args.family !== 'bitcoin')
|
|
191
|
+
throw new ToolInputError(`invalid family: must be "bitcoin", got ${JSON.stringify(args.family)}`);
|
|
192
|
+
const context = contextFrom(args);
|
|
193
|
+
let request;
|
|
194
|
+
try {
|
|
195
|
+
request = validateTransfer('bitcoin', args.network, { asset: 'btc', to: args.to, amount: args.amount });
|
|
196
|
+
}
|
|
197
|
+
catch (err) {
|
|
198
|
+
// the tool calls the network field `network`
|
|
199
|
+
if (err instanceof InvalidTxError && err.field === 'chain_network')
|
|
200
|
+
throw new ToolInputError(`invalid network: ${err.reason}`);
|
|
201
|
+
throw err;
|
|
202
|
+
}
|
|
203
|
+
const { chain_network, transfer } = request;
|
|
204
|
+
return submit('bitcoin', cancelled, async (wallet, limits) => {
|
|
205
|
+
try {
|
|
206
|
+
return await wallet.requestTransfer({ chain_network, transfer, context, ...limits });
|
|
207
|
+
}
|
|
208
|
+
catch (err) {
|
|
209
|
+
// the pairing is for another network
|
|
210
|
+
if (err instanceof InvalidTxError && err.field === 'chain_network')
|
|
211
|
+
throw new Error(`invalid network: ${err.reason}`);
|
|
212
|
+
throw err;
|
|
213
|
+
}
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
async function getRequest(args, cancelled) {
|
|
217
|
+
const { request_id, wait_seconds = 0 } = args;
|
|
218
|
+
if (typeof request_id !== 'string')
|
|
219
|
+
throw new ToolInputError('invalid request_id: must be the string request_evm_tx or request_transfer returned');
|
|
220
|
+
if (typeof wait_seconds !== 'number' || !(wait_seconds >= 0)) {
|
|
221
|
+
throw new ToolInputError(`invalid wait_seconds: must be a number from 0 to ${MAX_WAIT_SECONDS}`);
|
|
222
|
+
}
|
|
223
|
+
const record = records.get(request_id);
|
|
224
|
+
if (!record) {
|
|
225
|
+
throw new ToolInputError(`unknown request_id ${JSON.stringify(request_id)}: this server did not send it, or sent it before it restarted and stopped waiting. ` +
|
|
226
|
+
'if it was sent before a restart, the human may still approve it in their wallet: ask them before sending it again');
|
|
227
|
+
}
|
|
228
|
+
const waitMs = Math.min(wait_seconds, MAX_WAIT_SECONDS) * 1000;
|
|
229
|
+
if (record.status === 'pending' && waitMs > 0) {
|
|
230
|
+
let timer;
|
|
231
|
+
const waits = [record.settled, new Promise((r) => cancelled.addEventListener('abort', r, { once: true }))];
|
|
232
|
+
// a wait that reaches past expiry ends when the request's expiry timer settles it
|
|
233
|
+
if (waitMs < record.expiresAt - Date.now())
|
|
234
|
+
waits.push(new Promise((r) => { timer = setTimeout(r, waitMs); }));
|
|
235
|
+
await Promise.race(waits);
|
|
236
|
+
clearTimeout(timer);
|
|
237
|
+
}
|
|
238
|
+
// a fallback for an expiry timer that runs late (a busy process): it only marks expired once the clock has passed expires_at
|
|
239
|
+
if (record.status === 'pending' && Date.now() >= record.expiresAt)
|
|
240
|
+
record.settle('expired');
|
|
241
|
+
const { status, tx_hash, error } = record;
|
|
242
|
+
if (status === 'pending')
|
|
243
|
+
return toolResult({ request_id, status, expires_at: new Date(record.expiresAt).toISOString() });
|
|
244
|
+
return toolResult({ request_id, status, ...(tx_hash !== undefined && { tx_hash }), ...(error !== undefined && { error }) });
|
|
245
|
+
}
|
|
246
|
+
function listPairings() {
|
|
247
|
+
const pairings = REQUEST_FAMILIES.flatMap((family) => {
|
|
248
|
+
const pairing = options.loadPairing(family);
|
|
249
|
+
return pairing ? [{ family, network: pairing.chain_network ?? null, wallet: pairing.wallet ?? null, mode: pairing.mode ?? null }] : [];
|
|
250
|
+
});
|
|
251
|
+
return toolResult({ ...options.identity, pairings });
|
|
252
|
+
}
|
|
253
|
+
const TOOL_HANDLERS = {
|
|
254
|
+
request_evm_tx: requestEvmTx,
|
|
255
|
+
request_transfer: requestTransfer,
|
|
256
|
+
get_request: getRequest,
|
|
257
|
+
list_pairings: listPairings,
|
|
258
|
+
};
|
|
259
|
+
async function callTool(params, cancelled) {
|
|
260
|
+
if (!isObject(params) || typeof params.name !== 'string')
|
|
261
|
+
throw new RpcError(INVALID_PARAMS, 'tools/call needs a tool name');
|
|
262
|
+
const handler = TOOL_HANDLERS[params.name];
|
|
263
|
+
if (!handler)
|
|
264
|
+
throw new RpcError(INVALID_PARAMS, `unknown tool: ${params.name}`);
|
|
265
|
+
const args = params.arguments ?? {};
|
|
266
|
+
if (!isObject(args))
|
|
267
|
+
throw new RpcError(INVALID_PARAMS, 'tool arguments must be an object');
|
|
268
|
+
try {
|
|
269
|
+
return await handler(args, cancelled);
|
|
270
|
+
}
|
|
271
|
+
catch (err) {
|
|
272
|
+
if (err instanceof ToolInputError || err instanceof InvalidTxError)
|
|
273
|
+
return toolError(err.message);
|
|
274
|
+
throw err;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
function initialize(params) {
|
|
278
|
+
const requested = isObject(params) ? params.protocolVersion : undefined;
|
|
279
|
+
const protocolVersion = typeof requested === 'string' && SUPPORTED_PROTOCOL_VERSIONS.includes(requested) ? requested : SUPPORTED_PROTOCOL_VERSIONS[0];
|
|
280
|
+
return {
|
|
281
|
+
protocolVersion,
|
|
282
|
+
capabilities: { tools: {} },
|
|
283
|
+
serverInfo: { name: 'clanker-wallet', version: options.version },
|
|
284
|
+
instructions: INSTRUCTIONS,
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
async function dispatch(method, params, cancelled) {
|
|
288
|
+
switch (method) {
|
|
289
|
+
case 'initialize': return initialize(params);
|
|
290
|
+
case 'ping': return {};
|
|
291
|
+
case 'tools/list': return { tools: MCP_TOOLS };
|
|
292
|
+
case 'tools/call': return callTool(params, cancelled);
|
|
293
|
+
default: throw new RpcError(METHOD_NOT_FOUND, `method not found: ${method}`);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
/** the reply to one input line, or null when none is due (blank line, notification, a response from the client) */
|
|
297
|
+
async function handleLine(line) {
|
|
298
|
+
if (!line.trim())
|
|
299
|
+
return null;
|
|
300
|
+
let msg;
|
|
301
|
+
try {
|
|
302
|
+
msg = JSON.parse(line);
|
|
303
|
+
}
|
|
304
|
+
catch {
|
|
305
|
+
return reply(null, { error: { code: PARSE_ERROR, message: 'parse error: each line must be one JSON-RPC message' } });
|
|
306
|
+
}
|
|
307
|
+
if (Array.isArray(msg))
|
|
308
|
+
return reply(null, { error: { code: INVALID_REQUEST, message: 'batches are not supported' } });
|
|
309
|
+
if (!isObject(msg))
|
|
310
|
+
return reply(null, { error: { code: INVALID_REQUEST, message: 'invalid request' } });
|
|
311
|
+
const hasId = 'id' in msg;
|
|
312
|
+
const id = typeof msg.id === 'string' || typeof msg.id === 'number' ? msg.id : null;
|
|
313
|
+
if (hasId && id === null)
|
|
314
|
+
return reply(null, { error: { code: INVALID_REQUEST, message: 'invalid request: id must be a string or number' } });
|
|
315
|
+
if (typeof msg.method !== 'string') {
|
|
316
|
+
// a response to us: we send no requests, so there is nothing to match it with
|
|
317
|
+
if ('result' in msg || 'error' in msg)
|
|
318
|
+
return null;
|
|
319
|
+
return reply(id, { error: { code: INVALID_REQUEST, message: 'invalid request: no method' } });
|
|
320
|
+
}
|
|
321
|
+
if (msg.jsonrpc !== '2.0')
|
|
322
|
+
return reply(id, { error: { code: INVALID_REQUEST, message: 'invalid request: jsonrpc must be "2.0"' } });
|
|
323
|
+
// notifications need no answer; only cancelled changes anything here
|
|
324
|
+
if (!hasId) {
|
|
325
|
+
if (msg.method === 'notifications/cancelled' && isObject(msg.params))
|
|
326
|
+
inFlight.get(msg.params.requestId)?.abort();
|
|
327
|
+
return null;
|
|
328
|
+
}
|
|
329
|
+
const controller = new AbortController();
|
|
330
|
+
inFlight.set(id, controller);
|
|
331
|
+
let answer;
|
|
332
|
+
try {
|
|
333
|
+
answer = reply(id, { result: await dispatch(msg.method, msg.params, controller.signal) });
|
|
334
|
+
}
|
|
335
|
+
catch (err) {
|
|
336
|
+
if (err instanceof RpcError)
|
|
337
|
+
answer = reply(id, { error: { code: err.code, message: err.message } });
|
|
338
|
+
else
|
|
339
|
+
answer = reply(id, { error: { code: INTERNAL_ERROR, message: err instanceof Error ? err.message : String(err) } });
|
|
340
|
+
}
|
|
341
|
+
finally {
|
|
342
|
+
if (inFlight.get(id) === controller)
|
|
343
|
+
inFlight.delete(id);
|
|
344
|
+
}
|
|
345
|
+
// the client gave up on this call: it expects no response
|
|
346
|
+
return controller.signal.aborted ? null : answer;
|
|
347
|
+
}
|
|
348
|
+
return { handleLine };
|
|
349
|
+
}
|
|
350
|
+
function reply(id, body) {
|
|
351
|
+
return JSON.stringify({ jsonrpc: '2.0', id, ...body });
|
|
352
|
+
}
|
|
353
|
+
function newRecord(request_id, expiresAt) {
|
|
354
|
+
let resolveSettled;
|
|
355
|
+
const record = {
|
|
356
|
+
request_id,
|
|
357
|
+
status: 'pending',
|
|
358
|
+
expiresAt,
|
|
359
|
+
settled: new Promise((r) => { resolveSettled = r; }),
|
|
360
|
+
settle: (status, detail = {}) => {
|
|
361
|
+
if (record.status !== 'pending')
|
|
362
|
+
return;
|
|
363
|
+
record.status = status;
|
|
364
|
+
record.tx_hash = detail.tx_hash;
|
|
365
|
+
record.error = detail.error;
|
|
366
|
+
resolveSettled();
|
|
367
|
+
},
|
|
368
|
+
};
|
|
369
|
+
return record;
|
|
370
|
+
}
|
package/dist/types.d.ts
CHANGED
|
@@ -48,6 +48,10 @@ export interface PairingData {
|
|
|
48
48
|
wallet: string;
|
|
49
49
|
/** "encrypted" (default): relay sees only ciphertext. "managed": relay also receives tx params for simulation. */
|
|
50
50
|
mode?: 'encrypted' | 'managed';
|
|
51
|
+
/** "bitcoin" or "chia" for a non-evm wallet; absent for evm */
|
|
52
|
+
chain_family?: string;
|
|
53
|
+
/** the wallet's network, when the pairing names one */
|
|
54
|
+
chain_network?: string;
|
|
51
55
|
}
|
|
52
56
|
/** options for requestTx */
|
|
53
57
|
export interface RequestTxOptions {
|
|
@@ -57,4 +61,52 @@ export interface RequestTxOptions {
|
|
|
57
61
|
context?: TxContext;
|
|
58
62
|
/** timeout in ms (default: 3_600_000 = 1hr) */
|
|
59
63
|
timeout_ms?: number;
|
|
64
|
+
/** fail when the relay hasn't confirmed joining the room this long after connecting (default: only timeout_ms applies); nothing was sent then */
|
|
65
|
+
join_timeout_ms?: number;
|
|
66
|
+
/** aborting before the relay confirms the join fails the request with nothing sent; after that it only stops waiting */
|
|
67
|
+
signal?: AbortSignal;
|
|
68
|
+
}
|
|
69
|
+
/** wallet families a transfer can go to */
|
|
70
|
+
export type TransferFamily = 'bitcoin' | 'chia';
|
|
71
|
+
/** an intent-level send: the human's wallet builds and signs it */
|
|
72
|
+
export interface Transfer {
|
|
73
|
+
asset: 'btc' | 'xch';
|
|
74
|
+
/** bech32 / bech32m address on the request's network */
|
|
75
|
+
to: string;
|
|
76
|
+
/** integer string: sats for btc, mojos for xch */
|
|
77
|
+
amount: string;
|
|
78
|
+
/** chia only, in mojos (defaults to "0"); bitcoin wallets set their own fee */
|
|
79
|
+
fee?: string;
|
|
80
|
+
}
|
|
81
|
+
/** transfer as passed to requestTransfer: amounts as decimal strings or bigints */
|
|
82
|
+
export interface TransferInput {
|
|
83
|
+
asset: 'btc' | 'xch';
|
|
84
|
+
to: string;
|
|
85
|
+
amount: string | bigint;
|
|
86
|
+
fee?: string | bigint;
|
|
87
|
+
}
|
|
88
|
+
/** bitcoin / chia request sent to the human: no chain_id, no transaction */
|
|
89
|
+
export interface TransferRequest {
|
|
90
|
+
request_id: string;
|
|
91
|
+
agent_id: string;
|
|
92
|
+
timestamp: string;
|
|
93
|
+
chain_family: TransferFamily;
|
|
94
|
+
chain_network: string;
|
|
95
|
+
transfer: Transfer;
|
|
96
|
+
context?: TxContext;
|
|
97
|
+
signature: string;
|
|
98
|
+
}
|
|
99
|
+
/** options for requestTransfer; the family comes from the pairing */
|
|
100
|
+
export interface RequestTransferOptions {
|
|
101
|
+
/** bitcoin: mainnet | testnet | signet; chia: mainnet | testnet11 */
|
|
102
|
+
chain_network: string;
|
|
103
|
+
/** checked before sending; an invalid field throws InvalidTxError */
|
|
104
|
+
transfer: TransferInput;
|
|
105
|
+
context?: TxContext;
|
|
106
|
+
/** timeout in ms (default: 3_600_000 = 1hr) */
|
|
107
|
+
timeout_ms?: number;
|
|
108
|
+
/** fail when the relay hasn't confirmed joining the room this long after connecting (default: only timeout_ms applies); nothing was sent then */
|
|
109
|
+
join_timeout_ms?: number;
|
|
110
|
+
/** aborting before the relay confirms the join fails the request with nothing sent; after that it only stops waiting */
|
|
111
|
+
signal?: AbortSignal;
|
|
60
112
|
}
|
package/dist/validate.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import type { Transaction, TxInput } from './types.js';
|
|
2
|
-
export type TxField = 'chain_id' | 'to' | 'value' | 'data';
|
|
1
|
+
import type { Transaction, Transfer, TransferFamily, TransferInput, TxInput } from './types.js';
|
|
2
|
+
export type TxField = 'chain_id' | 'to' | 'value' | 'data' | 'chain_family' | 'chain_network' | 'asset' | 'amount' | 'fee';
|
|
3
3
|
/** a tx request field failed validation; nothing was sent */
|
|
4
4
|
export declare class InvalidTxError extends Error {
|
|
5
5
|
readonly field: TxField;
|
|
@@ -17,3 +17,14 @@ export declare function validateTx(chainId: unknown, tx: TxInput | Record<string
|
|
|
17
17
|
chain_id: number;
|
|
18
18
|
transaction: Transaction;
|
|
19
19
|
};
|
|
20
|
+
/**
|
|
21
|
+
* check a bitcoin / chia transfer before anything is signed or sent, and normalize it
|
|
22
|
+
* to the wire format (amounts as decimal strings, chia fee defaulting to "0").
|
|
23
|
+
* the recipient must decode as an address on `chainNetwork`. as strict as the web app,
|
|
24
|
+
* so a request it would refuse never leaves the agent. throws InvalidTxError.
|
|
25
|
+
*/
|
|
26
|
+
export declare function validateTransfer(chainFamily: unknown, chainNetwork: unknown, transfer: TransferInput | Record<string, unknown>): {
|
|
27
|
+
chain_family: TransferFamily;
|
|
28
|
+
chain_network: string;
|
|
29
|
+
transfer: Transfer;
|
|
30
|
+
};
|
package/dist/validate.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { decodeChiaAddress, isStandardSegwitAddress } from './bech32.js';
|
|
1
2
|
import { keccak256 } from './keccak.js';
|
|
2
3
|
/** a tx request field failed validation; nothing was sent */
|
|
3
4
|
export class InvalidTxError extends Error {
|
|
@@ -63,3 +64,65 @@ export function validateTx(chainId, tx) {
|
|
|
63
64
|
}
|
|
64
65
|
return { chain_id: chainId, transaction: { to, value: wei, data } };
|
|
65
66
|
}
|
|
67
|
+
const TRANSFER_ASSET = { bitcoin: 'btc', chia: 'xch' };
|
|
68
|
+
/** address hrp per network; the web app checks the same table */
|
|
69
|
+
const ADDRESS_HRP = {
|
|
70
|
+
bitcoin: { mainnet: 'bc', testnet: 'tb', signet: 'tb' },
|
|
71
|
+
chia: { mainnet: 'xch', testnet11: 'txch' },
|
|
72
|
+
};
|
|
73
|
+
const NETWORK_NAMES = {
|
|
74
|
+
bitcoin: 'mainnet, testnet or signet',
|
|
75
|
+
chia: 'mainnet or testnet11',
|
|
76
|
+
};
|
|
77
|
+
const POSITIVE_INT_RE = /^[1-9][0-9]*$/;
|
|
78
|
+
/** a decimal string or bigint amount as a decimal string, or null */
|
|
79
|
+
function units(v, allowZero) {
|
|
80
|
+
if (typeof v === 'bigint')
|
|
81
|
+
return v > 0n || (allowZero && v === 0n) ? v.toString() : null;
|
|
82
|
+
if (typeof v !== 'string')
|
|
83
|
+
return null;
|
|
84
|
+
return POSITIVE_INT_RE.test(v) || (allowZero && v === '0') ? v : null;
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* check a bitcoin / chia transfer before anything is signed or sent, and normalize it
|
|
88
|
+
* to the wire format (amounts as decimal strings, chia fee defaulting to "0").
|
|
89
|
+
* the recipient must decode as an address on `chainNetwork`. as strict as the web app,
|
|
90
|
+
* so a request it would refuse never leaves the agent. throws InvalidTxError.
|
|
91
|
+
*/
|
|
92
|
+
export function validateTransfer(chainFamily, chainNetwork, transfer) {
|
|
93
|
+
if (chainFamily !== 'bitcoin' && chainFamily !== 'chia') {
|
|
94
|
+
throw new InvalidTxError('chain_family', `transfers need a bitcoin or chia pairing, got ${shown(chainFamily ?? 'evm')}`);
|
|
95
|
+
}
|
|
96
|
+
const family = chainFamily;
|
|
97
|
+
const hrp = typeof chainNetwork === 'string' ? ADDRESS_HRP[family][chainNetwork] : undefined;
|
|
98
|
+
if (!hrp)
|
|
99
|
+
throw new InvalidTxError('chain_network', `must be ${NETWORK_NAMES[family]}, got ${shown(chainNetwork)}`);
|
|
100
|
+
const network = chainNetwork;
|
|
101
|
+
const asset = TRANSFER_ASSET[family];
|
|
102
|
+
if (transfer.asset !== asset)
|
|
103
|
+
throw new InvalidTxError('asset', `${family} transfers must be ${shown(asset)}, got ${shown(transfer.asset)}`);
|
|
104
|
+
const unit = family === 'bitcoin' ? 'sats' : 'mojos';
|
|
105
|
+
const amount = units(transfer.amount, false);
|
|
106
|
+
if (!amount)
|
|
107
|
+
throw new InvalidTxError('amount', `must be a positive integer amount of ${unit} (decimal string or bigint), got ${shown(transfer.amount)}`);
|
|
108
|
+
const { to } = transfer;
|
|
109
|
+
if (family === 'bitcoin') {
|
|
110
|
+
if (transfer.fee !== undefined)
|
|
111
|
+
throw new InvalidTxError('fee', 'bitcoin transfers take no fee; the wallet sets it');
|
|
112
|
+
// the wallet api takes sats as a js number
|
|
113
|
+
if (BigInt(amount) > BigInt(Number.MAX_SAFE_INTEGER)) {
|
|
114
|
+
throw new InvalidTxError('amount', `must be at most ${Number.MAX_SAFE_INTEGER} sats, got ${shown(amount)}`);
|
|
115
|
+
}
|
|
116
|
+
if (typeof to !== 'string' || !isStandardSegwitAddress(hrp, to)) {
|
|
117
|
+
throw new InvalidTxError('to', `not a bitcoin ${network} address (segwit, ${hrp}1...), got ${shown(to)}`);
|
|
118
|
+
}
|
|
119
|
+
return { chain_family: family, chain_network: network, transfer: { asset, to, amount } };
|
|
120
|
+
}
|
|
121
|
+
const fee = transfer.fee === undefined ? '0' : units(transfer.fee, true);
|
|
122
|
+
if (!fee)
|
|
123
|
+
throw new InvalidTxError('fee', `must be a non-negative integer amount of mojos (decimal string or bigint), got ${shown(transfer.fee)}`);
|
|
124
|
+
if (typeof to !== 'string' || !decodeChiaAddress(hrp, to)) {
|
|
125
|
+
throw new InvalidTxError('to', `not a chia ${network} address (${hrp}1...), got ${shown(to)}`);
|
|
126
|
+
}
|
|
127
|
+
return { chain_family: family, chain_network: network, transfer: { asset, to, amount, fee } };
|
|
128
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "clanker-wallet",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
@@ -59,6 +59,7 @@
|
|
|
59
59
|
"tweetnacl": "^1.0.3"
|
|
60
60
|
},
|
|
61
61
|
"devDependencies": {
|
|
62
|
+
"@modelcontextprotocol/sdk": "^1.32.2",
|
|
62
63
|
"@types/bun": "^1.3.8",
|
|
63
64
|
"typescript": "^5.4.0",
|
|
64
65
|
"vitest": "^2.1.0"
|