agentwallet-mcp 1.9.0 → 1.10.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/build/index.js CHANGED
@@ -12,8 +12,14 @@
12
12
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
13
13
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
14
14
  import { z } from 'zod';
15
+ import { assertPublicUrl, safeFetch } from './ssrf-guard.js';
15
16
  import { deriveX402Payment, isWithinCap } from './x402-payment.js';
16
17
  import { isLocalMode, getLocalAddress, localWalletRecord, localSend, localNativeBalance, localTokenBalance, localSignMessage, resolveRpcUrl, } from './local-wallet.js';
18
+ import { isSolanaLocalMode, getSolanaAddress, solanaWalletRecord, localSolBalance, localSplBalance, localSolTransfer, localSplTransfer, resolveSolanaRpc, } from './local-solana.js';
19
+ /** True when either chain family is running non-custodially. */
20
+ function anyLocalMode() {
21
+ return isLocalMode() || isSolanaLocalMode();
22
+ }
17
23
  // ─── Configuration ──────────────────────────────────────────────
18
24
  const API_BASE = process.env.AGENTWALLET_API_URL || 'https://hifriendbot.com/wp-json/agentwallet/v1';
19
25
  const API_USER = process.env.AGENTWALLET_USER || '';
@@ -38,21 +44,43 @@ function localChainId(body, qs) {
38
44
  return parseInt(fromEnv, 10);
39
45
  return 8453; // Base, matching the custodial default
40
46
  }
41
- function refuseSolanaLocally() {
42
- throw new Error('Local signing mode currently supports EVM chains only. Solana keys are not signed in-process yet, ' +
43
- 'so this call was refused rather than silently routed through the hosted custodial API. ' +
44
- 'Unset AGENTWALLET_PRIVATE_KEY to use the custodial path for Solana.');
47
+ /**
48
+ * Refuse rather than silently hand the operation to someone else's signer.
49
+ *
50
+ * Reached when the operator is running non-custodially for one chain family but
51
+ * has supplied no key for the other. Falling through to the hosted API here
52
+ * would move funds onto a key they do not hold, while they believe they are in
53
+ * self-custody. That is the single worst thing this server could do, so it
54
+ * stops and says exactly which variable is missing.
55
+ */
56
+ function refuseMissingLocalKey(family) {
57
+ const wanted = family === 'solana'
58
+ ? 'AGENTWALLET_SOLANA_KEY (or AGENTWALLET_SOLANA_KEYFILE)'
59
+ : 'AGENTWALLET_PRIVATE_KEY (or AGENTWALLET_KEYFILE)';
60
+ throw new Error(`This is a ${family === 'solana' ? 'Solana' : 'EVM'} operation and no local ${family === 'solana' ? 'Solana' : 'EVM'} key is configured, ` +
61
+ `but this server is running in local signing mode. Refusing rather than routing it through the hosted ` +
62
+ `custodial signer while you believe you hold the keys. Set ${wanted} to sign it here, or unset the other ` +
63
+ `local key variables to use the hosted path for everything.`);
45
64
  }
46
65
  async function routeLocally(path, method, body) {
47
66
  const [rawPath, rawQs = ''] = path.split('?');
48
67
  const qs = new URLSearchParams(rawQs);
49
68
  // POST /wallets/{id}/send
50
69
  if (method === 'POST' && /^\/wallets\/[^/]*\/send$/.test(rawPath)) {
51
- if (body?.token_mint)
52
- refuseSolanaLocally();
53
70
  const chainId = localChainId(body, qs);
54
- if (SOLANA_CHAIN_IDS.has(chainId))
55
- refuseSolanaLocally();
71
+ const solanaOp = SOLANA_CHAIN_IDS.has(chainId) || Boolean(body?.token_mint);
72
+ if (solanaOp) {
73
+ if (!isSolanaLocalMode())
74
+ refuseMissingLocalKey('solana');
75
+ const to = String(body?.to || '');
76
+ const value = String(body?.value ?? '0');
77
+ if (body?.token_mint) {
78
+ return localSplTransfer(String(body.token_mint), to, value, Number(body.token_decimals ?? 6), chainId);
79
+ }
80
+ return localSolTransfer(to, value, chainId);
81
+ }
82
+ if (!isLocalMode())
83
+ refuseMissingLocalKey('evm');
56
84
  const to = String(body?.to || '');
57
85
  const value = BigInt(String(body?.value ?? '0'));
58
86
  const data = String(body?.data || '');
@@ -65,16 +93,27 @@ async function routeLocally(path, method, body) {
65
93
  // GET /wallets/{id}/balance
66
94
  if (method === 'GET' && /^\/wallets\/[^/]*\/balance$/.test(rawPath)) {
67
95
  const chainId = localChainId(body, qs);
68
- if (SOLANA_CHAIN_IDS.has(chainId))
69
- refuseSolanaLocally();
96
+ if (SOLANA_CHAIN_IDS.has(chainId)) {
97
+ if (!isSolanaLocalMode())
98
+ refuseMissingLocalKey('solana');
99
+ return localSolBalance(chainId);
100
+ }
101
+ if (!isLocalMode())
102
+ refuseMissingLocalKey('evm');
70
103
  return localNativeBalance(chainId);
71
104
  }
72
105
  // GET /wallets/{id}/token-balance
73
106
  if (method === 'GET' && /^\/wallets\/[^/]*\/token-balance$/.test(rawPath)) {
74
107
  const chainId = localChainId(body, qs);
75
- if (SOLANA_CHAIN_IDS.has(chainId))
76
- refuseSolanaLocally();
77
- const token = qs.get('token') || qs.get('token_address') || '';
108
+ const token0 = qs.get('token') || qs.get('token_address') || '';
109
+ if (SOLANA_CHAIN_IDS.has(chainId)) {
110
+ if (!isSolanaLocalMode())
111
+ refuseMissingLocalKey('solana');
112
+ return localSplBalance(token0, chainId);
113
+ }
114
+ if (!isLocalMode())
115
+ refuseMissingLocalKey('evm');
116
+ const token = token0;
78
117
  if (!/^0x[a-fA-F0-9]{40}$/.test(token)) {
79
118
  throw new Error(`Local mode needs an EVM token address, got "${token}".`);
80
119
  }
@@ -88,11 +127,18 @@ async function routeLocally(path, method, body) {
88
127
  throw new Error('Local mode signs messages in-process. For transactions use send_transaction, ' +
89
128
  'which signs and broadcasts locally in one step.');
90
129
  }
91
- // Wallet listing and lookup describe the one local key.
92
- if (method === 'GET' && rawPath === '/wallets')
93
- return { wallets: [localWalletRecord()] };
94
- if (method === 'GET' && /^\/wallets\/[^/]+$/.test(rawPath))
95
- return localWalletRecord();
130
+ // Wallet listing and lookup describe whichever local keys are configured.
131
+ if (method === 'GET' && rawPath === '/wallets') {
132
+ const wallets = [];
133
+ if (isLocalMode())
134
+ wallets.push(localWalletRecord());
135
+ if (isSolanaLocalMode())
136
+ wallets.push(solanaWalletRecord());
137
+ return { wallets };
138
+ }
139
+ if (method === 'GET' && /^\/wallets\/[^/]+$/.test(rawPath)) {
140
+ return isLocalMode() ? localWalletRecord() : solanaWalletRecord();
141
+ }
96
142
  if (method === 'POST' && rawPath === '/wallets') {
97
143
  throw new Error('Local signing mode uses the single key you supplied, so there is nothing to create. ' +
98
144
  'Generate another key yourself and run a second instance with a different ' +
@@ -112,7 +158,7 @@ async function api(path, method = 'GET', body, extraHeaders) {
112
158
  /* Local mode intercepts here rather than inside each tool. Routing at the
113
159
  single choke point every funds operation already passes through means no
114
160
  tool can quietly stay custodial because someone forgot to update it. */
115
- if (isLocalMode()) {
161
+ if (anyLocalMode()) {
116
162
  const handled = await routeLocally(path, method, body);
117
163
  if (handled !== NOT_HANDLED)
118
164
  return handled;
@@ -132,7 +178,7 @@ async function api(path, method = 'GET', body, extraHeaders) {
132
178
  const res = await fetch(url, options);
133
179
  const data = await res.json();
134
180
  // Handle 402 Payment Required — auto-pay if a wallet or a local key is configured
135
- if (res.status === 402 && (X402_WALLET_ID || isLocalMode()) && !extraHeaders?.['X-PAYMENT']) {
181
+ if (res.status === 402 && (X402_WALLET_ID || anyLocalMode()) && !extraHeaders?.['X-PAYMENT']) {
136
182
  return handleX402Payment(data, path, method, body);
137
183
  }
138
184
  if (!res.ok) {
@@ -315,82 +361,82 @@ function resolveChainId(network) {
315
361
  // ─── Server ──────────────────────────────────────────────────────
316
362
  const server = new McpServer({
317
363
  name: 'agentwallet',
318
- version: '1.8.0',
364
+ version: '1.10.1',
319
365
  }, {
320
- instructions: `AgentWallet gives AI agents their own blockchain wallets. Private keys are encrypted server-side and never exposed — agents sign and broadcast transactions without ever touching raw keys.
321
-
322
- ## Getting Started
323
- 1. Call list_wallets to see existing wallets, or create_wallet to make a new one.
324
- 2. Fund the wallet by sending tokens to its address from an external source.
325
- 3. Use transfer (native tokens) or transfer_token (ERC-20/SPL tokens) to send funds.
326
-
327
- ## Supported Chains
328
- - **EVM chains:** Ethereum (1), Base (8453), Arbitrum (42161), Optimism (10), Polygon (137), Avalanche (43114), BSC (56), Zora (7777777), PulseChain (369)
329
- - **Solana:** Mainnet (900), Devnet (901)
330
- - Call get_chains for the full list with native tokens and stablecoin contract addresses.
331
-
332
- ## EVM vs Solana
333
- - **Addresses:** EVM uses 0x-prefixed hex (42 chars). Solana uses Base58 (32-44 chars).
334
- - **Native tokens:** EVM = ETH/POL/BNB/etc (18 decimals, wei). Solana = SOL (9 decimals, lamports).
335
- - **Token standards:** EVM uses ERC-20. Solana uses SPL tokens.
336
- - **EVM-only tools:** approve_token, get_allowance, wrap_eth, unwrap_eth, call_contract, get_token_info. These do not work on Solana.
337
- - **Works on both:** create_wallet, transfer, transfer_token, send_transaction, sign_transaction, get_balance, get_token_balance.
338
-
339
- ## Common Workflows
340
-
341
- ### Send native tokens (ETH, SOL, etc.)
342
- Use the transfer tool with a human-readable amount (e.g. "0.1" ETH). It auto-converts to wei/lamports.
343
-
344
- ### Send ERC-20 or SPL tokens
345
- Use transfer_token. You need: wallet_id, token contract address, recipient, amount, chain_id, and decimals (6 for USDC, 18 for most ERC-20s). Call get_chains to find stablecoin addresses.
346
-
347
- ### Interact with DeFi (EVM only)
348
- 1. Check allowance with get_allowance.
349
- 2. If needed, call approve_token to let the DeFi contract spend your tokens.
350
- 3. Use send_transaction with the contract's calldata to execute the interaction.
351
-
352
- ### Wrap/unwrap native tokens (EVM only)
353
- Use wrap_eth to convert ETH → WETH (required by many DeFi protocols). Use unwrap_eth to convert back.
354
-
355
- ### Read on-chain data (EVM only)
356
- Use call_contract for gas-free read-only calls (eth_call). Provide ABI-encoded calldata.
357
-
358
- ## x402 Payments — Paying for Resources
359
- The pay_x402 tool handles the full x402 payment flow automatically:
360
- 1. Fetches the URL.
361
- 2. If the server returns HTTP 402, parses the payment requirements.
362
- 3. Pays on-chain using your wallet.
363
- 4. Retries the request with proof of payment.
364
- Always set max_payment to prevent overspending (e.g. "1.00" for max 1 USDC).
365
-
366
- ## x402 Paywalls — Accepting Payments
367
- Create paywalls to charge other agents for accessing your resources:
368
- 1. create_paywall — set price, token, chain, and the resource URL to protect.
369
- 2. Share the access URL. Clients that pay on-chain get the resource; others get HTTP 402.
370
- 3. list_paywalls and get_paywall_payments to track revenue.
371
- 4. get_x402_revenue for aggregate stats across all paywalls.
372
-
373
- ## Wallet Security
374
- - Use pause_wallet to immediately freeze a wallet if compromised. No transactions can be signed while paused.
375
- - Use unpause_wallet to resume operations.
376
- - Use delete_wallet to permanently disable a wallet.
377
- - Wallets have server-enforced spending limits.
378
-
379
- ## Tool Selection Guide
380
- | Goal | Tool |
381
- |------|------|
382
- | Send ETH/SOL/native tokens | transfer |
383
- | Send USDC/ERC-20/SPL tokens | transfer_token |
384
- | Check native balance | get_balance |
385
- | Check token balance | get_token_balance |
386
- | Look up token details | get_token_info (EVM only) |
387
- | Approve DeFi spending | approve_token (EVM only) |
388
- | Check approval amount | get_allowance (EVM only) |
389
- | Read smart contract | call_contract (EVM only) |
390
- | Custom transaction | send_transaction |
391
- | Sign without broadcasting | sign_transaction |
392
- | Pay for x402 resource | pay_x402 |
393
- | Charge for your resource | create_paywall |
366
+ instructions: `AgentWallet gives AI agents their own blockchain wallets. Private keys are encrypted server-side and never exposed — agents sign and broadcast transactions without ever touching raw keys.
367
+
368
+ ## Getting Started
369
+ 1. Call list_wallets to see existing wallets, or create_wallet to make a new one.
370
+ 2. Fund the wallet by sending tokens to its address from an external source.
371
+ 3. Use transfer (native tokens) or transfer_token (ERC-20/SPL tokens) to send funds.
372
+
373
+ ## Supported Chains
374
+ - **EVM chains:** Ethereum (1), Base (8453), Arbitrum (42161), Optimism (10), Polygon (137), Avalanche (43114), BSC (56), Zora (7777777), PulseChain (369)
375
+ - **Solana:** Mainnet (900), Devnet (901)
376
+ - Call get_chains for the full list with native tokens and stablecoin contract addresses.
377
+
378
+ ## EVM vs Solana
379
+ - **Addresses:** EVM uses 0x-prefixed hex (42 chars). Solana uses Base58 (32-44 chars).
380
+ - **Native tokens:** EVM = ETH/POL/BNB/etc (18 decimals, wei). Solana = SOL (9 decimals, lamports).
381
+ - **Token standards:** EVM uses ERC-20. Solana uses SPL tokens.
382
+ - **EVM-only tools:** approve_token, get_allowance, wrap_eth, unwrap_eth, call_contract, get_token_info. These do not work on Solana.
383
+ - **Works on both:** create_wallet, transfer, transfer_token, send_transaction, sign_transaction, get_balance, get_token_balance.
384
+
385
+ ## Common Workflows
386
+
387
+ ### Send native tokens (ETH, SOL, etc.)
388
+ Use the transfer tool with a human-readable amount (e.g. "0.1" ETH). It auto-converts to wei/lamports.
389
+
390
+ ### Send ERC-20 or SPL tokens
391
+ Use transfer_token. You need: wallet_id, token contract address, recipient, amount, chain_id, and decimals (6 for USDC, 18 for most ERC-20s). Call get_chains to find stablecoin addresses.
392
+
393
+ ### Interact with DeFi (EVM only)
394
+ 1. Check allowance with get_allowance.
395
+ 2. If needed, call approve_token to let the DeFi contract spend your tokens.
396
+ 3. Use send_transaction with the contract's calldata to execute the interaction.
397
+
398
+ ### Wrap/unwrap native tokens (EVM only)
399
+ Use wrap_eth to convert ETH → WETH (required by many DeFi protocols). Use unwrap_eth to convert back.
400
+
401
+ ### Read on-chain data (EVM only)
402
+ Use call_contract for gas-free read-only calls (eth_call). Provide ABI-encoded calldata.
403
+
404
+ ## x402 Payments — Paying for Resources
405
+ The pay_x402 tool handles the full x402 payment flow automatically:
406
+ 1. Fetches the URL.
407
+ 2. If the server returns HTTP 402, parses the payment requirements.
408
+ 3. Pays on-chain using your wallet.
409
+ 4. Retries the request with proof of payment.
410
+ Always set max_payment to prevent overspending (e.g. "1.00" for max 1 USDC).
411
+
412
+ ## x402 Paywalls — Accepting Payments
413
+ Create paywalls to charge other agents for accessing your resources:
414
+ 1. create_paywall — set price, token, chain, and the resource URL to protect.
415
+ 2. Share the access URL. Clients that pay on-chain get the resource; others get HTTP 402.
416
+ 3. list_paywalls and get_paywall_payments to track revenue.
417
+ 4. get_x402_revenue for aggregate stats across all paywalls.
418
+
419
+ ## Wallet Security
420
+ - Use pause_wallet to immediately freeze a wallet if compromised. No transactions can be signed while paused.
421
+ - Use unpause_wallet to resume operations.
422
+ - Use delete_wallet to permanently disable a wallet.
423
+ - Wallets have server-enforced spending limits.
424
+
425
+ ## Tool Selection Guide
426
+ | Goal | Tool |
427
+ |------|------|
428
+ | Send ETH/SOL/native tokens | transfer |
429
+ | Send USDC/ERC-20/SPL tokens | transfer_token |
430
+ | Check native balance | get_balance |
431
+ | Check token balance | get_token_balance |
432
+ | Look up token details | get_token_info (EVM only) |
433
+ | Approve DeFi spending | approve_token (EVM only) |
434
+ | Check approval amount | get_allowance (EVM only) |
435
+ | Read smart contract | call_contract (EVM only) |
436
+ | Custom transaction | send_transaction |
437
+ | Sign without broadcasting | sign_transaction |
438
+ | Pay for x402 resource | pay_x402 |
439
+ | Charge for your resource | create_paywall |
394
440
  | Check usage/billing | get_usage |`,
395
441
  });
396
442
  // ─── Tool: create_wallet ─────────────────────────────────────────
@@ -855,19 +901,10 @@ server.tool('pay_x402', 'Handle an x402 payment flow. Fetches a URL, and if the
855
901
  throw new Error('Invalid headers JSON. Must be a JSON object (e.g. {"Authorization": "Bearer ..."}).');
856
902
  }
857
903
  }
858
- // Validate URL — block private IPs and cloud metadata endpoints
859
- const urlObj = new URL(url);
860
- const hostname = urlObj.hostname.toLowerCase();
861
- const blockedHosts = ['localhost', '127.0.0.1', '0.0.0.0', '[::1]', '169.254.169.254', 'metadata.google.internal'];
862
- const blockedPrefixes = ['10.', '172.16.', '172.17.', '172.18.', '172.19.', '172.20.', '172.21.',
863
- '172.22.', '172.23.', '172.24.', '172.25.', '172.26.', '172.27.', '172.28.', '172.29.',
864
- '172.30.', '172.31.', '192.168.'];
865
- if (blockedHosts.includes(hostname) || blockedPrefixes.some(p => hostname.startsWith(p))) {
866
- throw new Error('URL points to a private/internal address. Only public URLs are allowed.');
867
- }
868
- if (urlObj.protocol !== 'https:') {
869
- throw new Error('Only HTTPS URLs are supported for x402 payments.');
870
- }
904
+ // Validate URL: canonicalizes IP literals (including IPv4-mapped IPv6) and
905
+ // resolves DNS names, rejecting any private/internal destination. Redirect
906
+ // hops are re-validated by safeFetch below.
907
+ await assertPublicUrl(url);
871
908
  // Step 1: Make initial request
872
909
  const reqOptions = {
873
910
  method,
@@ -879,7 +916,7 @@ server.tool('pay_x402', 'Handle an x402 payment flow. Fetches a URL, and if the
879
916
  if (!reqHeaders['Content-Type'])
880
917
  reqHeaders['Content-Type'] = 'application/json';
881
918
  }
882
- const initialRes = await fetch(url, reqOptions);
919
+ const initialRes = await safeFetch(url, reqOptions);
883
920
  // If not 402, return the response as-is (no payment needed)
884
921
  if (initialRes.status !== 402) {
885
922
  const text = await initialRes.text();
@@ -1013,7 +1050,7 @@ server.tool('pay_x402', 'Handle an x402 payment flow. Fetches a URL, and if the
1013
1050
  if (reqBody && method !== 'GET') {
1014
1051
  retryOptions.body = reqBody;
1015
1052
  }
1016
- const retryRes = await fetch(url, retryOptions);
1053
+ const retryRes = await safeFetch(url, retryOptions);
1017
1054
  const retryText = await retryRes.text();
1018
1055
  let retryParsed;
1019
1056
  try {
@@ -1193,31 +1230,53 @@ server.tool('get_x402_revenue', 'Get aggregate x402 revenue statistics across al
1193
1230
  // ─── Tool: wallet_mode ──────────────────────────────────────────
1194
1231
  server.tool('wallet_mode', 'Report whether this server is signing locally (self-custody, the private key never leaves this machine) ' +
1195
1232
  'or through the hosted AgentWallet API (custodial). Use this to verify custody before moving funds.', {}, async () => {
1196
- if (!isLocalMode()) {
1233
+ if (!anyLocalMode()) {
1197
1234
  return jsonResponse({
1198
1235
  mode: 'custodial',
1199
1236
  custody: 'agentwallet',
1200
1237
  signing: 'AgentWallet servers hold an encrypted key and sign on your behalf.',
1201
1238
  api_base: API_BASE,
1202
- to_self_custody: 'Set AGENTWALLET_PRIVATE_KEY (or AGENTWALLET_KEYFILE) and restart. ' +
1239
+ to_self_custody: 'Set AGENTWALLET_PRIVATE_KEY for EVM and/or AGENTWALLET_SOLANA_KEY for Solana, then restart. ' +
1203
1240
  'Use export_wallet_key first if you want to carry an existing hosted wallet across.',
1204
1241
  });
1205
1242
  }
1206
- let rpc = 'default public endpoint';
1207
- try {
1208
- rpc = resolveRpcUrl(parseInt(process.env.AGENTWALLET_CHAIN_ID || '8453', 10));
1209
- }
1210
- catch { /* chain has no default; not worth failing the report over */ }
1211
- return jsonResponse({
1243
+ const report = {
1212
1244
  mode: 'local',
1213
1245
  custody: 'self',
1214
- address: getLocalAddress(),
1215
- signing: 'Signed in this process. The private key is never sent to AgentWallet or anyone else.',
1216
- rpc_endpoint: rpc,
1217
- chains: 'EVM only in local mode. Solana calls are refused rather than routed to the custodial API.',
1218
- per_tx_cap_native: process.env.AGENTWALLET_MAX_TX_NATIVE || 'not set',
1246
+ signing: 'Signed in this process. Keys are never sent to AgentWallet or anyone else.',
1219
1247
  max_autopay: process.env.AGENTWALLET_MAX_AUTOPAY || '1',
1220
- });
1248
+ };
1249
+ if (isLocalMode()) {
1250
+ let rpc = 'default public endpoint';
1251
+ try {
1252
+ rpc = resolveRpcUrl(parseInt(process.env.AGENTWALLET_CHAIN_ID || '8453', 10));
1253
+ }
1254
+ catch { /* chain has no default; not worth failing the report over */ }
1255
+ report.evm = {
1256
+ address: getLocalAddress(),
1257
+ rpc_endpoint: rpc,
1258
+ per_tx_cap_native: process.env.AGENTWALLET_MAX_TX_NATIVE || 'not set',
1259
+ };
1260
+ }
1261
+ else {
1262
+ report.evm = 'no local EVM key. EVM operations are refused, not sent to the hosted signer.';
1263
+ }
1264
+ if (isSolanaLocalMode()) {
1265
+ let rpc = 'default public endpoint';
1266
+ try {
1267
+ rpc = resolveSolanaRpc(900);
1268
+ }
1269
+ catch { /* fall through to the default label */ }
1270
+ report.solana = {
1271
+ address: getSolanaAddress(),
1272
+ rpc_endpoint: rpc,
1273
+ per_tx_cap_sol: process.env.AGENTWALLET_MAX_TX_SOL || 'not set',
1274
+ };
1275
+ }
1276
+ else {
1277
+ report.solana = 'no local Solana key. Solana operations are refused, not sent to the hosted signer.';
1278
+ }
1279
+ return jsonResponse(report);
1221
1280
  });
1222
1281
  // ─── Tool: export_wallet_key ────────────────────────────────────
1223
1282
  server.tool('export_wallet_key', 'Explain how to export the private key of a hosted (custodial) AgentWallet so it can be moved ' +
@@ -1225,12 +1284,12 @@ server.tool('export_wallet_key', 'Explain how to export the private key of a hos
1225
1284
  'performed by this tool.', {
1226
1285
  wallet_id: z.number().int().optional().describe('Hosted wallet ID to export'),
1227
1286
  }, async ({ wallet_id }) => {
1228
- if (isLocalMode()) {
1287
+ if (anyLocalMode()) {
1229
1288
  return jsonResponse({
1230
1289
  exportable: true,
1231
1290
  mode: 'local',
1232
- note: 'You are already in self-custody. The key is the one you supplied via AGENTWALLET_PRIVATE_KEY ' +
1233
- 'or AGENTWALLET_KEYFILE, and this server has no copy of it beyond this process.',
1291
+ note: 'You are already in self-custody. The keys are the ones you supplied via AGENTWALLET_PRIVATE_KEY, ' +
1292
+ 'AGENTWALLET_SOLANA_KEY or their KEYFILE variants, and this server keeps no copy beyond this process.',
1234
1293
  });
1235
1294
  }
1236
1295
  return jsonResponse({
@@ -1252,12 +1311,17 @@ async function main() {
1252
1311
  await server.connect(transport);
1253
1312
  /* stderr, so it never corrupts the stdio JSON-RPC stream. Announcing custody
1254
1313
  at startup means an operator sees which mode they are in without asking. */
1255
- if (isLocalMode()) {
1314
+ if (anyLocalMode()) {
1256
1315
  try {
1257
- console.error(`AgentWallet MCP: LOCAL signing mode. Address ${getLocalAddress()}. Key never leaves this machine.`);
1316
+ const parts = [];
1317
+ if (isLocalMode())
1318
+ parts.push(`EVM ${getLocalAddress()}`);
1319
+ if (isSolanaLocalMode())
1320
+ parts.push(`Solana ${getSolanaAddress()}`);
1321
+ console.error(`AgentWallet MCP: LOCAL signing mode. ${parts.join(', ')}. Keys never leave this machine.`);
1258
1322
  }
1259
1323
  catch (e) {
1260
- console.error(`AgentWallet MCP: local signing configured but the key could not be loaded: ${e.message}`);
1324
+ console.error(`AgentWallet MCP: local signing configured but a key could not be loaded: ${e.message}`);
1261
1325
  process.exit(1);
1262
1326
  }
1263
1327
  }
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Local (non-custodial) Solana signing.
3
+ *
4
+ * Same contract as local-wallet.ts on the EVM side: the secret key is loaded
5
+ * once into this process, never written anywhere, never logged, and never put
6
+ * in an error message. Transactions are signed here and submitted straight to
7
+ * an RPC endpoint. The AgentWallet API is not involved.
8
+ *
9
+ * SPL transfers are built from raw instructions rather than @solana/spl-token
10
+ * on purpose. That package pulls in bigint-buffer, which carries a high
11
+ * severity buffer overflow advisory, and a wallet has no business shipping
12
+ * that when the two instructions it needs are a dozen lines each.
13
+ */
14
+ import { Keypair } from '@solana/web3.js';
15
+ export declare function isSolanaLocalMode(): boolean;
16
+ export declare function getSolanaKeypair(): Keypair;
17
+ export declare function getSolanaAddress(): string;
18
+ export declare function resolveSolanaRpc(chainId?: number): string;
19
+ export declare function localSolBalance(chainId?: number): Promise<{
20
+ address: string;
21
+ chain_id: number;
22
+ balance_lamports: string;
23
+ balance: string;
24
+ mode: string;
25
+ }>;
26
+ export declare function localSplBalance(mintStr: string, chainId?: number): Promise<{
27
+ address: string;
28
+ chain_id: number;
29
+ mint: string;
30
+ token_account: string;
31
+ balance_raw: string;
32
+ balance: string;
33
+ decimals: number;
34
+ mode: string;
35
+ }>;
36
+ export interface LocalSolResult {
37
+ signature: string;
38
+ from: string;
39
+ to: string;
40
+ value: string;
41
+ chain_id: number;
42
+ mode: 'local';
43
+ signed_locally: true;
44
+ }
45
+ export declare function localSolTransfer(to: string, lamports: string, chainId?: number): Promise<LocalSolResult>;
46
+ /**
47
+ * Transfer SPL tokens. The recipient's associated account is created if it does
48
+ * not exist, using the idempotent instruction so a race cannot fail the send.
49
+ */
50
+ export declare function localSplTransfer(mintStr: string, to: string, rawAmount: string, decimals: number, chainId?: number): Promise<LocalSolResult>;
51
+ export declare function solanaWalletRecord(): {
52
+ id: string;
53
+ address: string;
54
+ wallet_type: string;
55
+ mode: string;
56
+ custody: string;
57
+ note: string;
58
+ };