agentwallet-mcp 1.9.0 → 1.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
@@ -4,7 +4,7 @@ Permissionless wallet infrastructure for AI agents. Create wallets, sign transac
4
4
 
5
5
  **Your keys can stay on your machine.** Set one environment variable and every signature happens in your own process. No server sees the key, no company can freeze the wallet, and you can verify that claim by reading `src/local-wallet.ts` or by running the server with the API pointed at a closed port.
6
6
 
7
- **No KYC. No KYT. No approval process. No transaction monitoring. No one can block your wallet. Pay with USDC on-chain — no credit card required.**
7
+ **No KYC. No KYT. No approval process. No transaction monitoring. No one can block your wallet. Pay with USDC on-chain, no credit card required.**
8
8
 
9
9
  ## Two modes
10
10
 
@@ -12,9 +12,9 @@ Permissionless wallet infrastructure for AI agents. Create wallets, sign transac
12
12
  |---|---|---|
13
13
  | Who holds the key | You, in your own process | Encrypted on AgentWallet servers |
14
14
  | Set up | One env var, no account needed | API key |
15
- | Chains | EVM | EVM + Solana |
15
+ | Chains | EVM + Solana | EVM + Solana |
16
16
  | Can anyone freeze it | No | Yes, that is what pause is for |
17
- | Spend guards | `AGENTWALLET_MAX_TX_NATIVE`, `AGENTWALLET_MAX_AUTOPAY` | Server-side limits, pause, rate limits |
17
+ | Spend guards | `AGENTWALLET_MAX_TX_NATIVE`, `AGENTWALLET_MAX_TX_SOL`, `AGENTWALLET_MAX_AUTOPAY` | Server-side limits, pause, rate limits |
18
18
  | Paywalls, usage, billing | Needs an API key too | Included |
19
19
 
20
20
  Run `wallet_mode` at any time and the server will tell you which one you are in, and which address it controls.
@@ -43,35 +43,57 @@ Use `AGENTWALLET_KEYFILE=/path/to/key` instead if you would rather keep the key
43
43
 
44
44
  `AGENTWALLET_MAX_TX_NATIVE` is a per-transaction ceiling in native units. In hosted mode the server enforces limits; in local mode there is no server, so this guard is the only one there is. Set it.
45
45
 
46
- **Local mode is EVM only right now.** Solana calls are refused with a clear error rather than quietly falling back to the custodial API, because silently moving your funds onto someone else's signer is exactly the behavior this mode exists to prevent.
46
+ ### Solana local signing
47
+
48
+ ```json
49
+ "env": {
50
+ "AGENTWALLET_SOLANA_KEY": "[12,34...]",
51
+ "AGENTWALLET_SOLANA_RPC": "https://your-own-rpc",
52
+ "AGENTWALLET_MAX_TX_SOL": "1"
53
+ }
54
+ ```
55
+
56
+ Accepts whichever format you already have: a `solana-keygen` id.json array, a base58 secret key as exported by Phantom, or base64. Use `AGENTWALLET_SOLANA_KEYFILE` to point at a file instead. Native SOL and SPL token transfers are both signed locally, and a missing associated token account is created for the recipient automatically.
57
+
58
+ Set either key, or both. They are independent: run EVM locally and Solana hosted, or the reverse.
59
+
60
+ **An operation with no matching local key is refused, never silently routed to the hosted signer.** If you have an EVM key configured and ask for a Solana transfer with no Solana key, the server stops and tells you which variable is missing. Quietly moving funds onto a key you do not hold, while you believe you are in self-custody, is the worst thing this server could do.
61
+
62
+ ### About the dependency tree
63
+
64
+ Local signing uses [viem](https://viem.sh) for EVM and `@solana/web3.js` for Solana, plus `bs58` for key parsing. SPL instructions are built by hand rather than with `@solana/spl-token`, because that package pulls in `bigint-buffer`, which carries a high severity buffer overflow advisory. A wallet has no business shipping that to save a dozen lines of instruction encoding.
65
+
66
+ `npm audit` currently reports issues inside `@modelcontextprotocol/sdk`'s HTTP transport dependencies. This server speaks stdio, so that code never loads, and the SDK is not something this package can patch. Run the audit yourself. Publishing a tree you can inspect is the point.
47
67
 
48
68
  <p align="center">
49
- <img src="assets/demo.svg" alt="AgentWallet demo — AI agent pays x402 invoice automatically" width="800">
69
+ <img src="assets/demo.svg" alt="AgentWallet demo, AI agent pays x402 invoice automatically" width="800">
50
70
  </p>
51
71
 
52
72
  ## Features
53
73
 
54
- - **31 MCP tools** — create wallets, send transactions, approve tokens, wrap ETH, transfer SPL tokens, pay and accept x402 payments, verify custody mode, and more
55
- - **EVM + Solana** — Ethereum, Base, Polygon, BSC, Arbitrum, Optimism, Avalanche, Zora, PulseChain, Solana, and any other EVM-compatible chain
56
- - **SOL + SPL tokens** — native SOL transfers and SPL token transfers (USDC, USDT, etc.) with automatic account creation
57
- - **Built-in guards** — daily spending limits, gas price protection, emergency pause, rate limiting, replay protection, and on-chain verification — all active by default
58
- - **x402 payments** — pay for x402-enabled APIs automatically, or accept x402 payments on your own endpoints (EVM and Solana)
59
- - **Self-custody option** — run local mode and the key never leaves your machine. In hosted mode, keys are encrypted at rest, decrypted only during signing, and zeroed from memory immediately after. Either way you can export and walk away.
60
- - **Permissionless** — No KYC. No KYT. No identity verification. No approval process. No compliance gatekeeping. Sign up, get an API key, and start transacting immediately.
61
- - **30-second setup** — three lines of config. No SDK to install. No dependencies to manage.
74
+ - **31 MCP tools**: create wallets, send transactions, approve tokens, wrap ETH, transfer SPL tokens, pay and accept x402 payments, verify custody mode, and more
75
+ - **EVM + Solana**: Ethereum, Base, Polygon, BSC, Arbitrum, Optimism, Avalanche, Zora, PulseChain, Solana, and any other EVM-compatible chain
76
+ - **SOL + SPL tokens**: native SOL transfers and SPL token transfers (USDC, USDT, etc.) with automatic account creation
77
+ - **Built-in guards**: daily spending limits, gas price protection, emergency pause, rate limiting, replay protection, and on-chain verification, all active by default
78
+ - **x402 payments**: pay for x402-enabled APIs automatically, or accept x402 payments on your own endpoints (EVM and Solana)
79
+ - **Self-custody option**: run local mode and the key never leaves your machine. In hosted mode, keys are encrypted at rest, decrypted only during signing, and zeroed from memory immediately after. Either way you can export and walk away.
80
+ - **Permissionless**: No KYC. No KYT. No identity verification. No approval process. No compliance gatekeeping. Sign up, get an API key, and start transacting immediately.
81
+ - **30-second setup**: three lines of config. No SDK to install. No dependencies to manage.
62
82
 
63
83
  ## Pricing
64
84
 
65
- - **$0.00345 per operation** (31% under Coinbase CDP list pricing, checked 2026-07-29)
85
+ - **$0.00345 per operation**
66
86
  - **6,000 free operations/month**
67
- - **$0.0005 per x402 verification** (50% under Coinbase, checked 2026-07-29)
87
+ - **$0.0005 per x402 verification**
68
88
  - **1,000 free x402 verifications/month**
69
- - **Pay with USDC on-chain** via x402 — no credit card required
70
- - No monthly fee, no tiers — just pay as you go
89
+ - **Pay with USDC on-chain** via x402, no credit card required
90
+ - No monthly fee, no tiers, just pay as you go
91
+
92
+ Competitor comparisons are kept at [hifriendbot.com/wallet/#pricing](https://hifriendbot.com/wallet/#pricing) with the date they were last verified. They live there rather than here because a published npm README cannot be corrected when someone else changes their prices.
71
93
 
72
94
  ## Quick Start
73
95
 
74
- Get your free API key at [hifriendbot.com/wallet](https://hifriendbot.com/wallet) — no credit card required, no KYC, no approval wait.
96
+ Get your free API key at [hifriendbot.com/wallet](https://hifriendbot.com/wallet), no credit card required, no KYC, no approval wait.
75
97
 
76
98
  ### Claude Desktop / OpenClaw
77
99
 
@@ -239,14 +261,14 @@ When an agent hits the paywall URL:
239
261
  3. Retries with proof of payment
240
262
  4. Receives the protected content
241
263
 
242
- On-chain verification ensures every payment is real. Replay protection prevents double-spending. Revenue tracking shows you who paid, how much, and when. 1,000 free verifications/month, then $0.0005 each, 50% under Coinbase as of 2026-07-29.
264
+ On-chain verification ensures every payment is real. Replay protection prevents double-spending. Revenue tracking shows you who paid, how much, and when. 1,000 free verifications/month, then $0.0005 each. See the [pricing comparison](https://hifriendbot.com/wallet/#pricing) for how that stacks up.
243
265
 
244
266
  ## How We Compare
245
267
 
246
268
  | Feature | Coinbase CDP | AgentWallet |
247
269
  |---------|-------------|-------------|
248
270
  | Setup Time | Install SDK + configure | **3 lines of config** |
249
- | Approval Process | Identity verification required | **None — instant access** |
271
+ | Approval Process | Identity verification required | **None, instant access** |
250
272
  | KYC Required | Yes | **No** |
251
273
  | KYT / Transaction Monitoring | Yes | **No** |
252
274
  | Can Block Your Wallet | Yes | **No** |
@@ -261,27 +283,27 @@ On-chain verification ensures every payment is real. Replay protection prevents
261
283
  | Token Tools | Yes | **ERC-20 + SPL (29 tools)** |
262
284
  | MCP Server | Yes | **Yes** |
263
285
 
264
- ## Pay with Crypto — No Credit Card Required
286
+ ## Pay with Crypto, No Credit Card Required
265
287
 
266
- AgentWallet is the only AI agent wallet infrastructure that accepts crypto for its own API fees. Every competitor — Coinbase CDP, Circle, MoonPay, Crossmint, Turnkey — requires a credit card or monthly invoice. With AgentWallet, your agent can pay for operations with USDC on-chain via the x402 protocol. No credit card, no invoice, no billing portal. Just on-chain payments.
288
+ AgentWallet is the only AI agent wallet infrastructure that accepts crypto for its own API fees. Every competitor, Coinbase CDP, Circle, MoonPay, Crossmint, Turnkey, requires a credit card or monthly invoice. With AgentWallet, your agent can pay for operations with USDC on-chain via the x402 protocol. No credit card, no invoice, no billing portal. Just on-chain payments.
267
289
 
268
290
  When your agent exceeds the free tier (6,000 ops/month) without a credit card configured, the API returns HTTP 402 with USDC payment instructions. Your agent pays on-chain, retries with proof of payment, and the operation executes. Fully automated via the MCP server.
269
291
 
270
- You can also pre-purchase x402 verification credits with USDC using the `buy_verification_credits` tool — keeping your paywalls running beyond the free 1,000 verifications/month without needing a credit card.
292
+ You can also pre-purchase x402 verification credits with USDC using the `buy_verification_credits` tool, keeping your paywalls running beyond the free 1,000 verifications/month without needing a credit card.
271
293
 
272
294
  ## Built-in Guards
273
295
 
274
- All guards are active by default — no configuration required.
275
-
276
- - **Encrypted at rest** — private keys encrypted before storage and never leave the server
277
- - **Memory zeroing** — keys wiped from memory immediately after every signing operation
278
- - **Daily spending limits** — set a per-wallet daily cap in USD, enforced automatically on every transaction
279
- - **Gas price protection** — transactions blocked when gas prices spike above safe thresholds
280
- - **Emergency pause** — instantly freeze any wallet or all wallets with one click
281
- - **Rate limiting** — API requests capped per minute to prevent abuse and brute force attacks
282
- - **Replay protection** — every x402 payment verified on-chain with unique transaction tracking
283
- - **On-chain verification** — x402 payments verified directly on the blockchain with finalized commitment
284
- - Bug bounty program: $50–$500 for responsible disclosure ([details](https://hifriendbot.com/wallet))
296
+ All guards are active by default, no configuration required.
297
+
298
+ - **Encrypted at rest**: private keys encrypted before storage and never leave the server
299
+ - **Memory zeroing**: keys wiped from memory immediately after every signing operation
300
+ - **Daily spending limits**: set a per-wallet daily cap in USD, enforced automatically on every transaction
301
+ - **Gas price protection**: transactions blocked when gas prices spike above safe thresholds
302
+ - **Emergency pause**: instantly freeze any wallet or all wallets with one click
303
+ - **Rate limiting**: API requests capped per minute to prevent abuse and brute force attacks
304
+ - **Replay protection**: every x402 payment verified on-chain with unique transaction tracking
305
+ - **On-chain verification**: x402 payments verified directly on the blockchain with finalized commitment
306
+ - Bug bounty program: $50,$500 for responsible disclosure ([details](https://hifriendbot.com/wallet))
285
307
 
286
308
  ## Links
287
309
 
package/build/index.js CHANGED
@@ -14,6 +14,11 @@ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
14
14
  import { z } from 'zod';
15
15
  import { deriveX402Payment, isWithinCap } from './x402-payment.js';
16
16
  import { isLocalMode, getLocalAddress, localWalletRecord, localSend, localNativeBalance, localTokenBalance, localSignMessage, resolveRpcUrl, } from './local-wallet.js';
17
+ import { isSolanaLocalMode, getSolanaAddress, solanaWalletRecord, localSolBalance, localSplBalance, localSolTransfer, localSplTransfer, resolveSolanaRpc, } from './local-solana.js';
18
+ /** True when either chain family is running non-custodially. */
19
+ function anyLocalMode() {
20
+ return isLocalMode() || isSolanaLocalMode();
21
+ }
17
22
  // ─── Configuration ──────────────────────────────────────────────
18
23
  const API_BASE = process.env.AGENTWALLET_API_URL || 'https://hifriendbot.com/wp-json/agentwallet/v1';
19
24
  const API_USER = process.env.AGENTWALLET_USER || '';
@@ -38,21 +43,43 @@ function localChainId(body, qs) {
38
43
  return parseInt(fromEnv, 10);
39
44
  return 8453; // Base, matching the custodial default
40
45
  }
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.');
46
+ /**
47
+ * Refuse rather than silently hand the operation to someone else's signer.
48
+ *
49
+ * Reached when the operator is running non-custodially for one chain family but
50
+ * has supplied no key for the other. Falling through to the hosted API here
51
+ * would move funds onto a key they do not hold, while they believe they are in
52
+ * self-custody. That is the single worst thing this server could do, so it
53
+ * stops and says exactly which variable is missing.
54
+ */
55
+ function refuseMissingLocalKey(family) {
56
+ const wanted = family === 'solana'
57
+ ? 'AGENTWALLET_SOLANA_KEY (or AGENTWALLET_SOLANA_KEYFILE)'
58
+ : 'AGENTWALLET_PRIVATE_KEY (or AGENTWALLET_KEYFILE)';
59
+ throw new Error(`This is a ${family === 'solana' ? 'Solana' : 'EVM'} operation and no local ${family === 'solana' ? 'Solana' : 'EVM'} key is configured, ` +
60
+ `but this server is running in local signing mode. Refusing rather than routing it through the hosted ` +
61
+ `custodial signer while you believe you hold the keys. Set ${wanted} to sign it here, or unset the other ` +
62
+ `local key variables to use the hosted path for everything.`);
45
63
  }
46
64
  async function routeLocally(path, method, body) {
47
65
  const [rawPath, rawQs = ''] = path.split('?');
48
66
  const qs = new URLSearchParams(rawQs);
49
67
  // POST /wallets/{id}/send
50
68
  if (method === 'POST' && /^\/wallets\/[^/]*\/send$/.test(rawPath)) {
51
- if (body?.token_mint)
52
- refuseSolanaLocally();
53
69
  const chainId = localChainId(body, qs);
54
- if (SOLANA_CHAIN_IDS.has(chainId))
55
- refuseSolanaLocally();
70
+ const solanaOp = SOLANA_CHAIN_IDS.has(chainId) || Boolean(body?.token_mint);
71
+ if (solanaOp) {
72
+ if (!isSolanaLocalMode())
73
+ refuseMissingLocalKey('solana');
74
+ const to = String(body?.to || '');
75
+ const value = String(body?.value ?? '0');
76
+ if (body?.token_mint) {
77
+ return localSplTransfer(String(body.token_mint), to, value, Number(body.token_decimals ?? 6), chainId);
78
+ }
79
+ return localSolTransfer(to, value, chainId);
80
+ }
81
+ if (!isLocalMode())
82
+ refuseMissingLocalKey('evm');
56
83
  const to = String(body?.to || '');
57
84
  const value = BigInt(String(body?.value ?? '0'));
58
85
  const data = String(body?.data || '');
@@ -65,16 +92,27 @@ async function routeLocally(path, method, body) {
65
92
  // GET /wallets/{id}/balance
66
93
  if (method === 'GET' && /^\/wallets\/[^/]*\/balance$/.test(rawPath)) {
67
94
  const chainId = localChainId(body, qs);
68
- if (SOLANA_CHAIN_IDS.has(chainId))
69
- refuseSolanaLocally();
95
+ if (SOLANA_CHAIN_IDS.has(chainId)) {
96
+ if (!isSolanaLocalMode())
97
+ refuseMissingLocalKey('solana');
98
+ return localSolBalance(chainId);
99
+ }
100
+ if (!isLocalMode())
101
+ refuseMissingLocalKey('evm');
70
102
  return localNativeBalance(chainId);
71
103
  }
72
104
  // GET /wallets/{id}/token-balance
73
105
  if (method === 'GET' && /^\/wallets\/[^/]*\/token-balance$/.test(rawPath)) {
74
106
  const chainId = localChainId(body, qs);
75
- if (SOLANA_CHAIN_IDS.has(chainId))
76
- refuseSolanaLocally();
77
- const token = qs.get('token') || qs.get('token_address') || '';
107
+ const token0 = qs.get('token') || qs.get('token_address') || '';
108
+ if (SOLANA_CHAIN_IDS.has(chainId)) {
109
+ if (!isSolanaLocalMode())
110
+ refuseMissingLocalKey('solana');
111
+ return localSplBalance(token0, chainId);
112
+ }
113
+ if (!isLocalMode())
114
+ refuseMissingLocalKey('evm');
115
+ const token = token0;
78
116
  if (!/^0x[a-fA-F0-9]{40}$/.test(token)) {
79
117
  throw new Error(`Local mode needs an EVM token address, got "${token}".`);
80
118
  }
@@ -88,11 +126,18 @@ async function routeLocally(path, method, body) {
88
126
  throw new Error('Local mode signs messages in-process. For transactions use send_transaction, ' +
89
127
  'which signs and broadcasts locally in one step.');
90
128
  }
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();
129
+ // Wallet listing and lookup describe whichever local keys are configured.
130
+ if (method === 'GET' && rawPath === '/wallets') {
131
+ const wallets = [];
132
+ if (isLocalMode())
133
+ wallets.push(localWalletRecord());
134
+ if (isSolanaLocalMode())
135
+ wallets.push(solanaWalletRecord());
136
+ return { wallets };
137
+ }
138
+ if (method === 'GET' && /^\/wallets\/[^/]+$/.test(rawPath)) {
139
+ return isLocalMode() ? localWalletRecord() : solanaWalletRecord();
140
+ }
96
141
  if (method === 'POST' && rawPath === '/wallets') {
97
142
  throw new Error('Local signing mode uses the single key you supplied, so there is nothing to create. ' +
98
143
  'Generate another key yourself and run a second instance with a different ' +
@@ -112,7 +157,7 @@ async function api(path, method = 'GET', body, extraHeaders) {
112
157
  /* Local mode intercepts here rather than inside each tool. Routing at the
113
158
  single choke point every funds operation already passes through means no
114
159
  tool can quietly stay custodial because someone forgot to update it. */
115
- if (isLocalMode()) {
160
+ if (anyLocalMode()) {
116
161
  const handled = await routeLocally(path, method, body);
117
162
  if (handled !== NOT_HANDLED)
118
163
  return handled;
@@ -132,7 +177,7 @@ async function api(path, method = 'GET', body, extraHeaders) {
132
177
  const res = await fetch(url, options);
133
178
  const data = await res.json();
134
179
  // 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']) {
180
+ if (res.status === 402 && (X402_WALLET_ID || anyLocalMode()) && !extraHeaders?.['X-PAYMENT']) {
136
181
  return handleX402Payment(data, path, method, body);
137
182
  }
138
183
  if (!res.ok) {
@@ -1193,31 +1238,53 @@ server.tool('get_x402_revenue', 'Get aggregate x402 revenue statistics across al
1193
1238
  // ─── Tool: wallet_mode ──────────────────────────────────────────
1194
1239
  server.tool('wallet_mode', 'Report whether this server is signing locally (self-custody, the private key never leaves this machine) ' +
1195
1240
  'or through the hosted AgentWallet API (custodial). Use this to verify custody before moving funds.', {}, async () => {
1196
- if (!isLocalMode()) {
1241
+ if (!anyLocalMode()) {
1197
1242
  return jsonResponse({
1198
1243
  mode: 'custodial',
1199
1244
  custody: 'agentwallet',
1200
1245
  signing: 'AgentWallet servers hold an encrypted key and sign on your behalf.',
1201
1246
  api_base: API_BASE,
1202
- to_self_custody: 'Set AGENTWALLET_PRIVATE_KEY (or AGENTWALLET_KEYFILE) and restart. ' +
1247
+ to_self_custody: 'Set AGENTWALLET_PRIVATE_KEY for EVM and/or AGENTWALLET_SOLANA_KEY for Solana, then restart. ' +
1203
1248
  'Use export_wallet_key first if you want to carry an existing hosted wallet across.',
1204
1249
  });
1205
1250
  }
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({
1251
+ const report = {
1212
1252
  mode: 'local',
1213
1253
  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',
1254
+ signing: 'Signed in this process. Keys are never sent to AgentWallet or anyone else.',
1219
1255
  max_autopay: process.env.AGENTWALLET_MAX_AUTOPAY || '1',
1220
- });
1256
+ };
1257
+ if (isLocalMode()) {
1258
+ let rpc = 'default public endpoint';
1259
+ try {
1260
+ rpc = resolveRpcUrl(parseInt(process.env.AGENTWALLET_CHAIN_ID || '8453', 10));
1261
+ }
1262
+ catch { /* chain has no default; not worth failing the report over */ }
1263
+ report.evm = {
1264
+ address: getLocalAddress(),
1265
+ rpc_endpoint: rpc,
1266
+ per_tx_cap_native: process.env.AGENTWALLET_MAX_TX_NATIVE || 'not set',
1267
+ };
1268
+ }
1269
+ else {
1270
+ report.evm = 'no local EVM key. EVM operations are refused, not sent to the hosted signer.';
1271
+ }
1272
+ if (isSolanaLocalMode()) {
1273
+ let rpc = 'default public endpoint';
1274
+ try {
1275
+ rpc = resolveSolanaRpc(900);
1276
+ }
1277
+ catch { /* fall through to the default label */ }
1278
+ report.solana = {
1279
+ address: getSolanaAddress(),
1280
+ rpc_endpoint: rpc,
1281
+ per_tx_cap_sol: process.env.AGENTWALLET_MAX_TX_SOL || 'not set',
1282
+ };
1283
+ }
1284
+ else {
1285
+ report.solana = 'no local Solana key. Solana operations are refused, not sent to the hosted signer.';
1286
+ }
1287
+ return jsonResponse(report);
1221
1288
  });
1222
1289
  // ─── Tool: export_wallet_key ────────────────────────────────────
1223
1290
  server.tool('export_wallet_key', 'Explain how to export the private key of a hosted (custodial) AgentWallet so it can be moved ' +
@@ -1225,12 +1292,12 @@ server.tool('export_wallet_key', 'Explain how to export the private key of a hos
1225
1292
  'performed by this tool.', {
1226
1293
  wallet_id: z.number().int().optional().describe('Hosted wallet ID to export'),
1227
1294
  }, async ({ wallet_id }) => {
1228
- if (isLocalMode()) {
1295
+ if (anyLocalMode()) {
1229
1296
  return jsonResponse({
1230
1297
  exportable: true,
1231
1298
  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.',
1299
+ note: 'You are already in self-custody. The keys are the ones you supplied via AGENTWALLET_PRIVATE_KEY, ' +
1300
+ 'AGENTWALLET_SOLANA_KEY or their KEYFILE variants, and this server keeps no copy beyond this process.',
1234
1301
  });
1235
1302
  }
1236
1303
  return jsonResponse({
@@ -1252,12 +1319,17 @@ async function main() {
1252
1319
  await server.connect(transport);
1253
1320
  /* stderr, so it never corrupts the stdio JSON-RPC stream. Announcing custody
1254
1321
  at startup means an operator sees which mode they are in without asking. */
1255
- if (isLocalMode()) {
1322
+ if (anyLocalMode()) {
1256
1323
  try {
1257
- console.error(`AgentWallet MCP: LOCAL signing mode. Address ${getLocalAddress()}. Key never leaves this machine.`);
1324
+ const parts = [];
1325
+ if (isLocalMode())
1326
+ parts.push(`EVM ${getLocalAddress()}`);
1327
+ if (isSolanaLocalMode())
1328
+ parts.push(`Solana ${getSolanaAddress()}`);
1329
+ console.error(`AgentWallet MCP: LOCAL signing mode. ${parts.join(', ')}. Keys never leave this machine.`);
1258
1330
  }
1259
1331
  catch (e) {
1260
- console.error(`AgentWallet MCP: local signing configured but the key could not be loaded: ${e.message}`);
1332
+ console.error(`AgentWallet MCP: local signing configured but a key could not be loaded: ${e.message}`);
1261
1333
  process.exit(1);
1262
1334
  }
1263
1335
  }
@@ -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
+ };
@@ -0,0 +1,252 @@
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 { readFileSync } from 'node:fs';
15
+ import { Connection, Keypair, PublicKey, SystemProgram, Transaction, TransactionInstruction, sendAndConfirmTransaction, LAMPORTS_PER_SOL, } from '@solana/web3.js';
16
+ import bs58 from 'bs58';
17
+ /* ── Program IDs ─────────────────────────────────────────────────── */
18
+ const TOKEN_PROGRAM = new PublicKey('TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA');
19
+ const TOKEN_2022_PROGRAM = new PublicKey('TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb');
20
+ const ASSOCIATED_TOKEN_PROGRAM = new PublicKey('ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL');
21
+ /* ── Key loading ─────────────────────────────────────────────────── */
22
+ let cached = null;
23
+ /**
24
+ * Accepts every format a Solana user is likely to already have:
25
+ * - solana-keygen id.json, a JSON array of 64 bytes
26
+ * - base58 secret key, what Phantom and friends export
27
+ * - base64, 64 bytes, or the 96-byte sodium keypair AgentWallet exports
28
+ * for hosted Solana wallets (its first 64 bytes are the secret key)
29
+ */
30
+ function parseSolanaKey(raw) {
31
+ const text = raw.trim();
32
+ if (text.startsWith('[')) {
33
+ const arr = JSON.parse(text);
34
+ if (!Array.isArray(arr) || arr.length < 64) {
35
+ throw new Error('Solana key JSON must be an array of at least 64 bytes.');
36
+ }
37
+ return Keypair.fromSecretKey(Uint8Array.from(arr.slice(0, 64)));
38
+ }
39
+ // base64 tends to contain characters base58 never does.
40
+ if (/[+/=]/.test(text)) {
41
+ const buf = Buffer.from(text, 'base64');
42
+ if (buf.length !== 64 && buf.length !== 96) {
43
+ throw new Error(`Base64 Solana key must decode to 64 or 96 bytes, got ${buf.length}.`);
44
+ }
45
+ return Keypair.fromSecretKey(Uint8Array.from(buf.subarray(0, 64)));
46
+ }
47
+ const decoded = bs58.decode(text);
48
+ if (decoded.length !== 64) {
49
+ throw new Error(`Base58 Solana key must decode to 64 bytes, got ${decoded.length}.`);
50
+ }
51
+ return Keypair.fromSecretKey(decoded);
52
+ }
53
+ export function isSolanaLocalMode() {
54
+ return Boolean((process.env.AGENTWALLET_SOLANA_KEY || '').trim() ||
55
+ (process.env.AGENTWALLET_SOLANA_KEYFILE || '').trim());
56
+ }
57
+ export function getSolanaKeypair() {
58
+ if (cached)
59
+ return cached;
60
+ const inline = (process.env.AGENTWALLET_SOLANA_KEY || '').trim();
61
+ const file = (process.env.AGENTWALLET_SOLANA_KEYFILE || '').trim();
62
+ let raw = inline;
63
+ if (!raw && file) {
64
+ try {
65
+ raw = readFileSync(file, 'utf8');
66
+ }
67
+ catch (e) {
68
+ throw new Error(`Could not read AGENTWALLET_SOLANA_KEYFILE at ${file}: ${e.message}`);
69
+ }
70
+ }
71
+ if (!raw)
72
+ throw new Error('Solana local signing is not configured.');
73
+ try {
74
+ cached = parseSolanaKey(raw);
75
+ }
76
+ catch (e) {
77
+ // Re-thrown deliberately without the key material in the message.
78
+ throw new Error(`Invalid Solana key: ${e.message}`);
79
+ }
80
+ return cached;
81
+ }
82
+ export function getSolanaAddress() {
83
+ return getSolanaKeypair().publicKey.toBase58();
84
+ }
85
+ /* ── Connection ──────────────────────────────────────────────────── */
86
+ const DEFAULT_SOLANA_RPC = {
87
+ 900: 'https://api.mainnet-beta.solana.com',
88
+ 901: 'https://api.devnet.solana.com',
89
+ 902: 'https://api.testnet.solana.com',
90
+ };
91
+ export function resolveSolanaRpc(chainId = 900) {
92
+ const explicit = (process.env.AGENTWALLET_SOLANA_RPC || '').trim();
93
+ if (explicit)
94
+ return explicit;
95
+ const fallback = DEFAULT_SOLANA_RPC[chainId];
96
+ if (fallback)
97
+ return fallback;
98
+ throw new Error(`No Solana RPC for chain ${chainId}. Set AGENTWALLET_SOLANA_RPC.`);
99
+ }
100
+ function connection(chainId = 900) {
101
+ return new Connection(resolveSolanaRpc(chainId), 'confirmed');
102
+ }
103
+ /* ── Guards ──────────────────────────────────────────────────────── */
104
+ function assertWithinSolCap(lamports) {
105
+ const cap = (process.env.AGENTWALLET_MAX_TX_SOL || '').trim();
106
+ if (!cap)
107
+ return;
108
+ if (!/^\d+(\.\d+)?$/.test(cap)) {
109
+ throw new Error(`AGENTWALLET_MAX_TX_SOL must be a decimal number, got "${cap}".`);
110
+ }
111
+ const [whole, frac = ''] = cap.split('.');
112
+ const capLamports = BigInt(whole + frac.padEnd(9, '0').slice(0, 9));
113
+ if (lamports > capLamports) {
114
+ throw new Error(`Blocked by local guard: amount exceeds AGENTWALLET_MAX_TX_SOL (${cap} SOL).`);
115
+ }
116
+ }
117
+ /* ── Reads ───────────────────────────────────────────────────────── */
118
+ export async function localSolBalance(chainId = 900) {
119
+ const conn = connection(chainId);
120
+ const owner = getSolanaKeypair().publicKey;
121
+ const lamports = await conn.getBalance(owner);
122
+ return {
123
+ address: owner.toBase58(),
124
+ chain_id: chainId,
125
+ balance_lamports: String(lamports),
126
+ balance: String(lamports / LAMPORTS_PER_SOL),
127
+ mode: 'local',
128
+ };
129
+ }
130
+ /** Which token program owns this mint: classic SPL or Token-2022. */
131
+ async function tokenProgramFor(conn, mint) {
132
+ const info = await conn.getAccountInfo(mint);
133
+ if (!info)
134
+ throw new Error(`Mint ${mint.toBase58()} not found on this cluster.`);
135
+ if (info.owner.equals(TOKEN_2022_PROGRAM))
136
+ return TOKEN_2022_PROGRAM;
137
+ return TOKEN_PROGRAM;
138
+ }
139
+ function associatedTokenAddress(owner, mint, tokenProgram) {
140
+ const [ata] = PublicKey.findProgramAddressSync([owner.toBuffer(), tokenProgram.toBuffer(), mint.toBuffer()], ASSOCIATED_TOKEN_PROGRAM);
141
+ return ata;
142
+ }
143
+ export async function localSplBalance(mintStr, chainId = 900) {
144
+ const conn = connection(chainId);
145
+ const owner = getSolanaKeypair().publicKey;
146
+ const mint = new PublicKey(mintStr);
147
+ const tokenProgram = await tokenProgramFor(conn, mint);
148
+ const ata = associatedTokenAddress(owner, mint, tokenProgram);
149
+ const res = await conn.getTokenAccountBalance(ata).catch(() => null);
150
+ return {
151
+ address: owner.toBase58(),
152
+ chain_id: chainId,
153
+ mint: mintStr,
154
+ token_account: ata.toBase58(),
155
+ balance_raw: res?.value.amount ?? '0',
156
+ balance: res?.value.uiAmountString ?? '0',
157
+ decimals: res?.value.decimals ?? 0,
158
+ mode: 'local',
159
+ };
160
+ }
161
+ export async function localSolTransfer(to, lamports, chainId = 900) {
162
+ const amount = BigInt(lamports);
163
+ assertWithinSolCap(amount);
164
+ const conn = connection(chainId);
165
+ const payer = getSolanaKeypair();
166
+ const tx = new Transaction().add(SystemProgram.transfer({
167
+ fromPubkey: payer.publicKey,
168
+ toPubkey: new PublicKey(to),
169
+ lamports: Number(amount),
170
+ }));
171
+ const signature = await sendAndConfirmTransaction(conn, tx, [payer]);
172
+ return {
173
+ signature,
174
+ from: payer.publicKey.toBase58(),
175
+ to,
176
+ value: lamports,
177
+ chain_id: chainId,
178
+ mode: 'local',
179
+ signed_locally: true,
180
+ };
181
+ }
182
+ /** SPL transferChecked, opcode 12: amount as u64 LE followed by decimals. */
183
+ function transferCheckedIx(tokenProgram, source, mint, dest, owner, amount, decimals) {
184
+ const data = Buffer.alloc(10);
185
+ data.writeUInt8(12, 0);
186
+ data.writeBigUInt64LE(amount, 1);
187
+ data.writeUInt8(decimals, 9);
188
+ return new TransactionInstruction({
189
+ programId: tokenProgram,
190
+ keys: [
191
+ { pubkey: source, isSigner: false, isWritable: true },
192
+ { pubkey: mint, isSigner: false, isWritable: false },
193
+ { pubkey: dest, isSigner: false, isWritable: true },
194
+ { pubkey: owner, isSigner: true, isWritable: false },
195
+ ],
196
+ data,
197
+ });
198
+ }
199
+ /** Associated token account create, idempotent variant (opcode 1). */
200
+ function createAtaIdempotentIx(payer, ata, owner, mint, tokenProgram) {
201
+ return new TransactionInstruction({
202
+ programId: ASSOCIATED_TOKEN_PROGRAM,
203
+ keys: [
204
+ { pubkey: payer, isSigner: true, isWritable: true },
205
+ { pubkey: ata, isSigner: false, isWritable: true },
206
+ { pubkey: owner, isSigner: false, isWritable: false },
207
+ { pubkey: mint, isSigner: false, isWritable: false },
208
+ { pubkey: SystemProgram.programId, isSigner: false, isWritable: false },
209
+ { pubkey: tokenProgram, isSigner: false, isWritable: false },
210
+ ],
211
+ data: Buffer.from([1]),
212
+ });
213
+ }
214
+ /**
215
+ * Transfer SPL tokens. The recipient's associated account is created if it does
216
+ * not exist, using the idempotent instruction so a race cannot fail the send.
217
+ */
218
+ export async function localSplTransfer(mintStr, to, rawAmount, decimals, chainId = 900) {
219
+ const conn = connection(chainId);
220
+ const payer = getSolanaKeypair();
221
+ const mint = new PublicKey(mintStr);
222
+ const recipient = new PublicKey(to);
223
+ const tokenProgram = await tokenProgramFor(conn, mint);
224
+ const sourceAta = associatedTokenAddress(payer.publicKey, mint, tokenProgram);
225
+ const destAta = associatedTokenAddress(recipient, mint, tokenProgram);
226
+ const tx = new Transaction();
227
+ const destInfo = await conn.getAccountInfo(destAta);
228
+ if (!destInfo) {
229
+ tx.add(createAtaIdempotentIx(payer.publicKey, destAta, recipient, mint, tokenProgram));
230
+ }
231
+ tx.add(transferCheckedIx(tokenProgram, sourceAta, mint, destAta, payer.publicKey, BigInt(rawAmount), decimals));
232
+ const signature = await sendAndConfirmTransaction(conn, tx, [payer]);
233
+ return {
234
+ signature,
235
+ from: payer.publicKey.toBase58(),
236
+ to,
237
+ value: rawAmount,
238
+ chain_id: chainId,
239
+ mode: 'local',
240
+ signed_locally: true,
241
+ };
242
+ }
243
+ export function solanaWalletRecord() {
244
+ return {
245
+ id: 'local-solana',
246
+ address: getSolanaAddress(),
247
+ wallet_type: 'solana',
248
+ mode: 'local',
249
+ custody: 'self',
250
+ note: 'Signed in-process. The secret key is never sent to AgentWallet servers.',
251
+ };
252
+ }
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "agentwallet-mcp",
3
3
  "mcpName": "io.github.hifriendbot/agentwallet",
4
- "version": "1.9.0",
5
- "description": "MCP server for AgentWallet: self-custody local signing or hosted wallets. Lets AI agents sign and broadcast on EVM + Solana, pay x402 invoices, and accept x402 payments.",
4
+ "version": "1.10.0",
5
+ "description": "MCP server for AgentWallet: self-custody local signing on EVM and Solana, or hosted wallets. Sign, broadcast, pay x402 invoices, and accept x402 payments.",
6
6
  "type": "module",
7
7
  "main": "build/index.js",
8
8
  "bin": {
@@ -56,6 +56,8 @@
56
56
  },
57
57
  "dependencies": {
58
58
  "@modelcontextprotocol/sdk": "^1.11.4",
59
+ "@solana/web3.js": "^1.98.4",
60
+ "bs58": "^6.0.0",
59
61
  "viem": "^2.55.10",
60
62
  "zod": "^3.24.0"
61
63
  },