agentwallet-mcp 1.13.9 → 1.13.11

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,9 +37,9 @@ 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
- `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.
42
+ `AGENTWALLET_RPC_<chainId>` (or `AGENTWALLET_RPC_URL` for all chains) points at an endpoint you trust (https; plain http only on localhost). Without it a public RPC is used, and a public RPC can see which addresses you ask about. Decimals for a token outside the registry come from that RPC and are accepted only up to 18; pin anything else with `AGENTWALLET_TOKEN_DECIMALS`.
43
43
 
44
44
  `AGENTWALLET_MAX_TX_NATIVE` is a per-transaction ceiling in native units (ETH, MATIC and so on). In hosted mode the server enforces limits; in local mode there is no server, so these guards are the only ones there are. Set them.
45
45
 
@@ -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
 
@@ -330,6 +330,7 @@ Which guards apply depends on who holds the key.
330
330
  **Local mode** (you hold the key): there is no server in the signing path, so the server-side limits and pause above cannot see or stop a locally signed transaction, and nobody (including us) can freeze a local wallet. Your protection is the per-transaction caps enforced inside your own process before anything is signed. Set them before you fund the wallet; an unset cap means no limit.
331
331
 
332
332
  - `AGENTWALLET_MAX_TX_NATIVE`: ceiling per transaction in native units (ETH, MATIC, and so on)
333
+ - `AGENTWALLET_MAX_FEE_NATIVE`: the most one transaction may burn in fees, gas x max fee per gas, in native units (default `0.01`). The value cap alone did not bound fees: a node answering an absurd tip or gas estimate could take the whole balance as priority fee on a cap-compliant transfer. Caller-supplied `gas_limit` / `max_fee` / `priority_fee` are honoured and then bounded by this cap
333
334
  - `AGENTWALLET_MAX_TX_TOKEN`: ceiling per ERC-20 transfer or approval, in human units of the token
334
335
  - `AGENTWALLET_ALLOW_UNKNOWN_TOKEN_CALLS`: set to `1` to let calldata the token cap cannot price reach a token contract or Permit2 (refused by default while the cap is set)
335
336
  - `AGENTWALLET_TOKEN_DECIMALS`: pin decimals for tokens outside the registry (`8453:0x<token>=6,<mint>=9`); a pin is used for amounts and caps instead of asking the RPC
@@ -337,8 +338,10 @@ Which guards apply depends on who holds the key.
337
338
  - `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
339
  - `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
340
  - `AGENTWALLET_TOKEN_RISK=0`: skip the GoPlus lookup `approve_token` performs (a third-party call that reveals which token you are about to approve)
341
+ - `AGENTWALLET_CHAIN_ID`: the EVM chain used when a call names none (default 8453)
342
+ - `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
343
 
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.
344
+ 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
345
 
343
346
  **Either mode**, for x402 paywalls you run through the hosted API:
344
347
 
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
@@ -99,7 +119,12 @@ async function routeLocally(path, method, body) {
99
119
  throw new Error(`Local mode expects an EVM address, got "${to}".`);
100
120
  }
101
121
  const hasData = data && data !== '0x';
102
- return localSend(chainId, to, value, hasData ? data : undefined);
122
+ const fees = {
123
+ ...(body?.gas_limit ? { gas: String(body.gas_limit) } : {}),
124
+ ...(body?.max_fee ? { maxFeePerGas: String(body.max_fee) } : {}),
125
+ ...(body?.priority_fee ? { maxPriorityFeePerGas: String(body.priority_fee) } : {}),
126
+ };
127
+ return localSend(chainId, to, value, hasData ? data : undefined, fees);
103
128
  }
104
129
  // POST /wallets/{id}/x402/authorize (x402 "exact": EIP-3009 authorization, signed here, broadcast by nobody)
105
130
  if (method === 'POST' && /^\/wallets\/[^/]*\/x402\/authorize$/.test(rawPath)) {
@@ -288,7 +313,7 @@ async function api(path, method = 'GET', body, extraHeaders) {
288
313
  function autopayRouteAllowed(path, method) {
289
314
  return method === 'POST' && /^\/wallets(\/\d+\/(sign|send))?(\?|$)/.test(path);
290
315
  }
291
- const AUTOPAY_PER_HOUR = (() => { const n = Number(process.env.AGENTWALLET_MAX_AUTOPAY_PER_HOUR ?? '20'); return Number.isInteger(n) && n >= 0 ? n : 20; })();
316
+ 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
317
  const autopayLog = [];
293
318
  /** Read a body no larger than maxChars, refusing early on a declared length and mid-stream on a real one. */
294
319
  async function readBounded(res, maxChars, who) {
@@ -552,7 +577,8 @@ const PAYWALL_MAX_CHARS = 4 * 1024 * 1024; // a paywall's answer, before and aft
552
577
  and the client's log (2026-10-04 audit). */
553
578
  function redactRpcSecrets(err) {
554
579
  const e = err;
555
- let msg = e?.shortMessage ? [e.shortMessage, e.details].filter(Boolean).join(' ') : String(e?.message ?? err);
580
+ // A rejection that is not an Error (web3.js rejects with a raw {InstructionError} object) used to read "[object Object]".
581
+ 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
582
  msg = msg.split(/\r?\n/).filter(l => !/^\s*(URL|Request body|Request Arguments|Raw Call Arguments)\s*:/i.test(l)).join('\n').trim();
557
583
  return msg.replace(/[a-z][a-z0-9+.-]*:\/\/[^\s"'<>)]+/gi, (u) => redactUrl(u)); // brackets stay in: IPv6 hosts
558
584
  }
@@ -582,7 +608,7 @@ async function verifiedDecimals(chainId, token, given) {
582
608
  /** Cap variables are read at send time; a typo must fail at startup, not after funding. */
583
609
  function validateGuardEnv() {
584
610
  parseDecimalPins(process.env.AGENTWALLET_TOKEN_DECIMALS); // throws on a malformed pin
585
- for (const name of ['AGENTWALLET_MAX_TX_NATIVE', 'AGENTWALLET_MAX_TX_TOKEN', 'AGENTWALLET_MAX_TX_SOL', 'AGENTWALLET_MAX_AUTOPAY']) {
611
+ for (const name of ['AGENTWALLET_MAX_TX_NATIVE', 'AGENTWALLET_MAX_TX_TOKEN', 'AGENTWALLET_MAX_TX_SOL', 'AGENTWALLET_MAX_AUTOPAY', 'AGENTWALLET_MAX_FEE_NATIVE']) {
586
612
  const raw = process.env[name];
587
613
  const v = (raw || '').trim();
588
614
  // Set but blank is a typo, not "no cap": refuse to start rather than run uncapped.
@@ -600,6 +626,48 @@ function validateGuardEnv() {
600
626
  if (t && (!/^\d+$/.test(t) || Number(t) < 60 || Number(t) > 31_536_000)) {
601
627
  throw new Error(`AGENTWALLET_X402_MAX_TIMEOUT must be a whole number of seconds between 60 and 31536000, got "${t}".`);
602
628
  }
629
+ const perHour = process.env.AGENTWALLET_MAX_AUTOPAY_PER_HOUR;
630
+ if (perHour !== undefined && !/^\d+$/.test(perHour.trim())) {
631
+ throw new Error(`AGENTWALLET_MAX_AUTOPAY_PER_HOUR must be a whole number (0 disables internal auto-pay), got "${perHour}".`);
632
+ }
633
+ for (const o of (process.env.AGENTWALLET_LEGACY_PAYWALL_ORIGINS || '').split(',').map(x => x.trim()).filter(Boolean)) {
634
+ let u = null;
635
+ try {
636
+ u = new URL(o);
637
+ }
638
+ catch {
639
+ u = null;
640
+ }
641
+ if (!u || u.protocol !== 'https:' || u.origin !== o.replace(/\/+$/, '')) {
642
+ throw new Error(`AGENTWALLET_LEGACY_PAYWALL_ORIGINS entry "${o}" is not an https origin (scheme and host only, like https://shop.example).`);
643
+ }
644
+ }
645
+ // Two sources for one key family is ambiguous; refuse rather than pick one silently.
646
+ if ((process.env.AGENTWALLET_PRIVATE_KEY || '').trim() && (process.env.AGENTWALLET_KEYFILE || '').trim()) {
647
+ throw new Error('Both AGENTWALLET_PRIVATE_KEY and AGENTWALLET_KEYFILE are set; keep one.');
648
+ }
649
+ if ((process.env.AGENTWALLET_SOLANA_KEY || '').trim() && (process.env.AGENTWALLET_SOLANA_KEYFILE || '').trim()) {
650
+ throw new Error('Both AGENTWALLET_SOLANA_KEY and AGENTWALLET_SOLANA_KEYFILE are set; keep one.');
651
+ }
652
+ // An unrecognised AGENTWALLET_* name is almost always a misspelled cap, which
653
+ // would run uncapped without a word (round 4). Refuse to start instead.
654
+ for (const k of Object.keys(process.env)) {
655
+ if (!k.startsWith('AGENTWALLET_'))
656
+ continue;
657
+ if (KNOWN_ENV.has(k) || /^AGENTWALLET_(RPC|SOLANA_RPC)_\d+$/.test(k))
658
+ continue;
659
+ 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>.`);
660
+ }
661
+ }
662
+ 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_FEE_NATIVE", "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"]);
663
+ /**
664
+ * Register a tool whose arguments are strict: an unknown key (max_payemnt for
665
+ * max_payment) is an error that names it, not a silently dropped guard. The
666
+ * advertised schema always said additionalProperties: false; now the runtime
667
+ * agrees (round 4).
668
+ */
669
+ function tool(name, description, shape, cb) {
670
+ return server.registerTool(name, { description, inputSchema: z.object(shape).strict() }, cb);
603
671
  }
604
672
  // ─── EVM Helpers ──────────────────────────────────────────────────
605
673
  /**
@@ -679,10 +747,10 @@ function resolveChainId(network) {
679
747
  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
748
  const server = new McpServer({
681
749
  name: 'agentwallet',
682
- version: '1.13.9',
750
+ version: '1.13.11',
683
751
  }, {
684
752
  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.'
753
+ ? '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
754
  : 'Private keys are encrypted server-side and never exposed: agents sign and broadcast transactions without ever touching raw keys.'}
687
755
 
688
756
  ## Getting Started
@@ -738,9 +806,7 @@ Create paywalls to charge other agents for accessing your resources:
738
806
 
739
807
  ## Wallet Security
740
808
  - ${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.'}
809
+ ${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
810
 
745
811
  ## Tool Selection Guide
746
812
  | Goal | Tool |
@@ -760,7 +826,7 @@ Create paywalls to charge other agents for accessing your resources:
760
826
  | Check usage/billing | get_usage |`,
761
827
  });
762
828
  // ─── Tool: create_wallet ─────────────────────────────────────────
763
- server.tool('create_wallet', 'Create a new EVM or Solana wallet. Returns the wallet ID and address. ' +
829
+ tool('create_wallet', 'Create a new EVM or Solana wallet. Returns the wallet ID and address. ' +
764
830
  (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
831
  label: z.string().default('').describe('Friendly name for the wallet'),
766
832
  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 +835,13 @@ server.tool('create_wallet', 'Create a new EVM or Solana wallet. Returns the wal
769
835
  return jsonResponse(data);
770
836
  });
771
837
  // ─── Tool: list_wallets ──────────────────────────────────────────
772
- server.tool('list_wallets', 'List all wallets owned by the authenticated user. ' +
838
+ tool('list_wallets', 'List all wallets owned by the authenticated user. ' +
773
839
  'Returns wallet IDs, addresses, labels, chain IDs, and status.', {}, async () => {
774
840
  const data = await api('/wallets');
775
841
  return jsonResponse(data);
776
842
  });
777
843
  // ─── Tool: get_wallet ────────────────────────────────────────────
778
- server.tool('get_wallet', 'Get details for a specific wallet by ID. ' +
844
+ tool('get_wallet', 'Get details for a specific wallet by ID. ' +
779
845
  'Returns address, label, chain, spending limits, and pause status.', {
780
846
  wallet_id: z.number().int().describe('Wallet ID'),
781
847
  }, async ({ wallet_id }) => {
@@ -783,7 +849,7 @@ server.tool('get_wallet', 'Get details for a specific wallet by ID. ' +
783
849
  return jsonResponse(data);
784
850
  });
785
851
  // ─── Tool: get_balance ───────────────────────────────────────────
786
- server.tool('get_balance', 'Get the native token balance for a wallet on a specific chain. ' +
852
+ tool('get_balance', 'Get the native token balance for a wallet on a specific chain. ' +
787
853
  'Returns balance in both wei (or lamports for Solana) and human-readable format.', {
788
854
  wallet_id: z.number().int().describe('Wallet ID'),
789
855
  chain_id: z.number().int().optional().describe('Chain ID to check (defaults to wallet\'s default chain)'),
@@ -793,7 +859,7 @@ server.tool('get_balance', 'Get the native token balance for a wallet on a speci
793
859
  return jsonResponse(data);
794
860
  });
795
861
  // ─── Tool: sign_transaction ──────────────────────────────────────
796
- server.tool('sign_transaction', 'Sign a transaction with a wallet\'s private key. ' +
862
+ tool('sign_transaction', 'Sign a transaction with a wallet\'s private key. ' +
797
863
  'For EVM: returns signed raw transaction hex. For Solana: returns base64 signed transaction. ' +
798
864
  'Does NOT broadcast — use send_transaction for sign + broadcast.', {
799
865
  wallet_id: z.number().int().describe('Wallet ID'),
@@ -839,7 +905,7 @@ server.tool('sign_transaction', 'Sign a transaction with a wallet\'s private key
839
905
  return jsonResponse(result);
840
906
  });
841
907
  // ─── Tool: send_transaction ──────────────────────────────────────
842
- server.tool('send_transaction', 'Sign and broadcast a transaction. ' +
908
+ tool('send_transaction', 'Sign and broadcast a transaction. ' +
843
909
  'Returns the transaction hash (EVM) or signature (Solana) on success. ' +
844
910
  (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
911
  wallet_id: z.number().int().describe('Wallet ID'),
@@ -885,7 +951,7 @@ server.tool('send_transaction', 'Sign and broadcast a transaction. ' +
885
951
  return jsonResponse(result);
886
952
  });
887
953
  // ─── Tool: transfer ─────────────────────────────────────────────
888
- server.tool('transfer', 'Send native tokens (ETH, AVAX, BNB, POL, PLS, SOL) to an address. ' +
954
+ tool('transfer', 'Send native tokens (ETH, AVAX, BNB, POL, PLS, SOL) to an address. ' +
889
955
  'Specify the amount in human-readable format (e.g. "0.1" for 0.1 ETH). ' +
890
956
  'The amount is converted to wei/lamports automatically. Signs and broadcasts the transaction.', {
891
957
  wallet_id: z.number().int().describe('Wallet ID to send from'),
@@ -916,7 +982,7 @@ server.tool('transfer', 'Send native tokens (ETH, AVAX, BNB, POL, PLS, SOL) to a
916
982
  });
917
983
  });
918
984
  // ─── 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. ' +
985
+ tool('get_token_balance', 'Get the ERC-20 or SPL token balance for a wallet on a specific chain. ' +
920
986
  'Returns the raw balance and human-readable balance. ' +
921
987
  'Use get_chains to find stablecoin addresses for each chain.', {
922
988
  wallet_id: z.number().int().describe('Wallet ID to check'),
@@ -960,7 +1026,7 @@ server.tool('get_token_balance', 'Get the ERC-20 or SPL token balance for a wall
960
1026
  });
961
1027
  });
962
1028
  // ─── Tool: transfer_token ───────────────────────────────────────
963
- server.tool('transfer_token', 'Send ERC-20 tokens (EVM) or SPL tokens (Solana) to an address. ' +
1029
+ tool('transfer_token', 'Send ERC-20 tokens (EVM) or SPL tokens (Solana) to an address. ' +
964
1030
  'Specify the amount in human-readable format (e.g. "100" for 100 USDC). ' +
965
1031
  'Signs and broadcasts the transaction. Use get_chains to find stablecoin addresses.', {
966
1032
  wallet_id: z.number().int().describe('Wallet ID to send from'),
@@ -1009,7 +1075,7 @@ server.tool('transfer_token', 'Send ERC-20 tokens (EVM) or SPL tokens (Solana) t
1009
1075
  });
1010
1076
  });
1011
1077
  // ─── Tool: call_contract ────────────────────────────────────────
1012
- server.tool('call_contract', 'Execute a read-only call against a smart contract (eth_call). ' +
1078
+ tool('call_contract', 'Execute a read-only call against a smart contract (eth_call). ' +
1013
1079
  'Returns the raw hex result. Does not cost gas or modify state. ' +
1014
1080
  'Useful for reading on-chain data like token balances, prices, positions.', {
1015
1081
  chain_id: z.number().int().describe('Chain ID'),
@@ -1023,7 +1089,7 @@ server.tool('call_contract', 'Execute a read-only call against a smart contract
1023
1089
  return jsonResponse(result);
1024
1090
  });
1025
1091
  // ─── Tool: approve_token ────────────────────────────────────────
1026
- server.tool('approve_token', 'Approve a spender contract to transfer ERC-20 tokens on your behalf. ' +
1092
+ tool('approve_token', 'Approve a spender contract to transfer ERC-20 tokens on your behalf. ' +
1027
1093
  'Required before interacting with any DeFi protocol (DEXs, lending, etc.). ' +
1028
1094
  'Use amount "max" for unlimited approval, or specify an exact amount.', {
1029
1095
  wallet_id: z.number().int().describe('Wallet ID'),
@@ -1071,7 +1137,7 @@ server.tool('approve_token', 'Approve a spender contract to transfer ERC-20 toke
1071
1137
  });
1072
1138
  });
1073
1139
  // ─── Tool: get_allowance ────────────────────────────────────────
1074
- server.tool('get_allowance', 'Check how many ERC-20 tokens a spender is approved to transfer. ' +
1140
+ tool('get_allowance', 'Check how many ERC-20 tokens a spender is approved to transfer. ' +
1075
1141
  'Returns the allowance in both raw and human-readable format. ' +
1076
1142
  'Use this to check if an approval is needed before a DeFi transaction.', {
1077
1143
  wallet_id: z.number().int().describe('Wallet ID (used to determine the owner address)'),
@@ -1107,7 +1173,7 @@ server.tool('get_allowance', 'Check how many ERC-20 tokens a spender is approved
1107
1173
  });
1108
1174
  // ─── WETH / Wrapped Native Token addresses ──────────────────────
1109
1175
  // ─── 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.). ' +
1176
+ tool('wrap_eth', 'Wrap native tokens (ETH, AVAX, BNB, POL, PLS) into their wrapped ERC-20 version (WETH, WAVAX, etc.). ' +
1111
1177
  'Required for most DeFi protocols that use ERC-20 tokens instead of raw native tokens. ' +
1112
1178
  'Specify amount in human-readable format (e.g. "0.5" for 0.5 ETH).', {
1113
1179
  wallet_id: z.number().int().describe('Wallet ID'),
@@ -1137,7 +1203,7 @@ server.tool('wrap_eth', 'Wrap native tokens (ETH, AVAX, BNB, POL, PLS) into thei
1137
1203
  });
1138
1204
  });
1139
1205
  // ─── Tool: unwrap_eth ───────────────────────────────────────────
1140
- server.tool('unwrap_eth', 'Unwrap wrapped tokens (WETH, WAVAX, WBNB, etc.) back to native tokens. ' +
1206
+ tool('unwrap_eth', 'Unwrap wrapped tokens (WETH, WAVAX, WBNB, etc.) back to native tokens. ' +
1141
1207
  'Specify amount in human-readable format (e.g. "0.5" for 0.5 WETH).', {
1142
1208
  wallet_id: z.number().int().describe('Wallet ID'),
1143
1209
  amount: z.string().describe('Amount to unwrap in human-readable format (e.g. "0.5")'),
@@ -1196,7 +1262,7 @@ function decodeAbiString(hex) {
1196
1262
  return '';
1197
1263
  }
1198
1264
  }
1199
- server.tool('get_token_info', 'Get the name, symbol, and decimals of any ERC-20 token by its contract address. ' +
1265
+ tool('get_token_info', 'Get the name, symbol, and decimals of any ERC-20 token by its contract address. ' +
1200
1266
  'Useful for discovering token details before transfers or approvals.', {
1201
1267
  token: z.string().regex(/^0x[a-fA-F0-9]{40}$/).describe('ERC-20 token contract address'),
1202
1268
  chain_id: z.number().int().describe('Chain ID'),
@@ -1336,7 +1402,7 @@ async function signPermit2(walletId, chainId, auth, approvalId) {
1336
1402
  return sig;
1337
1403
  }
1338
1404
  // ─── 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, ' +
1405
+ tool('pay_x402', 'Handle an x402 payment flow. Fetches a URL, and if the server returns HTTP 402 Payment Required, ' +
1340
1406
  'parses the payment requirements (v1 body or v2 PAYMENT-REQUIRED header), signs an EIP-3009 ' +
1341
1407
  'TransferWithAuthorization for the "exact" scheme (no gas, nothing broadcast by the payer), and retries ' +
1342
1408
  'the request with the payment header. AgentWallet paywalls are paid by on-chain transfer instead. ' +
@@ -1360,6 +1426,9 @@ server.tool('pay_x402', 'Handle an x402 payment flow. Fetches a URL, and if the
1360
1426
  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
1427
  'within the validity window re-sends the earlier signature (same nonce, so it cannot settle twice) instead of paying again.'),
1362
1428
  }, async ({ url, wallet_id, method, headers: headersJson, body: reqBody, max_payment, prefer_chain, request_approval, approval_id, fresh_authorization }) => {
1429
+ if (max_payment !== undefined && !/^\d+(\.\d+)?$/.test(String(max_payment).trim())) {
1430
+ throw new Error(`max_payment must be a decimal number of stablecoin units, got "${serverText(max_payment)}"; nothing was fetched.`);
1431
+ }
1363
1432
  // Build request headers
1364
1433
  const reqHeaders = { Accept: 'application/json' };
1365
1434
  if (headersJson) {
@@ -1801,10 +1870,10 @@ server.tool('pay_x402', 'Handle an x402 payment flow. Fetches a URL, and if the
1801
1870
  });
1802
1871
  });
1803
1872
  // ─── Tool: check_approval ───────────────────────────────────────
1804
- server.tool('check_approval', 'Status of a payment approval created when pay_x402 exceeded the cap on a hosted wallet. ' +
1873
+ tool('check_approval', 'Status of a payment approval created when pay_x402 exceeded the cap on a hosted wallet. ' +
1805
1874
  '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
1875
  // ─── 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. ' +
1876
+ 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
1877
  'A normal on-chain transaction (needs gas). By default approves the maximum, the ecosystem norm, so it never has to be repeated; ' +
1809
1878
  'pass amount to grant a bounded allowance instead (required under AGENTWALLET_MAX_TX_TOKEN, which refuses a max approval). ' +
1810
1879
  'Permit2 itself only moves what each signed authorization allows.', {
@@ -1824,20 +1893,20 @@ server.tool('approve_permit2', 'One-time ERC-20 approval of a token to the Permi
1824
1893
  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
1894
  });
1826
1895
  // ─── 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, ' +
1896
+ tool('check_token_risk', 'Assess an ERC-20 token before approving or swapping it: honeypot, taxes, owner powers, verified source, ' +
1828
1897
  'holder concentration, DEX liquidity. Uses GoPlus Security (free, no key) with an on-chain fallback. ' +
1829
1898
  'A warning for the caller to weigh, never a block.', {
1830
1899
  token: z.string().regex(/^0x[a-fA-F0-9]{40}$/).describe('ERC-20 token contract address'),
1831
1900
  chain_id: z.number().int().describe('Chain ID'),
1832
1901
  }, async ({ token, chain_id }) => jsonResponse({ token, chain_id, risk: await assessTokenRisk(chain_id, token, (to, data) => ethCallHex(chain_id, to, data)) }));
1833
1902
  // ─── Tool: get_usage ─────────────────────────────────────────────
1834
- server.tool('get_usage', 'Get the current month\'s usage statistics. ' +
1903
+ tool('get_usage', 'Get the current month\'s usage statistics. ' +
1835
1904
  'Returns operations count, tier info, remaining quota, and fees.', {}, async () => {
1836
1905
  const data = await api('/usage');
1837
1906
  return jsonResponse(data);
1838
1907
  });
1839
1908
  // ─── Tool: buy_verification_credits ─────────────────────────────
1840
- server.tool('buy_verification_credits', 'Buy x402 verification credits with USDC on-chain. ' +
1909
+ tool('buy_verification_credits', 'Buy x402 verification credits with USDC on-chain. ' +
1841
1910
  'Paywall owners need credits to process verifications beyond the free tier (1,000/month) ' +
1842
1911
  'when they don\'t have Stripe billing configured. ' +
1843
1912
  'Returns 402 payment instructions — pay on-chain and retry with proof.', {
@@ -1848,27 +1917,27 @@ server.tool('buy_verification_credits', 'Buy x402 verification credits with USDC
1848
1917
  return jsonResponse(data);
1849
1918
  });
1850
1919
  // ─── Tool: pause_wallet ──────────────────────────────────────────
1851
- server.tool('pause_wallet', 'Emergency pause a wallet. No transactions can be signed while paused.', {
1920
+ tool('pause_wallet', 'Emergency pause a wallet. No transactions can be signed while paused.', {
1852
1921
  wallet_id: z.number().int().describe('Wallet ID to pause'),
1853
1922
  }, async ({ wallet_id }) => {
1854
1923
  const data = await api(`/wallets/${wallet_id}/pause`, 'POST');
1855
1924
  return jsonResponse(data);
1856
1925
  });
1857
1926
  // ─── Tool: unpause_wallet ────────────────────────────────────────
1858
- server.tool('unpause_wallet', 'Resume a paused wallet so transactions can be signed again.', {
1927
+ tool('unpause_wallet', 'Resume a paused wallet so transactions can be signed again.', {
1859
1928
  wallet_id: z.number().int().describe('Wallet ID to unpause'),
1860
1929
  }, async ({ wallet_id }) => {
1861
1930
  const data = await api(`/wallets/${wallet_id}/unpause`, 'POST');
1862
1931
  return jsonResponse(data);
1863
1932
  });
1864
1933
  // ─── Tool: get_chains ────────────────────────────────────────────
1865
- server.tool('get_chains', 'List all supported chains (EVM + Solana) with their chain IDs, native tokens, ' +
1934
+ tool('get_chains', 'List all supported chains (EVM + Solana) with their chain IDs, native tokens, ' +
1866
1935
  'stablecoins, and RPC configuration status.', {}, async () => {
1867
1936
  const data = await api('/chains');
1868
1937
  return jsonResponse(data);
1869
1938
  });
1870
1939
  // ─── Tool: delete_wallet ─────────────────────────────────────────
1871
- server.tool('delete_wallet', 'Delete (soft-delete) a wallet. The wallet will no longer appear in listings ' +
1940
+ tool('delete_wallet', 'Delete (soft-delete) a wallet. The wallet will no longer appear in listings ' +
1872
1941
  'and cannot be used for transactions. The wallet is read first and the delete names its address, ' +
1873
1942
  'so a wallet that cannot be read is never deleted.', {
1874
1943
  wallet_id: z.number().int().describe('Wallet ID to delete'),
@@ -1883,7 +1952,7 @@ server.tool('delete_wallet', 'Delete (soft-delete) a wallet. The wallet will no
1883
1952
  return jsonResponse(data);
1884
1953
  });
1885
1954
  // ─── Tool: create_paywall ────────────────────────────────────────
1886
- server.tool('create_paywall', 'Create an x402 paywall that charges agents/clients for accessing a resource. ' +
1955
+ tool('create_paywall', 'Create an x402 paywall that charges agents/clients for accessing a resource. ' +
1887
1956
  'Returns a public access URL that returns HTTP 402 until paid. ' +
1888
1957
  'Agents pay on-chain, then retry with proof to get the content.', {
1889
1958
  wallet_id: z.number().int().describe('Wallet ID to receive payments'),
@@ -1923,7 +1992,7 @@ server.tool('create_paywall', 'Create an x402 paywall that charges agents/client
1923
1992
  });
1924
1993
  });
1925
1994
  // ─── Tool: list_paywalls ────────────────────────────────────────
1926
- server.tool('list_paywalls', 'List all your x402 paywalls. Returns paywall IDs, names, pricing, ' +
1995
+ tool('list_paywalls', 'List all your x402 paywalls. Returns paywall IDs, names, pricing, ' +
1927
1996
  'access URLs, payment counts, and revenue totals.', {
1928
1997
  page: z.number().int().default(1).describe('Page number'),
1929
1998
  per_page: z.number().int().min(1).max(100).default(50).describe('Results per page (max 100)'),
@@ -1932,7 +2001,7 @@ server.tool('list_paywalls', 'List all your x402 paywalls. Returns paywall IDs,
1932
2001
  return jsonResponse(data);
1933
2002
  });
1934
2003
  // ─── Tool: get_paywall ──────────────────────────────────────────
1935
- server.tool('get_paywall', 'Get details for a specific x402 paywall by ID. ' +
2004
+ tool('get_paywall', 'Get details for a specific x402 paywall by ID. ' +
1936
2005
  'Returns pricing, access URL, payment stats, and configuration.', {
1937
2006
  paywall_id: z.number().int().describe('Paywall ID'),
1938
2007
  }, async ({ paywall_id }) => {
@@ -1940,7 +2009,7 @@ server.tool('get_paywall', 'Get details for a specific x402 paywall by ID. ' +
1940
2009
  return jsonResponse(data);
1941
2010
  });
1942
2011
  // ─── Tool: update_paywall ───────────────────────────────────────
1943
- server.tool('update_paywall', 'Update an x402 paywall configuration. ' +
2012
+ tool('update_paywall', 'Update an x402 paywall configuration. ' +
1944
2013
  'Can change price, resource URL, active status, or any other field.', {
1945
2014
  paywall_id: z.number().int().describe('Paywall ID to update'),
1946
2015
  name: z.string().optional().describe('New paywall name'),
@@ -1978,14 +2047,14 @@ server.tool('update_paywall', 'Update an x402 paywall configuration. ' +
1978
2047
  return jsonResponse(data);
1979
2048
  });
1980
2049
  // ─── Tool: delete_paywall ───────────────────────────────────────
1981
- server.tool('delete_paywall', 'Delete an x402 paywall. The access URL will return 404 after deletion.', {
2050
+ tool('delete_paywall', 'Delete an x402 paywall. The access URL will return 404 after deletion.', {
1982
2051
  paywall_id: z.number().int().describe('Paywall ID to delete'),
1983
2052
  }, async ({ paywall_id }) => {
1984
2053
  const data = await api(`/x402/paywalls/${paywall_id}`, 'DELETE');
1985
2054
  return jsonResponse(data);
1986
2055
  });
1987
2056
  // ─── Tool: get_paywall_payments ─────────────────────────────────
1988
- server.tool('get_paywall_payments', 'Get payment history for a specific x402 paywall. ' +
2057
+ tool('get_paywall_payments', 'Get payment history for a specific x402 paywall. ' +
1989
2058
  'Returns verified payments with TX hashes, payer addresses, amounts, and timestamps.', {
1990
2059
  paywall_id: z.number().int().describe('Paywall ID'),
1991
2060
  page: z.number().int().default(1).describe('Page number'),
@@ -1995,13 +2064,13 @@ server.tool('get_paywall_payments', 'Get payment history for a specific x402 pay
1995
2064
  return jsonResponse(data);
1996
2065
  });
1997
2066
  // ─── Tool: get_x402_revenue ─────────────────────────────────────
1998
- server.tool('get_x402_revenue', 'Get aggregate x402 revenue statistics across all your paywalls. ' +
2067
+ tool('get_x402_revenue', 'Get aggregate x402 revenue statistics across all your paywalls. ' +
1999
2068
  'Returns total payments and revenue broken down by chain and token.', {}, async () => {
2000
2069
  const data = await api('/x402/revenue');
2001
2070
  return jsonResponse(data);
2002
2071
  });
2003
2072
  // ─── Tool: wallet_mode ──────────────────────────────────────────
2004
- server.tool('wallet_mode', 'Report whether this server is signing locally (self-custody, the private key never leaves this machine) ' +
2073
+ tool('wallet_mode', 'Report whether this server is signing locally (self-custody, the private key never leaves this machine) ' +
2005
2074
  'or through the hosted AgentWallet API (custodial). Use this to verify custody before moving funds.', {}, async () => {
2006
2075
  if (!anyLocalMode()) {
2007
2076
  return jsonResponse({
@@ -2011,8 +2080,13 @@ server.tool('wallet_mode', 'Report whether this server is signing locally (self-
2011
2080
  api_base: redactUrl(API_BASE),
2012
2081
  client_guards: {
2013
2082
  autopay_cap_usd: autopayEnvCap(),
2083
+ internal_autopay_armed: Boolean(X402_WALLET_ID),
2084
+ max_autopay_per_hour: AUTOPAY_PER_HOUR,
2014
2085
  autopay_assets: (process.env.AGENTWALLET_AUTOPAY_ASSETS || '').trim() || 'registry stablecoins only',
2015
2086
  x402_max_timeout_seconds: maxAuthWindowSeconds(),
2087
+ approvals: process.env.AGENTWALLET_APPROVALS !== '0',
2088
+ permit2_auto_approve: process.env.AGENTWALLET_PERMIT2_AUTO_APPROVE === '1',
2089
+ legacy_paywall_origins: (process.env.AGENTWALLET_LEGACY_PAYWALL_ORIGINS || '').trim() || 'none (API host only)',
2016
2090
  note: 'These bound pay_x402 on hosted wallets too; the hosted signer adds its own pause, daily limit and approval checks.',
2017
2091
  },
2018
2092
  to_self_custody: 'Set AGENTWALLET_PRIVATE_KEY for EVM and/or AGENTWALLET_SOLANA_KEY for Solana, then restart. ' +
@@ -2023,12 +2097,18 @@ server.tool('wallet_mode', 'Report whether this server is signing locally (self-
2023
2097
  mode: 'local',
2024
2098
  custody: 'self',
2025
2099
  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',
2100
+ max_autopay: autopayEnvCap(),
2101
+ max_autopay_per_hour: AUTOPAY_PER_HOUR,
2102
+ internal_autopay_armed: false, // internal auto-pay never runs in local mode
2103
+ autopay_assets: (process.env.AGENTWALLET_AUTOPAY_ASSETS || '').trim() || 'registry stablecoins only',
2028
2104
  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)',
2105
+ per_tx_cap_token: (process.env.AGENTWALLET_MAX_TX_TOKEN || '').trim() || 'not set (ERC-20 and SPL transfers are uncapped)',
2030
2106
  allow_unknown_token_calls: process.env.AGENTWALLET_ALLOW_UNKNOWN_TOKEN_CALLS === '1',
2031
2107
  token_decimals_pins: parseDecimalPins(process.env.AGENTWALLET_TOKEN_DECIMALS).size,
2108
+ token_risk_lookup: process.env.AGENTWALLET_TOKEN_RISK !== '0',
2109
+ permit2_auto_approve: process.env.AGENTWALLET_PERMIT2_AUTO_APPROVE === '1',
2110
+ legacy_paywall_origins: (process.env.AGENTWALLET_LEGACY_PAYWALL_ORIGINS || '').trim() || 'none (API host only)',
2111
+ default_chain_id: (process.env.AGENTWALLET_CHAIN_ID || '').trim() || '8453',
2032
2112
  };
2033
2113
  if (isLocalMode()) {
2034
2114
  let rpc = 'default public endpoint';
@@ -2039,7 +2119,8 @@ server.tool('wallet_mode', 'Report whether this server is signing locally (self-
2039
2119
  report.evm = {
2040
2120
  address: getLocalAddress(),
2041
2121
  rpc_endpoint: redactUrl(rpc),
2042
- per_tx_cap_native: process.env.AGENTWALLET_MAX_TX_NATIVE || 'not set',
2122
+ per_tx_cap_native: (process.env.AGENTWALLET_MAX_TX_NATIVE || '').trim() || 'not set',
2123
+ per_tx_fee_cap_native: (process.env.AGENTWALLET_MAX_FEE_NATIVE || '').trim() || '0.01 (default)',
2043
2124
  };
2044
2125
  }
2045
2126
  else {
@@ -2054,8 +2135,8 @@ server.tool('wallet_mode', 'Report whether this server is signing locally (self-
2054
2135
  report.solana = {
2055
2136
  address: getSolanaAddress(),
2056
2137
  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)',
2138
+ per_tx_cap_sol: (process.env.AGENTWALLET_MAX_TX_SOL || '').trim() || 'not set',
2139
+ 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
2140
  };
2060
2141
  }
2061
2142
  else {
@@ -2064,7 +2145,7 @@ server.tool('wallet_mode', 'Report whether this server is signing locally (self-
2064
2145
  return jsonResponse(report);
2065
2146
  });
2066
2147
  // ─── 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 ' +
2148
+ tool('export_wallet_key', 'Explain how to export the private key of a hosted (custodial) AgentWallet so it can be moved ' +
2068
2149
  'to self-custody or any other wallet. Export itself is deliberately human-gated and is not ' +
2069
2150
  'performed by this tool.', {
2070
2151
  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(),
@@ -49,14 +49,15 @@ export declare function localNativeBalance(chainId: number): Promise<{
49
49
  mode: string;
50
50
  }>;
51
51
  export declare function localTokenBalance(chainId: number, token: Address): Promise<{
52
+ mode: string;
53
+ warning?: string | undefined;
52
54
  address: `0x${string}`;
53
55
  chain_id: number;
54
56
  token: `0x${string}`;
55
57
  symbol: string;
56
- decimals: number;
58
+ decimals: number | null;
57
59
  balance_raw: string;
58
- balance: string;
59
- mode: string;
60
+ balance: string | null;
60
61
  }>;
61
62
  export interface LocalSendResult {
62
63
  tx_hash: string;
@@ -66,6 +67,9 @@ export interface LocalSendResult {
66
67
  chain_id: number;
67
68
  mode: 'local';
68
69
  signed_locally: true;
70
+ gas?: string;
71
+ max_fee_per_gas?: string;
72
+ worst_case_fee?: string;
69
73
  }
70
74
  /**
71
75
  * What a decimals() probe learned about an address. The four outcomes are
@@ -89,7 +93,20 @@ type DecimalsProbe = number | 'unusable' | 'not-a-token' | 'unreachable';
89
93
  /** An eth_call failure is a revert only when the node says so; every other failure is "could not ask". */
90
94
  export declare function classifyDecimalsError(err: unknown): DecimalsProbe;
91
95
  export declare function permit2AddressFor(chainId: number): string;
92
- export declare function localSend(chainId: number, to: Address, valueWei: bigint, data?: Hex): Promise<LocalSendResult>;
96
+ /** Caller-chosen gas parameters (wei / gas units as decimal strings); honoured, then bounded by the fee cap. */
97
+ export interface FeeOverrides {
98
+ gas?: string;
99
+ maxFeePerGas?: string;
100
+ maxPriorityFeePerGas?: string;
101
+ }
102
+ /**
103
+ * The most a transaction may burn in fees: gas x maxFeePerGas, in native units.
104
+ * Without it a node that answered a 47,000 gwei tip or a 30M gas estimate took
105
+ * the whole balance as fees on a cap-compliant 0.001 ETH transfer (round 4).
106
+ * Default 0.01; AGENTWALLET_MAX_FEE_NATIVE raises or lowers it.
107
+ */
108
+ export declare function feeCapWei(): bigint;
109
+ export declare function localSend(chainId: number, to: Address, valueWei: bigint, data?: Hex, fees?: FeeOverrides): Promise<LocalSendResult>;
93
110
  /** ERC-20 transfer, encoded locally. */
94
111
  export declare function localTransferToken(chainId: number, token: Address, to: Address, rawAmount: string): Promise<LocalSendResult>;
95
112
  /** ERC-20 approve, encoded locally. */
@@ -15,7 +15,7 @@
15
15
  * on the custodial path and say so explicitly rather than silently downgrading.
16
16
  */
17
17
  import { readFileSync, statSync } from 'node:fs';
18
- import { createPublicClient, createWalletClient, http, encodeFunctionData, parseAbi, formatUnits, } from 'viem';
18
+ import { createPublicClient, createWalletClient, http, encodeFunctionData, parseAbi, formatUnits, keccak256, } from 'viem';
19
19
  import { privateKeyToAccount } from 'viem/accounts';
20
20
  import { nonceManager } from 'viem/nonce';
21
21
  import { lookupTrustedDecimals, maxAuthWindowSeconds } from './x402-payment.js';
@@ -109,16 +109,31 @@ const DEFAULT_RPC = {
109
109
  export function resolveRpcUrl(chainId) {
110
110
  const perChain = (process.env[`AGENTWALLET_RPC_${chainId}`] || '').trim();
111
111
  if (perChain)
112
- return perChain;
112
+ return assertRpcScheme(perChain, `AGENTWALLET_RPC_${chainId}`);
113
113
  const generic = (process.env.AGENTWALLET_RPC_URL || '').trim();
114
114
  if (generic)
115
- return generic;
115
+ return assertRpcScheme(generic, 'AGENTWALLET_RPC_URL');
116
116
  const fallback = DEFAULT_RPC[chainId];
117
117
  if (fallback)
118
118
  return fallback;
119
119
  throw new Error(`No RPC endpoint for chain ${chainId} in local mode. ` +
120
120
  `Set AGENTWALLET_RPC_${chainId} to an endpoint you trust.`);
121
121
  }
122
+ /** An RPC over plain http to anything but loopback is the hostile-node model in full; refused like the API URL is. */
123
+ function assertRpcScheme(url, name) {
124
+ let u;
125
+ try {
126
+ u = new URL(url);
127
+ }
128
+ catch {
129
+ throw new Error(`${name} is not a URL: "${url}".`);
130
+ }
131
+ const loopback = u.hostname === 'localhost' || u.hostname === '127.0.0.1' || u.hostname === '[::1]';
132
+ if (u.protocol !== 'https:' && !(u.protocol === 'http:' && loopback)) {
133
+ throw new Error(`${name} must be https (got ${u.protocol}//${u.host}); plain http is allowed only for localhost.`);
134
+ }
135
+ return url;
136
+ }
122
137
  /**
123
138
  * Chain ids each RPC URL has been seen to serve, so an operator pointing
124
139
  * AGENTWALLET_RPC_URL at one node for every chain, or mistyping a per-chain
@@ -187,16 +202,21 @@ const ERC20_ABI = parseAbi([
187
202
  export async function localTokenBalance(chainId, token) {
188
203
  const { pub } = clients(chainId);
189
204
  const address = getLocalAddress();
190
- const [raw, decimals, symbol] = await Promise.all([
205
+ const [raw, decimalsRaw, symbol] = await Promise.all([
191
206
  pub.readContract({ address: token, abi: ERC20_ABI, functionName: 'balanceOf', args: [address] }),
192
- pub.readContract({ address: token, abi: ERC20_ABI, functionName: 'decimals' }).catch(() => 18),
207
+ pub.readContract({ address: token, abi: ERC20_ABI, functionName: 'decimals' }).catch(() => null),
193
208
  pub.readContract({ address: token, abi: ERC20_ABI, functionName: 'symbol' }).catch(() => ''),
194
209
  ]);
210
+ // A decimals() of 2^20 handed to viem's formatUnits held the whole server for
211
+ // minutes (quadratic trailing-zero trim, round 4): only 0..36 is formatted.
212
+ const d = Number(decimalsRaw);
213
+ const safe = Number.isInteger(d) && d >= 0 && d <= 36 ? d : null;
195
214
  return {
196
215
  address, chain_id: chainId, token, symbol,
197
- decimals: Number(decimals),
216
+ decimals: safe,
198
217
  balance_raw: raw.toString(),
199
- balance: formatUnits(raw, Number(decimals)),
218
+ balance: safe === null ? null : formatUnits(raw, safe),
219
+ ...(safe === null ? { warning: 'the token reported decimals outside 0..36 (or none); only the raw balance is shown' } : {}),
200
220
  mode: 'local',
201
221
  };
202
222
  }
@@ -261,8 +281,11 @@ async function probeTokenDecimals(chainId, token) {
261
281
  return 'unusable'; // answered, but not one uint8 word
262
282
  const d = Number(BigInt(result));
263
283
  // Reject nonsense rather than trusting it; an out-of-range value would
264
- // widen the ceiling exactly like the old assumption did.
265
- if (Number.isInteger(d) && d >= 0 && d <= 36) {
284
+ // widen the ceiling exactly like the old assumption did. Above 18 the RPC is
285
+ // the only witness and a lie there scales the cap by 10^18 (a node saying 36
286
+ // for mainnet WETH, round 4): such tokens must be pinned with
287
+ // AGENTWALLET_TOKEN_DECIMALS.
288
+ if (Number.isInteger(d) && d >= 0 && d <= 18) {
266
289
  decimalsCache.set(key, d);
267
290
  return d;
268
291
  }
@@ -435,7 +458,25 @@ async function assertWithinTokenCap(chainId, to, data, valueWei = 0n) {
435
458
  }
436
459
  }
437
460
  }
438
- export async function localSend(chainId, to, valueWei, data) {
461
+ function parseUint(v, what) {
462
+ if (!/^\d{1,40}$/.test(v))
463
+ throw new Error(`${what} must be a non-negative integer string, got "${v}".`);
464
+ return BigInt(v);
465
+ }
466
+ /**
467
+ * The most a transaction may burn in fees: gas x maxFeePerGas, in native units.
468
+ * Without it a node that answered a 47,000 gwei tip or a 30M gas estimate took
469
+ * the whole balance as fees on a cap-compliant 0.001 ETH transfer (round 4).
470
+ * Default 0.01; AGENTWALLET_MAX_FEE_NATIVE raises or lowers it.
471
+ */
472
+ export function feeCapWei() {
473
+ const raw = (process.env.AGENTWALLET_MAX_FEE_NATIVE || '0.01').trim();
474
+ if (!/^\d+(\.\d+)?$/.test(raw))
475
+ throw new Error(`AGENTWALLET_MAX_FEE_NATIVE must be a decimal number, got "${raw}".`);
476
+ const [whole, frac = ''] = raw.split('.');
477
+ return BigInt(whole + frac.padEnd(18, '0').slice(0, 18));
478
+ }
479
+ export async function localSend(chainId, to, valueWei, data, fees) {
439
480
  assertWithinNativeCap(valueWei);
440
481
  if (data && data !== '0x') {
441
482
  if (!/^0x([0-9a-fA-F]{2})*$/.test(data))
@@ -443,20 +484,73 @@ export async function localSend(chainId, to, valueWei, data) {
443
484
  await assertWithinTokenCap(chainId, to, data, valueWei);
444
485
  }
445
486
  await assertRpcServesChain(chainId);
446
- const { wallet } = clients(chainId);
447
- const hash = await wallet.sendTransaction({
448
- to,
449
- value: valueWei,
450
- ...(data ? { data } : {}),
487
+ const { pub, wallet } = clients(chainId);
488
+ const account = getLocalAccount();
489
+ const base = { account, to, value: valueWei, ...(data ? { data } : {}) };
490
+ // Gas: the caller's figure when given (it used to be read and dropped,
491
+ // round 4), otherwise the node's estimate.
492
+ const gas = fees?.gas ? parseUint(fees.gas, 'gas_limit') : await pub.estimateGas(base);
493
+ // Fees: the caller's figures when given, otherwise the node's; either way the
494
+ // worst case gas x maxFeePerGas must fit the fee cap before anything is signed.
495
+ let maxFeePerGas;
496
+ let maxPriorityFeePerGas;
497
+ let gasPrice;
498
+ if (fees?.maxFeePerGas) {
499
+ maxFeePerGas = parseUint(fees.maxFeePerGas, 'max_fee');
500
+ maxPriorityFeePerGas = fees.maxPriorityFeePerGas ? parseUint(fees.maxPriorityFeePerGas, 'priority_fee') : maxFeePerGas;
501
+ }
502
+ else {
503
+ try {
504
+ const est = await pub.estimateFeesPerGas();
505
+ maxFeePerGas = est.maxFeePerGas;
506
+ maxPriorityFeePerGas = fees?.maxPriorityFeePerGas ? parseUint(fees.maxPriorityFeePerGas, 'priority_fee') : est.maxPriorityFeePerGas;
507
+ }
508
+ catch {
509
+ gasPrice = (await pub.estimateFeesPerGas({ type: 'legacy' })).gasPrice; // a chain without EIP-1559
510
+ }
511
+ }
512
+ if (maxFeePerGas !== undefined && maxPriorityFeePerGas !== undefined && maxPriorityFeePerGas > maxFeePerGas)
513
+ maxPriorityFeePerGas = maxFeePerGas;
514
+ const perGas = maxFeePerGas ?? gasPrice ?? 0n;
515
+ const worst = gas * perGas;
516
+ const feeCap = feeCapWei();
517
+ if (worst > feeCap) {
518
+ throw new Error(`Blocked by local guard: the transaction could burn up to ${formatUnits(worst, 18)} native in fees (gas ${gas} x ${formatUnits(perGas, 9)} gwei), ` +
519
+ `above AGENTWALLET_MAX_FEE_NATIVE (${formatUnits(feeCap, 18)}). A node answering an absurd fee or gas estimate is the usual cause; ` +
520
+ `pass gas_limit / max_fee / priority_fee explicitly, or the operator can raise the cap.`);
521
+ }
522
+ // Sign here, hash here, then hand the bytes to the node: the hash reported is
523
+ // the one computed from the signed transaction, never the node's answer.
524
+ const address = getLocalAddress();
525
+ const nonce = await account.nonceManager.consume({ address, chainId, client: pub });
526
+ const request = await wallet.prepareTransactionRequest({
527
+ ...base, gas, nonce, chainId,
528
+ ...(gasPrice !== undefined ? { type: 'legacy', gasPrice } : { type: 'eip1559', maxFeePerGas: maxFeePerGas, maxPriorityFeePerGas: maxPriorityFeePerGas }),
451
529
  });
530
+ const signed = await wallet.signTransaction(request);
531
+ const localHash = keccak256(signed);
532
+ let reported;
533
+ try {
534
+ reported = await pub.sendRawTransaction({ serializedTransaction: signed });
535
+ }
536
+ catch (e) {
537
+ account.nonceManager.reset({ address, chainId });
538
+ throw new Error(`Broadcast of transaction ${localHash} (nonce ${nonce}) failed: ${e.message.split('\n')[0]}. The transaction is signed and MAY be live if the node forwarded it before answering; check the hash on an explorer before retrying.`);
539
+ }
540
+ if (reported.toLowerCase() !== localHash.toLowerCase()) {
541
+ throw new Error(`Transaction ${localHash} (nonce ${nonce}) was BROADCAST, but the node answered with a different hash (${String(reported).slice(0, 80)}), so it cannot be trusted about its fate. Verify ${localHash} on an explorer before retrying.`);
542
+ }
452
543
  return {
453
- tx_hash: hash,
454
- from: getLocalAddress(),
544
+ tx_hash: localHash,
545
+ from: address,
455
546
  to,
456
547
  value: valueWei.toString(),
457
548
  chain_id: chainId,
458
549
  mode: 'local',
459
550
  signed_locally: true,
551
+ gas: gas.toString(),
552
+ max_fee_per_gas: (maxFeePerGas ?? gasPrice ?? 0n).toString(),
553
+ worst_case_fee: formatUnits(worst, 18),
460
554
  };
461
555
  }
462
556
  /** ERC-20 transfer, encoded locally. */
@@ -1,19 +1,3 @@
1
- /**
2
- * Pure helpers for x402 auto-pay amount/token derivation.
3
- *
4
- * Isolated from the server bootstrap in index.ts so the funds-sensitive amount
5
- * math can be unit-tested without starting the MCP server.
6
- *
7
- * SECURITY INVARIANT: token decimals must NEVER come from the 402 response.
8
- * A resource server controls `asset`, `payTo`, `maxAmountRequired` AND
9
- * `requiredDecimals`. If the cap is scaled using server-declared decimals, the
10
- * server can inflate the cap arbitrarily: declaring 18 decimals for 6-decimal
11
- * USDC turns a "1 USDC" cap into 10^18 base units, so a request for
12
- * 1,000,000,000,000 units (1,000,000 USDC) passes a 1 USDC cap. Reported by
13
- * ARC Security Research, 2026-08-02, against 1.10.1. Decimals are now resolved
14
- * from a trusted source and the declared value is only ever used to detect a
15
- * mismatch and refuse.
16
- */
17
1
  export interface X402AcceptLike {
18
2
  maxAmountRequired: string;
19
3
  requiredDecimals?: number;
@@ -14,6 +14,7 @@
14
14
  * from a trusted source and the declared value is only ever used to detect a
15
15
  * mismatch and refuse.
16
16
  */
17
+ import { wrappedNativeAddress } from './wrapped-native.js';
17
18
  /**
18
19
  * Decimals for assets that x402 endpoints actually settle in, keyed by
19
20
  * chain id then lowercased contract address (or SPL mint on Solana).
@@ -283,6 +284,11 @@ export function lookupTrustedDecimals(chainId, token) {
283
284
  const pinned = pinnedDecimals(chainId, token);
284
285
  if (pinned !== null)
285
286
  return pinned;
287
+ // Every chain's wrapped native coin is 18 decimals (WETH9 clones; the local
288
+ // guard asserts it). The registry carried WETH for Base only, so mainnet WETH
289
+ // was priced by the RPC, which could say 36 (2026-10-04 round 4).
290
+ if (wrappedNativeAddress(chainId)?.toLowerCase() === token.toLowerCase())
291
+ return 18;
286
292
  const spl = TRUSTED_SPL_DECIMALS[token];
287
293
  if (typeof spl === 'number')
288
294
  return spl;
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.11",
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 && node test/audit-11311.test.mjs",
20
21
  "prepublishOnly": "npm run build"
21
22
  },
22
23
  "keywords": [