agentwallet-mcp 1.12.4 → 1.12.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.
package/README.md CHANGED
@@ -43,7 +43,7 @@ Use `AGENTWALLET_KEYFILE=/path/to/key` instead if you would rather keep the key
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
 
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`, `approve`, `increaseAllowance` and Permit2 `approve`, the approvals because an unbounded allowance is a drain waiting to happen. Any other calldata sent to a token contract or to Permit2 is refused while the cap is set, because the guard cannot price it; set `AGENTWALLET_ALLOW_UNKNOWN_TOKEN_CALLS=1` to allow such calls deliberately. Calls to other contracts (routers, bridges) pass, since they can only pull what an approval already allowed. Decimals are resolved locally from the trusted registry, then the token's own `decimals()`; a token that resolves neither way is evaluated at 0 decimals, the only value that cannot fail open.
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`, `approve`, `increaseAllowance` and Permit2 `approve`, the approvals because an unbounded allowance is a drain waiting to happen. Any other calldata sent to a token contract or to Permit2 is refused while the cap is set, because the guard cannot price it; set `AGENTWALLET_ALLOW_UNKNOWN_TOKEN_CALLS=1` to allow such calls deliberately. Calls to other contracts (routers, bridges) pass, since they can only pull what an approval already allowed; a contract counts as "other" only when it demonstrably declines `decimals()`, so an absurd or malformed answer, or an RPC that cannot be reached, refuses rather than allows. Decimals are resolved locally from the trusted registry, then the token's own `decimals()`; a token that resolves neither way is evaluated at 0 decimals, the only value that cannot fail open.
47
47
 
48
48
  ### Solana local signing
49
49
 
package/build/index.js CHANGED
@@ -467,7 +467,7 @@ function resolveChainId(network) {
467
467
  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
468
  const server = new McpServer({
469
469
  name: 'agentwallet',
470
- version: '1.12.4',
470
+ version: '1.12.5',
471
471
  }, {
472
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
473
 
@@ -15,7 +15,7 @@
15
15
  * on the custodial path and say so explicitly rather than silently downgrading.
16
16
  */
17
17
  import { readFileSync } from 'node:fs';
18
- import { createPublicClient, createWalletClient, http, encodeFunctionData, parseAbi, formatUnits, } from 'viem';
18
+ import { createPublicClient, createWalletClient, http, encodeFunctionData, parseAbi, formatUnits, BaseError, ContractFunctionZeroDataError, ContractFunctionRevertedError, ExecutionRevertedError, AbiDecodingDataSizeTooSmallError, AbiDecodingDataSizeInvalidError, } from 'viem';
19
19
  import { privateKeyToAccount } from 'viem/accounts';
20
20
  import { lookupTrustedDecimals } from './x402-payment.js';
21
21
  import { typedDataFor } from './x402-eip3009.js';
@@ -195,8 +195,7 @@ export async function localTokenBalance(chainId, token) {
195
195
  const decimalsCache = new Map();
196
196
  /**
197
197
  * Decimals for `token`: the trusted registry, then the contract's own
198
- * decimals() (cached on success). Null when the address does not answer like
199
- * an ERC-20 at all, which is how the guard tells a token from a router.
198
+ * decimals() (cached on success). See DecimalsProbe for the other outcomes.
200
199
  */
201
200
  async function probeTokenDecimals(chainId, token) {
202
201
  const known = lookupTrustedDecimals(chainId, token);
@@ -206,28 +205,47 @@ async function probeTokenDecimals(chainId, token) {
206
205
  const cached = decimalsCache.get(key);
207
206
  if (typeof cached === 'number')
208
207
  return cached;
208
+ let onChain;
209
209
  try {
210
210
  const { pub } = clients(chainId);
211
- const onChain = await pub.readContract({
211
+ onChain = await pub.readContract({
212
212
  address: token,
213
213
  abi: ERC20_ABI,
214
214
  functionName: 'decimals',
215
215
  });
216
- const d = Number(onChain);
217
- // Reject nonsense rather than trusting it; an out-of-range value would
218
- // widen the ceiling exactly like the old assumption did.
219
- if (Number.isInteger(d) && d >= 0 && d <= 36) {
220
- decimalsCache.set(key, d);
221
- return d;
222
- }
223
216
  }
224
- catch {
225
- // Unreachable RPC, non-standard token, not a token at all.
217
+ catch (err) {
218
+ // viem wraps every readContract failure in ContractFunctionExecutionError;
219
+ // the cause says whether the contract itself declined (revert, empty
220
+ // return: not an ERC-20) or the answer never arrived (RPC down, timeout,
221
+ // malformed response). Only the first is evidence of anything.
222
+ const declined = err instanceof BaseError && err.walk((e) => e instanceof ContractFunctionZeroDataError
223
+ || e instanceof ContractFunctionRevertedError
224
+ || e instanceof ExecutionRevertedError // nodes that report a revert as -32000 rather than 3
225
+ ) !== null;
226
+ if (declined)
227
+ return 'not-a-token';
228
+ // A decode failure on non-empty data means the contract answered
229
+ // decimals() with something that is not a uint8: a token, just a broken one.
230
+ const decodeFailed = err instanceof BaseError && err.walk((e) => e instanceof AbiDecodingDataSizeTooSmallError || e instanceof AbiDecodingDataSizeInvalidError) !== null;
231
+ return decodeFailed ? 'unusable' : 'unreachable';
226
232
  }
227
- return null;
233
+ const d = Number(onChain);
234
+ // Reject nonsense rather than trusting it; an out-of-range value would
235
+ // widen the ceiling exactly like the old assumption did.
236
+ if (Number.isInteger(d) && d >= 0 && d <= 36) {
237
+ decimalsCache.set(key, d);
238
+ return d;
239
+ }
240
+ return 'unusable';
228
241
  }
242
+ /**
243
+ * Decimals to price a known transfer or approval at. Anything short of a
244
+ * usable answer is evaluated at 0, the only value that cannot fail open.
245
+ */
229
246
  async function resolveTokenDecimals(chainId, token) {
230
- return (await probeTokenDecimals(chainId, token)) ?? 0;
247
+ const probe = await probeTokenDecimals(chainId, token);
248
+ return typeof probe === 'number' ? probe : 0;
231
249
  }
232
250
  /** Canonical Permit2, the same address on every EVM chain it is deployed to. */
233
251
  const PERMIT2 = '0x000000000022d473030f116ddee9f6b43ac78ba3';
@@ -263,12 +281,18 @@ async function assertWithinTokenCap(chainId, to, data) {
263
281
  if (!priced) {
264
282
  if (process.env.AGENTWALLET_ALLOW_UNKNOWN_TOKEN_CALLS === '1')
265
283
  return;
266
- const looksLikeToken = isPermit2 || (await probeTokenDecimals(chainId, to)) !== null;
267
- if (looksLikeToken) {
268
- throw new Error(`Blocked by local guard: calldata selector 0x${selector} sent to ` +
269
- `${isPermit2 ? 'Permit2' : 'a token contract'} is not one AGENTWALLET_MAX_TX_TOKEN can price, ` +
270
- `so it is refused rather than let through uncapped. Use transfer, transferFrom, approve, ` +
271
- `increaseAllowance or Permit2 approve, or set AGENTWALLET_ALLOW_UNKNOWN_TOKEN_CALLS=1 to allow it deliberately.`);
284
+ // Only a contract that demonstrably declines decimals() is let through.
285
+ // "Answered nonsense" and "could not ask" both refuse: the first is a
286
+ // token with a broken or hostile decimals(), the second is no evidence at
287
+ // all, and this guard does not allow on no evidence (2026-09-26 report).
288
+ const probe = isPermit2 ? 'unusable' : await probeTokenDecimals(chainId, to);
289
+ if (probe !== 'not-a-token') {
290
+ const why = probe === 'unreachable'
291
+ ? `the RPC for chain ${chainId} could not be reached to classify the target, so the guard cannot tell a token from a router`
292
+ : `the target is ${isPermit2 ? 'Permit2' : 'a token contract'} and this calldata is not one AGENTWALLET_MAX_TX_TOKEN can price`;
293
+ throw new Error(`Blocked by local guard: calldata selector 0x${selector} refused rather than let through uncapped: ${why}. ` +
294
+ `Use transfer, transferFrom, approve, increaseAllowance or Permit2 approve, ` +
295
+ `or set AGENTWALLET_ALLOW_UNKNOWN_TOKEN_CALLS=1 to allow it deliberately.`);
272
296
  }
273
297
  return; // an ordinary contract call; it can only pull what an approval already allowed
274
298
  }
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.12.4",
4
+ "version": "1.12.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/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/local-mode.test.mjs && node test/ssrf-guard.test.mjs",
19
+ "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",
20
20
  "prepublishOnly": "npm run build"
21
21
  },
22
22
  "keywords": [