clanker-wallet 0.8.0 → 0.10.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
@@ -178,12 +178,25 @@ Claude Desktop (`claude_desktop_config.json`), Cursor (`~/.cursor/mcp.json`), an
178
178
 
179
179
  | Tool | Arguments | Returns |
180
180
  |------|-----------|---------|
181
- | `request_evm_tx` | `chain_id`, `to`, `value` (wei, integer string), `data` (default `0x`), `reason`, optional `urgency` / `expected_outcome` / `from` (the paired wallet to send from, needed when several wallets of the same family are paired) | `{"request_id":"req_...","status":"pending","expires_at":"..."}` |
182
- | `request_transfer` | `family` (`bitcoin`), `network` (`mainnet`, `testnet`, `signet`), `to`, `amount` (sats, integer string), `reason`, optional `urgency` / `expected_outcome` / `from` (the paired wallet to send from, needed when several wallets of the same family are paired) | as above |
183
- | `get_request` | `request_id`, optional `wait_seconds` (at most 50) | `status`: `pending`, `approved` (with `tx_hash`), `rejected`, `failed` (with `error`), `unknown` (with `error`) or `expired` |
184
- | `list_pairings` | none | `agent_id`, `fingerprint`, and the paired families (`evm`, `bitcoin`) with network, wallet and mode |
181
+ | `request_evm_tx` | `chain_id`, `to`, `value` (wei, integer string), `data` (default `0x`), `reason`, optional `urgency` / `expected_outcome` / `from` (the paired wallet to send from, needed when several wallets of the same family are paired) | `{"request_id":"req_...","review_url":"https://clanker-wallet.xyz/?request=req_...","status":"pending","expires_at":"..."}` |
182
+ | `request_transfer` | `family` (`bitcoin` or `chia`), `network` (bitcoin: `mainnet`, `testnet`, `signet`; chia: `mainnet`, `testnet11`), `to`, `amount` (sats, integer string) or mojos for chia, `reason`, optional `fee` (chia only: mojos, default `"0"`) / `urgency` / `expected_outcome` / `from` (the paired wallet to send from, needed when several wallets of the same family are paired) | as above |
183
+ | `request_token_transfer` | `chain_id`, `to`, `amount` (positive human decimal string), `reason`, optional `token` (listed symbol or contract address; omitted/native symbol = native coin), `from`, `urgency`, `expected_outcome` | `request_id`, `review_url`, pending status and expiry; existing EVM request, no signing here |
184
+ | `get_request` | `request_id`, optional `wait_seconds` (at most 50) | `status`: `pending`, `approved` (with `tx_hash`; a chia send may have none), `rejected`, `failed` (with `error`), `unknown` (with `error`) or `expired` |
185
+ | `get_balance` | `family`, `address` (any recipient), EVM `chain_id`, bitcoin/chia `network`, optional EVM `token` (listed symbol or address) | exact `raw_balance`, human `balance`, `asset`, `decimals`; bitcoin also confirmed/mempool deltas; chia recipient puzzle-hash unspent coins only |
186
+ | `get_tx_status` | `family` (evm/bitcoin), `tx_hash`, EVM `chain_id` or bitcoin `network` | receipt `status` and `confirmations`; missing EVM receipt is `pending_or_unknown`, missing bitcoin hash is `unknown` |
187
+ | `list_pairings` | none | `agent_id`, `fingerprint`, and the paired families (`evm`, `bitcoin`, `chia`) with network, wallet and mode |
185
188
 
186
- Requests are asynchronous: a request tool returns once the relay has the request, then the assistant calls `get_request` (with `wait_seconds` to wait for an answer) until the status is no longer `pending`. Invalid input or a missing pairing is a tool error, and nothing is sent. Pending requests live in the server process: after 1 h the server stops waiting and reports `expired`, and a restarted server no longer knows them (`get_request` then reports an unknown `request_id`). In both cases the human may still approve the request in their wallet, so ask them before sending it again. `unknown` means the request was delivered but the server lost its relay connection: the human may still approve it, so ask them before sending it again. Chia is not offered over MCP yet.
189
+ `request_token_transfer` uses the browser-safe `clanker-wallet/tokens` list (chain id, symbol, address, decimals). Unlisted symbols are refused: pass the contract address; never search by symbol. Unlisted contracts must answer both `decimals()` and `symbol()` via RPC. Excess fraction digits are refused, never rounded. The review card reads unlisted token metadata independently and warns with the full contract address; a reported symbol matching a listed token at another address is a danger flag, not an identity check.
190
+
191
+ `get_balance` needs no pairing and can check any recipient: EVM native/ERC-20 through RPC; bitcoin via `mempool.space` on mainnet/testnet/signet (includes mempool deltas, which can be negative); chia via keyless `api.coinset.org` / `testnet11.api.coinset.org` coin records on mainnet/testnet11. Chia addresses are decoded with the package's bech32 implementation; only unspent coins at that puzzle hash are summed, not every address in a wallet, not mempool or guaranteed spendability. Coin uint64 values stay exact; older runtimes without JSON source context refuse unsafe numeric amounts rather than round. Providers may lag or be unavailable. After a chia approval without a hash, call `get_balance` on the recipient; compare against a prior balance where possible, since a balance alone cannot prove which payment arrived.
192
+
193
+ `get_tx_status` reports EVM receipt success/failure plus confirmations, or bitcoin confirmations. No receipt is `pending_or_unknown`, not proof of submission; bitcoin can be `pending`, `confirmed` or `unknown`. Reads never sign or send transactions. Chain response bodies are stream-limited to 1 MiB (1,048,576 bytes); Chia replies also allow at most 10,000 coin records. Oversized responses are canceled and refused explicitly, never truncated into a partial balance. Addresses with larger coin histories must be checked using a provider/wallet with appropriate limits; this tool does not paginate or return a partial sum. These bounds supplement provider limits and the request timeout, not a guarantee of provider availability. The hosted connector shares a 60-per-minute per-grant chain-read budget across both read tools and token preparation; local servers use the provider's limits. Full multi-chain transaction history remains out of scope: it would need indexed transfer/transaction providers (typically keyed EVM APIs), pagination/backfills and wallet/address indexing; no keyed API is introduced here.
194
+
195
+ Chain RPC URLs are keyless public defaults for Ethereum, Base, Arbitrum, Optimism, Polygon and their configured testnets. Override per chain with the `rpcUrls` option or `CLANKER_WALLET_RPC_URLS` (JSON object of chain id to HTTP(S) URL); the hosted connector uses `RPC_URLS` in the same format. Keep keyed URLs in private deployment configuration. RPC services see queried contracts; token metadata is untrusted.
196
+
197
+ Show the user the `review_url` returned by request tools (also present on pending `get_request` results). It opens `https://clanker-wallet.xyz/?request=<request_id>` and highlights the card after the wallet loads its waiting requests; unknown ids are silently ignored. The link contains only the request id, not pairing secrets. Local MCP servers can override the origin with `webOrigin` or `CLANKER_WALLET_WEB_ORIGIN`.
198
+
199
+ Requests are asynchronous: a request tool returns once the relay has the request, then the assistant calls `get_request` (with `wait_seconds` to wait for an answer) until the status is no longer `pending`. Invalid input or a missing pairing is a tool error, and nothing is sent. Pending requests live in the server process: after 1 h the server stops waiting and reports `expired`, and a restarted server no longer knows them (`get_request` then reports an unknown `request_id`). In both cases the human may still approve the request in their wallet, so ask them before sending it again. `unknown` means the request was delivered but the server lost its relay connection: the human may still approve it, so ask them before sending it again. A chia approval may have no `tx_hash`: the Sage wallet sends without reporting a transaction id, so check the recipient's balance.
187
200
 
188
201
  ---
189
202
 
package/SKILL.md CHANGED
@@ -215,12 +215,25 @@ Claude Desktop (`claude_desktop_config.json`), Cursor (`~/.cursor/mcp.json`), an
215
215
 
216
216
  | Tool | Arguments | Returns |
217
217
  |------|-----------|---------|
218
- | `request_evm_tx` | `chain_id`, `to`, `value` (wei, integer string), `data` (default `0x`), `reason`, optional `urgency` / `expected_outcome` / `from` (the paired wallet to send from, needed when several wallets of the same family are paired) | `{"request_id":"req_...","status":"pending","expires_at":"..."}` |
219
- | `request_transfer` | `family` (`bitcoin`), `network` (`mainnet`, `testnet`, `signet`), `to`, `amount` (sats, integer string), `reason`, optional `urgency` / `expected_outcome` / `from` (the paired wallet to send from, needed when several wallets of the same family are paired) | as above |
220
- | `get_request` | `request_id`, optional `wait_seconds` (at most 50) | `status`: `pending`, `approved` (with `tx_hash`), `rejected`, `failed` (with `error`), `unknown` (with `error`) or `expired` |
221
- | `list_pairings` | none | `agent_id`, `fingerprint`, and the paired families (`evm`, `bitcoin`) with network, wallet and mode |
218
+ | `request_evm_tx` | `chain_id`, `to`, `value` (wei, integer string), `data` (default `0x`), `reason`, optional `urgency` / `expected_outcome` / `from` (the paired wallet to send from, needed when several wallets of the same family are paired) | `{"request_id":"req_...","review_url":"https://clanker-wallet.xyz/?request=req_...","status":"pending","expires_at":"..."}` |
219
+ | `request_transfer` | `family` (`bitcoin` or `chia`), `network` (bitcoin: `mainnet`, `testnet`, `signet`; chia: `mainnet`, `testnet11`), `to`, `amount` (sats, integer string) or mojos for chia, `reason`, optional `fee` (chia only: mojos, default `"0"`) / `urgency` / `expected_outcome` / `from` (the paired wallet to send from, needed when several wallets of the same family are paired) | as above |
220
+ | `request_token_transfer` | `chain_id`, `to`, `amount` (positive human decimal string), `reason`, optional `token` (listed symbol or contract address; omitted/native symbol = native coin), `from`, `urgency`, `expected_outcome` | `request_id`, `review_url`, pending status and expiry; existing EVM request, no signing here |
221
+ | `get_request` | `request_id`, optional `wait_seconds` (at most 50) | `status`: `pending`, `approved` (with `tx_hash`; a chia send may have none), `rejected`, `failed` (with `error`), `unknown` (with `error`) or `expired` |
222
+ | `get_balance` | `family`, `address` (any recipient), EVM `chain_id`, bitcoin/chia `network`, optional EVM `token` (listed symbol or address) | exact `raw_balance`, human `balance`, `asset`, `decimals`; bitcoin also confirmed/mempool deltas; chia recipient puzzle-hash unspent coins only |
223
+ | `get_tx_status` | `family` (evm/bitcoin), `tx_hash`, EVM `chain_id` or bitcoin `network` | receipt `status` and `confirmations`; missing EVM receipt is `pending_or_unknown`, missing bitcoin hash is `unknown` |
224
+ | `list_pairings` | none | `agent_id`, `fingerprint`, and the paired families (`evm`, `bitcoin`, `chia`) with network, wallet and mode |
222
225
 
223
- Flow: call `list_pairings` to see which chains you can use, call `request_evm_tx` or `request_transfer` (same rules as `tx` / `send`: wei and sats as integer strings, real addresses), tell the human a request is waiting (see "Transaction notifications"), then call `get_request` with `wait_seconds` until the status is no longer `pending`. Invalid input or a missing pairing comes back as a tool error and nothing is sent. After 1 h the server stops waiting and reports `expired`, and a restarted server no longer knows its earlier requests (`get_request` then reports an unknown `request_id`). In both cases the human may still approve the request in their wallet, so ask them before sending it again. `unknown` means the request was delivered but the server lost its relay connection: the human may still approve it, so ask them before sending it again. Chia is not offered over MCP yet.
226
+ `request_token_transfer` uses the browser-safe `clanker-wallet/tokens` list (chain id, symbol, address, decimals). Unlisted symbols are refused: pass the contract address; never search by symbol. Unlisted contracts must answer both `decimals()` and `symbol()` via RPC. Excess fraction digits are refused, never rounded. The review card reads unlisted token metadata independently and warns with the full contract address; a reported symbol matching a listed token at another address is a danger flag, not an identity check.
227
+
228
+ `get_balance` needs no pairing and can check any recipient: EVM native/ERC-20 through RPC; bitcoin via `mempool.space` on mainnet/testnet/signet (includes mempool deltas, which can be negative); chia via keyless `api.coinset.org` / `testnet11.api.coinset.org` coin records on mainnet/testnet11. Chia addresses are decoded with the package's bech32 implementation; only unspent coins at that puzzle hash are summed, not every address in a wallet, not mempool or guaranteed spendability. Coin uint64 values stay exact; older runtimes without JSON source context refuse unsafe numeric amounts rather than round. Providers may lag or be unavailable. After a chia approval without a hash, call `get_balance` on the recipient; compare against a prior balance where possible, since a balance alone cannot prove which payment arrived.
229
+
230
+ `get_tx_status` reports EVM receipt success/failure plus confirmations, or bitcoin confirmations. No receipt is `pending_or_unknown`, not proof of submission; bitcoin can be `pending`, `confirmed` or `unknown`. Reads never sign or send transactions. Chain response bodies are stream-limited to 1 MiB (1,048,576 bytes); Chia replies also allow at most 10,000 coin records. Oversized responses are canceled and refused explicitly, never truncated into a partial balance. Addresses with larger coin histories must be checked using a provider/wallet with appropriate limits; this tool does not paginate or return a partial sum. These bounds supplement provider limits and the request timeout, not a guarantee of provider availability. The hosted connector shares a 60-per-minute per-grant chain-read budget across both read tools and token preparation; local servers use the provider's limits. Full multi-chain transaction history remains out of scope: it would need indexed transfer/transaction providers (typically keyed EVM APIs), pagination/backfills and wallet/address indexing; no keyed API is introduced here.
231
+
232
+ Chain RPC URLs are keyless public defaults for Ethereum, Base, Arbitrum, Optimism, Polygon and their configured testnets. Override per chain with the `rpcUrls` option or `CLANKER_WALLET_RPC_URLS` (JSON object of chain id to HTTP(S) URL); the hosted connector uses `RPC_URLS` in the same format. Keep keyed URLs in private deployment configuration. RPC services see queried contracts; token metadata is untrusted.
233
+
234
+ Show the user the `review_url` returned by request tools (also present on pending `get_request` results). It opens `https://clanker-wallet.xyz/?request=<request_id>` and highlights the card after the wallet loads its waiting requests; unknown ids are silently ignored. The link contains only the request id, not pairing secrets. Local MCP servers can override the origin with `webOrigin` or `CLANKER_WALLET_WEB_ORIGIN`.
235
+
236
+ Flow: call `list_pairings` to see which chains you can use, call `request_evm_tx` or `request_transfer` (same rules as `tx` / `send`: wei, sats and mojos as integer strings, real addresses), tell the human a request is waiting (see "Transaction notifications"), then call `get_request` with `wait_seconds` until the status is no longer `pending`. Invalid input or a missing pairing comes back as a tool error and nothing is sent. After 1 h the server stops waiting and reports `expired`, and a restarted server no longer knows its earlier requests (`get_request` then reports an unknown `request_id`). In both cases the human may still approve the request in their wallet, so ask them before sending it again. `unknown` means the request was delivered but the server lost its relay connection: the human may still approve it, so ask them before sending it again. A chia approval may have no `tx_hash`: the Sage wallet sends without reporting a transaction id, so check the recipient's balance.
224
237
 
225
238
  ## Troubleshooting
226
239
 
@@ -0,0 +1,31 @@
1
+ /** Keyless public defaults; operators can supply keyed URLs without exposing them in tool results. */
2
+ export declare const DEFAULT_RPC_URLS: Readonly<Record<number, string>>;
3
+ export interface ChainDataOptions {
4
+ rpcUrls?: Readonly<Record<number, string>>;
5
+ fetch?: (input: string | URL | Request, init?: RequestInit) => Promise<Response>;
6
+ }
7
+ export declare class ChainDataError extends Error {
8
+ }
9
+ /** Bound decoded chain-response buffering by bytes actually read, not an untrusted Content-Length. */
10
+ export declare function readChainResponse(response: Response): Promise<string>;
11
+ export declare function rpcUrlsFromJson(value: string | undefined): Record<number, string> | undefined;
12
+ export declare function evmRpc(chainId: number, method: string, params: unknown[], options?: ChainDataOptions): Promise<unknown>;
13
+ export interface TokenMetadata {
14
+ address: string;
15
+ symbol: string;
16
+ decimals: number;
17
+ }
18
+ export declare function nativeSymbol(chainId: number): string;
19
+ /** Read both fields for unlisted contracts. Never search a symbol or guess its precision. */
20
+ export declare function resolveToken(chainId: number, token: unknown, options?: ChainDataOptions): Promise<TokenMetadata | null>;
21
+ export declare function abiUint(value: unknown): bigint;
22
+ export declare function decimalUnits(amount: unknown, decimals: number): string;
23
+ export declare function buildTokenTransfer(args: {
24
+ chain_id: unknown;
25
+ to: unknown;
26
+ token?: unknown;
27
+ amount: unknown;
28
+ }, options?: ChainDataOptions): Promise<{
29
+ chain_id: number;
30
+ transaction: import("./types.js").Transaction;
31
+ }>;
@@ -0,0 +1,174 @@
1
+ import { TOKENS } from './tokens.js';
2
+ import { validateTx } from './validate.js';
3
+ /** Keyless public defaults; operators can supply keyed URLs without exposing them in tool results. */
4
+ export const DEFAULT_RPC_URLS = {
5
+ 1: 'https://ethereum-rpc.publicnode.com',
6
+ 11155111: 'https://ethereum-sepolia-rpc.publicnode.com',
7
+ 8453: 'https://mainnet.base.org',
8
+ 84532: 'https://sepolia.base.org',
9
+ 42161: 'https://arb1.arbitrum.io/rpc',
10
+ 421614: 'https://sepolia-rollup.arbitrum.io/rpc',
11
+ 10: 'https://mainnet.optimism.io',
12
+ 11155420: 'https://sepolia.optimism.io',
13
+ 137: 'https://polygon-bor-rpc.publicnode.com',
14
+ 80002: 'https://polygon-amoy-bor-rpc.publicnode.com',
15
+ };
16
+ export class ChainDataError extends Error {
17
+ }
18
+ const MAX_CHAIN_RESPONSE_BYTES = 1024 * 1024;
19
+ /** Bound decoded chain-response buffering by bytes actually read, not an untrusted Content-Length. */
20
+ export async function readChainResponse(response) {
21
+ const reader = response.body?.getReader();
22
+ if (!reader)
23
+ return '';
24
+ const decoder = new TextDecoder();
25
+ let bytes = 0;
26
+ let text = '';
27
+ try {
28
+ while (true) {
29
+ const chunk = await reader.read();
30
+ if (chunk.done)
31
+ return text + decoder.decode();
32
+ bytes += chunk.value.byteLength;
33
+ if (bytes > MAX_CHAIN_RESPONSE_BYTES)
34
+ throw new ChainDataError('chain response too large: limit 1048576 bytes; no partial result returned');
35
+ text += decoder.decode(chunk.value, { stream: true });
36
+ }
37
+ }
38
+ catch (err) {
39
+ try {
40
+ await reader.cancel();
41
+ }
42
+ catch { /* an already-errored body may refuse cancellation; retain the original sanitized failure */ }
43
+ if (err instanceof ChainDataError)
44
+ throw err;
45
+ throw new ChainDataError('chain response body read failed');
46
+ }
47
+ finally {
48
+ reader.releaseLock();
49
+ }
50
+ }
51
+ export function rpcUrlsFromJson(value) {
52
+ if (!value)
53
+ return undefined;
54
+ try {
55
+ const urls = JSON.parse(value);
56
+ if (!urls || typeof urls !== 'object' || Array.isArray(urls))
57
+ throw new Error();
58
+ for (const [chain, url] of Object.entries(urls)) {
59
+ if (!/^[1-9][0-9]*$/.test(chain) || typeof url !== 'string' || !/^https?:$/.test(new URL(url).protocol))
60
+ throw new Error();
61
+ }
62
+ return urls;
63
+ }
64
+ catch {
65
+ throw new ChainDataError('invalid RPC URL map: expected JSON mapping chain ids to http(s) URLs');
66
+ }
67
+ }
68
+ export async function evmRpc(chainId, method, params, options = {}) {
69
+ const url = options.rpcUrls?.[chainId] ?? DEFAULT_RPC_URLS[chainId];
70
+ if (!url)
71
+ throw new ChainDataError(`no RPC configured for chain ${chainId}`);
72
+ try {
73
+ if (!/^https?:$/.test(new URL(url).protocol))
74
+ throw new Error();
75
+ const response = await (options.fetch ?? globalThis.fetch)(url, {
76
+ method: 'POST', headers: { 'content-type': 'application/json' },
77
+ body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params }), signal: AbortSignal.timeout(10_000),
78
+ });
79
+ if (!response.ok) {
80
+ await response.body?.cancel();
81
+ throw new Error();
82
+ }
83
+ const body = JSON.parse(await readChainResponse(response));
84
+ if (body.error || !Object.hasOwn(body, 'result'))
85
+ throw new Error();
86
+ return body.result;
87
+ }
88
+ catch (err) {
89
+ if (err instanceof ChainDataError)
90
+ throw err;
91
+ throw new ChainDataError(`chain ${chainId} RPC read failed (${method})`);
92
+ }
93
+ }
94
+ export function nativeSymbol(chainId) {
95
+ return chainId === 137 || chainId === 80002 ? 'POL' : 'ETH';
96
+ }
97
+ /** Read both fields for unlisted contracts. Never search a symbol or guess its precision. */
98
+ export async function resolveToken(chainId, token, options = {}) {
99
+ validateTx(chainId, { to: '0x' + '0'.repeat(40), value: '0' });
100
+ if (token === undefined || (typeof token === 'string' && token.toUpperCase() === nativeSymbol(chainId)))
101
+ return null;
102
+ if (typeof token !== 'string' || !token)
103
+ throw new ChainDataError('invalid token: use a listed symbol or pass the contract address');
104
+ const listed = TOKENS.find(t => t.chain_id === chainId && (t.address === token.toLowerCase() || t.symbol.toUpperCase() === token.toUpperCase()));
105
+ if (listed)
106
+ return listed;
107
+ if (!/^0x[0-9a-fA-F]{40}$/.test(token))
108
+ throw new ChainDataError('unlisted symbol: pass the contract address; never search by symbol');
109
+ const { transaction } = validateTx(chainId, { to: token, value: '0' });
110
+ try {
111
+ const call = (data) => evmRpc(chainId, 'eth_call', [{ to: transaction.to, data }, 'latest'], options);
112
+ const [rawDecimals, rawSymbol] = await Promise.all([call('0x313ce567'), call('0x95d89b41')]);
113
+ const decimals = Number(abiUint(rawDecimals));
114
+ if (decimals > 255)
115
+ throw new Error();
116
+ const symbol = abiSymbol(rawSymbol);
117
+ return { address: transaction.to, decimals, symbol };
118
+ }
119
+ catch (err) {
120
+ const detail = err instanceof ChainDataError ? ` (${err.message})` : '';
121
+ throw new ChainDataError(`token metadata unavailable${detail}: decimals() and symbol() must both answer; nothing was sent`);
122
+ }
123
+ }
124
+ export function abiUint(value) {
125
+ if (typeof value !== 'string' || !/^0x[0-9a-fA-F]{64}$/.test(value))
126
+ throw new ChainDataError('invalid uint256 RPC result');
127
+ return BigInt(value);
128
+ }
129
+ function abiSymbol(value) {
130
+ if (typeof value !== 'string' || !/^0x(?:[0-9a-fA-F]{2})+$/.test(value) || value.length > 1026)
131
+ throw new Error();
132
+ const hex = value.slice(2);
133
+ let bytes;
134
+ if (hex.length === 64)
135
+ bytes = hex.replace(/(?:00)+$/, ''); // legacy bytes32 symbol
136
+ else {
137
+ if (BigInt('0x' + hex.slice(0, 64)) !== 32n)
138
+ throw new Error();
139
+ const size = Number(BigInt('0x' + hex.slice(64, 128)));
140
+ if (size < 1 || size > 128 || hex.length < 128 + Math.ceil(size / 32) * 64)
141
+ throw new Error();
142
+ bytes = hex.slice(128, 128 + size * 2);
143
+ }
144
+ const symbol = new TextDecoder('utf-8', { fatal: true }).decode(Uint8Array.from(bytes.match(/../g) ?? [], b => parseInt(b, 16)));
145
+ if (!symbol.trim() || /[\u0000-\u001f\u007f-\u009f]/.test(symbol))
146
+ throw new Error();
147
+ return symbol;
148
+ }
149
+ function decimalParts(amount) {
150
+ if (typeof amount !== 'string' || amount.length > 400 || !/^(0|[1-9][0-9]*)(\.[0-9]+)?$/.test(amount))
151
+ throw new ChainDataError('invalid amount: use a positive human decimal string, not exponent notation');
152
+ const [whole, fraction = ''] = amount.split('.');
153
+ if (BigInt(whole + fraction) === 0n)
154
+ throw new ChainDataError('amount must be positive');
155
+ return [whole, fraction];
156
+ }
157
+ export function decimalUnits(amount, decimals) {
158
+ const [whole, fraction] = decimalParts(amount);
159
+ if (fraction.length > decimals)
160
+ throw new ChainDataError(`amount has more fraction digits than ${decimals}; refusing to round`);
161
+ const raw = BigInt(whole + fraction.padEnd(decimals, '0'));
162
+ if (raw <= 0n || raw >= 2n ** 256n)
163
+ throw new ChainDataError('amount must be positive and fit uint256');
164
+ return raw.toString();
165
+ }
166
+ export async function buildTokenTransfer(args, options = {}) {
167
+ const { chain_id, transaction } = validateTx(args.chain_id, { to: args.to, value: '0' });
168
+ decimalParts(args.amount); // refuse invalid/zero amounts before metadata I/O
169
+ const token = await resolveToken(chain_id, args.token, options);
170
+ const amount = decimalUnits(args.amount, token?.decimals ?? 18);
171
+ return validateTx(chain_id, token ? {
172
+ to: token.address, value: '0', data: '0xa9059cbb' + transaction.to.slice(2).toLowerCase().padStart(64, '0') + BigInt(amount).toString(16).padStart(64, '0'),
173
+ } : { to: transaction.to, value: amount, data: '0x' });
174
+ }
package/dist/index.d.ts CHANGED
@@ -1,3 +1,6 @@
1
+ export { getBalance, getTxStatus } from './reads.js';
2
+ export { buildTokenTransfer, resolveToken, rpcUrlsFromJson, evmRpc, ChainDataError, DEFAULT_RPC_URLS } from './chain-data.js';
3
+ export type { ChainDataOptions, TokenMetadata } from './chain-data.js';
1
4
  export { ClankerWallet, RelayError } from './client.js';
2
5
  export type { ClankerWalletOptions } from './client.js';
3
6
  export { base64ToBytes, bytesToBase64, decryptMessage, encryptMessage, fromHex, generateKeypair, keypairFromSecret, signMessage, signingKeypairFromSeed, toHex, verifySignature } from './crypto.js';
package/dist/index.js CHANGED
@@ -1,3 +1,5 @@
1
+ export { getBalance, getTxStatus } from './reads.js';
2
+ export { buildTokenTransfer, resolveToken, rpcUrlsFromJson, evmRpc, ChainDataError, DEFAULT_RPC_URLS } from './chain-data.js';
1
3
  export { ClankerWallet, RelayError } from './client.js';
2
4
  export { base64ToBytes, bytesToBase64, decryptMessage, encryptMessage, fromHex, generateKeypair, keypairFromSecret, signMessage, signingKeypairFromSeed, toHex, verifySignature } from './crypto.js';
3
5
  export { TypedEmitter } from './events.js';
package/dist/mcp.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { type ChainDataOptions } from './chain-data.js';
1
2
  import { type ClankerWallet } from './client.js';
2
3
  /**
3
4
  * MCP server for `clanker-wallet mcp`: newline-delimited JSON-RPC 2.0 over stdio, hand-written so
@@ -17,11 +18,13 @@ export interface SavedPairing {
17
18
  chain_family?: string;
18
19
  chain_network?: string;
19
20
  }
20
- /** the families the tools request on; chia waits until the web app shows it again */
21
- type RequestFamily = 'evm' | 'bitcoin';
22
- export interface McpServerOptions {
21
+ /** the families the tools request on */
22
+ type RequestFamily = 'evm' | 'bitcoin' | 'chia';
23
+ export interface McpServerOptions extends ChainDataOptions {
23
24
  /** the package version, reported in initialize */
24
25
  version: string;
26
+ /** review app origin (or CLANKER_WALLET_WEB_ORIGIN; default https://clanker-wallet.xyz) */
27
+ webOrigin?: string;
25
28
  identity: {
26
29
  agent_id: string;
27
30
  fingerprint: string;
@@ -76,6 +79,11 @@ export declare const MCP_TOOLS: ({
76
79
  };
77
80
  request_id?: undefined;
78
81
  wait_seconds?: undefined;
82
+ family?: undefined;
83
+ network?: undefined;
84
+ address?: undefined;
85
+ token?: undefined;
86
+ tx_hash?: undefined;
79
87
  };
80
88
  required: string[];
81
89
  };
@@ -126,8 +134,70 @@ export declare const MCP_TOOLS: ({
126
134
  type: string;
127
135
  description: string;
128
136
  };
137
+ fee: {
138
+ type: string;
139
+ description: string;
140
+ };
141
+ request_id?: undefined;
142
+ wait_seconds?: undefined;
143
+ chain_id?: undefined;
144
+ address?: undefined;
145
+ token?: undefined;
146
+ tx_hash?: undefined;
147
+ };
148
+ required: string[];
149
+ };
150
+ annotations: {
151
+ readOnlyHint: boolean;
152
+ destructiveHint: boolean;
153
+ idempotentHint: boolean;
154
+ openWorldHint: boolean;
155
+ };
156
+ } | {
157
+ name: string;
158
+ description: string;
159
+ inputSchema: {
160
+ type: string;
161
+ properties: {
162
+ from: {
163
+ type: string;
164
+ description: string;
165
+ };
166
+ reason: {
167
+ type: string;
168
+ description: string;
169
+ };
170
+ urgency: {
171
+ type: string;
172
+ enum: string[];
173
+ description: string;
174
+ };
175
+ expected_outcome: {
176
+ type: string;
177
+ description: string;
178
+ };
179
+ chain_id: {
180
+ type: string;
181
+ description: string;
182
+ };
183
+ to: {
184
+ type: string;
185
+ description: string;
186
+ };
187
+ token: {
188
+ type: string;
189
+ description: string;
190
+ };
191
+ amount: {
192
+ type: string;
193
+ description: string;
194
+ };
129
195
  request_id?: undefined;
130
196
  wait_seconds?: undefined;
197
+ family?: undefined;
198
+ network?: undefined;
199
+ address?: undefined;
200
+ tx_hash?: undefined;
131
201
  };
132
202
  required: string[];
133
203
  };
@@ -153,6 +223,12 @@ export declare const MCP_TOOLS: ({
153
223
  maximum: number;
154
224
  description: string;
155
225
  };
226
+ family?: undefined;
227
+ chain_id?: undefined;
228
+ network?: undefined;
229
+ address?: undefined;
230
+ token?: undefined;
231
+ tx_hash?: undefined;
156
232
  };
157
233
  required: string[];
158
234
  };
@@ -160,6 +236,79 @@ export declare const MCP_TOOLS: ({
160
236
  readOnlyHint: boolean;
161
237
  openWorldHint: boolean;
162
238
  };
239
+ } | {
240
+ name: string;
241
+ description: string;
242
+ inputSchema: {
243
+ type: string;
244
+ properties: {
245
+ family: {
246
+ type: string;
247
+ enum: string[];
248
+ description: string;
249
+ };
250
+ chain_id: {
251
+ type: string;
252
+ description: string;
253
+ };
254
+ network: {
255
+ type: string;
256
+ enum: string[];
257
+ description: string;
258
+ };
259
+ address: {
260
+ type: string;
261
+ description: string;
262
+ };
263
+ token: {
264
+ type: string;
265
+ description: string;
266
+ };
267
+ request_id?: undefined;
268
+ wait_seconds?: undefined;
269
+ tx_hash?: undefined;
270
+ };
271
+ required: string[];
272
+ };
273
+ annotations: {
274
+ openWorldHint: boolean;
275
+ readOnlyHint: boolean;
276
+ };
277
+ } | {
278
+ name: string;
279
+ description: string;
280
+ inputSchema: {
281
+ type: string;
282
+ properties: {
283
+ family: {
284
+ type: string;
285
+ enum: string[];
286
+ description: string;
287
+ };
288
+ chain_id: {
289
+ type: string;
290
+ description: string;
291
+ };
292
+ network: {
293
+ type: string;
294
+ enum: string[];
295
+ description: string;
296
+ };
297
+ tx_hash: {
298
+ type: string;
299
+ description: string;
300
+ };
301
+ request_id?: undefined;
302
+ wait_seconds?: undefined;
303
+ address?: undefined;
304
+ token?: undefined;
305
+ };
306
+ required: string[];
307
+ };
308
+ annotations: {
309
+ openWorldHint: boolean;
310
+ readOnlyHint: boolean;
311
+ };
163
312
  } | {
164
313
  name: string;
165
314
  description: string;
@@ -168,6 +317,12 @@ export declare const MCP_TOOLS: ({
168
317
  properties: {
169
318
  request_id?: undefined;
170
319
  wait_seconds?: undefined;
320
+ family?: undefined;
321
+ chain_id?: undefined;
322
+ network?: undefined;
323
+ address?: undefined;
324
+ token?: undefined;
325
+ tx_hash?: undefined;
171
326
  };
172
327
  required?: undefined;
173
328
  };
package/dist/mcp.js CHANGED
@@ -1,3 +1,5 @@
1
+ import { getBalance, getTxStatus } from './reads.js';
2
+ import { buildTokenTransfer, ChainDataError, rpcUrlsFromJson } from './chain-data.js';
1
3
  import { RelayError } from './client.js';
2
4
  import { InvalidTxError, validateTransfer, validateTx } from './validate.js';
3
5
  /**
@@ -13,7 +15,7 @@ export const MAX_WAIT_SECONDS = 50;
13
15
  const DEFAULT_REQUEST_TTL_MS = 3_600_000;
14
16
  /** 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
17
  const DEFAULT_JOIN_TIMEOUT_MS = 20_000;
16
- const REQUEST_FAMILIES = ['evm', 'bitcoin'];
18
+ const REQUEST_FAMILIES = ['evm', 'bitcoin', 'chia'];
17
19
  const URGENCIES = ['low', 'medium', 'high'];
18
20
  const CONTEXT_PROPERTIES = {
19
21
  reason: { type: 'string', description: 'why you want this, in plain words; shown to the human on the review card' },
@@ -22,7 +24,7 @@ const CONTEXT_PROPERTIES = {
22
24
  };
23
25
  /** the same on both request tools; the hosted connector copies these definitions verbatim */
24
26
  const FROM_PROPERTY = {
25
- from: { type: 'string', description: 'the paired wallet address to send from, as list_pairings shows it; needed only when several wallets of the same family (evm or bitcoin) are paired' },
27
+ from: { type: 'string', description: 'the paired wallet address to send from, as list_pairings shows it; needed only when several wallets of the same family (evm, bitcoin or chia) are paired' },
26
28
  };
27
29
  /**
28
30
  * MCP tool annotations: assistants (ChatGPT, dots, Claude) may skip their confirmation prompt for read-only tools.
@@ -36,7 +38,7 @@ export const MCP_TOOLS = [
36
38
  name: 'request_evm_tx',
37
39
  description: 'Ask the human to send an EVM transaction (ETH or a contract call) from their own wallet. ' +
38
40
  'Nothing is signed here: the human approves every request on their own device, or rejects it. ' +
39
- 'Returns { request_id } once the request is delivered; then call get_request for the outcome. ' +
41
+ 'Returns { request_id, review_url } once delivered; show review_url to the user, then call get_request for the outcome. ' +
40
42
  'value is an integer string in wei (1 ETH = "1000000000000000000"), no decimals.',
41
43
  inputSchema: {
42
44
  type: 'object',
@@ -54,17 +56,23 @@ export const MCP_TOOLS = [
54
56
  },
55
57
  {
56
58
  name: 'request_transfer',
57
- description: 'Ask the human to send bitcoin from their own wallet, which builds, signs and broadcasts it. ' +
59
+ description: 'Ask the human to send bitcoin or chia (XCH) from their own wallet, which builds, signs and broadcasts it. ' +
58
60
  'Nothing is signed here: the human approves every request on their own device, or rejects it. ' +
59
- 'Returns { request_id } once the request is delivered; then call get_request for the outcome. ' +
60
- 'amount is an integer string in sats (1 BTC = "100000000"), no decimals; the wallet sets the fee.',
61
+ 'Returns { request_id, review_url } once delivered; show review_url to the user, then call get_request for the outcome. ' +
62
+ 'amount is an integer string, no decimals: sats for bitcoin (1 BTC = "100000000"), where the wallet sets the fee; ' +
63
+ 'mojos for chia (1 XCH = "1000000000000"), with an optional fee in mojos (default "0").',
61
64
  inputSchema: {
62
65
  type: 'object',
63
66
  properties: {
64
- family: { type: 'string', enum: ['bitcoin'], description: 'chain family' },
65
- network: { type: 'string', enum: ['mainnet', 'testnet', 'signet'], description: 'must match the paired wallet (see list_pairings)' },
66
- to: { type: 'string', description: 'segwit address on that network: bc1... on mainnet, tb1... on testnet and signet' },
67
- amount: { type: 'string', description: 'integer string in sats' },
67
+ family: { type: 'string', enum: ['bitcoin', 'chia'], description: 'chain family' },
68
+ network: {
69
+ type: 'string',
70
+ enum: ['mainnet', 'testnet', 'signet', 'testnet11'],
71
+ description: 'bitcoin: mainnet, testnet or signet; chia: mainnet or testnet11. must match the paired wallet (see list_pairings)',
72
+ },
73
+ to: { type: 'string', description: 'address on that network: bitcoin segwit bc1... (mainnet) or tb1... (testnet, signet); chia xch1... (mainnet) or txch1... (testnet11)' },
74
+ amount: { type: 'string', description: 'integer string in sats (bitcoin) or mojos (chia)' },
75
+ fee: { type: 'string', description: 'chia only: integer string in mojos, default "0"; bitcoin takes none (the wallet sets it)' },
68
76
  ...CONTEXT_PROPERTIES,
69
77
  ...FROM_PROPERTY,
70
78
  },
@@ -72,9 +80,29 @@ export const MCP_TOOLS = [
72
80
  },
73
81
  annotations: REQUEST_HINTS,
74
82
  },
83
+ {
84
+ name: 'request_token_transfer',
85
+ description: 'Ask the human to send an EVM token or native coin from their own wallet. ' +
86
+ 'The human approves every request on their own device. amount is a human decimal string; never rounded. ' +
87
+ 'Use a listed symbol for that chain or pass the contract address; unlisted symbols are refused, never searched. ' +
88
+ 'Omit token (or use the native symbol) for native coin. Returns { request_id, review_url }; show review_url to the user, then call get_request.',
89
+ inputSchema: {
90
+ type: 'object',
91
+ properties: {
92
+ chain_id: { type: 'integer', description: 'EVM chain id' },
93
+ to: { type: 'string', description: 'recipient EVM address' },
94
+ token: { type: 'string', description: 'listed symbol on this chain or token contract address; omitted or native symbol sends native coin' },
95
+ amount: { type: 'string', description: 'positive human decimal string, e.g. 1.25; excess fraction digits are refused' },
96
+ ...CONTEXT_PROPERTIES,
97
+ ...FROM_PROPERTY,
98
+ },
99
+ required: ['chain_id', 'to', 'amount', 'reason'],
100
+ },
101
+ annotations: REQUEST_HINTS,
102
+ },
75
103
  {
76
104
  name: 'get_request',
77
- description: 'Get the outcome of a request: pending, approved (with tx_hash), rejected, failed (with error), unknown (with error) or expired. ' +
105
+ description: 'Get the outcome of a request: pending, approved (with tx_hash; a chia send may have none, so check the recipient\'s balance), rejected, failed (with error), unknown (with error) or expired. ' +
78
106
  `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. ` +
79
107
  '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. ' +
80
108
  'expired means this server stopped waiting, 1 hour after sending; after a restart it no longer knows earlier request ids. ' +
@@ -82,13 +110,44 @@ export const MCP_TOOLS = [
82
110
  inputSchema: {
83
111
  type: 'object',
84
112
  properties: {
85
- request_id: { type: 'string', description: 'from request_evm_tx or request_transfer' },
113
+ request_id: { type: 'string', description: 'from request_evm_tx, request_transfer or request_token_transfer' },
86
114
  wait_seconds: { type: 'number', minimum: 0, maximum: MAX_WAIT_SECONDS, description: 'wait up to this long for an answer (default 0)' },
87
115
  },
88
116
  required: ['request_id'],
89
117
  },
90
118
  annotations: READ_HINTS,
91
119
  },
120
+ {
121
+ name: 'get_balance',
122
+ description: 'Read the balance of any address, including a recipient not paired here. EVM native or ERC-20 (listed symbol or contract address), bitcoin confirmed/mempool, or chia unspent coins at the recipient puzzle hash (not the whole wallet; excludes mempool). After a chia send approved without a hash, check the recipient balance; a balance alone does not prove which payment arrived.',
123
+ inputSchema: {
124
+ type: 'object',
125
+ properties: {
126
+ family: { type: 'string', enum: ['evm', 'bitcoin', 'chia'], description: 'chain family' },
127
+ chain_id: { type: 'integer', description: 'required for EVM' },
128
+ network: { type: 'string', enum: ['mainnet', 'testnet', 'signet', 'testnet11'], description: 'required for bitcoin or chia; EVM uses chain_id' },
129
+ address: { type: 'string', description: 'any address on that network, including a recipient' },
130
+ token: { type: 'string', description: 'EVM only: listed symbol or contract address; omitted/native symbol = native coin' },
131
+ },
132
+ required: ['family', 'address'],
133
+ },
134
+ annotations: { ...READ_HINTS, openWorldHint: true },
135
+ },
136
+ {
137
+ name: 'get_tx_status',
138
+ description: 'Read an EVM transaction receipt status and confirmations, or bitcoin confirmations. An EVM missing receipt means pending_or_unknown, not proof of submission. After a chia approval without a hash, use get_balance on the recipient instead.',
139
+ inputSchema: {
140
+ type: 'object',
141
+ properties: {
142
+ family: { type: 'string', enum: ['evm', 'bitcoin'], description: 'chain family' },
143
+ chain_id: { type: 'integer', description: 'required for EVM' },
144
+ network: { type: 'string', enum: ['mainnet', 'testnet', 'signet'], description: 'required for bitcoin' },
145
+ tx_hash: { type: 'string', description: 'EVM 0x + 64 hex digits; bitcoin 64 hex digits' },
146
+ },
147
+ required: ['family', 'tx_hash'],
148
+ },
149
+ annotations: { ...READ_HINTS, openWorldHint: true },
150
+ },
92
151
  {
93
152
  name: 'list_pairings',
94
153
  description: 'List the wallets you can request on (chain family, network, wallet address, mode) and your agent fingerprint, ' +
@@ -98,7 +157,7 @@ export const MCP_TOOLS = [
98
157
  },
99
158
  ];
100
159
  const INSTRUCTIONS = 'clanker-wallet sends transaction requests to a human, who approves or rejects each one in their own wallet; ' +
101
- 'you never hold keys. Call list_pairings to see which chains you can use, request_evm_tx or request_transfer to ask, ' +
160
+ 'you never hold keys. Call list_pairings to see which chains you can use, request_evm_tx, request_transfer or request_token_transfer to ask, ' +
102
161
  'and get_request (with wait_seconds) until the request is no longer pending.';
103
162
  /** what get_request reports, in the order the docs list them */
104
163
  export const REQUEST_STATUSES = ['pending', 'approved', 'rejected', 'failed', 'unknown', 'expired'];
@@ -160,10 +219,14 @@ function fromMismatch(family, from, pairing) {
160
219
  const NOT_PAIRED = {
161
220
  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>\'',
162
221
  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>\'',
222
+ chia: 'not paired with a chia wallet: ask the human to open the XCH tab at https://clanker-wallet.xyz, copy its pairing JSON, and run: clanker-wallet pair \'<JSON>\'',
163
223
  };
164
224
  export function createMcpServer(options) {
225
+ const webOrigin = new URL(options.webOrigin ?? process.env.CLANKER_WALLET_WEB_ORIGIN ?? 'https://clanker-wallet.xyz').origin;
226
+ const reviewUrl = (id) => `${webOrigin}/?request=${encodeURIComponent(id)}`;
165
227
  const ttl = options.requestTtlMs ?? DEFAULT_REQUEST_TTL_MS;
166
228
  const joinTimeout = options.joinTimeoutMs ?? DEFAULT_JOIN_TIMEOUT_MS;
229
+ const chainOptions = { ...options, rpcUrls: options.rpcUrls ?? rpcUrlsFromJson(process.env.CLANKER_WALLET_RPC_URLS) };
167
230
  const records = new Map();
168
231
  /** tools/call requests still being answered, by JSON-RPC id, for notifications/cancelled */
169
232
  const inFlight = new Map();
@@ -173,6 +236,8 @@ export function createMcpServer(options) {
173
236
  * join timeout, cancelled)
174
237
  */
175
238
  function submit(family, from, cancelled, send) {
239
+ if (cancelled.aborted)
240
+ return Promise.resolve(toolError('request cancelled before sending; nothing was sent'));
176
241
  const pairing = options.loadPairing(family);
177
242
  if (!pairing)
178
243
  return Promise.resolve(toolError(NOT_PAIRED[family]));
@@ -196,7 +261,7 @@ export function createMcpServer(options) {
196
261
  record = newRecord(request_id, expiresAt);
197
262
  records.set(request_id, record);
198
263
  record.settled.then(() => clearTimeout(expiry));
199
- resolve(toolResult({ request_id, status: 'pending', expires_at: new Date(expiresAt).toISOString() }));
264
+ resolve(toolResult({ request_id, review_url: reviewUrl(request_id), status: 'pending', expires_at: new Date(expiresAt).toISOString() }));
200
265
  });
201
266
  wallet.on('response', (response) => { answered = response; });
202
267
  send(wallet, { timeout_ms: ttl, join_timeout_ms: joinTimeout, signal: unsent.signal }).then((txHash) => record.settle('approved', { tx_hash: txHash }), (err) => {
@@ -225,14 +290,22 @@ export function createMcpServer(options) {
225
290
  const { chain_id, transaction } = validateTx(args.chain_id, { to: args.to, value: args.value, data: args.data ?? '0x' });
226
291
  return submit('evm', from, cancelled, (wallet, limits) => wallet.requestTx({ chain_id, transaction, context, ...limits }));
227
292
  }
293
+ async function requestTokenTransfer(args, cancelled) {
294
+ const context = contextFrom(args);
295
+ const from = fromArg(args);
296
+ const { chain_id, transaction } = await buildTokenTransfer({ chain_id: args.chain_id, to: args.to, token: args.token, amount: args.amount }, chainOptions);
297
+ return submit('evm', from, cancelled, (wallet, limits) => wallet.requestTx({ chain_id, transaction, context, ...limits }));
298
+ }
228
299
  async function requestTransfer(args, cancelled) {
229
- if (args.family !== 'bitcoin')
230
- throw new ToolInputError(`invalid family: must be "bitcoin", got ${JSON.stringify(args.family)}`);
300
+ const family = args.family;
301
+ if (family !== 'bitcoin' && family !== 'chia')
302
+ throw new ToolInputError(`invalid family: must be "bitcoin" or "chia", got ${JSON.stringify(family)}`);
231
303
  const context = contextFrom(args);
232
304
  const from = fromArg(args);
233
305
  let request;
234
306
  try {
235
- request = validateTransfer('bitcoin', args.network, { asset: 'btc', to: args.to, amount: args.amount });
307
+ const asset = family === 'bitcoin' ? 'btc' : 'xch';
308
+ request = validateTransfer(family, args.network, { asset, to: args.to, amount: args.amount, ...(args.fee !== undefined && { fee: args.fee }) });
236
309
  }
237
310
  catch (err) {
238
311
  // the tool calls the network field `network`
@@ -241,7 +314,7 @@ export function createMcpServer(options) {
241
314
  throw err;
242
315
  }
243
316
  const { chain_network, transfer } = request;
244
- return submit('bitcoin', from, cancelled, async (wallet, limits) => {
317
+ return submit(family, from, cancelled, async (wallet, limits) => {
245
318
  try {
246
319
  return await wallet.requestTransfer({ chain_network, transfer, context, ...limits });
247
320
  }
@@ -280,7 +353,7 @@ export function createMcpServer(options) {
280
353
  record.settle('expired');
281
354
  const { status, tx_hash, error } = record;
282
355
  if (status === 'pending')
283
- return toolResult({ request_id, status, expires_at: new Date(record.expiresAt).toISOString() });
356
+ return toolResult({ request_id, review_url: reviewUrl(request_id), status, expires_at: new Date(record.expiresAt).toISOString() });
284
357
  return toolResult({ request_id, status, ...(tx_hash !== undefined && { tx_hash }), ...(error !== undefined && { error }) });
285
358
  }
286
359
  function listPairings() {
@@ -293,7 +366,10 @@ export function createMcpServer(options) {
293
366
  const TOOL_HANDLERS = {
294
367
  request_evm_tx: requestEvmTx,
295
368
  request_transfer: requestTransfer,
369
+ request_token_transfer: requestTokenTransfer,
296
370
  get_request: getRequest,
371
+ get_balance: async (args) => toolResult(await getBalance(args, chainOptions)),
372
+ get_tx_status: async (args) => toolResult(await getTxStatus(args, chainOptions)),
297
373
  list_pairings: listPairings,
298
374
  };
299
375
  async function callTool(params, cancelled) {
@@ -309,7 +385,7 @@ export function createMcpServer(options) {
309
385
  return await handler(args, cancelled);
310
386
  }
311
387
  catch (err) {
312
- if (err instanceof ToolInputError || err instanceof InvalidTxError)
388
+ if (err instanceof ToolInputError || err instanceof InvalidTxError || err instanceof ChainDataError)
313
389
  return toolError(err.message);
314
390
  throw err;
315
391
  }
@@ -0,0 +1,51 @@
1
+ import { type ChainDataOptions } from './chain-data.js';
2
+ export declare function getBalance(args: Record<string, unknown>, options?: ChainDataOptions): Promise<{
3
+ decimals: number;
4
+ raw_balance: string;
5
+ balance: string;
6
+ token_address?: string | undefined;
7
+ family: string;
8
+ chain_id: number;
9
+ address: string;
10
+ asset: string;
11
+ network?: undefined;
12
+ confirmed_balance?: undefined;
13
+ unconfirmed_balance?: undefined;
14
+ scope?: undefined;
15
+ } | {
16
+ family: string;
17
+ network: string;
18
+ address: string;
19
+ asset: string;
20
+ decimals: number;
21
+ raw_balance: string;
22
+ balance: string;
23
+ confirmed_balance: string;
24
+ unconfirmed_balance: string;
25
+ scope?: undefined;
26
+ } | {
27
+ family: string;
28
+ network: string;
29
+ address: unknown;
30
+ asset: string;
31
+ decimals: number;
32
+ raw_balance: string;
33
+ balance: string;
34
+ scope: string;
35
+ confirmed_balance?: undefined;
36
+ unconfirmed_balance?: undefined;
37
+ }>;
38
+ export declare function getTxStatus(args: Record<string, unknown>, options?: ChainDataOptions): Promise<{
39
+ family: string;
40
+ chain_id: number;
41
+ tx_hash: string;
42
+ status: string;
43
+ confirmations: number;
44
+ } | {
45
+ status: string;
46
+ confirmations: number;
47
+ family: string;
48
+ network: string;
49
+ tx_hash: string;
50
+ chain_id?: undefined;
51
+ }>;
package/dist/reads.js ADDED
@@ -0,0 +1,210 @@
1
+ import { decodeChiaAddress, decodeSegwitAddress } from './bech32.js';
2
+ import { abiUint, ChainDataError, evmRpc, nativeSymbol, readChainResponse, resolveToken } from './chain-data.js';
3
+ import { validateTx } from './validate.js';
4
+ const BITCOIN_API = {
5
+ mainnet: 'https://mempool.space/api', testnet: 'https://mempool.space/testnet/api', signet: 'https://mempool.space/signet/api',
6
+ };
7
+ const MAX_CHIA_COIN_RECORDS = 10_000;
8
+ const CHIA_API = { mainnet: 'https://api.coinset.org', testnet11: 'https://testnet11.api.coinset.org' };
9
+ const object = (value) => {
10
+ if (!value || typeof value !== 'object' || Array.isArray(value))
11
+ throw new ChainDataError('malformed chain response');
12
+ return value;
13
+ };
14
+ const nonnegativeInteger = (value) => {
15
+ if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 0)
16
+ throw new ChainDataError('invalid chain integer');
17
+ return value;
18
+ };
19
+ function quantity(value) {
20
+ if (typeof value !== 'string' || !/^0x(?:0|[1-9a-fA-F][0-9a-fA-F]*)$/.test(value) || value.length > 66)
21
+ throw new ChainDataError('invalid RPC quantity');
22
+ return BigInt(value);
23
+ }
24
+ function formatUnits(raw, decimals) {
25
+ const negative = raw < 0n;
26
+ const digits = (negative ? -raw : raw).toString().padStart(decimals + 1, '0');
27
+ const fraction = decimals ? digits.slice(-decimals).replace(/0+$/, '') : '';
28
+ return (negative ? '-' : '') + (decimals ? digits.slice(0, -decimals) : digits) + (fraction ? '.' + fraction : '');
29
+ }
30
+ function network(args, endpoints) {
31
+ if (typeof args.network !== 'string' || !Object.hasOwn(endpoints, args.network))
32
+ throw new ChainDataError(`invalid network: must be ${Object.keys(endpoints).join(', ')}`);
33
+ if (args.chain_id !== undefined || args.token !== undefined)
34
+ throw new ChainDataError('chain_id and token are EVM-only');
35
+ return args.network;
36
+ }
37
+ async function http(url, options, init, allowMissing = false) {
38
+ try {
39
+ const result = await (options.fetch ?? globalThis.fetch)(url, { ...init, signal: AbortSignal.timeout(10_000) });
40
+ if (!result.ok) {
41
+ await result.body?.cancel();
42
+ if (!(allowMissing && result.status === 404))
43
+ throw new Error();
44
+ }
45
+ return result;
46
+ }
47
+ catch {
48
+ throw new ChainDataError('public chain API read failed; retry later');
49
+ }
50
+ }
51
+ async function json(response) {
52
+ const text = await readChainResponse(response);
53
+ try {
54
+ return JSON.parse(text);
55
+ }
56
+ catch {
57
+ throw new ChainDataError('malformed chain JSON response');
58
+ }
59
+ }
60
+ /** Validate legacy Base58Check as well as segwit recipients; no URL/path comes from the caller. */
61
+ async function bitcoinAddress(address, chainNetwork) {
62
+ if (typeof address !== 'string' || address.length > 90)
63
+ throw new ChainDataError('invalid bitcoin address');
64
+ if (decodeSegwitAddress(chainNetwork === 'mainnet' ? 'bc' : 'tb', address))
65
+ return address.toLowerCase();
66
+ const alphabet = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz';
67
+ if (address.length < 26 || address.length > 35 || [...address].some(c => !alphabet.includes(c)))
68
+ throw new ChainDataError('invalid bitcoin address for network');
69
+ let value = 0n;
70
+ for (const c of address)
71
+ value = value * 58n + BigInt(alphabet.indexOf(c));
72
+ let hex = value.toString(16);
73
+ if (hex.length % 2)
74
+ hex = '0' + hex;
75
+ const bytes = Uint8Array.from([...Array(address.match(/^1*/)?.[0].length ?? 0).fill(0), ...hex.match(/../g).map(b => parseInt(b, 16))]);
76
+ const versions = chainNetwork === 'mainnet' ? [0, 5] : [111, 196];
77
+ if (bytes.length !== 25 || !versions.includes(bytes[0]))
78
+ throw new ChainDataError('invalid bitcoin address for network');
79
+ const first = await crypto.subtle.digest('SHA-256', bytes.slice(0, 21));
80
+ const checksum = new Uint8Array(await crypto.subtle.digest('SHA-256', first));
81
+ if (!bytes.slice(21).every((b, i) => b === checksum[i]))
82
+ throw new ChainDataError('invalid bitcoin address checksum');
83
+ return address;
84
+ }
85
+ /** Preserve uint64 coin amounts using native JSON source context; older runtimes refuse unsafe numbers. */
86
+ function coinJson(text) {
87
+ try {
88
+ return object(JSON.parse(text, (_key, value, context) => {
89
+ if (typeof value !== 'number')
90
+ return value;
91
+ if (context?.source)
92
+ return context.source;
93
+ if (Number.isSafeInteger(value))
94
+ return String(value);
95
+ throw new Error('unsafe coin amount');
96
+ }));
97
+ }
98
+ catch {
99
+ throw new ChainDataError('malformed or imprecise coin-record response');
100
+ }
101
+ }
102
+ export async function getBalance(args, options = {}) {
103
+ if (args.family === 'evm') {
104
+ if (args.network !== undefined)
105
+ throw new ChainDataError('EVM reads use chain_id, not network');
106
+ const { chain_id, transaction } = validateTx(args.chain_id, { to: args.address, value: '0' });
107
+ const token = await resolveToken(chain_id, args.token, options);
108
+ const result = token
109
+ ? await evmRpc(chain_id, 'eth_call', [{ to: token.address, data: '0x70a08231' + transaction.to.slice(2).toLowerCase().padStart(64, '0') }, 'latest'], options)
110
+ : await evmRpc(chain_id, 'eth_getBalance', [transaction.to, 'latest'], options);
111
+ const raw = token ? abiUint(result) : quantity(result);
112
+ const decimals = token?.decimals ?? 18;
113
+ return { family: 'evm', chain_id, address: transaction.to, asset: token?.symbol ?? nativeSymbol(chain_id), ...(token && { token_address: token.address }), decimals, raw_balance: raw.toString(), balance: formatUnits(raw, decimals) };
114
+ }
115
+ if (args.family === 'bitcoin') {
116
+ const chainNetwork = network(args, BITCOIN_API);
117
+ const address = await bitcoinAddress(args.address, chainNetwork);
118
+ const body = object(await json(await http(`${BITCOIN_API[chainNetwork]}/address/${encodeURIComponent(address)}`, options)));
119
+ const stats = (field) => {
120
+ const row = object(body[field]);
121
+ return BigInt(nonnegativeInteger(row.funded_txo_sum)) - BigInt(nonnegativeInteger(row.spent_txo_sum));
122
+ };
123
+ const confirmed = stats('chain_stats');
124
+ const pending = stats('mempool_stats');
125
+ if (confirmed < 0n || confirmed + pending < 0n)
126
+ throw new ChainDataError('invalid bitcoin balance response');
127
+ return { family: 'bitcoin', network: chainNetwork, address, asset: 'BTC', decimals: 8, raw_balance: String(confirmed + pending), balance: formatUnits(confirmed + pending, 8), confirmed_balance: formatUnits(confirmed, 8), unconfirmed_balance: formatUnits(pending, 8) };
128
+ }
129
+ if (args.family === 'chia') {
130
+ const chainNetwork = network(args, CHIA_API);
131
+ const puzzle = typeof args.address === 'string' && decodeChiaAddress(chainNetwork === 'mainnet' ? 'xch' : 'txch', args.address);
132
+ if (!puzzle)
133
+ throw new ChainDataError('invalid chia address for network');
134
+ const puzzleHash = '0x' + puzzle.map(b => b.toString(16).padStart(2, '0')).join('');
135
+ const response = await http(`${CHIA_API[chainNetwork]}/get_coin_records_by_puzzle_hash`, options, {
136
+ method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ puzzle_hash: puzzleHash, include_spent_coins: false }),
137
+ });
138
+ const body = coinJson(await readChainResponse(response));
139
+ if (body.success !== true || !Array.isArray(body.coin_records))
140
+ throw new ChainDataError('coin-record API did not return a successful balance');
141
+ if (body.coin_records.length > MAX_CHIA_COIN_RECORDS)
142
+ throw new ChainDataError('too many coin records: limit 10000; no partial balance returned');
143
+ let raw = 0n;
144
+ const seen = new Set();
145
+ for (const item of body.coin_records) {
146
+ const record = object(item);
147
+ if (record.spent === true)
148
+ continue;
149
+ if (record.spent !== false)
150
+ throw new ChainDataError('invalid coin-record spent flag');
151
+ const coin = object(record.coin);
152
+ if (coin.puzzle_hash !== puzzleHash || typeof coin.parent_coin_info !== 'string' || !/^0x[0-9a-fA-F]{64}$/.test(coin.parent_coin_info) || typeof coin.amount !== 'string' || !/^(0|[1-9][0-9]*)$/.test(coin.amount) || coin.amount.length > 20)
153
+ throw new ChainDataError('invalid coin-record identity or amount');
154
+ const amount = BigInt(coin.amount);
155
+ if (amount >= 2n ** 64n)
156
+ throw new ChainDataError('coin amount exceeds uint64');
157
+ const id = coin.parent_coin_info.toLowerCase() + ':' + coin.amount;
158
+ if (seen.has(id))
159
+ throw new ChainDataError('duplicate coin record');
160
+ seen.add(id);
161
+ raw += amount;
162
+ }
163
+ return { family: 'chia', network: chainNetwork, address: args.address, asset: 'XCH', decimals: 12, raw_balance: raw.toString(), balance: formatUnits(raw, 12), scope: 'unspent coins at this puzzle hash; not the whole wallet; excludes mempool' };
164
+ }
165
+ throw new ChainDataError('invalid family: must be evm, bitcoin or chia');
166
+ }
167
+ export async function getTxStatus(args, options = {}) {
168
+ if (args.family === 'evm') {
169
+ if (args.network !== undefined)
170
+ throw new ChainDataError('EVM reads use chain_id, not network');
171
+ const { chain_id } = validateTx(args.chain_id, { to: '0x' + '0'.repeat(40), value: '0' });
172
+ if (typeof args.tx_hash !== 'string' || !/^0x[0-9a-fA-F]{64}$/.test(args.tx_hash))
173
+ throw new ChainDataError('invalid EVM tx_hash');
174
+ const receipt = await evmRpc(chain_id, 'eth_getTransactionReceipt', [args.tx_hash], options);
175
+ if (receipt === null)
176
+ return { family: 'evm', chain_id, tx_hash: args.tx_hash, status: 'pending_or_unknown', confirmations: 0 };
177
+ const row = object(receipt);
178
+ if (row.status !== '0x0' && row.status !== '0x1')
179
+ throw new ChainDataError('invalid receipt status');
180
+ const height = quantity(row.blockNumber);
181
+ const tip = quantity(await evmRpc(chain_id, 'eth_blockNumber', [], options));
182
+ if (tip < height || tip - height >= BigInt(Number.MAX_SAFE_INTEGER))
183
+ throw new ChainDataError('inconsistent receipt/block height; retry after chain reorganization');
184
+ return { family: 'evm', chain_id, tx_hash: args.tx_hash, status: row.status === '0x1' ? 'confirmed' : 'failed', confirmations: Number(tip - height + 1n) };
185
+ }
186
+ if (args.family === 'bitcoin') {
187
+ const chainNetwork = network(args, BITCOIN_API);
188
+ if (typeof args.tx_hash !== 'string' || !/^[0-9a-fA-F]{64}$/.test(args.tx_hash))
189
+ throw new ChainDataError('invalid bitcoin tx_hash');
190
+ const base = BITCOIN_API[chainNetwork];
191
+ const response = await http(`${base}/tx/${args.tx_hash}/status`, options, undefined, true);
192
+ const common = { family: 'bitcoin', network: chainNetwork, tx_hash: args.tx_hash };
193
+ if (response.status === 404)
194
+ return { ...common, status: 'unknown', confirmations: 0 };
195
+ const row = object(await json(response));
196
+ if (row.confirmed === false)
197
+ return { ...common, status: 'pending', confirmations: 0 };
198
+ if (row.confirmed !== true)
199
+ throw new ChainDataError('invalid bitcoin transaction status');
200
+ const height = nonnegativeInteger(row.block_height);
201
+ const tipText = await readChainResponse(await http(`${base}/blocks/tip/height`, options));
202
+ if (!/^(0|[1-9][0-9]*)$/.test(tipText))
203
+ throw new ChainDataError('invalid bitcoin tip height');
204
+ const tip = nonnegativeInteger(Number(tipText));
205
+ if (tip < height)
206
+ throw new ChainDataError('inconsistent bitcoin block height; retry');
207
+ return { ...common, status: 'confirmed', confirmations: tip - height + 1 };
208
+ }
209
+ throw new ChainDataError('transaction status supports evm or bitcoin; after a chia send without a hash, use get_balance on the recipient');
210
+ }
@@ -0,0 +1,9 @@
1
+ /** Browser-safe, data-only token list. Addresses, not symbols, identify assets. */
2
+ export interface ListedToken {
3
+ readonly chain_id: number;
4
+ readonly address: string;
5
+ readonly symbol: string;
6
+ readonly decimals: number;
7
+ readonly label: string;
8
+ }
9
+ export declare const TOKENS: readonly ListedToken[];
package/dist/tokens.js ADDED
@@ -0,0 +1,142 @@
1
+ export const TOKENS = [
2
+ {
3
+ "chain_id": 1,
4
+ "address": "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48",
5
+ "symbol": "USDC",
6
+ "decimals": 6,
7
+ "label": "USDC"
8
+ },
9
+ {
10
+ "chain_id": 1,
11
+ "address": "0xdac17f958d2ee523a2206206994597c13d831ec7",
12
+ "symbol": "USDT",
13
+ "decimals": 6,
14
+ "label": "USDT"
15
+ },
16
+ {
17
+ "chain_id": 1,
18
+ "address": "0x6b175474e89094c44da98b954eedeac495271d0f",
19
+ "symbol": "DAI",
20
+ "decimals": 18,
21
+ "label": "DAI"
22
+ },
23
+ {
24
+ "chain_id": 1,
25
+ "address": "0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2",
26
+ "symbol": "WETH",
27
+ "decimals": 18,
28
+ "label": "WETH"
29
+ },
30
+ {
31
+ "chain_id": 1,
32
+ "address": "0x2260fac5e5542a773aa44fbcfedf7c193bc2c599",
33
+ "symbol": "WBTC",
34
+ "decimals": 8,
35
+ "label": "WBTC"
36
+ },
37
+ {
38
+ "chain_id": 1,
39
+ "address": "0x514910771af9ca656af840dff83e8264ecf986ca",
40
+ "symbol": "LINK",
41
+ "decimals": 18,
42
+ "label": "LINK"
43
+ },
44
+ {
45
+ "chain_id": 1,
46
+ "address": "0x1f9840a85d5af5bf1d1762f925bdaddc4201f984",
47
+ "symbol": "UNI",
48
+ "decimals": 18,
49
+ "label": "UNI"
50
+ },
51
+ {
52
+ "chain_id": 1,
53
+ "address": "0x7fc66500c84a76ad7e9c93437bfc5ac33e2ddae9",
54
+ "symbol": "AAVE",
55
+ "decimals": 18,
56
+ "label": "AAVE"
57
+ },
58
+ {
59
+ "chain_id": 8453,
60
+ "address": "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913",
61
+ "symbol": "USDC",
62
+ "decimals": 6,
63
+ "label": "USDC (Base)"
64
+ },
65
+ {
66
+ "chain_id": 8453,
67
+ "address": "0x4200000000000000000000000000000000000006",
68
+ "symbol": "WETH",
69
+ "decimals": 18,
70
+ "label": "WETH (Base)"
71
+ },
72
+ {
73
+ "chain_id": 84532,
74
+ "address": "0x036cbd53842c5426634e7929541ec2318f3dcf7e",
75
+ "symbol": "USDC",
76
+ "decimals": 6,
77
+ "label": "USDC (Base Sepolia)"
78
+ },
79
+ {
80
+ "chain_id": 84532,
81
+ "address": "0x4200000000000000000000000000000000000006",
82
+ "symbol": "WETH",
83
+ "decimals": 18,
84
+ "label": "WETH (Base Sepolia)"
85
+ },
86
+ {
87
+ "chain_id": 42161,
88
+ "address": "0xaf88d065e77c8cc2239327c5edb3a432268e5831",
89
+ "symbol": "USDC",
90
+ "decimals": 6,
91
+ "label": "USDC (Arbitrum)"
92
+ },
93
+ {
94
+ "chain_id": 42161,
95
+ "address": "0x82af49447d8a07e3bd95bd0d56f35241523fbab1",
96
+ "symbol": "WETH",
97
+ "decimals": 18,
98
+ "label": "WETH (Arbitrum)"
99
+ },
100
+ {
101
+ "chain_id": 137,
102
+ "address": "0x3c499c542cef5e3811e1192ce70d8cc03d5c3359",
103
+ "symbol": "USDC",
104
+ "decimals": 6,
105
+ "label": "USDC (Polygon)"
106
+ },
107
+ {
108
+ "chain_id": 137,
109
+ "address": "0x0d500b1d8e8ef31e21c99d1db9a6444d3adf1270",
110
+ "symbol": "WMATIC",
111
+ "decimals": 18,
112
+ "label": "WMATIC"
113
+ },
114
+ {
115
+ "chain_id": 10,
116
+ "address": "0x0b2c639c533813f4aa9d7837caf62653d097ff85",
117
+ "symbol": "USDC",
118
+ "decimals": 6,
119
+ "label": "USDC (Optimism)"
120
+ },
121
+ {
122
+ "chain_id": 10,
123
+ "address": "0x4200000000000000000000000000000000000006",
124
+ "symbol": "WETH",
125
+ "decimals": 18,
126
+ "label": "WETH (Optimism)"
127
+ },
128
+ {
129
+ "chain_id": 1,
130
+ "address": "0x7f39c581f595b53c5cb19bd0b3f8da6c935e2ca0",
131
+ "symbol": "wstETH",
132
+ "decimals": 18,
133
+ "label": "Lido wstETH"
134
+ },
135
+ {
136
+ "chain_id": 1,
137
+ "address": "0xae7ab96520de3a18e5e111b5eaab095312d7fe84",
138
+ "symbol": "stETH",
139
+ "decimals": 18,
140
+ "label": "Lido stETH"
141
+ }
142
+ ];
package/package.json CHANGED
@@ -1,10 +1,14 @@
1
1
  {
2
2
  "name": "clanker-wallet",
3
- "version": "0.8.0",
3
+ "version": "0.10.0",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
7
7
  "exports": {
8
+ "./tokens": {
9
+ "types": "./dist/tokens.d.ts",
10
+ "import": "./dist/tokens.js"
11
+ },
8
12
  ".": {
9
13
  "import": "./dist/index.js",
10
14
  "types": "./dist/index.d.ts"