agentwallet-mcp 1.12.5 → 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,10 +12,11 @@
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
+ import { WRAPPED_NATIVE } from './wrapped-native.js';
19
20
  import { buildUptoAuthorization, uptoPayload, permit2AllowanceCalldata, permit2ApproveCalldata, decodeUint, PERMIT2_ADDRESS, } from './x402-permit2.js';
20
21
  import { assessTokenRisk } from './token-risk.js';
21
22
  import { isLegacyAgentWalletAccept, requiredAmount, buildAuthorization, buildPaymentPayload, buildPaymentPayloadRaw, paymentHeaderFor, parsePaymentRequired, parseSettlement, pickOption, knownTokenDomain, parseEip7702Delegation, erc1271ProbeCalldata, classifyErc1271Probe, } from './x402-eip3009.js';
@@ -30,6 +31,11 @@ const API_USER = process.env.AGENTWALLET_USER || '';
30
31
  const API_PASS = process.env.AGENTWALLET_PASS || ''; // WordPress application password
31
32
  const X402_WALLET_ID = process.env.AGENTWALLET_WALLET_ID || ''; // Wallet ID for x402 auto-pay
32
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
+ }
33
39
  /* ── Local-mode routing ──────────────────────────────────────────
34
40
  Translates the REST paths the tools already speak into in-process signing
35
41
  calls. Anything not listed here falls through to the hosted API, so
@@ -222,7 +228,7 @@ async function api(path, method = 'GET', body, extraHeaders) {
222
228
  const res = await fetch(url, options);
223
229
  const data = await res.json();
224
230
  // Handle 402 Payment Required — auto-pay if a wallet or a local key is configured
225
- 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']) {
226
232
  return handleX402Payment(data, path, method, body);
227
233
  }
228
234
  if (!res.ok) {
@@ -287,10 +293,15 @@ async function handleX402Payment(x402Data, originalPath, originalMethod, origina
287
293
  throw new Error('402 Payment Required but no payment options available.');
288
294
  }
289
295
  const accept = accepts[0]; // Use first option
290
- const payTo = accept.payTo;
296
+ const payTo = String(accept.payTo || '');
291
297
  // Determine chain_id from network string (CAIP-2, plain name, or raw ID)
292
298
  const network = accept.network || '';
293
- 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
+ }
294
305
  // x402 maxAmountRequired is ALREADY in base/atomic units (e.g. "10000" =
295
306
  // 0.01 USDC at 6 decimals) and must be used directly. The previous code ran
296
307
  // it through parseUnits(), which re-scaled it by 10**decimals and overpaid by
@@ -298,8 +309,11 @@ async function handleX402Payment(x402Data, originalPath, originalMethod, origina
298
309
  // the pay_x402 tool) and enforces a hard ceiling so a malformed or malicious
299
310
  // 402 response cannot drain the wallet. AGENTWALLET_MAX_AUTOPAY is in
300
311
  // human-readable units of the asset; default 1.
301
- const maxAutopay = process.env.AGENTWALLET_MAX_AUTOPAY || '1';
312
+ const maxAutopay = autopayEnvCap();
302
313
  const assetForDecimals = accept.asset || accept.extra?.token || '';
314
+ const policy = autopayAssetAllowed(chainId, assetForDecimals);
315
+ if (!policy.allowed)
316
+ throw new Error(policy.reason);
303
317
  const trustedDecimals = assetForDecimals
304
318
  ? await resolveTrustedDecimals(chainId, assetForDecimals)
305
319
  : nativeDecimals(chainId); // native asset: 18 on EVM (wei), 9 on Solana (lamports)
@@ -369,6 +383,66 @@ function isValidAddress(address) {
369
383
  return true;
370
384
  return false;
371
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
+ }
372
446
  // ─── EVM Helpers ──────────────────────────────────────────────────
373
447
  /**
374
448
  * Convert a human-readable amount (e.g. "0.1") to raw units string given decimals.
@@ -379,8 +453,11 @@ function parseUnits(amount, decimals) {
379
453
  throw new Error(`Invalid amount "${amount}". Must be a positive number (e.g. "0.1" or "100").`);
380
454
  }
381
455
  const [whole, frac = ''] = amount.split('.');
382
- const fracPadded = frac.slice(0, decimals).padEnd(decimals, '0');
383
- 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'));
384
461
  return raw.toString();
385
462
  }
386
463
  /**
@@ -398,14 +475,20 @@ function formatUnits(raw, decimals) {
398
475
  * Pad an address to 32 bytes (64 hex chars) for ABI encoding.
399
476
  */
400
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}".`);
401
482
  return address.slice(2).toLowerCase().padStart(64, '0');
402
483
  }
403
484
  /**
404
485
  * Encode a uint256 as 32 bytes hex (64 chars).
405
486
  */
406
487
  function encodeUint256(value) {
407
- const hex = BigInt(value).toString(16);
408
- 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');
409
492
  }
410
493
  // ─── Server ──────────────────────────────────────────────────────
411
494
  // ─── x402 Network Mapping ──────────────────────────────────────
@@ -467,87 +550,89 @@ function resolveChainId(network) {
467
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');
468
551
  const server = new McpServer({
469
552
  name: 'agentwallet',
470
- version: '1.12.5',
553
+ version: '1.13.0',
471
554
  }, {
472
- 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.
473
-
474
- ## Getting Started
475
- 1. Call list_wallets to see existing wallets, or create_wallet to make a new one.
476
- 2. Fund the wallet by sending tokens to its address from an external source.
477
- 3. Use transfer (native tokens) or transfer_token (ERC-20/SPL tokens) to send funds.
478
-
479
- ## Supported Chains
480
- - **EVM chains:** Ethereum (1), Base (8453), Arbitrum (42161), Optimism (10), Polygon (137), Avalanche (43114), BSC (56), Zora (7777777), PulseChain (369)
481
- - **Solana:** Mainnet (900), Devnet (901)
482
- - Call get_chains for the full list with native tokens and stablecoin contract addresses.
483
-
484
- ## EVM vs Solana
485
- - **Addresses:** EVM uses 0x-prefixed hex (42 chars). Solana uses Base58 (32-44 chars).
486
- - **Native tokens:** EVM = ETH/POL/BNB/etc (18 decimals, wei). Solana = SOL (9 decimals, lamports).
487
- - **Token standards:** EVM uses ERC-20. Solana uses SPL tokens.
488
- - **EVM-only tools:** approve_token, get_allowance, wrap_eth, unwrap_eth, call_contract, get_token_info. These do not work on Solana.
489
- - **Works on both:** create_wallet, transfer, transfer_token, send_transaction, sign_transaction, get_balance, get_token_balance.
490
-
491
- ## Common Workflows
492
-
493
- ### Send native tokens (ETH, SOL, etc.)
494
- Use the transfer tool with a human-readable amount (e.g. "0.1" ETH). It auto-converts to wei/lamports.
495
-
496
- ### Send ERC-20 or SPL tokens
497
- Use transfer_token. You need: wallet_id, token contract address, recipient, amount, chain_id, and decimals (6 for USDC, 18 for most ERC-20s). Call get_chains to find stablecoin addresses.
498
-
499
- ### Interact with DeFi (EVM only)
500
- 1. Check allowance with get_allowance.
501
- 2. If needed, call approve_token to let the DeFi contract spend your tokens.
502
- 3. Use send_transaction with the contract's calldata to execute the interaction.
503
-
504
- ### Wrap/unwrap native tokens (EVM only)
505
- Use wrap_eth to convert ETH → WETH (required by many DeFi protocols). Use unwrap_eth to convert back.
506
-
507
- ### Read on-chain data (EVM only)
508
- Use call_contract for gas-free read-only calls (eth_call). Provide ABI-encoded calldata.
509
-
510
- ## x402 Payments — Paying for Resources
511
- The pay_x402 tool handles the full x402 payment flow automatically:
512
- 1. Fetches the URL.
513
- 2. If the server returns HTTP 402, parses the payment requirements.
514
- 3. Pays on-chain using your wallet.
515
- 4. Retries the request with proof of payment.
516
- Always set max_payment to prevent overspending (e.g. "1.00" for max 1 USDC).
517
-
518
- ## x402 Paywalls — Accepting Payments
519
- Create paywalls to charge other agents for accessing your resources:
520
- 1. create_paywall — set price, token, chain, and the resource URL to protect.
521
- 2. Share the access URL. Clients that pay on-chain get the resource; others get HTTP 402.
522
- 3. list_paywalls and get_paywall_payments to track revenue.
523
- 4. get_x402_revenue for aggregate stats across all paywalls.
524
-
525
- ## Wallet Security
526
- - Use pause_wallet to immediately freeze a wallet if compromised. No transactions can be signed while paused.
527
- - Use unpause_wallet to resume operations.
528
- - Use delete_wallet to permanently disable a wallet.
529
- - Wallets have server-enforced spending limits.
530
-
531
- ## Tool Selection Guide
532
- | Goal | Tool |
533
- |------|------|
534
- | Send ETH/SOL/native tokens | transfer |
535
- | Send USDC/ERC-20/SPL tokens | transfer_token |
536
- | Check native balance | get_balance |
537
- | Check token balance | get_token_balance |
538
- | Look up token details | get_token_info (EVM only) |
539
- | Approve DeFi spending | approve_token (EVM only) |
540
- | Check approval amount | get_allowance (EVM only) |
541
- | Read smart contract | call_contract (EVM only) |
542
- | Custom transaction | send_transaction |
543
- | Sign without broadcasting | sign_transaction |
544
- | Pay for x402 resource | pay_x402 |
545
- | Charge for your resource | create_paywall |
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.'}
558
+
559
+ ## Getting Started
560
+ 1. Call list_wallets to see existing wallets, or create_wallet to make a new one.
561
+ 2. Fund the wallet by sending tokens to its address from an external source.
562
+ 3. Use transfer (native tokens) or transfer_token (ERC-20/SPL tokens) to send funds.
563
+
564
+ ## Supported Chains
565
+ - **EVM chains:** Ethereum (1), Base (8453), Arbitrum (42161), Optimism (10), Polygon (137), Avalanche (43114), BSC (56), Zora (7777777), PulseChain (369)
566
+ - **Solana:** Mainnet (900), Devnet (901), Testnet (902)
567
+ - Call get_chains for the full list with native tokens and stablecoin contract addresses.
568
+
569
+ ## EVM vs Solana
570
+ - **Addresses:** EVM uses 0x-prefixed hex (42 chars). Solana uses Base58 (32-44 chars).
571
+ - **Native tokens:** EVM = ETH/POL/BNB/etc (18 decimals, wei). Solana = SOL (9 decimals, lamports).
572
+ - **Token standards:** EVM uses ERC-20. Solana uses SPL tokens.
573
+ - **EVM-only tools:** approve_token, get_allowance, wrap_eth, unwrap_eth, call_contract, get_token_info. These do not work on Solana.
574
+ - **Works on both:** create_wallet, transfer, transfer_token, send_transaction, sign_transaction, get_balance, get_token_balance.
575
+
576
+ ## Common Workflows
577
+
578
+ ### Send native tokens (ETH, SOL, etc.)
579
+ Use the transfer tool with a human-readable amount (e.g. "0.1" ETH). It auto-converts to wei/lamports.
580
+
581
+ ### Send ERC-20 or SPL tokens
582
+ Use transfer_token. You need: wallet_id, token contract address, recipient, amount, chain_id, and decimals (6 for USDC, 18 for most ERC-20s). Call get_chains to find stablecoin addresses.
583
+
584
+ ### Interact with DeFi (EVM only)
585
+ 1. Check allowance with get_allowance.
586
+ 2. If needed, call approve_token to let the DeFi contract spend your tokens.
587
+ 3. Use send_transaction with the contract's calldata to execute the interaction.
588
+
589
+ ### Wrap/unwrap native tokens (EVM only)
590
+ Use wrap_eth to convert ETH → WETH (required by many DeFi protocols). Use unwrap_eth to convert back.
591
+
592
+ ### Read on-chain data (EVM only)
593
+ Use call_contract for gas-free read-only calls (eth_call). Provide ABI-encoded calldata.
594
+
595
+ ## x402 Payments — Paying for Resources
596
+ The pay_x402 tool handles the full x402 payment flow automatically:
597
+ 1. Fetches the URL.
598
+ 2. If the server returns HTTP 402, parses the payment requirements.
599
+ 3. Pays on-chain using your wallet.
600
+ 4. Retries the request with proof of payment.
601
+ Always set max_payment to prevent overspending (e.g. "1.00" for max 1 USDC).
602
+
603
+ ## x402 Paywalls — Accepting Payments
604
+ Create paywalls to charge other agents for accessing your resources:
605
+ 1. create_paywall — set price, token, chain, and the resource URL to protect.
606
+ 2. Share the access URL. Clients that pay on-chain get the resource; others get HTTP 402.
607
+ 3. list_paywalls and get_paywall_payments to track revenue.
608
+ 4. get_x402_revenue for aggregate stats across all paywalls.
609
+
610
+ ## Wallet Security
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.'}
612
+ - Use unpause_wallet to resume operations.
613
+ - Use delete_wallet to permanently disable a wallet.
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.'}
615
+
616
+ ## Tool Selection Guide
617
+ | Goal | Tool |
618
+ |------|------|
619
+ | Send ETH/SOL/native tokens | transfer |
620
+ | Send USDC/ERC-20/SPL tokens | transfer_token |
621
+ | Check native balance | get_balance |
622
+ | Check token balance | get_token_balance |
623
+ | Look up token details | get_token_info (EVM only) |
624
+ | Approve DeFi spending | approve_token (EVM only) |
625
+ | Check approval amount | get_allowance (EVM only) |
626
+ | Read smart contract | call_contract (EVM only) |
627
+ | Custom transaction | send_transaction |
628
+ | Sign without broadcasting | sign_transaction |
629
+ | Pay for x402 resource | pay_x402 |
630
+ | Charge for your resource | create_paywall |
546
631
  | Check usage/billing | get_usage |`,
547
632
  });
548
633
  // ─── Tool: create_wallet ─────────────────────────────────────────
549
634
  server.tool('create_wallet', 'Create a new EVM or Solana wallet. Returns the wallet ID and address. ' +
550
- '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.'), {
551
636
  label: z.string().default('').describe('Friendly name for the wallet'),
552
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)'),
553
638
  }, async ({ label, chain_id }) => {
@@ -585,13 +670,13 @@ server.tool('sign_transaction', 'Sign a transaction with a wallet\'s private key
585
670
  wallet_id: z.number().int().describe('Wallet ID'),
586
671
  to: AddressSchema.describe('Destination address (0x-prefixed for EVM, Base58 for Solana)'),
587
672
  chain_id: z.number().int().optional().describe('Chain ID (defaults to wallet\'s default)'),
588
- value: z.string().default('0').describe('Value in wei/lamports (decimal string)'),
589
- 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'),
590
675
  gas_limit: z.string().optional().describe('Gas limit — EVM only (auto-estimated if omitted)'),
591
676
  max_fee: z.string().optional().describe('Max fee per gas in wei — EVM only (auto if omitted)'),
592
677
  priority_fee: z.string().optional().describe('Max priority fee per gas in wei — EVM only (auto if omitted)'),
593
678
  token_mint: z.string().optional().describe('SPL token mint address — Solana only (for SPL token transfers)'),
594
- 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)'),
595
680
  }, async ({ wallet_id, to, chain_id, value, data, gas_limit, max_fee, priority_fee, token_mint, token_decimals }) => {
596
681
  // Validate address format
597
682
  if (!isValidAddress(to)) {
@@ -600,6 +685,10 @@ server.tool('sign_transaction', 'Sign a transaction with a wallet\'s private key
600
685
  const body = { to, value };
601
686
  if (chain_id)
602
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).');
603
692
  if (isSolanaChain(chain_id ?? 0)) {
604
693
  // Solana-specific params
605
694
  if (token_mint)
@@ -623,17 +712,17 @@ server.tool('sign_transaction', 'Sign a transaction with a wallet\'s private key
623
712
  // ─── Tool: send_transaction ──────────────────────────────────────
624
713
  server.tool('send_transaction', 'Sign and broadcast a transaction. ' +
625
714
  'Returns the transaction hash (EVM) or signature (Solana) on success. ' +
626
- '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.'), {
627
716
  wallet_id: z.number().int().describe('Wallet ID'),
628
717
  to: AddressSchema.describe('Destination address (0x-prefixed for EVM, Base58 for Solana)'),
629
718
  chain_id: z.number().int().optional().describe('Chain ID (defaults to wallet\'s default)'),
630
- value: z.string().default('0').describe('Value in wei/lamports (decimal string)'),
631
- 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'),
632
721
  gas_limit: z.string().optional().describe('Gas limit — EVM only (auto-estimated if omitted)'),
633
722
  max_fee: z.string().optional().describe('Max fee per gas in wei — EVM only (auto if omitted)'),
634
723
  priority_fee: z.string().optional().describe('Max priority fee per gas in wei — EVM only (auto if omitted)'),
635
724
  token_mint: z.string().optional().describe('SPL token mint address — Solana only (for SPL token transfers)'),
636
- 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)'),
637
726
  }, async ({ wallet_id, to, chain_id, value, data, gas_limit, max_fee, priority_fee, token_mint, token_decimals }) => {
638
727
  // Validate address format
639
728
  if (!isValidAddress(to)) {
@@ -642,6 +731,10 @@ server.tool('send_transaction', 'Sign and broadcast a transaction. ' +
642
731
  const body = { to, value };
643
732
  if (chain_id)
644
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).');
645
738
  if (isSolanaChain(chain_id ?? 0)) {
646
739
  // Solana-specific params
647
740
  if (token_mint)
@@ -700,7 +793,7 @@ server.tool('get_token_balance', 'Get the ERC-20 or SPL token balance for a wall
700
793
  wallet_id: z.number().int().describe('Wallet ID to check'),
701
794
  token: z.string().describe('Token address (0x-prefixed ERC-20 contract for EVM, Base58 mint for Solana)'),
702
795
  chain_id: z.number().int().describe('Chain ID to check on'),
703
- 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'),
704
797
  }, async ({ wallet_id, token, chain_id, decimals }) => {
705
798
  // Validate token address format
706
799
  if (!isValidAddress(token)) {
@@ -713,15 +806,28 @@ server.tool('get_token_balance', 'Get the ERC-20 or SPL token balance for a wall
713
806
  return jsonResponse({
714
807
  ...data,
715
808
  balance: data.balance_formatted,
716
- decimals: data.decimals ?? decimals,
809
+ decimals: data.decimals ?? decimals ?? null,
717
810
  });
718
811
  }
719
- // EVM: format from raw
720
- 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;
721
826
  return jsonResponse({
722
827
  ...data,
723
- balance: balanceFormatted,
724
- decimals,
828
+ balance: data.balance ?? formatUnits(data.balance_raw || '0', trusted),
829
+ decimals: trusted,
830
+ ...(warning ? { warning } : {}),
725
831
  });
726
832
  });
727
833
  // ─── Tool: transfer_token ───────────────────────────────────────
@@ -733,8 +839,8 @@ server.tool('transfer_token', 'Send ERC-20 tokens (EVM) or SPL tokens (Solana) t
733
839
  to: AddressSchema.describe('Recipient address (0x-prefixed for EVM, Base58 for Solana)'),
734
840
  amount: z.string().describe('Amount in human-readable format (e.g. "100" for 100 USDC)'),
735
841
  chain_id: z.number().int().describe('Chain ID'),
736
- decimals: z.number().int().default(18).describe('Token decimals (6 for USDC, 18 for most tokens)'),
737
- }, 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 }) => {
738
844
  // Validate addresses
739
845
  if (!isValidAddress(token)) {
740
846
  throw new Error(`Invalid token address "${token}". Use 0x-prefixed hex for EVM or Base58 for Solana.`);
@@ -742,6 +848,7 @@ server.tool('transfer_token', 'Send ERC-20 tokens (EVM) or SPL tokens (Solana) t
742
848
  if (!isValidAddress(to)) {
743
849
  throw new Error(`Invalid recipient address "${to}". Use 0x-prefixed hex for EVM or Base58 for Solana.`);
744
850
  }
851
+ const decimals = await verifiedDecimals(chain_id, token, givenDecimals);
745
852
  const rawAmount = parseUnits(amount, decimals);
746
853
  let result;
747
854
  if (isSolanaChain(chain_id)) {
@@ -795,8 +902,8 @@ server.tool('approve_token', 'Approve a spender contract to transfer ERC-20 toke
795
902
  spender: z.string().regex(/^0x[a-fA-F0-9]{40}$/).describe('Contract address to approve as spender'),
796
903
  amount: z.string().describe('Amount to approve in human-readable format (e.g. "1000"), or "max" for unlimited'),
797
904
  chain_id: z.number().int().describe('Chain ID'),
798
- decimals: z.number().int().default(18).describe('Token decimals (6 for USDC, 18 for most tokens)'),
799
- }, 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 }) => {
800
907
  if (isSolanaChain(chain_id)) {
801
908
  throw new Error('approve_token is not supported on Solana. Solana SPL tokens do not use ERC-20 style approvals.');
802
909
  }
@@ -806,13 +913,13 @@ server.tool('approve_token', 'Approve a spender contract to transfer ERC-20 toke
806
913
  rawAmount = (BigInt(2) ** BigInt(256) - BigInt(1)).toString();
807
914
  }
808
915
  else {
809
- rawAmount = parseUnits(amount, decimals);
916
+ rawAmount = parseUnits(amount, await verifiedDecimals(chain_id, token, givenDecimals));
810
917
  }
811
918
  const calldata = '0x095ea7b3' + padAddress(spender) + encodeUint256(rawAmount);
812
919
  // Asset risk rides along with the approval (issue #13). It never blocks; it is the
813
920
  // caller's call. Skipped for Permit2, which only moves what a signature allows.
814
921
  let risk = null;
815
- if (spender.toLowerCase() !== PERMIT2_ADDRESS.toLowerCase()) {
922
+ if (spender.toLowerCase() !== PERMIT2_ADDRESS.toLowerCase() && process.env.AGENTWALLET_TOKEN_RISK !== '0') {
816
923
  try {
817
924
  risk = await assessTokenRisk(chain_id, token, (to, data) => ethCallHex(chain_id, to, data));
818
925
  }
@@ -842,8 +949,8 @@ server.tool('get_allowance', 'Check how many ERC-20 tokens a spender is approved
842
949
  token: z.string().regex(/^0x[a-fA-F0-9]{40}$/).describe('ERC-20 token contract address'),
843
950
  spender: z.string().regex(/^0x[a-fA-F0-9]{40}$/).describe('Spender contract address to check'),
844
951
  chain_id: z.number().int().describe('Chain ID'),
845
- decimals: z.number().int().default(18).describe('Token decimals (6 for USDC, 18 for most tokens)'),
846
- }, 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 }) => {
847
954
  if (isSolanaChain(chain_id)) {
848
955
  throw new Error('get_allowance is not supported on Solana. Solana SPL tokens do not use ERC-20 style allowances.');
849
956
  }
@@ -852,9 +959,13 @@ server.tool('get_allowance', 'Check how many ERC-20 tokens a spender is approved
852
959
  // allowance(address owner, address spender) — selector: 0xdd62ed3e
853
960
  const calldata = '0xdd62ed3e' + padAddress(wallet.address) + padAddress(spender);
854
961
  const result = await api('/eth-call', 'POST', { chain_id, to: token, data: calldata });
855
- // Parse uint256 result
856
- 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.');
857
967
  const raw = BigInt('0x' + (rawHex || '0')).toString();
968
+ const decimals = await verifiedDecimals(chain_id, token, givenDecimals);
858
969
  const maxUint256 = (BigInt(2) ** BigInt(256) - BigInt(1)).toString();
859
970
  return jsonResponse({
860
971
  token,
@@ -866,17 +977,6 @@ server.tool('get_allowance', 'Check how many ERC-20 tokens a spender is approved
866
977
  });
867
978
  });
868
979
  // ─── WETH / Wrapped Native Token addresses ──────────────────────
869
- const WRAPPED_NATIVE = {
870
- 1: { address: '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2', symbol: 'WETH' },
871
- 8453: { address: '0x4200000000000000000000000000000000000006', symbol: 'WETH' },
872
- 42161: { address: '0x82aF49447D8a07e3bd95BD0d56f35241523fBab1', symbol: 'WETH' },
873
- 10: { address: '0x4200000000000000000000000000000000000006', symbol: 'WETH' },
874
- 137: { address: '0x0d500B1d8E8eF31E21C99d1Db9A6444d3ADf1270', symbol: 'WPOL' },
875
- 56: { address: '0xbb4CdB9CBd36B01bD1cBaEBF2De08d9173bc095c', symbol: 'WBNB' },
876
- 43114: { address: '0xB31f66AA3C1e785363F0875A1B74E27b85FD66c7', symbol: 'WAVAX' },
877
- 7777777: { address: '0x4200000000000000000000000000000000000006', symbol: 'WETH' },
878
- 369: { address: '0xA1077a294dDE1B09bB078844df40758a5D0f9a27', symbol: 'WPLS' },
879
- };
880
980
  // ─── Tool: wrap_eth ─────────────────────────────────────────────
881
981
  server.tool('wrap_eth', 'Wrap native tokens (ETH, AVAX, BNB, POL, PLS) into their wrapped ERC-20 version (WETH, WAVAX, etc.). ' +
882
982
  'Required for most DeFi protocols that use ERC-20 tokens instead of raw native tokens. ' +
@@ -959,7 +1059,9 @@ function decodeAbiString(hex) {
959
1059
  return '';
960
1060
  // Convert hex to UTF-8
961
1061
  const bytes = new Uint8Array(pairs.map(b => parseInt(b, 16)));
962
- 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);
963
1065
  }
964
1066
  catch {
965
1067
  return '';
@@ -981,8 +1083,9 @@ server.tool('get_token_info', 'Get the name, symbol, and decimals of any ERC-20
981
1083
  ]);
982
1084
  const name = decodeAbiString(nameResult.result);
983
1085
  const symbol = decodeAbiString(symbolResult.result);
984
- const decimalsHex = decimalsResult.result.replace('0x', '');
985
- 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;
986
1089
  return jsonResponse({
987
1090
  token,
988
1091
  chain_id,
@@ -1111,16 +1214,32 @@ server.tool('pay_x402', 'Handle an x402 payment flow. Fetches a URL, and if the
1111
1214
  request_approval: z.boolean().optional().describe('Hosted wallets only. When the payment exceeds the cap, email the wallet owner an approve/deny link ' +
1112
1215
  'instead of refusing, and return an approval_id to retry with. Default true unless AGENTWALLET_APPROVALS=0.'),
1113
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.'),
1114
- }, 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 }) => {
1115
1220
  // Build request headers
1116
1221
  const reqHeaders = { Accept: 'application/json' };
1117
1222
  if (headersJson) {
1223
+ let parsed;
1118
1224
  try {
1119
- Object.assign(reqHeaders, JSON.parse(headersJson));
1225
+ parsed = JSON.parse(headersJson);
1120
1226
  }
1121
1227
  catch {
1228
+ parsed = null;
1229
+ }
1230
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
1122
1231
  throw new Error('Invalid headers JSON. Must be a JSON object (e.g. {"Authorization": "Bearer ..."}).');
1123
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
+ }
1124
1243
  }
1125
1244
  // Validate URL: canonicalizes IP literals (including IPv4-mapped IPv6) and
1126
1245
  // resolves DNS names, rejecting any private/internal destination. An early
@@ -1157,6 +1276,15 @@ server.tool('pay_x402', 'Handle an x402 payment flow. Fetches a URL, and if the
1157
1276
  });
1158
1277
  }
1159
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
+ }
1160
1288
  const initialText = await initialRes.text();
1161
1289
  let initialBody = null;
1162
1290
  try {
@@ -1200,20 +1328,33 @@ server.tool('pay_x402', 'Handle an x402 payment flow. Fetches a URL, and if the
1200
1328
  if (typeof option.requiredDecimals === 'number')
1201
1329
  assertDeclaredDecimals(option.requiredDecimals, trustedDecimals);
1202
1330
  const amount = formatUnits(rawAmount, trustedDecimals);
1203
- const tokenLabel = option.extra?.name || (tokenAddress ? 'tokens' : 'native');
1331
+ const tokenLabel = assetLabel(chainId, tokenAddress); // never the 402's own label
1204
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
+ }
1205
1344
  // Step 4: Hard per-payment cap, ALWAYS applied. If the caller omits max_payment
1206
1345
  // we fall back to AGENTWALLET_MAX_AUTOPAY (default "1"). Above the cap a hosted
1207
1346
  // wallet can ask its owner instead of refusing: an approval is created, the
1208
1347
  // owner gets approve/deny links by email, and the agent retries with the id.
1209
1348
  // For upto the cap applies to the MAXIMUM the seller may settle.
1210
- const capSource = max_payment ? 'max_payment' : 'AGENTWALLET_MAX_AUTOPAY';
1211
- 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);
1212
1352
  let approvalUsed = null;
1213
1353
  if (!isWithinCap(rawAmount, trustedDecimals, effectiveMax)) {
1214
1354
  const overCap = {
1215
1355
  status: 402, payment_required: true, payment_made: false,
1216
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.` } : {}),
1217
1358
  network: option.network, chain_id: chainId, pay_to: option.payTo, description: option.description,
1218
1359
  };
1219
1360
  const wantApproval = request_approval ?? (process.env.AGENTWALLET_APPROVALS !== '0');
@@ -1222,8 +1363,9 @@ server.tool('pay_x402', 'Handle an x402 payment flow. Fetches a URL, and if the
1222
1363
  if (a?.status !== 'approved' || Number(a.used) === 1) {
1223
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 : ''}` });
1224
1365
  }
1225
- if (String(a.pay_to || '').toLowerCase() !== option.payTo.toLowerCase() || Number(a.chain_id) !== chainId || BigInt(String(a.value || '0')) < BigInt(rawAmount)) {
1226
- 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).' });
1227
1369
  }
1228
1370
  approvalUsed = String(approval_id);
1229
1371
  }
@@ -1265,12 +1407,25 @@ server.tool('pay_x402', 'Handle an x402 payment flow. Fetches a URL, and if the
1265
1407
  let payer = null;
1266
1408
  let permit2ApprovalTx = null;
1267
1409
  let payerDelegationInfo = null;
1268
- 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) {
1269
1424
  payer = await payerAddress(wallet_id);
1270
1425
  payerDelegationInfo = await payerDelegation(chainId, payer);
1271
1426
  const allowance = decodeUint(await ethCallHex(chainId, tokenAddress, permit2AllowanceCalldata(payer)));
1272
1427
  if (allowance < BigInt(rawAmount)) {
1273
- if (process.env.AGENTWALLET_PERMIT2_AUTO_APPROVE === '1') {
1428
+ if (process.env.AGENTWALLET_PERMIT2_AUTO_APPROVE === '1' && assetPolicy.via === 'stablecoin') {
1274
1429
  const r = (await api(`/wallets/${wallet_id}/send`, 'POST', { to: tokenAddress, value: '0', data: permit2ApproveCalldata(), chain_id: chainId }, skip));
1275
1430
  permit2ApprovalTx = String(r?.tx_hash || '');
1276
1431
  await new Promise(res => setTimeout(res, 4000));
@@ -1322,6 +1477,12 @@ server.tool('pay_x402', 'Handle an x402 payment flow. Fetches a URL, and if the
1322
1477
  throw new Error('x402: the payment transaction returned no hash.');
1323
1478
  paymentHeader = Buffer.from(JSON.stringify({ x402Version, scheme: option.scheme, network: option.network, payload: { txHash } })).toString('base64');
1324
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
+ }
1325
1486
  // Step 6: Retry with the payment header and read the settlement receipt.
1326
1487
  const retryHeaders = { ...reqHeaders, [headerName]: paymentHeader };
1327
1488
  const retryOptions = {
@@ -1345,6 +1506,11 @@ server.tool('pay_x402', 'Handle an x402 payment flow. Fetches a URL, and if the
1345
1506
  // A second 402 means the facilitator declined (unfunded payer, expired window, bad domain...). v2 servers
1346
1507
  // put the reason in a fresh PAYMENT-REQUIRED header, v1 servers in the body; surface it instead of {}.
1347
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
+ }
1348
1514
  if (retryRes.status === 402) {
1349
1515
  const again = parsePaymentRequired(n => retryRes.headers.get(n), retryParsed);
1350
1516
  retryError = String(again?.error || retryParsed?.error || 'Payment was not accepted (no reason given).');
@@ -1357,7 +1523,8 @@ server.tool('pay_x402', 'Handle an x402 payment flow. Fetches a URL, and if the
1357
1523
  // Paid means the endpoint accepted the payment: a settlement receipt, or a success status after the payment header.
1358
1524
  // A 4xx other than 402 (bad body, auth) means the request failed for another reason; the authorization was
1359
1525
  // sent but the facilitator normally does not settle a failed request, so it is not reported as paid.
1360
- 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,
1361
1528
  retry_error: retryError,
1362
1529
  payment_method: isUpto ? 'permit2-upto-authorization' : (standardExact ? 'eip3009-authorization' : 'onchain-transfer'),
1363
1530
  approval_id: approvalUsed,
@@ -1371,8 +1538,10 @@ server.tool('pay_x402', 'Handle an x402 payment flow. Fetches a URL, and if the
1371
1538
  pay_to: option.payTo,
1372
1539
  payer,
1373
1540
  payer_delegation: payerDelegationInfo,
1374
- tx_hash: txHash ?? settlement?.transaction ?? null,
1375
- 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,
1376
1545
  authorization: authorization ? { nonce: authorization.nonce, valid_before: authorization.validBefore }
1377
1546
  : (uptoAuth ? { scheme: 'upto', max_amount: amount, nonce: uptoAuth.nonce, deadline: uptoAuth.deadline, facilitator: uptoAuth.witness.facilitator } : null),
1378
1547
  description: option.description,
@@ -1384,16 +1553,23 @@ server.tool('check_approval', 'Status of a payment approval created when pay_x40
1384
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' })));
1385
1554
  // ─── Tool: approve_permit2 ──────────────────────────────────────
1386
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. ' +
1387
- 'A normal on-chain transaction (needs gas). Approves the maximum so it never has to be repeated; Permit2 itself only moves ' +
1388
- '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.', {
1389
1559
  wallet_id: z.number().int().describe('Wallet ID'),
1390
1560
  token: z.string().regex(/^0x[a-fA-F0-9]{40}$/).describe('ERC-20 token address (e.g. USDC on Base)'),
1391
1561
  chain_id: z.number().int().describe('Chain ID'),
1392
- }, 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 }) => {
1393
1564
  if (isSolanaChain(chain_id))
1394
1565
  throw new Error('Permit2 is an EVM contract; there is nothing to approve on Solana.');
1395
- const result = (await api(`/wallets/${wallet_id}/send`, 'POST', { to: token, value: '0', data: permit2ApproveCalldata(), chain_id }));
1396
- 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.' });
1397
1573
  });
1398
1574
  // ─── Tool: check_token_risk ─────────────────────────────────────
1399
1575
  server.tool('check_token_risk', 'Assess an ERC-20 token before approving or swapping it: honeypot, taxes, owner powers, verified source, ' +
@@ -1457,13 +1633,16 @@ server.tool('create_paywall', 'Create an x402 paywall that charges agents/client
1457
1633
  amount: z.string().describe('Price in human-readable format (e.g. "0.01" for 0.01 USDC)'),
1458
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.'),
1459
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.'),
1460
- 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'),
1461
1637
  token_name: z.string().default('USDC').describe('Token display name (e.g. "USDC", "ETH")'),
1462
1638
  chain_id: z.number().int().default(8453).describe('Chain ID for payments (8453=Base, 1=Ethereum, etc.)'),
1463
1639
  resource_url: z.string().url().describe('URL of the protected resource to serve after payment verification'),
1464
1640
  resource_mime: z.string().default('application/json').describe('MIME type of the resource (e.g. "application/json", "text/plain")'),
1465
1641
  }, async ({ wallet_id, name, description, amount, token_type, token_address, token_decimals, token_name, chain_id, resource_url, resource_mime }) => {
1466
- // 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);
1467
1646
  const rawAmount = parseUnits(amount, token_decimals);
1468
1647
  const data = await api('/x402/paywalls', 'POST', {
1469
1648
  wallet_id,
@@ -1488,7 +1667,7 @@ server.tool('create_paywall', 'Create an x402 paywall that charges agents/client
1488
1667
  server.tool('list_paywalls', 'List all your x402 paywalls. Returns paywall IDs, names, pricing, ' +
1489
1668
  'access URLs, payment counts, and revenue totals.', {
1490
1669
  page: z.number().int().default(1).describe('Page number'),
1491
- 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)'),
1492
1671
  }, async ({ page, per_page }) => {
1493
1672
  const data = await api(`/x402/paywalls?page=${page}&per_page=${per_page}`);
1494
1673
  return jsonResponse(data);
@@ -1508,7 +1687,7 @@ server.tool('update_paywall', 'Update an x402 paywall configuration. ' +
1508
1687
  name: z.string().optional().describe('New paywall name'),
1509
1688
  description: z.string().optional().describe('New description'),
1510
1689
  amount: z.string().optional().describe('New price in human-readable format (e.g. "0.05")'),
1511
- 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'),
1512
1691
  resource_url: z.string().url().optional().describe('New resource URL'),
1513
1692
  resource_mime: z.string().optional().describe('New MIME type'),
1514
1693
  is_active: z.boolean().optional().describe('Enable (true) or disable (false) the paywall'),
@@ -1526,7 +1705,14 @@ server.tool('update_paywall', 'Update an x402 paywall configuration. ' +
1526
1705
  body.is_active = is_active;
1527
1706
  // Convert human-readable amount to raw if provided
1528
1707
  if (amount !== undefined) {
1529
- 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
+ }
1530
1716
  body.amount = parseUnits(amount, decimals);
1531
1717
  }
1532
1718
  const data = await api(`/x402/paywalls/${paywall_id}`, 'PUT', body);
@@ -1544,7 +1730,7 @@ server.tool('get_paywall_payments', 'Get payment history for a specific x402 pay
1544
1730
  'Returns verified payments with TX hashes, payer addresses, amounts, and timestamps.', {
1545
1731
  paywall_id: z.number().int().describe('Paywall ID'),
1546
1732
  page: z.number().int().default(1).describe('Page number'),
1547
- 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)'),
1548
1734
  }, async ({ paywall_id, page, per_page }) => {
1549
1735
  const data = await api(`/x402/paywalls/${paywall_id}/payments?page=${page}&per_page=${per_page}`);
1550
1736
  return jsonResponse(data);
@@ -1573,6 +1759,10 @@ server.tool('wallet_mode', 'Report whether this server is signing locally (self-
1573
1759
  custody: 'self',
1574
1760
  signing: 'Signed in this process. Keys are never sent to AgentWallet or anyone else.',
1575
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',
1576
1766
  };
1577
1767
  if (isLocalMode()) {
1578
1768
  let rpc = 'default public endpoint';
@@ -1582,7 +1772,7 @@ server.tool('wallet_mode', 'Report whether this server is signing locally (self-
1582
1772
  catch { /* chain has no default; not worth failing the report over */ }
1583
1773
  report.evm = {
1584
1774
  address: getLocalAddress(),
1585
- rpc_endpoint: rpc,
1775
+ rpc_endpoint: redactUrl(rpc),
1586
1776
  per_tx_cap_native: process.env.AGENTWALLET_MAX_TX_NATIVE || 'not set',
1587
1777
  };
1588
1778
  }
@@ -1597,8 +1787,9 @@ server.tool('wallet_mode', 'Report whether this server is signing locally (self-
1597
1787
  catch { /* fall through to the default label */ }
1598
1788
  report.solana = {
1599
1789
  address: getSolanaAddress(),
1600
- rpc_endpoint: rpc,
1790
+ rpc_endpoint: redactUrl(rpc),
1601
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)',
1602
1793
  };
1603
1794
  }
1604
1795
  else {
@@ -1635,6 +1826,7 @@ server.tool('export_wallet_key', 'Explain how to export the private key of a hos
1635
1826
  });
1636
1827
  // ─── Start ──────────────────────────────────────────────────────
1637
1828
  async function main() {
1829
+ validateGuardEnv();
1638
1830
  const transport = new StdioServerTransport();
1639
1831
  await server.connect(transport);
1640
1832
  /* stderr, so it never corrupts the stdio JSON-RPC stream. Announcing custody
@@ -1647,6 +1839,8 @@ async function main() {
1647
1839
  if (isSolanaLocalMode())
1648
1840
  parts.push(`Solana ${getSolanaAddress()}`);
1649
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.');
1650
1844
  }
1651
1845
  catch (e) {
1652
1846
  console.error(`AgentWallet MCP: local signing configured but a key could not be loaded: ${e.message}`);