agentwallet-mcp 1.13.4 → 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,15 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
13
13
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
14
14
  import { z } from 'zod';
15
15
  import { assertPublicUrl, safeFetch, finalUrlOf } from './ssrf-guard.js';
16
- import { deriveX402Payment, isWithinCap, lookupTrustedDecimals, assertDeclaredDecimals, nativeDecimals, SOLANA_CHAIN_IDS, assetLabel, autopayAssetAllowed, autopayEnvCap, effectiveAutopayCap, maxAuthWindowSeconds, parseDecimalPins, approvalRefusal, authorizationResource, } from './x402-payment.js';
16
+ import { deriveX402Payment, isWithinCap, lookupTrustedDecimals, assertDeclaredDecimals, nativeDecimals, SOLANA_CHAIN_IDS, assetLabel, autopayAssetAllowed, autopayEnvCap, effectiveAutopayCap, maxAuthWindowSeconds, parseDecimalPins, approvalRefusal, authorizationResource, resolveNetworkChainId, normalizeRawAmount, parseAutopayAssets, assetSupportsExact, } from './x402-payment.js';
17
+ import { createHash } from 'node:crypto';
17
18
  import { isLocalMode, getLocalAddress, localWalletRecord, localSend, localNativeBalance, localTokenBalance, localSignMessage, resolveRpcUrl, } from './local-wallet.js';
18
19
  import { localSignAuthorization, localEthCall, localGetCode, localSignPermit2Upto } from './local-wallet.js';
19
20
  import { WRAPPED_NATIVE } from './wrapped-native.js';
20
- import { buildUptoAuthorization, uptoPayload, permit2AllowanceCalldata, permit2ApproveCalldata, decodeUint, PERMIT2_ADDRESS, } from './x402-permit2.js';
21
+ import { buildUptoAuthorization, uptoPayload, permit2AllowanceCalldata, permit2ApproveCalldata, decodeUint, permit2Address, } from './x402-permit2.js';
21
22
  import { assessTokenRisk } from './token-risk.js';
22
- import { isLegacyAgentWalletAccept, requiredAmount, buildAuthorization, buildPaymentPayload, buildPaymentPayloadRaw, paymentHeaderFor, parsePaymentRequired, parseSettlement, pickOption, knownTokenDomain, parseEip7702Delegation, erc1271ProbeCalldata, classifyErc1271Probe, } from './x402-eip3009.js';
23
- import { isSolanaLocalMode, getSolanaAddress, solanaWalletRecord, localSolBalance, localSplBalance, localSolTransfer, localSplTransfer, resolveSolanaRpc, } from './local-solana.js';
23
+ import { isLegacyAgentWalletAccept, requiredAmount, buildAuthorization, buildPaymentPayload, buildPaymentPayloadRaw, paymentHeaderFor, parsePaymentRequired, parseSettlement, pickOption, knownTokenDomain, parseEip7702Delegation, erc1271ProbeCalldata, classifyErc1271Probe, settlementVerdict, legacyReceiptAllowed, } from './x402-eip3009.js';
24
+ import { isSolanaLocalMode, getSolanaAddress, solanaWalletRecord, localSolBalance, localSplBalance, localSolTransfer, localSplTransfer, resolveSolanaRpc, mintDecimalsStrict, } from './local-solana.js';
24
25
  /** True when either chain family is running non-custodially. */
25
26
  function anyLocalMode() {
26
27
  return isLocalMode() || isSolanaLocalMode();
@@ -76,7 +77,7 @@ async function routeLocally(path, method, body) {
76
77
  const [rawPath, rawQs = ''] = path.split('?');
77
78
  const qs = new URLSearchParams(rawQs);
78
79
  // POST /wallets/{id}/send
79
- if (method === 'POST' && /^\/wallets\/[^/]*\/send$/.test(rawPath)) {
80
+ if (method === 'POST' && /^\/wallets\/[^/]+\/send$/.test(rawPath)) {
80
81
  const chainId = localChainId(body, qs);
81
82
  const solanaOp = SOLANA_CHAIN_IDS.has(chainId) || Boolean(body?.token_mint);
82
83
  if (solanaOp) {
@@ -209,7 +210,13 @@ async function api(path, method = 'GET', body, extraHeaders) {
209
210
  single choke point every funds operation already passes through means no
210
211
  tool can quietly stay custodial because someone forgot to update it. */
211
212
  if (anyLocalMode()) {
212
- const handled = await routeLocally(path, method, body);
213
+ let handled;
214
+ try {
215
+ handled = await routeLocally(path, method, body);
216
+ }
217
+ catch (e) {
218
+ throw new Error(redactRpcSecrets(e)); // viem puts the RPC URL, key and all, in its messages
219
+ }
213
220
  if (handled !== NOT_HANDLED)
214
221
  return handled;
215
222
  }
@@ -221,15 +228,39 @@ async function api(path, method = 'GET', body, extraHeaders) {
221
228
  if (API_USER && API_PASS) {
222
229
  headers['Authorization'] = 'Basic ' + Buffer.from(`${API_USER}:${API_PASS}`).toString('base64');
223
230
  }
224
- const options = { method, headers };
231
+ // The API host has no reason to send a wallet operation elsewhere: a
232
+ // redirect is refused instead of followed, so a request body is never
233
+ // replayed to another origin and another origin's answer is never returned
234
+ // as the API's (2026-10-04 audit).
235
+ const options = { method, headers, redirect: 'manual', signal: AbortSignal.timeout(API_TIMEOUT_MS) };
225
236
  if (body && method !== 'GET') {
226
237
  options.body = JSON.stringify(body);
227
238
  }
228
239
  const res = await fetch(url, options);
229
- const data = await res.json();
230
- // Handle 402 Payment Required — auto-pay if a wallet or a local key is configured
231
- if (res.status === 402 && (X402_WALLET_ID || anyLocalMode()) && !extraHeaders?.['X-PAYMENT'] && !extraHeaders?.['X-AGW-SKIP-X402']) {
232
- return handleX402Payment(data, path, method, body);
240
+ if (res.status >= 300 && res.status < 400) {
241
+ throw new Error(`The AgentWallet API host answered ${path} with a redirect (HTTP ${res.status}); refusing to follow it.`);
242
+ }
243
+ const text = await res.text();
244
+ if (text.length > API_MAX_RESPONSE_CHARS)
245
+ throw new Error(`The AgentWallet API host returned an oversized response (${text.length} characters); refusing to parse it.`);
246
+ let data;
247
+ try {
248
+ data = JSON.parse(text);
249
+ }
250
+ catch {
251
+ throw new Error(`The AgentWallet API host returned a non-JSON response (HTTP ${res.status}).`);
252
+ }
253
+ // A 402 from the API host is paid automatically only for a HOSTED wallet the
254
+ // operator named with AGENTWALLET_WALLET_ID. In local mode the host is not a
255
+ // custodian and must never be able to make the local key sign: a 402 is
256
+ // returned as an error and the agent can call pay_x402 explicitly, which
257
+ // reports what it does (2026-10-04 audit).
258
+ if (res.status === 402 && !extraHeaders?.['X-PAYMENT'] && !extraHeaders?.['X-AGW-SKIP-X402']) {
259
+ if (anyLocalMode()) {
260
+ throw new Error(`The AgentWallet API host asked for payment on ${path}; nothing is paid automatically in local mode. If you trust it, call pay_x402 with that URL so the payment is explicit and reported.`);
261
+ }
262
+ if (X402_WALLET_ID)
263
+ return handleX402Payment(data, path, method, body);
233
264
  }
234
265
  if (!res.ok) {
235
266
  const error = data.error || `HTTP ${res.status}`;
@@ -395,8 +426,10 @@ function isValidAddress(address) {
395
426
  }
396
427
  // ─── Guard helpers ──────────────────────────────────────────────
397
428
  /** Request headers the caller may not set on pay_x402: Host would rename the TLS peer, the rest belong to the client. */
398
- 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
399
431
  /** Authorizations signed in this process, keyed by endpoint and requirement, kept until they expire. */
432
+ const signingInFlight = new Map();
400
433
  const signedPayments = new Map();
401
434
  function looksLikeTxHash(v) {
402
435
  return typeof v === 'string' && (/^0x[0-9a-fA-F]{64}$/.test(v) || /^[1-9A-HJ-NP-Za-km-z]{86,88}$/.test(v));
@@ -417,6 +450,18 @@ function redactUrl(u) {
417
450
  return u;
418
451
  }
419
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
455
+ /* Error text from the local signing path with every URL reduced to its origin
456
+ and viem's "URL:" / "Request body:" lines dropped: an RPC endpoint carries
457
+ its API key in the path or query, and a tool result is the agent's context
458
+ and the client's log (2026-10-04 audit). */
459
+ function redactRpcSecrets(err) {
460
+ const e = err;
461
+ let msg = e?.shortMessage ? [e.shortMessage, e.details].filter(Boolean).join(' ') : String(e?.message ?? err);
462
+ msg = msg.split(/\r?\n/).filter(l => !/^\s*(URL|Request body|Request Arguments|Raw Call Arguments)\s*:/i.test(l)).join('\n').trim();
463
+ return msg.replace(/[a-z][a-z0-9+.-]*:\/\/[^\s"'<>)]+/gi, (u) => redactUrl(u)); // brackets stay in: IPv6 hosts
464
+ }
420
465
  /**
421
466
  * Decimals to scale a human amount by. Always the token's own value (registry,
422
467
  * then decimals() on chain); a caller-supplied value must agree or the call
@@ -425,12 +470,12 @@ function redactUrl(u) {
425
470
  async function verifiedDecimals(chainId, token, given) {
426
471
  let trusted = null;
427
472
  if (isSolanaChain(chainId)) {
428
- trusted = lookupTrustedDecimals(chainId, token);
429
- if (trusted === null) {
430
- if (given === undefined)
431
- throw new Error(`Decimals for mint ${token} are not in the registry; pass decimals explicitly.`);
432
- return given; // the token program verifies it on-chain (TransferChecked)
433
- }
473
+ // The mint itself says how many decimals it has; a caller's value only
474
+ // ever confirms it (2026-10-04 audit: the substituted byte could not catch
475
+ // an amount already scaled by a wrong guess).
476
+ trusted = await mintDecimalsStrict(token, chainId);
477
+ if (trusted === null)
478
+ throw new Error(`Could not read the decimals of mint ${token} from the chain; refusing to guess the scale of the amount.`);
434
479
  }
435
480
  else {
436
481
  trusted = await resolveTrustedDecimals(chainId, token);
@@ -444,15 +489,23 @@ async function verifiedDecimals(chainId, token, given) {
444
489
  function validateGuardEnv() {
445
490
  parseDecimalPins(process.env.AGENTWALLET_TOKEN_DECIMALS); // throws on a malformed pin
446
491
  for (const name of ['AGENTWALLET_MAX_TX_NATIVE', 'AGENTWALLET_MAX_TX_TOKEN', 'AGENTWALLET_MAX_TX_SOL', 'AGENTWALLET_MAX_AUTOPAY']) {
447
- const v = (process.env[name] || '').trim();
492
+ const raw = process.env[name];
493
+ const v = (raw || '').trim();
494
+ // Set but blank is a typo, not "no cap": refuse to start rather than run uncapped.
495
+ if (raw !== undefined && v === '')
496
+ throw new Error(`${name} is set but blank. Set it to a number or unset it.`);
448
497
  if (v && !/^\d+(\.\d+)?$/.test(v))
449
498
  throw new Error(`${name} must be a decimal number, got "${v}".`);
450
499
  }
451
- for (const e of (process.env.AGENTWALLET_AUTOPAY_ASSETS || '').split(',').map(s => s.trim()).filter(Boolean)) {
452
- if (e !== 'native' && !/^0x[0-9a-fA-F]{40}$/.test(e) && !/^[1-9A-HJ-NP-Za-km-z]{32,44}$/.test(e)) {
453
- throw new Error(`AGENTWALLET_AUTOPAY_ASSETS entry "${e}" is not "native", an EVM address or an SPL mint.`);
500
+ for (const e of parseAutopayAssets(process.env.AGENTWALLET_AUTOPAY_ASSETS)) {
501
+ if (e.asset !== 'native' && !/^0x[0-9a-f]{40}$/.test(e.asset) && !/^[1-9A-HJ-NP-Za-km-z]{32,44}$/.test(e.asset)) {
502
+ throw new Error(`AGENTWALLET_AUTOPAY_ASSETS entry "${e.asset}" is not "native", an EVM address or an SPL mint (optionally prefixed "chainId:").`);
454
503
  }
455
504
  }
505
+ const t = (process.env.AGENTWALLET_X402_MAX_TIMEOUT || '').trim();
506
+ if (t && (!/^\d+$/.test(t) || Number(t) < 60 || Number(t) > 31_536_000)) {
507
+ throw new Error(`AGENTWALLET_X402_MAX_TIMEOUT must be a whole number of seconds between 60 and 31536000, got "${t}".`);
508
+ }
456
509
  }
457
510
  // ─── EVM Helpers ──────────────────────────────────────────────────
458
511
  /**
@@ -480,7 +533,7 @@ function formatUnits(raw, decimals) {
480
533
  const frac = padded.slice(padded.length - decimals);
481
534
  // Trim trailing zeros but keep at least one decimal
482
535
  const trimmed = frac.replace(/0+$/, '') || '0';
483
- return `${whole}.${trimmed}`;
536
+ return decimals === 0 ? whole : `${whole}.${trimmed}`; // a 0-decimal amount has no fraction, so it round-trips through parseUnits
484
537
  }
485
538
  /**
486
539
  * Pad an address to 32 bytes (64 hex chars) for ABI encoding.
@@ -503,48 +556,13 @@ function encodeUint256(value) {
503
556
  }
504
557
  // ─── Server ──────────────────────────────────────────────────────
505
558
  // ─── x402 Network Mapping ──────────────────────────────────────
506
- const X402_NETWORKS = {
507
- 'ethereum': 1,
508
- 'base': 8453,
509
- 'base-sepolia': 84532,
510
- 'polygon': 137,
511
- 'arbitrum': 42161,
512
- 'optimism': 10,
513
- 'bsc': 56,
514
- 'avalanche': 43114,
515
- 'zora': 7777777,
516
- 'pulsechain': 369,
517
- 'solana': 900,
518
- 'solana-devnet': 901,
519
- };
520
- // Solana genesis hash prefixes for CAIP-2
521
- const SOLANA_GENESIS = {
522
- 900: '5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp', // mainnet-beta
523
- 901: 'EtWTRABZaYq6iMfeYKouRu166VU2xqa1', // devnet
524
- };
525
559
  /**
526
- * Resolve an x402 network identifier to a chain ID.
527
- * Supports plain names ("base"), CAIP-2 format ("eip155:8453"), and raw chain IDs ("8453").
560
+ * Resolve an x402 network identifier to a chain ID: plain names ("base"),
561
+ * CAIP-2 ("eip155:8453", "solana:<genesis prefix>") and raw ids ("8453").
562
+ * The table and the parsing live in x402-payment.ts so they can be tested.
528
563
  */
529
564
  function resolveChainId(network) {
530
- const lower = network.toLowerCase();
531
- if (X402_NETWORKS[lower])
532
- return X402_NETWORKS[lower];
533
- const caipMatch = network.match(/^eip155:(\d+)$/);
534
- if (caipMatch)
535
- return parseInt(caipMatch[1], 10);
536
- // Solana CAIP-2: solana:<genesis_prefix>
537
- const solanaMatch = network.match(/^solana:(.+)$/);
538
- if (solanaMatch) {
539
- for (const [chainId, genesis] of Object.entries(SOLANA_GENESIS)) {
540
- if (solanaMatch[1] === genesis)
541
- return parseInt(chainId, 10);
542
- }
543
- }
544
- const num = parseInt(network, 10);
545
- if (!isNaN(num) && num > 0)
546
- return num;
547
- return null;
565
+ return resolveNetworkChainId(network);
548
566
  }
549
567
  // ─── Server ──────────────────────────────────────────────────────
550
568
  /**
@@ -561,7 +579,7 @@ function resolveChainId(network) {
561
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');
562
580
  const server = new McpServer({
563
581
  name: 'agentwallet',
564
- version: '1.13.4',
582
+ version: '1.13.6',
565
583
  }, {
566
584
  instructions: `AgentWallet gives AI agents their own blockchain wallets. ${anyLocalMode()
567
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.'
@@ -930,7 +948,7 @@ server.tool('approve_token', 'Approve a spender contract to transfer ERC-20 toke
930
948
  // Asset risk rides along with the approval (issue #13). It never blocks; it is the
931
949
  // caller's call. Skipped for Permit2, which only moves what a signature allows.
932
950
  let risk = null;
933
- 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') {
934
952
  try {
935
953
  risk = await assessTokenRisk(chain_id, token, (to, data) => ethCallHex(chain_id, to, data));
936
954
  }
@@ -1159,11 +1177,17 @@ async function ethCallHex(chainId, to, data) {
1159
1177
  * rather than guess.
1160
1178
  */
1161
1179
  async function resolveTokenDomain(chainId, asset, extra) {
1162
- if (extra?.name && extra?.version)
1163
- return { name: String(extra.name), version: String(extra.version) };
1164
1180
  const known = knownTokenDomain(chainId, asset);
1165
- 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
+ }
1166
1187
  return known;
1188
+ }
1189
+ if (declared)
1190
+ return declared;
1167
1191
  let name = '', version = '';
1168
1192
  try {
1169
1193
  name = decodeAbiString(await ethCallHex(chainId, asset, '0x06fdde03')); // name()
@@ -1176,6 +1200,13 @@ async function resolveTokenDomain(chainId, asset, extra) {
1176
1200
  }
1177
1201
  return { name, version };
1178
1202
  }
1203
+ /** Text an endpoint wrote, bounded so it cannot flood the agent's context. */
1204
+ function serverText(s) {
1205
+ if (s === undefined || s === null)
1206
+ return null;
1207
+ const t = String(s);
1208
+ return t.length > 300 ? t.slice(0, 300) + '…' : t;
1209
+ }
1179
1210
  /** Sign the authorization with whichever custody mode is active (local key, or the hosted signer). */
1180
1211
  async function signAuthorization(walletId, chainId, asset, domain, auth, approvalId) {
1181
1212
  const r = (await api(`/wallets/${walletId}/x402/authorize`, 'POST', {
@@ -1192,7 +1223,7 @@ async function signAuthorization(walletId, chainId, asset, domain, auth, approva
1192
1223
  /** Sign a Permit2 upto authorization with whichever custody mode is active. */
1193
1224
  async function signPermit2(walletId, chainId, auth, approvalId) {
1194
1225
  const r = (await api(`/wallets/${walletId}/x402/permit2`, 'POST', {
1195
- 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),
1196
1227
  nonce: auth.nonce, deadline: auth.deadline, to: auth.witness.to, facilitator: auth.witness.facilitator, valid_after: auth.witness.validAfter,
1197
1228
  ...(approvalId ? { approval_id: approvalId } : {}),
1198
1229
  }, { 'X-AGW-SKIP-X402': 'true' }));
@@ -1206,8 +1237,8 @@ server.tool('pay_x402', 'Handle an x402 payment flow. Fetches a URL, and if the
1206
1237
  'parses the payment requirements (v1 body or v2 PAYMENT-REQUIRED header), signs an EIP-3009 ' +
1207
1238
  'TransferWithAuthorization for the "exact" scheme (no gas, nothing broadcast by the payer), and retries ' +
1208
1239
  'the request with the payment header. AgentWallet paywalls are paid by on-chain transfer instead. ' +
1209
- 'The "upto" scheme is refused, never approximated. Returns the final response and the settlement ' +
1210
- 'receipt. Supports the x402 open payment standard (https://x402.org). Set max_payment to cap spend.', {
1240
+ 'The "upto" scheme is settled with a Permit2 authorization bounded by the cap. Returns the final response and the settlement ' +
1241
+ 'receipt. Supports the x402 open payment standard (https://x402.org). max_payment can only lower the operator\'s cap for one call.', {
1211
1242
  url: z.string().url().describe('The URL to access (will handle 402 payment if required)'),
1212
1243
  wallet_id: z.number().int().describe('Wallet ID to pay from'),
1213
1244
  method: z.string().default('GET').describe('HTTP method (GET, POST, PUT, DELETE)'),
@@ -1216,10 +1247,8 @@ server.tool('pay_x402', 'Handle an x402 payment flow. Fetches a URL, and if the
1216
1247
  'endpoint redirects to a different origin they are dropped, not forwarded.'),
1217
1248
  body: z.string().optional().describe('Optional request body for POST/PUT requests'),
1218
1249
  max_payment: z.string().optional().describe('Maximum payment in human-readable format (e.g. "1.00" for 1 USDC). ' +
1219
- 'Rejects payments above this amount. If omitted, a default cap of ' +
1220
- 'AGENTWALLET_MAX_AUTOPAY (default "1") is applied, so an untrusted 402 ' +
1221
- 'endpoint can never authorize an unbounded payment. Set this explicitly ' +
1222
- 'to allow a larger single payment.'),
1250
+ 'Lowers the operator\'s AGENTWALLET_MAX_AUTOPAY ceiling (default "1") for this one call; ' +
1251
+ 'it can never raise it, and a value above the ceiling is ignored and reported as max_payment_ignored.'),
1223
1252
  prefer_chain: z.number().int().optional().describe('Preferred chain ID if the server accepts payment on multiple chains ' +
1224
1253
  '(e.g. 8453 for Base, 1 for Ethereum)'),
1225
1254
  request_approval: z.boolean().optional().describe('Hosted wallets only. When the payment exceeds the cap, email the wallet owner an approve/deny link ' +
@@ -1319,16 +1348,14 @@ server.tool('pay_x402', 'Handle an x402 payment flow. Fetches a URL, and if the
1319
1348
  payment_required: true,
1320
1349
  payment_made: false,
1321
1350
  error: picked.reason,
1322
- 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,
1323
1353
  });
1324
1354
  }
1325
1355
  const option = picked.option;
1326
1356
  const chainId = picked.chainId;
1327
1357
  const isUpto = option.scheme === 'upto';
1328
- const rawAmount = requiredAmount(option);
1329
- if (!/^\d+$/.test(rawAmount)) {
1330
- throw new Error(`x402: invalid amount "${rawAmount}" in the payment requirements (expected integer base units).`);
1331
- }
1358
+ const rawAmount = normalizeRawAmount(requiredAmount(option)); // "0010000" and "10000" are one amount
1332
1359
  // Decimals come from the registry or the token contract, NEVER from the 402
1333
1360
  // body. The endpoint controls what it declares, so trusting it would let a
1334
1361
  // hostile server inflate the cap and misreport the amount shown to the agent.
@@ -1352,6 +1379,20 @@ server.tool('pay_x402', 'Handle an x402 payment flow. Fetches a URL, and if the
1352
1379
  error: assetPolicy.reason,
1353
1380
  });
1354
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
+ }
1355
1396
  // Step 4: Hard per-payment cap, ALWAYS applied. If the caller omits max_payment
1356
1397
  // we fall back to AGENTWALLET_MAX_AUTOPAY (default "1"). Above the cap a hosted
1357
1398
  // wallet can ask its owner instead of refusing: an approval is created, the
@@ -1366,7 +1407,7 @@ server.tool('pay_x402', 'Handle an x402 payment flow. Fetches a URL, and if the
1366
1407
  status: 402, payment_required: true, payment_made: false,
1367
1408
  required_amount: amount, max_allowed: effectiveMax, cap_source: capSource, token: tokenLabel,
1368
1409
  ...(clamped ? { max_payment_ignored: `max_payment ${max_payment} exceeds the operator ceiling AGENTWALLET_MAX_AUTOPAY=${effectiveMax}; only the operator can raise it.` } : {}),
1369
- 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,
1370
1411
  };
1371
1412
  const wantApproval = request_approval ?? (process.env.AGENTWALLET_APPROVALS !== '0');
1372
1413
  if (approval_id) {
@@ -1400,8 +1441,8 @@ server.tool('pay_x402', 'Handle an x402 payment flow. Fetches a URL, and if the
1400
1441
  ...overCap,
1401
1442
  error: `Payment of ${amount} ${tokenLabel} exceeds the ${effectiveMax} cap (from ${capSource}).` +
1402
1443
  (max_payment ? '' : ' No max_payment was provided, so the default cap was applied.') +
1403
- (anyLocalMode() ? ' Local mode has no approval channel: raise max_payment, AGENTWALLET_MAX_AUTOPAY or AGENTWALLET_MAX_TX_TOKEN yourself.'
1404
- : ' Pass request_approval=true to email the wallet owner for a one-time approval, or raise the cap.'),
1444
+ (anyLocalMode() ? ' Local mode has no approval channel; the caps are the operator\'s and only the operator can change them. Do not edit the environment or config to raise them.'
1445
+ : ' Pass request_approval=true to email the wallet owner for a one-time approval; the cap itself is the operator\'s to change.'),
1405
1446
  });
1406
1447
  }
1407
1448
  }
@@ -1410,7 +1451,7 @@ server.tool('pay_x402', 'Handle an x402 payment flow. Fetches a URL, and if the
1410
1451
  // usage; the payer broadcasts nothing and pays no gas for either. AgentWallet's
1411
1452
  // own paywalls (verified by receipt), native-asset requests and Solana still use
1412
1453
  // a broadcast transfer proved by hash.
1413
- const standardExact = !isUpto && !isSolanaChain(chainId) && Boolean(tokenAddress) && !isLegacyAgentWalletAccept(option);
1454
+ const standardExact = !isUpto && !isSolanaChain(chainId) && Boolean(tokenAddress) && !legacyReceipt;
1414
1455
  let headerName = 'X-PAYMENT';
1415
1456
  let paymentHeader = '';
1416
1457
  let txHash = null;
@@ -1423,80 +1464,103 @@ server.tool('pay_x402', 'Handle an x402 payment flow. Fetches a URL, and if the
1423
1464
  // The resource is the whole URL including its query: /buy?id=1 and /buy?id=2
1424
1465
  // are different purchases, and reusing the first signature for the second
1425
1466
  // would report a payment the second resource never received.
1426
- const cacheKey = [wallet_id, chainId, tokenAddress.toLowerCase(), option.payTo.toLowerCase(), rawAmount, option.scheme, authorizationResource(url)].join('|');
1467
+ // The method and body are part of what was bought: POST {item:A} and
1468
+ // POST {item:B} to one URL are two purchases. Concurrent calls for one key
1469
+ // 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;
1427
1475
  const cachedAuth = fresh_authorization ? undefined : signedPayments.get(cacheKey);
1428
1476
  for (const [k, v] of signedPayments)
1429
1477
  if (v.until <= Date.now())
1430
1478
  signedPayments.delete(k);
1431
- if (cachedAuth && cachedAuth.until > Date.now()) {
1432
- // Same endpoint, same requirement, still inside the window: re-send the
1433
- // earlier signature. Its nonce is single-use, so a server that already
1434
- // settled it cannot settle it again, and one that never did now can.
1435
- ({ headerName, paymentHeader, txHash, authorization, uptoAuth, payer } = cachedAuth);
1436
- authorizationReused = true;
1437
- }
1438
- else if (isUpto) {
1439
- payer = await payerAddress(wallet_id);
1440
- payerDelegationInfo = await payerDelegation(chainId, payer);
1441
- const allowance = decodeUint(await ethCallHex(chainId, tokenAddress, permit2AllowanceCalldata(payer)));
1442
- if (allowance < BigInt(rawAmount)) {
1443
- if (process.env.AGENTWALLET_PERMIT2_AUTO_APPROVE === '1' && assetPolicy.via === 'stablecoin') {
1444
- const r = (await api(`/wallets/${wallet_id}/send`, 'POST', { to: tokenAddress, value: '0', data: permit2ApproveCalldata(), chain_id: chainId }, skip));
1445
- permit2ApprovalTx = String(r?.tx_hash || '');
1446
- await new Promise(res => setTimeout(res, 4000));
1447
- }
1448
- else {
1449
- return jsonResponse({
1450
- status: 402, payment_required: true, payment_made: false, permit2_approval_needed: true,
1451
- token: tokenLabel, token_address: tokenAddress, chain_id: chainId, payer, permit2: PERMIT2_ADDRESS,
1452
- error: `This endpoint uses the x402 "upto" scheme, which settles through Permit2. Wallet ${payer} has not approved ` +
1453
- `${tokenLabel} to Permit2 on chain ${chainId}. Call approve_permit2 once for this token (a normal transaction that needs gas), ` +
1454
- `or set AGENTWALLET_PERMIT2_AUTO_APPROVE=1, then call pay_x402 again.`,
1455
- });
1456
- }
1457
- }
1458
- uptoAuth = buildUptoAuthorization(payer, option);
1459
- const signature = await signPermit2(wallet_id, chainId, uptoAuth, approvalUsed);
1460
- const payload = buildPaymentPayloadRaw(x402Version, option, uptoPayload(uptoAuth, signature), paymentInfo.resource, paymentInfo.extensions);
1461
- const h = paymentHeaderFor(x402Version, payload);
1462
- headerName = h.name;
1463
- paymentHeader = h.value;
1464
- }
1465
- else if (standardExact) {
1466
- payer = await payerAddress(wallet_id);
1467
- payerDelegationInfo = await payerDelegation(chainId, payer);
1468
- const domain = await resolveTokenDomain(chainId, tokenAddress, option.extra);
1469
- authorization = buildAuthorization(payer, option);
1470
- const signature = await signAuthorization(wallet_id, chainId, tokenAddress, domain, authorization, approvalUsed);
1471
- const payload = buildPaymentPayload(x402Version, option, authorization, signature, paymentInfo.resource, paymentInfo.extensions);
1472
- const h = paymentHeaderFor(x402Version, payload);
1473
- headerName = h.name;
1474
- paymentHeader = h.value;
1479
+ let releaseSigning = () => { };
1480
+ if (!(cachedAuth && cachedAuth.until > Date.now())) {
1481
+ const mine = new Promise(r => { releaseSigning = r; });
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);
1475
1487
  }
1476
- else {
1477
- let txResult;
1478
- if (isSolanaChain(chainId)) {
1479
- txResult = (await api(`/wallets/${wallet_id}/send`, 'POST', tokenAddress
1480
- ? { to: option.payTo, value: rawAmount, token_mint: tokenAddress, token_decimals: trustedDecimals, chain_id: chainId }
1481
- : { 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;
1482
1495
  }
1483
- else if (tokenAddress) {
1484
- const calldata = '0xa9059cbb' + padAddress(option.payTo) + encodeUint256(rawAmount);
1485
- txResult = (await api(`/wallets/${wallet_id}/send`, 'POST', { to: tokenAddress, value: '0', data: calldata, chain_id: chainId }));
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;
1523
+ }
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;
1486
1534
  }
1487
1535
  else {
1488
- 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');
1553
+ }
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 });
1489
1559
  }
1490
- txHash = String(txResult.tx_hash || txResult.signature || '');
1491
- if (!txHash)
1492
- throw new Error('x402: the payment transaction returned no hash.');
1493
- paymentHeader = Buffer.from(JSON.stringify({ x402Version, scheme: option.scheme, network: option.network, payload: { txHash } })).toString('base64');
1494
1560
  }
1495
- if (!authorizationReused) {
1496
- const until = authorization ? Number(authorization.validBefore) * 1000
1497
- : uptoAuth ? Number(uptoAuth.deadline) * 1000
1498
- : Date.now() + maxAuthWindowSeconds() * 1000; // a broadcast transfer: the hash stays valid, re-sending it never pays twice
1499
- signedPayments.set(cacheKey, { headerName, paymentHeader, txHash, authorization, uptoAuth, payer, until });
1561
+ finally {
1562
+ releaseSigning();
1563
+ signingInFlight.delete(cacheKey);
1500
1564
  }
1501
1565
  // Step 6: Retry with the payment header and read the settlement receipt.
1502
1566
  const retryHeaders = { ...reqHeaders, [headerName]: paymentHeader };
@@ -1508,7 +1572,19 @@ server.tool('pay_x402', 'Handle an x402 payment flow. Fetches a URL, and if the
1508
1572
  if (reqBody && method !== 'GET') {
1509
1573
  retryOptions.body = reqBody;
1510
1574
  }
1511
- 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
+ }
1512
1588
  const retryText = await retryRes.text();
1513
1589
  let retryParsed;
1514
1590
  try {
@@ -1523,22 +1599,24 @@ server.tool('pay_x402', 'Handle an x402 payment flow. Fetches a URL, and if the
1523
1599
  let retryError = null;
1524
1600
  const retryServedFrom = finalUrlOf(retryRes, url);
1525
1601
  const retryCrossOrigin = new URL(retryServedFrom).origin !== new URL(url).origin;
1602
+ const verdict = settlementVerdict(retryRes.status, settlement, retryCrossOrigin);
1526
1603
  if (retryCrossOrigin) {
1527
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.`;
1528
1605
  }
1529
1606
  if (retryRes.status === 402) {
1530
1607
  const again = parsePaymentRequired(n => retryRes.headers.get(n), retryParsed);
1531
- retryError = String(again?.error || retryParsed?.error || 'Payment was not accepted (no reason given).');
1608
+ retryError = serverText(String(again?.error || retryParsed?.error || 'Payment was not accepted (no reason given).'));
1532
1609
  if (payerDelegationInfo && /signature/i.test(retryError))
1533
1610
  retryError += ` Likely cause: ${payerDelegationInfo.warning}`;
1534
1611
  }
1535
1612
  return jsonResponse({
1536
1613
  status: retryRes.status,
1537
1614
  payment_required: true,
1538
- // Paid means the endpoint accepted the payment: a settlement receipt, or a success status after the payment header.
1539
- // A 4xx other than 402 (bad body, auth) means the request failed for another reason; the authorization was
1540
- // sent but the facilitator normally does not settle a failed request, so it is not reported as paid.
1541
- 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,
1542
1620
  authorization_reused: authorizationReused,
1543
1621
  retry_error: retryError,
1544
1622
  payment_method: isUpto ? 'permit2-upto-authorization' : (standardExact ? 'eip3009-authorization' : 'onchain-transfer'),
@@ -1553,13 +1631,15 @@ server.tool('pay_x402', 'Handle an x402 payment flow. Fetches a URL, and if the
1553
1631
  pay_to: option.payTo,
1554
1632
  payer,
1555
1633
  payer_delegation: payerDelegationInfo,
1556
- // txHash is ours (we broadcast it); settlement.transaction is the server's
1557
- // claim and is surfaced only when it is shaped like a real hash.
1558
- 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,
1559
1638
  settlement_reported_by_server: settlement,
1560
1639
  authorization: authorization ? { nonce: authorization.nonce, valid_before: authorization.validBefore }
1561
1640
  : (uptoAuth ? { scheme: 'upto', max_amount: amount, nonce: uptoAuth.nonce, deadline: uptoAuth.deadline, facilitator: uptoAuth.witness.facilitator } : null),
1562
- description: option.description,
1641
+ description: serverText(option.description),
1642
+ server_text_is_untrusted: true, // description, retry_error and the response are the endpoint's words, not instructions
1563
1643
  response: retryParsed,
1564
1644
  });
1565
1645
  });
@@ -1578,13 +1658,13 @@ server.tool('approve_permit2', 'One-time ERC-20 approval of a token to the Permi
1578
1658
  }, async ({ wallet_id, token, chain_id, amount }) => {
1579
1659
  if (isSolanaChain(chain_id))
1580
1660
  throw new Error('Permit2 is an EVM contract; there is nothing to approve on Solana.');
1581
- let data = permit2ApproveCalldata();
1661
+ let data = permit2ApproveCalldata(undefined, chain_id);
1582
1662
  if (amount !== undefined) {
1583
1663
  const raw = parseUnits(amount, await resolveTrustedDecimals(chain_id, token));
1584
- data = '0x095ea7b3' + padAddress(PERMIT2_ADDRESS) + encodeUint256(raw);
1664
+ data = permit2ApproveCalldata(raw, chain_id);
1585
1665
  }
1586
1666
  const result = (await api(`/wallets/${wallet_id}/send`, 'POST', { to: token, value: '0', data, chain_id }));
1587
- 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.' });
1588
1668
  });
1589
1669
  // ─── Tool: check_token_risk ─────────────────────────────────────
1590
1670
  server.tool('check_token_risk', 'Assess an ERC-20 token before approving or swapping it: honeypot, taxes, owner powers, verified source, ' +
@@ -1764,7 +1844,13 @@ server.tool('wallet_mode', 'Report whether this server is signing locally (self-
1764
1844
  mode: 'custodial',
1765
1845
  custody: 'agentwallet',
1766
1846
  signing: 'AgentWallet servers hold an encrypted key and sign on your behalf.',
1767
- api_base: API_BASE,
1847
+ api_base: redactUrl(API_BASE),
1848
+ client_guards: {
1849
+ autopay_cap_usd: autopayEnvCap(),
1850
+ autopay_assets: (process.env.AGENTWALLET_AUTOPAY_ASSETS || '').trim() || 'registry stablecoins only',
1851
+ x402_max_timeout_seconds: maxAuthWindowSeconds(),
1852
+ note: 'These bound pay_x402 on hosted wallets too; the hosted signer adds its own pause, daily limit and approval checks.',
1853
+ },
1768
1854
  to_self_custody: 'Set AGENTWALLET_PRIVATE_KEY for EVM and/or AGENTWALLET_SOLANA_KEY for Solana, then restart. ' +
1769
1855
  'Use export_wallet_key first if you want to carry an existing hosted wallet across.',
1770
1856
  });
@@ -1864,7 +1950,7 @@ async function main() {
1864
1950
  }
1865
1951
  }
1866
1952
  else {
1867
- console.error('AgentWallet MCP: custodial mode via ' + API_BASE + '. Run wallet_mode for details.');
1953
+ console.error('AgentWallet MCP: custodial mode via ' + redactUrl(API_BASE) + '. Run wallet_mode for details.');
1868
1954
  }
1869
1955
  }
1870
1956
  main().catch((error) => {