agentwallet-mcp 1.13.6 → 1.13.8

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,7 +13,7 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
13
13
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
14
14
  import { z } from 'zod';
15
15
  import { assertPublicUrl, safeFetch, 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';
@@ -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.6',
682
+ version: '1.13.8',
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
  });
@@ -1325,7 +1425,7 @@ server.tool('pay_x402', 'Handle an x402 payment flow. Fetches a URL, and if the
1325
1425
  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
1426
  final_url: servedFrom });
1327
1427
  }
1328
- const initialText = await initialRes.text();
1428
+ const initialText = await readBounded(initialRes, PAYWALL_MAX_CHARS, 'The endpoint');
1329
1429
  let initialBody = null;
1330
1430
  try {
1331
1431
  initialBody = JSON.parse(initialText);
@@ -1386,6 +1486,10 @@ server.tool('pay_x402', 'Handle an x402 payment flow. Fetches a URL, and if the
1386
1486
  // "exact" requirement in a token with no EIP-3009 can never settle, so it
1387
1487
  // is refused before an approval could be consumed for it.
1388
1488
  const legacyReceipt = isLegacyAgentWalletAccept(option) && legacyReceiptAllowed(servedFrom, API_BASE, process.env.AGENTWALLET_LEGACY_PAYWALL_ORIGINS);
1489
+ // Only a signed authorization (EIP-3009 "exact" or Permit2 "upto") is bound to an
1490
+ // approval by the hosted signer; a broadcast transfer (native coin, Solana, an
1491
+ // AgentWallet receipt paywall) is not, so an approval must never lift the cap for one.
1492
+ const willSign = isUpto || (!isSolanaChain(chainId) && Boolean(tokenAddress) && !legacyReceipt);
1389
1493
  if (!isUpto && !isSolanaChain(chainId) && tokenAddress && !legacyReceipt && !assetSupportsExact(chainId, tokenAddress)) {
1390
1494
  return jsonResponse({
1391
1495
  status: 402, payment_required: true, payment_made: false,
@@ -1393,6 +1497,20 @@ server.tool('pay_x402', 'Handle an x402 payment flow. Fetches a URL, and if the
1393
1497
  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
1498
  });
1395
1499
  }
1500
+ // Step 3d: The reuse key. It is computed before the cap so that an
1501
+ // authorization already signed for exactly this purchase is re-sent even when
1502
+ // the cap or approval that admitted it is no longer available (an approval is
1503
+ // consumed by the signature, so a retry after a dropped paid request used to
1504
+ // dead-end on "already used", round 3). The body counts only when it is sent.
1505
+ const METHOD = (method || 'GET').toUpperCase();
1506
+ const bodyDigest = createHash('sha256').update(METHOD !== 'GET' && reqBody ? reqBody : '').digest('hex').slice(0, 16);
1507
+ const walletKey = anyLocalMode() ? 'local' : String(wallet_id); // the local signer has one key whatever wallet_id says
1508
+ const resourceTag = [walletKey, chainId, authorizationResource(url), METHOD, bodyDigest].join('|');
1509
+ const cacheKey = [walletKey, chainId, tokenAddress.toLowerCase(), option.payTo.toLowerCase(), rawAmount, option.scheme, resourceTag].join('|');
1510
+ for (const [k, v] of signedPayments)
1511
+ if (v.until <= Date.now())
1512
+ signedPayments.delete(k);
1513
+ const alreadySigned = !fresh_authorization && (signedPayments.get(cacheKey)?.until ?? 0) > Date.now();
1396
1514
  // Step 4: Hard per-payment cap, ALWAYS applied. If the caller omits max_payment
1397
1515
  // we fall back to AGENTWALLET_MAX_AUTOPAY (default "1"). Above the cap a hosted
1398
1516
  // wallet can ask its owner instead of refusing: an approval is created, the
@@ -1402,7 +1520,7 @@ server.tool('pay_x402', 'Handle an x402 payment flow. Fetches a URL, and if the
1402
1520
  // above it is ignored and reported, because the agent is what the cap bounds.
1403
1521
  const { cap: effectiveMax, source: capSource, clamped } = effectiveAutopayCap(max_payment);
1404
1522
  let approvalUsed = null;
1405
- if (!isWithinCap(rawAmount, trustedDecimals, effectiveMax)) {
1523
+ if (!alreadySigned && !isWithinCap(rawAmount, trustedDecimals, effectiveMax)) {
1406
1524
  const overCap = {
1407
1525
  status: 402, payment_required: true, payment_made: false,
1408
1526
  required_amount: amount, max_allowed: effectiveMax, cap_source: capSource, token: tokenLabel,
@@ -1411,9 +1529,12 @@ server.tool('pay_x402', 'Handle an x402 payment flow. Fetches a URL, and if the
1411
1529
  };
1412
1530
  const wantApproval = request_approval ?? (process.env.AGENTWALLET_APPROVALS !== '0');
1413
1531
  if (approval_id) {
1532
+ if (!willSign) {
1533
+ 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.` });
1534
+ }
1414
1535
  // Local mode refuses before any lookup: only the hosted signer can consume
1415
1536
  // 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 };
1537
+ const want = { walletId: wallet_id, chainId, asset: tokenAddress, payTo: option.payTo, rawAmount, scheme: option.scheme };
1417
1538
  const local = anyLocalMode();
1418
1539
  const a = local ? null : (await api(`/approvals/${encodeURIComponent(approval_id)}`, 'GET', undefined, skip));
1419
1540
  const refusal = approvalRefusal(approval_id, a, want, local);
@@ -1422,10 +1543,10 @@ server.tool('pay_x402', 'Handle an x402 payment flow. Fetches a URL, and if the
1422
1543
  }
1423
1544
  approvalUsed = String(approval_id);
1424
1545
  }
1425
- else if (wantApproval && !anyLocalMode()) {
1546
+ else if (wantApproval && !anyLocalMode() && willSign) {
1426
1547
  const created = (await api('/approvals', 'POST', {
1427
1548
  wallet_id, chain_id: chainId, scheme: option.scheme, asset: tokenAddress, pay_to: option.payTo, value: rawAmount,
1428
- amount_human: amount, token_name: tokenLabel, url,
1549
+ amount_human: amount, token_name: tokenLabel, url: approvalResourceUrl(url),
1429
1550
  }, skip));
1430
1551
  if (!created?.id) {
1431
1552
  return jsonResponse({ ...overCap, error: `Payment of ${amount} ${tokenLabel} exceeds the ${effectiveMax} cap and the approval request failed: ${created?.error || 'unknown error'}.` });
@@ -1451,7 +1572,7 @@ server.tool('pay_x402', 'Handle an x402 payment flow. Fetches a URL, and if the
1451
1572
  // usage; the payer broadcasts nothing and pays no gas for either. AgentWallet's
1452
1573
  // own paywalls (verified by receipt), native-asset requests and Solana still use
1453
1574
  // a broadcast transfer proved by hash.
1454
- const standardExact = !isUpto && !isSolanaChain(chainId) && Boolean(tokenAddress) && !legacyReceipt;
1575
+ const standardExact = !isUpto && willSign;
1455
1576
  let headerName = 'X-PAYMENT';
1456
1577
  let paymentHeader = '';
1457
1578
  let txHash = null;
@@ -1467,16 +1588,35 @@ server.tool('pay_x402', 'Handle an x402 payment flow. Fetches a URL, and if the
1467
1588
  // The method and body are part of what was bought: POST {item:A} and
1468
1589
  // POST {item:B} to one URL are two purchases. Concurrent calls for one key
1469
1590
  // 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;
1591
+ // (bodyDigest and cacheKey were computed in Step 3d, before the cap.)
1592
+ // A mutex, not a one-shot wait: when the first signer fails nothing is cached,
1593
+ // and every waiter used to proceed at once and sign its own nonce (round 3).
1594
+ // The resource is a second mutex key: parallel calls whose requirements differ
1595
+ // (a paywall varying the amount per answer) would otherwise sign side by side
1596
+ // before any of them could see the other's live authorization.
1597
+ const resourceLock = 'resource|' + resourceTag;
1598
+ while (!fresh_authorization && (signingInFlight.get(cacheKey) || signingInFlight.get(resourceLock))) {
1599
+ await (signingInFlight.get(cacheKey) || signingInFlight.get(resourceLock));
1600
+ }
1475
1601
  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);
1602
+ if (!(cachedAuth && cachedAuth.until > Date.now()) && !fresh_authorization) {
1603
+ // One live authorization per resource. A paywall that answers every call with
1604
+ // a slightly different requirement (amount 999999, 999998, ...) used to harvest
1605
+ // a cap-sized nonce per call (round 3); the second distinct requirement for the
1606
+ // same purchase is refused until the first expires or fresh_authorization is passed.
1607
+ const other = [...signedPayments.entries()].find(([k, v]) => k !== cacheKey && v.resource === resourceTag && v.until > Date.now());
1608
+ if (other) {
1609
+ const [, live] = other;
1610
+ return jsonResponse({
1611
+ status: 402, payment_required: true, payment_made: false,
1612
+ 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; to sign a second authorization for this resource pass fresh_authorization=true.`,
1613
+ live_authorization: { amount: live.amount, valid_until: new Date(live.until).toISOString(), payer: live.payer },
1614
+ requested_amount: amount, token: tokenLabel, pay_to: option.payTo, chain_id: chainId,
1615
+ });
1616
+ }
1617
+ }
1479
1618
  let releaseSigning = () => { };
1619
+ let mineLock = null;
1480
1620
  if (!(cachedAuth && cachedAuth.until > Date.now())) {
1481
1621
  const mine = new Promise(r => { releaseSigning = r; });
1482
1622
  // Released in the finally below, whatever path this call takes. A timer
@@ -1484,6 +1624,10 @@ server.tool('pay_x402', 'Handle an x402 payment flow. Fetches a URL, and if the
1484
1624
  // a second call signed a second nonce (2026-10-04 round 2); every
1485
1625
  // network hop inside the gate carries its own timeout instead.
1486
1626
  signingInFlight.set(cacheKey, mine);
1627
+ if (!fresh_authorization) {
1628
+ signingInFlight.set(resourceLock, mine);
1629
+ mineLock = mine;
1630
+ }
1487
1631
  }
1488
1632
  try {
1489
1633
  if (cachedAuth && cachedAuth.until > Date.now()) {
@@ -1555,12 +1699,17 @@ server.tool('pay_x402', 'Handle an x402 payment flow. Fetches a URL, and if the
1555
1699
  const until = authorization ? Number(authorization.validBefore) * 1000
1556
1700
  : uptoAuth ? Number(uptoAuth.deadline) * 1000
1557
1701
  : 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 });
1702
+ signedPayments.set(cacheKey, { headerName, paymentHeader, txHash, authorization, uptoAuth, payer, until, resource: resourceTag, amount, signedAt: Date.now() });
1703
+ // A receipt on stderr the moment a signature exists, so a result the transport
1704
+ // drops (a client crash, an oversized frame) is still auditable (round 3).
1705
+ 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
1706
  }
1560
1707
  }
1561
1708
  finally {
1562
1709
  releaseSigning();
1563
1710
  signingInFlight.delete(cacheKey);
1711
+ if (signingInFlight.get(resourceLock) === mineLock)
1712
+ signingInFlight.delete(resourceLock);
1564
1713
  }
1565
1714
  // Step 6: Retry with the payment header and read the settlement receipt.
1566
1715
  const retryHeaders = { ...reqHeaders, [headerName]: paymentHeader };
@@ -1585,7 +1734,7 @@ server.tool('pay_x402', 'Handle an x402 payment flow. Fetches a URL, and if the
1585
1734
  tx_hash: txHash ?? null, amount, token: tokenLabel, pay_to: option.payTo, chain_id: chainId,
1586
1735
  });
1587
1736
  }
1588
- const retryText = await retryRes.text();
1737
+ const retryText = await readBounded(retryRes, PAYWALL_MAX_CHARS, 'The endpoint');
1589
1738
  let retryParsed;
1590
1739
  try {
1591
1740
  retryParsed = JSON.parse(retryText);
@@ -1712,10 +1861,17 @@ server.tool('get_chains', 'List all supported chains (EVM + Solana) with their c
1712
1861
  });
1713
1862
  // ─── Tool: delete_wallet ─────────────────────────────────────────
1714
1863
  server.tool('delete_wallet', 'Delete (soft-delete) a wallet. The wallet will no longer appear in listings ' +
1715
- 'and cannot be used for transactions.', {
1864
+ 'and cannot be used for transactions. The wallet is read first and the delete names its address, ' +
1865
+ 'so a wallet that cannot be read is never deleted.', {
1716
1866
  wallet_id: z.number().int().describe('Wallet ID to delete'),
1717
1867
  }, async ({ wallet_id }) => {
1718
- const data = await api(`/wallets/${wallet_id}`, 'DELETE');
1868
+ // The server asks the caller to name the wallet it means to delete, so a
1869
+ // leaked API key alone cannot destroy a key (2026-10-04 audit, server 1.7.1).
1870
+ const w = (await api(`/wallets/${wallet_id}`, 'GET', undefined, { 'X-AGW-SKIP-X402': 'true' }));
1871
+ const address = String(w?.address || w?.wallet_address || '');
1872
+ if (!address)
1873
+ throw new Error(`Wallet ${wallet_id} could not be read (${serverText(w?.error) || 'no address in the response'}); nothing was deleted.`);
1874
+ const data = await api(`/wallets/${wallet_id}`, 'DELETE', { confirm: address });
1719
1875
  return jsonResponse(data);
1720
1876
  });
1721
1877
  // ─── Tool: create_paywall ────────────────────────────────────────
@@ -1929,8 +2085,12 @@ server.tool('export_wallet_key', 'Explain how to export the private key of a hos
1929
2085
  // ─── Start ──────────────────────────────────────────────────────
1930
2086
  async function main() {
1931
2087
  validateGuardEnv();
1932
- const transport = new StdioServerTransport();
2088
+ // 64 MB frames before the SDK closes the transport (default 10 MB); errors are logged, not swallowed (round 3).
2089
+ const transport = new StdioServerTransport(process.stdin, process.stdout, { maxBufferSize: 64 * 1024 * 1024 });
2090
+ process.stdout.on('error', (e) => { if (e.code === 'EPIPE')
2091
+ process.exit(0); console.error('[agentwallet] stdout error: ' + e.message); });
1933
2092
  await server.connect(transport);
2093
+ server.server.onerror = (e) => console.error('[agentwallet] transport error: ' + e.message);
1934
2094
  /* stderr, so it never corrupts the stdio JSON-RPC stream. Announcing custody
1935
2095
  at startup means an operator sees which mode they are in without asking. */
1936
2096
  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. */
@@ -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.6",
4
+ "version": "1.13.8",
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",
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",
20
20
  "prepublishOnly": "npm run build"
21
21
  },
22
22
  "keywords": [