agentwallet-mcp 1.12.6 → 1.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/build/index.js CHANGED
@@ -12,8 +12,8 @@
12
12
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
13
13
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
14
14
  import { z } from 'zod';
15
- import { assertPublicUrl, safeFetch } from './ssrf-guard.js';
16
- import { deriveX402Payment, isWithinCap, lookupTrustedDecimals, assertDeclaredDecimals, nativeDecimals, SOLANA_CHAIN_IDS, } from './x402-payment.js';
15
+ import { assertPublicUrl, safeFetch, FINAL_URL_HEADER } from './ssrf-guard.js';
16
+ import { deriveX402Payment, isWithinCap, lookupTrustedDecimals, assertDeclaredDecimals, nativeDecimals, SOLANA_CHAIN_IDS, assetLabel, autopayAssetAllowed, autopayEnvCap, effectiveAutopayCap, maxAuthWindowSeconds, } from './x402-payment.js';
17
17
  import { isLocalMode, getLocalAddress, localWalletRecord, localSend, localNativeBalance, localTokenBalance, localSignMessage, resolveRpcUrl, } from './local-wallet.js';
18
18
  import { localSignAuthorization, localEthCall, localGetCode, localSignPermit2Upto } from './local-wallet.js';
19
19
  import { WRAPPED_NATIVE } from './wrapped-native.js';
@@ -31,6 +31,11 @@ const API_USER = process.env.AGENTWALLET_USER || '';
31
31
  const API_PASS = process.env.AGENTWALLET_PASS || ''; // WordPress application password
32
32
  const X402_WALLET_ID = process.env.AGENTWALLET_WALLET_ID || ''; // Wallet ID for x402 auto-pay
33
33
  const DASHBOARD_URL = process.env.AGENTWALLET_DASHBOARD_URL || 'https://hifriendbot.com/wallet/';
34
+ // Basic credentials and 402 auto-pay decisions ride on this URL, so it must be
35
+ // https, or loopback for a local test server. Anything else is refused at start.
36
+ if (/^http:/i.test(API_BASE) && !/^http:\/\/(localhost|127\.0\.0\.1|\[::1\])(:\d+)?(\/|$)/i.test(API_BASE)) {
37
+ throw new Error(`AGENTWALLET_API_URL must be https (got ${API_BASE}); plain http is allowed only for localhost.`);
38
+ }
34
39
  /* ── Local-mode routing ──────────────────────────────────────────
35
40
  Translates the REST paths the tools already speak into in-process signing
36
41
  calls. Anything not listed here falls through to the hosted API, so
@@ -223,7 +228,7 @@ async function api(path, method = 'GET', body, extraHeaders) {
223
228
  const res = await fetch(url, options);
224
229
  const data = await res.json();
225
230
  // Handle 402 Payment Required — auto-pay if a wallet or a local key is configured
226
- if (res.status === 402 && (X402_WALLET_ID || anyLocalMode()) && !extraHeaders?.['X-PAYMENT']) {
231
+ if (res.status === 402 && (X402_WALLET_ID || anyLocalMode()) && !extraHeaders?.['X-PAYMENT'] && !extraHeaders?.['X-AGW-SKIP-X402']) {
227
232
  return handleX402Payment(data, path, method, body);
228
233
  }
229
234
  if (!res.ok) {
@@ -288,10 +293,15 @@ async function handleX402Payment(x402Data, originalPath, originalMethod, origina
288
293
  throw new Error('402 Payment Required but no payment options available.');
289
294
  }
290
295
  const accept = accepts[0]; // Use first option
291
- const payTo = accept.payTo;
296
+ const payTo = String(accept.payTo || '');
292
297
  // Determine chain_id from network string (CAIP-2, plain name, or raw ID)
293
298
  const network = accept.network || '';
294
- const chainId = resolveChainId(network) ?? 8453;
299
+ const chainId = resolveChainId(network);
300
+ if (chainId === null)
301
+ throw new Error(`x402 auto-pay: unknown network "${network}" in the payment requirements; refusing to guess a chain.`);
302
+ if (isSolanaChain(chainId) ? !/^[1-9A-HJ-NP-Za-km-z]{32,44}$/.test(payTo) : !/^0x[0-9a-fA-F]{40}$/.test(payTo)) {
303
+ throw new Error(`x402 auto-pay: payTo "${payTo}" is not a valid address for chain ${chainId}.`);
304
+ }
295
305
  // x402 maxAmountRequired is ALREADY in base/atomic units (e.g. "10000" =
296
306
  // 0.01 USDC at 6 decimals) and must be used directly. The previous code ran
297
307
  // it through parseUnits(), which re-scaled it by 10**decimals and overpaid by
@@ -299,8 +309,11 @@ async function handleX402Payment(x402Data, originalPath, originalMethod, origina
299
309
  // the pay_x402 tool) and enforces a hard ceiling so a malformed or malicious
300
310
  // 402 response cannot drain the wallet. AGENTWALLET_MAX_AUTOPAY is in
301
311
  // human-readable units of the asset; default 1.
302
- const maxAutopay = process.env.AGENTWALLET_MAX_AUTOPAY || '1';
312
+ const maxAutopay = autopayEnvCap();
303
313
  const assetForDecimals = accept.asset || accept.extra?.token || '';
314
+ const policy = autopayAssetAllowed(chainId, assetForDecimals);
315
+ if (!policy.allowed)
316
+ throw new Error(policy.reason);
304
317
  const trustedDecimals = assetForDecimals
305
318
  ? await resolveTrustedDecimals(chainId, assetForDecimals)
306
319
  : nativeDecimals(chainId); // native asset: 18 on EVM (wei), 9 on Solana (lamports)
@@ -370,6 +383,66 @@ function isValidAddress(address) {
370
383
  return true;
371
384
  return false;
372
385
  }
386
+ // ─── Guard helpers ──────────────────────────────────────────────
387
+ /** Request headers the caller may not set on pay_x402: Host would rename the TLS peer, the rest belong to the client. */
388
+ const FORBIDDEN_REQUEST_HEADERS = new Set(['host', 'content-length', 'transfer-encoding', 'connection', 'upgrade', 'expect', 'te', 'trailer', 'keep-alive']);
389
+ /** Authorizations signed in this process, keyed by endpoint and requirement, kept until they expire. */
390
+ const signedPayments = new Map();
391
+ function looksLikeTxHash(v) {
392
+ return typeof v === 'string' && (/^0x[0-9a-fA-F]{64}$/.test(v) || /^[1-9A-HJ-NP-Za-km-z]{86,88}$/.test(v));
393
+ }
394
+ function assertPayTo(chainId, payTo) {
395
+ const p = String(payTo ?? '');
396
+ const ok = isSolanaChain(chainId) ? /^[1-9A-HJ-NP-Za-km-z]{32,44}$/.test(p) : /^0x[0-9a-fA-F]{40}$/.test(p);
397
+ if (!ok)
398
+ throw new Error(`x402: payTo "${p}" is not a valid address for chain ${chainId}.`);
399
+ }
400
+ /** A URL with credentials in its path or query (Alchemy/Infura style keys) reduced to its origin for display. */
401
+ function redactUrl(u) {
402
+ try {
403
+ const p = new URL(u);
404
+ return p.pathname !== '/' || p.search ? `${p.origin}/…` : p.origin;
405
+ }
406
+ catch {
407
+ return u;
408
+ }
409
+ }
410
+ /**
411
+ * Decimals to scale a human amount by. Always the token's own value (registry,
412
+ * then decimals() on chain); a caller-supplied value must agree or the call
413
+ * is refused, because "18" for USDC scales the amount by a trillion.
414
+ */
415
+ async function verifiedDecimals(chainId, token, given) {
416
+ let trusted = null;
417
+ if (isSolanaChain(chainId)) {
418
+ trusted = lookupTrustedDecimals(chainId, token);
419
+ if (trusted === null) {
420
+ if (given === undefined)
421
+ throw new Error(`Decimals for mint ${token} are not in the registry; pass decimals explicitly.`);
422
+ return given; // the token program verifies it on-chain (TransferChecked)
423
+ }
424
+ }
425
+ else {
426
+ trusted = await resolveTrustedDecimals(chainId, token);
427
+ }
428
+ if (given !== undefined && given !== trusted) {
429
+ throw new Error(`decimals ${given} was passed but ${token} has ${trusted} decimals; refusing to scale the amount by the wrong factor.`);
430
+ }
431
+ return trusted;
432
+ }
433
+ /** Cap variables are read at send time; a typo must fail at startup, not after funding. */
434
+ function validateGuardEnv() {
435
+ for (const name of ['AGENTWALLET_MAX_TX_NATIVE', 'AGENTWALLET_MAX_TX_TOKEN', 'AGENTWALLET_MAX_TX_SOL', 'AGENTWALLET_MAX_AUTOPAY']) {
436
+ const v = (process.env[name] || '').trim();
437
+ if (v && !/^\d+(\.\d+)?$/.test(v))
438
+ throw new Error(`${name} must be a decimal number, got "${v}".`);
439
+ }
440
+ for (const e of (process.env.AGENTWALLET_AUTOPAY_ASSETS || '').split(',').map(s => s.trim()).filter(Boolean)) {
441
+ if (e !== 'native' && !/^0x[0-9a-fA-F]{40}$/.test(e) && !/^[1-9A-HJ-NP-Za-km-z]{32,44}$/.test(e)) {
442
+ throw new Error(`AGENTWALLET_AUTOPAY_ASSETS entry "${e}" is not "native", an EVM address or an SPL mint.`);
443
+ }
444
+ }
445
+ }
373
446
  // ─── EVM Helpers ──────────────────────────────────────────────────
374
447
  /**
375
448
  * Convert a human-readable amount (e.g. "0.1") to raw units string given decimals.
@@ -380,8 +453,11 @@ function parseUnits(amount, decimals) {
380
453
  throw new Error(`Invalid amount "${amount}". Must be a positive number (e.g. "0.1" or "100").`);
381
454
  }
382
455
  const [whole, frac = ''] = amount.split('.');
383
- const fracPadded = frac.slice(0, decimals).padEnd(decimals, '0');
384
- const raw = BigInt(whole + fracPadded);
456
+ // More fractional digits than the token has cannot be sent; truncating would
457
+ // move a different amount than the one echoed back to the caller.
458
+ if (frac.length > decimals)
459
+ throw new Error(`Amount "${amount}" has ${frac.length} decimal places but the token has ${decimals}.`);
460
+ const raw = BigInt(whole + frac.padEnd(decimals, '0'));
385
461
  return raw.toString();
386
462
  }
387
463
  /**
@@ -399,14 +475,20 @@ function formatUnits(raw, decimals) {
399
475
  * Pad an address to 32 bytes (64 hex chars) for ABI encoding.
400
476
  */
401
477
  function padAddress(address) {
478
+ // An over-long "address" would shift every later calldata word (the amount
479
+ // among them) and the EVM would decode whatever landed in the first 32 bytes.
480
+ if (!/^0x[0-9a-fA-F]{40}$/.test(address))
481
+ throw new Error(`Not an EVM address: "${address}".`);
402
482
  return address.slice(2).toLowerCase().padStart(64, '0');
403
483
  }
404
484
  /**
405
485
  * Encode a uint256 as 32 bytes hex (64 chars).
406
486
  */
407
487
  function encodeUint256(value) {
408
- const hex = BigInt(value).toString(16);
409
- return hex.padStart(64, '0');
488
+ const v = BigInt(value);
489
+ if (v < 0n || v > (1n << 256n) - 1n)
490
+ throw new Error(`Amount ${value} does not fit in uint256.`);
491
+ return v.toString(16).padStart(64, '0');
410
492
  }
411
493
  // ─── Server ──────────────────────────────────────────────────────
412
494
  // ─── x402 Network Mapping ──────────────────────────────────────
@@ -468,9 +550,11 @@ function resolveChainId(network) {
468
550
  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');
469
551
  const server = new McpServer({
470
552
  name: 'agentwallet',
471
- version: '1.12.6',
553
+ version: '1.13.0',
472
554
  }, {
473
- instructions: `AgentWallet gives AI agents their own blockchain wallets. Private keys are encrypted server-side and never exposed — agents sign and broadcast transactions without ever touching raw keys.
555
+ instructions: `AgentWallet gives AI agents their own blockchain wallets. ${anyLocalMode()
556
+ ? '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.'
557
+ : 'Private keys are encrypted server-side and never exposed: agents sign and broadcast transactions without ever touching raw keys.'}
474
558
 
475
559
  ## Getting Started
476
560
  1. Call list_wallets to see existing wallets, or create_wallet to make a new one.
@@ -479,7 +563,7 @@ const server = new McpServer({
479
563
 
480
564
  ## Supported Chains
481
565
  - **EVM chains:** Ethereum (1), Base (8453), Arbitrum (42161), Optimism (10), Polygon (137), Avalanche (43114), BSC (56), Zora (7777777), PulseChain (369)
482
- - **Solana:** Mainnet (900), Devnet (901)
566
+ - **Solana:** Mainnet (900), Devnet (901), Testnet (902)
483
567
  - Call get_chains for the full list with native tokens and stablecoin contract addresses.
484
568
 
485
569
  ## EVM vs Solana
@@ -524,10 +608,10 @@ Create paywalls to charge other agents for accessing your resources:
524
608
  4. get_x402_revenue for aggregate stats across all paywalls.
525
609
 
526
610
  ## Wallet Security
527
- - Use pause_wallet to immediately freeze a wallet if compromised. No transactions can be signed while paused.
611
+ - ${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.'}
528
612
  - Use unpause_wallet to resume operations.
529
613
  - Use delete_wallet to permanently disable a wallet.
530
- - Wallets have server-enforced spending limits.
614
+ - ${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.'}
531
615
 
532
616
  ## Tool Selection Guide
533
617
  | Goal | Tool |
@@ -548,7 +632,7 @@ Create paywalls to charge other agents for accessing your resources:
548
632
  });
549
633
  // ─── Tool: create_wallet ─────────────────────────────────────────
550
634
  server.tool('create_wallet', 'Create a new EVM or Solana wallet. Returns the wallet ID and address. ' +
551
- 'Private key is encrypted server-side and never exposed.', {
635
+ (anyLocalMode() ? 'In local mode this reports the key already configured; no wallet is created remotely.' : 'Private key is encrypted server-side and never exposed.'), {
552
636
  label: z.string().default('').describe('Friendly name for the wallet'),
553
637
  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)'),
554
638
  }, async ({ label, chain_id }) => {
@@ -586,13 +670,13 @@ server.tool('sign_transaction', 'Sign a transaction with a wallet\'s private key
586
670
  wallet_id: z.number().int().describe('Wallet ID'),
587
671
  to: AddressSchema.describe('Destination address (0x-prefixed for EVM, Base58 for Solana)'),
588
672
  chain_id: z.number().int().optional().describe('Chain ID (defaults to wallet\'s default)'),
589
- value: z.string().default('0').describe('Value in wei/lamports (decimal string)'),
590
- data: z.string().default('').describe('Hex-encoded calldata (0x-prefixed) for EVM contract calls'),
673
+ value: z.string().regex(/^\d+$/, 'decimal integer string').default('0').describe('Value in wei/lamports (decimal integer string; no hex, no decimals)'),
674
+ data: z.string().regex(/^(0x([0-9a-fA-F]{2})*)?$/, '0x-prefixed even-length hex').default('').describe('Hex-encoded calldata (0x-prefixed) for EVM contract calls'),
591
675
  gas_limit: z.string().optional().describe('Gas limit — EVM only (auto-estimated if omitted)'),
592
676
  max_fee: z.string().optional().describe('Max fee per gas in wei — EVM only (auto if omitted)'),
593
677
  priority_fee: z.string().optional().describe('Max priority fee per gas in wei — EVM only (auto if omitted)'),
594
678
  token_mint: z.string().optional().describe('SPL token mint address — Solana only (for SPL token transfers)'),
595
- token_decimals: z.number().int().optional().describe('SPL token decimals — Solana only (6 for USDC)'),
679
+ token_decimals: z.number().int().min(0).max(36).optional().describe('SPL token decimals, Solana only (6 for USDC)'),
596
680
  }, async ({ wallet_id, to, chain_id, value, data, gas_limit, max_fee, priority_fee, token_mint, token_decimals }) => {
597
681
  // Validate address format
598
682
  if (!isValidAddress(to)) {
@@ -601,6 +685,10 @@ server.tool('sign_transaction', 'Sign a transaction with a wallet\'s private key
601
685
  const body = { to, value };
602
686
  if (chain_id)
603
687
  body.chain_id = chain_id;
688
+ // Without a chain id the SPL fields were silently dropped and the server
689
+ // fell back to a native transfer of the same number, to the same address.
690
+ if (token_mint && !chain_id)
691
+ throw new Error('token_mint needs an explicit Solana chain_id (900 mainnet, 901 devnet).');
604
692
  if (isSolanaChain(chain_id ?? 0)) {
605
693
  // Solana-specific params
606
694
  if (token_mint)
@@ -624,17 +712,17 @@ server.tool('sign_transaction', 'Sign a transaction with a wallet\'s private key
624
712
  // ─── Tool: send_transaction ──────────────────────────────────────
625
713
  server.tool('send_transaction', 'Sign and broadcast a transaction. ' +
626
714
  'Returns the transaction hash (EVM) or signature (Solana) on success. ' +
627
- 'The transaction is signed server-side and broadcast via RPC.', {
715
+ (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.'), {
628
716
  wallet_id: z.number().int().describe('Wallet ID'),
629
717
  to: AddressSchema.describe('Destination address (0x-prefixed for EVM, Base58 for Solana)'),
630
718
  chain_id: z.number().int().optional().describe('Chain ID (defaults to wallet\'s default)'),
631
- value: z.string().default('0').describe('Value in wei/lamports (decimal string)'),
632
- data: z.string().default('').describe('Hex-encoded calldata (0x-prefixed) for EVM contract calls'),
719
+ value: z.string().regex(/^\d+$/, 'decimal integer string').default('0').describe('Value in wei/lamports (decimal integer string; no hex, no decimals)'),
720
+ data: z.string().regex(/^(0x([0-9a-fA-F]{2})*)?$/, '0x-prefixed even-length hex').default('').describe('Hex-encoded calldata (0x-prefixed) for EVM contract calls'),
633
721
  gas_limit: z.string().optional().describe('Gas limit — EVM only (auto-estimated if omitted)'),
634
722
  max_fee: z.string().optional().describe('Max fee per gas in wei — EVM only (auto if omitted)'),
635
723
  priority_fee: z.string().optional().describe('Max priority fee per gas in wei — EVM only (auto if omitted)'),
636
724
  token_mint: z.string().optional().describe('SPL token mint address — Solana only (for SPL token transfers)'),
637
- token_decimals: z.number().int().optional().describe('SPL token decimals — Solana only (6 for USDC)'),
725
+ token_decimals: z.number().int().min(0).max(36).optional().describe('SPL token decimals, Solana only (6 for USDC)'),
638
726
  }, async ({ wallet_id, to, chain_id, value, data, gas_limit, max_fee, priority_fee, token_mint, token_decimals }) => {
639
727
  // Validate address format
640
728
  if (!isValidAddress(to)) {
@@ -643,6 +731,10 @@ server.tool('send_transaction', 'Sign and broadcast a transaction. ' +
643
731
  const body = { to, value };
644
732
  if (chain_id)
645
733
  body.chain_id = chain_id;
734
+ // Without a chain id the SPL fields were silently dropped and the server
735
+ // fell back to a native transfer of the same number, to the same address.
736
+ if (token_mint && !chain_id)
737
+ throw new Error('token_mint needs an explicit Solana chain_id (900 mainnet, 901 devnet).');
646
738
  if (isSolanaChain(chain_id ?? 0)) {
647
739
  // Solana-specific params
648
740
  if (token_mint)
@@ -701,7 +793,7 @@ server.tool('get_token_balance', 'Get the ERC-20 or SPL token balance for a wall
701
793
  wallet_id: z.number().int().describe('Wallet ID to check'),
702
794
  token: z.string().describe('Token address (0x-prefixed ERC-20 contract for EVM, Base58 mint for Solana)'),
703
795
  chain_id: z.number().int().describe('Chain ID to check on'),
704
- decimals: z.number().int().default(18).describe('Token decimals (6 for USDC, 18 for most tokens)'),
796
+ decimals: z.number().int().min(0).max(36).optional().describe('Token decimals. Taken from the response or the token contract when omitted'),
705
797
  }, async ({ wallet_id, token, chain_id, decimals }) => {
706
798
  // Validate token address format
707
799
  if (!isValidAddress(token)) {
@@ -714,15 +806,28 @@ server.tool('get_token_balance', 'Get the ERC-20 or SPL token balance for a wall
714
806
  return jsonResponse({
715
807
  ...data,
716
808
  balance: data.balance_formatted,
717
- decimals: data.decimals ?? decimals,
809
+ decimals: data.decimals ?? decimals ?? null,
718
810
  });
719
811
  }
720
- // EVM: format from raw
721
- const balanceFormatted = formatUnits(data.balance_raw || '0', decimals);
812
+ // The response's own decimals (local mode reads them from the contract) win
813
+ // over the caller's guess; a guess of 18 used to reformat a 1 USDC balance
814
+ // as 0.000000000001. When nothing supplies them, resolve them ourselves.
815
+ let trusted = typeof data.decimals === 'number' ? data.decimals : null;
816
+ if (trusted === null) {
817
+ if (typeof decimals === 'number')
818
+ trusted = decimals;
819
+ else if (!isSolanaChain(chain_id))
820
+ trusted = await resolveTrustedDecimals(chain_id, token);
821
+ }
822
+ if (trusted === null)
823
+ throw new Error('Could not determine the token decimals; pass decimals explicitly.');
824
+ const warning = typeof decimals === 'number' && typeof data.decimals === 'number' && decimals !== data.decimals
825
+ ? `decimals ${decimals} was passed but the token reports ${data.decimals}; the token's value was used.` : undefined;
722
826
  return jsonResponse({
723
827
  ...data,
724
- balance: balanceFormatted,
725
- decimals,
828
+ balance: data.balance ?? formatUnits(data.balance_raw || '0', trusted),
829
+ decimals: trusted,
830
+ ...(warning ? { warning } : {}),
726
831
  });
727
832
  });
728
833
  // ─── Tool: transfer_token ───────────────────────────────────────
@@ -734,8 +839,8 @@ server.tool('transfer_token', 'Send ERC-20 tokens (EVM) or SPL tokens (Solana) t
734
839
  to: AddressSchema.describe('Recipient address (0x-prefixed for EVM, Base58 for Solana)'),
735
840
  amount: z.string().describe('Amount in human-readable format (e.g. "100" for 100 USDC)'),
736
841
  chain_id: z.number().int().describe('Chain ID'),
737
- decimals: z.number().int().default(18).describe('Token decimals (6 for USDC, 18 for most tokens)'),
738
- }, async ({ wallet_id, token, to, amount, chain_id, decimals }) => {
842
+ decimals: z.number().int().min(0).max(36).optional().describe('Token decimals. Resolved from the registry or the token contract when omitted; if given, it must match'),
843
+ }, async ({ wallet_id, token, to, amount, chain_id, decimals: givenDecimals }) => {
739
844
  // Validate addresses
740
845
  if (!isValidAddress(token)) {
741
846
  throw new Error(`Invalid token address "${token}". Use 0x-prefixed hex for EVM or Base58 for Solana.`);
@@ -743,6 +848,7 @@ server.tool('transfer_token', 'Send ERC-20 tokens (EVM) or SPL tokens (Solana) t
743
848
  if (!isValidAddress(to)) {
744
849
  throw new Error(`Invalid recipient address "${to}". Use 0x-prefixed hex for EVM or Base58 for Solana.`);
745
850
  }
851
+ const decimals = await verifiedDecimals(chain_id, token, givenDecimals);
746
852
  const rawAmount = parseUnits(amount, decimals);
747
853
  let result;
748
854
  if (isSolanaChain(chain_id)) {
@@ -796,8 +902,8 @@ server.tool('approve_token', 'Approve a spender contract to transfer ERC-20 toke
796
902
  spender: z.string().regex(/^0x[a-fA-F0-9]{40}$/).describe('Contract address to approve as spender'),
797
903
  amount: z.string().describe('Amount to approve in human-readable format (e.g. "1000"), or "max" for unlimited'),
798
904
  chain_id: z.number().int().describe('Chain ID'),
799
- decimals: z.number().int().default(18).describe('Token decimals (6 for USDC, 18 for most tokens)'),
800
- }, async ({ wallet_id, token, spender, amount, chain_id, decimals }) => {
905
+ decimals: z.number().int().min(0).max(36).optional().describe('Token decimals. Resolved from the registry or the token contract when omitted; if given, it must match'),
906
+ }, async ({ wallet_id, token, spender, amount, chain_id, decimals: givenDecimals }) => {
801
907
  if (isSolanaChain(chain_id)) {
802
908
  throw new Error('approve_token is not supported on Solana. Solana SPL tokens do not use ERC-20 style approvals.');
803
909
  }
@@ -807,13 +913,13 @@ server.tool('approve_token', 'Approve a spender contract to transfer ERC-20 toke
807
913
  rawAmount = (BigInt(2) ** BigInt(256) - BigInt(1)).toString();
808
914
  }
809
915
  else {
810
- rawAmount = parseUnits(amount, decimals);
916
+ rawAmount = parseUnits(amount, await verifiedDecimals(chain_id, token, givenDecimals));
811
917
  }
812
918
  const calldata = '0x095ea7b3' + padAddress(spender) + encodeUint256(rawAmount);
813
919
  // Asset risk rides along with the approval (issue #13). It never blocks; it is the
814
920
  // caller's call. Skipped for Permit2, which only moves what a signature allows.
815
921
  let risk = null;
816
- if (spender.toLowerCase() !== PERMIT2_ADDRESS.toLowerCase()) {
922
+ if (spender.toLowerCase() !== PERMIT2_ADDRESS.toLowerCase() && process.env.AGENTWALLET_TOKEN_RISK !== '0') {
817
923
  try {
818
924
  risk = await assessTokenRisk(chain_id, token, (to, data) => ethCallHex(chain_id, to, data));
819
925
  }
@@ -843,8 +949,8 @@ server.tool('get_allowance', 'Check how many ERC-20 tokens a spender is approved
843
949
  token: z.string().regex(/^0x[a-fA-F0-9]{40}$/).describe('ERC-20 token contract address'),
844
950
  spender: z.string().regex(/^0x[a-fA-F0-9]{40}$/).describe('Spender contract address to check'),
845
951
  chain_id: z.number().int().describe('Chain ID'),
846
- decimals: z.number().int().default(18).describe('Token decimals (6 for USDC, 18 for most tokens)'),
847
- }, async ({ wallet_id, token, spender, chain_id, decimals }) => {
952
+ decimals: z.number().int().min(0).max(36).optional().describe('Token decimals. Resolved from the registry or the token contract when omitted; if given, it must match'),
953
+ }, async ({ wallet_id, token, spender, chain_id, decimals: givenDecimals }) => {
848
954
  if (isSolanaChain(chain_id)) {
849
955
  throw new Error('get_allowance is not supported on Solana. Solana SPL tokens do not use ERC-20 style allowances.');
850
956
  }
@@ -853,9 +959,13 @@ server.tool('get_allowance', 'Check how many ERC-20 tokens a spender is approved
853
959
  // allowance(address owner, address spender) — selector: 0xdd62ed3e
854
960
  const calldata = '0xdd62ed3e' + padAddress(wallet.address) + padAddress(spender);
855
961
  const result = await api('/eth-call', 'POST', { chain_id, to: token, data: calldata });
856
- // Parse uint256 result
857
- const rawHex = result.result.replace('0x', '');
962
+ // Parse exactly one uint256 word: a hostile token returning more bytes must
963
+ // not turn into a 512-bit "allowance".
964
+ const rawHex = String(result?.result || '').replace(/^0x/, '').slice(0, 64);
965
+ if (rawHex && !/^[0-9a-fA-F]+$/.test(rawHex))
966
+ throw new Error('allowance() returned non-hex data.');
858
967
  const raw = BigInt('0x' + (rawHex || '0')).toString();
968
+ const decimals = await verifiedDecimals(chain_id, token, givenDecimals);
859
969
  const maxUint256 = (BigInt(2) ** BigInt(256) - BigInt(1)).toString();
860
970
  return jsonResponse({
861
971
  token,
@@ -949,7 +1059,9 @@ function decodeAbiString(hex) {
949
1059
  return '';
950
1060
  // Convert hex to UTF-8
951
1061
  const bytes = new Uint8Array(pairs.map(b => parseInt(b, 16)));
952
- return new TextDecoder().decode(bytes);
1062
+ // Attacker-controlled text: keep it short and printable so a token cannot
1063
+ // smuggle kilobytes of instructions or terminal escapes into the agent's context.
1064
+ return new TextDecoder().decode(bytes).replace(/[\u0000-\u001f\u007f-\u009f]/g, '').slice(0, 64);
953
1065
  }
954
1066
  catch {
955
1067
  return '';
@@ -971,8 +1083,9 @@ server.tool('get_token_info', 'Get the name, symbol, and decimals of any ERC-20
971
1083
  ]);
972
1084
  const name = decodeAbiString(nameResult.result);
973
1085
  const symbol = decodeAbiString(symbolResult.result);
974
- const decimalsHex = decimalsResult.result.replace('0x', '');
975
- const decimals = decimalsHex ? parseInt(decimalsHex, 16) : 0;
1086
+ const decimalsHex = String(decimalsResult?.result || '').replace(/^0x/, '').slice(0, 64);
1087
+ const parsedDecimals = /^[0-9a-fA-F]+$/.test(decimalsHex) ? Number(BigInt('0x' + decimalsHex)) : NaN;
1088
+ const decimals = Number.isInteger(parsedDecimals) && parsedDecimals >= 0 && parsedDecimals <= 255 ? parsedDecimals : null;
976
1089
  return jsonResponse({
977
1090
  token,
978
1091
  chain_id,
@@ -1101,16 +1214,32 @@ server.tool('pay_x402', 'Handle an x402 payment flow. Fetches a URL, and if the
1101
1214
  request_approval: z.boolean().optional().describe('Hosted wallets only. When the payment exceeds the cap, email the wallet owner an approve/deny link ' +
1102
1215
  'instead of refusing, and return an approval_id to retry with. Default true unless AGENTWALLET_APPROVALS=0.'),
1103
1216
  approval_id: z.string().optional().describe('An approval id from an earlier over-cap attempt. Once the owner has approved it, pass it here to make that one payment.'),
1104
- }, async ({ url, wallet_id, method, headers: headersJson, body: reqBody, max_payment, prefer_chain, request_approval, approval_id }) => {
1217
+ 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 ' +
1218
+ 'within the validity window re-sends the earlier signature (same nonce, so it cannot settle twice) instead of paying again.'),
1219
+ }, async ({ url, wallet_id, method, headers: headersJson, body: reqBody, max_payment, prefer_chain, request_approval, approval_id, fresh_authorization }) => {
1105
1220
  // Build request headers
1106
1221
  const reqHeaders = { Accept: 'application/json' };
1107
1222
  if (headersJson) {
1223
+ let parsed;
1108
1224
  try {
1109
- Object.assign(reqHeaders, JSON.parse(headersJson));
1225
+ parsed = JSON.parse(headersJson);
1110
1226
  }
1111
1227
  catch {
1228
+ parsed = null;
1229
+ }
1230
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
1112
1231
  throw new Error('Invalid headers JSON. Must be a JSON object (e.g. {"Authorization": "Bearer ..."}).');
1113
1232
  }
1233
+ for (const [k, v] of Object.entries(parsed)) {
1234
+ const name = k.toLowerCase();
1235
+ // Host would replace the TLS server name; the rest are hop-by-hop or framing headers the client owns.
1236
+ if (FORBIDDEN_REQUEST_HEADERS.has(name) || name.startsWith('proxy-') || name.startsWith(':')) {
1237
+ throw new Error(`Header "${k}" cannot be set by the caller.`);
1238
+ }
1239
+ if (typeof v !== 'string')
1240
+ throw new Error(`Header "${k}" must be a string.`);
1241
+ reqHeaders[k] = v;
1242
+ }
1114
1243
  }
1115
1244
  // Validate URL: canonicalizes IP literals (including IPv4-mapped IPv6) and
1116
1245
  // resolves DNS names, rejecting any private/internal destination. An early
@@ -1147,6 +1276,15 @@ server.tool('pay_x402', 'Handle an x402 payment flow. Fetches a URL, and if the
1147
1276
  });
1148
1277
  }
1149
1278
  // Step 2: Requirements come from the PAYMENT-REQUIRED header (v2) or the JSON body (v1).
1279
+ // A 402 that arrived through a cross-origin redirect cannot be paid here: the
1280
+ // payment header would be stripped on the same hop and the money would go
1281
+ // to whoever the redirecting server named. The agent should call the final URL.
1282
+ const servedFrom = initialRes.headers.get(FINAL_URL_HEADER) || url;
1283
+ if (new URL(servedFrom).origin !== new URL(url).origin) {
1284
+ return jsonResponse({ status: 402, payment_required: true, payment_made: false,
1285
+ error: `The endpoint redirected to another origin (${new URL(servedFrom).origin}) before asking for payment. Call pay_x402 with that URL directly if you trust it.`,
1286
+ final_url: servedFrom });
1287
+ }
1150
1288
  const initialText = await initialRes.text();
1151
1289
  let initialBody = null;
1152
1290
  try {
@@ -1190,20 +1328,33 @@ server.tool('pay_x402', 'Handle an x402 payment flow. Fetches a URL, and if the
1190
1328
  if (typeof option.requiredDecimals === 'number')
1191
1329
  assertDeclaredDecimals(option.requiredDecimals, trustedDecimals);
1192
1330
  const amount = formatUnits(rawAmount, trustedDecimals);
1193
- const tokenLabel = option.extra?.name || (tokenAddress ? 'tokens' : 'native');
1331
+ const tokenLabel = assetLabel(chainId, tokenAddress); // never the 402's own label
1194
1332
  const skip = { 'X-AGW-SKIP-X402': 'true' };
1333
+ assertPayTo(chainId, option.payTo);
1334
+ // Step 3b: Can this asset be priced at all? The cap is in dollars; native
1335
+ // ETH or an arbitrary token is refused unless the operator allowlisted it.
1336
+ const assetPolicy = autopayAssetAllowed(chainId, tokenAddress);
1337
+ if (!assetPolicy.allowed) {
1338
+ return jsonResponse({
1339
+ status: 402, payment_required: true, payment_made: false,
1340
+ required_amount: amount, token: tokenLabel, token_address: tokenAddress || null, network: option.network, chain_id: chainId, pay_to: option.payTo,
1341
+ error: assetPolicy.reason,
1342
+ });
1343
+ }
1195
1344
  // Step 4: Hard per-payment cap, ALWAYS applied. If the caller omits max_payment
1196
1345
  // we fall back to AGENTWALLET_MAX_AUTOPAY (default "1"). Above the cap a hosted
1197
1346
  // wallet can ask its owner instead of refusing: an approval is created, the
1198
1347
  // owner gets approve/deny links by email, and the agent retries with the id.
1199
1348
  // For upto the cap applies to the MAXIMUM the seller may settle.
1200
- const capSource = max_payment ? 'max_payment' : 'AGENTWALLET_MAX_AUTOPAY';
1201
- const effectiveMax = max_payment || process.env.AGENTWALLET_MAX_AUTOPAY || '1';
1349
+ // max_payment can only lower the operator's ceiling; an agent-chosen number
1350
+ // above it is ignored and reported, because the agent is what the cap bounds.
1351
+ const { cap: effectiveMax, source: capSource, clamped } = effectiveAutopayCap(max_payment);
1202
1352
  let approvalUsed = null;
1203
1353
  if (!isWithinCap(rawAmount, trustedDecimals, effectiveMax)) {
1204
1354
  const overCap = {
1205
1355
  status: 402, payment_required: true, payment_made: false,
1206
1356
  required_amount: amount, max_allowed: effectiveMax, cap_source: capSource, token: tokenLabel,
1357
+ ...(clamped ? { max_payment_ignored: `max_payment ${max_payment} exceeds the operator ceiling AGENTWALLET_MAX_AUTOPAY=${effectiveMax}; only the operator can raise it.` } : {}),
1207
1358
  network: option.network, chain_id: chainId, pay_to: option.payTo, description: option.description,
1208
1359
  };
1209
1360
  const wantApproval = request_approval ?? (process.env.AGENTWALLET_APPROVALS !== '0');
@@ -1212,8 +1363,9 @@ server.tool('pay_x402', 'Handle an x402 payment flow. Fetches a URL, and if the
1212
1363
  if (a?.status !== 'approved' || Number(a.used) === 1) {
1213
1364
  return jsonResponse({ ...overCap, approval_id, approval_status: a?.status ?? null, error: `Approval ${approval_id} is ${a?.status ?? 'unknown'}, not approved.${a?.error ? ' ' + a.error : ''}` });
1214
1365
  }
1215
- if (String(a.pay_to || '').toLowerCase() !== option.payTo.toLowerCase() || Number(a.chain_id) !== chainId || BigInt(String(a.value || '0')) < BigInt(rawAmount)) {
1216
- return jsonResponse({ ...overCap, approval_id, error: 'Approval does not cover this payment (recipient, chain or amount differ).' });
1366
+ const sameAsset = String(a.asset ?? '').toLowerCase() === tokenAddress.toLowerCase();
1367
+ if (!sameAsset || String(a.pay_to || '').toLowerCase() !== option.payTo.toLowerCase() || Number(a.chain_id) !== chainId || BigInt(String(a.value || '0')) < BigInt(rawAmount)) {
1368
+ return jsonResponse({ ...overCap, approval_id, error: 'Approval does not cover this payment (asset, recipient, chain or amount differ).' });
1217
1369
  }
1218
1370
  approvalUsed = String(approval_id);
1219
1371
  }
@@ -1255,12 +1407,25 @@ server.tool('pay_x402', 'Handle an x402 payment flow. Fetches a URL, and if the
1255
1407
  let payer = null;
1256
1408
  let permit2ApprovalTx = null;
1257
1409
  let payerDelegationInfo = null;
1258
- if (isUpto) {
1410
+ let authorizationReused = false;
1411
+ const cacheKey = [wallet_id, chainId, tokenAddress.toLowerCase(), option.payTo.toLowerCase(), rawAmount, option.scheme, new URL(url).origin + new URL(url).pathname].join('|');
1412
+ const cachedAuth = fresh_authorization ? undefined : signedPayments.get(cacheKey);
1413
+ for (const [k, v] of signedPayments)
1414
+ if (v.until <= Date.now())
1415
+ signedPayments.delete(k);
1416
+ if (cachedAuth && cachedAuth.until > Date.now()) {
1417
+ // Same endpoint, same requirement, still inside the window: re-send the
1418
+ // earlier signature. Its nonce is single-use, so a server that already
1419
+ // settled it cannot settle it again, and one that never did now can.
1420
+ ({ headerName, paymentHeader, txHash, authorization, uptoAuth, payer } = cachedAuth);
1421
+ authorizationReused = true;
1422
+ }
1423
+ else if (isUpto) {
1259
1424
  payer = await payerAddress(wallet_id);
1260
1425
  payerDelegationInfo = await payerDelegation(chainId, payer);
1261
1426
  const allowance = decodeUint(await ethCallHex(chainId, tokenAddress, permit2AllowanceCalldata(payer)));
1262
1427
  if (allowance < BigInt(rawAmount)) {
1263
- if (process.env.AGENTWALLET_PERMIT2_AUTO_APPROVE === '1') {
1428
+ if (process.env.AGENTWALLET_PERMIT2_AUTO_APPROVE === '1' && assetPolicy.via === 'stablecoin') {
1264
1429
  const r = (await api(`/wallets/${wallet_id}/send`, 'POST', { to: tokenAddress, value: '0', data: permit2ApproveCalldata(), chain_id: chainId }, skip));
1265
1430
  permit2ApprovalTx = String(r?.tx_hash || '');
1266
1431
  await new Promise(res => setTimeout(res, 4000));
@@ -1312,6 +1477,12 @@ server.tool('pay_x402', 'Handle an x402 payment flow. Fetches a URL, and if the
1312
1477
  throw new Error('x402: the payment transaction returned no hash.');
1313
1478
  paymentHeader = Buffer.from(JSON.stringify({ x402Version, scheme: option.scheme, network: option.network, payload: { txHash } })).toString('base64');
1314
1479
  }
1480
+ if (!authorizationReused) {
1481
+ const until = authorization ? Number(authorization.validBefore) * 1000
1482
+ : uptoAuth ? Number(uptoAuth.deadline) * 1000
1483
+ : Date.now() + maxAuthWindowSeconds() * 1000; // a broadcast transfer: the hash stays valid, re-sending it never pays twice
1484
+ signedPayments.set(cacheKey, { headerName, paymentHeader, txHash, authorization, uptoAuth, payer, until });
1485
+ }
1315
1486
  // Step 6: Retry with the payment header and read the settlement receipt.
1316
1487
  const retryHeaders = { ...reqHeaders, [headerName]: paymentHeader };
1317
1488
  const retryOptions = {
@@ -1335,6 +1506,11 @@ server.tool('pay_x402', 'Handle an x402 payment flow. Fetches a URL, and if the
1335
1506
  // A second 402 means the facilitator declined (unfunded payer, expired window, bad domain...). v2 servers
1336
1507
  // put the reason in a fresh PAYMENT-REQUIRED header, v1 servers in the body; surface it instead of {}.
1337
1508
  let retryError = null;
1509
+ const retryServedFrom = retryRes.headers.get(FINAL_URL_HEADER) || url;
1510
+ const retryCrossOrigin = new URL(retryServedFrom).origin !== new URL(url).origin;
1511
+ if (retryCrossOrigin) {
1512
+ retryError = `The paid request was redirected to another origin (${new URL(retryServedFrom).origin}); the payment header is never forwarded across origins, so this response did not see the payment.`;
1513
+ }
1338
1514
  if (retryRes.status === 402) {
1339
1515
  const again = parsePaymentRequired(n => retryRes.headers.get(n), retryParsed);
1340
1516
  retryError = String(again?.error || retryParsed?.error || 'Payment was not accepted (no reason given).');
@@ -1347,7 +1523,8 @@ server.tool('pay_x402', 'Handle an x402 payment flow. Fetches a URL, and if the
1347
1523
  // Paid means the endpoint accepted the payment: a settlement receipt, or a success status after the payment header.
1348
1524
  // A 4xx other than 402 (bad body, auth) means the request failed for another reason; the authorization was
1349
1525
  // sent but the facilitator normally does not settle a failed request, so it is not reported as paid.
1350
- payment_made: Boolean(settlement?.success) || (retryRes.status >= 200 && retryRes.status < 300),
1526
+ payment_made: !retryCrossOrigin && (Boolean(settlement?.success) || (retryRes.status >= 200 && retryRes.status < 300)),
1527
+ authorization_reused: authorizationReused,
1351
1528
  retry_error: retryError,
1352
1529
  payment_method: isUpto ? 'permit2-upto-authorization' : (standardExact ? 'eip3009-authorization' : 'onchain-transfer'),
1353
1530
  approval_id: approvalUsed,
@@ -1361,8 +1538,10 @@ server.tool('pay_x402', 'Handle an x402 payment flow. Fetches a URL, and if the
1361
1538
  pay_to: option.payTo,
1362
1539
  payer,
1363
1540
  payer_delegation: payerDelegationInfo,
1364
- tx_hash: txHash ?? settlement?.transaction ?? null,
1365
- settlement,
1541
+ // txHash is ours (we broadcast it); settlement.transaction is the server's
1542
+ // claim and is surfaced only when it is shaped like a real hash.
1543
+ tx_hash: txHash ?? (looksLikeTxHash(settlement?.transaction) ? settlement?.transaction : null) ?? null,
1544
+ settlement_reported_by_server: settlement,
1366
1545
  authorization: authorization ? { nonce: authorization.nonce, valid_before: authorization.validBefore }
1367
1546
  : (uptoAuth ? { scheme: 'upto', max_amount: amount, nonce: uptoAuth.nonce, deadline: uptoAuth.deadline, facilitator: uptoAuth.witness.facilitator } : null),
1368
1547
  description: option.description,
@@ -1374,16 +1553,23 @@ server.tool('check_approval', 'Status of a payment approval created when pay_x40
1374
1553
  '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' })));
1375
1554
  // ─── Tool: approve_permit2 ──────────────────────────────────────
1376
1555
  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. ' +
1377
- 'A normal on-chain transaction (needs gas). Approves the maximum so it never has to be repeated; Permit2 itself only moves ' +
1378
- 'what each signed authorization allows.', {
1556
+ 'A normal on-chain transaction (needs gas). By default approves the maximum, the ecosystem norm, so it never has to be repeated; ' +
1557
+ 'pass amount to grant a bounded allowance instead (required under AGENTWALLET_MAX_TX_TOKEN, which refuses a max approval). ' +
1558
+ 'Permit2 itself only moves what each signed authorization allows.', {
1379
1559
  wallet_id: z.number().int().describe('Wallet ID'),
1380
1560
  token: z.string().regex(/^0x[a-fA-F0-9]{40}$/).describe('ERC-20 token address (e.g. USDC on Base)'),
1381
1561
  chain_id: z.number().int().describe('Chain ID'),
1382
- }, async ({ wallet_id, token, chain_id }) => {
1562
+ amount: z.string().optional().describe('Allowance to grant in human units (e.g. "100"). Omit for unlimited.'),
1563
+ }, async ({ wallet_id, token, chain_id, amount }) => {
1383
1564
  if (isSolanaChain(chain_id))
1384
1565
  throw new Error('Permit2 is an EVM contract; there is nothing to approve on Solana.');
1385
- const result = (await api(`/wallets/${wallet_id}/send`, 'POST', { to: token, value: '0', data: permit2ApproveCalldata(), chain_id }));
1386
- return jsonResponse({ ...result, token, spender: PERMIT2_ADDRESS, amount: 'unlimited', note: 'Permit2 only transfers what a signed authorization allows; the allowance itself moves nothing.' });
1566
+ let data = permit2ApproveCalldata();
1567
+ if (amount !== undefined) {
1568
+ const raw = parseUnits(amount, await resolveTrustedDecimals(chain_id, token));
1569
+ data = '0x095ea7b3' + padAddress(PERMIT2_ADDRESS) + encodeUint256(raw);
1570
+ }
1571
+ const result = (await api(`/wallets/${wallet_id}/send`, 'POST', { to: token, value: '0', data, chain_id }));
1572
+ return jsonResponse({ ...result, token, spender: PERMIT2_ADDRESS, amount: amount ?? 'unlimited', note: 'Permit2 only transfers what a signed authorization allows; the allowance itself moves nothing.' });
1387
1573
  });
1388
1574
  // ─── Tool: check_token_risk ─────────────────────────────────────
1389
1575
  server.tool('check_token_risk', 'Assess an ERC-20 token before approving or swapping it: honeypot, taxes, owner powers, verified source, ' +
@@ -1447,13 +1633,16 @@ server.tool('create_paywall', 'Create an x402 paywall that charges agents/client
1447
1633
  amount: z.string().describe('Price in human-readable format (e.g. "0.01" for 0.01 USDC)'),
1448
1634
  token_type: z.enum(['erc20', 'spl', 'native']).default('erc20').describe('"erc20" for EVM stablecoins, "spl" for Solana SPL tokens, "native" for ETH/SOL/POL/etc.'),
1449
1635
  token_address: z.string().default('').describe('Token contract address (ERC-20 for EVM, SPL mint Base58 for Solana). Required if token_type is "erc20" or "spl". Use get_chains to find stablecoin addresses.'),
1450
- token_decimals: z.number().int().default(6).describe('Token decimals (6 for USDC, 18 for ETH/most tokens)'),
1636
+ token_decimals: z.number().int().min(0).max(36).optional().describe('Token decimals. Defaults to the chain native decimals for "native", else 6 (USDC); set it for any other token'),
1451
1637
  token_name: z.string().default('USDC').describe('Token display name (e.g. "USDC", "ETH")'),
1452
1638
  chain_id: z.number().int().default(8453).describe('Chain ID for payments (8453=Base, 1=Ethereum, etc.)'),
1453
1639
  resource_url: z.string().url().describe('URL of the protected resource to serve after payment verification'),
1454
1640
  resource_mime: z.string().default('application/json').describe('MIME type of the resource (e.g. "application/json", "text/plain")'),
1455
1641
  }, async ({ wallet_id, name, description, amount, token_type, token_address, token_decimals, token_name, chain_id, resource_url, resource_mime }) => {
1456
- // Convert human-readable amount to raw token units
1642
+ // Convert human-readable amount to raw token units. A native paywall used
1643
+ // to default to 6 decimals and price 0.01 ETH as 10000 wei.
1644
+ if (token_decimals === undefined)
1645
+ token_decimals = token_type === 'native' ? nativeDecimals(chain_id) : (lookupTrustedDecimals(chain_id, token_address) ?? 6);
1457
1646
  const rawAmount = parseUnits(amount, token_decimals);
1458
1647
  const data = await api('/x402/paywalls', 'POST', {
1459
1648
  wallet_id,
@@ -1478,7 +1667,7 @@ server.tool('create_paywall', 'Create an x402 paywall that charges agents/client
1478
1667
  server.tool('list_paywalls', 'List all your x402 paywalls. Returns paywall IDs, names, pricing, ' +
1479
1668
  'access URLs, payment counts, and revenue totals.', {
1480
1669
  page: z.number().int().default(1).describe('Page number'),
1481
- per_page: z.number().int().default(50).describe('Results per page (max 100)'),
1670
+ per_page: z.number().int().min(1).max(100).default(50).describe('Results per page (max 100)'),
1482
1671
  }, async ({ page, per_page }) => {
1483
1672
  const data = await api(`/x402/paywalls?page=${page}&per_page=${per_page}`);
1484
1673
  return jsonResponse(data);
@@ -1498,7 +1687,7 @@ server.tool('update_paywall', 'Update an x402 paywall configuration. ' +
1498
1687
  name: z.string().optional().describe('New paywall name'),
1499
1688
  description: z.string().optional().describe('New description'),
1500
1689
  amount: z.string().optional().describe('New price in human-readable format (e.g. "0.05")'),
1501
- token_decimals: z.number().int().optional().describe('Token decimals (needed if changing amount)'),
1690
+ token_decimals: z.number().int().min(0).max(36).optional().describe('Token decimals. Read from the paywall itself when omitted'),
1502
1691
  resource_url: z.string().url().optional().describe('New resource URL'),
1503
1692
  resource_mime: z.string().optional().describe('New MIME type'),
1504
1693
  is_active: z.boolean().optional().describe('Enable (true) or disable (false) the paywall'),
@@ -1516,7 +1705,14 @@ server.tool('update_paywall', 'Update an x402 paywall configuration. ' +
1516
1705
  body.is_active = is_active;
1517
1706
  // Convert human-readable amount to raw if provided
1518
1707
  if (amount !== undefined) {
1519
- const decimals = token_decimals ?? 6; // Default to USDC decimals
1708
+ let decimals = token_decimals;
1709
+ if (decimals === undefined) {
1710
+ const existing = (await api(`/x402/paywalls/${paywall_id}`));
1711
+ const d = Number(existing?.token_decimals);
1712
+ if (!Number.isInteger(d) || d < 0 || d > 36)
1713
+ throw new Error('Could not read the paywall decimals; pass token_decimals with the new amount.');
1714
+ decimals = d;
1715
+ }
1520
1716
  body.amount = parseUnits(amount, decimals);
1521
1717
  }
1522
1718
  const data = await api(`/x402/paywalls/${paywall_id}`, 'PUT', body);
@@ -1534,7 +1730,7 @@ server.tool('get_paywall_payments', 'Get payment history for a specific x402 pay
1534
1730
  'Returns verified payments with TX hashes, payer addresses, amounts, and timestamps.', {
1535
1731
  paywall_id: z.number().int().describe('Paywall ID'),
1536
1732
  page: z.number().int().default(1).describe('Page number'),
1537
- per_page: z.number().int().default(20).describe('Results per page (max 100)'),
1733
+ per_page: z.number().int().min(1).max(100).default(20).describe('Results per page (max 100)'),
1538
1734
  }, async ({ paywall_id, page, per_page }) => {
1539
1735
  const data = await api(`/x402/paywalls/${paywall_id}/payments?page=${page}&per_page=${per_page}`);
1540
1736
  return jsonResponse(data);
@@ -1563,6 +1759,10 @@ server.tool('wallet_mode', 'Report whether this server is signing locally (self-
1563
1759
  custody: 'self',
1564
1760
  signing: 'Signed in this process. Keys are never sent to AgentWallet or anyone else.',
1565
1761
  max_autopay: process.env.AGENTWALLET_MAX_AUTOPAY || '1',
1762
+ autopay_assets: process.env.AGENTWALLET_AUTOPAY_ASSETS || 'registry stablecoins only',
1763
+ x402_max_timeout_seconds: maxAuthWindowSeconds(),
1764
+ per_tx_cap_token: process.env.AGENTWALLET_MAX_TX_TOKEN || 'not set (ERC-20 and SPL transfers are uncapped)',
1765
+ allow_unknown_token_calls: process.env.AGENTWALLET_ALLOW_UNKNOWN_TOKEN_CALLS === '1',
1566
1766
  };
1567
1767
  if (isLocalMode()) {
1568
1768
  let rpc = 'default public endpoint';
@@ -1572,7 +1772,7 @@ server.tool('wallet_mode', 'Report whether this server is signing locally (self-
1572
1772
  catch { /* chain has no default; not worth failing the report over */ }
1573
1773
  report.evm = {
1574
1774
  address: getLocalAddress(),
1575
- rpc_endpoint: rpc,
1775
+ rpc_endpoint: redactUrl(rpc),
1576
1776
  per_tx_cap_native: process.env.AGENTWALLET_MAX_TX_NATIVE || 'not set',
1577
1777
  };
1578
1778
  }
@@ -1587,8 +1787,9 @@ server.tool('wallet_mode', 'Report whether this server is signing locally (self-
1587
1787
  catch { /* fall through to the default label */ }
1588
1788
  report.solana = {
1589
1789
  address: getSolanaAddress(),
1590
- rpc_endpoint: rpc,
1790
+ rpc_endpoint: redactUrl(rpc),
1591
1791
  per_tx_cap_sol: process.env.AGENTWALLET_MAX_TX_SOL || 'not set',
1792
+ per_tx_cap_token: process.env.AGENTWALLET_MAX_TX_TOKEN || 'not set (SPL transfers are uncapped; AGENTWALLET_MAX_TX_SOL does not cover them)',
1592
1793
  };
1593
1794
  }
1594
1795
  else {
@@ -1625,6 +1826,7 @@ server.tool('export_wallet_key', 'Explain how to export the private key of a hos
1625
1826
  });
1626
1827
  // ─── Start ──────────────────────────────────────────────────────
1627
1828
  async function main() {
1829
+ validateGuardEnv();
1628
1830
  const transport = new StdioServerTransport();
1629
1831
  await server.connect(transport);
1630
1832
  /* stderr, so it never corrupts the stdio JSON-RPC stream. Announcing custody
@@ -1637,6 +1839,8 @@ async function main() {
1637
1839
  if (isSolanaLocalMode())
1638
1840
  parts.push(`Solana ${getSolanaAddress()}`);
1639
1841
  console.error(`AgentWallet MCP: LOCAL signing mode. ${parts.join(', ')}. Keys never leave this machine.`);
1842
+ if (!(process.env.AGENTWALLET_MAX_TX_TOKEN || '').trim())
1843
+ console.error('AgentWallet MCP: warning, AGENTWALLET_MAX_TX_TOKEN is not set; ERC-20 and SPL transfers have no per-transaction ceiling.');
1640
1844
  }
1641
1845
  catch (e) {
1642
1846
  console.error(`AgentWallet MCP: local signing configured but a key could not be loaded: ${e.message}`);