agentwallet-mcp 1.13.4 → 1.13.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
@@ -66,7 +66,7 @@ Set either key, or both. They are independent: run EVM locally and Solana hosted
66
66
 
67
67
  Local signing uses [viem](https://viem.sh) for EVM and `@solana/web3.js` for Solana, plus `bs58` for key parsing. SPL instructions are built by hand rather than with `@solana/spl-token`, because that package pulls in `bigint-buffer`, which carries a high severity buffer overflow advisory. A wallet has no business shipping that to save a dozen lines of instruction encoding.
68
68
 
69
- `npm audit` currently reports issues inside `@modelcontextprotocol/sdk`'s HTTP transport dependencies. This server speaks stdio, so that code never loads, and the SDK is not something this package can patch. Run the audit yourself. Publishing a tree you can inspect is the point.
69
+ `npm audit` reports no advisories as of 1.13.5; the overrides in package.json keep two advisories under `@solana/web3.js`'s jayson closed. Earlier releases carried advisories inside `@modelcontextprotocol/sdk`'s HTTP transport dependencies. This server speaks stdio, so that code never loads, and the SDK is not something this package can patch. Run the audit yourself. Publishing a tree you can inspect is the point.
70
70
 
71
71
  <p align="center">
72
72
  <img src="assets/demo.svg" alt="AgentWallet demo, AI agent pays x402 invoice automatically" width="800">
package/build/index.js CHANGED
@@ -13,14 +13,15 @@ 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, finalUrlOf } from './ssrf-guard.js';
16
- import { deriveX402Payment, isWithinCap, lookupTrustedDecimals, assertDeclaredDecimals, nativeDecimals, SOLANA_CHAIN_IDS, assetLabel, autopayAssetAllowed, autopayEnvCap, effectiveAutopayCap, maxAuthWindowSeconds, parseDecimalPins, approvalRefusal, authorizationResource, } from './x402-payment.js';
16
+ import { deriveX402Payment, isWithinCap, lookupTrustedDecimals, assertDeclaredDecimals, nativeDecimals, SOLANA_CHAIN_IDS, assetLabel, autopayAssetAllowed, autopayEnvCap, effectiveAutopayCap, maxAuthWindowSeconds, parseDecimalPins, approvalRefusal, authorizationResource, resolveNetworkChainId, normalizeRawAmount, parseAutopayAssets, } from './x402-payment.js';
17
+ import { createHash } from 'node:crypto';
17
18
  import { isLocalMode, getLocalAddress, localWalletRecord, localSend, localNativeBalance, localTokenBalance, localSignMessage, resolveRpcUrl, } from './local-wallet.js';
18
19
  import { localSignAuthorization, localEthCall, localGetCode, localSignPermit2Upto } from './local-wallet.js';
19
20
  import { WRAPPED_NATIVE } from './wrapped-native.js';
20
21
  import { buildUptoAuthorization, uptoPayload, permit2AllowanceCalldata, permit2ApproveCalldata, decodeUint, PERMIT2_ADDRESS, } from './x402-permit2.js';
21
22
  import { assessTokenRisk } from './token-risk.js';
22
23
  import { isLegacyAgentWalletAccept, requiredAmount, buildAuthorization, buildPaymentPayload, buildPaymentPayloadRaw, paymentHeaderFor, parsePaymentRequired, parseSettlement, pickOption, knownTokenDomain, parseEip7702Delegation, erc1271ProbeCalldata, classifyErc1271Probe, } from './x402-eip3009.js';
23
- import { isSolanaLocalMode, getSolanaAddress, solanaWalletRecord, localSolBalance, localSplBalance, localSolTransfer, localSplTransfer, resolveSolanaRpc, } from './local-solana.js';
24
+ import { isSolanaLocalMode, getSolanaAddress, solanaWalletRecord, localSolBalance, localSplBalance, localSolTransfer, localSplTransfer, resolveSolanaRpc, mintDecimalsStrict, } from './local-solana.js';
24
25
  /** True when either chain family is running non-custodially. */
25
26
  function anyLocalMode() {
26
27
  return isLocalMode() || isSolanaLocalMode();
@@ -76,7 +77,7 @@ async function routeLocally(path, method, body) {
76
77
  const [rawPath, rawQs = ''] = path.split('?');
77
78
  const qs = new URLSearchParams(rawQs);
78
79
  // POST /wallets/{id}/send
79
- if (method === 'POST' && /^\/wallets\/[^/]*\/send$/.test(rawPath)) {
80
+ if (method === 'POST' && /^\/wallets\/[^/]+\/send$/.test(rawPath)) {
80
81
  const chainId = localChainId(body, qs);
81
82
  const solanaOp = SOLANA_CHAIN_IDS.has(chainId) || Boolean(body?.token_mint);
82
83
  if (solanaOp) {
@@ -209,7 +210,13 @@ async function api(path, method = 'GET', body, extraHeaders) {
209
210
  single choke point every funds operation already passes through means no
210
211
  tool can quietly stay custodial because someone forgot to update it. */
211
212
  if (anyLocalMode()) {
212
- const handled = await routeLocally(path, method, body);
213
+ let handled;
214
+ try {
215
+ handled = await routeLocally(path, method, body);
216
+ }
217
+ catch (e) {
218
+ throw new Error(redactRpcSecrets(e)); // viem puts the RPC URL, key and all, in its messages
219
+ }
213
220
  if (handled !== NOT_HANDLED)
214
221
  return handled;
215
222
  }
@@ -221,15 +228,39 @@ async function api(path, method = 'GET', body, extraHeaders) {
221
228
  if (API_USER && API_PASS) {
222
229
  headers['Authorization'] = 'Basic ' + Buffer.from(`${API_USER}:${API_PASS}`).toString('base64');
223
230
  }
224
- const options = { method, headers };
231
+ // The API host has no reason to send a wallet operation elsewhere: a
232
+ // redirect is refused instead of followed, so a request body is never
233
+ // replayed to another origin and another origin's answer is never returned
234
+ // as the API's (2026-10-04 audit).
235
+ const options = { method, headers, redirect: 'manual' };
225
236
  if (body && method !== 'GET') {
226
237
  options.body = JSON.stringify(body);
227
238
  }
228
239
  const res = await fetch(url, options);
229
- const data = await res.json();
230
- // Handle 402 Payment Required — auto-pay if a wallet or a local key is configured
231
- if (res.status === 402 && (X402_WALLET_ID || anyLocalMode()) && !extraHeaders?.['X-PAYMENT'] && !extraHeaders?.['X-AGW-SKIP-X402']) {
232
- return handleX402Payment(data, path, method, body);
240
+ if (res.status >= 300 && res.status < 400) {
241
+ throw new Error(`The AgentWallet API host answered ${path} with a redirect (HTTP ${res.status}); refusing to follow it.`);
242
+ }
243
+ const text = await res.text();
244
+ if (text.length > API_MAX_RESPONSE_CHARS)
245
+ throw new Error(`The AgentWallet API host returned an oversized response (${text.length} characters); refusing to parse it.`);
246
+ let data;
247
+ try {
248
+ data = JSON.parse(text);
249
+ }
250
+ catch {
251
+ throw new Error(`The AgentWallet API host returned a non-JSON response (HTTP ${res.status}).`);
252
+ }
253
+ // A 402 from the API host is paid automatically only for a HOSTED wallet the
254
+ // operator named with AGENTWALLET_WALLET_ID. In local mode the host is not a
255
+ // custodian and must never be able to make the local key sign: a 402 is
256
+ // returned as an error and the agent can call pay_x402 explicitly, which
257
+ // reports what it does (2026-10-04 audit).
258
+ if (res.status === 402 && !extraHeaders?.['X-PAYMENT'] && !extraHeaders?.['X-AGW-SKIP-X402']) {
259
+ if (anyLocalMode()) {
260
+ throw new Error(`The AgentWallet API host asked for payment on ${path}; nothing is paid automatically in local mode. If you trust it, call pay_x402 with that URL so the payment is explicit and reported.`);
261
+ }
262
+ if (X402_WALLET_ID)
263
+ return handleX402Payment(data, path, method, body);
233
264
  }
234
265
  if (!res.ok) {
235
266
  const error = data.error || `HTTP ${res.status}`;
@@ -397,6 +428,7 @@ function isValidAddress(address) {
397
428
  /** Request headers the caller may not set on pay_x402: Host would rename the TLS peer, the rest belong to the client. */
398
429
  const FORBIDDEN_REQUEST_HEADERS = new Set(['host', 'content-length', 'transfer-encoding', 'connection', 'upgrade', 'expect', 'te', 'trailer', 'keep-alive']);
399
430
  /** Authorizations signed in this process, keyed by endpoint and requirement, kept until they expire. */
431
+ const signingInFlight = new Map();
400
432
  const signedPayments = new Map();
401
433
  function looksLikeTxHash(v) {
402
434
  return typeof v === 'string' && (/^0x[0-9a-fA-F]{64}$/.test(v) || /^[1-9A-HJ-NP-Za-km-z]{86,88}$/.test(v));
@@ -417,6 +449,17 @@ function redactUrl(u) {
417
449
  return u;
418
450
  }
419
451
  }
452
+ const API_MAX_RESPONSE_CHARS = 8 * 1024 * 1024;
453
+ /* Error text from the local signing path with every URL reduced to its origin
454
+ and viem's "URL:" / "Request body:" lines dropped: an RPC endpoint carries
455
+ its API key in the path or query, and a tool result is the agent's context
456
+ and the client's log (2026-10-04 audit). */
457
+ function redactRpcSecrets(err) {
458
+ const e = err;
459
+ let msg = e?.shortMessage ? [e.shortMessage, e.details].filter(Boolean).join(' ') : String(e?.message ?? err);
460
+ msg = msg.split(/\r?\n/).filter(l => !/^\s*(URL|Request body|Request Arguments|Raw Call Arguments)\s*:/i.test(l)).join('\n').trim();
461
+ return msg.replace(/[a-z][a-z0-9+.-]*:\/\/[^\s"'<>)\]]+/gi, (u) => redactUrl(u));
462
+ }
420
463
  /**
421
464
  * Decimals to scale a human amount by. Always the token's own value (registry,
422
465
  * then decimals() on chain); a caller-supplied value must agree or the call
@@ -425,12 +468,12 @@ function redactUrl(u) {
425
468
  async function verifiedDecimals(chainId, token, given) {
426
469
  let trusted = null;
427
470
  if (isSolanaChain(chainId)) {
428
- trusted = lookupTrustedDecimals(chainId, token);
429
- if (trusted === null) {
430
- if (given === undefined)
431
- throw new Error(`Decimals for mint ${token} are not in the registry; pass decimals explicitly.`);
432
- return given; // the token program verifies it on-chain (TransferChecked)
433
- }
471
+ // The mint itself says how many decimals it has; a caller's value only
472
+ // ever confirms it (2026-10-04 audit: the substituted byte could not catch
473
+ // an amount already scaled by a wrong guess).
474
+ trusted = await mintDecimalsStrict(token, chainId);
475
+ if (trusted === null)
476
+ throw new Error(`Could not read the decimals of mint ${token} from the chain; refusing to guess the scale of the amount.`);
434
477
  }
435
478
  else {
436
479
  trusted = await resolveTrustedDecimals(chainId, token);
@@ -444,15 +487,23 @@ async function verifiedDecimals(chainId, token, given) {
444
487
  function validateGuardEnv() {
445
488
  parseDecimalPins(process.env.AGENTWALLET_TOKEN_DECIMALS); // throws on a malformed pin
446
489
  for (const name of ['AGENTWALLET_MAX_TX_NATIVE', 'AGENTWALLET_MAX_TX_TOKEN', 'AGENTWALLET_MAX_TX_SOL', 'AGENTWALLET_MAX_AUTOPAY']) {
447
- const v = (process.env[name] || '').trim();
490
+ const raw = process.env[name];
491
+ const v = (raw || '').trim();
492
+ // Set but blank is a typo, not "no cap": refuse to start rather than run uncapped.
493
+ if (raw !== undefined && v === '')
494
+ throw new Error(`${name} is set but blank. Set it to a number or unset it.`);
448
495
  if (v && !/^\d+(\.\d+)?$/.test(v))
449
496
  throw new Error(`${name} must be a decimal number, got "${v}".`);
450
497
  }
451
- for (const e of (process.env.AGENTWALLET_AUTOPAY_ASSETS || '').split(',').map(s => s.trim()).filter(Boolean)) {
452
- if (e !== 'native' && !/^0x[0-9a-fA-F]{40}$/.test(e) && !/^[1-9A-HJ-NP-Za-km-z]{32,44}$/.test(e)) {
453
- throw new Error(`AGENTWALLET_AUTOPAY_ASSETS entry "${e}" is not "native", an EVM address or an SPL mint.`);
498
+ for (const e of parseAutopayAssets(process.env.AGENTWALLET_AUTOPAY_ASSETS)) {
499
+ if (e.asset !== 'native' && !/^0x[0-9a-f]{40}$/.test(e.asset) && !/^[1-9A-HJ-NP-Za-km-z]{32,44}$/.test(e.asset)) {
500
+ throw new Error(`AGENTWALLET_AUTOPAY_ASSETS entry "${e.asset}" is not "native", an EVM address or an SPL mint (optionally prefixed "chainId:").`);
454
501
  }
455
502
  }
503
+ const t = (process.env.AGENTWALLET_X402_MAX_TIMEOUT || '').trim();
504
+ if (t && (!/^\d+$/.test(t) || Number(t) < 60 || Number(t) > 31_536_000)) {
505
+ throw new Error(`AGENTWALLET_X402_MAX_TIMEOUT must be a whole number of seconds between 60 and 31536000, got "${t}".`);
506
+ }
456
507
  }
457
508
  // ─── EVM Helpers ──────────────────────────────────────────────────
458
509
  /**
@@ -503,48 +554,13 @@ function encodeUint256(value) {
503
554
  }
504
555
  // ─── Server ──────────────────────────────────────────────────────
505
556
  // ─── x402 Network Mapping ──────────────────────────────────────
506
- const X402_NETWORKS = {
507
- 'ethereum': 1,
508
- 'base': 8453,
509
- 'base-sepolia': 84532,
510
- 'polygon': 137,
511
- 'arbitrum': 42161,
512
- 'optimism': 10,
513
- 'bsc': 56,
514
- 'avalanche': 43114,
515
- 'zora': 7777777,
516
- 'pulsechain': 369,
517
- 'solana': 900,
518
- 'solana-devnet': 901,
519
- };
520
- // Solana genesis hash prefixes for CAIP-2
521
- const SOLANA_GENESIS = {
522
- 900: '5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp', // mainnet-beta
523
- 901: 'EtWTRABZaYq6iMfeYKouRu166VU2xqa1', // devnet
524
- };
525
557
  /**
526
- * Resolve an x402 network identifier to a chain ID.
527
- * Supports plain names ("base"), CAIP-2 format ("eip155:8453"), and raw chain IDs ("8453").
558
+ * Resolve an x402 network identifier to a chain ID: plain names ("base"),
559
+ * CAIP-2 ("eip155:8453", "solana:<genesis prefix>") and raw ids ("8453").
560
+ * The table and the parsing live in x402-payment.ts so they can be tested.
528
561
  */
529
562
  function resolveChainId(network) {
530
- const lower = network.toLowerCase();
531
- if (X402_NETWORKS[lower])
532
- return X402_NETWORKS[lower];
533
- const caipMatch = network.match(/^eip155:(\d+)$/);
534
- if (caipMatch)
535
- return parseInt(caipMatch[1], 10);
536
- // Solana CAIP-2: solana:<genesis_prefix>
537
- const solanaMatch = network.match(/^solana:(.+)$/);
538
- if (solanaMatch) {
539
- for (const [chainId, genesis] of Object.entries(SOLANA_GENESIS)) {
540
- if (solanaMatch[1] === genesis)
541
- return parseInt(chainId, 10);
542
- }
543
- }
544
- const num = parseInt(network, 10);
545
- if (!isNaN(num) && num > 0)
546
- return num;
547
- return null;
563
+ return resolveNetworkChainId(network);
548
564
  }
549
565
  // ─── Server ──────────────────────────────────────────────────────
550
566
  /**
@@ -561,7 +577,7 @@ function resolveChainId(network) {
561
577
  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');
562
578
  const server = new McpServer({
563
579
  name: 'agentwallet',
564
- version: '1.13.4',
580
+ version: '1.13.5',
565
581
  }, {
566
582
  instructions: `AgentWallet gives AI agents their own blockchain wallets. ${anyLocalMode()
567
583
  ? '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.'
@@ -1176,6 +1192,13 @@ async function resolveTokenDomain(chainId, asset, extra) {
1176
1192
  }
1177
1193
  return { name, version };
1178
1194
  }
1195
+ /** Text an endpoint wrote, bounded so it cannot flood the agent's context. */
1196
+ function serverText(s) {
1197
+ if (s === undefined || s === null)
1198
+ return null;
1199
+ const t = String(s);
1200
+ return t.length > 300 ? t.slice(0, 300) + '…' : t;
1201
+ }
1179
1202
  /** Sign the authorization with whichever custody mode is active (local key, or the hosted signer). */
1180
1203
  async function signAuthorization(walletId, chainId, asset, domain, auth, approvalId) {
1181
1204
  const r = (await api(`/wallets/${walletId}/x402/authorize`, 'POST', {
@@ -1206,8 +1229,8 @@ server.tool('pay_x402', 'Handle an x402 payment flow. Fetches a URL, and if the
1206
1229
  'parses the payment requirements (v1 body or v2 PAYMENT-REQUIRED header), signs an EIP-3009 ' +
1207
1230
  'TransferWithAuthorization for the "exact" scheme (no gas, nothing broadcast by the payer), and retries ' +
1208
1231
  'the request with the payment header. AgentWallet paywalls are paid by on-chain transfer instead. ' +
1209
- 'The "upto" scheme is refused, never approximated. Returns the final response and the settlement ' +
1210
- 'receipt. Supports the x402 open payment standard (https://x402.org). Set max_payment to cap spend.', {
1232
+ 'The "upto" scheme is settled with a Permit2 authorization bounded by the cap. Returns the final response and the settlement ' +
1233
+ 'receipt. Supports the x402 open payment standard (https://x402.org). max_payment can only lower the operator\'s cap for one call.', {
1211
1234
  url: z.string().url().describe('The URL to access (will handle 402 payment if required)'),
1212
1235
  wallet_id: z.number().int().describe('Wallet ID to pay from'),
1213
1236
  method: z.string().default('GET').describe('HTTP method (GET, POST, PUT, DELETE)'),
@@ -1216,10 +1239,8 @@ server.tool('pay_x402', 'Handle an x402 payment flow. Fetches a URL, and if the
1216
1239
  'endpoint redirects to a different origin they are dropped, not forwarded.'),
1217
1240
  body: z.string().optional().describe('Optional request body for POST/PUT requests'),
1218
1241
  max_payment: z.string().optional().describe('Maximum payment in human-readable format (e.g. "1.00" for 1 USDC). ' +
1219
- 'Rejects payments above this amount. If omitted, a default cap of ' +
1220
- 'AGENTWALLET_MAX_AUTOPAY (default "1") is applied, so an untrusted 402 ' +
1221
- 'endpoint can never authorize an unbounded payment. Set this explicitly ' +
1222
- 'to allow a larger single payment.'),
1242
+ 'Lowers the operator\'s AGENTWALLET_MAX_AUTOPAY ceiling (default "1") for this one call; ' +
1243
+ 'it can never raise it, and a value above the ceiling is ignored and reported as max_payment_ignored.'),
1223
1244
  prefer_chain: z.number().int().optional().describe('Preferred chain ID if the server accepts payment on multiple chains ' +
1224
1245
  '(e.g. 8453 for Base, 1 for Ethereum)'),
1225
1246
  request_approval: z.boolean().optional().describe('Hosted wallets only. When the payment exceeds the cap, email the wallet owner an approve/deny link ' +
@@ -1325,10 +1346,7 @@ server.tool('pay_x402', 'Handle an x402 payment flow. Fetches a URL, and if the
1325
1346
  const option = picked.option;
1326
1347
  const chainId = picked.chainId;
1327
1348
  const isUpto = option.scheme === 'upto';
1328
- const rawAmount = requiredAmount(option);
1329
- if (!/^\d+$/.test(rawAmount)) {
1330
- throw new Error(`x402: invalid amount "${rawAmount}" in the payment requirements (expected integer base units).`);
1331
- }
1349
+ const rawAmount = normalizeRawAmount(requiredAmount(option)); // "0010000" and "10000" are one amount
1332
1350
  // Decimals come from the registry or the token contract, NEVER from the 402
1333
1351
  // body. The endpoint controls what it declares, so trusting it would let a
1334
1352
  // hostile server inflate the cap and misreport the amount shown to the agent.
@@ -1400,8 +1418,8 @@ server.tool('pay_x402', 'Handle an x402 payment flow. Fetches a URL, and if the
1400
1418
  ...overCap,
1401
1419
  error: `Payment of ${amount} ${tokenLabel} exceeds the ${effectiveMax} cap (from ${capSource}).` +
1402
1420
  (max_payment ? '' : ' No max_payment was provided, so the default cap was applied.') +
1403
- (anyLocalMode() ? ' Local mode has no approval channel: raise max_payment, AGENTWALLET_MAX_AUTOPAY or AGENTWALLET_MAX_TX_TOKEN yourself.'
1404
- : ' Pass request_approval=true to email the wallet owner for a one-time approval, or raise the cap.'),
1421
+ (anyLocalMode() ? ' Local mode has no approval channel; the caps are the operator\'s and only the operator can change them. Do not edit the environment or config to raise them.'
1422
+ : ' Pass request_approval=true to email the wallet owner for a one-time approval; the cap itself is the operator\'s to change.'),
1405
1423
  });
1406
1424
  }
1407
1425
  }
@@ -1423,11 +1441,24 @@ server.tool('pay_x402', 'Handle an x402 payment flow. Fetches a URL, and if the
1423
1441
  // The resource is the whole URL including its query: /buy?id=1 and /buy?id=2
1424
1442
  // are different purchases, and reusing the first signature for the second
1425
1443
  // would report a payment the second resource never received.
1426
- const cacheKey = [wallet_id, chainId, tokenAddress.toLowerCase(), option.payTo.toLowerCase(), rawAmount, option.scheme, authorizationResource(url)].join('|');
1444
+ // The method and body are part of what was bought: POST {item:A} and
1445
+ // POST {item:B} to one URL are two purchases. Concurrent calls for one key
1446
+ // wait for the first signature instead of each signing a nonce.
1447
+ const bodyDigest = createHash('sha256').update(reqBody ?? '').digest('hex').slice(0, 16);
1448
+ const cacheKey = [wallet_id, chainId, tokenAddress.toLowerCase(), option.payTo.toLowerCase(), rawAmount, option.scheme, authorizationResource(url), (method || 'GET').toUpperCase(), bodyDigest].join('|');
1449
+ const inFlight = signingInFlight.get(cacheKey);
1450
+ if (inFlight && !fresh_authorization)
1451
+ await inFlight;
1427
1452
  const cachedAuth = fresh_authorization ? undefined : signedPayments.get(cacheKey);
1428
1453
  for (const [k, v] of signedPayments)
1429
1454
  if (v.until <= Date.now())
1430
1455
  signedPayments.delete(k);
1456
+ let releaseSigning = () => { };
1457
+ if (!(cachedAuth && cachedAuth.until > Date.now())) {
1458
+ const mine = new Promise(r => { releaseSigning = r; });
1459
+ // Released when the signature is cached, or after 30 s if this call throws first, so a failure never wedges the key.
1460
+ signingInFlight.set(cacheKey, Promise.race([mine, new Promise(r => setTimeout(r, 30_000).unref())]));
1461
+ }
1431
1462
  if (cachedAuth && cachedAuth.until > Date.now()) {
1432
1463
  // Same endpoint, same requirement, still inside the window: re-send the
1433
1464
  // earlier signature. Its nonce is single-use, so a server that already
@@ -1441,7 +1472,8 @@ server.tool('pay_x402', 'Handle an x402 payment flow. Fetches a URL, and if the
1441
1472
  const allowance = decodeUint(await ethCallHex(chainId, tokenAddress, permit2AllowanceCalldata(payer)));
1442
1473
  if (allowance < BigInt(rawAmount)) {
1443
1474
  if (process.env.AGENTWALLET_PERMIT2_AUTO_APPROVE === '1' && assetPolicy.via === 'stablecoin') {
1444
- const r = (await api(`/wallets/${wallet_id}/send`, 'POST', { to: tokenAddress, value: '0', data: permit2ApproveCalldata(), chain_id: chainId }, skip));
1475
+ // Approve exactly this payment's maximum, never unlimited (2026-10-04 audit).
1476
+ const r = (await api(`/wallets/${wallet_id}/send`, 'POST', { to: tokenAddress, value: '0', data: permit2ApproveCalldata(rawAmount), chain_id: chainId }, skip));
1445
1477
  permit2ApprovalTx = String(r?.tx_hash || '');
1446
1478
  await new Promise(res => setTimeout(res, 4000));
1447
1479
  }
@@ -1498,6 +1530,8 @@ server.tool('pay_x402', 'Handle an x402 payment flow. Fetches a URL, and if the
1498
1530
  : Date.now() + maxAuthWindowSeconds() * 1000; // a broadcast transfer: the hash stays valid, re-sending it never pays twice
1499
1531
  signedPayments.set(cacheKey, { headerName, paymentHeader, txHash, authorization, uptoAuth, payer, until });
1500
1532
  }
1533
+ releaseSigning();
1534
+ signingInFlight.delete(cacheKey);
1501
1535
  // Step 6: Retry with the payment header and read the settlement receipt.
1502
1536
  const retryHeaders = { ...reqHeaders, [headerName]: paymentHeader };
1503
1537
  const retryOptions = {
@@ -1528,7 +1562,7 @@ server.tool('pay_x402', 'Handle an x402 payment flow. Fetches a URL, and if the
1528
1562
  }
1529
1563
  if (retryRes.status === 402) {
1530
1564
  const again = parsePaymentRequired(n => retryRes.headers.get(n), retryParsed);
1531
- retryError = String(again?.error || retryParsed?.error || 'Payment was not accepted (no reason given).');
1565
+ retryError = serverText(String(again?.error || retryParsed?.error || 'Payment was not accepted (no reason given).'));
1532
1566
  if (payerDelegationInfo && /signature/i.test(retryError))
1533
1567
  retryError += ` Likely cause: ${payerDelegationInfo.warning}`;
1534
1568
  }
@@ -1559,7 +1593,8 @@ server.tool('pay_x402', 'Handle an x402 payment flow. Fetches a URL, and if the
1559
1593
  settlement_reported_by_server: settlement,
1560
1594
  authorization: authorization ? { nonce: authorization.nonce, valid_before: authorization.validBefore }
1561
1595
  : (uptoAuth ? { scheme: 'upto', max_amount: amount, nonce: uptoAuth.nonce, deadline: uptoAuth.deadline, facilitator: uptoAuth.witness.facilitator } : null),
1562
- description: option.description,
1596
+ description: serverText(option.description),
1597
+ server_text_is_untrusted: true, // description, retry_error and the response are the endpoint's words, not instructions
1563
1598
  response: retryParsed,
1564
1599
  });
1565
1600
  });
@@ -1764,7 +1799,13 @@ server.tool('wallet_mode', 'Report whether this server is signing locally (self-
1764
1799
  mode: 'custodial',
1765
1800
  custody: 'agentwallet',
1766
1801
  signing: 'AgentWallet servers hold an encrypted key and sign on your behalf.',
1767
- api_base: API_BASE,
1802
+ api_base: redactUrl(API_BASE),
1803
+ client_guards: {
1804
+ autopay_cap_usd: autopayEnvCap(),
1805
+ autopay_assets: (process.env.AGENTWALLET_AUTOPAY_ASSETS || '').trim() || 'registry stablecoins only',
1806
+ x402_max_timeout_seconds: maxAuthWindowSeconds(),
1807
+ note: 'These bound pay_x402 on hosted wallets too; the hosted signer adds its own pause, daily limit and approval checks.',
1808
+ },
1768
1809
  to_self_custody: 'Set AGENTWALLET_PRIVATE_KEY for EVM and/or AGENTWALLET_SOLANA_KEY for Solana, then restart. ' +
1769
1810
  'Use export_wallet_key first if you want to carry an existing hosted wallet across.',
1770
1811
  });
@@ -1864,7 +1905,7 @@ async function main() {
1864
1905
  }
1865
1906
  }
1866
1907
  else {
1867
- console.error('AgentWallet MCP: custodial mode via ' + API_BASE + '. Run wallet_mode for details.');
1908
+ console.error('AgentWallet MCP: custodial mode via ' + redactUrl(API_BASE) + '. Run wallet_mode for details.');
1868
1909
  }
1869
1910
  }
1870
1911
  main().catch((error) => {
@@ -16,6 +16,8 @@ export declare function isSolanaLocalMode(): boolean;
16
16
  export declare function getSolanaKeypair(): Keypair;
17
17
  export declare function getSolanaAddress(): string;
18
18
  export declare function resolveSolanaRpc(chainId?: number): string;
19
+ /** The mint's decimals from the chain, or null when they cannot be read. Never guesses. */
20
+ export declare function mintDecimalsStrict(mint: string, chainId: number): Promise<number | null>;
19
21
  export declare function localSolBalance(chainId?: number): Promise<{
20
22
  address: string;
21
23
  chain_id: number;
@@ -53,6 +53,8 @@ function parseSolanaKey(raw) {
53
53
  }
54
54
  return Keypair.fromSecretKey(Uint8Array.from(buf.subarray(0, 64)));
55
55
  }
56
+ if (text.length > 256)
57
+ throw new Error('Solana key text is too long to be a key.'); // base58 decoding is quadratic; cap before decoding
56
58
  const decoded = bs58.decode(text);
57
59
  if (decoded.length !== 64) {
58
60
  throw new Error(`Base58 Solana key must decode to 64 bytes, got ${decoded.length}.`);
@@ -170,6 +172,23 @@ function assertWithinSolCap(lamports) {
170
172
  * times too permissive, and SPL mints with 0 to 5 decimals are common.
171
173
  */
172
174
  const splDecimalsCache = new Map();
175
+ /** The mint's decimals from the chain, or null when they cannot be read. Never guesses. */
176
+ export async function mintDecimalsStrict(mint, chainId) {
177
+ const known = lookupTrustedDecimals(chainId, mint);
178
+ if (typeof known === 'number')
179
+ return known;
180
+ try {
181
+ const conn = connection(chainId);
182
+ await assertCluster(conn, chainId);
183
+ const info = await conn.getParsedAccountInfo(new PublicKey(mint));
184
+ const data = info?.value?.data;
185
+ const d = Number(data?.parsed?.info?.decimals);
186
+ return Number.isInteger(d) && d >= 0 && d <= 36 ? d : null;
187
+ }
188
+ catch {
189
+ return null;
190
+ }
191
+ }
173
192
  async function resolveMintDecimals(mint, chainId) {
174
193
  const known = lookupTrustedDecimals(chainId, mint);
175
194
  if (typeof known === 'number')
@@ -373,23 +392,33 @@ function createAtaIdempotentIx(payer, ata, owner, mint, tokenProgram) {
373
392
  * not exist, using the idempotent instruction so a race cannot fail the send.
374
393
  */
375
394
  export async function localSplTransfer(mintStr, to, rawAmount, decimals, chainId = 900) {
376
- // Checked before any RPC round trip: a blocked transfer should cost nothing
377
- // and should not announce itself to the RPC provider first.
378
- await assertWithinSplCap(mintStr, BigInt(rawAmount), chainId);
379
395
  const conn = connection(chainId);
396
+ // The cluster is verified before the cap reads decimals from it, so the
397
+ // decimals cache is never filled from a node serving another chain.
380
398
  await assertCluster(conn, chainId);
399
+ await assertWithinSplCap(mintStr, BigInt(rawAmount), chainId);
381
400
  const payer = getSolanaKeypair();
382
401
  const mint = new PublicKey(mintStr);
383
402
  const recipient = new PublicKey(to);
384
403
  await assertRecipientIsWallet(conn, recipient);
385
404
  const { tokenProgram, ataSize } = await mintLayout(conn, mint);
386
- // The instruction's decimals byte is the mint's real value, not whatever the
387
- // caller passed; TransferChecked would reject a wrong one on-chain anyway.
388
- decimals = await resolveMintDecimals(mintStr, chainId);
405
+ // The caller scaled the amount by `decimals`. That value must be the mint's
406
+ // real one: substituting the real value into the instruction (as 1.13.0
407
+ // did) let a wrong guess through, because the amount had already been
408
+ // scaled by the guess and TransferChecked only sees the real byte
409
+ // (2026-10-04 audit). A mismatch refuses.
410
+ const real = await mintDecimalsStrict(mintStr, chainId);
411
+ if (real === null)
412
+ throw new Error(`Could not read the decimals of mint ${mintStr} from the chain; refusing to guess the scale of the amount.`);
413
+ if (decimals !== real)
414
+ throw new Error(`decimals ${decimals} was passed but mint ${mintStr} has ${real} decimals; refusing to scale the amount by the wrong factor.`);
389
415
  const sourceAta = associatedTokenAddress(payer.publicKey, mint, tokenProgram);
390
416
  const destAta = associatedTokenAddress(recipient, mint, tokenProgram);
391
417
  const tx = new Transaction();
392
418
  const destInfo = await conn.getAccountInfo(destAta);
419
+ if (destInfo && !destInfo.owner.equals(tokenProgram)) {
420
+ throw new Error(`Refusing to send: the recipient's token account address ${destAta.toBase58()} is already occupied by an account the token program does not own, so no token account can be created there.`);
421
+ }
393
422
  if (!destInfo) {
394
423
  // Creating the recipient's token account costs the payer rent in SOL, an
395
424
  // outflow the SOL cap must see (0.002 SOL a time adds up over many sends).
@@ -442,6 +471,14 @@ async function assertRecipientIsWallet(conn, recipient) {
442
471
  }
443
472
  throw new Error(`Refusing to send to ${recipient.toBase58()}: it is an account owned by the SPL token program, not a wallet.`);
444
473
  }
474
+ // Everything else that exists must be a plain system account: a program's
475
+ // data or buffer account, an address lookup table, a stake or vote account
476
+ // is owned by its program and nothing can ever sign for it as a wallet
477
+ // (2026-10-04 audit). A program-derived vault that holds tokens is still
478
+ // system-owned, so this costs nothing legitimate.
479
+ if (!info.owner.equals(SystemProgram.programId)) {
480
+ throw new Error(`Refusing to send to ${recipient.toBase58()}: it is owned by program ${info.owner.toBase58()}, not a wallet. Pass a wallet address.`);
481
+ }
445
482
  }
446
483
  export function solanaWalletRecord() {
447
484
  return {
@@ -67,6 +67,28 @@ export interface LocalSendResult {
67
67
  mode: 'local';
68
68
  signed_locally: true;
69
69
  }
70
+ /**
71
+ * What a decimals() probe learned about an address. The four outcomes are
72
+ * kept apart on purpose: only `not-a-token` may ever reach an allow branch.
73
+ *
74
+ * - a number: the contract answered decimals() with a usable value
75
+ * - `unusable`: it answered, but with a value no real token has (outside
76
+ * 0..36) or data that does not decode; it IS a contract that implements
77
+ * decimals(), so treat it as a token and price nothing on trust
78
+ * - `not-a-token`: the call reverted or returned no data, which is how a
79
+ * router, bridge or plain account answers
80
+ * - `unreachable`: the RPC failed, so nothing is known either way
81
+ *
82
+ * Reported privately 2026-09-26: the previous shape collapsed `unusable` and
83
+ * `unreachable` into the same null as `not-a-token`, so a token whose
84
+ * decimals() returned 77 was classified as "not a token" and unpriced
85
+ * calldata to it went through the cap unrefused. Byte-identical contracts
86
+ * differing only in that immutable were blocked (18) and sent (77).
87
+ */
88
+ type DecimalsProbe = number | 'unusable' | 'not-a-token' | 'unreachable';
89
+ /** An eth_call failure is a revert only when the node says so; every other failure is "could not ask". */
90
+ export declare function classifyDecimalsError(err: unknown): DecimalsProbe;
91
+ export declare function permit2AddressFor(chainId: number): string;
70
92
  export declare function localSend(chainId: number, to: Address, valueWei: bigint, data?: Hex): Promise<LocalSendResult>;
71
93
  /** ERC-20 transfer, encoded locally. */
72
94
  export declare function localTransferToken(chainId: number, token: Address, to: Address, rawAmount: string): Promise<LocalSendResult>;
@@ -119,3 +141,4 @@ export declare function localSignPermit2Upto(chainId: number, auth: UptoPermit2A
119
141
  from: Address;
120
142
  mode: 'local';
121
143
  }>;
144
+ export {};
@@ -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, BaseError, ContractFunctionZeroDataError, ContractFunctionRevertedError, ExecutionRevertedError, AbiDecodingDataSizeTooSmallError, AbiDecodingDataSizeInvalidError, } from 'viem';
18
+ import { createPublicClient, createWalletClient, http, encodeFunctionData, parseAbi, formatUnits, } from 'viem';
19
19
  import { privateKeyToAccount } from 'viem/accounts';
20
20
  import { lookupTrustedDecimals, maxAuthWindowSeconds } from './x402-payment.js';
21
21
  import { wrappedNativeAddress } from './wrapped-native.js';
@@ -49,12 +49,19 @@ function loadKeyMaterial() {
49
49
  }
50
50
  return null;
51
51
  }
52
+ const SECP256K1_N = BigInt('0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141');
52
53
  function normalizeKey(raw) {
53
54
  const hex = raw.startsWith('0x') ? raw.slice(2) : raw;
54
55
  if (!/^[0-9a-fA-F]{64}$/.test(hex)) {
55
56
  throw new Error('Invalid private key. Expected 64 hex characters, with or without a 0x prefix. ' +
56
57
  'The value itself is never logged.');
57
58
  }
59
+ // Checked here with a fixed message: the curve library's own range error
60
+ // prints the offending value in decimal (2026-10-04 audit).
61
+ const n = BigInt('0x' + hex);
62
+ if (n === 0n || n >= SECP256K1_N) {
63
+ throw new Error('Invalid private key: the value is outside the secp256k1 range. The value itself is never logged.');
64
+ }
58
65
  return `0x${hex}`;
59
66
  }
60
67
  /** True when the server is running non-custodially. */
@@ -68,10 +75,10 @@ export function getLocalAccount() {
68
75
  if (keyLoadError)
69
76
  throw new Error(keyLoadError);
70
77
  const raw = loadKeyMaterial();
78
+ if (keyLoadError)
79
+ throw new Error(keyLoadError); // the keyfile's own reason, before the generic line
71
80
  if (!raw)
72
81
  throw new Error('Local signing mode is not configured.');
73
- if (keyLoadError)
74
- throw new Error(keyLoadError);
75
82
  cachedAccount = privateKeyToAccount(normalizeKey(raw));
76
83
  return cachedAccount;
77
84
  }
@@ -155,8 +162,8 @@ function assertWithinNativeCap(valueWei) {
155
162
  const [whole, frac = ''] = cap.split('.');
156
163
  const capWei = BigInt(whole + frac.padEnd(18, '0').slice(0, 18));
157
164
  if (valueWei > capWei) {
158
- throw new Error(`Blocked by local guard: transaction value exceeds AGENTWALLET_MAX_TX_NATIVE (${cap}). ` +
159
- `Raise the cap deliberately if this is intended.`);
165
+ throw new Error(`Blocked by local guard: transaction value exceeds AGENTWALLET_MAX_TX_NATIVE (${cap}), a cap only the operator can change. ` +
166
+ `Do not edit the environment or config to raise it.`);
160
167
  }
161
168
  }
162
169
  /* ── Read operations ─────────────────────────────────────────────── */
@@ -230,32 +237,26 @@ async function probeTokenDecimals(chainId, token) {
230
237
  const cached = decimalsCache.get(key);
231
238
  if (typeof cached === 'number')
232
239
  return cached;
233
- let onChain;
240
+ // The raw JSON-RPC envelope is classified here rather than through viem's
241
+ // readContract: viem maps error code -32603 ("Internal error", what an
242
+ // overloaded node or a proxy answers) and an empty envelope to "the contract
243
+ // reverted", which is the one answer this guard treats as evidence of a
244
+ // non-token (2026-10-04 audit). Only an explicit revert counts as that.
245
+ let result;
234
246
  try {
235
247
  const { pub } = clients(chainId);
236
- onChain = await pub.readContract({
237
- address: token,
238
- abi: ERC20_ABI,
239
- functionName: 'decimals',
240
- });
248
+ result = await pub.request({ method: 'eth_call', params: [{ to: token, data: DECIMALS_SELECTOR }, 'latest'] });
241
249
  }
242
250
  catch (err) {
243
- // viem wraps every readContract failure in ContractFunctionExecutionError;
244
- // the cause says whether the contract itself declined (revert, empty
245
- // return: not an ERC-20) or the answer never arrived (RPC down, timeout,
246
- // malformed response). Only the first is evidence of anything.
247
- const declined = err instanceof BaseError && err.walk((e) => e instanceof ContractFunctionZeroDataError
248
- || e instanceof ContractFunctionRevertedError
249
- || e instanceof ExecutionRevertedError // nodes that report a revert as -32000 rather than 3
250
- ) !== null;
251
- if (declined)
252
- return 'not-a-token';
253
- // A decode failure on non-empty data means the contract answered
254
- // decimals() with something that is not a uint8: a token, just a broken one.
255
- const decodeFailed = err instanceof BaseError && err.walk((e) => e instanceof AbiDecodingDataSizeTooSmallError || e instanceof AbiDecodingDataSizeInvalidError) !== null;
256
- return decodeFailed ? 'unusable' : 'unreachable';
251
+ return classifyDecimalsError(err);
257
252
  }
258
- const d = Number(onChain);
253
+ if (result === '0x')
254
+ return 'not-a-token'; // answered with nothing: no decimals() here
255
+ if (typeof result !== 'string' || !/^0x([0-9a-fA-F]{2})+$/.test(result))
256
+ return 'unreachable'; // not an answer at all
257
+ if (result.length !== 66)
258
+ return 'unusable'; // answered, but not one uint8 word
259
+ const d = Number(BigInt(result));
259
260
  // Reject nonsense rather than trusting it; an out-of-range value would
260
261
  // widen the ceiling exactly like the old assumption did.
261
262
  if (Number.isInteger(d) && d >= 0 && d <= 36) {
@@ -264,6 +265,19 @@ async function probeTokenDecimals(chainId, token) {
264
265
  }
265
266
  return 'unusable';
266
267
  }
268
+ const DECIMALS_SELECTOR = '0x313ce567';
269
+ /** An eth_call failure is a revert only when the node says so; every other failure is "could not ask". */
270
+ export function classifyDecimalsError(err) {
271
+ const e = err;
272
+ const code = typeof e?.code === 'number' ? e.code : NaN;
273
+ const text = [e?.message, e?.details, e?.shortMessage].filter(Boolean).join(' ');
274
+ const hasRevertData = typeof e?.data === 'string' && /^0x[0-9a-fA-F]*$/.test(e.data);
275
+ if (code === 3)
276
+ return 'not-a-token'; // the JSON-RPC "execution error" code
277
+ if ((code === -32000 || code === -32603 || code === -32015) && (/revert/i.test(text) || hasRevertData))
278
+ return 'not-a-token';
279
+ return 'unreachable';
280
+ }
267
281
  /**
268
282
  * Decimals to price a known transfer or approval at. Anything short of a
269
283
  * usable answer is evaluated at 0, the only value that cannot fail open.
@@ -272,8 +286,16 @@ async function resolveTokenDecimals(chainId, token) {
272
286
  const probe = await probeTokenDecimals(chainId, token);
273
287
  return typeof probe === 'number' ? probe : 0;
274
288
  }
275
- /** Canonical Permit2, the same address on every EVM chain it is deployed to. */
289
+ /** Canonical Permit2, plus the chains that run a different deployment. */
276
290
  const PERMIT2 = '0x000000000022d473030f116ddee9f6b43ac78ba3';
291
+ const PERMIT2_BY_CHAIN = { 324: '0x0000000000225e31d15943971f47ad3022f714fa' }; // zkSync Era
292
+ export function permit2AddressFor(chainId) { return PERMIT2_BY_CHAIN[chainId] || PERMIT2; }
293
+ /* Permit2's own function selectors. Calldata with one of these aimed at a
294
+ contract that declines decimals() is a Permit2 call on a deployment this
295
+ table does not know, and is refused rather than let through as "a router"
296
+ (2026-10-04 audit). approve, permit (2 shapes), transferFrom (2), lockdown,
297
+ permitTransferFrom (2), permitWitnessTransferFrom (2), invalidateNonces/UnorderedNonces. */
298
+ const PERMIT2_SELECTORS = new Set(['87517c45', '2b67b570', '2a2d80d1', '36c78516', '0d58b1db', 'cc53287f', '30f28b7a', 'edd9444b', '137c29fe', 'fe8ec1a7', '3ff9dcb1', '0d58b1db']);
277
299
  const CAP_LAYOUTS = {
278
300
  a9059cbb: { amountIndex: 1, what: 'token transfer' }, // transfer(address,uint256)
279
301
  '23b872dd': { amountIndex: 2, what: 'token transfer' }, // transferFrom(address,address,uint256)
@@ -314,7 +336,7 @@ async function assertWithinTokenCap(chainId, to, data, valueWei = 0n) {
314
336
  }
315
337
  const hex = data.slice(2);
316
338
  const selector = hex.slice(0, 8).toLowerCase();
317
- const isPermit2 = to.toLowerCase() === PERMIT2;
339
+ const isPermit2 = to.toLowerCase() === PERMIT2 || to.toLowerCase() === permit2AddressFor(chainId);
318
340
  const layout = CAP_LAYOUTS[selector];
319
341
  const isWrappedNative = to.toLowerCase() === wrappedNativeAddress(chainId);
320
342
  const priced = layout
@@ -335,7 +357,11 @@ async function assertWithinTokenCap(chainId, to, data, valueWei = 0n) {
335
357
  throw new Error(`Blocked by local guard: calldata selector 0x${selector} refused rather than let through uncapped: ${why}. ` +
336
358
  `Use transfer, transferFrom, approve, increaseAllowance, Permit2 approve, ` +
337
359
  `or deposit/withdraw on the chain's wrapped-native contract, ` +
338
- `or set AGENTWALLET_ALLOW_UNKNOWN_TOKEN_CALLS=1 to allow it deliberately.`);
360
+ `or ask the operator, who alone can set AGENTWALLET_ALLOW_UNKNOWN_TOKEN_CALLS=1.`);
361
+ }
362
+ if (PERMIT2_SELECTORS.has(selector)) {
363
+ throw new Error(`Blocked by local guard: calldata selector 0x${selector} is a Permit2 function aimed at ${to}, which is not the Permit2 deployment this guard knows for chain ${chainId}; ` +
364
+ `refused rather than let through uncapped. Only the operator can allow it (AGENTWALLET_ALLOW_UNKNOWN_TOKEN_CALLS=1).`);
339
365
  }
340
366
  return; // an ordinary contract call; it can only pull what an existing approval allows (see the note above)
341
367
  }
@@ -367,8 +393,8 @@ async function assertWithinTokenCap(chainId, to, data, valueWei = 0n) {
367
393
  const capRaw = BigInt(whole + frac.padEnd(decimals, '0').slice(0, decimals));
368
394
  if (amount > capRaw) {
369
395
  throw new Error(`Blocked by local guard: ${layout.what} of ${amount} base units exceeds ` +
370
- `AGENTWALLET_MAX_TX_TOKEN (${cap}, evaluated at ${decimals} decimals). ` +
371
- `Raise the cap deliberately if this is intended.`);
396
+ `AGENTWALLET_MAX_TX_TOKEN (${cap}, evaluated at ${decimals} decimals), a cap only the operator can change. ` +
397
+ `Do not edit the environment or config to raise it.`);
372
398
  }
373
399
  // increaseAllowance ADDS to what a spender may already pull. Checking the
374
400
  // increment alone let two individually compliant calls leave a standing
@@ -393,7 +419,7 @@ async function assertWithinTokenCap(chainId, to, data, valueWei = 0n) {
393
419
  if (total > capRaw) {
394
420
  throw new Error(`Blocked by local guard: ${layout.what} of ${amount} base units would leave a standing allowance of ${total} base units ` +
395
421
  `for ${spender}, above AGENTWALLET_MAX_TX_TOKEN (${cap}, evaluated at ${decimals} decimals). ` +
396
- `Use approve with an absolute amount, or raise the cap deliberately.`);
422
+ `Use approve with an absolute amount; the cap itself is the operator's to change.`);
397
423
  }
398
424
  }
399
425
  }
@@ -96,6 +96,8 @@ export declare function headersForRedirect(headers: HeadersInit | undefined, fro
96
96
  * take the wallet process down. Wire bytes are capped separately on the Agent.
97
97
  */
98
98
  export declare const MAX_RESPONSE_BYTES: number;
99
+ /** Deadline applied when the caller passes no AbortSignal. */
100
+ export declare const DEFAULT_FETCH_TIMEOUT_MS = 30000;
99
101
  /** Header safeFetch adds to every response: the URL the body actually came from, after redirects. */
100
102
  export declare const FINAL_URL_HEADER = "x-agw-final-url";
101
103
  /** The URL a safeFetch response actually came from, after every redirect. Only safeFetch can set it. */
@@ -215,6 +215,11 @@ export async function resolvePublicUrl(url) {
215
215
  throw new Error(`Could not resolve host "${bare}".`);
216
216
  const addresses = [];
217
217
  for (const a of answers) {
218
+ // Only a canonical IP string is judged; anything else from a resolver is
219
+ // refused rather than passed through isPrivateAddress, which cannot
220
+ // classify a non-canonical spelling and would answer "public".
221
+ if (isIP(a.address) === 0)
222
+ throw new Error(`Resolver returned a non-canonical address for "${bare}"; refusing.`);
218
223
  if (isPrivateAddress(a.address)) {
219
224
  throw new Error('URL resolves to a private/internal address. Only public URLs are allowed.');
220
225
  }
@@ -328,6 +333,8 @@ export function headersForRedirect(headers, from, to, bodyDropped) {
328
333
  * take the wallet process down. Wire bytes are capped separately on the Agent.
329
334
  */
330
335
  export const MAX_RESPONSE_BYTES = 8 * 1024 * 1024;
336
+ /** Deadline applied when the caller passes no AbortSignal. */
337
+ export const DEFAULT_FETCH_TIMEOUT_MS = 30_000;
331
338
  /** Header safeFetch adds to every response: the URL the body actually came from, after redirects. */
332
339
  export const FINAL_URL_HEADER = 'x-agw-final-url';
333
340
  /* The final URL is also recorded here, keyed by the Response object, and
@@ -397,7 +404,8 @@ async function readBounded(stream, limit) {
397
404
  */
398
405
  export async function safeFetch(url, options = {}, maxHops = 3) {
399
406
  let current = url;
400
- let opts = options;
407
+ // A caller that forgets a signal still gets a deadline; a silent server must not hold the process.
408
+ let opts = options.signal ? options : { ...options, signal: AbortSignal.timeout(DEFAULT_FETCH_TIMEOUT_MS) };
401
409
  for (let hop = 0; hop <= maxHops; hop++) {
402
410
  const { addresses } = await resolvePublicUrl(current);
403
411
  // One agent per hop, carrying that hop's pinned addresses. The hostname is
@@ -81,6 +81,8 @@ export declare function isLegacyAgentWalletAccept(a: X402Requirement): boolean;
81
81
  export declare function requiredAmount(a: X402Requirement): string;
82
82
  export declare function createNonce(): `0x${string}`;
83
83
  /** Authorization window mirrors the reference client: valid from 10 minutes ago until maxTimeoutSeconds from now. */
84
+ /** Shortest validity window signed, whatever the server asks for. */
85
+ export declare const MIN_WINDOW_SECONDS = 60;
84
86
  export declare function buildAuthorization(from: string, req: X402Requirement, nowSeconds?: number, nonce?: `0x${string}`): Eip3009Authorization;
85
87
  /** EIP-712 typed data for the authorization. `name`/`version` are the token's own domain values. */
86
88
  export declare function typedDataFor(chainId: number, asset: string, name: string, version: string, auth: Eip3009Authorization): {
@@ -52,6 +52,8 @@ export function createNonce() {
52
52
  }
53
53
  const DEFAULT_TIMEOUT_SECONDS = 300;
54
54
  /** Authorization window mirrors the reference client: valid from 10 minutes ago until maxTimeoutSeconds from now. */
55
+ /** Shortest validity window signed, whatever the server asks for. */
56
+ export const MIN_WINDOW_SECONDS = 60;
55
57
  export function buildAuthorization(from, req, nowSeconds = Math.floor(Date.now() / 1000), nonce = createNonce()) {
56
58
  const value = requiredAmount(req);
57
59
  if (!/^\d+$/.test(value))
@@ -63,7 +65,9 @@ export function buildAuthorization(from, req, nowSeconds = Math.floor(Date.now()
63
65
  // asking for years would otherwise hold one until the wallet is refilled.
64
66
  const requested = Number.isFinite(req.maxTimeoutSeconds) && req.maxTimeoutSeconds > 0
65
67
  ? Math.floor(req.maxTimeoutSeconds) : DEFAULT_TIMEOUT_SECONDS;
66
- const timeout = Math.min(requested, maxAuthWindowSeconds());
68
+ // Floor as well as cap: a server asking for a 2-second window would make
69
+ // every retry sign a fresh nonce it can also settle (2026-10-04 audit).
70
+ const timeout = Math.max(Math.min(requested, maxAuthWindowSeconds()), Math.min(MIN_WINDOW_SECONDS, maxAuthWindowSeconds()));
67
71
  return {
68
72
  from: from,
69
73
  to: req.payTo,
@@ -66,11 +66,20 @@ export declare function assetLabel(chainId: number, asset: string): string;
66
66
  * list of token addresses / mints, or the word "native". Listed assets are
67
67
  * then capped in their own units, which the operator has opted into knowingly.
68
68
  */
69
+ export declare function parseAutopayAssets(raw: string | undefined): Array<{
70
+ chainId: number | null;
71
+ asset: string;
72
+ }>;
69
73
  export declare function autopayAssetAllowed(chainId: number, asset: string): {
70
74
  allowed: boolean;
71
75
  via: 'stablecoin' | 'allowlist' | 'none';
72
76
  reason?: string;
73
77
  };
78
+ export declare const X402_NETWORK_IDS: Record<string, number>;
79
+ /** A positive integer chain id for an x402 network string, or null. Never guesses. */
80
+ export declare function resolveNetworkChainId(network: string): number | null;
81
+ /** An integer base-unit amount in canonical form: "0010000" and "10000" are one amount, not two cache keys. */
82
+ export declare function normalizeRawAmount(raw: string): string;
74
83
  /** The operator's per-payment ceiling, as configured. Validated so a typo never reads as "unlimited". */
75
84
  export declare function autopayEnvCap(): string;
76
85
  /**
@@ -110,21 +110,73 @@ export function assetLabel(chainId, asset) {
110
110
  * list of token addresses / mints, or the word "native". Listed assets are
111
111
  * then capped in their own units, which the operator has opted into knowingly.
112
112
  */
113
+ /* One AGENTWALLET_AUTOPAY_ASSETS entry: "8453:0x..." or "900:native" names one
114
+ chain; a bare "0x..." / "native" applies on every chain (the original
115
+ grammar, kept so existing configs keep working, but the same address is a
116
+ different contract on another chain, so scoped entries are the safer form). */
117
+ export function parseAutopayAssets(raw) {
118
+ return (raw || '').split(',').map(s => s.trim()).filter(Boolean).map(e => {
119
+ const m = e.match(/^(\d+):(.+)$/);
120
+ const chainId = m ? parseInt(m[1], 10) : null;
121
+ const a = (m ? m[2] : e).trim();
122
+ return { chainId, asset: a.startsWith('0x') ? a.toLowerCase() : a };
123
+ });
124
+ }
113
125
  export function autopayAssetAllowed(chainId, asset) {
114
126
  if (isStableAsset(chainId, asset))
115
127
  return { allowed: true, via: 'stablecoin' };
116
- const list = (process.env.AGENTWALLET_AUTOPAY_ASSETS || '').split(',').map(s => s.trim()).filter(Boolean);
117
128
  const wanted = asset ? (asset.startsWith('0x') ? asset.toLowerCase() : asset) : 'native';
118
- if (list.some(e => (e.startsWith('0x') ? e.toLowerCase() : e) === wanted))
129
+ const hit = parseAutopayAssets(process.env.AGENTWALLET_AUTOPAY_ASSETS).some(e => e.asset === wanted && (e.chainId === null || e.chainId === chainId));
130
+ if (hit)
119
131
  return { allowed: true, via: 'allowlist' };
120
132
  const label = assetLabel(chainId, asset);
121
133
  return {
122
134
  allowed: false, via: 'none',
123
135
  reason: `x402 blocked: this endpoint wants payment in ${label}${asset ? ` (${asset})` : ''} on chain ${chainId}, which is not a stablecoin ` +
124
- `AGENTWALLET_MAX_AUTOPAY can price. To allow it, add ${asset ? `"${asset}"` : '"native"'} to AGENTWALLET_AUTOPAY_ASSETS; ` +
125
- `the cap then applies in ${label} units, not dollars.`,
136
+ `AGENTWALLET_MAX_AUTOPAY can price and is not on the operator's AGENTWALLET_AUTOPAY_ASSETS list for chain ${chainId}. ` +
137
+ `Only the operator can change that list; do not edit the environment or config to add it.`,
126
138
  };
127
139
  }
140
+ /* The x402 network names this client knows. Looked up with Object.hasOwn so a
141
+ name like "constructor" or "__proto__" (inherited from Object.prototype)
142
+ is unknown rather than a truthy function (2026-10-04 audit). */
143
+ export const X402_NETWORK_IDS = {
144
+ 'ethereum': 1, 'base': 8453, 'base-sepolia': 84532, 'polygon': 137, 'arbitrum': 42161, 'optimism': 10,
145
+ 'bsc': 56, 'avalanche': 43114, 'zora': 7777777, 'pulsechain': 369, 'solana': 900, 'solana-devnet': 901,
146
+ };
147
+ const SOLANA_GENESIS_PREFIX = { 900: '5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp', 901: 'EtWTRABZaYq6iMfeYKouRu166VU2xqa1' };
148
+ /** A positive integer chain id for an x402 network string, or null. Never guesses. */
149
+ export function resolveNetworkChainId(network) {
150
+ if (typeof network !== 'string')
151
+ return null;
152
+ const lower = network.toLowerCase();
153
+ if (Object.hasOwn(X402_NETWORK_IDS, lower))
154
+ return X402_NETWORK_IDS[lower];
155
+ const caip = network.match(/^eip155:(\d{1,12})$/);
156
+ if (caip) {
157
+ const n = Number(caip[1]);
158
+ return Number.isSafeInteger(n) && n > 0 ? n : null;
159
+ }
160
+ const sol = network.match(/^solana:(.+)$/);
161
+ if (sol) {
162
+ for (const [id, g] of Object.entries(SOLANA_GENESIS_PREFIX)) {
163
+ if (sol[1] === g)
164
+ return Number(id);
165
+ }
166
+ return null;
167
+ }
168
+ if (/^\d{1,12}$/.test(network)) {
169
+ const n = Number(network);
170
+ return n > 0 ? n : null;
171
+ }
172
+ return null;
173
+ }
174
+ /** An integer base-unit amount in canonical form: "0010000" and "10000" are one amount, not two cache keys. */
175
+ export function normalizeRawAmount(raw) {
176
+ if (!/^\d+$/.test(raw))
177
+ throw new Error(`x402: invalid amount "${raw}" in the payment requirements (expected integer base units).`);
178
+ return BigInt(raw).toString();
179
+ }
128
180
  /** The operator's per-payment ceiling, as configured. Validated so a typo never reads as "unlimited". */
129
181
  export function autopayEnvCap() {
130
182
  const raw = (process.env.AGENTWALLET_MAX_AUTOPAY || '').trim() || '1';
@@ -287,7 +339,7 @@ export function deriveX402Payment(accept, maxAutopayHuman, trustedDecimals) {
287
339
  const capRaw = toBaseUnits(maxAutopayHuman, trustedDecimals);
288
340
  if (BigInt(rawAmount) > BigInt(capRaw)) {
289
341
  throw new Error(`x402 auto-pay blocked: required amount (${rawAmount} base units) exceeds the ` +
290
- `AGENTWALLET_MAX_AUTOPAY cap of ${maxAutopayHuman}. Raise AGENTWALLET_MAX_AUTOPAY to allow it.`);
342
+ `AGENTWALLET_MAX_AUTOPAY cap of ${maxAutopayHuman}, which only the operator can change.`);
291
343
  }
292
344
  return { tokenAddress, rawAmount, decimals: trustedDecimals };
293
345
  }
@@ -346,12 +398,14 @@ export function authorizationResource(url) {
346
398
  export function approvalRefusal(approvalId, row, want, localMode) {
347
399
  if (localMode) {
348
400
  return {
349
- error: `Approval ${approvalId} cannot be used in local mode: approvals are consumed by the hosted signer, and this server signs with your own key, so nothing could mark it used. Local mode has no approval channel: raise AGENTWALLET_MAX_AUTOPAY or AGENTWALLET_MAX_TX_TOKEN yourself.`,
401
+ error: `Approval ${approvalId} cannot be used in local mode: approvals are consumed by the hosted signer, and this server signs with your own key, so nothing could mark it used. Local mode has no approval channel; the caps are the operator's and only the operator can change them.`,
350
402
  };
351
403
  }
352
404
  const status = row?.status ?? null;
353
- // "used" must read as exactly 0. A missing or unparseable field refuses rather than passes.
354
- if (status !== 'approved' || Number(row?.used) !== 0) {
405
+ // "used" must be exactly the number 0 or the string "0" (wpdb rows carry
406
+ // strings). null, "", false and anything else refuse: Number(null) is 0.
407
+ const unused = row?.used === 0 || row?.used === '0';
408
+ if (status !== 'approved' || !unused) {
355
409
  const state = status === 'approved' ? 'already used' : `${status ?? 'unknown'}, not approved`;
356
410
  return { approval_status: status, error: `Approval ${approvalId} is ${state}.${row?.error ? ' ' + row.error : ''}` };
357
411
  }
@@ -119,6 +119,6 @@ export declare function uptoTypedData(chainId: number, auth: UptoPermit2Authoriz
119
119
  export declare function uptoPayload(auth: UptoPermit2Authorization, signature: string): Record<string, unknown>;
120
120
  /** allowance(owner, Permit2) calldata. */
121
121
  export declare function permit2AllowanceCalldata(owner: string): `0x${string}`;
122
- /** approve(Permit2, max) calldata: the one-time step upto needs. */
123
- export declare function permit2ApproveCalldata(): `0x${string}`;
122
+ /** approve(Permit2, amount) calldata; unlimited only when no amount is given. */
123
+ export declare function permit2ApproveCalldata(amountRaw?: string): `0x${string}`;
124
124
  export declare function decodeUint(hex: string): bigint;
@@ -52,7 +52,7 @@ export function buildUptoAuthorization(from, req, nowSeconds = Math.floor(Date.n
52
52
  if (!/^0x[0-9a-fA-F]{40}$/.test(req.payTo))
53
53
  throw new Error(`x402 upto: payTo is not an EVM address: "${req.payTo}".`);
54
54
  const requested = Number.isFinite(req.maxTimeoutSeconds) && req.maxTimeoutSeconds > 0 ? Math.floor(req.maxTimeoutSeconds) : 300;
55
- const timeout = Math.min(requested, maxAuthWindowSeconds()); // see buildAuthorization
55
+ const timeout = Math.max(Math.min(requested, maxAuthWindowSeconds()), Math.min(60, maxAuthWindowSeconds())); // see buildAuthorization
56
56
  return {
57
57
  from: from,
58
58
  permitted: { token: req.asset, amount },
@@ -85,9 +85,10 @@ function pad(addr) { return addr.replace(/^0x/, '').toLowerCase().padStart(64, '
85
85
  export function permit2AllowanceCalldata(owner) {
86
86
  return ('0xdd62ed3e' + pad(owner) + pad(PERMIT2_ADDRESS));
87
87
  }
88
- /** approve(Permit2, max) calldata: the one-time step upto needs. */
89
- export function permit2ApproveCalldata() {
90
- return ('0x095ea7b3' + pad(PERMIT2_ADDRESS) + 'f'.repeat(64));
88
+ /** approve(Permit2, amount) calldata; unlimited only when no amount is given. */
89
+ export function permit2ApproveCalldata(amountRaw) {
90
+ const word = amountRaw === undefined ? 'f'.repeat(64) : BigInt(amountRaw).toString(16).padStart(64, '0');
91
+ return ('0x095ea7b3' + pad(PERMIT2_ADDRESS) + word);
91
92
  }
92
93
  export function decodeUint(hex) {
93
94
  const h = (hex || '').replace(/^0x/, '');
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.13.4",
4
+ "version": "1.13.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/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",
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 && 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",
20
20
  "prepublishOnly": "npm run build"
21
21
  },
22
22
  "keywords": [