agentwallet-mcp 1.10.4 → 1.10.5

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.
@@ -122,19 +122,46 @@ function assertWithinSolCap(lamports) {
122
122
  * AGENTWALLET_MAX_TX_TOKEN on the EVM side and uses the same variable so an
123
123
  * operator sets one token cap, not one per chain family.
124
124
  *
125
- * Decimals for the cap come from the trusted table when the mint is known.
126
- * The caller-supplied value is not used for the ceiling: an inflated one would
127
- * inflate the cap exactly like the x402 bypass. When the mint is unknown we
128
- * assume 6, the tightest common value, so it fails closed.
125
+ * Decimals for the cap come from the trusted table when the mint is known, then
126
+ * from the mint account itself, and finally 0. The caller-supplied value is
127
+ * never used for the ceiling: an inflated one would inflate the cap exactly
128
+ * like the x402 bypass.
129
+ *
130
+ * 0 is the only safe fallback. Assuming 6 for an unknown mint is what AW-001
131
+ * exploited: for a mint with d decimals, a ceiling evaluated at 6 is 10**(6-d)
132
+ * times too permissive, and SPL mints with 0 to 5 decimals are common.
129
133
  */
130
- function assertWithinSplCap(mint, rawAmount) {
134
+ const splDecimalsCache = new Map();
135
+ async function resolveMintDecimals(mint, chainId) {
136
+ const known = lookupTrustedDecimals(900, mint);
137
+ if (typeof known === 'number')
138
+ return known;
139
+ const cached = splDecimalsCache.get(mint);
140
+ if (typeof cached === 'number')
141
+ return cached;
142
+ try {
143
+ const conn = connection(chainId);
144
+ const info = await conn.getParsedAccountInfo(new PublicKey(mint));
145
+ const data = info?.value?.data;
146
+ const d = Number(data?.parsed?.info?.decimals);
147
+ if (Number.isInteger(d) && d >= 0 && d <= 36) {
148
+ splDecimalsCache.set(mint, d);
149
+ return d;
150
+ }
151
+ }
152
+ catch {
153
+ // Unreachable RPC or non-standard mint: fall through to the safe floor.
154
+ }
155
+ return 0;
156
+ }
157
+ async function assertWithinSplCap(mint, rawAmount, chainId = 900) {
131
158
  const cap = (process.env.AGENTWALLET_MAX_TX_TOKEN || '').trim();
132
159
  if (!cap)
133
160
  return;
134
161
  if (!/^\d+(\.\d+)?$/.test(cap)) {
135
162
  throw new Error(`AGENTWALLET_MAX_TX_TOKEN must be a decimal number, got "${cap}".`);
136
163
  }
137
- const decimals = lookupTrustedDecimals(900, mint) ?? 6;
164
+ const decimals = await resolveMintDecimals(mint, chainId);
138
165
  const [whole, frac = ''] = cap.split('.');
139
166
  const capRaw = BigInt(whole + frac.padEnd(decimals, '0').slice(0, decimals));
140
167
  if (rawAmount > capRaw) {
@@ -252,7 +279,7 @@ function createAtaIdempotentIx(payer, ata, owner, mint, tokenProgram) {
252
279
  export async function localSplTransfer(mintStr, to, rawAmount, decimals, chainId = 900) {
253
280
  // Checked before any RPC round trip: a blocked transfer should cost nothing
254
281
  // and should not announce itself to the RPC provider first.
255
- assertWithinSplCap(mintStr, BigInt(rawAmount));
282
+ await assertWithinSplCap(mintStr, BigInt(rawAmount), chainId);
256
283
  const conn = connection(chainId);
257
284
  const payer = getSolanaKeypair();
258
285
  const mint = new PublicKey(mintStr);
@@ -178,11 +178,48 @@ export async function localTokenBalance(chainId, token) {
178
178
  * waiting to happen.
179
179
  *
180
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.
181
+ *
182
+ * Decimals decide the ceiling, so getting them wrong breaks the guard. They are
183
+ * resolved in this order: the trusted registry, then the token's own decimals()
184
+ * on chain (cached), and finally 0. Assuming 0 for an unresolvable token is the
185
+ * only value that cannot fail open: any real token has decimals >= 0, so a cap
186
+ * evaluated at 0 is never larger than the true one.
187
+ *
188
+ * Do not "assume 6 because it is the smallest in common use". It is not: GUSD
189
+ * and EURS use 2, and some tokens use 0. For a token with d decimals, a ceiling
190
+ * evaluated at 6 is 10**(6-d) times too permissive, which is how AW-001 let a
191
+ * 2-decimal token move 10,000x the configured cap.
184
192
  */
185
- function assertWithinTokenCap(chainId, token, data) {
193
+ const decimalsCache = new Map();
194
+ async function resolveTokenDecimals(chainId, token) {
195
+ const known = lookupTrustedDecimals(chainId, token);
196
+ if (typeof known === 'number')
197
+ return known;
198
+ const key = `${chainId}:${token.toLowerCase()}`;
199
+ const cached = decimalsCache.get(key);
200
+ if (typeof cached === 'number')
201
+ return cached;
202
+ try {
203
+ const { pub } = clients(chainId);
204
+ const onChain = await pub.readContract({
205
+ address: token,
206
+ abi: ERC20_ABI,
207
+ functionName: 'decimals',
208
+ });
209
+ const d = Number(onChain);
210
+ // Reject nonsense rather than trusting it; an out-of-range value would
211
+ // widen the ceiling exactly like the old assumption did.
212
+ if (Number.isInteger(d) && d >= 0 && d <= 36) {
213
+ decimalsCache.set(key, d);
214
+ return d;
215
+ }
216
+ }
217
+ catch {
218
+ // Unreachable RPC, non-standard token, anything at all: fall through to 0.
219
+ }
220
+ return 0;
221
+ }
222
+ async function assertWithinTokenCap(chainId, token, data) {
186
223
  const cap = (process.env.AGENTWALLET_MAX_TX_TOKEN || '').trim();
187
224
  if (!cap)
188
225
  return;
@@ -200,7 +237,7 @@ function assertWithinTokenCap(chainId, token, data) {
200
237
  if (word.length !== 64)
201
238
  return; // malformed; leave it to the node to reject
202
239
  const amount = BigInt('0x' + word);
203
- const decimals = lookupTrustedDecimals(chainId, token) ?? 6;
240
+ const decimals = await resolveTokenDecimals(chainId, token);
204
241
  const [whole, frac = ''] = cap.split('.');
205
242
  const capRaw = BigInt(whole + frac.padEnd(decimals, '0').slice(0, decimals));
206
243
  if (amount > capRaw) {
@@ -213,7 +250,7 @@ function assertWithinTokenCap(chainId, token, data) {
213
250
  export async function localSend(chainId, to, valueWei, data) {
214
251
  assertWithinNativeCap(valueWei);
215
252
  if (data && data.length >= 10)
216
- assertWithinTokenCap(chainId, to, data);
253
+ await assertWithinTokenCap(chainId, to, data);
217
254
  const { wallet } = clients(chainId);
218
255
  const hash = await wallet.sendTransaction({
219
256
  to,
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.4",
4
+ "version": "1.10.5",
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/token-cap.test.mjs && node test/spl-cap.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/spl-cap.test.mjs && node test/unknown-decimals-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": [