agentwallet-mcp 1.13.5 → 1.13.6

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, } 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, } 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
- import { buildUptoAuthorization, uptoPayload, permit2AllowanceCalldata, permit2ApproveCalldata, decodeUint, PERMIT2_ADDRESS, } from './x402-permit2.js';
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, } from './x402-eip3009.js';
23
+ import { isLegacyAgentWalletAccept, requiredAmount, buildAuthorization, buildPaymentPayload, buildPaymentPayloadRaw, paymentHeaderFor, parsePaymentRequired, parseSettlement, pickOption, knownTokenDomain, 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() {
@@ -232,7 +232,7 @@ async function api(path, method = 'GET', body, extraHeaders) {
232
232
  // redirect is refused instead of followed, so a request body is never
233
233
  // replayed to another origin and another origin's answer is never returned
234
234
  // as the API's (2026-10-04 audit).
235
- const options = { method, headers, redirect: 'manual' };
235
+ const options = { method, headers, redirect: 'manual', signal: AbortSignal.timeout(API_TIMEOUT_MS) };
236
236
  if (body && method !== 'GET') {
237
237
  options.body = JSON.stringify(body);
238
238
  }
@@ -426,7 +426,8 @@ function isValidAddress(address) {
426
426
  }
427
427
  // ─── Guard helpers ──────────────────────────────────────────────
428
428
  /** Request headers the caller may not set on pay_x402: Host would rename the TLS peer, the rest belong to the client. */
429
- const FORBIDDEN_REQUEST_HEADERS = new Set(['host', 'content-length', 'transfer-encoding', 'connection', 'upgrade', 'expect', 'te', 'trailer', 'keep-alive']);
429
+ const FORBIDDEN_REQUEST_HEADERS = new Set(['host', 'content-length', 'transfer-encoding', 'connection', 'upgrade', 'expect', 'te', 'trailer', 'keep-alive',
430
+ 'x-payment', 'payment-signature', 'x-payment-response', 'payment-response']); // the payment headers are the tool's own to set
430
431
  /** Authorizations signed in this process, keyed by endpoint and requirement, kept until they expire. */
431
432
  const signingInFlight = new Map();
432
433
  const signedPayments = new Map();
@@ -450,6 +451,7 @@ function redactUrl(u) {
450
451
  }
451
452
  }
452
453
  const API_MAX_RESPONSE_CHARS = 8 * 1024 * 1024;
454
+ const API_TIMEOUT_MS = 60_000; // a hosted hop that hangs must not hold a signing gate open
453
455
  /* Error text from the local signing path with every URL reduced to its origin
454
456
  and viem's "URL:" / "Request body:" lines dropped: an RPC endpoint carries
455
457
  its API key in the path or query, and a tool result is the agent's context
@@ -458,7 +460,7 @@ function redactRpcSecrets(err) {
458
460
  const e = err;
459
461
  let msg = e?.shortMessage ? [e.shortMessage, e.details].filter(Boolean).join(' ') : String(e?.message ?? err);
460
462
  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));
463
+ return msg.replace(/[a-z][a-z0-9+.-]*:\/\/[^\s"'<>)]+/gi, (u) => redactUrl(u)); // brackets stay in: IPv6 hosts
462
464
  }
463
465
  /**
464
466
  * Decimals to scale a human amount by. Always the token's own value (registry,
@@ -531,7 +533,7 @@ function formatUnits(raw, decimals) {
531
533
  const frac = padded.slice(padded.length - decimals);
532
534
  // Trim trailing zeros but keep at least one decimal
533
535
  const trimmed = frac.replace(/0+$/, '') || '0';
534
- return `${whole}.${trimmed}`;
536
+ return decimals === 0 ? whole : `${whole}.${trimmed}`; // a 0-decimal amount has no fraction, so it round-trips through parseUnits
535
537
  }
536
538
  /**
537
539
  * Pad an address to 32 bytes (64 hex chars) for ABI encoding.
@@ -577,7 +579,7 @@ function resolveChainId(network) {
577
579
  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');
578
580
  const server = new McpServer({
579
581
  name: 'agentwallet',
580
- version: '1.13.5',
582
+ version: '1.13.6',
581
583
  }, {
582
584
  instructions: `AgentWallet gives AI agents their own blockchain wallets. ${anyLocalMode()
583
585
  ? '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.'
@@ -946,7 +948,7 @@ server.tool('approve_token', 'Approve a spender contract to transfer ERC-20 toke
946
948
  // Asset risk rides along with the approval (issue #13). It never blocks; it is the
947
949
  // caller's call. Skipped for Permit2, which only moves what a signature allows.
948
950
  let risk = null;
949
- if (spender.toLowerCase() !== PERMIT2_ADDRESS.toLowerCase() && process.env.AGENTWALLET_TOKEN_RISK !== '0') {
951
+ if (spender.toLowerCase() !== permit2Address(chain_id).toLowerCase() && process.env.AGENTWALLET_TOKEN_RISK !== '0') {
950
952
  try {
951
953
  risk = await assessTokenRisk(chain_id, token, (to, data) => ethCallHex(chain_id, to, data));
952
954
  }
@@ -1175,11 +1177,17 @@ async function ethCallHex(chainId, to, data) {
1175
1177
  * rather than guess.
1176
1178
  */
1177
1179
  async function resolveTokenDomain(chainId, asset, extra) {
1178
- if (extra?.name && extra?.version)
1179
- return { name: String(extra.name), version: String(extra.version) };
1180
1180
  const known = knownTokenDomain(chainId, asset);
1181
- if (known)
1181
+ const declared = (typeof extra?.name === 'string' && typeof extra?.version === 'string') ? { name: extra.name, version: extra.version } : null;
1182
+ if (known) {
1183
+ // A registry token's domain is known; a 402 declaring a different one would only yield a signature the token rejects.
1184
+ if (declared && (declared.name !== known.name || declared.version !== known.version)) {
1185
+ throw new Error(`x402: the endpoint declared EIP-712 domain "${serverText(declared.name)}"/"${serverText(declared.version)}" for ${asset} on chain ${chainId}, but that token's domain is "${known.name}"/"${known.version}"; refusing to sign under a domain the token would reject.`);
1186
+ }
1182
1187
  return known;
1188
+ }
1189
+ if (declared)
1190
+ return declared;
1183
1191
  let name = '', version = '';
1184
1192
  try {
1185
1193
  name = decodeAbiString(await ethCallHex(chainId, asset, '0x06fdde03')); // name()
@@ -1215,7 +1223,7 @@ async function signAuthorization(walletId, chainId, asset, domain, auth, approva
1215
1223
  /** Sign a Permit2 upto authorization with whichever custody mode is active. */
1216
1224
  async function signPermit2(walletId, chainId, auth, approvalId) {
1217
1225
  const r = (await api(`/wallets/${walletId}/x402/permit2`, 'POST', {
1218
- chain_id: chainId, asset: auth.permitted.token, amount: auth.permitted.amount, spender: auth.spender, permit2: PERMIT2_ADDRESS,
1226
+ chain_id: chainId, asset: auth.permitted.token, amount: auth.permitted.amount, spender: auth.spender, permit2: permit2Address(chainId),
1219
1227
  nonce: auth.nonce, deadline: auth.deadline, to: auth.witness.to, facilitator: auth.witness.facilitator, valid_after: auth.witness.validAfter,
1220
1228
  ...(approvalId ? { approval_id: approvalId } : {}),
1221
1229
  }, { 'X-AGW-SKIP-X402': 'true' }));
@@ -1340,7 +1348,8 @@ server.tool('pay_x402', 'Handle an x402 payment flow. Fetches a URL, and if the
1340
1348
  payment_required: true,
1341
1349
  payment_made: false,
1342
1350
  error: picked.reason,
1343
- offered: accepts.map(a => ({ scheme: a.scheme, network: a.network, amount: requiredAmount(a), asset: a.asset ?? a.extra?.token ?? null })),
1351
+ offered: accepts.slice(0, 10).map(a => ({ scheme: serverText(a.scheme), network: serverText(a.network), amount: serverText(requiredAmount(a)), asset: serverText(a.asset ?? a.extra?.token ?? null) })),
1352
+ server_text_is_untrusted: true,
1344
1353
  });
1345
1354
  }
1346
1355
  const option = picked.option;
@@ -1370,6 +1379,20 @@ server.tool('pay_x402', 'Handle an x402 payment flow. Fetches a URL, and if the
1370
1379
  error: assetPolicy.reason,
1371
1380
  });
1372
1381
  }
1382
+ // Step 3c: The tx-hash receipt flow (an on-chain transfer, final the moment
1383
+ // it is broadcast) is only for AgentWallet's own paywalls: the API host or
1384
+ // the origins in AGENTWALLET_LEGACY_PAYWALL_ORIGINS. Any other host sending
1385
+ // the legacy shape gets the standard flow (2026-10-04 round 2). And an
1386
+ // "exact" requirement in a token with no EIP-3009 can never settle, so it
1387
+ // is refused before an approval could be consumed for it.
1388
+ const legacyReceipt = isLegacyAgentWalletAccept(option) && legacyReceiptAllowed(servedFrom, API_BASE, process.env.AGENTWALLET_LEGACY_PAYWALL_ORIGINS);
1389
+ if (!isUpto && !isSolanaChain(chainId) && tokenAddress && !legacyReceipt && !assetSupportsExact(chainId, tokenAddress)) {
1390
+ return jsonResponse({
1391
+ status: 402, payment_required: true, payment_made: false,
1392
+ required_amount: amount, token: tokenLabel, token_address: tokenAddress, network: option.network, chain_id: chainId, pay_to: option.payTo,
1393
+ 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
+ });
1395
+ }
1373
1396
  // Step 4: Hard per-payment cap, ALWAYS applied. If the caller omits max_payment
1374
1397
  // we fall back to AGENTWALLET_MAX_AUTOPAY (default "1"). Above the cap a hosted
1375
1398
  // wallet can ask its owner instead of refusing: an approval is created, the
@@ -1384,7 +1407,7 @@ server.tool('pay_x402', 'Handle an x402 payment flow. Fetches a URL, and if the
1384
1407
  status: 402, payment_required: true, payment_made: false,
1385
1408
  required_amount: amount, max_allowed: effectiveMax, cap_source: capSource, token: tokenLabel,
1386
1409
  ...(clamped ? { max_payment_ignored: `max_payment ${max_payment} exceeds the operator ceiling AGENTWALLET_MAX_AUTOPAY=${effectiveMax}; only the operator can raise it.` } : {}),
1387
- network: option.network, chain_id: chainId, pay_to: option.payTo, description: option.description,
1410
+ network: option.network, chain_id: chainId, pay_to: option.payTo, description: serverText(option.description), server_text_is_untrusted: true,
1388
1411
  };
1389
1412
  const wantApproval = request_approval ?? (process.env.AGENTWALLET_APPROVALS !== '0');
1390
1413
  if (approval_id) {
@@ -1428,7 +1451,7 @@ server.tool('pay_x402', 'Handle an x402 payment flow. Fetches a URL, and if the
1428
1451
  // usage; the payer broadcasts nothing and pays no gas for either. AgentWallet's
1429
1452
  // own paywalls (verified by receipt), native-asset requests and Solana still use
1430
1453
  // a broadcast transfer proved by hash.
1431
- const standardExact = !isUpto && !isSolanaChain(chainId) && Boolean(tokenAddress) && !isLegacyAgentWalletAccept(option);
1454
+ const standardExact = !isUpto && !isSolanaChain(chainId) && Boolean(tokenAddress) && !legacyReceipt;
1432
1455
  let headerName = 'X-PAYMENT';
1433
1456
  let paymentHeader = '';
1434
1457
  let txHash = null;
@@ -1456,82 +1479,89 @@ server.tool('pay_x402', 'Handle an x402 payment flow. Fetches a URL, and if the
1456
1479
  let releaseSigning = () => { };
1457
1480
  if (!(cachedAuth && cachedAuth.until > Date.now())) {
1458
1481
  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
- }
1462
- if (cachedAuth && cachedAuth.until > Date.now()) {
1463
- // Same endpoint, same requirement, still inside the window: re-send the
1464
- // earlier signature. Its nonce is single-use, so a server that already
1465
- // settled it cannot settle it again, and one that never did now can.
1466
- ({ headerName, paymentHeader, txHash, authorization, uptoAuth, payer } = cachedAuth);
1467
- authorizationReused = true;
1468
- }
1469
- else if (isUpto) {
1470
- payer = await payerAddress(wallet_id);
1471
- payerDelegationInfo = await payerDelegation(chainId, payer);
1472
- const allowance = decodeUint(await ethCallHex(chainId, tokenAddress, permit2AllowanceCalldata(payer)));
1473
- if (allowance < BigInt(rawAmount)) {
1474
- if (process.env.AGENTWALLET_PERMIT2_AUTO_APPROVE === '1' && assetPolicy.via === 'stablecoin') {
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));
1477
- permit2ApprovalTx = String(r?.tx_hash || '');
1478
- await new Promise(res => setTimeout(res, 4000));
1479
- }
1480
- else {
1481
- return jsonResponse({
1482
- status: 402, payment_required: true, payment_made: false, permit2_approval_needed: true,
1483
- token: tokenLabel, token_address: tokenAddress, chain_id: chainId, payer, permit2: PERMIT2_ADDRESS,
1484
- error: `This endpoint uses the x402 "upto" scheme, which settles through Permit2. Wallet ${payer} has not approved ` +
1485
- `${tokenLabel} to Permit2 on chain ${chainId}. Call approve_permit2 once for this token (a normal transaction that needs gas), ` +
1486
- `or set AGENTWALLET_PERMIT2_AUTO_APPROVE=1, then call pay_x402 again.`,
1487
- });
1488
- }
1489
- }
1490
- uptoAuth = buildUptoAuthorization(payer, option);
1491
- const signature = await signPermit2(wallet_id, chainId, uptoAuth, approvalUsed);
1492
- const payload = buildPaymentPayloadRaw(x402Version, option, uptoPayload(uptoAuth, signature), paymentInfo.resource, paymentInfo.extensions);
1493
- const h = paymentHeaderFor(x402Version, payload);
1494
- headerName = h.name;
1495
- paymentHeader = h.value;
1496
- }
1497
- else if (standardExact) {
1498
- payer = await payerAddress(wallet_id);
1499
- payerDelegationInfo = await payerDelegation(chainId, payer);
1500
- const domain = await resolveTokenDomain(chainId, tokenAddress, option.extra);
1501
- authorization = buildAuthorization(payer, option);
1502
- const signature = await signAuthorization(wallet_id, chainId, tokenAddress, domain, authorization, approvalUsed);
1503
- const payload = buildPaymentPayload(x402Version, option, authorization, signature, paymentInfo.resource, paymentInfo.extensions);
1504
- const h = paymentHeaderFor(x402Version, payload);
1505
- headerName = h.name;
1506
- paymentHeader = h.value;
1482
+ // Released in the finally below, whatever path this call takes. A timer
1483
+ // used to stand in for that and opened the gate under a slow signer, so
1484
+ // a second call signed a second nonce (2026-10-04 round 2); every
1485
+ // network hop inside the gate carries its own timeout instead.
1486
+ signingInFlight.set(cacheKey, mine);
1507
1487
  }
1508
- else {
1509
- let txResult;
1510
- if (isSolanaChain(chainId)) {
1511
- txResult = (await api(`/wallets/${wallet_id}/send`, 'POST', tokenAddress
1512
- ? { to: option.payTo, value: rawAmount, token_mint: tokenAddress, token_decimals: trustedDecimals, chain_id: chainId }
1513
- : { to: option.payTo, value: rawAmount, chain_id: chainId }));
1488
+ try {
1489
+ if (cachedAuth && cachedAuth.until > Date.now()) {
1490
+ // Same endpoint, same requirement, still inside the window: re-send the
1491
+ // earlier signature. Its nonce is single-use, so a server that already
1492
+ // settled it cannot settle it again, and one that never did now can.
1493
+ ({ headerName, paymentHeader, txHash, authorization, uptoAuth, payer } = cachedAuth);
1494
+ authorizationReused = true;
1495
+ }
1496
+ else if (isUpto) {
1497
+ payer = await payerAddress(wallet_id);
1498
+ payerDelegationInfo = await payerDelegation(chainId, payer);
1499
+ const allowance = decodeUint(await ethCallHex(chainId, tokenAddress, permit2AllowanceCalldata(payer, chainId)));
1500
+ if (allowance < BigInt(rawAmount)) {
1501
+ if (process.env.AGENTWALLET_PERMIT2_AUTO_APPROVE === '1' && assetPolicy.via === 'stablecoin') {
1502
+ // Approve exactly this payment's maximum, never unlimited (2026-10-04 audit).
1503
+ const r = (await api(`/wallets/${wallet_id}/send`, 'POST', { to: tokenAddress, value: '0', data: permit2ApproveCalldata(rawAmount, chainId), chain_id: chainId }, skip));
1504
+ permit2ApprovalTx = String(r?.tx_hash || '');
1505
+ await new Promise(res => setTimeout(res, 4000));
1506
+ }
1507
+ else {
1508
+ return jsonResponse({
1509
+ status: 402, payment_required: true, payment_made: false, permit2_approval_needed: true,
1510
+ token: tokenLabel, token_address: tokenAddress, chain_id: chainId, payer, permit2: permit2Address(chainId),
1511
+ error: `This endpoint uses the x402 "upto" scheme, which settles through Permit2. Wallet ${payer} has not approved ` +
1512
+ `${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.`,
1514
+ });
1515
+ }
1516
+ }
1517
+ uptoAuth = buildUptoAuthorization(payer, option);
1518
+ const signature = await signPermit2(wallet_id, chainId, uptoAuth, approvalUsed);
1519
+ const payload = buildPaymentPayloadRaw(x402Version, option, uptoPayload(uptoAuth, signature), paymentInfo.resource, paymentInfo.extensions);
1520
+ const h = paymentHeaderFor(x402Version, payload);
1521
+ headerName = h.name;
1522
+ paymentHeader = h.value;
1514
1523
  }
1515
- else if (tokenAddress) {
1516
- const calldata = '0xa9059cbb' + padAddress(option.payTo) + encodeUint256(rawAmount);
1517
- txResult = (await api(`/wallets/${wallet_id}/send`, 'POST', { to: tokenAddress, value: '0', data: calldata, chain_id: chainId }));
1524
+ else if (standardExact) {
1525
+ payer = await payerAddress(wallet_id);
1526
+ payerDelegationInfo = await payerDelegation(chainId, payer);
1527
+ const domain = await resolveTokenDomain(chainId, tokenAddress, option.extra);
1528
+ authorization = buildAuthorization(payer, option);
1529
+ const signature = await signAuthorization(wallet_id, chainId, tokenAddress, domain, authorization, approvalUsed);
1530
+ const payload = buildPaymentPayload(x402Version, option, authorization, signature, paymentInfo.resource, paymentInfo.extensions);
1531
+ const h = paymentHeaderFor(x402Version, payload);
1532
+ headerName = h.name;
1533
+ paymentHeader = h.value;
1518
1534
  }
1519
1535
  else {
1520
- txResult = (await api(`/wallets/${wallet_id}/send`, 'POST', { to: option.payTo, value: rawAmount, data: '', chain_id: chainId }));
1536
+ let txResult;
1537
+ if (isSolanaChain(chainId)) {
1538
+ txResult = (await api(`/wallets/${wallet_id}/send`, 'POST', tokenAddress
1539
+ ? { to: option.payTo, value: rawAmount, token_mint: tokenAddress, token_decimals: trustedDecimals, chain_id: chainId }
1540
+ : { to: option.payTo, value: rawAmount, chain_id: chainId }));
1541
+ }
1542
+ else if (tokenAddress) {
1543
+ const calldata = '0xa9059cbb' + padAddress(option.payTo) + encodeUint256(rawAmount);
1544
+ txResult = (await api(`/wallets/${wallet_id}/send`, 'POST', { to: tokenAddress, value: '0', data: calldata, chain_id: chainId }));
1545
+ }
1546
+ else {
1547
+ txResult = (await api(`/wallets/${wallet_id}/send`, 'POST', { to: option.payTo, value: rawAmount, data: '', chain_id: chainId }));
1548
+ }
1549
+ txHash = String(txResult.tx_hash || txResult.signature || '');
1550
+ if (!txHash)
1551
+ throw new Error('x402: the payment transaction returned no hash.');
1552
+ paymentHeader = Buffer.from(JSON.stringify({ x402Version, scheme: option.scheme, network: option.network, payload: { txHash } })).toString('base64');
1521
1553
  }
1522
- txHash = String(txResult.tx_hash || txResult.signature || '');
1523
- if (!txHash)
1524
- throw new Error('x402: the payment transaction returned no hash.');
1525
- paymentHeader = Buffer.from(JSON.stringify({ x402Version, scheme: option.scheme, network: option.network, payload: { txHash } })).toString('base64');
1526
- }
1527
- if (!authorizationReused) {
1528
- const until = authorization ? Number(authorization.validBefore) * 1000
1529
- : uptoAuth ? Number(uptoAuth.deadline) * 1000
1530
- : Date.now() + maxAuthWindowSeconds() * 1000; // a broadcast transfer: the hash stays valid, re-sending it never pays twice
1531
- signedPayments.set(cacheKey, { headerName, paymentHeader, txHash, authorization, uptoAuth, payer, until });
1532
- }
1533
- releaseSigning();
1534
- signingInFlight.delete(cacheKey);
1554
+ if (!authorizationReused) {
1555
+ const until = authorization ? Number(authorization.validBefore) * 1000
1556
+ : uptoAuth ? Number(uptoAuth.deadline) * 1000
1557
+ : 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 });
1559
+ }
1560
+ }
1561
+ finally {
1562
+ releaseSigning();
1563
+ signingInFlight.delete(cacheKey);
1564
+ }
1535
1565
  // Step 6: Retry with the payment header and read the settlement receipt.
1536
1566
  const retryHeaders = { ...reqHeaders, [headerName]: paymentHeader };
1537
1567
  const retryOptions = {
@@ -1542,7 +1572,19 @@ server.tool('pay_x402', 'Handle an x402 payment flow. Fetches a URL, and if the
1542
1572
  if (reqBody && method !== 'GET') {
1543
1573
  retryOptions.body = reqBody;
1544
1574
  }
1545
- const retryRes = await safeFetch(url, retryOptions);
1575
+ let retryRes;
1576
+ try {
1577
+ retryRes = await safeFetch(url, retryOptions);
1578
+ }
1579
+ catch (e) {
1580
+ // The authorization has already been handed over; say so instead of a bare transport error.
1581
+ return jsonResponse({
1582
+ status: 0, payment_required: true, payment_made: null, payment_outcome: 'unknown',
1583
+ error: `The paid request could not be completed: ${e.message}. The authorization was already sent; it stays cached, and calling pay_x402 again with the same URL inside its window re-sends the same one rather than signing a new one.`,
1584
+ authorization: authorization ? { nonce: authorization.nonce, valid_before: authorization.validBefore } : (uptoAuth ? { scheme: 'upto', nonce: uptoAuth.nonce, deadline: uptoAuth.deadline } : null),
1585
+ tx_hash: txHash ?? null, amount, token: tokenLabel, pay_to: option.payTo, chain_id: chainId,
1586
+ });
1587
+ }
1546
1588
  const retryText = await retryRes.text();
1547
1589
  let retryParsed;
1548
1590
  try {
@@ -1557,6 +1599,7 @@ server.tool('pay_x402', 'Handle an x402 payment flow. Fetches a URL, and if the
1557
1599
  let retryError = null;
1558
1600
  const retryServedFrom = finalUrlOf(retryRes, url);
1559
1601
  const retryCrossOrigin = new URL(retryServedFrom).origin !== new URL(url).origin;
1602
+ const verdict = settlementVerdict(retryRes.status, settlement, retryCrossOrigin);
1560
1603
  if (retryCrossOrigin) {
1561
1604
  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.`;
1562
1605
  }
@@ -1569,10 +1612,11 @@ server.tool('pay_x402', 'Handle an x402 payment flow. Fetches a URL, and if the
1569
1612
  return jsonResponse({
1570
1613
  status: retryRes.status,
1571
1614
  payment_required: true,
1572
- // Paid means the endpoint accepted the payment: a settlement receipt, or a success status after the payment header.
1573
- // A 4xx other than 402 (bad body, auth) means the request failed for another reason; the authorization was
1574
- // sent but the facilitator normally does not settle a failed request, so it is not reported as paid.
1575
- payment_made: !retryCrossOrigin && (Boolean(settlement?.success) || (retryRes.status >= 200 && retryRes.status < 300)),
1615
+ // Paid means a success status after the payment header and no server-reported settlement failure. A 402 or
1616
+ // any other non-2xx is not paid whatever the settlement header claims (a server could send success:true on a
1617
+ // 402 and mislead the agent, 2026-10-04 round 2); the facilitator does not settle a failed request.
1618
+ payment_made: verdict.payment_made,
1619
+ payment_outcome: verdict.outcome,
1576
1620
  authorization_reused: authorizationReused,
1577
1621
  retry_error: retryError,
1578
1622
  payment_method: isUpto ? 'permit2-upto-authorization' : (standardExact ? 'eip3009-authorization' : 'onchain-transfer'),
@@ -1587,9 +1631,10 @@ server.tool('pay_x402', 'Handle an x402 payment flow. Fetches a URL, and if the
1587
1631
  pay_to: option.payTo,
1588
1632
  payer,
1589
1633
  payer_delegation: payerDelegationInfo,
1590
- // txHash is ours (we broadcast it); settlement.transaction is the server's
1591
- // claim and is surfaced only when it is shaped like a real hash.
1592
- tx_hash: txHash ?? (looksLikeTxHash(settlement?.transaction) ? settlement?.transaction : null) ?? null,
1634
+ // tx_hash is only ever ours (we broadcast it). The server's claimed hash
1635
+ // is kept apart and unverified: it is not evidence the payment settled.
1636
+ tx_hash: txHash ?? null,
1637
+ server_claimed_tx_hash: looksLikeTxHash(settlement?.transaction) ? settlement?.transaction : null,
1593
1638
  settlement_reported_by_server: settlement,
1594
1639
  authorization: authorization ? { nonce: authorization.nonce, valid_before: authorization.validBefore }
1595
1640
  : (uptoAuth ? { scheme: 'upto', max_amount: amount, nonce: uptoAuth.nonce, deadline: uptoAuth.deadline, facilitator: uptoAuth.witness.facilitator } : null),
@@ -1613,13 +1658,13 @@ server.tool('approve_permit2', 'One-time ERC-20 approval of a token to the Permi
1613
1658
  }, async ({ wallet_id, token, chain_id, amount }) => {
1614
1659
  if (isSolanaChain(chain_id))
1615
1660
  throw new Error('Permit2 is an EVM contract; there is nothing to approve on Solana.');
1616
- let data = permit2ApproveCalldata();
1661
+ let data = permit2ApproveCalldata(undefined, chain_id);
1617
1662
  if (amount !== undefined) {
1618
1663
  const raw = parseUnits(amount, await resolveTrustedDecimals(chain_id, token));
1619
- data = '0x095ea7b3' + padAddress(PERMIT2_ADDRESS) + encodeUint256(raw);
1664
+ data = permit2ApproveCalldata(raw, chain_id);
1620
1665
  }
1621
1666
  const result = (await api(`/wallets/${wallet_id}/send`, 'POST', { to: token, value: '0', data, chain_id }));
1622
- return jsonResponse({ ...result, token, spender: PERMIT2_ADDRESS, amount: amount ?? 'unlimited', note: 'Permit2 only transfers what a signed authorization allows; the allowance itself moves nothing.' });
1667
+ return jsonResponse({ ...result, token, spender: permit2Address(chain_id), amount: amount ?? 'unlimited', note: 'Permit2 only transfers what a signed authorization allows; the allowance itself moves nothing.' });
1623
1668
  });
1624
1669
  // ─── Tool: check_token_risk ─────────────────────────────────────
1625
1670
  server.tool('check_token_risk', 'Assess an ERC-20 token before approving or swapping it: honeypot, taxes, owner powers, verified source, ' +
@@ -416,15 +416,23 @@ export async function localSplTransfer(mintStr, to, rawAmount, decimals, chainId
416
416
  const destAta = associatedTokenAddress(recipient, mint, tokenProgram);
417
417
  const tx = new Transaction();
418
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.`);
419
+ // The associated-account program creates into an address that already holds
420
+ // plain SOL (system-owned, no data): it tops up the rent, allocates and
421
+ // assigns. Only an address holding something else cannot become the token
422
+ // account (2026-10-04 round 2; the 1.13.5 refusal of any occupant let anyone
423
+ // block a recipient for the price of the rent-exempt minimum).
424
+ const prefunded = destInfo !== null && destInfo.owner.equals(SystemProgram.programId) && destInfo.data.length === 0;
425
+ if (destInfo && !destInfo.owner.equals(tokenProgram) && !prefunded) {
426
+ throw new Error(`Refusing to send: the recipient's token account address ${destAta.toBase58()} is occupied by an account owned by ${destInfo.owner.toBase58()}, so no token account can be created there.`);
421
427
  }
422
- if (!destInfo) {
428
+ if (!destInfo || prefunded) {
423
429
  // Creating the recipient's token account costs the payer rent in SOL, an
424
430
  // outflow the SOL cap must see (0.002 SOL a time adds up over many sends).
425
- // Rent follows the account's real size, which for Token-2022 is above 165.
431
+ // Rent follows the account's real size, which for Token-2022 is above 165;
432
+ // SOL already sitting at the address reduces what the payer tops up.
426
433
  const rent = await conn.getMinimumBalanceForRentExemption(ataSize);
427
- assertWithinSolCap(BigInt(rent));
434
+ const shortfall = Math.max(0, rent - (destInfo?.lamports ?? 0));
435
+ assertWithinSolCap(BigInt(shortfall));
428
436
  tx.add(createAtaIdempotentIx(payer.publicKey, destAta, recipient, mint, tokenProgram));
429
437
  }
430
438
  tx.add(transferCheckedIx(tokenProgram, sourceAta, mint, destAta, payer.publicKey, BigInt(rawAmount), decimals));
@@ -471,11 +479,12 @@ async function assertRecipientIsWallet(conn, recipient) {
471
479
  }
472
480
  throw new Error(`Refusing to send to ${recipient.toBase58()}: it is an account owned by the SPL token program, not a wallet.`);
473
481
  }
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.
482
+ // Everything else that exists must be system-owned: a program's data or
483
+ // buffer account, an address lookup table, a stake or vote account is owned
484
+ // by its program and nothing can ever sign for it as a wallet (2026-10-04
485
+ // audit). A program-derived vault that holds tokens is system-owned and
486
+ // passes; so does a durable-nonce account (system-owned, 80 bytes), whose
487
+ // authority can withdraw what lands there.
479
488
  if (!info.owner.equals(SystemProgram.programId)) {
480
489
  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
490
  }
@@ -271,7 +271,7 @@ export function classifyDecimalsError(err) {
271
271
  const e = err;
272
272
  const code = typeof e?.code === 'number' ? e.code : NaN;
273
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);
274
+ const hasRevertData = typeof e?.data === 'string' && /^0x[0-9a-fA-F]+$/.test(e.data); // "0x" alone is not revert data
275
275
  if (code === 3)
276
276
  return 'not-a-token'; // the JSON-RPC "execution error" code
277
277
  if ((code === -32000 || code === -32603 || code === -32015) && (/revert/i.test(text) || hasRevertData))
@@ -295,7 +295,12 @@ export function permit2AddressFor(chainId) { return PERMIT2_BY_CHAIN[chainId] ||
295
295
  table does not know, and is refused rather than let through as "a router"
296
296
  (2026-10-04 audit). approve, permit (2 shapes), transferFrom (2), lockdown,
297
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']);
298
+ const PERMIT2_SELECTORS = new Set(['87517c45', '2b67b570', '2a2d80d1', '36c78516', '0d58b1db', 'cc53287f', '30f28b7a', 'edd9444b', '137c29fe', 'fe8ec1a7', '3ff9dcb1', '65d9723c']);
299
+ /* ERC-721 / ERC-1155 approvals and transfers. A collection declines decimals()
300
+ and so read as "not a token" to the cap, which let setApprovalForAll to an
301
+ attacker through uncapped (2026-10-04 round 2, verified on a live
302
+ collection). Nothing prices an NFT, so these are refused outright. */
303
+ const NFT_SELECTORS = new Set(['a22cb465', '42842e0e', 'b88d4fde', 'f242432a', '2eb2c2d6']);
299
304
  const CAP_LAYOUTS = {
300
305
  a9059cbb: { amountIndex: 1, what: 'token transfer' }, // transfer(address,uint256)
301
306
  '23b872dd': { amountIndex: 2, what: 'token transfer' }, // transferFrom(address,address,uint256)
@@ -359,6 +364,10 @@ async function assertWithinTokenCap(chainId, to, data, valueWei = 0n) {
359
364
  `or deposit/withdraw on the chain's wrapped-native contract, ` +
360
365
  `or ask the operator, who alone can set AGENTWALLET_ALLOW_UNKNOWN_TOKEN_CALLS=1.`);
361
366
  }
367
+ if (NFT_SELECTORS.has(selector)) {
368
+ throw new Error(`Blocked by local guard: calldata selector 0x${selector} is an NFT approval or transfer (ERC-721/1155) aimed at ${to}; AGENTWALLET_MAX_TX_TOKEN cannot price it, ` +
369
+ `so it is refused rather than let through uncapped. Only the operator can allow it (AGENTWALLET_ALLOW_UNKNOWN_TOKEN_CALLS=1).`);
370
+ }
362
371
  if (PERMIT2_SELECTORS.has(selector)) {
363
372
  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
373
  `refused rather than let through uncapped. Only the operator can allow it (AGENTWALLET_ALLOW_UNKNOWN_TOKEN_CALLS=1).`);
@@ -158,6 +158,16 @@ export declare function paymentHeaderFor(x402Version: number, payload: Record<st
158
158
  };
159
159
  /** v2 servers put the requirements in PAYMENT-REQUIRED; v1 servers put them in the JSON body. */
160
160
  export declare function parsePaymentRequired(getHeader: (name: string) => string | null | undefined, body: unknown): X402PaymentRequired | null;
161
+ export declare function sanitizeRequired(req: X402PaymentRequired): X402PaymentRequired;
162
+ /** What the paid retry proved. The server's own claim never makes a refused request "paid". */
163
+ export declare function settlementVerdict(status: number, settlement: {
164
+ success?: unknown;
165
+ } | null, crossOrigin: boolean): {
166
+ payment_made: boolean;
167
+ outcome: 'paid' | 'refused' | 'server_reports_failure' | 'not_delivered';
168
+ };
169
+ /** The tx-hash receipt flow is for AgentWallet's own paywalls: the API host, or origins the operator lists. */
170
+ export declare function legacyReceiptAllowed(finalUrl: string, apiBase: string, extraOrigins: string | undefined): boolean;
161
171
  export declare function parseSettlement(getHeader: (name: string) => string | null | undefined): X402Settlement | null;
162
172
  /**
163
173
  * Choose which accepts[] entry to pay. `exact` (EIP-3009, gasless for the
@@ -178,12 +178,77 @@ function decodeBase64Json(v) {
178
178
  export function parsePaymentRequired(getHeader, body) {
179
179
  const fromHeader = decodeBase64Json(getHeader('PAYMENT-REQUIRED') ?? getHeader('payment-required'));
180
180
  if (fromHeader && Array.isArray(fromHeader.accepts))
181
- return fromHeader;
181
+ return sanitizeRequired(fromHeader);
182
182
  if (body && typeof body === 'object' && Array.isArray(body.accepts)) {
183
- return body;
183
+ return sanitizeRequired(body);
184
184
  }
185
185
  return null;
186
186
  }
187
+ /* Only entries shaped like requirements survive: plain objects whose scheme,
188
+ network and payTo are strings. A null entry, or an array where an address
189
+ should be, used to reach the pickers and surface a raw TypeError or a
190
+ 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
192
+ export function sanitizeRequired(req) {
193
+ const str = (v) => (typeof v === 'string' ? v : typeof v === 'number' && Number.isFinite(v) ? String(v) : undefined);
194
+ const accepts = [];
195
+ for (const a of req.accepts.slice(0, MAX_ACCEPTS)) {
196
+ if (!a || typeof a !== 'object' || Array.isArray(a))
197
+ continue;
198
+ const o = a;
199
+ const scheme = str(o.scheme), network = str(o.network), payTo = str(o.payTo);
200
+ if (scheme === undefined || network === undefined || payTo === undefined)
201
+ continue;
202
+ const extraIn = (o.extra && typeof o.extra === 'object' && !Array.isArray(o.extra)) ? o.extra : undefined;
203
+ const extra = extraIn ? { ...extraIn } : undefined;
204
+ if (extra)
205
+ for (const k of ['token', 'name', 'version', 'facilitatorAddress', 'spender', 'permit2']) {
206
+ if (k in extra && typeof extra[k] !== 'string')
207
+ delete extra[k];
208
+ }
209
+ const out = { ...o, scheme, network, payTo };
210
+ for (const k of ['amount', 'maxAmountRequired', 'asset', 'description', 'resource', 'mimeType']) {
211
+ const v = str(o[k]);
212
+ if (v === undefined)
213
+ delete out[k];
214
+ else
215
+ out[k] = v;
216
+ }
217
+ if (extra)
218
+ out.extra = extra;
219
+ else
220
+ delete out.extra;
221
+ accepts.push(out);
222
+ }
223
+ return { ...req, accepts };
224
+ }
225
+ /** What the paid retry proved. The server's own claim never makes a refused request "paid". */
226
+ export function settlementVerdict(status, settlement, crossOrigin) {
227
+ if (crossOrigin)
228
+ return { payment_made: false, outcome: 'not_delivered' };
229
+ if (status < 200 || status >= 300)
230
+ return { payment_made: false, outcome: 'refused' };
231
+ if (settlement && settlement.success === false)
232
+ return { payment_made: false, outcome: 'server_reports_failure' };
233
+ return { payment_made: true, outcome: 'paid' };
234
+ }
235
+ /** The tx-hash receipt flow is for AgentWallet's own paywalls: the API host, or origins the operator lists. */
236
+ export function legacyReceiptAllowed(finalUrl, apiBase, extraOrigins) {
237
+ try {
238
+ const origin = new URL(finalUrl).origin;
239
+ if (origin === new URL(apiBase).origin)
240
+ return true;
241
+ return (extraOrigins || '').split(',').map(s => s.trim()).filter(Boolean).some(o => { try {
242
+ return new URL(o).origin === origin;
243
+ }
244
+ catch {
245
+ return false;
246
+ } });
247
+ }
248
+ catch {
249
+ return false;
250
+ }
251
+ }
187
252
  export function parseSettlement(getHeader) {
188
253
  return (decodeBase64Json(getHeader('PAYMENT-RESPONSE') ?? getHeader('payment-response'))
189
254
  ?? decodeBase64Json(getHeader('X-PAYMENT-RESPONSE') ?? getHeader('x-payment-response')));
@@ -201,7 +266,7 @@ export function pickOption(accepts, resolveChainId, preferChain) {
201
266
  const upto = onChain.filter(a => isUptoPayable(a));
202
267
  const payable = [...exact, ...upto];
203
268
  if (payable.length === 0) {
204
- const schemes = Array.from(new Set(accepts.map(a => a.scheme))).join(', ') || 'none';
269
+ const schemes = Array.from(new Set(accepts.map(a => String(a.scheme).slice(0, 40)))).slice(0, 10).join(', ') || 'none';
205
270
  const reason = accepts.some(a => a.scheme === 'upto')
206
271
  ? 'This endpoint offers the x402 "upto" scheme without an extra.facilitatorAddress (or without an asset), so the ' +
207
272
  'Permit2 authorization cannot be bound to a facilitator. AgentWallet will not approximate it with an upfront transfer. ' +
@@ -40,11 +40,13 @@ export declare const TRUSTED_DECIMALS: Record<number, Record<string, number>>;
40
40
  * would happily label 5 ETH as "5 USDC". The stable flag decides whether
41
41
  * AGENTWALLET_MAX_AUTOPAY (denominated in dollars) can price the asset at all.
42
42
  */
43
+ export declare function assetSupportsExact(chainId: number, asset: string): boolean;
43
44
  export declare const KNOWN_ASSETS: ReadonlyArray<{
44
45
  chainId: number;
45
46
  address: string;
46
47
  symbol: string;
47
48
  stable: boolean;
49
+ exact?: boolean;
48
50
  }>;
49
51
  /** Symbol of the chain's native asset, or "native" for a chain this build does not know. */
50
52
  export declare function nativeSymbol(chainId: number): string;
@@ -58,15 +58,25 @@ export const TRUSTED_DECIMALS = {
58
58
  * would happily label 5 ETH as "5 USDC". The stable flag decides whether
59
59
  * AGENTWALLET_MAX_AUTOPAY (denominated in dollars) can price the asset at all.
60
60
  */
61
+ /* exact: false marks a stablecoin that does not implement EIP-3009 (checked on
62
+ chain 2026-10-04: USDT, DAI and USDbC have no authorizationState), so an
63
+ "exact" x402 requirement naming it can never settle; such assets still work
64
+ for "upto" through Permit2. Absent means true. */
65
+ export function assetSupportsExact(chainId, asset) {
66
+ const key = asset.toLowerCase();
67
+ const hit = KNOWN_ASSETS.find(a => a.chainId === chainId && a.address === key);
68
+ return hit ? hit.exact !== false : true;
69
+ }
61
70
  export const KNOWN_ASSETS = [
62
71
  { chainId: 1, address: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', symbol: 'USDC', stable: true },
63
- { chainId: 1, address: '0xdac17f958d2ee523a2206206994597c13d831ec7', symbol: 'USDT', stable: true },
64
- { chainId: 1, address: '0x6b175474e89094c44da98b954eedeac495271d0f', symbol: 'DAI', stable: true },
72
+ { chainId: 1, address: '0xdac17f958d2ee523a2206206994597c13d831ec7', symbol: 'USDT', stable: true, exact: false },
73
+ { chainId: 1, address: '0x6b175474e89094c44da98b954eedeac495271d0f', symbol: 'DAI', stable: true, exact: false },
65
74
  { chainId: 8453, address: '0x833589fcd6edb6e08f4c7c32d4f71b54bda02913', symbol: 'USDC', stable: true },
66
- { chainId: 8453, address: '0xd9aaec86b65d86f6a7b5b1b0c42ffa531710b6ca', symbol: 'USDbC', stable: true },
75
+ { chainId: 8453, address: '0xd9aaec86b65d86f6a7b5b1b0c42ffa531710b6ca', symbol: 'USDbC', stable: true, exact: false },
67
76
  { chainId: 8453, address: '0x4200000000000000000000000000000000000006', symbol: 'WETH', stable: false },
68
77
  { chainId: 137, address: '0x3c499c542cef5e3811e1192ce70d8cc03d5c3359', symbol: 'USDC', stable: true },
69
- { chainId: 137, address: '0xc2132d05d31c914a87c6611c10748aeb04b58e8f', symbol: 'USDT', stable: true },
78
+ { chainId: 137, address: '0xc2132d05d31c914a87c6611c10748aeb04b58e8f', symbol: 'USDT0', stable: true, exact: false },
79
+ { chainId: 43114, address: '0xb97ef9ef8734c71904d8002f8b6bc66dd9c48a6e', symbol: 'USDC', stable: true },
70
80
  { chainId: 42161, address: '0xaf88d065e77c8cc2239327c5edb3a432268e5831', symbol: 'USDC', stable: true },
71
81
  { chainId: 10, address: '0x0b2c639c533813f4aa9d7837caf62653d097ff85', symbol: 'USDC', stable: true },
72
82
  { chainId: 900, address: 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', symbol: 'USDC', stable: true },
@@ -79,7 +89,7 @@ const NATIVE_SYMBOL = {
79
89
  };
80
90
  function assetKey(chainId, asset) {
81
91
  // EVM addresses are case-insensitive hex; SPL mints are case-sensitive base58.
82
- return `${chainId}:${asset.startsWith('0x') ? asset.toLowerCase() : asset}`;
92
+ return `${chainId}:${/^0x/i.test(asset) ? asset.toLowerCase() : asset}`;
83
93
  }
84
94
  const KNOWN_BY_KEY = new Map(KNOWN_ASSETS.map(a => [assetKey(a.chainId, a.address), a]));
85
95
  /** Symbol of the chain's native asset, or "native" for a chain this build does not know. */
@@ -119,13 +129,13 @@ export function parseAutopayAssets(raw) {
119
129
  const m = e.match(/^(\d+):(.+)$/);
120
130
  const chainId = m ? parseInt(m[1], 10) : null;
121
131
  const a = (m ? m[2] : e).trim();
122
- return { chainId, asset: a.startsWith('0x') ? a.toLowerCase() : a };
132
+ return { chainId, asset: /^0x/i.test(a) ? a.toLowerCase() : a };
123
133
  });
124
134
  }
125
135
  export function autopayAssetAllowed(chainId, asset) {
126
136
  if (isStableAsset(chainId, asset))
127
137
  return { allowed: true, via: 'stablecoin' };
128
- const wanted = asset ? (asset.startsWith('0x') ? asset.toLowerCase() : asset) : 'native';
138
+ const wanted = asset ? (/^0x/i.test(asset) ? asset.toLowerCase() : asset) : 'native';
129
139
  const hit = parseAutopayAssets(process.env.AGENTWALLET_AUTOPAY_ASSETS).some(e => e.asset === wanted && (e.chainId === null || e.chainId === chainId));
130
140
  if (hit)
131
141
  return { allowed: true, via: 'allowlist' };
@@ -1,5 +1,6 @@
1
1
  import type { X402Requirement } from './x402-eip3009.js';
2
2
  export declare const PERMIT2_ADDRESS: "0x000000000022D473030F116dDEE9F6B43aC78BA3";
3
+ export declare function permit2Address(chainId: number): `0x${string}`;
3
4
  export declare const X402_UPTO_PERMIT2_PROXY: "0x4020A4f3b7b90ccA423B9fabCc0CE57C6C240002";
4
5
  /** EIP-712 types. Referenced types must follow the primary type in alphabetical order (TokenPermissions, Witness). */
5
6
  export declare const UPTO_PERMIT2_WITNESS_TYPES: {
@@ -62,7 +63,7 @@ export declare function uptoTypedData(chainId: number, auth: UptoPermit2Authoriz
62
63
  domain: {
63
64
  name: string;
64
65
  chainId: number;
65
- verifyingContract: "0x000000000022D473030F116dDEE9F6B43aC78BA3";
66
+ verifyingContract: `0x${string}`;
66
67
  };
67
68
  types: {
68
69
  readonly PermitWitnessTransferFrom: readonly [{
@@ -118,7 +119,7 @@ export declare function uptoTypedData(chainId: number, auth: UptoPermit2Authoriz
118
119
  /** The payload object that goes under `payload` in the x402 payment. */
119
120
  export declare function uptoPayload(auth: UptoPermit2Authorization, signature: string): Record<string, unknown>;
120
121
  /** allowance(owner, Permit2) calldata. */
121
- export declare function permit2AllowanceCalldata(owner: string): `0x${string}`;
122
+ export declare function permit2AllowanceCalldata(owner: string, chainId?: number): `0x${string}`;
122
123
  /** approve(Permit2, amount) calldata; unlimited only when no amount is given. */
123
- export declare function permit2ApproveCalldata(amountRaw?: string): `0x${string}`;
124
+ export declare function permit2ApproveCalldata(amountRaw?: string, chainId?: number): `0x${string}`;
124
125
  export declare function decodeUint(hex: string): bigint;
@@ -10,6 +10,9 @@
10
10
  import { randomBytes } from 'node:crypto';
11
11
  import { maxAuthWindowSeconds } from './x402-payment.js';
12
12
  export const PERMIT2_ADDRESS = '0x000000000022D473030F116dDEE9F6B43aC78BA3';
13
+ /** Chains whose Permit2 is not at the canonical address (zkSync Era runs its own deployment). */
14
+ const PERMIT2_BY_CHAIN = { 324: '0x0000000000225e31D15943971F47aD3022F714Fa' };
15
+ export function permit2Address(chainId) { return PERMIT2_BY_CHAIN[chainId] ?? PERMIT2_ADDRESS; }
13
16
  export const X402_UPTO_PERMIT2_PROXY = '0x4020A4f3b7b90ccA423B9fabCc0CE57C6C240002';
14
17
  /** EIP-712 types. Referenced types must follow the primary type in alphabetical order (TokenPermissions, Witness). */
15
18
  export const UPTO_PERMIT2_WITNESS_TYPES = {
@@ -64,7 +67,7 @@ export function buildUptoAuthorization(from, req, nowSeconds = Math.floor(Date.n
64
67
  }
65
68
  export function uptoTypedData(chainId, auth) {
66
69
  return {
67
- domain: { name: 'Permit2', chainId, verifyingContract: PERMIT2_ADDRESS },
70
+ domain: { name: 'Permit2', chainId, verifyingContract: permit2Address(chainId) },
68
71
  types: UPTO_PERMIT2_WITNESS_TYPES,
69
72
  primaryType: 'PermitWitnessTransferFrom',
70
73
  message: {
@@ -82,13 +85,15 @@ export function uptoPayload(auth, signature) {
82
85
  }
83
86
  function pad(addr) { return addr.replace(/^0x/, '').toLowerCase().padStart(64, '0'); }
84
87
  /** allowance(owner, Permit2) calldata. */
85
- export function permit2AllowanceCalldata(owner) {
86
- return ('0xdd62ed3e' + pad(owner) + pad(PERMIT2_ADDRESS));
88
+ export function permit2AllowanceCalldata(owner, chainId = 8453) {
89
+ return ('0xdd62ed3e' + pad(owner) + pad(permit2Address(chainId)));
87
90
  }
88
91
  /** approve(Permit2, amount) calldata; unlimited only when no amount is given. */
89
- export function permit2ApproveCalldata(amountRaw) {
92
+ export function permit2ApproveCalldata(amountRaw, chainId = 8453) {
93
+ if (amountRaw !== undefined && (!/^\d+$/.test(amountRaw) || BigInt(amountRaw) > (1n << 256n) - 1n))
94
+ throw new Error(`Permit2 approval amount "${amountRaw}" is not a uint256.`);
90
95
  const word = amountRaw === undefined ? 'f'.repeat(64) : BigInt(amountRaw).toString(16).padStart(64, '0');
91
- return ('0x095ea7b3' + pad(PERMIT2_ADDRESS) + word);
96
+ return ('0x095ea7b3' + pad(permit2Address(chainId)) + word);
92
97
  }
93
98
  export function decodeUint(hex) {
94
99
  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.5",
4
+ "version": "1.13.6",
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",
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",
20
20
  "prepublishOnly": "npm run build"
21
21
  },
22
22
  "keywords": [