agentwallet-mcp 1.13.7 → 1.13.9

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/build/index.js CHANGED
@@ -13,14 +13,14 @@ 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, resolveNetworkChainId, normalizeRawAmount, parseAutopayAssets, assetSupportsExact, } 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, assetSupportsExact, approvalResourceUrl, } from './x402-payment.js';
17
17
  import { createHash } from 'node:crypto';
18
18
  import { isLocalMode, getLocalAddress, localWalletRecord, localSend, localNativeBalance, localTokenBalance, localSignMessage, resolveRpcUrl, } from './local-wallet.js';
19
19
  import { localSignAuthorization, localEthCall, localGetCode, localSignPermit2Upto } from './local-wallet.js';
20
20
  import { WRAPPED_NATIVE } from './wrapped-native.js';
21
21
  import { buildUptoAuthorization, uptoPayload, permit2AllowanceCalldata, permit2ApproveCalldata, decodeUint, permit2Address, } from './x402-permit2.js';
22
22
  import { assessTokenRisk } from './token-risk.js';
23
- import { isLegacyAgentWalletAccept, requiredAmount, buildAuthorization, buildPaymentPayload, buildPaymentPayloadRaw, paymentHeaderFor, parsePaymentRequired, parseSettlement, pickOption, knownTokenDomain, parseEip7702Delegation, erc1271ProbeCalldata, classifyErc1271Probe, settlementVerdict, legacyReceiptAllowed, } from './x402-eip3009.js';
23
+ import { isLegacyAgentWalletAccept, requiredAmount, buildAuthorization, buildPaymentPayload, buildPaymentPayloadRaw, paymentHeaderFor, parsePaymentRequired, parseSettlement, pickOption, knownTokenDomain, decodeAbiString as decodeDomainString, parseEip7702Delegation, erc1271ProbeCalldata, classifyErc1271Probe, settlementVerdict, legacyReceiptAllowed, } from './x402-eip3009.js';
24
24
  import { isSolanaLocalMode, getSolanaAddress, solanaWalletRecord, localSolBalance, localSplBalance, localSolTransfer, localSplTransfer, resolveSolanaRpc, mintDecimalsStrict, } from './local-solana.js';
25
25
  /** True when either chain family is running non-custodially. */
26
26
  function anyLocalMode() {
@@ -223,6 +223,7 @@ async function api(path, method = 'GET', body, extraHeaders) {
223
223
  const url = `${API_BASE}${path}`;
224
224
  const headers = {
225
225
  'Content-Type': 'application/json',
226
+ 'Accept-Encoding': 'identity', // a 500 KB gzip body inflated to 500 MB before the size check (round 3)
226
227
  ...extraHeaders,
227
228
  };
228
229
  if (API_USER && API_PASS) {
@@ -240,9 +241,7 @@ async function api(path, method = 'GET', body, extraHeaders) {
240
241
  if (res.status >= 300 && res.status < 400) {
241
242
  throw new Error(`The AgentWallet API host answered ${path} with a redirect (HTTP ${res.status}); refusing to follow it.`);
242
243
  }
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.`);
244
+ const text = await readBounded(res, API_MAX_RESPONSE_CHARS, 'The AgentWallet API host');
246
245
  let data;
247
246
  try {
248
247
  data = JSON.parse(text);
@@ -250,6 +249,7 @@ async function api(path, method = 'GET', body, extraHeaders) {
250
249
  catch {
251
250
  throw new Error(`The AgentWallet API host returned a non-JSON response (HTTP ${res.status}).`);
252
251
  }
252
+ data = boundHostData(data, 0); // strings, arrays and depth bounded; the host writes none of the agent's instructions
253
253
  // A 402 from the API host is paid automatically only for a HOSTED wallet the
254
254
  // operator named with AGENTWALLET_WALLET_ID. In local mode the host is not a
255
255
  // custodian and must never be able to make the local key sign: a 402 is
@@ -259,15 +259,79 @@ async function api(path, method = 'GET', body, extraHeaders) {
259
259
  if (anyLocalMode()) {
260
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
261
  }
262
- if (X402_WALLET_ID)
262
+ if (X402_WALLET_ID) {
263
+ // Only the routes the hosted billing meters (create, sign, send) are paid for
264
+ // silently; a 402 on a read or anything else is reported, never paid (round 3).
265
+ if (!autopayRouteAllowed(path, method)) {
266
+ throw new Error(`The AgentWallet API host asked for payment on ${method} ${path}, a route the hosted billing does not meter; nothing was paid. If you trust it, call pay_x402 with that URL so the payment is explicit and reported.`);
267
+ }
263
268
  return handleX402Payment(data, path, method, body);
269
+ }
264
270
  }
265
271
  if (!res.ok) {
266
- const error = data.error || `HTTP ${res.status}`;
272
+ const error = serverText(data.error) || `HTTP ${res.status}`;
267
273
  throw new Error(error);
268
274
  }
275
+ // A send that comes back without a hash was not sent, whatever the body says
276
+ // ("Transfer complete. 5 ETH sent." with no tx_hash, round 3).
277
+ if (method === 'POST' && /^\/wallets\/[^/]+\/send(\?|$)/.test(path) && data && typeof data === 'object') {
278
+ const d = data;
279
+ if (!looksLikeTxHash(d.tx_hash) && !(typeof d.signature === 'string' && /^[1-9A-HJ-NP-Za-km-z]{43,120}$/.test(d.signature))) {
280
+ throw new Error('The AgentWallet API host answered the send without a transaction hash or signature; treating it as not sent.');
281
+ }
282
+ }
283
+ if (data && typeof data === 'object' && !Array.isArray(data))
284
+ data.server_text_is_untrusted = true;
269
285
  return data;
270
286
  }
287
+ /** Internal auto-pay covers the metered wallet operations only. */
288
+ function autopayRouteAllowed(path, method) {
289
+ return method === 'POST' && /^\/wallets(\/\d+\/(sign|send))?(\?|$)/.test(path);
290
+ }
291
+ const AUTOPAY_PER_HOUR = (() => { const n = Number(process.env.AGENTWALLET_MAX_AUTOPAY_PER_HOUR ?? '20'); return Number.isInteger(n) && n >= 0 ? n : 20; })();
292
+ const autopayLog = [];
293
+ /** Read a body no larger than maxChars, refusing early on a declared length and mid-stream on a real one. */
294
+ async function readBounded(res, maxChars, who) {
295
+ const declared = Number(res.headers.get('content-length') || 0);
296
+ if (declared > maxChars)
297
+ throw new Error(`${who} declared a ${declared}-byte body; refusing to read more than ${maxChars}.`);
298
+ if (!res.body)
299
+ return '';
300
+ const reader = res.body.getReader();
301
+ const chunks = [];
302
+ let total = 0;
303
+ for (;;) {
304
+ const { done, value } = await reader.read();
305
+ if (done)
306
+ break;
307
+ total += value.length;
308
+ if (total > maxChars) {
309
+ await reader.cancel().catch(() => { });
310
+ throw new Error(`${who} sent more than ${maxChars} bytes; refusing to read further.`);
311
+ }
312
+ chunks.push(value);
313
+ }
314
+ return Buffer.concat(chunks).toString('utf8');
315
+ }
316
+ const HOST_MAX_DEPTH = 32;
317
+ const HOST_MAX_STRING = 4000;
318
+ const HOST_MAX_ARRAY = 1000;
319
+ /** Host JSON with every string, array and nesting level bounded (an 8 KB body of nested arrays became a 32 MB result, round 3). */
320
+ function boundHostData(v, depth) {
321
+ if (depth > HOST_MAX_DEPTH)
322
+ throw new Error(`The AgentWallet API host returned JSON nested deeper than ${HOST_MAX_DEPTH} levels; refusing it.`);
323
+ if (typeof v === 'string')
324
+ return v.length > HOST_MAX_STRING ? v.slice(0, HOST_MAX_STRING) + ' [truncated by agentwallet-mcp]' : v;
325
+ if (Array.isArray(v))
326
+ return v.slice(0, HOST_MAX_ARRAY).map(x => boundHostData(x, depth + 1));
327
+ if (v && typeof v === 'object') {
328
+ const out = {};
329
+ for (const [k, x] of Object.entries(v))
330
+ out[k.slice(0, 200)] = boundHostData(x, depth + 1);
331
+ return out;
332
+ }
333
+ return v;
334
+ }
271
335
  /**
272
336
  * Resolve an asset's decimals from a source the paying party controls, never
273
337
  * from the 402 response.
@@ -359,6 +423,14 @@ async function handleX402Payment(x402Data, originalPath, originalMethod, origina
359
423
  ? await resolveTrustedDecimals(chainId, assetForDecimals)
360
424
  : nativeDecimals(chainId); // native asset: 18 on EVM (wei), 9 on Solana (lamports)
361
425
  const { tokenAddress, rawAmount, decimals } = deriveX402Payment(accept, maxAutopay, trustedDecimals);
426
+ // A hostile or broken host that answers 402 on every call must not drain the
427
+ // wallet one cap at a time: at most AUTOPAY_PER_HOUR payments an hour (round 3).
428
+ const hourAgo = Date.now() - 3_600_000;
429
+ while (autopayLog.length && autopayLog[0] < hourAgo)
430
+ autopayLog.shift();
431
+ if (autopayLog.length >= AUTOPAY_PER_HOUR) {
432
+ throw new Error(`x402 auto-pay: ${autopayLog.length} automatic payments were already made in the last hour (AGENTWALLET_MAX_AUTOPAY_PER_HOUR=${AUTOPAY_PER_HOUR}); refusing another. Original error: ${serverText(x402Data.error)}`);
433
+ }
362
434
  // Send payment using our wallet (with X-AGW-SKIP-X402 to prevent recursion)
363
435
  let txHash;
364
436
  try {
@@ -377,11 +449,18 @@ async function handleX402Payment(x402Data, originalPath, originalMethod, origina
377
449
  }
378
450
  }
379
451
  catch (e) {
380
- throw new Error(`x402 auto-pay failed: ${e.message}. Original error: ${x402Data.error}`);
452
+ throw new Error(`x402 auto-pay failed: ${e.message}. Original error: ${serverText(x402Data.error)}`);
381
453
  }
382
454
  if (!txHash) {
383
455
  throw new Error('x402 auto-pay: no transaction hash returned.');
384
456
  }
457
+ autopayLog.push(Date.now());
458
+ const autopayNotice = {
459
+ amount: formatUnits(rawAmount, decimals), token: assetLabel(chainId, tokenAddress), pay_to: payTo, chain_id: chainId, tx_hash: txHash,
460
+ route: `${originalMethod} ${originalPath}`,
461
+ note: 'Paid automatically from the hosted wallet named by AGENTWALLET_WALLET_ID because the API host answered 402 on this operation.',
462
+ };
463
+ console.error(`[agentwallet] auto-paid ${autopayNotice.amount} ${autopayNotice.token} to ${payTo} on chain ${chainId} for ${originalMethod} ${originalPath} (tx ${txHash})`);
385
464
  // Build X-PAYMENT header (base64-encoded JSON proof)
386
465
  const proof = {
387
466
  x402Version: 1,
@@ -393,8 +472,12 @@ async function handleX402Payment(x402Data, originalPath, originalMethod, origina
393
472
  // Wait briefly for tx confirmation (EVM ~2s, Solana ~6s)
394
473
  const waitMs = isSolanaChain(chainId) ? 8000 : 3000;
395
474
  await new Promise(resolve => setTimeout(resolve, waitMs));
396
- // Retry original request with payment proof
397
- return api(originalPath, originalMethod, originalBody, { 'X-PAYMENT': paymentHeader });
475
+ // Retry original request with payment proof; the payment rides along in the result,
476
+ // which used to say nothing about it (round 3).
477
+ const paid = await api(originalPath, originalMethod, originalBody, { 'X-PAYMENT': paymentHeader });
478
+ if (paid && typeof paid === 'object' && !Array.isArray(paid))
479
+ return { ...paid, autopay_payment: autopayNotice };
480
+ return { result: paid, autopay_payment: autopayNotice };
398
481
  }
399
482
  /**
400
483
  * Build ERC-20 transfer(address,uint256) calldata
@@ -405,8 +488,18 @@ function buildErc20TransferData(to, amount) {
405
488
  const amountHex = BigInt(amount).toString(16).padStart(64, '0');
406
489
  return '0xa9059cbb' + addressPadded + amountHex;
407
490
  }
491
+ const RESULT_MAX_CHARS = 1_000_000;
408
492
  function jsonResponse(data) {
409
- return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
493
+ let text;
494
+ try {
495
+ text = JSON.stringify(data, null, 2);
496
+ }
497
+ catch (e) {
498
+ text = JSON.stringify({ error: 'The result could not be serialised: ' + e.message });
499
+ }
500
+ if (text.length > RESULT_MAX_CHARS)
501
+ text = text.slice(0, RESULT_MAX_CHARS) + `\n... [result truncated at ${RESULT_MAX_CHARS} characters by agentwallet-mcp]`;
502
+ return { content: [{ type: 'text', text }] };
410
503
  }
411
504
  // ─── Solana Helpers ───────────────────────────────────────────────
412
505
  function isSolanaChain(chainId) {
@@ -452,6 +545,7 @@ function redactUrl(u) {
452
545
  }
453
546
  const API_MAX_RESPONSE_CHARS = 8 * 1024 * 1024;
454
547
  const API_TIMEOUT_MS = 60_000; // a hosted hop that hangs must not hold a signing gate open
548
+ const PAYWALL_MAX_CHARS = 4 * 1024 * 1024; // a paywall's answer, before and after payment
455
549
  /* Error text from the local signing path with every URL reduced to its origin
456
550
  and viem's "URL:" / "Request body:" lines dropped: an RPC endpoint carries
457
551
  its API key in the path or query, and a tool result is the agent's context
@@ -528,11 +622,17 @@ function parseUnits(amount, decimals) {
528
622
  * Format raw units to human-readable string given decimals.
529
623
  */
530
624
  function formatUnits(raw, decimals) {
625
+ // decimals 300000 from a host once held the whole server for 36 s in the regex below (round 3)
626
+ if (!Number.isInteger(decimals) || decimals < 0 || decimals > 36)
627
+ throw new Error(`decimals must be an integer from 0 to 36, got ${String(decimals)}.`);
531
628
  const padded = raw.padStart(decimals + 1, '0');
532
629
  const whole = padded.slice(0, padded.length - decimals) || '0';
533
630
  const frac = padded.slice(padded.length - decimals);
534
- // Trim trailing zeros but keep at least one decimal
535
- const trimmed = frac.replace(/0+$/, '') || '0';
631
+ // Trim trailing zeros but keep at least one decimal (a loop, not /0+$/, which backtracks quadratically)
632
+ let end = frac.length;
633
+ while (end > 0 && frac[end - 1] === '0')
634
+ end--;
635
+ const trimmed = frac.slice(0, end) || '0';
536
636
  return decimals === 0 ? whole : `${whole}.${trimmed}`; // a 0-decimal amount has no fraction, so it round-trips through parseUnits
537
637
  }
538
638
  /**
@@ -579,7 +679,7 @@ function resolveChainId(network) {
579
679
  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');
580
680
  const server = new McpServer({
581
681
  name: 'agentwallet',
582
- version: '1.13.7',
682
+ version: '1.13.9',
583
683
  }, {
584
684
  instructions: `AgentWallet gives AI agents their own blockchain wallets. ${anyLocalMode()
585
685
  ? '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.'
@@ -841,7 +941,7 @@ server.tool('get_token_balance', 'Get the ERC-20 or SPL token balance for a wall
841
941
  // The response's own decimals (local mode reads them from the contract) win
842
942
  // over the caller's guess; a guess of 18 used to reformat a 1 USDC balance
843
943
  // as 0.000000000001. When nothing supplies them, resolve them ourselves.
844
- let trusted = typeof data.decimals === 'number' ? data.decimals : null;
944
+ let trusted = (typeof data.decimals === 'number' && Number.isInteger(data.decimals) && data.decimals >= 0 && data.decimals <= 36) ? data.decimals : null;
845
945
  if (trusted === null) {
846
946
  if (typeof decimals === 'number')
847
947
  trusted = decimals;
@@ -854,7 +954,7 @@ server.tool('get_token_balance', 'Get the ERC-20 or SPL token balance for a wall
854
954
  ? `decimals ${decimals} was passed but the token reports ${data.decimals}; the token's value was used.` : undefined;
855
955
  return jsonResponse({
856
956
  ...data,
857
- balance: data.balance ?? formatUnits(data.balance_raw || '0', trusted),
957
+ balance: /^\d+$/.test(String(data.balance_raw ?? '')) ? formatUnits(String(data.balance_raw), trusted) : serverText(data.balance), // computed here, not the host's word
858
958
  decimals: trusted,
859
959
  ...(warning ? { warning } : {}),
860
960
  });
@@ -1190,8 +1290,11 @@ async function resolveTokenDomain(chainId, asset, extra) {
1190
1290
  return declared;
1191
1291
  let name = '', version = '';
1192
1292
  try {
1193
- name = decodeAbiString(await ethCallHex(chainId, asset, '0x06fdde03')); // name()
1194
- version = decodeAbiString(await ethCallHex(chainId, asset, '0x54fd4d50')); // version()
1293
+ // The signing decoder (256 bytes, strict UTF-8), not the 64-char display sanitiser
1294
+ // get_token_info uses: a truncated or replaced name signs under a domain the
1295
+ // token never had (round 3).
1296
+ name = decodeDomainString(await ethCallHex(chainId, asset, '0x06fdde03')); // name()
1297
+ version = decodeDomainString(await ethCallHex(chainId, asset, '0x54fd4d50')); // version()
1195
1298
  }
1196
1299
  catch { /* refused below */ }
1197
1300
  if (!name || !version) {
@@ -1325,7 +1428,7 @@ server.tool('pay_x402', 'Handle an x402 payment flow. Fetches a URL, and if the
1325
1428
  error: `The endpoint redirected to another origin (${new URL(servedFrom).origin}) before asking for payment. Call pay_x402 with that URL directly if you trust it.`,
1326
1429
  final_url: servedFrom });
1327
1430
  }
1328
- const initialText = await initialRes.text();
1431
+ const initialText = await readBounded(initialRes, PAYWALL_MAX_CHARS, 'The endpoint');
1329
1432
  let initialBody = null;
1330
1433
  try {
1331
1434
  initialBody = JSON.parse(initialText);
@@ -1386,6 +1489,10 @@ server.tool('pay_x402', 'Handle an x402 payment flow. Fetches a URL, and if the
1386
1489
  // "exact" requirement in a token with no EIP-3009 can never settle, so it
1387
1490
  // is refused before an approval could be consumed for it.
1388
1491
  const legacyReceipt = isLegacyAgentWalletAccept(option) && legacyReceiptAllowed(servedFrom, API_BASE, process.env.AGENTWALLET_LEGACY_PAYWALL_ORIGINS);
1492
+ // Only a signed authorization (EIP-3009 "exact" or Permit2 "upto") is bound to an
1493
+ // approval by the hosted signer; a broadcast transfer (native coin, Solana, an
1494
+ // AgentWallet receipt paywall) is not, so an approval must never lift the cap for one.
1495
+ const willSign = isUpto || (!isSolanaChain(chainId) && Boolean(tokenAddress) && !legacyReceipt);
1389
1496
  if (!isUpto && !isSolanaChain(chainId) && tokenAddress && !legacyReceipt && !assetSupportsExact(chainId, tokenAddress)) {
1390
1497
  return jsonResponse({
1391
1498
  status: 402, payment_required: true, payment_made: false,
@@ -1393,6 +1500,20 @@ server.tool('pay_x402', 'Handle an x402 payment flow. Fetches a URL, and if the
1393
1500
  error: `${tokenLabel} on chain ${chainId} does not implement EIP-3009, so an "exact" x402 payment in it cannot settle; nothing was signed. The endpoint would need an "upto" option or a USDC requirement.`,
1394
1501
  });
1395
1502
  }
1503
+ // Step 3d: The reuse key. It is computed before the cap so that an
1504
+ // authorization already signed for exactly this purchase is re-sent even when
1505
+ // the cap or approval that admitted it is no longer available (an approval is
1506
+ // consumed by the signature, so a retry after a dropped paid request used to
1507
+ // dead-end on "already used", round 3). The body counts only when it is sent.
1508
+ const METHOD = (method || 'GET').toUpperCase();
1509
+ const bodyDigest = createHash('sha256').update(METHOD !== 'GET' && reqBody ? reqBody : '').digest('hex').slice(0, 16);
1510
+ const walletKey = anyLocalMode() ? 'local' : String(wallet_id); // the local signer has one key whatever wallet_id says
1511
+ const resourceTag = [walletKey, chainId, authorizationResource(url), METHOD, bodyDigest].join('|');
1512
+ const cacheKey = [walletKey, chainId, tokenAddress.toLowerCase(), option.payTo.toLowerCase(), rawAmount, option.scheme, resourceTag].join('|');
1513
+ for (const [k, v] of signedPayments)
1514
+ if (v.until <= Date.now())
1515
+ signedPayments.delete(k);
1516
+ const alreadySigned = !fresh_authorization && (signedPayments.get(cacheKey)?.until ?? 0) > Date.now();
1396
1517
  // Step 4: Hard per-payment cap, ALWAYS applied. If the caller omits max_payment
1397
1518
  // we fall back to AGENTWALLET_MAX_AUTOPAY (default "1"). Above the cap a hosted
1398
1519
  // wallet can ask its owner instead of refusing: an approval is created, the
@@ -1402,7 +1523,7 @@ server.tool('pay_x402', 'Handle an x402 payment flow. Fetches a URL, and if the
1402
1523
  // above it is ignored and reported, because the agent is what the cap bounds.
1403
1524
  const { cap: effectiveMax, source: capSource, clamped } = effectiveAutopayCap(max_payment);
1404
1525
  let approvalUsed = null;
1405
- if (!isWithinCap(rawAmount, trustedDecimals, effectiveMax)) {
1526
+ if (!alreadySigned && !isWithinCap(rawAmount, trustedDecimals, effectiveMax)) {
1406
1527
  const overCap = {
1407
1528
  status: 402, payment_required: true, payment_made: false,
1408
1529
  required_amount: amount, max_allowed: effectiveMax, cap_source: capSource, token: tokenLabel,
@@ -1411,9 +1532,12 @@ server.tool('pay_x402', 'Handle an x402 payment flow. Fetches a URL, and if the
1411
1532
  };
1412
1533
  const wantApproval = request_approval ?? (process.env.AGENTWALLET_APPROVALS !== '0');
1413
1534
  if (approval_id) {
1535
+ if (!willSign) {
1536
+ return jsonResponse({ ...overCap, approval_id, error: `Approval ${approval_id} cannot cover this payment: it is a broadcast transfer (native coin, Solana, or an AgentWallet receipt paywall), which the hosted signer does not bind to an approval, so the same id would lift the cap on every call. Approvals cover EIP-3009 "exact" and Permit2 "upto" authorizations only.` });
1537
+ }
1414
1538
  // Local mode refuses before any lookup: only the hosted signer can consume
1415
1539
  // an approval, so here the same id would lift the cap on every call.
1416
- const want = { walletId: wallet_id, chainId, asset: tokenAddress, payTo: option.payTo, rawAmount };
1540
+ const want = { walletId: wallet_id, chainId, asset: tokenAddress, payTo: option.payTo, rawAmount, scheme: option.scheme };
1417
1541
  const local = anyLocalMode();
1418
1542
  const a = local ? null : (await api(`/approvals/${encodeURIComponent(approval_id)}`, 'GET', undefined, skip));
1419
1543
  const refusal = approvalRefusal(approval_id, a, want, local);
@@ -1422,10 +1546,10 @@ server.tool('pay_x402', 'Handle an x402 payment flow. Fetches a URL, and if the
1422
1546
  }
1423
1547
  approvalUsed = String(approval_id);
1424
1548
  }
1425
- else if (wantApproval && !anyLocalMode()) {
1549
+ else if (wantApproval && !anyLocalMode() && willSign) {
1426
1550
  const created = (await api('/approvals', 'POST', {
1427
1551
  wallet_id, chain_id: chainId, scheme: option.scheme, asset: tokenAddress, pay_to: option.payTo, value: rawAmount,
1428
- amount_human: amount, token_name: tokenLabel, url,
1552
+ amount_human: amount, token_name: tokenLabel, url: approvalResourceUrl(url),
1429
1553
  }, skip));
1430
1554
  if (!created?.id) {
1431
1555
  return jsonResponse({ ...overCap, error: `Payment of ${amount} ${tokenLabel} exceeds the ${effectiveMax} cap and the approval request failed: ${created?.error || 'unknown error'}.` });
@@ -1451,7 +1575,7 @@ server.tool('pay_x402', 'Handle an x402 payment flow. Fetches a URL, and if the
1451
1575
  // usage; the payer broadcasts nothing and pays no gas for either. AgentWallet's
1452
1576
  // own paywalls (verified by receipt), native-asset requests and Solana still use
1453
1577
  // a broadcast transfer proved by hash.
1454
- const standardExact = !isUpto && !isSolanaChain(chainId) && Boolean(tokenAddress) && !legacyReceipt;
1578
+ const standardExact = !isUpto && willSign;
1455
1579
  let headerName = 'X-PAYMENT';
1456
1580
  let paymentHeader = '';
1457
1581
  let txHash = null;
@@ -1467,16 +1591,35 @@ server.tool('pay_x402', 'Handle an x402 payment flow. Fetches a URL, and if the
1467
1591
  // The method and body are part of what was bought: POST {item:A} and
1468
1592
  // POST {item:B} to one URL are two purchases. Concurrent calls for one key
1469
1593
  // wait for the first signature instead of each signing a nonce.
1470
- const bodyDigest = createHash('sha256').update(reqBody ?? '').digest('hex').slice(0, 16);
1471
- const cacheKey = [wallet_id, chainId, tokenAddress.toLowerCase(), option.payTo.toLowerCase(), rawAmount, option.scheme, authorizationResource(url), (method || 'GET').toUpperCase(), bodyDigest].join('|');
1472
- const inFlight = signingInFlight.get(cacheKey);
1473
- if (inFlight && !fresh_authorization)
1474
- await inFlight;
1594
+ // (bodyDigest and cacheKey were computed in Step 3d, before the cap.)
1595
+ // A mutex, not a one-shot wait: when the first signer fails nothing is cached,
1596
+ // and every waiter used to proceed at once and sign its own nonce (round 3).
1597
+ // The resource is a second mutex key: parallel calls whose requirements differ
1598
+ // (a paywall varying the amount per answer) would otherwise sign side by side
1599
+ // before any of them could see the other's live authorization.
1600
+ const resourceLock = 'resource|' + resourceTag;
1601
+ while (!fresh_authorization && (signingInFlight.get(cacheKey) || signingInFlight.get(resourceLock))) {
1602
+ await (signingInFlight.get(cacheKey) || signingInFlight.get(resourceLock));
1603
+ }
1475
1604
  const cachedAuth = fresh_authorization ? undefined : signedPayments.get(cacheKey);
1476
- for (const [k, v] of signedPayments)
1477
- if (v.until <= Date.now())
1478
- signedPayments.delete(k);
1605
+ if (!(cachedAuth && cachedAuth.until > Date.now()) && !fresh_authorization) {
1606
+ // One live authorization per resource. A paywall that answers every call with
1607
+ // a slightly different requirement (amount 999999, 999998, ...) used to harvest
1608
+ // a cap-sized nonce per call (round 3); the second distinct requirement for the
1609
+ // same purchase is refused until the first expires or fresh_authorization is passed.
1610
+ const other = [...signedPayments.entries()].find(([k, v]) => k !== cacheKey && v.resource === resourceTag && v.until > Date.now());
1611
+ if (other) {
1612
+ const [, live] = other;
1613
+ return jsonResponse({
1614
+ status: 402, payment_required: true, payment_made: false,
1615
+ error: `This resource already holds a live authorization signed ${Math.round((Date.now() - live.signedAt) / 1000)} s ago for ${live.amount} ${tokenLabel} (the endpoint's requirement changed between answers: amount, recipient, asset or scheme). It is re-sent by calling pay_x402 again with the same arguments once the endpoint asks for the same requirement. Nothing more is signed for this resource until that authorization expires.`,
1616
+ live_authorization: { amount: live.amount, valid_until: new Date(live.until).toISOString(), payer: live.payer },
1617
+ requested_amount: amount, token: tokenLabel, pay_to: option.payTo, chain_id: chainId,
1618
+ });
1619
+ }
1620
+ }
1479
1621
  let releaseSigning = () => { };
1622
+ let mineLock = null;
1480
1623
  if (!(cachedAuth && cachedAuth.until > Date.now())) {
1481
1624
  const mine = new Promise(r => { releaseSigning = r; });
1482
1625
  // Released in the finally below, whatever path this call takes. A timer
@@ -1484,6 +1627,10 @@ server.tool('pay_x402', 'Handle an x402 payment flow. Fetches a URL, and if the
1484
1627
  // a second call signed a second nonce (2026-10-04 round 2); every
1485
1628
  // network hop inside the gate carries its own timeout instead.
1486
1629
  signingInFlight.set(cacheKey, mine);
1630
+ if (!fresh_authorization) {
1631
+ signingInFlight.set(resourceLock, mine);
1632
+ mineLock = mine;
1633
+ }
1487
1634
  }
1488
1635
  try {
1489
1636
  if (cachedAuth && cachedAuth.until > Date.now()) {
@@ -1510,7 +1657,7 @@ server.tool('pay_x402', 'Handle an x402 payment flow. Fetches a URL, and if the
1510
1657
  token: tokenLabel, token_address: tokenAddress, chain_id: chainId, payer, permit2: permit2Address(chainId),
1511
1658
  error: `This endpoint uses the x402 "upto" scheme, which settles through Permit2. Wallet ${payer} has not approved ` +
1512
1659
  `${tokenLabel} to Permit2 on chain ${chainId}. Call approve_permit2 once for this token (a normal transaction that needs gas), ` +
1513
- `or set AGENTWALLET_PERMIT2_AUTO_APPROVE=1, then call pay_x402 again.`,
1660
+ `then call pay_x402 again.` + (assetPolicy.via === 'stablecoin' ? ' The operator may instead set AGENTWALLET_PERMIT2_AUTO_APPROVE=1 (registry stablecoins only); that is the operator\'s setting, not the agent\'s.' : ''),
1514
1661
  });
1515
1662
  }
1516
1663
  }
@@ -1555,12 +1702,17 @@ server.tool('pay_x402', 'Handle an x402 payment flow. Fetches a URL, and if the
1555
1702
  const until = authorization ? Number(authorization.validBefore) * 1000
1556
1703
  : uptoAuth ? Number(uptoAuth.deadline) * 1000
1557
1704
  : Date.now() + maxAuthWindowSeconds() * 1000; // a broadcast transfer: the hash stays valid, re-sending it never pays twice
1558
- signedPayments.set(cacheKey, { headerName, paymentHeader, txHash, authorization, uptoAuth, payer, until });
1705
+ signedPayments.set(cacheKey, { headerName, paymentHeader, txHash, authorization, uptoAuth, payer, until, resource: resourceTag, amount, signedAt: Date.now() });
1706
+ // A receipt on stderr the moment a signature exists, so a result the transport
1707
+ // drops (a client crash, an oversized frame) is still auditable (round 3).
1708
+ console.error(`[agentwallet] x402 authorization sent: ${amount} ${tokenLabel} to ${option.payTo} on chain ${chainId} for ${authorizationResource(url)} (${authorization ? 'nonce ' + authorization.nonce : uptoAuth ? 'permit2 nonce ' + uptoAuth.nonce : 'tx ' + txHash})`);
1559
1709
  }
1560
1710
  }
1561
1711
  finally {
1562
1712
  releaseSigning();
1563
1713
  signingInFlight.delete(cacheKey);
1714
+ if (signingInFlight.get(resourceLock) === mineLock)
1715
+ signingInFlight.delete(resourceLock);
1564
1716
  }
1565
1717
  // Step 6: Retry with the payment header and read the settlement receipt.
1566
1718
  const retryHeaders = { ...reqHeaders, [headerName]: paymentHeader };
@@ -1585,7 +1737,7 @@ server.tool('pay_x402', 'Handle an x402 payment flow. Fetches a URL, and if the
1585
1737
  tx_hash: txHash ?? null, amount, token: tokenLabel, pay_to: option.payTo, chain_id: chainId,
1586
1738
  });
1587
1739
  }
1588
- const retryText = await retryRes.text();
1740
+ const retryText = await readBounded(retryRes, PAYWALL_MAX_CHARS, 'The endpoint');
1589
1741
  let retryParsed;
1590
1742
  try {
1591
1743
  retryParsed = JSON.parse(retryText);
@@ -1600,6 +1752,8 @@ server.tool('pay_x402', 'Handle an x402 payment flow. Fetches a URL, and if the
1600
1752
  const retryServedFrom = finalUrlOf(retryRes, url);
1601
1753
  const retryCrossOrigin = new URL(retryServedFrom).origin !== new URL(url).origin;
1602
1754
  const verdict = settlementVerdict(retryRes.status, settlement, retryCrossOrigin);
1755
+ const claimedRaw = typeof settlement?.amount === 'string' && /^\d+$/.test(settlement.amount) ? settlement.amount : null;
1756
+ const claimedAboveMax = claimedRaw !== null && BigInt(claimedRaw) > BigInt(rawAmount);
1603
1757
  if (retryCrossOrigin) {
1604
1758
  retryError = `The paid request was redirected to another origin (${new URL(retryServedFrom).origin}); the payment header is never forwarded across origins, so this response did not see the payment.`;
1605
1759
  }
@@ -1624,6 +1778,9 @@ server.tool('pay_x402', 'Handle an x402 payment flow. Fetches a URL, and if the
1624
1778
  permit2_approval_tx: permit2ApprovalTx,
1625
1779
  x402_version: x402Version,
1626
1780
  amount,
1781
+ // For upto the amount is the MAXIMUM the facilitator may settle, not what was
1782
+ // taken; the server's claimed settled amount is shown apart and unverified (round 3).
1783
+ ...(isUpto ? { amount_is_maximum: true, authorized_max: amount, settled_amount_claimed: serverText(settlement?.amount), settled_claim_inconsistent: claimedAboveMax } : {}),
1627
1784
  token: tokenLabel,
1628
1785
  token_address: tokenAddress || null,
1629
1786
  network: option.network,
@@ -1936,8 +2093,12 @@ server.tool('export_wallet_key', 'Explain how to export the private key of a hos
1936
2093
  // ─── Start ──────────────────────────────────────────────────────
1937
2094
  async function main() {
1938
2095
  validateGuardEnv();
1939
- const transport = new StdioServerTransport();
2096
+ // 64 MB frames before the SDK closes the transport (default 10 MB); errors are logged, not swallowed (round 3).
2097
+ const transport = new StdioServerTransport(process.stdin, process.stdout, { maxBufferSize: 64 * 1024 * 1024 });
2098
+ process.stdout.on('error', (e) => { if (e.code === 'EPIPE')
2099
+ process.exit(0); console.error('[agentwallet] stdout error: ' + e.message); });
1940
2100
  await server.connect(transport);
2101
+ server.server.onerror = (e) => console.error('[agentwallet] transport error: ' + e.message);
1941
2102
  /* stderr, so it never corrupts the stdio JSON-RPC stream. Announcing custody
1942
2103
  at startup means an operator sees which mode they are in without asking. */
1943
2104
  if (anyLocalMode()) {
@@ -17,6 +17,7 @@
17
17
  import { readFileSync, statSync } from 'node:fs';
18
18
  import { createPublicClient, createWalletClient, http, encodeFunctionData, parseAbi, formatUnits, } from 'viem';
19
19
  import { privateKeyToAccount } from 'viem/accounts';
20
+ import { nonceManager } from 'viem/nonce';
20
21
  import { lookupTrustedDecimals, maxAuthWindowSeconds } from './x402-payment.js';
21
22
  import { wrappedNativeAddress } from './wrapped-native.js';
22
23
  import { typedDataFor } from './x402-eip3009.js';
@@ -79,7 +80,9 @@ export function getLocalAccount() {
79
80
  throw new Error(keyLoadError); // the keyfile's own reason, before the generic line
80
81
  if (!raw)
81
82
  throw new Error('Local signing mode is not configured.');
82
- cachedAccount = privateKeyToAccount(normalizeKey(raw));
83
+ // The nonce manager hands parallel sends consecutive nonces; without it every
84
+ // concurrent send read the same pending nonce and all but one failed (round 3).
85
+ cachedAccount = privateKeyToAccount(normalizeKey(raw), { nonceManager });
83
86
  return cachedAccount;
84
87
  }
85
88
  /** The address the local key controls. Safe to log. */
@@ -121,7 +121,14 @@ export function decodeAbiString(hex) {
121
121
  if (!Number.isFinite(len) || len <= 0 || len > 256)
122
122
  return '';
123
123
  const bytes = Buffer.from(h.slice(128, 128 + len * 2), 'hex');
124
- return bytes.toString('utf8');
124
+ // The domain separator is keccak of the raw bytes: a name that is not valid
125
+ // UTF-8 cannot be reproduced from a string, so it is refused, never guessed.
126
+ try {
127
+ return new TextDecoder('utf-8', { fatal: true }).decode(bytes);
128
+ }
129
+ catch {
130
+ return '';
131
+ }
125
132
  }
126
133
  /** Build the payment payload the resource server expects for this x402 version. */
127
134
  export function buildPaymentPayload(x402Version, req, auth, signature, resource, extensions) {
@@ -188,7 +195,11 @@ export function parsePaymentRequired(getHeader, body) {
188
195
  network and payTo are strings. A null entry, or an array where an address
189
196
  should be, used to reach the pickers and surface a raw TypeError or a
190
197
  coerced value (2026-10-04 round 2). Numeric amounts are kept as strings. */
191
- const MAX_ACCEPTS = 64; // a bound on the picker's work, well above anything a real server lists
198
+ const MAX_ACCEPTS = 64;
199
+ /** An all-uppercase hex address is not an EIP-55 checksum and viem refuses it at signing; lowercase has no checksum to fail (round 3). Mixed case is kept: it IS a checksum. */
200
+ function lowerIfAllCaps(v) {
201
+ return (typeof v === 'string' && /^0x[0-9A-F]{40}$/.test(v) && /[A-F]/.test(v)) ? v.toLowerCase() : v;
202
+ } // a bound on the picker's work, well above anything a real server lists
192
203
  export function sanitizeRequired(req) {
193
204
  const str = (v) => (typeof v === 'string' ? v : typeof v === 'number' && Number.isFinite(v) ? String(v) : undefined);
194
205
  const accepts = [];
@@ -196,7 +207,7 @@ export function sanitizeRequired(req) {
196
207
  if (!a || typeof a !== 'object' || Array.isArray(a))
197
208
  continue;
198
209
  const o = a;
199
- const scheme = str(o.scheme), network = str(o.network), payTo = str(o.payTo);
210
+ const scheme = str(o.scheme), network = str(o.network), payTo = lowerIfAllCaps(str(o.payTo));
200
211
  if (scheme === undefined || network === undefined || payTo === undefined)
201
212
  continue;
202
213
  const extraIn = (o.extra && typeof o.extra === 'object' && !Array.isArray(o.extra)) ? o.extra : undefined;
@@ -206,6 +217,8 @@ export function sanitizeRequired(req) {
206
217
  if (k in extra && typeof extra[k] !== 'string')
207
218
  delete extra[k];
208
219
  }
220
+ if (extra && typeof extra.token === 'string')
221
+ extra.token = lowerIfAllCaps(extra.token);
209
222
  const out = { ...o, scheme, network, payTo };
210
223
  for (const k of ['amount', 'maxAmountRequired', 'asset', 'description', 'resource', 'mimeType']) {
211
224
  const v = str(o[k]);
@@ -214,6 +227,8 @@ export function sanitizeRequired(req) {
214
227
  else
215
228
  out[k] = v;
216
229
  }
230
+ if (typeof out.asset === 'string')
231
+ out.asset = lowerIfAllCaps(out.asset);
217
232
  if (extra)
218
233
  out.extra = extra;
219
234
  else
@@ -267,7 +282,11 @@ export function pickOption(accepts, resolveChainId, preferChain) {
267
282
  const payable = [...exact, ...upto];
268
283
  if (payable.length === 0) {
269
284
  const schemes = Array.from(new Set(accepts.map(a => String(a.scheme).slice(0, 40)))).slice(0, 10).join(', ') || 'none';
270
- const reason = accepts.some(a => a.scheme === 'upto')
285
+ if (onChain.length === 0 && accepts.length > 0) {
286
+ const nets = Array.from(new Set(accepts.map(a => String(a.network).slice(0, 40)))).slice(0, 10).join(', ');
287
+ return { option: null, chainId: null, reason: `No payable option: the offered network(s) ${nets} are not ones this client knows (pass a CAIP-2 id like eip155:8453 or a chain id).` };
288
+ }
289
+ const reason = onChain.some(a => a.scheme === 'upto')
271
290
  ? 'This endpoint offers the x402 "upto" scheme without an extra.facilitatorAddress (or without an asset), so the ' +
272
291
  'Permit2 authorization cannot be bound to a facilitator. AgentWallet will not approximate it with an upfront transfer. ' +
273
292
  'Ask the endpoint operator to publish facilitatorAddress, or an "exact" option.'
@@ -177,6 +177,7 @@ export declare function isWithinCap(rawAmountRequired: string, trustedDecimals:
177
177
  /** The row GET /approvals/{id} answers with. Numbers arrive as strings from the server. */
178
178
  export interface ApprovalRow {
179
179
  status?: string;
180
+ scheme?: string;
180
181
  used?: number | string;
181
182
  wallet_id?: number | string;
182
183
  chain_id?: number | string;
@@ -191,7 +192,10 @@ export interface ApprovalWant {
191
192
  asset: string;
192
193
  payTo: string;
193
194
  rawAmount: string;
195
+ scheme?: string;
194
196
  }
197
+ /** The url stored with an approval request: origin and path only, never userinfo or a query (round 3). */
198
+ export declare function approvalResourceUrl(url: string): string;
195
199
  /**
196
200
  * Why an approval cannot stand in for the cap on this payment, or null when it can.
197
201
  *
@@ -386,6 +386,11 @@ export function isWithinCap(rawAmountRequired, trustedDecimals, capHuman) {
386
386
  const capRaw = toBaseUnits(capHuman, trustedDecimals);
387
387
  return BigInt(rawAmountRequired) <= BigInt(capRaw);
388
388
  }
389
+ /** The url stored with an approval request: origin and path only, never userinfo or a query (round 3). */
390
+ export function approvalResourceUrl(url) {
391
+ const u = new URL(url);
392
+ return u.origin + u.pathname;
393
+ }
389
394
  /**
390
395
  * Why an approval cannot stand in for the cap on this payment, or null when it can.
391
396
  *
@@ -424,10 +429,11 @@ export function approvalRefusal(approvalId, row, want, localMode) {
424
429
  && Number(row?.chain_id) === want.chainId
425
430
  && String(row?.asset ?? '').toLowerCase() === want.asset.toLowerCase()
426
431
  && String(row?.pay_to ?? '').toLowerCase() === want.payTo.toLowerCase()
432
+ && (!want.scheme || !row?.scheme || String(row.scheme) === want.scheme)
427
433
  && /^\d+$/.test(value) && /^\d+$/.test(want.rawAmount)
428
434
  && BigInt(value) >= BigInt(want.rawAmount);
429
435
  if (!covers) {
430
- return { error: 'Approval does not cover this payment (wallet, asset, recipient, chain or amount differ).' };
436
+ return { error: 'Approval does not cover this payment (wallet, asset, recipient, chain, scheme or amount differ).' };
431
437
  }
432
438
  return null;
433
439
  }
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.7",
4
+ "version": "1.13.9",
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 && node test/audit-1135.test.mjs && node test/local-no-autopay.test.mjs && node test/audit-1136.test.mjs && node test/delete-confirm.test.mjs",
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 && node test/audit-1136.test.mjs && node test/delete-confirm.test.mjs && node test/audit-1138.test.mjs && node test/audit-1139.test.mjs",
20
20
  "prepublishOnly": "npm run build"
21
21
  },
22
22
  "keywords": [