agentwallet-mcp 1.13.10 → 1.13.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -39,7 +39,7 @@ Run `wallet_mode` at any time and the server will tell you which one you are in,
39
39
 
40
40
  Use `AGENTWALLET_KEYFILE=/path/to/key` instead if you would rather keep the key out of your shell config (one or the other: the server refuses to start when both are set). The key is read once, never written to disk, never logged, and never included in an error message.
41
41
 
42
- `AGENTWALLET_RPC_<chainId>` (or `AGENTWALLET_RPC_URL` for all chains) points at an endpoint you trust. Without it a public RPC is used, and a public RPC can see which addresses you ask about.
42
+ `AGENTWALLET_RPC_<chainId>` (or `AGENTWALLET_RPC_URL` for all chains) points at an endpoint you trust (https; plain http only on localhost). Without it a public RPC is used, and a public RPC can see which addresses you ask about. Decimals for a token outside the registry come from that RPC and are accepted only up to 18; pin anything else with `AGENTWALLET_TOKEN_DECIMALS`.
43
43
 
44
44
  `AGENTWALLET_MAX_TX_NATIVE` is a per-transaction ceiling in native units (ETH, MATIC and so on). In hosted mode the server enforces limits; in local mode there is no server, so these guards are the only ones there are. Set them.
45
45
 
@@ -330,6 +330,7 @@ Which guards apply depends on who holds the key.
330
330
  **Local mode** (you hold the key): there is no server in the signing path, so the server-side limits and pause above cannot see or stop a locally signed transaction, and nobody (including us) can freeze a local wallet. Your protection is the per-transaction caps enforced inside your own process before anything is signed. Set them before you fund the wallet; an unset cap means no limit.
331
331
 
332
332
  - `AGENTWALLET_MAX_TX_NATIVE`: ceiling per transaction in native units (ETH, MATIC, and so on)
333
+ - `AGENTWALLET_MAX_FEE_NATIVE`: the most one transaction may burn in fees, gas x max fee per gas, in native units (default `0.01`). The value cap alone did not bound fees: a node answering an absurd tip or gas estimate could take the whole balance as priority fee on a cap-compliant transfer. Caller-supplied `gas_limit` / `max_fee` / `priority_fee` are honoured and then bounded by this cap
333
334
  - `AGENTWALLET_MAX_TX_TOKEN`: ceiling per ERC-20 transfer or approval, in human units of the token
334
335
  - `AGENTWALLET_ALLOW_UNKNOWN_TOKEN_CALLS`: set to `1` to let calldata the token cap cannot price reach a token contract or Permit2 (refused by default while the cap is set)
335
336
  - `AGENTWALLET_TOKEN_DECIMALS`: pin decimals for tokens outside the registry (`8453:0x<token>=6,<mint>=9`); a pin is used for amounts and caps instead of asking the RPC
package/build/index.js CHANGED
@@ -119,7 +119,12 @@ async function routeLocally(path, method, body) {
119
119
  throw new Error(`Local mode expects an EVM address, got "${to}".`);
120
120
  }
121
121
  const hasData = data && data !== '0x';
122
- return localSend(chainId, to, value, hasData ? data : undefined);
122
+ const fees = {
123
+ ...(body?.gas_limit ? { gas: String(body.gas_limit) } : {}),
124
+ ...(body?.max_fee ? { maxFeePerGas: String(body.max_fee) } : {}),
125
+ ...(body?.priority_fee ? { maxPriorityFeePerGas: String(body.priority_fee) } : {}),
126
+ };
127
+ return localSend(chainId, to, value, hasData ? data : undefined, fees);
123
128
  }
124
129
  // POST /wallets/{id}/x402/authorize (x402 "exact": EIP-3009 authorization, signed here, broadcast by nobody)
125
130
  if (method === 'POST' && /^\/wallets\/[^/]*\/x402\/authorize$/.test(rawPath)) {
@@ -603,7 +608,7 @@ async function verifiedDecimals(chainId, token, given) {
603
608
  /** Cap variables are read at send time; a typo must fail at startup, not after funding. */
604
609
  function validateGuardEnv() {
605
610
  parseDecimalPins(process.env.AGENTWALLET_TOKEN_DECIMALS); // throws on a malformed pin
606
- for (const name of ['AGENTWALLET_MAX_TX_NATIVE', 'AGENTWALLET_MAX_TX_TOKEN', 'AGENTWALLET_MAX_TX_SOL', 'AGENTWALLET_MAX_AUTOPAY']) {
611
+ for (const name of ['AGENTWALLET_MAX_TX_NATIVE', 'AGENTWALLET_MAX_TX_TOKEN', 'AGENTWALLET_MAX_TX_SOL', 'AGENTWALLET_MAX_AUTOPAY', 'AGENTWALLET_MAX_FEE_NATIVE']) {
607
612
  const raw = process.env[name];
608
613
  const v = (raw || '').trim();
609
614
  // Set but blank is a typo, not "no cap": refuse to start rather than run uncapped.
@@ -654,7 +659,7 @@ function validateGuardEnv() {
654
659
  throw new Error(`Unknown environment variable ${k}. AgentWallet refuses to start on an unrecognised AGENTWALLET_* name so that a misspelled cap cannot run uncapped. Known names: ${[...KNOWN_ENV].join(', ')}, AGENTWALLET_RPC_<chainId>, AGENTWALLET_SOLANA_RPC_<chainId>.`);
655
660
  }
656
661
  }
657
- const KNOWN_ENV = new Set(["AGENTWALLET_ALLOW_UNKNOWN_TOKEN_CALLS", "AGENTWALLET_API_URL", "AGENTWALLET_APPROVALS", "AGENTWALLET_AUTOPAY_ASSETS", "AGENTWALLET_CHAIN_ID", "AGENTWALLET_DASHBOARD_URL", "AGENTWALLET_KEYFILE", "AGENTWALLET_LEGACY_PAYWALL_ORIGINS", "AGENTWALLET_MAX_AUTOPAY", "AGENTWALLET_MAX_AUTOPAY_PER_HOUR", "AGENTWALLET_MAX_TX_NATIVE", "AGENTWALLET_MAX_TX_SOL", "AGENTWALLET_MAX_TX_TOKEN", "AGENTWALLET_PASS", "AGENTWALLET_PERMIT2_AUTO_APPROVE", "AGENTWALLET_PRIVATE_KEY", "AGENTWALLET_RPC_URL", "AGENTWALLET_SOLANA_KEY", "AGENTWALLET_SOLANA_KEYFILE", "AGENTWALLET_SOLANA_RPC", "AGENTWALLET_TOKEN_DECIMALS", "AGENTWALLET_TOKEN_RISK", "AGENTWALLET_USER", "AGENTWALLET_WALLET_ID", "AGENTWALLET_X402_MAX_TIMEOUT"]);
662
+ const KNOWN_ENV = new Set(["AGENTWALLET_ALLOW_UNKNOWN_TOKEN_CALLS", "AGENTWALLET_API_URL", "AGENTWALLET_APPROVALS", "AGENTWALLET_AUTOPAY_ASSETS", "AGENTWALLET_CHAIN_ID", "AGENTWALLET_DASHBOARD_URL", "AGENTWALLET_KEYFILE", "AGENTWALLET_LEGACY_PAYWALL_ORIGINS", "AGENTWALLET_MAX_AUTOPAY", "AGENTWALLET_MAX_AUTOPAY_PER_HOUR", "AGENTWALLET_MAX_FEE_NATIVE", "AGENTWALLET_MAX_TX_NATIVE", "AGENTWALLET_MAX_TX_SOL", "AGENTWALLET_MAX_TX_TOKEN", "AGENTWALLET_PASS", "AGENTWALLET_PERMIT2_AUTO_APPROVE", "AGENTWALLET_PRIVATE_KEY", "AGENTWALLET_RPC_URL", "AGENTWALLET_SOLANA_KEY", "AGENTWALLET_SOLANA_KEYFILE", "AGENTWALLET_SOLANA_RPC", "AGENTWALLET_TOKEN_DECIMALS", "AGENTWALLET_TOKEN_RISK", "AGENTWALLET_USER", "AGENTWALLET_WALLET_ID", "AGENTWALLET_X402_MAX_TIMEOUT"]);
658
663
  /**
659
664
  * Register a tool whose arguments are strict: an unknown key (max_payemnt for
660
665
  * max_payment) is an error that names it, not a silently dropped guard. The
@@ -742,7 +747,7 @@ function resolveChainId(network) {
742
747
  const AddressSchema = z.string().regex(/^(0x[a-fA-F0-9]{40}|[1-9A-HJ-NP-Za-km-z]{32,44})$/, 'Must be a 0x-prefixed 40-hex EVM address or a base58 Solana address');
743
748
  const server = new McpServer({
744
749
  name: 'agentwallet',
745
- version: '1.13.10',
750
+ version: '1.13.11',
746
751
  }, {
747
752
  instructions: `AgentWallet gives AI agents their own blockchain wallets. ${anyLocalMode()
748
753
  ? 'This server runs in LOCAL (self-custody) mode: the operator supplied the key, signing happens in this process, and the only spend guards are the environment caps (AGENTWALLET_MAX_TX_NATIVE / _TOKEN / _SOL per transaction, AGENTWALLET_MAX_AUTOPAY and AGENTWALLET_AUTOPAY_ASSETS for x402, AGENTWALLET_X402_MAX_TIMEOUT for authorization windows). There is no server-side pause, daily limit or approval email; pause_wallet, unpause_wallet and delete_wallet are refused.'
@@ -2115,6 +2120,7 @@ tool('wallet_mode', 'Report whether this server is signing locally (self-custody
2115
2120
  address: getLocalAddress(),
2116
2121
  rpc_endpoint: redactUrl(rpc),
2117
2122
  per_tx_cap_native: (process.env.AGENTWALLET_MAX_TX_NATIVE || '').trim() || 'not set',
2123
+ per_tx_fee_cap_native: (process.env.AGENTWALLET_MAX_FEE_NATIVE || '').trim() || '0.01 (default)',
2118
2124
  };
2119
2125
  }
2120
2126
  else {
@@ -49,14 +49,15 @@ export declare function localNativeBalance(chainId: number): Promise<{
49
49
  mode: string;
50
50
  }>;
51
51
  export declare function localTokenBalance(chainId: number, token: Address): Promise<{
52
+ mode: string;
53
+ warning?: string | undefined;
52
54
  address: `0x${string}`;
53
55
  chain_id: number;
54
56
  token: `0x${string}`;
55
57
  symbol: string;
56
- decimals: number;
58
+ decimals: number | null;
57
59
  balance_raw: string;
58
- balance: string;
59
- mode: string;
60
+ balance: string | null;
60
61
  }>;
61
62
  export interface LocalSendResult {
62
63
  tx_hash: string;
@@ -66,6 +67,9 @@ export interface LocalSendResult {
66
67
  chain_id: number;
67
68
  mode: 'local';
68
69
  signed_locally: true;
70
+ gas?: string;
71
+ max_fee_per_gas?: string;
72
+ worst_case_fee?: string;
69
73
  }
70
74
  /**
71
75
  * What a decimals() probe learned about an address. The four outcomes are
@@ -89,7 +93,20 @@ type DecimalsProbe = number | 'unusable' | 'not-a-token' | 'unreachable';
89
93
  /** An eth_call failure is a revert only when the node says so; every other failure is "could not ask". */
90
94
  export declare function classifyDecimalsError(err: unknown): DecimalsProbe;
91
95
  export declare function permit2AddressFor(chainId: number): string;
92
- export declare function localSend(chainId: number, to: Address, valueWei: bigint, data?: Hex): Promise<LocalSendResult>;
96
+ /** Caller-chosen gas parameters (wei / gas units as decimal strings); honoured, then bounded by the fee cap. */
97
+ export interface FeeOverrides {
98
+ gas?: string;
99
+ maxFeePerGas?: string;
100
+ maxPriorityFeePerGas?: string;
101
+ }
102
+ /**
103
+ * The most a transaction may burn in fees: gas x maxFeePerGas, in native units.
104
+ * Without it a node that answered a 47,000 gwei tip or a 30M gas estimate took
105
+ * the whole balance as fees on a cap-compliant 0.001 ETH transfer (round 4).
106
+ * Default 0.01; AGENTWALLET_MAX_FEE_NATIVE raises or lowers it.
107
+ */
108
+ export declare function feeCapWei(): bigint;
109
+ export declare function localSend(chainId: number, to: Address, valueWei: bigint, data?: Hex, fees?: FeeOverrides): Promise<LocalSendResult>;
93
110
  /** ERC-20 transfer, encoded locally. */
94
111
  export declare function localTransferToken(chainId: number, token: Address, to: Address, rawAmount: string): Promise<LocalSendResult>;
95
112
  /** ERC-20 approve, encoded locally. */
@@ -15,7 +15,7 @@
15
15
  * on the custodial path and say so explicitly rather than silently downgrading.
16
16
  */
17
17
  import { readFileSync, statSync } from 'node:fs';
18
- import { createPublicClient, createWalletClient, http, encodeFunctionData, parseAbi, formatUnits, } from 'viem';
18
+ import { createPublicClient, createWalletClient, http, encodeFunctionData, parseAbi, formatUnits, keccak256, } from 'viem';
19
19
  import { privateKeyToAccount } from 'viem/accounts';
20
20
  import { nonceManager } from 'viem/nonce';
21
21
  import { lookupTrustedDecimals, maxAuthWindowSeconds } from './x402-payment.js';
@@ -109,16 +109,31 @@ const DEFAULT_RPC = {
109
109
  export function resolveRpcUrl(chainId) {
110
110
  const perChain = (process.env[`AGENTWALLET_RPC_${chainId}`] || '').trim();
111
111
  if (perChain)
112
- return perChain;
112
+ return assertRpcScheme(perChain, `AGENTWALLET_RPC_${chainId}`);
113
113
  const generic = (process.env.AGENTWALLET_RPC_URL || '').trim();
114
114
  if (generic)
115
- return generic;
115
+ return assertRpcScheme(generic, 'AGENTWALLET_RPC_URL');
116
116
  const fallback = DEFAULT_RPC[chainId];
117
117
  if (fallback)
118
118
  return fallback;
119
119
  throw new Error(`No RPC endpoint for chain ${chainId} in local mode. ` +
120
120
  `Set AGENTWALLET_RPC_${chainId} to an endpoint you trust.`);
121
121
  }
122
+ /** An RPC over plain http to anything but loopback is the hostile-node model in full; refused like the API URL is. */
123
+ function assertRpcScheme(url, name) {
124
+ let u;
125
+ try {
126
+ u = new URL(url);
127
+ }
128
+ catch {
129
+ throw new Error(`${name} is not a URL: "${url}".`);
130
+ }
131
+ const loopback = u.hostname === 'localhost' || u.hostname === '127.0.0.1' || u.hostname === '[::1]';
132
+ if (u.protocol !== 'https:' && !(u.protocol === 'http:' && loopback)) {
133
+ throw new Error(`${name} must be https (got ${u.protocol}//${u.host}); plain http is allowed only for localhost.`);
134
+ }
135
+ return url;
136
+ }
122
137
  /**
123
138
  * Chain ids each RPC URL has been seen to serve, so an operator pointing
124
139
  * AGENTWALLET_RPC_URL at one node for every chain, or mistyping a per-chain
@@ -187,16 +202,21 @@ const ERC20_ABI = parseAbi([
187
202
  export async function localTokenBalance(chainId, token) {
188
203
  const { pub } = clients(chainId);
189
204
  const address = getLocalAddress();
190
- const [raw, decimals, symbol] = await Promise.all([
205
+ const [raw, decimalsRaw, symbol] = await Promise.all([
191
206
  pub.readContract({ address: token, abi: ERC20_ABI, functionName: 'balanceOf', args: [address] }),
192
- pub.readContract({ address: token, abi: ERC20_ABI, functionName: 'decimals' }).catch(() => 18),
207
+ pub.readContract({ address: token, abi: ERC20_ABI, functionName: 'decimals' }).catch(() => null),
193
208
  pub.readContract({ address: token, abi: ERC20_ABI, functionName: 'symbol' }).catch(() => ''),
194
209
  ]);
210
+ // A decimals() of 2^20 handed to viem's formatUnits held the whole server for
211
+ // minutes (quadratic trailing-zero trim, round 4): only 0..36 is formatted.
212
+ const d = Number(decimalsRaw);
213
+ const safe = Number.isInteger(d) && d >= 0 && d <= 36 ? d : null;
195
214
  return {
196
215
  address, chain_id: chainId, token, symbol,
197
- decimals: Number(decimals),
216
+ decimals: safe,
198
217
  balance_raw: raw.toString(),
199
- balance: formatUnits(raw, Number(decimals)),
218
+ balance: safe === null ? null : formatUnits(raw, safe),
219
+ ...(safe === null ? { warning: 'the token reported decimals outside 0..36 (or none); only the raw balance is shown' } : {}),
200
220
  mode: 'local',
201
221
  };
202
222
  }
@@ -261,8 +281,11 @@ async function probeTokenDecimals(chainId, token) {
261
281
  return 'unusable'; // answered, but not one uint8 word
262
282
  const d = Number(BigInt(result));
263
283
  // Reject nonsense rather than trusting it; an out-of-range value would
264
- // widen the ceiling exactly like the old assumption did.
265
- if (Number.isInteger(d) && d >= 0 && d <= 36) {
284
+ // widen the ceiling exactly like the old assumption did. Above 18 the RPC is
285
+ // the only witness and a lie there scales the cap by 10^18 (a node saying 36
286
+ // for mainnet WETH, round 4): such tokens must be pinned with
287
+ // AGENTWALLET_TOKEN_DECIMALS.
288
+ if (Number.isInteger(d) && d >= 0 && d <= 18) {
266
289
  decimalsCache.set(key, d);
267
290
  return d;
268
291
  }
@@ -435,7 +458,25 @@ async function assertWithinTokenCap(chainId, to, data, valueWei = 0n) {
435
458
  }
436
459
  }
437
460
  }
438
- export async function localSend(chainId, to, valueWei, data) {
461
+ function parseUint(v, what) {
462
+ if (!/^\d{1,40}$/.test(v))
463
+ throw new Error(`${what} must be a non-negative integer string, got "${v}".`);
464
+ return BigInt(v);
465
+ }
466
+ /**
467
+ * The most a transaction may burn in fees: gas x maxFeePerGas, in native units.
468
+ * Without it a node that answered a 47,000 gwei tip or a 30M gas estimate took
469
+ * the whole balance as fees on a cap-compliant 0.001 ETH transfer (round 4).
470
+ * Default 0.01; AGENTWALLET_MAX_FEE_NATIVE raises or lowers it.
471
+ */
472
+ export function feeCapWei() {
473
+ const raw = (process.env.AGENTWALLET_MAX_FEE_NATIVE || '0.01').trim();
474
+ if (!/^\d+(\.\d+)?$/.test(raw))
475
+ throw new Error(`AGENTWALLET_MAX_FEE_NATIVE must be a decimal number, got "${raw}".`);
476
+ const [whole, frac = ''] = raw.split('.');
477
+ return BigInt(whole + frac.padEnd(18, '0').slice(0, 18));
478
+ }
479
+ export async function localSend(chainId, to, valueWei, data, fees) {
439
480
  assertWithinNativeCap(valueWei);
440
481
  if (data && data !== '0x') {
441
482
  if (!/^0x([0-9a-fA-F]{2})*$/.test(data))
@@ -443,20 +484,73 @@ export async function localSend(chainId, to, valueWei, data) {
443
484
  await assertWithinTokenCap(chainId, to, data, valueWei);
444
485
  }
445
486
  await assertRpcServesChain(chainId);
446
- const { wallet } = clients(chainId);
447
- const hash = await wallet.sendTransaction({
448
- to,
449
- value: valueWei,
450
- ...(data ? { data } : {}),
487
+ const { pub, wallet } = clients(chainId);
488
+ const account = getLocalAccount();
489
+ const base = { account, to, value: valueWei, ...(data ? { data } : {}) };
490
+ // Gas: the caller's figure when given (it used to be read and dropped,
491
+ // round 4), otherwise the node's estimate.
492
+ const gas = fees?.gas ? parseUint(fees.gas, 'gas_limit') : await pub.estimateGas(base);
493
+ // Fees: the caller's figures when given, otherwise the node's; either way the
494
+ // worst case gas x maxFeePerGas must fit the fee cap before anything is signed.
495
+ let maxFeePerGas;
496
+ let maxPriorityFeePerGas;
497
+ let gasPrice;
498
+ if (fees?.maxFeePerGas) {
499
+ maxFeePerGas = parseUint(fees.maxFeePerGas, 'max_fee');
500
+ maxPriorityFeePerGas = fees.maxPriorityFeePerGas ? parseUint(fees.maxPriorityFeePerGas, 'priority_fee') : maxFeePerGas;
501
+ }
502
+ else {
503
+ try {
504
+ const est = await pub.estimateFeesPerGas();
505
+ maxFeePerGas = est.maxFeePerGas;
506
+ maxPriorityFeePerGas = fees?.maxPriorityFeePerGas ? parseUint(fees.maxPriorityFeePerGas, 'priority_fee') : est.maxPriorityFeePerGas;
507
+ }
508
+ catch {
509
+ gasPrice = (await pub.estimateFeesPerGas({ type: 'legacy' })).gasPrice; // a chain without EIP-1559
510
+ }
511
+ }
512
+ if (maxFeePerGas !== undefined && maxPriorityFeePerGas !== undefined && maxPriorityFeePerGas > maxFeePerGas)
513
+ maxPriorityFeePerGas = maxFeePerGas;
514
+ const perGas = maxFeePerGas ?? gasPrice ?? 0n;
515
+ const worst = gas * perGas;
516
+ const feeCap = feeCapWei();
517
+ if (worst > feeCap) {
518
+ throw new Error(`Blocked by local guard: the transaction could burn up to ${formatUnits(worst, 18)} native in fees (gas ${gas} x ${formatUnits(perGas, 9)} gwei), ` +
519
+ `above AGENTWALLET_MAX_FEE_NATIVE (${formatUnits(feeCap, 18)}). A node answering an absurd fee or gas estimate is the usual cause; ` +
520
+ `pass gas_limit / max_fee / priority_fee explicitly, or the operator can raise the cap.`);
521
+ }
522
+ // Sign here, hash here, then hand the bytes to the node: the hash reported is
523
+ // the one computed from the signed transaction, never the node's answer.
524
+ const address = getLocalAddress();
525
+ const nonce = await account.nonceManager.consume({ address, chainId, client: pub });
526
+ const request = await wallet.prepareTransactionRequest({
527
+ ...base, gas, nonce, chainId,
528
+ ...(gasPrice !== undefined ? { type: 'legacy', gasPrice } : { type: 'eip1559', maxFeePerGas: maxFeePerGas, maxPriorityFeePerGas: maxPriorityFeePerGas }),
451
529
  });
530
+ const signed = await wallet.signTransaction(request);
531
+ const localHash = keccak256(signed);
532
+ let reported;
533
+ try {
534
+ reported = await pub.sendRawTransaction({ serializedTransaction: signed });
535
+ }
536
+ catch (e) {
537
+ account.nonceManager.reset({ address, chainId });
538
+ throw new Error(`Broadcast of transaction ${localHash} (nonce ${nonce}) failed: ${e.message.split('\n')[0]}. The transaction is signed and MAY be live if the node forwarded it before answering; check the hash on an explorer before retrying.`);
539
+ }
540
+ if (reported.toLowerCase() !== localHash.toLowerCase()) {
541
+ throw new Error(`Transaction ${localHash} (nonce ${nonce}) was BROADCAST, but the node answered with a different hash (${String(reported).slice(0, 80)}), so it cannot be trusted about its fate. Verify ${localHash} on an explorer before retrying.`);
542
+ }
452
543
  return {
453
- tx_hash: hash,
454
- from: getLocalAddress(),
544
+ tx_hash: localHash,
545
+ from: address,
455
546
  to,
456
547
  value: valueWei.toString(),
457
548
  chain_id: chainId,
458
549
  mode: 'local',
459
550
  signed_locally: true,
551
+ gas: gas.toString(),
552
+ max_fee_per_gas: (maxFeePerGas ?? gasPrice ?? 0n).toString(),
553
+ worst_case_fee: formatUnits(worst, 18),
460
554
  };
461
555
  }
462
556
  /** ERC-20 transfer, encoded locally. */
@@ -1,19 +1,3 @@
1
- /**
2
- * Pure helpers for x402 auto-pay amount/token derivation.
3
- *
4
- * Isolated from the server bootstrap in index.ts so the funds-sensitive amount
5
- * math can be unit-tested without starting the MCP server.
6
- *
7
- * SECURITY INVARIANT: token decimals must NEVER come from the 402 response.
8
- * A resource server controls `asset`, `payTo`, `maxAmountRequired` AND
9
- * `requiredDecimals`. If the cap is scaled using server-declared decimals, the
10
- * server can inflate the cap arbitrarily: declaring 18 decimals for 6-decimal
11
- * USDC turns a "1 USDC" cap into 10^18 base units, so a request for
12
- * 1,000,000,000,000 units (1,000,000 USDC) passes a 1 USDC cap. Reported by
13
- * ARC Security Research, 2026-08-02, against 1.10.1. Decimals are now resolved
14
- * from a trusted source and the declared value is only ever used to detect a
15
- * mismatch and refuse.
16
- */
17
1
  export interface X402AcceptLike {
18
2
  maxAmountRequired: string;
19
3
  requiredDecimals?: number;
@@ -14,6 +14,7 @@
14
14
  * from a trusted source and the declared value is only ever used to detect a
15
15
  * mismatch and refuse.
16
16
  */
17
+ import { wrappedNativeAddress } from './wrapped-native.js';
17
18
  /**
18
19
  * Decimals for assets that x402 endpoints actually settle in, keyed by
19
20
  * chain id then lowercased contract address (or SPL mint on Solana).
@@ -283,6 +284,11 @@ export function lookupTrustedDecimals(chainId, token) {
283
284
  const pinned = pinnedDecimals(chainId, token);
284
285
  if (pinned !== null)
285
286
  return pinned;
287
+ // Every chain's wrapped native coin is 18 decimals (WETH9 clones; the local
288
+ // guard asserts it). The registry carried WETH for Base only, so mainnet WETH
289
+ // was priced by the RPC, which could say 36 (2026-10-04 round 4).
290
+ if (wrappedNativeAddress(chainId)?.toLowerCase() === token.toLowerCase())
291
+ return 18;
286
292
  const spl = TRUSTED_SPL_DECIMALS[token];
287
293
  if (typeof spl === 'number')
288
294
  return spl;
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "agentwallet-mcp",
3
3
  "mcpName": "io.github.hifriendbot/agentwallet",
4
4
  "repository": { "type": "git", "url": "https://github.com/hifriendbot/agentwallet-mcp" },
5
- "version": "1.13.10",
5
+ "version": "1.13.11",
6
6
  "description": "MCP server for AgentWallet: self-custody local signing on EVM and Solana, or hosted wallets. Sign, broadcast, pay x402 invoices, and accept x402 payments.",
7
7
  "type": "module",
8
8
  "main": "build/index.js",
@@ -17,7 +17,7 @@
17
17
  "scripts": {
18
18
  "build": "tsc",
19
19
  "start": "node build/index.js",
20
- "test": "node test/x402-payment.test.mjs && node test/eip3009.test.mjs && node test/permit2-risk.test.mjs && node test/token-cap.test.mjs && node test/spl-cap.test.mjs && node test/unknown-decimals-cap.test.mjs && node test/decimals-range-cap.test.mjs && node test/local-mode.test.mjs && node test/ssrf-guard.test.mjs && node test/x402-policy.test.mjs && node test/local-guard-shape.test.mjs && node test/allowance-total-cap.test.mjs && node test/autopay-scheme.test.mjs && node test/spl-recipient-guard.test.mjs && node test/approval-binding.test.mjs && node test/ata-rent-size.test.mjs && node test/final-url-guard.test.mjs && node test/audit-1135.test.mjs && node test/local-no-autopay.test.mjs && node test/audit-1136.test.mjs && node test/delete-confirm.test.mjs && node test/audit-1138.test.mjs && node test/audit-1139.test.mjs && node test/audit-11310.test.mjs",
20
+ "test": "node test/x402-payment.test.mjs && node test/eip3009.test.mjs && node test/permit2-risk.test.mjs && node test/token-cap.test.mjs && node test/spl-cap.test.mjs && node test/unknown-decimals-cap.test.mjs && node test/decimals-range-cap.test.mjs && node test/local-mode.test.mjs && node test/ssrf-guard.test.mjs && node test/x402-policy.test.mjs && node test/local-guard-shape.test.mjs && node test/allowance-total-cap.test.mjs && node test/autopay-scheme.test.mjs && node test/spl-recipient-guard.test.mjs && node test/approval-binding.test.mjs && node test/ata-rent-size.test.mjs && node test/final-url-guard.test.mjs && node test/audit-1135.test.mjs && node test/local-no-autopay.test.mjs && node test/audit-1136.test.mjs && node test/delete-confirm.test.mjs && node test/audit-1138.test.mjs && node test/audit-1139.test.mjs && node test/audit-11310.test.mjs && node test/audit-11311.test.mjs",
21
21
  "prepublishOnly": "npm run build"
22
22
  },
23
23
  "keywords": [