agentwallet-mcp 1.10.1 → 1.10.2

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
@@ -41,7 +41,9 @@ Use `AGENTWALLET_KEYFILE=/path/to/key` instead if you would rather keep the key
41
41
 
42
42
  `AGENTWALLET_RPC_<chainId>` (or `AGENTWALLET_RPC_URL` for all chains) points at an endpoint you trust. Without it a public RPC is used, and a public RPC can see which addresses you ask about.
43
43
 
44
- `AGENTWALLET_MAX_TX_NATIVE` is a per-transaction ceiling in native units. In hosted mode the server enforces limits; in local mode there is no server, so this guard is the only one there is. Set it.
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
+
46
+ `AGENTWALLET_MAX_TX_TOKEN` is the equivalent ceiling for ERC-20 movement, in human units of the token. **Set this one too if you hold stablecoins.** The native cap cannot see a token transfer: an ERC-20 send carries `value = 0` with the amount in the calldata, so `AGENTWALLET_MAX_TX_NATIVE` alone leaves a USDC balance uncapped. `AGENTWALLET_MAX_TX_TOKEN` covers `transfer`, `transferFrom` and `approve`, the last because an unbounded allowance is a drain waiting to happen. Decimals are resolved locally; unknown tokens are evaluated at 6 decimals, the tightest common value, so it fails closed rather than open.
45
47
 
46
48
  ### Solana local signing
47
49
 
package/build/index.js CHANGED
@@ -13,7 +13,7 @@ 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
15
  import { assertPublicUrl, safeFetch } from './ssrf-guard.js';
16
- import { deriveX402Payment, isWithinCap } from './x402-payment.js';
16
+ import { deriveX402Payment, isWithinCap, lookupTrustedDecimals, assertDeclaredDecimals, } from './x402-payment.js';
17
17
  import { isLocalMode, getLocalAddress, localWalletRecord, localSend, localNativeBalance, localTokenBalance, localSignMessage, resolveRpcUrl, } from './local-wallet.js';
18
18
  import { isSolanaLocalMode, getSolanaAddress, solanaWalletRecord, localSolBalance, localSplBalance, localSolTransfer, localSplTransfer, resolveSolanaRpc, } from './local-solana.js';
19
19
  /** True when either chain family is running non-custodially. */
@@ -187,6 +187,53 @@ async function api(path, method = 'GET', body, extraHeaders) {
187
187
  }
188
188
  return data;
189
189
  }
190
+ /**
191
+ * Resolve an asset's decimals from a source the paying party controls, never
192
+ * from the 402 response.
193
+ *
194
+ * Order: known-asset registry first (offline, works in local self-custody mode
195
+ * where there may be no reachable API), then the token contract's own
196
+ * decimals() via eth_call. If neither answers we refuse rather than guess,
197
+ * because guessing is exactly what let a hostile endpoint inflate the cap.
198
+ */
199
+ const decimalsCache = new Map();
200
+ async function resolveTrustedDecimals(chainId, token) {
201
+ const key = `${chainId}:${token.toLowerCase()}`;
202
+ const cached = decimalsCache.get(key);
203
+ if (typeof cached === 'number')
204
+ return cached;
205
+ const known = lookupTrustedDecimals(chainId, token);
206
+ if (known !== null) {
207
+ decimalsCache.set(key, known);
208
+ return known;
209
+ }
210
+ if (isSolanaChain(chainId)) {
211
+ throw new Error(`x402 blocked: cannot verify decimals for SPL mint ${token} on chain ${chainId}. ` +
212
+ `Unknown mints are refused because the payment cap cannot be checked without trusted decimals.`);
213
+ }
214
+ // decimals() selector
215
+ let onChain = null;
216
+ try {
217
+ const r = (await api('/eth-call', 'POST', {
218
+ chain_id: chainId, to: token, data: '0x313ce567',
219
+ }, { 'X-AGW-SKIP-X402': 'true' }));
220
+ const hex = (r?.result || '').replace(/^0x/, '');
221
+ if (hex && /^[0-9a-fA-F]+$/.test(hex)) {
222
+ const n = parseInt(hex, 16);
223
+ if (Number.isInteger(n) && n >= 0 && n <= 36)
224
+ onChain = n;
225
+ }
226
+ }
227
+ catch {
228
+ onChain = null; // fall through to the refusal below
229
+ }
230
+ if (onChain === null) {
231
+ throw new Error(`x402 blocked: could not resolve on-chain decimals for ${token} on chain ${chainId}. ` +
232
+ `The payment cap cannot be enforced without them, so the payment was refused.`);
233
+ }
234
+ decimalsCache.set(key, onChain);
235
+ return onChain;
236
+ }
190
237
  /**
191
238
  * Handle x402 auto-payment: pay on-chain, then retry the original request
192
239
  */
@@ -208,7 +255,11 @@ async function handleX402Payment(x402Data, originalPath, originalMethod, origina
208
255
  // 402 response cannot drain the wallet. AGENTWALLET_MAX_AUTOPAY is in
209
256
  // human-readable units of the asset; default 1.
210
257
  const maxAutopay = process.env.AGENTWALLET_MAX_AUTOPAY || '1';
211
- const { tokenAddress, rawAmount, decimals } = deriveX402Payment(accept, maxAutopay);
258
+ const assetForDecimals = accept.asset || accept.extra?.token || '';
259
+ const trustedDecimals = assetForDecimals
260
+ ? await resolveTrustedDecimals(chainId, assetForDecimals)
261
+ : 18; // native asset: value is already in wei-equivalent base units
262
+ const { tokenAddress, rawAmount, decimals } = deriveX402Payment(accept, maxAutopay, trustedDecimals);
212
263
  // Send payment using our wallet (with X-AGW-SKIP-X402 to prevent recursion)
213
264
  let txHash;
214
265
  try {
@@ -958,8 +1009,18 @@ server.tool('pay_x402', 'Handle an x402 payment flow. Fetches a URL, and if the
958
1009
  throw new Error(`Unsupported x402 network: "${option.network}". ` +
959
1010
  `Supported: ${Object.keys(X402_NETWORKS).join(', ')}, or any CAIP-2 / numeric chain ID.`);
960
1011
  }
961
- // Calculate human-readable amount
962
- const amount = formatUnits(option.maxAmountRequired, option.requiredDecimals);
1012
+ // Decimals come from the registry or the token contract, NEVER from the 402
1013
+ // body. The endpoint controls requiredDecimals, so trusting it lets a
1014
+ // hostile server inflate the cap AND misreport the amount shown to the
1015
+ // agent. The declared value is only used to detect a mismatch and refuse.
1016
+ const assetForDecimals = option.asset || option.extra?.token || '';
1017
+ const trustedDecimals = assetForDecimals
1018
+ ? await resolveTrustedDecimals(chainId, assetForDecimals)
1019
+ : 18;
1020
+ assertDeclaredDecimals(option.requiredDecimals, trustedDecimals);
1021
+ // Human-readable amount, computed with trusted decimals so what the agent
1022
+ // is told matches what will actually leave the wallet.
1023
+ const amount = formatUnits(option.maxAmountRequired, trustedDecimals);
963
1024
  // Step 4: Enforce a hard per-payment cap. ALWAYS applied — if the caller
964
1025
  // omits max_payment we fall back to AGENTWALLET_MAX_AUTOPAY (default "1"),
965
1026
  // mirroring the internal auto-pay path (deriveX402Payment). Without this, an
@@ -967,7 +1028,7 @@ server.tool('pay_x402', 'Handle an x402 payment flow. Fetches a URL, and if the
967
1028
  // maxAmountRequired and drain the wallet. The cap is never silently skipped.
968
1029
  const capSource = max_payment ? 'max_payment' : 'AGENTWALLET_MAX_AUTOPAY';
969
1030
  const effectiveMax = max_payment || process.env.AGENTWALLET_MAX_AUTOPAY || '1';
970
- if (!isWithinCap(option.maxAmountRequired, option.requiredDecimals, effectiveMax)) {
1031
+ if (!isWithinCap(option.maxAmountRequired, trustedDecimals, effectiveMax)) {
971
1032
  return jsonResponse({
972
1033
  status: 402,
973
1034
  payment_required: true,
@@ -999,7 +1060,7 @@ server.tool('pay_x402', 'Handle an x402 payment flow. Fetches a URL, and if the
999
1060
  to: option.payTo,
1000
1061
  value: option.maxAmountRequired,
1001
1062
  token_mint: tokenAddress,
1002
- token_decimals: option.requiredDecimals,
1063
+ token_decimals: trustedDecimals,
1003
1064
  chain_id: chainId,
1004
1065
  }));
1005
1066
  }
@@ -64,10 +64,6 @@ export interface LocalSendResult {
64
64
  mode: 'local';
65
65
  signed_locally: true;
66
66
  }
67
- /**
68
- * Sign and broadcast a transaction. viem fills nonce, gas and EIP-1559 fees
69
- * from the RPC unless they are supplied.
70
- */
71
67
  export declare function localSend(chainId: number, to: Address, valueWei: bigint, data?: Hex): Promise<LocalSendResult>;
72
68
  /** ERC-20 transfer, encoded locally. */
73
69
  export declare function localTransferToken(chainId: number, token: Address, to: Address, rawAmount: string): Promise<LocalSendResult>;
@@ -17,6 +17,7 @@
17
17
  import { readFileSync } from 'node:fs';
18
18
  import { createPublicClient, createWalletClient, http, encodeFunctionData, parseAbi, formatUnits, } from 'viem';
19
19
  import { privateKeyToAccount } from 'viem/accounts';
20
+ import { lookupTrustedDecimals } from './x402-payment.js';
20
21
  /* ── Key loading ─────────────────────────────────────────────────── */
21
22
  /**
22
23
  * Read the key once at startup. Kept module-private: nothing exports the key
@@ -166,8 +167,53 @@ export async function localTokenBalance(chainId, token) {
166
167
  * Sign and broadcast a transaction. viem fills nonce, gas and EIP-1559 fees
167
168
  * from the RPC unless they are supplied.
168
169
  */
170
+ /**
171
+ * Bound ERC-20 movement, which the native cap does not see.
172
+ *
173
+ * A token transfer is `to = tokenContract, value = 0, data = transfer(...)`, so
174
+ * assertWithinNativeCap always passed it. In local mode there is no server-side
175
+ * limit behind this, and the README tells operators the local guard is the only
176
+ * one they have, so a wallet holding USDC was effectively uncapped. Covers
177
+ * transfer, transferFrom and approve, since an unbounded approval is a drain
178
+ * waiting to happen.
179
+ *
180
+ * Opt-in via AGENTWALLET_MAX_TX_TOKEN, expressed in human units of the token.
181
+ * When the token's decimals are not known locally we assume 6, the smallest in
182
+ * common use, which makes the raw ceiling the tightest one and fails closed
183
+ * rather than open.
184
+ */
185
+ function assertWithinTokenCap(chainId, token, data) {
186
+ const cap = (process.env.AGENTWALLET_MAX_TX_TOKEN || '').trim();
187
+ if (!cap)
188
+ return;
189
+ if (!/^\d+(\.\d+)?$/.test(cap)) {
190
+ throw new Error(`AGENTWALLET_MAX_TX_TOKEN must be a decimal number, got "${cap}".`);
191
+ }
192
+ const hex = data.slice(2);
193
+ const selector = hex.slice(0, 8).toLowerCase();
194
+ // transfer(address,uint256) | approve(address,uint256) | transferFrom(address,address,uint256)
195
+ const layouts = { a9059cbb: 1, '095ea7b3': 1, '23b872dd': 2 };
196
+ const argIndex = layouts[selector];
197
+ if (argIndex === undefined)
198
+ return; // not a value-moving ERC-20 call
199
+ const word = hex.slice(8 + argIndex * 64, 8 + (argIndex + 1) * 64);
200
+ if (word.length !== 64)
201
+ return; // malformed; leave it to the node to reject
202
+ const amount = BigInt('0x' + word);
203
+ const decimals = lookupTrustedDecimals(chainId, token) ?? 6;
204
+ const [whole, frac = ''] = cap.split('.');
205
+ const capRaw = BigInt(whole + frac.padEnd(decimals, '0').slice(0, decimals));
206
+ if (amount > capRaw) {
207
+ const what = selector === '095ea7b3' ? 'approval' : 'token transfer';
208
+ throw new Error(`Blocked by local guard: ${what} of ${amount} base units exceeds ` +
209
+ `AGENTWALLET_MAX_TX_TOKEN (${cap}, evaluated at ${decimals} decimals). ` +
210
+ `Raise the cap deliberately if this is intended.`);
211
+ }
212
+ }
169
213
  export async function localSend(chainId, to, valueWei, data) {
170
214
  assertWithinNativeCap(valueWei);
215
+ if (data && data.length >= 10)
216
+ assertWithinTokenCap(chainId, to, data);
171
217
  const { wallet } = clients(chainId);
172
218
  const hash = await wallet.sendTransaction({
173
219
  to,
@@ -3,6 +3,16 @@
3
3
  *
4
4
  * Isolated from the server bootstrap in index.ts so the funds-sensitive amount
5
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.
6
16
  */
7
17
  export interface X402AcceptLike {
8
18
  maxAmountRequired: string;
@@ -12,22 +22,48 @@ export interface X402AcceptLike {
12
22
  token?: string;
13
23
  };
14
24
  }
25
+ /**
26
+ * Decimals for assets that x402 endpoints actually settle in, keyed by
27
+ * chain id then lowercased contract address (or SPL mint on Solana).
28
+ *
29
+ * This exists so the common path needs no network call, and so local
30
+ * self-custody mode (which may have no reachable API) can still verify a cap.
31
+ * It is intentionally small: every entry is a value that can be checked by
32
+ * hand. Anything not listed is resolved on-chain instead, and if that fails
33
+ * the payment is refused rather than guessed.
34
+ */
35
+ export declare const TRUSTED_DECIMALS: Record<number, Record<string, number>>;
36
+ /** Solana SPL mints, keyed by mint address (case-sensitive base58). */
37
+ export declare const TRUSTED_SPL_DECIMALS: Record<string, number>;
38
+ /**
39
+ * Look up decimals we already know to be correct. Returns null when the asset
40
+ * is unknown, which means the caller must resolve it on-chain or refuse.
41
+ */
42
+ export declare function lookupTrustedDecimals(chainId: number, token: string): number | null;
43
+ /**
44
+ * Guard the server's declared decimals against the trusted value.
45
+ *
46
+ * A mismatch is not a formatting quirk. It is the exact shape of the cap-bypass
47
+ * attack, so it fails closed and says so.
48
+ */
49
+ export declare function assertDeclaredDecimals(declared: unknown, trusted: number): void;
15
50
  /**
16
51
  * Derive the on-chain transfer parameters from an x402 payment requirement.
17
52
  *
18
53
  * CRITICAL: x402 `maxAmountRequired` is ALREADY denominated in base/atomic
19
54
  * units (e.g. "10000" = 0.01 USDC at 6 decimals). It MUST be used directly.
20
55
  * Running it through parseUnits() re-scales it by 10**decimals and overpays by
21
- * that factor (1,000,000x for USDC). This mirrors the pay_x402 tool, which has
22
- * always used maxAmountRequired directly.
56
+ * that factor (1,000,000x for USDC).
23
57
  *
24
- * Also enforces a hard ceiling so a malformed or malicious 402 response can
25
- * never authorize a transfer larger than `maxAutopayHuman` units of the asset.
58
+ * Enforces a hard ceiling so a malformed or malicious 402 response can never
59
+ * authorize a transfer larger than `maxAutopayHuman` units of the asset.
26
60
  *
27
61
  * @param accept the chosen x402 accept option
28
62
  * @param maxAutopayHuman max auto-pay, in human-readable units of the asset
63
+ * @param trustedDecimals decimals resolved from a registry or the token
64
+ * contract. MUST NOT be taken from the 402 response.
29
65
  */
30
- export declare function deriveX402Payment(accept: X402AcceptLike, maxAutopayHuman: string): {
66
+ export declare function deriveX402Payment(accept: X402AcceptLike, maxAutopayHuman: string, trustedDecimals: number): {
31
67
  tokenAddress: string;
32
68
  rawAmount: string;
33
69
  decimals: number;
@@ -42,7 +78,7 @@ export declare function deriveX402Payment(accept: X402AcceptLike, maxAutopayHuma
42
78
  * can return a structured rejection to the calling agent.
43
79
  *
44
80
  * @param rawAmountRequired x402 maxAmountRequired, already in base units
45
- * @param decimals token decimals
81
+ * @param trustedDecimals token decimals from a trusted source, NOT the 402 body
46
82
  * @param capHuman cap in human-readable units of the asset (e.g. "1")
47
83
  */
48
- export declare function isWithinCap(rawAmountRequired: string, decimals: number, capHuman: string): boolean;
84
+ export declare function isWithinCap(rawAmountRequired: string, trustedDecimals: number, capHuman: string): boolean;
@@ -3,12 +3,100 @@
3
3
  *
4
4
  * Isolated from the server bootstrap in index.ts so the funds-sensitive amount
5
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
+ /**
18
+ * Decimals for assets that x402 endpoints actually settle in, keyed by
19
+ * chain id then lowercased contract address (or SPL mint on Solana).
20
+ *
21
+ * This exists so the common path needs no network call, and so local
22
+ * self-custody mode (which may have no reachable API) can still verify a cap.
23
+ * It is intentionally small: every entry is a value that can be checked by
24
+ * hand. Anything not listed is resolved on-chain instead, and if that fails
25
+ * the payment is refused rather than guessed.
26
+ */
27
+ export const TRUSTED_DECIMALS = {
28
+ // Ethereum
29
+ 1: {
30
+ '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48': 6, // USDC
31
+ '0xdac17f958d2ee523a2206206994597c13d831ec7': 6, // USDT
32
+ '0x6b175474e89094c44da98b954eedeac495271d0f': 18, // DAI
33
+ },
34
+ // Base
35
+ 8453: {
36
+ '0x833589fcd6edb6e08f4c7c32d4f71b54bda02913': 6, // USDC
37
+ '0xd9aaec86b65d86f6a7b5b1b0c42ffa531710b6ca': 6, // USDbC
38
+ '0x4200000000000000000000000000000000000006': 18, // WETH
39
+ },
40
+ // Polygon
41
+ 137: {
42
+ '0x3c499c542cef5e3811e1192ce70d8cc03d5c3359': 6, // USDC (native)
43
+ '0xc2132d05d31c914a87c6611c10748aeb04b58e8f': 6, // USDT
44
+ },
45
+ // Arbitrum One
46
+ 42161: {
47
+ '0xaf88d065e77c8cc2239327c5edb3a432268e5831': 6, // USDC
48
+ },
49
+ // Optimism
50
+ 10: {
51
+ '0x0b2c639c533813f4aa9d7837caf62653d097ff85': 6, // USDC
52
+ },
53
+ };
54
+ /** Solana SPL mints, keyed by mint address (case-sensitive base58). */
55
+ export const TRUSTED_SPL_DECIMALS = {
56
+ EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v: 6, // USDC
57
+ Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB: 6, // USDT
58
+ };
59
+ /**
60
+ * Look up decimals we already know to be correct. Returns null when the asset
61
+ * is unknown, which means the caller must resolve it on-chain or refuse.
62
+ */
63
+ export function lookupTrustedDecimals(chainId, token) {
64
+ if (!token)
65
+ return null;
66
+ const spl = TRUSTED_SPL_DECIMALS[token];
67
+ if (typeof spl === 'number')
68
+ return spl;
69
+ const forChain = TRUSTED_DECIMALS[chainId];
70
+ if (!forChain)
71
+ return null;
72
+ const d = forChain[token.toLowerCase()];
73
+ return typeof d === 'number' ? d : null;
74
+ }
75
+ /**
76
+ * Guard the server's declared decimals against the trusted value.
77
+ *
78
+ * A mismatch is not a formatting quirk. It is the exact shape of the cap-bypass
79
+ * attack, so it fails closed and says so.
6
80
  */
81
+ export function assertDeclaredDecimals(declared, trusted) {
82
+ if (declared === undefined || declared === null)
83
+ return; // absent is fine, we use trusted
84
+ if (typeof declared !== 'number' || !Number.isInteger(declared) || declared < 0 || declared > 36) {
85
+ throw new Error(`x402: malformed requiredDecimals (${String(declared)}). Refusing to derive a payment amount.`);
86
+ }
87
+ if (declared !== trusted) {
88
+ throw new Error(`x402 blocked: the endpoint declared ${declared} decimals for this asset but its ` +
89
+ `on-chain value is ${trusted}. This is how a payment cap is bypassed, so the payment was refused.`);
90
+ }
91
+ }
7
92
  /** Convert a human-readable decimal amount (e.g. "0.01") to base/atomic units. */
8
93
  function toBaseUnits(amount, decimals) {
9
94
  if (!/^\d+(\.\d+)?$/.test(amount)) {
10
95
  throw new Error(`Invalid amount "${amount}".`);
11
96
  }
97
+ if (!Number.isInteger(decimals) || decimals < 0 || decimals > 36) {
98
+ throw new Error(`Invalid decimals "${decimals}".`);
99
+ }
12
100
  const [whole, frac = ''] = amount.split('.');
13
101
  const fracPadded = frac.slice(0, decimals).padEnd(decimals, '0');
14
102
  return BigInt(whole + fracPadded).toString();
@@ -19,30 +107,31 @@ function toBaseUnits(amount, decimals) {
19
107
  * CRITICAL: x402 `maxAmountRequired` is ALREADY denominated in base/atomic
20
108
  * units (e.g. "10000" = 0.01 USDC at 6 decimals). It MUST be used directly.
21
109
  * Running it through parseUnits() re-scales it by 10**decimals and overpays by
22
- * that factor (1,000,000x for USDC). This mirrors the pay_x402 tool, which has
23
- * always used maxAmountRequired directly.
110
+ * that factor (1,000,000x for USDC).
24
111
  *
25
- * Also enforces a hard ceiling so a malformed or malicious 402 response can
26
- * never authorize a transfer larger than `maxAutopayHuman` units of the asset.
112
+ * Enforces a hard ceiling so a malformed or malicious 402 response can never
113
+ * authorize a transfer larger than `maxAutopayHuman` units of the asset.
27
114
  *
28
115
  * @param accept the chosen x402 accept option
29
116
  * @param maxAutopayHuman max auto-pay, in human-readable units of the asset
117
+ * @param trustedDecimals decimals resolved from a registry or the token
118
+ * contract. MUST NOT be taken from the 402 response.
30
119
  */
31
- export function deriveX402Payment(accept, maxAutopayHuman) {
32
- const decimals = accept.requiredDecimals || 6;
120
+ export function deriveX402Payment(accept, maxAutopayHuman, trustedDecimals) {
33
121
  // Standard x402 uses `asset` for the token address; AgentWallet's own server
34
122
  // currently emits it under extra.token. Prefer the standard field, fall back.
35
123
  const tokenAddress = accept.asset || accept.extra?.token || '';
124
+ assertDeclaredDecimals(accept.requiredDecimals, trustedDecimals);
36
125
  const rawAmount = accept.maxAmountRequired;
37
126
  if (!/^\d+$/.test(rawAmount)) {
38
127
  throw new Error(`x402 auto-pay: invalid maxAmountRequired "${rawAmount}" (expected an integer base-unit amount).`);
39
128
  }
40
- const capRaw = toBaseUnits(maxAutopayHuman, decimals);
129
+ const capRaw = toBaseUnits(maxAutopayHuman, trustedDecimals);
41
130
  if (BigInt(rawAmount) > BigInt(capRaw)) {
42
131
  throw new Error(`x402 auto-pay blocked: required amount (${rawAmount} base units) exceeds the ` +
43
132
  `AGENTWALLET_MAX_AUTOPAY cap of ${maxAutopayHuman}. Raise AGENTWALLET_MAX_AUTOPAY to allow it.`);
44
133
  }
45
- return { tokenAddress, rawAmount, decimals };
134
+ return { tokenAddress, rawAmount, decimals: trustedDecimals };
46
135
  }
47
136
  /**
48
137
  * Returns true if an x402 required amount (already in base/atomic units) is
@@ -54,13 +143,13 @@ export function deriveX402Payment(accept, maxAutopayHuman) {
54
143
  * can return a structured rejection to the calling agent.
55
144
  *
56
145
  * @param rawAmountRequired x402 maxAmountRequired, already in base units
57
- * @param decimals token decimals
146
+ * @param trustedDecimals token decimals from a trusted source, NOT the 402 body
58
147
  * @param capHuman cap in human-readable units of the asset (e.g. "1")
59
148
  */
60
- export function isWithinCap(rawAmountRequired, decimals, capHuman) {
149
+ export function isWithinCap(rawAmountRequired, trustedDecimals, capHuman) {
61
150
  if (!/^\d+$/.test(rawAmountRequired)) {
62
151
  throw new Error(`Invalid maxAmountRequired "${rawAmountRequired}" (expected an integer base-unit amount).`);
63
152
  }
64
- const capRaw = toBaseUnits(capHuman, decimals);
153
+ const capRaw = toBaseUnits(capHuman, trustedDecimals);
65
154
  return BigInt(rawAmountRequired) <= BigInt(capRaw);
66
155
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "agentwallet-mcp",
3
3
  "mcpName": "io.github.hifriendbot/agentwallet",
4
- "version": "1.10.1",
4
+ "version": "1.10.2",
5
5
  "description": "MCP server for AgentWallet: self-custody local signing on EVM and Solana, or hosted wallets. Sign, broadcast, pay x402 invoices, and accept x402 payments.",
6
6
  "type": "module",
7
7
  "main": "build/index.js",
@@ -16,7 +16,7 @@
16
16
  "scripts": {
17
17
  "build": "tsc",
18
18
  "start": "node build/index.js",
19
- "test": "node test/x402-payment.test.mjs && node test/local-mode.test.mjs && node test/ssrf-guard.test.mjs",
19
+ "test": "node test/x402-payment.test.mjs && node test/token-cap.test.mjs && node test/local-mode.test.mjs && node test/ssrf-guard.test.mjs",
20
20
  "prepublishOnly": "npm run build"
21
21
  },
22
22
  "keywords": [