agentwallet-mcp 1.13.9 → 1.13.10

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
@@ -37,7 +37,7 @@ Run `wallet_mode` at any time and the server will tell you which one you are in,
37
37
  }
38
38
  ```
39
39
 
40
- Use `AGENTWALLET_KEYFILE=/path/to/key` instead if you would rather keep the key out of your shell config. The key is read once, never written to disk, never logged, and never included in an error message.
40
+ Use `AGENTWALLET_KEYFILE=/path/to/key` instead if you would rather keep the key out of your shell config (one or the other: the server refuses to start when both are set). The key is read once, never written to disk, never logged, and never included in an error message.
41
41
 
42
42
  `AGENTWALLET_RPC_<chainId>` (or `AGENTWALLET_RPC_URL` for all chains) points at an endpoint you trust. Without it a public RPC is used, and a public RPC can see which addresses you ask about.
43
43
 
@@ -69,7 +69,7 @@ Local signing uses [viem](https://viem.sh) for EVM and `@solana/web3.js` for Sol
69
69
  `npm audit` reports no advisories as of 1.13.5; the overrides in package.json keep two advisories under `@solana/web3.js`'s jayson closed. Earlier releases carried advisories 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.
70
70
 
71
71
  <p align="center">
72
- <img src="assets/demo.svg" alt="AgentWallet demo, AI agent pays x402 invoice automatically" width="800">
72
+ <img src="https://raw.githubusercontent.com/hifriendbot/agentwallet-mcp/main/assets/demo.svg" alt="AgentWallet demo, AI agent pays x402 invoice automatically" width="800">
73
73
  </p>
74
74
 
75
75
  ## Features
@@ -118,7 +118,7 @@ Add to your config:
118
118
  }
119
119
  ```
120
120
 
121
- > `AGENTWALLET_WALLET_ID` is optional. Set it to enable x402 auto-pay: when you exceed the free tier without a credit card, the MCP server automatically pays for operations with USDC from this wallet.
121
+ > `AGENTWALLET_WALLET_ID` is optional. Set it to enable x402 auto-pay: when you exceed the free tier without a credit card, the MCP server automatically pays for operations with USDC from this wallet. Only the metered routes (creating a wallet, signing, sending) are paid this way, at most `AGENTWALLET_MAX_AUTOPAY_PER_HOUR` times an hour (default 20), and every automatic payment is reported in the tool result under `autopay_payment`. A 402 on any other route is reported, never paid.
122
122
  >
123
123
  > **Auto-pay safety cap.** `AGENTWALLET_MAX_AUTOPAY` (optional, default `1`) is the most one x402 payment may authorize, in stablecoin units: `1` means one dollar. It prices registry stablecoins only (USDC, USDT, USDbC, DAI on the supported chains, USDC and USDT on Solana). A requirement in the chain's native asset or in any other token is refused, because "1" measured in ETH is a few thousand dollars; list such assets in `AGENTWALLET_AUTOPAY_ASSETS` (comma-separated addresses or mints, or the word `native`) to allow them, and the cap then applies in that asset's own units. Any requirement above the cap is rejected instead of paid, so a malformed or tampered payment requirement cannot drain the wallet. This automatic path settles `exact` requirements only; an `upto` offer from the API is refused before any wallet call, because paying a usage maximum upfront is not what the offer means (use `pay_x402`, which signs a Permit2 authorization for it). The cap is the operator's ceiling: a `max_payment` argument can lower it for one call but never raise it. Raise the variable itself if you genuinely need larger automatic payments (for example `"5"` for up to 5 USDC per call).
124
124
 
@@ -337,8 +337,10 @@ Which guards apply depends on who holds the key.
337
337
  - `AGENTWALLET_MAX_AUTOPAY`: ceiling per x402 auto-payment in stablecoin units (default `1`); `AGENTWALLET_AUTOPAY_ASSETS` opts native or non-stable assets in; `AGENTWALLET_X402_MAX_TIMEOUT` bounds how long a signed authorization stays valid (default 3600 seconds)
338
338
  - `AGENTWALLET_SOLANA_RPC_<chainId>` (or `AGENTWALLET_SOLANA_RPC` for all clusters): the RPC's genesis hash is checked against the chain id before anything is sent, and an EVM RPC's `eth_chainId` likewise, so one URL cannot silently serve a different network than the one requested
339
339
  - `AGENTWALLET_TOKEN_RISK=0`: skip the GoPlus lookup `approve_token` performs (a third-party call that reveals which token you are about to approve)
340
+ - `AGENTWALLET_CHAIN_ID`: the EVM chain used when a call names none (default 8453)
341
+ - `AGENTWALLET_LEGACY_PAYWALL_ORIGINS`: comma-separated https origins whose legacy-shaped 402 may be settled by an on-chain transfer proved by hash; every other host gets a signed authorization
340
342
 
341
- All cap variables are validated at startup; a typo stops the server rather than reading as "no limit". `wallet_mode` reports every guard, including the token cap and whether unknown token calls are allowed. Node.js 22.19 or newer is required.
343
+ Every `AGENTWALLET_*` variable is validated at startup: a malformed cap stops the server rather than reading as "no limit", and an unrecognised name (a misspelled cap) stops it too. `wallet_mode` reports every guard with its parsed value. Tool arguments are strict: an unknown argument name (a typo in `max_payment`) is an error, never silently ignored. `pay_x402` signs one live authorization per resource; a second, different requirement for the same resource is refused until the first expires unless `fresh_authorization` is passed. Node.js 22.19 or newer is required.
342
344
 
343
345
  **Either mode**, for x402 paywalls you run through the hosted API:
344
346
 
package/build/index.js CHANGED
@@ -27,15 +27,35 @@ function anyLocalMode() {
27
27
  return isLocalMode() || isSolanaLocalMode();
28
28
  }
29
29
  // ─── Configuration ──────────────────────────────────────────────
30
- const API_BASE = process.env.AGENTWALLET_API_URL || 'https://hifriendbot.com/wp-json/agentwallet/v1';
31
- const API_USER = process.env.AGENTWALLET_USER || '';
32
- const API_PASS = process.env.AGENTWALLET_PASS || ''; // WordPress application password
33
- const X402_WALLET_ID = process.env.AGENTWALLET_WALLET_ID || ''; // Wallet ID for x402 auto-pay
34
- const DASHBOARD_URL = process.env.AGENTWALLET_DASHBOARD_URL || 'https://hifriendbot.com/wallet/';
30
+ const API_BASE = (process.env.AGENTWALLET_API_URL || 'https://hifriendbot.com/wp-json/agentwallet/v1').trim().replace(/\/+$/, '');
31
+ const API_USER = (process.env.AGENTWALLET_USER || '').trim();
32
+ const API_PASS = (process.env.AGENTWALLET_PASS || '').trim(); // WordPress application password
33
+ const X402_WALLET_ID = (process.env.AGENTWALLET_WALLET_ID || '').trim(); // Wallet ID for x402 auto-pay
34
+ const DASHBOARD_URL = (process.env.AGENTWALLET_DASHBOARD_URL || 'https://hifriendbot.com/wallet/').trim();
35
35
  // Basic credentials and 402 auto-pay decisions ride on this URL, so it must be
36
- // https, or loopback for a local test server. Anything else is refused at start.
37
- if (/^http:/i.test(API_BASE) && !/^http:\/\/(localhost|127\.0\.0\.1|\[::1\])(:\d+)?(\/|$)/i.test(API_BASE)) {
38
- throw new Error(`AGENTWALLET_API_URL must be https (got ${API_BASE}); plain http is allowed only for localhost.`);
36
+ // https, or loopback for a local test server. The value is parsed, not
37
+ // pattern-matched: a leading space defeated the old regex while fetch still
38
+ // sent the credentials in plaintext (2026-10-04 round 4).
39
+ {
40
+ let u;
41
+ try {
42
+ u = new URL(API_BASE);
43
+ }
44
+ catch {
45
+ throw new Error(`AGENTWALLET_API_URL is not a URL: "${API_BASE}".`);
46
+ }
47
+ const loopback = u.hostname === 'localhost' || u.hostname === '127.0.0.1' || u.hostname === '[::1]';
48
+ if (u.username || u.password)
49
+ throw new Error('AGENTWALLET_API_URL must not carry credentials; use AGENTWALLET_USER and AGENTWALLET_PASS.');
50
+ if (u.protocol !== 'https:' && !(u.protocol === 'http:' && loopback)) {
51
+ throw new Error(`AGENTWALLET_API_URL must be https (got ${u.protocol}//${u.host}); plain http is allowed only for localhost.`);
52
+ }
53
+ if (API_USER.includes(':'))
54
+ throw new Error('AGENTWALLET_USER must not contain ":" (the Basic auth separator).');
55
+ if ((API_USER === '') !== (API_PASS === ''))
56
+ console.error('AgentWallet MCP: warning, only one of AGENTWALLET_USER / AGENTWALLET_PASS is set; hosted calls will carry no credentials.');
57
+ if (X402_WALLET_ID && !/^\d+$/.test(X402_WALLET_ID))
58
+ throw new Error(`AGENTWALLET_WALLET_ID must be a wallet id (digits only), got "${X402_WALLET_ID}".`);
39
59
  }
40
60
  /* ── Local-mode routing ──────────────────────────────────────────
41
61
  Translates the REST paths the tools already speak into in-process signing
@@ -288,7 +308,7 @@ async function api(path, method = 'GET', body, extraHeaders) {
288
308
  function autopayRouteAllowed(path, method) {
289
309
  return method === 'POST' && /^\/wallets(\/\d+\/(sign|send))?(\?|$)/.test(path);
290
310
  }
291
- const AUTOPAY_PER_HOUR = (() => { const n = Number(process.env.AGENTWALLET_MAX_AUTOPAY_PER_HOUR ?? '20'); return Number.isInteger(n) && n >= 0 ? n : 20; })();
311
+ const AUTOPAY_PER_HOUR = (() => { const raw = (process.env.AGENTWALLET_MAX_AUTOPAY_PER_HOUR ?? '20').trim(); return /^\d+$/.test(raw) ? Number(raw) : 20; })(); // validated in validateGuardEnv
292
312
  const autopayLog = [];
293
313
  /** Read a body no larger than maxChars, refusing early on a declared length and mid-stream on a real one. */
294
314
  async function readBounded(res, maxChars, who) {
@@ -552,7 +572,8 @@ const PAYWALL_MAX_CHARS = 4 * 1024 * 1024; // a paywall's answer, before and aft
552
572
  and the client's log (2026-10-04 audit). */
553
573
  function redactRpcSecrets(err) {
554
574
  const e = err;
555
- let msg = e?.shortMessage ? [e.shortMessage, e.details].filter(Boolean).join(' ') : String(e?.message ?? err);
575
+ // A rejection that is not an Error (web3.js rejects with a raw {InstructionError} object) used to read "[object Object]".
576
+ let msg = e?.shortMessage ? [e.shortMessage, e.details].filter(Boolean).join(' ') : (e?.message !== undefined ? String(e.message) : (err && typeof err === 'object' ? JSON.stringify(err) : String(err)));
556
577
  msg = msg.split(/\r?\n/).filter(l => !/^\s*(URL|Request body|Request Arguments|Raw Call Arguments)\s*:/i.test(l)).join('\n').trim();
557
578
  return msg.replace(/[a-z][a-z0-9+.-]*:\/\/[^\s"'<>)]+/gi, (u) => redactUrl(u)); // brackets stay in: IPv6 hosts
558
579
  }
@@ -600,6 +621,48 @@ function validateGuardEnv() {
600
621
  if (t && (!/^\d+$/.test(t) || Number(t) < 60 || Number(t) > 31_536_000)) {
601
622
  throw new Error(`AGENTWALLET_X402_MAX_TIMEOUT must be a whole number of seconds between 60 and 31536000, got "${t}".`);
602
623
  }
624
+ const perHour = process.env.AGENTWALLET_MAX_AUTOPAY_PER_HOUR;
625
+ if (perHour !== undefined && !/^\d+$/.test(perHour.trim())) {
626
+ throw new Error(`AGENTWALLET_MAX_AUTOPAY_PER_HOUR must be a whole number (0 disables internal auto-pay), got "${perHour}".`);
627
+ }
628
+ for (const o of (process.env.AGENTWALLET_LEGACY_PAYWALL_ORIGINS || '').split(',').map(x => x.trim()).filter(Boolean)) {
629
+ let u = null;
630
+ try {
631
+ u = new URL(o);
632
+ }
633
+ catch {
634
+ u = null;
635
+ }
636
+ if (!u || u.protocol !== 'https:' || u.origin !== o.replace(/\/+$/, '')) {
637
+ throw new Error(`AGENTWALLET_LEGACY_PAYWALL_ORIGINS entry "${o}" is not an https origin (scheme and host only, like https://shop.example).`);
638
+ }
639
+ }
640
+ // Two sources for one key family is ambiguous; refuse rather than pick one silently.
641
+ if ((process.env.AGENTWALLET_PRIVATE_KEY || '').trim() && (process.env.AGENTWALLET_KEYFILE || '').trim()) {
642
+ throw new Error('Both AGENTWALLET_PRIVATE_KEY and AGENTWALLET_KEYFILE are set; keep one.');
643
+ }
644
+ if ((process.env.AGENTWALLET_SOLANA_KEY || '').trim() && (process.env.AGENTWALLET_SOLANA_KEYFILE || '').trim()) {
645
+ throw new Error('Both AGENTWALLET_SOLANA_KEY and AGENTWALLET_SOLANA_KEYFILE are set; keep one.');
646
+ }
647
+ // An unrecognised AGENTWALLET_* name is almost always a misspelled cap, which
648
+ // would run uncapped without a word (round 4). Refuse to start instead.
649
+ for (const k of Object.keys(process.env)) {
650
+ if (!k.startsWith('AGENTWALLET_'))
651
+ continue;
652
+ if (KNOWN_ENV.has(k) || /^AGENTWALLET_(RPC|SOLANA_RPC)_\d+$/.test(k))
653
+ continue;
654
+ throw new Error(`Unknown environment variable ${k}. AgentWallet refuses to start on an unrecognised AGENTWALLET_* name so that a misspelled cap cannot run uncapped. Known names: ${[...KNOWN_ENV].join(', ')}, AGENTWALLET_RPC_<chainId>, AGENTWALLET_SOLANA_RPC_<chainId>.`);
655
+ }
656
+ }
657
+ const KNOWN_ENV = new Set(["AGENTWALLET_ALLOW_UNKNOWN_TOKEN_CALLS", "AGENTWALLET_API_URL", "AGENTWALLET_APPROVALS", "AGENTWALLET_AUTOPAY_ASSETS", "AGENTWALLET_CHAIN_ID", "AGENTWALLET_DASHBOARD_URL", "AGENTWALLET_KEYFILE", "AGENTWALLET_LEGACY_PAYWALL_ORIGINS", "AGENTWALLET_MAX_AUTOPAY", "AGENTWALLET_MAX_AUTOPAY_PER_HOUR", "AGENTWALLET_MAX_TX_NATIVE", "AGENTWALLET_MAX_TX_SOL", "AGENTWALLET_MAX_TX_TOKEN", "AGENTWALLET_PASS", "AGENTWALLET_PERMIT2_AUTO_APPROVE", "AGENTWALLET_PRIVATE_KEY", "AGENTWALLET_RPC_URL", "AGENTWALLET_SOLANA_KEY", "AGENTWALLET_SOLANA_KEYFILE", "AGENTWALLET_SOLANA_RPC", "AGENTWALLET_TOKEN_DECIMALS", "AGENTWALLET_TOKEN_RISK", "AGENTWALLET_USER", "AGENTWALLET_WALLET_ID", "AGENTWALLET_X402_MAX_TIMEOUT"]);
658
+ /**
659
+ * Register a tool whose arguments are strict: an unknown key (max_payemnt for
660
+ * max_payment) is an error that names it, not a silently dropped guard. The
661
+ * advertised schema always said additionalProperties: false; now the runtime
662
+ * agrees (round 4).
663
+ */
664
+ function tool(name, description, shape, cb) {
665
+ return server.registerTool(name, { description, inputSchema: z.object(shape).strict() }, cb);
603
666
  }
604
667
  // ─── EVM Helpers ──────────────────────────────────────────────────
605
668
  /**
@@ -679,10 +742,10 @@ function resolveChainId(network) {
679
742
  const AddressSchema = z.string().regex(/^(0x[a-fA-F0-9]{40}|[1-9A-HJ-NP-Za-km-z]{32,44})$/, 'Must be a 0x-prefixed 40-hex EVM address or a base58 Solana address');
680
743
  const server = new McpServer({
681
744
  name: 'agentwallet',
682
- version: '1.13.9',
745
+ version: '1.13.10',
683
746
  }, {
684
747
  instructions: `AgentWallet gives AI agents their own blockchain wallets. ${anyLocalMode()
685
- ? 'This server runs in LOCAL (self-custody) mode: the operator supplied the key, signing happens in this process, and the only spend guards are the AGENTWALLET_MAX_TX_* environment caps. There is no server-side pause, daily limit or approval email; pause_wallet, unpause_wallet and delete_wallet are refused.'
748
+ ? 'This server runs in LOCAL (self-custody) mode: the operator supplied the key, signing happens in this process, and the only spend guards are the environment caps (AGENTWALLET_MAX_TX_NATIVE / _TOKEN / _SOL per transaction, AGENTWALLET_MAX_AUTOPAY and AGENTWALLET_AUTOPAY_ASSETS for x402, AGENTWALLET_X402_MAX_TIMEOUT for authorization windows). There is no server-side pause, daily limit or approval email; pause_wallet, unpause_wallet and delete_wallet are refused.'
686
749
  : 'Private keys are encrypted server-side and never exposed: agents sign and broadcast transactions without ever touching raw keys.'}
687
750
 
688
751
  ## Getting Started
@@ -738,9 +801,7 @@ Create paywalls to charge other agents for accessing your resources:
738
801
 
739
802
  ## Wallet Security
740
803
  - ${anyLocalMode() ? 'pause_wallet does not exist in local mode: revoke the key (rotate it and move the funds) if the process is compromised.' : 'Use pause_wallet to immediately freeze a wallet if compromised. No transactions can be signed while paused.'}
741
- - Use unpause_wallet to resume operations.
742
- - Use delete_wallet to permanently disable a wallet.
743
- - ${anyLocalMode() ? 'Spend limits are the AGENTWALLET_MAX_TX_NATIVE / _TOKEN / _SOL / _AUTOPAY caps the operator set; run wallet_mode to see them. Unset means no limit.' : 'Wallets have server-enforced spending limits.'}
804
+ ${anyLocalMode() ? '' : '- Use unpause_wallet to resume operations.\n- Use delete_wallet to permanently disable a wallet.\n'}- ${anyLocalMode() ? 'Spend limits are the AGENTWALLET_MAX_TX_NATIVE / _TOKEN / _SOL caps the operator set (unset means no limit) and AGENTWALLET_MAX_AUTOPAY for x402 (default 1); run wallet_mode to see them.' : 'Wallets have server-enforced spending limits.'}
744
805
 
745
806
  ## Tool Selection Guide
746
807
  | Goal | Tool |
@@ -760,7 +821,7 @@ Create paywalls to charge other agents for accessing your resources:
760
821
  | Check usage/billing | get_usage |`,
761
822
  });
762
823
  // ─── Tool: create_wallet ─────────────────────────────────────────
763
- server.tool('create_wallet', 'Create a new EVM or Solana wallet. Returns the wallet ID and address. ' +
824
+ tool('create_wallet', 'Create a new EVM or Solana wallet. Returns the wallet ID and address. ' +
764
825
  (anyLocalMode() ? 'In local mode this reports the key already configured; no wallet is created remotely.' : 'Private key is encrypted server-side and never exposed.'), {
765
826
  label: z.string().default('').describe('Friendly name for the wallet'),
766
827
  chain_id: z.number().int().default(8453).describe('Default chain ID (1=Ethereum, 8453=Base, 42161=Arbitrum, 10=Optimism, 137=Polygon, 43114=Avalanche, 56=BSC, 7777777=Zora, 369=PulseChain, 900=Solana, 901=Solana Devnet)'),
@@ -769,13 +830,13 @@ server.tool('create_wallet', 'Create a new EVM or Solana wallet. Returns the wal
769
830
  return jsonResponse(data);
770
831
  });
771
832
  // ─── Tool: list_wallets ──────────────────────────────────────────
772
- server.tool('list_wallets', 'List all wallets owned by the authenticated user. ' +
833
+ tool('list_wallets', 'List all wallets owned by the authenticated user. ' +
773
834
  'Returns wallet IDs, addresses, labels, chain IDs, and status.', {}, async () => {
774
835
  const data = await api('/wallets');
775
836
  return jsonResponse(data);
776
837
  });
777
838
  // ─── Tool: get_wallet ────────────────────────────────────────────
778
- server.tool('get_wallet', 'Get details for a specific wallet by ID. ' +
839
+ tool('get_wallet', 'Get details for a specific wallet by ID. ' +
779
840
  'Returns address, label, chain, spending limits, and pause status.', {
780
841
  wallet_id: z.number().int().describe('Wallet ID'),
781
842
  }, async ({ wallet_id }) => {
@@ -783,7 +844,7 @@ server.tool('get_wallet', 'Get details for a specific wallet by ID. ' +
783
844
  return jsonResponse(data);
784
845
  });
785
846
  // ─── Tool: get_balance ───────────────────────────────────────────
786
- server.tool('get_balance', 'Get the native token balance for a wallet on a specific chain. ' +
847
+ tool('get_balance', 'Get the native token balance for a wallet on a specific chain. ' +
787
848
  'Returns balance in both wei (or lamports for Solana) and human-readable format.', {
788
849
  wallet_id: z.number().int().describe('Wallet ID'),
789
850
  chain_id: z.number().int().optional().describe('Chain ID to check (defaults to wallet\'s default chain)'),
@@ -793,7 +854,7 @@ server.tool('get_balance', 'Get the native token balance for a wallet on a speci
793
854
  return jsonResponse(data);
794
855
  });
795
856
  // ─── Tool: sign_transaction ──────────────────────────────────────
796
- server.tool('sign_transaction', 'Sign a transaction with a wallet\'s private key. ' +
857
+ tool('sign_transaction', 'Sign a transaction with a wallet\'s private key. ' +
797
858
  'For EVM: returns signed raw transaction hex. For Solana: returns base64 signed transaction. ' +
798
859
  'Does NOT broadcast — use send_transaction for sign + broadcast.', {
799
860
  wallet_id: z.number().int().describe('Wallet ID'),
@@ -839,7 +900,7 @@ server.tool('sign_transaction', 'Sign a transaction with a wallet\'s private key
839
900
  return jsonResponse(result);
840
901
  });
841
902
  // ─── Tool: send_transaction ──────────────────────────────────────
842
- server.tool('send_transaction', 'Sign and broadcast a transaction. ' +
903
+ tool('send_transaction', 'Sign and broadcast a transaction. ' +
843
904
  'Returns the transaction hash (EVM) or signature (Solana) on success. ' +
844
905
  (anyLocalMode() ? 'Signed in this process with the local key and broadcast via the configured RPC.' : 'The transaction is signed server-side and broadcast via RPC.'), {
845
906
  wallet_id: z.number().int().describe('Wallet ID'),
@@ -885,7 +946,7 @@ server.tool('send_transaction', 'Sign and broadcast a transaction. ' +
885
946
  return jsonResponse(result);
886
947
  });
887
948
  // ─── Tool: transfer ─────────────────────────────────────────────
888
- server.tool('transfer', 'Send native tokens (ETH, AVAX, BNB, POL, PLS, SOL) to an address. ' +
949
+ tool('transfer', 'Send native tokens (ETH, AVAX, BNB, POL, PLS, SOL) to an address. ' +
889
950
  'Specify the amount in human-readable format (e.g. "0.1" for 0.1 ETH). ' +
890
951
  'The amount is converted to wei/lamports automatically. Signs and broadcasts the transaction.', {
891
952
  wallet_id: z.number().int().describe('Wallet ID to send from'),
@@ -916,7 +977,7 @@ server.tool('transfer', 'Send native tokens (ETH, AVAX, BNB, POL, PLS, SOL) to a
916
977
  });
917
978
  });
918
979
  // ─── Tool: get_token_balance ────────────────────────────────────
919
- server.tool('get_token_balance', 'Get the ERC-20 or SPL token balance for a wallet on a specific chain. ' +
980
+ tool('get_token_balance', 'Get the ERC-20 or SPL token balance for a wallet on a specific chain. ' +
920
981
  'Returns the raw balance and human-readable balance. ' +
921
982
  'Use get_chains to find stablecoin addresses for each chain.', {
922
983
  wallet_id: z.number().int().describe('Wallet ID to check'),
@@ -960,7 +1021,7 @@ server.tool('get_token_balance', 'Get the ERC-20 or SPL token balance for a wall
960
1021
  });
961
1022
  });
962
1023
  // ─── Tool: transfer_token ───────────────────────────────────────
963
- server.tool('transfer_token', 'Send ERC-20 tokens (EVM) or SPL tokens (Solana) to an address. ' +
1024
+ tool('transfer_token', 'Send ERC-20 tokens (EVM) or SPL tokens (Solana) to an address. ' +
964
1025
  'Specify the amount in human-readable format (e.g. "100" for 100 USDC). ' +
965
1026
  'Signs and broadcasts the transaction. Use get_chains to find stablecoin addresses.', {
966
1027
  wallet_id: z.number().int().describe('Wallet ID to send from'),
@@ -1009,7 +1070,7 @@ server.tool('transfer_token', 'Send ERC-20 tokens (EVM) or SPL tokens (Solana) t
1009
1070
  });
1010
1071
  });
1011
1072
  // ─── Tool: call_contract ────────────────────────────────────────
1012
- server.tool('call_contract', 'Execute a read-only call against a smart contract (eth_call). ' +
1073
+ tool('call_contract', 'Execute a read-only call against a smart contract (eth_call). ' +
1013
1074
  'Returns the raw hex result. Does not cost gas or modify state. ' +
1014
1075
  'Useful for reading on-chain data like token balances, prices, positions.', {
1015
1076
  chain_id: z.number().int().describe('Chain ID'),
@@ -1023,7 +1084,7 @@ server.tool('call_contract', 'Execute a read-only call against a smart contract
1023
1084
  return jsonResponse(result);
1024
1085
  });
1025
1086
  // ─── Tool: approve_token ────────────────────────────────────────
1026
- server.tool('approve_token', 'Approve a spender contract to transfer ERC-20 tokens on your behalf. ' +
1087
+ tool('approve_token', 'Approve a spender contract to transfer ERC-20 tokens on your behalf. ' +
1027
1088
  'Required before interacting with any DeFi protocol (DEXs, lending, etc.). ' +
1028
1089
  'Use amount "max" for unlimited approval, or specify an exact amount.', {
1029
1090
  wallet_id: z.number().int().describe('Wallet ID'),
@@ -1071,7 +1132,7 @@ server.tool('approve_token', 'Approve a spender contract to transfer ERC-20 toke
1071
1132
  });
1072
1133
  });
1073
1134
  // ─── Tool: get_allowance ────────────────────────────────────────
1074
- server.tool('get_allowance', 'Check how many ERC-20 tokens a spender is approved to transfer. ' +
1135
+ tool('get_allowance', 'Check how many ERC-20 tokens a spender is approved to transfer. ' +
1075
1136
  'Returns the allowance in both raw and human-readable format. ' +
1076
1137
  'Use this to check if an approval is needed before a DeFi transaction.', {
1077
1138
  wallet_id: z.number().int().describe('Wallet ID (used to determine the owner address)'),
@@ -1107,7 +1168,7 @@ server.tool('get_allowance', 'Check how many ERC-20 tokens a spender is approved
1107
1168
  });
1108
1169
  // ─── WETH / Wrapped Native Token addresses ──────────────────────
1109
1170
  // ─── Tool: wrap_eth ─────────────────────────────────────────────
1110
- server.tool('wrap_eth', 'Wrap native tokens (ETH, AVAX, BNB, POL, PLS) into their wrapped ERC-20 version (WETH, WAVAX, etc.). ' +
1171
+ tool('wrap_eth', 'Wrap native tokens (ETH, AVAX, BNB, POL, PLS) into their wrapped ERC-20 version (WETH, WAVAX, etc.). ' +
1111
1172
  'Required for most DeFi protocols that use ERC-20 tokens instead of raw native tokens. ' +
1112
1173
  'Specify amount in human-readable format (e.g. "0.5" for 0.5 ETH).', {
1113
1174
  wallet_id: z.number().int().describe('Wallet ID'),
@@ -1137,7 +1198,7 @@ server.tool('wrap_eth', 'Wrap native tokens (ETH, AVAX, BNB, POL, PLS) into thei
1137
1198
  });
1138
1199
  });
1139
1200
  // ─── Tool: unwrap_eth ───────────────────────────────────────────
1140
- server.tool('unwrap_eth', 'Unwrap wrapped tokens (WETH, WAVAX, WBNB, etc.) back to native tokens. ' +
1201
+ tool('unwrap_eth', 'Unwrap wrapped tokens (WETH, WAVAX, WBNB, etc.) back to native tokens. ' +
1141
1202
  'Specify amount in human-readable format (e.g. "0.5" for 0.5 WETH).', {
1142
1203
  wallet_id: z.number().int().describe('Wallet ID'),
1143
1204
  amount: z.string().describe('Amount to unwrap in human-readable format (e.g. "0.5")'),
@@ -1196,7 +1257,7 @@ function decodeAbiString(hex) {
1196
1257
  return '';
1197
1258
  }
1198
1259
  }
1199
- server.tool('get_token_info', 'Get the name, symbol, and decimals of any ERC-20 token by its contract address. ' +
1260
+ tool('get_token_info', 'Get the name, symbol, and decimals of any ERC-20 token by its contract address. ' +
1200
1261
  'Useful for discovering token details before transfers or approvals.', {
1201
1262
  token: z.string().regex(/^0x[a-fA-F0-9]{40}$/).describe('ERC-20 token contract address'),
1202
1263
  chain_id: z.number().int().describe('Chain ID'),
@@ -1336,7 +1397,7 @@ async function signPermit2(walletId, chainId, auth, approvalId) {
1336
1397
  return sig;
1337
1398
  }
1338
1399
  // ─── Tool: pay_x402 ─────────────────────────────────────────────
1339
- server.tool('pay_x402', 'Handle an x402 payment flow. Fetches a URL, and if the server returns HTTP 402 Payment Required, ' +
1400
+ tool('pay_x402', 'Handle an x402 payment flow. Fetches a URL, and if the server returns HTTP 402 Payment Required, ' +
1340
1401
  'parses the payment requirements (v1 body or v2 PAYMENT-REQUIRED header), signs an EIP-3009 ' +
1341
1402
  'TransferWithAuthorization for the "exact" scheme (no gas, nothing broadcast by the payer), and retries ' +
1342
1403
  'the request with the payment header. AgentWallet paywalls are paid by on-chain transfer instead. ' +
@@ -1360,6 +1421,9 @@ server.tool('pay_x402', 'Handle an x402 payment flow. Fetches a URL, and if the
1360
1421
  fresh_authorization: z.boolean().optional().describe('Sign a new authorization even if one for the same endpoint, amount and recipient is still valid. By default a repeat call ' +
1361
1422
  'within the validity window re-sends the earlier signature (same nonce, so it cannot settle twice) instead of paying again.'),
1362
1423
  }, async ({ url, wallet_id, method, headers: headersJson, body: reqBody, max_payment, prefer_chain, request_approval, approval_id, fresh_authorization }) => {
1424
+ if (max_payment !== undefined && !/^\d+(\.\d+)?$/.test(String(max_payment).trim())) {
1425
+ throw new Error(`max_payment must be a decimal number of stablecoin units, got "${serverText(max_payment)}"; nothing was fetched.`);
1426
+ }
1363
1427
  // Build request headers
1364
1428
  const reqHeaders = { Accept: 'application/json' };
1365
1429
  if (headersJson) {
@@ -1801,10 +1865,10 @@ server.tool('pay_x402', 'Handle an x402 payment flow. Fetches a URL, and if the
1801
1865
  });
1802
1866
  });
1803
1867
  // ─── Tool: check_approval ───────────────────────────────────────
1804
- server.tool('check_approval', 'Status of a payment approval created when pay_x402 exceeded the cap on a hosted wallet. ' +
1868
+ tool('check_approval', 'Status of a payment approval created when pay_x402 exceeded the cap on a hosted wallet. ' +
1805
1869
  'Returns pending, approved, denied or expired. Once approved, call pay_x402 again with approval_id.', { approval_id: z.string().describe('Approval id returned by pay_x402') }, async ({ approval_id }) => jsonResponse(await api(`/approvals/${encodeURIComponent(approval_id)}`, 'GET', undefined, { 'X-AGW-SKIP-X402': 'true' })));
1806
1870
  // ─── Tool: approve_permit2 ──────────────────────────────────────
1807
- server.tool('approve_permit2', 'One-time ERC-20 approval of a token to the Permit2 contract, needed before paying x402 "upto" endpoints with that token. ' +
1871
+ tool('approve_permit2', 'One-time ERC-20 approval of a token to the Permit2 contract, needed before paying x402 "upto" endpoints with that token. ' +
1808
1872
  'A normal on-chain transaction (needs gas). By default approves the maximum, the ecosystem norm, so it never has to be repeated; ' +
1809
1873
  'pass amount to grant a bounded allowance instead (required under AGENTWALLET_MAX_TX_TOKEN, which refuses a max approval). ' +
1810
1874
  'Permit2 itself only moves what each signed authorization allows.', {
@@ -1824,20 +1888,20 @@ server.tool('approve_permit2', 'One-time ERC-20 approval of a token to the Permi
1824
1888
  return jsonResponse({ ...result, token, spender: permit2Address(chain_id), amount: amount ?? 'unlimited', note: 'Permit2 only transfers what a signed authorization allows; the allowance itself moves nothing.' });
1825
1889
  });
1826
1890
  // ─── Tool: check_token_risk ─────────────────────────────────────
1827
- server.tool('check_token_risk', 'Assess an ERC-20 token before approving or swapping it: honeypot, taxes, owner powers, verified source, ' +
1891
+ tool('check_token_risk', 'Assess an ERC-20 token before approving or swapping it: honeypot, taxes, owner powers, verified source, ' +
1828
1892
  'holder concentration, DEX liquidity. Uses GoPlus Security (free, no key) with an on-chain fallback. ' +
1829
1893
  'A warning for the caller to weigh, never a block.', {
1830
1894
  token: z.string().regex(/^0x[a-fA-F0-9]{40}$/).describe('ERC-20 token contract address'),
1831
1895
  chain_id: z.number().int().describe('Chain ID'),
1832
1896
  }, async ({ token, chain_id }) => jsonResponse({ token, chain_id, risk: await assessTokenRisk(chain_id, token, (to, data) => ethCallHex(chain_id, to, data)) }));
1833
1897
  // ─── Tool: get_usage ─────────────────────────────────────────────
1834
- server.tool('get_usage', 'Get the current month\'s usage statistics. ' +
1898
+ tool('get_usage', 'Get the current month\'s usage statistics. ' +
1835
1899
  'Returns operations count, tier info, remaining quota, and fees.', {}, async () => {
1836
1900
  const data = await api('/usage');
1837
1901
  return jsonResponse(data);
1838
1902
  });
1839
1903
  // ─── Tool: buy_verification_credits ─────────────────────────────
1840
- server.tool('buy_verification_credits', 'Buy x402 verification credits with USDC on-chain. ' +
1904
+ tool('buy_verification_credits', 'Buy x402 verification credits with USDC on-chain. ' +
1841
1905
  'Paywall owners need credits to process verifications beyond the free tier (1,000/month) ' +
1842
1906
  'when they don\'t have Stripe billing configured. ' +
1843
1907
  'Returns 402 payment instructions — pay on-chain and retry with proof.', {
@@ -1848,27 +1912,27 @@ server.tool('buy_verification_credits', 'Buy x402 verification credits with USDC
1848
1912
  return jsonResponse(data);
1849
1913
  });
1850
1914
  // ─── Tool: pause_wallet ──────────────────────────────────────────
1851
- server.tool('pause_wallet', 'Emergency pause a wallet. No transactions can be signed while paused.', {
1915
+ tool('pause_wallet', 'Emergency pause a wallet. No transactions can be signed while paused.', {
1852
1916
  wallet_id: z.number().int().describe('Wallet ID to pause'),
1853
1917
  }, async ({ wallet_id }) => {
1854
1918
  const data = await api(`/wallets/${wallet_id}/pause`, 'POST');
1855
1919
  return jsonResponse(data);
1856
1920
  });
1857
1921
  // ─── Tool: unpause_wallet ────────────────────────────────────────
1858
- server.tool('unpause_wallet', 'Resume a paused wallet so transactions can be signed again.', {
1922
+ tool('unpause_wallet', 'Resume a paused wallet so transactions can be signed again.', {
1859
1923
  wallet_id: z.number().int().describe('Wallet ID to unpause'),
1860
1924
  }, async ({ wallet_id }) => {
1861
1925
  const data = await api(`/wallets/${wallet_id}/unpause`, 'POST');
1862
1926
  return jsonResponse(data);
1863
1927
  });
1864
1928
  // ─── Tool: get_chains ────────────────────────────────────────────
1865
- server.tool('get_chains', 'List all supported chains (EVM + Solana) with their chain IDs, native tokens, ' +
1929
+ tool('get_chains', 'List all supported chains (EVM + Solana) with their chain IDs, native tokens, ' +
1866
1930
  'stablecoins, and RPC configuration status.', {}, async () => {
1867
1931
  const data = await api('/chains');
1868
1932
  return jsonResponse(data);
1869
1933
  });
1870
1934
  // ─── Tool: delete_wallet ─────────────────────────────────────────
1871
- server.tool('delete_wallet', 'Delete (soft-delete) a wallet. The wallet will no longer appear in listings ' +
1935
+ tool('delete_wallet', 'Delete (soft-delete) a wallet. The wallet will no longer appear in listings ' +
1872
1936
  'and cannot be used for transactions. The wallet is read first and the delete names its address, ' +
1873
1937
  'so a wallet that cannot be read is never deleted.', {
1874
1938
  wallet_id: z.number().int().describe('Wallet ID to delete'),
@@ -1883,7 +1947,7 @@ server.tool('delete_wallet', 'Delete (soft-delete) a wallet. The wallet will no
1883
1947
  return jsonResponse(data);
1884
1948
  });
1885
1949
  // ─── Tool: create_paywall ────────────────────────────────────────
1886
- server.tool('create_paywall', 'Create an x402 paywall that charges agents/clients for accessing a resource. ' +
1950
+ tool('create_paywall', 'Create an x402 paywall that charges agents/clients for accessing a resource. ' +
1887
1951
  'Returns a public access URL that returns HTTP 402 until paid. ' +
1888
1952
  'Agents pay on-chain, then retry with proof to get the content.', {
1889
1953
  wallet_id: z.number().int().describe('Wallet ID to receive payments'),
@@ -1923,7 +1987,7 @@ server.tool('create_paywall', 'Create an x402 paywall that charges agents/client
1923
1987
  });
1924
1988
  });
1925
1989
  // ─── Tool: list_paywalls ────────────────────────────────────────
1926
- server.tool('list_paywalls', 'List all your x402 paywalls. Returns paywall IDs, names, pricing, ' +
1990
+ tool('list_paywalls', 'List all your x402 paywalls. Returns paywall IDs, names, pricing, ' +
1927
1991
  'access URLs, payment counts, and revenue totals.', {
1928
1992
  page: z.number().int().default(1).describe('Page number'),
1929
1993
  per_page: z.number().int().min(1).max(100).default(50).describe('Results per page (max 100)'),
@@ -1932,7 +1996,7 @@ server.tool('list_paywalls', 'List all your x402 paywalls. Returns paywall IDs,
1932
1996
  return jsonResponse(data);
1933
1997
  });
1934
1998
  // ─── Tool: get_paywall ──────────────────────────────────────────
1935
- server.tool('get_paywall', 'Get details for a specific x402 paywall by ID. ' +
1999
+ tool('get_paywall', 'Get details for a specific x402 paywall by ID. ' +
1936
2000
  'Returns pricing, access URL, payment stats, and configuration.', {
1937
2001
  paywall_id: z.number().int().describe('Paywall ID'),
1938
2002
  }, async ({ paywall_id }) => {
@@ -1940,7 +2004,7 @@ server.tool('get_paywall', 'Get details for a specific x402 paywall by ID. ' +
1940
2004
  return jsonResponse(data);
1941
2005
  });
1942
2006
  // ─── Tool: update_paywall ───────────────────────────────────────
1943
- server.tool('update_paywall', 'Update an x402 paywall configuration. ' +
2007
+ tool('update_paywall', 'Update an x402 paywall configuration. ' +
1944
2008
  'Can change price, resource URL, active status, or any other field.', {
1945
2009
  paywall_id: z.number().int().describe('Paywall ID to update'),
1946
2010
  name: z.string().optional().describe('New paywall name'),
@@ -1978,14 +2042,14 @@ server.tool('update_paywall', 'Update an x402 paywall configuration. ' +
1978
2042
  return jsonResponse(data);
1979
2043
  });
1980
2044
  // ─── Tool: delete_paywall ───────────────────────────────────────
1981
- server.tool('delete_paywall', 'Delete an x402 paywall. The access URL will return 404 after deletion.', {
2045
+ tool('delete_paywall', 'Delete an x402 paywall. The access URL will return 404 after deletion.', {
1982
2046
  paywall_id: z.number().int().describe('Paywall ID to delete'),
1983
2047
  }, async ({ paywall_id }) => {
1984
2048
  const data = await api(`/x402/paywalls/${paywall_id}`, 'DELETE');
1985
2049
  return jsonResponse(data);
1986
2050
  });
1987
2051
  // ─── Tool: get_paywall_payments ─────────────────────────────────
1988
- server.tool('get_paywall_payments', 'Get payment history for a specific x402 paywall. ' +
2052
+ tool('get_paywall_payments', 'Get payment history for a specific x402 paywall. ' +
1989
2053
  'Returns verified payments with TX hashes, payer addresses, amounts, and timestamps.', {
1990
2054
  paywall_id: z.number().int().describe('Paywall ID'),
1991
2055
  page: z.number().int().default(1).describe('Page number'),
@@ -1995,13 +2059,13 @@ server.tool('get_paywall_payments', 'Get payment history for a specific x402 pay
1995
2059
  return jsonResponse(data);
1996
2060
  });
1997
2061
  // ─── Tool: get_x402_revenue ─────────────────────────────────────
1998
- server.tool('get_x402_revenue', 'Get aggregate x402 revenue statistics across all your paywalls. ' +
2062
+ tool('get_x402_revenue', 'Get aggregate x402 revenue statistics across all your paywalls. ' +
1999
2063
  'Returns total payments and revenue broken down by chain and token.', {}, async () => {
2000
2064
  const data = await api('/x402/revenue');
2001
2065
  return jsonResponse(data);
2002
2066
  });
2003
2067
  // ─── Tool: wallet_mode ──────────────────────────────────────────
2004
- server.tool('wallet_mode', 'Report whether this server is signing locally (self-custody, the private key never leaves this machine) ' +
2068
+ tool('wallet_mode', 'Report whether this server is signing locally (self-custody, the private key never leaves this machine) ' +
2005
2069
  'or through the hosted AgentWallet API (custodial). Use this to verify custody before moving funds.', {}, async () => {
2006
2070
  if (!anyLocalMode()) {
2007
2071
  return jsonResponse({
@@ -2011,8 +2075,13 @@ server.tool('wallet_mode', 'Report whether this server is signing locally (self-
2011
2075
  api_base: redactUrl(API_BASE),
2012
2076
  client_guards: {
2013
2077
  autopay_cap_usd: autopayEnvCap(),
2078
+ internal_autopay_armed: Boolean(X402_WALLET_ID),
2079
+ max_autopay_per_hour: AUTOPAY_PER_HOUR,
2014
2080
  autopay_assets: (process.env.AGENTWALLET_AUTOPAY_ASSETS || '').trim() || 'registry stablecoins only',
2015
2081
  x402_max_timeout_seconds: maxAuthWindowSeconds(),
2082
+ approvals: process.env.AGENTWALLET_APPROVALS !== '0',
2083
+ permit2_auto_approve: process.env.AGENTWALLET_PERMIT2_AUTO_APPROVE === '1',
2084
+ legacy_paywall_origins: (process.env.AGENTWALLET_LEGACY_PAYWALL_ORIGINS || '').trim() || 'none (API host only)',
2016
2085
  note: 'These bound pay_x402 on hosted wallets too; the hosted signer adds its own pause, daily limit and approval checks.',
2017
2086
  },
2018
2087
  to_self_custody: 'Set AGENTWALLET_PRIVATE_KEY for EVM and/or AGENTWALLET_SOLANA_KEY for Solana, then restart. ' +
@@ -2023,12 +2092,18 @@ server.tool('wallet_mode', 'Report whether this server is signing locally (self-
2023
2092
  mode: 'local',
2024
2093
  custody: 'self',
2025
2094
  signing: 'Signed in this process. Keys are never sent to AgentWallet or anyone else.',
2026
- max_autopay: process.env.AGENTWALLET_MAX_AUTOPAY || '1',
2027
- autopay_assets: process.env.AGENTWALLET_AUTOPAY_ASSETS || 'registry stablecoins only',
2095
+ max_autopay: autopayEnvCap(),
2096
+ max_autopay_per_hour: AUTOPAY_PER_HOUR,
2097
+ internal_autopay_armed: false, // internal auto-pay never runs in local mode
2098
+ autopay_assets: (process.env.AGENTWALLET_AUTOPAY_ASSETS || '').trim() || 'registry stablecoins only',
2028
2099
  x402_max_timeout_seconds: maxAuthWindowSeconds(),
2029
- per_tx_cap_token: process.env.AGENTWALLET_MAX_TX_TOKEN || 'not set (ERC-20 and SPL transfers are uncapped)',
2100
+ per_tx_cap_token: (process.env.AGENTWALLET_MAX_TX_TOKEN || '').trim() || 'not set (ERC-20 and SPL transfers are uncapped)',
2030
2101
  allow_unknown_token_calls: process.env.AGENTWALLET_ALLOW_UNKNOWN_TOKEN_CALLS === '1',
2031
2102
  token_decimals_pins: parseDecimalPins(process.env.AGENTWALLET_TOKEN_DECIMALS).size,
2103
+ token_risk_lookup: process.env.AGENTWALLET_TOKEN_RISK !== '0',
2104
+ permit2_auto_approve: process.env.AGENTWALLET_PERMIT2_AUTO_APPROVE === '1',
2105
+ legacy_paywall_origins: (process.env.AGENTWALLET_LEGACY_PAYWALL_ORIGINS || '').trim() || 'none (API host only)',
2106
+ default_chain_id: (process.env.AGENTWALLET_CHAIN_ID || '').trim() || '8453',
2032
2107
  };
2033
2108
  if (isLocalMode()) {
2034
2109
  let rpc = 'default public endpoint';
@@ -2039,7 +2114,7 @@ server.tool('wallet_mode', 'Report whether this server is signing locally (self-
2039
2114
  report.evm = {
2040
2115
  address: getLocalAddress(),
2041
2116
  rpc_endpoint: redactUrl(rpc),
2042
- per_tx_cap_native: process.env.AGENTWALLET_MAX_TX_NATIVE || 'not set',
2117
+ per_tx_cap_native: (process.env.AGENTWALLET_MAX_TX_NATIVE || '').trim() || 'not set',
2043
2118
  };
2044
2119
  }
2045
2120
  else {
@@ -2054,8 +2129,8 @@ server.tool('wallet_mode', 'Report whether this server is signing locally (self-
2054
2129
  report.solana = {
2055
2130
  address: getSolanaAddress(),
2056
2131
  rpc_endpoint: redactUrl(rpc),
2057
- per_tx_cap_sol: process.env.AGENTWALLET_MAX_TX_SOL || 'not set',
2058
- per_tx_cap_token: process.env.AGENTWALLET_MAX_TX_TOKEN || 'not set (SPL transfers are uncapped; AGENTWALLET_MAX_TX_SOL does not cover them)',
2132
+ per_tx_cap_sol: (process.env.AGENTWALLET_MAX_TX_SOL || '').trim() || 'not set',
2133
+ per_tx_cap_token: (process.env.AGENTWALLET_MAX_TX_TOKEN || '').trim() || 'not set (SPL transfers are uncapped; AGENTWALLET_MAX_TX_SOL does not cover them)',
2059
2134
  };
2060
2135
  }
2061
2136
  else {
@@ -2064,7 +2139,7 @@ server.tool('wallet_mode', 'Report whether this server is signing locally (self-
2064
2139
  return jsonResponse(report);
2065
2140
  });
2066
2141
  // ─── Tool: export_wallet_key ────────────────────────────────────
2067
- server.tool('export_wallet_key', 'Explain how to export the private key of a hosted (custodial) AgentWallet so it can be moved ' +
2142
+ tool('export_wallet_key', 'Explain how to export the private key of a hosted (custodial) AgentWallet so it can be moved ' +
2068
2143
  'to self-custody or any other wallet. Export itself is deliberately human-gated and is not ' +
2069
2144
  'performed by this tool.', {
2070
2145
  wallet_id: z.number().int().optional().describe('Hosted wallet ID to export'),
@@ -16,6 +16,22 @@ export declare function isSolanaLocalMode(): boolean;
16
16
  export declare function getSolanaKeypair(): Keypair;
17
17
  export declare function getSolanaAddress(): string;
18
18
  export declare function resolveSolanaRpc(chainId?: number): string;
19
+ /**
20
+ * Bound SPL movement. AGENTWALLET_MAX_TX_SOL only ever covered native SOL, so
21
+ * an SPL transfer, which is how USDC moves on Solana and the whole point of the
22
+ * x402 rail here, went out with no local ceiling at all. Mirrors
23
+ * AGENTWALLET_MAX_TX_TOKEN on the EVM side and uses the same variable so an
24
+ * operator sets one token cap, not one per chain family.
25
+ *
26
+ * Decimals for the cap come from the trusted table when the mint is known, then
27
+ * from the mint account itself, and finally 0. The caller-supplied value is
28
+ * never used for the ceiling: an inflated one would inflate the cap exactly
29
+ * like the x402 bypass.
30
+ *
31
+ * 0 is the only safe fallback. Assuming 6 for an unknown mint is what AW-001
32
+ * exploited: for a mint with d decimals, a ceiling evaluated at 6 is 10**(6-d)
33
+ * times too permissive, and SPL mints with 0 to 5 decimals are common.
34
+ */
19
35
  /** The mint's decimals from the chain, or null when they cannot be read. Never guesses. */
20
36
  export declare function mintDecimalsStrict(mint: string, chainId: number): Promise<number | null>;
21
37
  export declare function localSolBalance(chainId?: number): Promise<{
@@ -12,7 +12,7 @@
12
12
  * that when the two instructions it needs are a dozen lines each.
13
13
  */
14
14
  import { readFileSync } from 'node:fs';
15
- import { Connection, Keypair, PublicKey, SystemProgram, Transaction, TransactionInstruction, sendAndConfirmTransaction, LAMPORTS_PER_SOL, } from '@solana/web3.js';
15
+ import { Connection, Keypair, PublicKey, SystemProgram, Transaction, TransactionInstruction, LAMPORTS_PER_SOL, } from '@solana/web3.js';
16
16
  import bs58 from 'bs58';
17
17
  import { lookupTrustedDecimals } from './x402-payment.js';
18
18
  /* ── Program IDs ─────────────────────────────────────────────────── */
@@ -171,7 +171,10 @@ function assertWithinSolCap(lamports) {
171
171
  * exploited: for a mint with d decimals, a ceiling evaluated at 6 is 10**(6-d)
172
172
  * times too permissive, and SPL mints with 0 to 5 decimals are common.
173
173
  */
174
- const splDecimalsCache = new Map();
174
+ /* The cap and the instruction read the mint's decimals through ONE call now:
175
+ a cached cap-side read and an uncached instruction-side read let a hostile
176
+ RPC answer 6 to one and 18 to the other, which scaled the cap by 10^12
177
+ while the instruction carried the real byte (2026-10-04 round 4). */
175
178
  /** The mint's decimals from the chain, or null when they cannot be read. Never guesses. */
176
179
  export async function mintDecimalsStrict(mint, chainId) {
177
180
  const known = lookupTrustedDecimals(chainId, mint);
@@ -189,37 +192,14 @@ export async function mintDecimalsStrict(mint, chainId) {
189
192
  return null;
190
193
  }
191
194
  }
192
- async function resolveMintDecimals(mint, chainId) {
193
- const known = lookupTrustedDecimals(chainId, mint);
194
- if (typeof known === 'number')
195
- return known;
196
- const cacheKey = `${chainId}:${mint}`; // the same mint address can exist on two clusters with different decimals
197
- const cached = splDecimalsCache.get(cacheKey);
198
- if (typeof cached === 'number')
199
- return cached;
200
- try {
201
- const conn = connection(chainId);
202
- const info = await conn.getParsedAccountInfo(new PublicKey(mint));
203
- const data = info?.value?.data;
204
- const d = Number(data?.parsed?.info?.decimals);
205
- if (Number.isInteger(d) && d >= 0 && d <= 36) {
206
- splDecimalsCache.set(cacheKey, d);
207
- return d;
208
- }
209
- }
210
- catch {
211
- // Unreachable RPC or non-standard mint: fall through to the safe floor.
212
- }
213
- return 0;
214
- }
215
- async function assertWithinSplCap(mint, rawAmount, chainId = 900) {
195
+ /** The cap is evaluated at exactly the decimals the instruction will carry; the caller passes the value it just verified against the chain. */
196
+ function assertWithinSplCap(rawAmount, decimals) {
216
197
  const cap = (process.env.AGENTWALLET_MAX_TX_TOKEN || '').trim();
217
198
  if (!cap)
218
199
  return;
219
200
  if (!/^\d+(\.\d+)?$/.test(cap)) {
220
201
  throw new Error(`AGENTWALLET_MAX_TX_TOKEN must be a decimal number, got "${cap}".`);
221
202
  }
222
- const decimals = await resolveMintDecimals(mint, chainId);
223
203
  const [whole, frac = ''] = cap.split('.');
224
204
  const capRaw = BigInt(whole + frac.padEnd(decimals, '0').slice(0, decimals));
225
205
  if (rawAmount > capRaw) {
@@ -228,6 +208,41 @@ async function assertWithinSplCap(mint, rawAmount, chainId = 900) {
228
208
  `Raise the cap deliberately if this is intended.`);
229
209
  }
230
210
  }
211
+ const CONFIRM_TIMEOUT_MS = 90_000;
212
+ /**
213
+ * Sign, broadcast and confirm. The signature reported is the one computed
214
+ * here, never the node's answer (a node that returned a different string had
215
+ * the agent confirming and reporting a signature that was not this
216
+ * transaction, round 4); anything that fails after the broadcast says so and
217
+ * carries the signature, so an agent does not retry a transfer that went out.
218
+ */
219
+ async function sendSigned(conn, tx, payer) {
220
+ const { blockhash, lastValidBlockHeight } = await conn.getLatestBlockhash();
221
+ tx.recentBlockhash = blockhash;
222
+ tx.feePayer = payer.publicKey;
223
+ tx.sign(payer);
224
+ if (!tx.signature)
225
+ throw new Error('The transaction could not be signed.');
226
+ const local = bs58.encode(tx.signature);
227
+ const reported = await conn.sendRawTransaction(tx.serialize());
228
+ const after = (what) => new Error(`Transaction ${local} was BROADCAST but ${what}. Check it on an explorer before retrying: a retry is a second transfer.`);
229
+ if (reported !== local)
230
+ throw after(`the RPC answered with a different signature (${String(reported).slice(0, 90)}), so the node cannot be trusted about its fate`);
231
+ const confirm = conn.confirmTransaction({ signature: local, blockhash, lastValidBlockHeight }, 'confirmed');
232
+ const timeout = new Promise((_, reject) => setTimeout(() => reject(after(`was not confirmed within ${CONFIRM_TIMEOUT_MS / 1000} s`)), CONFIRM_TIMEOUT_MS).unref());
233
+ let result;
234
+ try {
235
+ result = await Promise.race([confirm, timeout]);
236
+ }
237
+ catch (e) {
238
+ if (e instanceof Error && /was BROADCAST/.test(e.message))
239
+ throw e;
240
+ throw after(`confirmation failed: ${e instanceof Error ? e.message : JSON.stringify(e)}`);
241
+ }
242
+ if (result.value.err)
243
+ throw after(`it failed on chain: ${JSON.stringify(result.value.err)}`);
244
+ return local;
245
+ }
231
246
  /* ── Reads ───────────────────────────────────────────────────────── */
232
247
  export async function localSolBalance(chainId = 900) {
233
248
  const conn = connection(chainId);
@@ -344,7 +359,7 @@ export async function localSolTransfer(to, lamports, chainId = 900) {
344
359
  toPubkey: new PublicKey(to),
345
360
  lamports: Number(amount),
346
361
  }));
347
- const signature = await sendAndConfirmTransaction(conn, tx, [payer]);
362
+ const signature = await sendSigned(conn, tx, payer);
348
363
  return {
349
364
  signature,
350
365
  from: payer.publicKey.toBase58(),
@@ -396,22 +411,23 @@ export async function localSplTransfer(mintStr, to, rawAmount, decimals, chainId
396
411
  // The cluster is verified before the cap reads decimals from it, so the
397
412
  // decimals cache is never filled from a node serving another chain.
398
413
  await assertCluster(conn, chainId);
399
- await assertWithinSplCap(mintStr, BigInt(rawAmount), chainId);
400
- const payer = getSolanaKeypair();
401
- const mint = new PublicKey(mintStr);
402
- const recipient = new PublicKey(to);
403
- await assertRecipientIsWallet(conn, recipient);
404
- const { tokenProgram, ataSize } = await mintLayout(conn, mint);
405
414
  // The caller scaled the amount by `decimals`. That value must be the mint's
406
415
  // real one: substituting the real value into the instruction (as 1.13.0
407
416
  // did) let a wrong guess through, because the amount had already been
408
417
  // scaled by the guess and TransferChecked only sees the real byte
409
- // (2026-10-04 audit). A mismatch refuses.
418
+ // (2026-10-04 audit). A mismatch refuses. Read ONCE, and the cap below is
419
+ // evaluated at this same value (round 4).
410
420
  const real = await mintDecimalsStrict(mintStr, chainId);
411
421
  if (real === null)
412
422
  throw new Error(`Could not read the decimals of mint ${mintStr} from the chain; refusing to guess the scale of the amount.`);
413
423
  if (decimals !== real)
414
424
  throw new Error(`decimals ${decimals} was passed but mint ${mintStr} has ${real} decimals; refusing to scale the amount by the wrong factor.`);
425
+ assertWithinSplCap(BigInt(rawAmount), real);
426
+ const payer = getSolanaKeypair();
427
+ const mint = new PublicKey(mintStr);
428
+ const recipient = new PublicKey(to);
429
+ await assertRecipientIsWallet(conn, recipient);
430
+ const { tokenProgram, ataSize } = await mintLayout(conn, mint);
415
431
  const sourceAta = associatedTokenAddress(payer.publicKey, mint, tokenProgram);
416
432
  const destAta = associatedTokenAddress(recipient, mint, tokenProgram);
417
433
  const tx = new Transaction();
@@ -430,13 +446,16 @@ export async function localSplTransfer(mintStr, to, rawAmount, decimals, chainId
430
446
  // outflow the SOL cap must see (0.002 SOL a time adds up over many sends).
431
447
  // Rent follows the account's real size, which for Token-2022 is above 165;
432
448
  // SOL already sitting at the address reduces what the payer tops up.
433
- const rent = await conn.getMinimumBalanceForRentExemption(ataSize);
434
- const shortfall = Math.max(0, rent - (destInfo?.lamports ?? 0));
435
- assertWithinSolCap(BigInt(shortfall));
449
+ // The node's rent figure is floored at the cluster constant and the SOL the
450
+ // node says already sits at the address is not credited: both are numbers a
451
+ // lying RPC used to zero so the create went out under the cap (round 4).
452
+ const claimed = Number(await conn.getMinimumBalanceForRentExemption(ataSize));
453
+ const rent = Math.max(Number.isFinite(claimed) ? Math.trunc(claimed) : 0, (128 + ataSize) * 6960);
454
+ assertWithinSolCap(BigInt(rent));
436
455
  tx.add(createAtaIdempotentIx(payer.publicKey, destAta, recipient, mint, tokenProgram));
437
456
  }
438
457
  tx.add(transferCheckedIx(tokenProgram, sourceAta, mint, destAta, payer.publicKey, BigInt(rawAmount), decimals));
439
- const signature = await sendAndConfirmTransaction(conn, tx, [payer]);
458
+ const signature = await sendSigned(conn, tx, payer);
440
459
  return {
441
460
  signature,
442
461
  from: payer.publicKey.toBase58(),
package/package.json CHANGED
@@ -1,7 +1,8 @@
1
1
  {
2
2
  "name": "agentwallet-mcp",
3
3
  "mcpName": "io.github.hifriendbot/agentwallet",
4
- "version": "1.13.9",
4
+ "repository": { "type": "git", "url": "https://github.com/hifriendbot/agentwallet-mcp" },
5
+ "version": "1.13.10",
5
6
  "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
7
  "type": "module",
7
8
  "main": "build/index.js",
@@ -16,7 +17,7 @@
16
17
  "scripts": {
17
18
  "build": "tsc",
18
19
  "start": "node build/index.js",
19
- "test": "node test/x402-payment.test.mjs && node test/eip3009.test.mjs && node test/permit2-risk.test.mjs && node test/token-cap.test.mjs && node test/spl-cap.test.mjs && node test/unknown-decimals-cap.test.mjs && node test/decimals-range-cap.test.mjs && node test/local-mode.test.mjs && node test/ssrf-guard.test.mjs && node test/x402-policy.test.mjs && node test/local-guard-shape.test.mjs && node test/allowance-total-cap.test.mjs && node test/autopay-scheme.test.mjs && node test/spl-recipient-guard.test.mjs && node test/approval-binding.test.mjs && node test/ata-rent-size.test.mjs && node test/final-url-guard.test.mjs && node test/audit-1135.test.mjs && node test/local-no-autopay.test.mjs && node test/audit-1136.test.mjs && node test/delete-confirm.test.mjs && node test/audit-1138.test.mjs && node test/audit-1139.test.mjs",
20
+ "test": "node test/x402-payment.test.mjs && node test/eip3009.test.mjs && node test/permit2-risk.test.mjs && node test/token-cap.test.mjs && node test/spl-cap.test.mjs && node test/unknown-decimals-cap.test.mjs && node test/decimals-range-cap.test.mjs && node test/local-mode.test.mjs && node test/ssrf-guard.test.mjs && node test/x402-policy.test.mjs && node test/local-guard-shape.test.mjs && node test/allowance-total-cap.test.mjs && node test/autopay-scheme.test.mjs && node test/spl-recipient-guard.test.mjs && node test/approval-binding.test.mjs && node test/ata-rent-size.test.mjs && node test/final-url-guard.test.mjs && node test/audit-1135.test.mjs && node test/local-no-autopay.test.mjs && node test/audit-1136.test.mjs && node test/delete-confirm.test.mjs && node test/audit-1138.test.mjs && node test/audit-1139.test.mjs && node test/audit-11310.test.mjs",
20
21
  "prepublishOnly": "npm run build"
21
22
  },
22
23
  "keywords": [