agentwallet-mcp 1.13.10 → 1.13.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -39,7 +39,7 @@ Run `wallet_mode` at any time and the server will tell you which one you are in,
39
39
 
40
40
  Use `AGENTWALLET_KEYFILE=/path/to/key` instead if you would rather keep the key out of your shell config (one or the other: the server refuses to start when both are set). The key is read once, never written to disk, never logged, and never included in an error message.
41
41
 
42
- `AGENTWALLET_RPC_<chainId>` (or `AGENTWALLET_RPC_URL` for all chains) points at an endpoint you trust. Without it a public RPC is used, and a public RPC can see which addresses you ask about.
42
+ `AGENTWALLET_RPC_<chainId>` (or `AGENTWALLET_RPC_URL` for all chains) points at an endpoint you trust (https; plain http only on localhost). Without it a public RPC is used, and a public RPC can see which addresses you ask about. Decimals for a token outside the registry come from that RPC and are accepted only up to 18; pin anything else with `AGENTWALLET_TOKEN_DECIMALS`.
43
43
 
44
44
  `AGENTWALLET_MAX_TX_NATIVE` is a per-transaction ceiling in native units (ETH, MATIC and so on). In hosted mode the server enforces limits; in local mode there is no server, so these guards are the only ones there are. Set them.
45
45
 
@@ -330,6 +330,7 @@ Which guards apply depends on who holds the key.
330
330
  **Local mode** (you hold the key): there is no server in the signing path, so the server-side limits and pause above cannot see or stop a locally signed transaction, and nobody (including us) can freeze a local wallet. Your protection is the per-transaction caps enforced inside your own process before anything is signed. Set them before you fund the wallet; an unset cap means no limit.
331
331
 
332
332
  - `AGENTWALLET_MAX_TX_NATIVE`: ceiling per transaction in native units (ETH, MATIC, and so on)
333
+ - `AGENTWALLET_MAX_FEE_NATIVE`: the most one transaction may burn in fees, gas x max fee per gas, in native units (default `0.01`). The value cap alone did not bound fees: a node answering an absurd tip or gas estimate could take the whole balance as priority fee on a cap-compliant transfer. Caller-supplied `gas_limit` / `max_fee` / `priority_fee` are honoured and then bounded by this cap
333
334
  - `AGENTWALLET_MAX_TX_TOKEN`: ceiling per ERC-20 transfer or approval, in human units of the token
334
335
  - `AGENTWALLET_ALLOW_UNKNOWN_TOKEN_CALLS`: set to `1` to let calldata the token cap cannot price reach a token contract or Permit2 (refused by default while the cap is set)
335
336
  - `AGENTWALLET_TOKEN_DECIMALS`: pin decimals for tokens outside the registry (`8453:0x<token>=6,<mint>=9`); a pin is used for amounts and caps instead of asking the RPC
package/build/index.js CHANGED
@@ -16,7 +16,7 @@ import { assertPublicUrl, safeFetch, finalUrlOf } from './ssrf-guard.js';
16
16
  import { deriveX402Payment, isWithinCap, lookupTrustedDecimals, assertDeclaredDecimals, nativeDecimals, SOLANA_CHAIN_IDS, assetLabel, autopayAssetAllowed, autopayEnvCap, effectiveAutopayCap, maxAuthWindowSeconds, parseDecimalPins, approvalRefusal, authorizationResource, resolveNetworkChainId, normalizeRawAmount, parseAutopayAssets, assetSupportsExact, approvalResourceUrl, } from './x402-payment.js';
17
17
  import { createHash } from 'node:crypto';
18
18
  import { isLocalMode, getLocalAddress, localWalletRecord, localSend, localNativeBalance, localTokenBalance, localSignMessage, resolveRpcUrl, } from './local-wallet.js';
19
- import { localSignAuthorization, localEthCall, localGetCode, localSignPermit2Upto } from './local-wallet.js';
19
+ import { localSignAuthorization, localEthCall, localGetCode, localSignPermit2Upto, probeTokenDecimals } from './local-wallet.js';
20
20
  import { WRAPPED_NATIVE } from './wrapped-native.js';
21
21
  import { buildUptoAuthorization, uptoPayload, permit2AllowanceCalldata, permit2ApproveCalldata, decodeUint, permit2Address, } from './x402-permit2.js';
22
22
  import { assessTokenRisk } from './token-risk.js';
@@ -119,7 +119,12 @@ async function routeLocally(path, method, body) {
119
119
  throw new Error(`Local mode expects an EVM address, got "${to}".`);
120
120
  }
121
121
  const hasData = data && data !== '0x';
122
- return localSend(chainId, to, value, hasData ? data : undefined);
122
+ const fees = {
123
+ ...(body?.gas_limit ? { gas: String(body.gas_limit) } : {}),
124
+ ...(body?.max_fee ? { maxFeePerGas: String(body.max_fee) } : {}),
125
+ ...(body?.priority_fee ? { maxPriorityFeePerGas: String(body.priority_fee) } : {}),
126
+ };
127
+ return localSend(chainId, to, value, hasData ? data : undefined, fees);
123
128
  }
124
129
  // POST /wallets/{id}/x402/authorize (x402 "exact": EIP-3009 authorization, signed here, broadcast by nobody)
125
130
  if (method === 'POST' && /^\/wallets\/[^/]*\/x402\/authorize$/.test(rawPath)) {
@@ -376,21 +381,29 @@ async function resolveTrustedDecimals(chainId, token) {
376
381
  throw new Error(`x402 blocked: cannot verify decimals for SPL mint ${token} on chain ${chainId}. ` +
377
382
  `Unknown mints are refused because the payment cap cannot be checked without trusted decimals.`);
378
383
  }
379
- // decimals() selector
384
+ // decimals() selector. In local mode this is the SAME probe and cache the
385
+ // local cap uses: two caches let a node answer 6 to one and 18 to the other
386
+ // and move 1000x the cap (round 5).
380
387
  let onChain = null;
381
- try {
382
- const r = (await api('/eth-call', 'POST', {
383
- chain_id: chainId, to: token, data: '0x313ce567',
384
- }, { 'X-AGW-SKIP-X402': 'true' }));
385
- const hex = (r?.result || '').replace(/^0x/, '');
386
- if (hex && /^[0-9a-fA-F]+$/.test(hex)) {
387
- const n = parseInt(hex, 16);
388
- if (Number.isInteger(n) && n >= 0 && n <= 36)
389
- onChain = n;
390
- }
388
+ if (anyLocalMode()) {
389
+ const p = await probeTokenDecimals(chainId, token);
390
+ onChain = typeof p === 'number' ? p : null;
391
391
  }
392
- catch {
393
- onChain = null; // fall through to the refusal below
392
+ else {
393
+ try {
394
+ const r = (await api('/eth-call', 'POST', {
395
+ chain_id: chainId, to: token, data: '0x313ce567',
396
+ }, { 'X-AGW-SKIP-X402': 'true' }));
397
+ const hex = (r?.result || '').replace(/^0x/, '');
398
+ if (hex && /^[0-9a-fA-F]+$/.test(hex)) {
399
+ const n = parseInt(hex, 16);
400
+ if (Number.isInteger(n) && n >= 0 && n <= 36)
401
+ onChain = n;
402
+ }
403
+ }
404
+ catch {
405
+ onChain = null; // fall through to the refusal below
406
+ }
394
407
  }
395
408
  if (onChain === null) {
396
409
  throw new Error(`x402 blocked: could not resolve on-chain decimals for ${token} on chain ${chainId}. ` +
@@ -574,8 +587,43 @@ function redactRpcSecrets(err) {
574
587
  const e = err;
575
588
  // A rejection that is not an Error (web3.js rejects with a raw {InstructionError} object) used to read "[object Object]".
576
589
  let msg = e?.shortMessage ? [e.shortMessage, e.details].filter(Boolean).join(' ') : (e?.message !== undefined ? String(e.message) : (err && typeof err === 'object' ? JSON.stringify(err) : String(err)));
590
+ // Bounded BEFORE the regex: the URL pattern is quadratic on text without "://",
591
+ // and a node's 160 KB error page held the whole server for 14 s (round 5).
592
+ msg = msg.slice(0, RPC_ERROR_MAX_CHARS);
577
593
  msg = msg.split(/\r?\n/).filter(l => !/^\s*(URL|Request body|Request Arguments|Raw Call Arguments)\s*:/i.test(l)).join('\n').trim();
578
- return msg.replace(/[a-z][a-z0-9+.-]*:\/\/[^\s"'<>)]+/gi, (u) => redactUrl(u)); // brackets stay in: IPv6 hosts
594
+ msg = msg.replace(/[a-z][a-z0-9+.-]*:\/\/[^\s"'<>)]+/gi, (u) => redactUrl(u)); // brackets stay in: IPv6 hosts
595
+ // A node that echoes the request path ("The requested URL /v3/<key> was not found")
596
+ // leaks the provider key without a scheme in front of it; every configured RPC
597
+ // URL's path and query are scrubbed as plain text as well (round 5).
598
+ for (const secret of rpcPathSecrets())
599
+ msg = msg.split(secret).join('/…');
600
+ return msg.length > 2000 ? msg.slice(0, 2000) + '…' : msg;
601
+ }
602
+ const RPC_ERROR_MAX_CHARS = 4000;
603
+ let rpcPathSecretsCache = null;
604
+ /** The path+query of every configured RPC URL (and its URL-decoded form), longest first. */
605
+ function rpcPathSecrets() {
606
+ if (rpcPathSecretsCache)
607
+ return rpcPathSecretsCache;
608
+ const out = new Set();
609
+ for (const [k, v] of Object.entries(process.env)) {
610
+ if (!/^AGENTWALLET_(RPC_URL|RPC_\d+|SOLANA_RPC|SOLANA_RPC_\d+)$/.test(k) || !v)
611
+ continue;
612
+ try {
613
+ const u = new URL(v.trim());
614
+ const tail = u.pathname + u.search;
615
+ if (tail.length > 1) {
616
+ out.add(tail);
617
+ try {
618
+ out.add(decodeURIComponent(tail));
619
+ }
620
+ catch { /* keep the raw form */ }
621
+ }
622
+ }
623
+ catch { /* not a URL; nothing to scrub */ }
624
+ }
625
+ rpcPathSecretsCache = [...out].sort((a, b) => b.length - a.length);
626
+ return rpcPathSecretsCache;
579
627
  }
580
628
  /**
581
629
  * Decimals to scale a human amount by. Always the token's own value (registry,
@@ -603,7 +651,7 @@ async function verifiedDecimals(chainId, token, given) {
603
651
  /** Cap variables are read at send time; a typo must fail at startup, not after funding. */
604
652
  function validateGuardEnv() {
605
653
  parseDecimalPins(process.env.AGENTWALLET_TOKEN_DECIMALS); // throws on a malformed pin
606
- for (const name of ['AGENTWALLET_MAX_TX_NATIVE', 'AGENTWALLET_MAX_TX_TOKEN', 'AGENTWALLET_MAX_TX_SOL', 'AGENTWALLET_MAX_AUTOPAY']) {
654
+ for (const name of ['AGENTWALLET_MAX_TX_NATIVE', 'AGENTWALLET_MAX_TX_TOKEN', 'AGENTWALLET_MAX_TX_SOL', 'AGENTWALLET_MAX_AUTOPAY', 'AGENTWALLET_MAX_FEE_NATIVE']) {
607
655
  const raw = process.env[name];
608
656
  const v = (raw || '').trim();
609
657
  // Set but blank is a typo, not "no cap": refuse to start rather than run uncapped.
@@ -654,7 +702,7 @@ function validateGuardEnv() {
654
702
  throw new Error(`Unknown environment variable ${k}. AgentWallet refuses to start on an unrecognised AGENTWALLET_* name so that a misspelled cap cannot run uncapped. Known names: ${[...KNOWN_ENV].join(', ')}, AGENTWALLET_RPC_<chainId>, AGENTWALLET_SOLANA_RPC_<chainId>.`);
655
703
  }
656
704
  }
657
- const KNOWN_ENV = new Set(["AGENTWALLET_ALLOW_UNKNOWN_TOKEN_CALLS", "AGENTWALLET_API_URL", "AGENTWALLET_APPROVALS", "AGENTWALLET_AUTOPAY_ASSETS", "AGENTWALLET_CHAIN_ID", "AGENTWALLET_DASHBOARD_URL", "AGENTWALLET_KEYFILE", "AGENTWALLET_LEGACY_PAYWALL_ORIGINS", "AGENTWALLET_MAX_AUTOPAY", "AGENTWALLET_MAX_AUTOPAY_PER_HOUR", "AGENTWALLET_MAX_TX_NATIVE", "AGENTWALLET_MAX_TX_SOL", "AGENTWALLET_MAX_TX_TOKEN", "AGENTWALLET_PASS", "AGENTWALLET_PERMIT2_AUTO_APPROVE", "AGENTWALLET_PRIVATE_KEY", "AGENTWALLET_RPC_URL", "AGENTWALLET_SOLANA_KEY", "AGENTWALLET_SOLANA_KEYFILE", "AGENTWALLET_SOLANA_RPC", "AGENTWALLET_TOKEN_DECIMALS", "AGENTWALLET_TOKEN_RISK", "AGENTWALLET_USER", "AGENTWALLET_WALLET_ID", "AGENTWALLET_X402_MAX_TIMEOUT"]);
705
+ const KNOWN_ENV = new Set(["AGENTWALLET_ALLOW_UNKNOWN_TOKEN_CALLS", "AGENTWALLET_API_URL", "AGENTWALLET_APPROVALS", "AGENTWALLET_AUTOPAY_ASSETS", "AGENTWALLET_CHAIN_ID", "AGENTWALLET_DASHBOARD_URL", "AGENTWALLET_KEYFILE", "AGENTWALLET_LEGACY_PAYWALL_ORIGINS", "AGENTWALLET_MAX_AUTOPAY", "AGENTWALLET_MAX_AUTOPAY_PER_HOUR", "AGENTWALLET_MAX_FEE_NATIVE", "AGENTWALLET_MAX_TX_NATIVE", "AGENTWALLET_MAX_TX_SOL", "AGENTWALLET_MAX_TX_TOKEN", "AGENTWALLET_PASS", "AGENTWALLET_PERMIT2_AUTO_APPROVE", "AGENTWALLET_PRIVATE_KEY", "AGENTWALLET_RPC_URL", "AGENTWALLET_SOLANA_KEY", "AGENTWALLET_SOLANA_KEYFILE", "AGENTWALLET_SOLANA_RPC", "AGENTWALLET_TOKEN_DECIMALS", "AGENTWALLET_TOKEN_RISK", "AGENTWALLET_USER", "AGENTWALLET_WALLET_ID", "AGENTWALLET_X402_MAX_TIMEOUT"]);
658
706
  /**
659
707
  * Register a tool whose arguments are strict: an unknown key (max_payemnt for
660
708
  * max_payment) is an error that names it, not a silently dropped guard. The
@@ -742,7 +790,7 @@ function resolveChainId(network) {
742
790
  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');
743
791
  const server = new McpServer({
744
792
  name: 'agentwallet',
745
- version: '1.13.10',
793
+ version: '1.13.12',
746
794
  }, {
747
795
  instructions: `AgentWallet gives AI agents their own blockchain wallets. ${anyLocalMode()
748
796
  ? '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 environment caps (AGENTWALLET_MAX_TX_NATIVE / _TOKEN / _SOL per transaction, AGENTWALLET_MAX_AUTOPAY and AGENTWALLET_AUTOPAY_ASSETS for x402, AGENTWALLET_X402_MAX_TIMEOUT for authorization windows). There is no server-side pause, daily limit or approval email; pause_wallet, unpause_wallet and delete_wallet are refused.'
@@ -1347,8 +1395,10 @@ async function resolveTokenDomain(chainId, asset, extra) {
1347
1395
  }
1348
1396
  return known;
1349
1397
  }
1350
- if (declared)
1351
- return declared;
1398
+ // Outside the registry the contract's own name()/version() come first; the
1399
+ // 402's declared pair is accepted only when the contract does not answer, and
1400
+ // a declared pair that contradicts the contract is refused (round 5: a
1401
+ // declared "Evil Name"/"9" used to be signed over the real domain).
1352
1402
  let name = '', version = '';
1353
1403
  try {
1354
1404
  // The signing decoder (256 bytes, strict UTF-8), not the 64-char display sanitiser
@@ -1357,12 +1407,17 @@ async function resolveTokenDomain(chainId, asset, extra) {
1357
1407
  name = decodeDomainString(await ethCallHex(chainId, asset, '0x06fdde03')); // name()
1358
1408
  version = decodeDomainString(await ethCallHex(chainId, asset, '0x54fd4d50')); // version()
1359
1409
  }
1360
- catch { /* refused below */ }
1361
- if (!name || !version) {
1362
- throw new Error(`x402: cannot determine the EIP-712 domain for token ${asset} on chain ${chainId}: the endpoint sent no ` +
1363
- `extra.name/extra.version and the contract did not answer name()/version(). Refused rather than signed with a guessed domain.`);
1410
+ catch { /* handled below */ }
1411
+ if (name && version) {
1412
+ if (declared && (declared.name !== name || declared.version !== version)) {
1413
+ throw new Error(`x402: the endpoint declared EIP-712 domain "${serverText(declared.name)}"/"${serverText(declared.version)}" for ${asset} on chain ${chainId}, but the contract reports "${serverText(name)}"/"${serverText(version)}"; refusing to sign under a domain the token would reject.`);
1414
+ }
1415
+ return { name, version };
1364
1416
  }
1365
- return { name, version };
1417
+ if (declared)
1418
+ return declared;
1419
+ throw new Error(`x402: cannot determine the EIP-712 domain for token ${asset} on chain ${chainId}: the endpoint sent no ` +
1420
+ `extra.name/extra.version and the contract did not answer name()/version(). Refused rather than signed with a guessed domain.`);
1366
1421
  }
1367
1422
  /** Text an endpoint wrote, bounded so it cannot flood the agent's context. */
1368
1423
  function serverText(s) {
@@ -1816,8 +1871,17 @@ tool('pay_x402', 'Handle an x402 payment flow. Fetches a URL, and if the server
1816
1871
  const retryServedFrom = finalUrlOf(retryRes, url);
1817
1872
  const retryCrossOrigin = new URL(retryServedFrom).origin !== new URL(url).origin;
1818
1873
  const verdict = settlementVerdict(retryRes.status, settlement, retryCrossOrigin);
1819
- const claimedRaw = typeof settlement?.amount === 'string' && /^\d+$/.test(settlement.amount) ? settlement.amount : null;
1820
- const claimedAboveMax = claimedRaw !== null && BigInt(claimedRaw) > BigInt(rawAmount);
1874
+ // The server's claimed settled amount may be in base units or human units; it is inconsistent if it exceeds the maximum in either reading.
1875
+ const claimedStr = typeof settlement?.amount === 'string' ? settlement.amount.trim() : null;
1876
+ let claimedAboveMax = false;
1877
+ if (claimedStr && /^\d+(\.\d+)?$/.test(claimedStr)) {
1878
+ try {
1879
+ claimedAboveMax = (/^\d+$/.test(claimedStr) && BigInt(claimedStr) > BigInt(rawAmount)) || BigInt(parseUnits(claimedStr, trustedDecimals)) > BigInt(rawAmount);
1880
+ }
1881
+ catch {
1882
+ claimedAboveMax = false;
1883
+ }
1884
+ }
1821
1885
  if (retryCrossOrigin) {
1822
1886
  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.`;
1823
1887
  }
@@ -1836,6 +1900,8 @@ tool('pay_x402', 'Handle an x402 payment flow. Fetches a URL, and if the server
1836
1900
  payment_made: verdict.payment_made,
1837
1901
  payment_outcome: verdict.outcome,
1838
1902
  authorization_reused: authorizationReused,
1903
+ // A cached authorization is re-sent as signed; if this call's max_payment is lower than it, say so (no new authorization was signed).
1904
+ cached_authorization_above_current_cap: authorizationReused && !isWithinCap(rawAmount, trustedDecimals, effectiveMax),
1839
1905
  retry_error: retryError,
1840
1906
  payment_method: isUpto ? 'permit2-upto-authorization' : (standardExact ? 'eip3009-authorization' : 'onchain-transfer'),
1841
1907
  approval_id: approvalUsed,
@@ -1893,7 +1959,13 @@ tool('check_token_risk', 'Assess an ERC-20 token before approving or swapping it
1893
1959
  'A warning for the caller to weigh, never a block.', {
1894
1960
  token: z.string().regex(/^0x[a-fA-F0-9]{40}$/).describe('ERC-20 token contract address'),
1895
1961
  chain_id: z.number().int().describe('Chain ID'),
1896
- }, async ({ token, chain_id }) => jsonResponse({ token, chain_id, risk: await assessTokenRisk(chain_id, token, (to, data) => ethCallHex(chain_id, to, data)) }));
1962
+ }, async ({ token, chain_id }) => {
1963
+ if (process.env.AGENTWALLET_TOKEN_RISK === '0') {
1964
+ // The operator said no third-party lookups; approve_token honoured it, this tool did not (round 5).
1965
+ return jsonResponse({ token, chain_id, risk: { source: 'none', notes: ['The GoPlus lookup is disabled by AGENTWALLET_TOKEN_RISK=0; no third party was contacted.'] } });
1966
+ }
1967
+ return jsonResponse({ token, chain_id, risk: await assessTokenRisk(chain_id, token, (to, data) => ethCallHex(chain_id, to, data)) });
1968
+ });
1897
1969
  // ─── Tool: get_usage ─────────────────────────────────────────────
1898
1970
  tool('get_usage', 'Get the current month\'s usage statistics. ' +
1899
1971
  'Returns operations count, tier info, remaining quota, and fees.', {}, async () => {
@@ -2115,6 +2187,7 @@ tool('wallet_mode', 'Report whether this server is signing locally (self-custody
2115
2187
  address: getLocalAddress(),
2116
2188
  rpc_endpoint: redactUrl(rpc),
2117
2189
  per_tx_cap_native: (process.env.AGENTWALLET_MAX_TX_NATIVE || '').trim() || 'not set',
2190
+ per_tx_fee_cap_native: (process.env.AGENTWALLET_MAX_FEE_NATIVE || '').trim() || '0.01 (default)',
2118
2191
  };
2119
2192
  }
2120
2193
  else {
@@ -106,6 +106,9 @@ export function resolveSolanaRpc(chainId = 900) {
106
106
  const explicit = (process.env.AGENTWALLET_SOLANA_RPC || '').trim();
107
107
  if (explicit)
108
108
  return explicit;
109
+ if (Object.keys(process.env).some(k => /^AGENTWALLET_SOLANA_RPC_\d+$/.test(k) && (process.env[k] || '').trim())) {
110
+ throw new Error(`No Solana RPC for chain ${chainId}: AGENTWALLET_SOLANA_RPC_<chainId> is set for another cluster, so the public default is not used. Set AGENTWALLET_SOLANA_RPC_${chainId} or AGENTWALLET_SOLANA_RPC.`);
111
+ }
109
112
  const fallback = DEFAULT_SOLANA_RPC[chainId];
110
113
  if (fallback)
111
114
  return fallback;
@@ -49,14 +49,15 @@ export declare function localNativeBalance(chainId: number): Promise<{
49
49
  mode: string;
50
50
  }>;
51
51
  export declare function localTokenBalance(chainId: number, token: Address): Promise<{
52
+ mode: string;
53
+ warning?: string | undefined;
52
54
  address: `0x${string}`;
53
55
  chain_id: number;
54
56
  token: `0x${string}`;
55
57
  symbol: string;
56
- decimals: number;
58
+ decimals: number | null;
57
59
  balance_raw: string;
58
- balance: string;
59
- mode: string;
60
+ balance: string | null;
60
61
  }>;
61
62
  export interface LocalSendResult {
62
63
  tx_hash: string;
@@ -66,6 +67,9 @@ export interface LocalSendResult {
66
67
  chain_id: number;
67
68
  mode: 'local';
68
69
  signed_locally: true;
70
+ gas?: string;
71
+ max_fee_per_gas?: string;
72
+ worst_case_fee?: string;
69
73
  }
70
74
  /**
71
75
  * What a decimals() probe learned about an address. The four outcomes are
@@ -86,10 +90,28 @@ export interface LocalSendResult {
86
90
  * differing only in that immutable were blocked (18) and sent (77).
87
91
  */
88
92
  type DecimalsProbe = number | 'unusable' | 'not-a-token' | 'unreachable';
93
+ /**
94
+ * Decimals for `token`: the trusted registry, then the contract's own
95
+ * decimals() (cached on success). See DecimalsProbe for the other outcomes.
96
+ */
97
+ export declare function probeTokenDecimals(chainId: number, token: Address): Promise<DecimalsProbe>;
89
98
  /** An eth_call failure is a revert only when the node says so; every other failure is "could not ask". */
90
99
  export declare function classifyDecimalsError(err: unknown): DecimalsProbe;
91
100
  export declare function permit2AddressFor(chainId: number): string;
92
- export declare function localSend(chainId: number, to: Address, valueWei: bigint, data?: Hex): Promise<LocalSendResult>;
101
+ /** Caller-chosen gas parameters (wei / gas units as decimal strings); honoured, then bounded by the fee cap. */
102
+ export interface FeeOverrides {
103
+ gas?: string;
104
+ maxFeePerGas?: string;
105
+ maxPriorityFeePerGas?: string;
106
+ }
107
+ /**
108
+ * The most a transaction may burn in fees: gas x maxFeePerGas, in native units.
109
+ * Without it a node that answered a 47,000 gwei tip or a 30M gas estimate took
110
+ * the whole balance as fees on a cap-compliant 0.001 ETH transfer (round 4).
111
+ * Default 0.01; AGENTWALLET_MAX_FEE_NATIVE raises or lowers it.
112
+ */
113
+ export declare function feeCapWei(): bigint;
114
+ export declare function localSend(chainId: number, to: Address, valueWei: bigint, data?: Hex, fees?: FeeOverrides): Promise<LocalSendResult>;
93
115
  /** ERC-20 transfer, encoded locally. */
94
116
  export declare function localTransferToken(chainId: number, token: Address, to: Address, rawAmount: string): Promise<LocalSendResult>;
95
117
  /** ERC-20 approve, encoded locally. */
@@ -15,7 +15,7 @@
15
15
  * on the custodial path and say so explicitly rather than silently downgrading.
16
16
  */
17
17
  import { readFileSync, statSync } from 'node:fs';
18
- import { createPublicClient, createWalletClient, http, encodeFunctionData, parseAbi, formatUnits, } from 'viem';
18
+ import { createPublicClient, createWalletClient, http, encodeFunctionData, parseAbi, formatUnits, keccak256, } from 'viem';
19
19
  import { privateKeyToAccount } from 'viem/accounts';
20
20
  import { nonceManager } from 'viem/nonce';
21
21
  import { lookupTrustedDecimals, maxAuthWindowSeconds } from './x402-payment.js';
@@ -109,16 +109,37 @@ const DEFAULT_RPC = {
109
109
  export function resolveRpcUrl(chainId) {
110
110
  const perChain = (process.env[`AGENTWALLET_RPC_${chainId}`] || '').trim();
111
111
  if (perChain)
112
- return perChain;
112
+ return assertRpcScheme(perChain, `AGENTWALLET_RPC_${chainId}`);
113
113
  const generic = (process.env.AGENTWALLET_RPC_URL || '').trim();
114
114
  if (generic)
115
- return generic;
115
+ return assertRpcScheme(generic, 'AGENTWALLET_RPC_URL');
116
+ // An operator who named a node for any chain wants control of all of them:
117
+ // a chain they did not name must not fall through to a public endpoint
118
+ // (a devnet default answered a real simulation in round 5).
119
+ if (Object.keys(process.env).some(k => /^AGENTWALLET_RPC_\d+$/.test(k) && (process.env[k] || '').trim())) {
120
+ throw new Error(`No RPC endpoint for chain ${chainId}: AGENTWALLET_RPC_<chainId> is set for other chains, so the public default is not used. Set AGENTWALLET_RPC_${chainId} or AGENTWALLET_RPC_URL.`);
121
+ }
116
122
  const fallback = DEFAULT_RPC[chainId];
117
123
  if (fallback)
118
124
  return fallback;
119
125
  throw new Error(`No RPC endpoint for chain ${chainId} in local mode. ` +
120
126
  `Set AGENTWALLET_RPC_${chainId} to an endpoint you trust.`);
121
127
  }
128
+ /** An RPC over plain http to anything but loopback is the hostile-node model in full; refused like the API URL is. */
129
+ function assertRpcScheme(url, name) {
130
+ let u;
131
+ try {
132
+ u = new URL(url);
133
+ }
134
+ catch {
135
+ throw new Error(`${name} is not a URL: "${url}".`);
136
+ }
137
+ const loopback = u.hostname === 'localhost' || u.hostname === '127.0.0.1' || u.hostname === '[::1]';
138
+ if (u.protocol !== 'https:' && !(u.protocol === 'http:' && loopback)) {
139
+ throw new Error(`${name} must be https (got ${u.protocol}//${u.host}); plain http is allowed only for localhost.`);
140
+ }
141
+ return url;
142
+ }
122
143
  /**
123
144
  * Chain ids each RPC URL has been seen to serve, so an operator pointing
124
145
  * AGENTWALLET_RPC_URL at one node for every chain, or mistyping a per-chain
@@ -171,6 +192,7 @@ function assertWithinNativeCap(valueWei) {
171
192
  }
172
193
  /* ── Read operations ─────────────────────────────────────────────── */
173
194
  export async function localNativeBalance(chainId) {
195
+ await assertRpcServesChain(chainId); // reads used to skip the check and report another chain's balance (round 5)
174
196
  const { pub } = clients(chainId);
175
197
  const address = getLocalAddress();
176
198
  const wei = await pub.getBalance({ address });
@@ -185,18 +207,24 @@ const ERC20_ABI = parseAbi([
185
207
  'function allowance(address,address) view returns (uint256)',
186
208
  ]);
187
209
  export async function localTokenBalance(chainId, token) {
210
+ await assertRpcServesChain(chainId);
188
211
  const { pub } = clients(chainId);
189
212
  const address = getLocalAddress();
190
- const [raw, decimals, symbol] = await Promise.all([
213
+ const [raw, decimalsRaw, symbol] = await Promise.all([
191
214
  pub.readContract({ address: token, abi: ERC20_ABI, functionName: 'balanceOf', args: [address] }),
192
- pub.readContract({ address: token, abi: ERC20_ABI, functionName: 'decimals' }).catch(() => 18),
215
+ pub.readContract({ address: token, abi: ERC20_ABI, functionName: 'decimals' }).catch(() => null),
193
216
  pub.readContract({ address: token, abi: ERC20_ABI, functionName: 'symbol' }).catch(() => ''),
194
217
  ]);
218
+ // A decimals() of 2^20 handed to viem's formatUnits held the whole server for
219
+ // minutes (quadratic trailing-zero trim, round 4): only 0..36 is formatted.
220
+ const d = Number(decimalsRaw);
221
+ const safe = Number.isInteger(d) && d >= 0 && d <= 36 ? d : null;
195
222
  return {
196
223
  address, chain_id: chainId, token, symbol,
197
- decimals: Number(decimals),
224
+ decimals: safe,
198
225
  balance_raw: raw.toString(),
199
- balance: formatUnits(raw, Number(decimals)),
226
+ balance: safe === null ? null : formatUnits(raw, safe),
227
+ ...(safe === null ? { warning: 'the token reported decimals outside 0..36 (or none); only the raw balance is shown' } : {}),
200
228
  mode: 'local',
201
229
  };
202
230
  }
@@ -232,7 +260,7 @@ const decimalsCache = new Map();
232
260
  * Decimals for `token`: the trusted registry, then the contract's own
233
261
  * decimals() (cached on success). See DecimalsProbe for the other outcomes.
234
262
  */
235
- async function probeTokenDecimals(chainId, token) {
263
+ export async function probeTokenDecimals(chainId, token) {
236
264
  const known = lookupTrustedDecimals(chainId, token);
237
265
  if (typeof known === 'number')
238
266
  return known;
@@ -261,8 +289,11 @@ async function probeTokenDecimals(chainId, token) {
261
289
  return 'unusable'; // answered, but not one uint8 word
262
290
  const d = Number(BigInt(result));
263
291
  // Reject nonsense rather than trusting it; an out-of-range value would
264
- // widen the ceiling exactly like the old assumption did.
265
- if (Number.isInteger(d) && d >= 0 && d <= 36) {
292
+ // widen the ceiling exactly like the old assumption did. Above 18 the RPC is
293
+ // the only witness and a lie there scales the cap by 10^18 (a node saying 36
294
+ // for mainnet WETH, round 4): such tokens must be pinned with
295
+ // AGENTWALLET_TOKEN_DECIMALS.
296
+ if (Number.isInteger(d) && d >= 0 && d <= 18) {
266
297
  decimalsCache.set(key, d);
267
298
  return d;
268
299
  }
@@ -303,12 +334,19 @@ const PERMIT2_SELECTORS = new Set(['87517c45', '2b67b570', '2a2d80d1', '36c78516
303
334
  and so read as "not a token" to the cap, which let setApprovalForAll to an
304
335
  attacker through uncapped (2026-10-04 round 2, verified on a live
305
336
  collection). Nothing prices an NFT, so these are refused outright. */
306
- const NFT_SELECTORS = new Set(['a22cb465', '42842e0e', 'b88d4fde', 'f242432a', '2eb2c2d6']);
337
+ const NFT_SELECTORS = new Set(['a22cb465', '42842e0e', 'b88d4fde', 'f242432a', '2eb2c2d6', '959b8c3f']); // plus ERC-777 authorizeOperator: an unbounded delegation
307
338
  const CAP_LAYOUTS = {
308
339
  a9059cbb: { amountIndex: 1, what: 'token transfer' }, // transfer(address,uint256)
309
340
  '23b872dd': { amountIndex: 2, what: 'token transfer' }, // transferFrom(address,address,uint256)
310
341
  '095ea7b3': { amountIndex: 1, what: 'approval' }, // approve(address,uint256)
311
342
  '39509351': { amountIndex: 1, what: 'allowance increase' }, // increaseAllowance(address,uint256)
343
+ // ERC-677 and ERC-777 value movers: priced like transfer, so a contract that
344
+ // declines decimals() is evaluated at 0 decimals instead of waved through
345
+ // (transferAndCall(attacker, 1e24) on a not-a-token target, round 5).
346
+ '4000aea0': { amountIndex: 1, what: 'token transfer (transferAndCall)' }, // transferAndCall(address,uint256,bytes)
347
+ cae9ca51: { amountIndex: 1, what: 'approval (approveAndCall)' }, // approveAndCall(address,uint256,bytes)
348
+ '9bd9bbc6': { amountIndex: 1, what: 'token transfer (ERC-777 send)' }, // send(address,uint256,bytes)
349
+ '62ad1b83': { amountIndex: 2, what: 'token transfer (ERC-777 operatorSend)' }, // operatorSend(address,address,uint256,bytes,bytes)
312
350
  '87517c45': { amountIndex: 2, tokenIndex: 0, what: 'Permit2 approval' }, // Permit2.approve(address,address,uint160,uint48)
313
351
  // WETH9 calls, priced only when `to` is the chain's own wrapped-native
314
352
  // contract (wrappedNativeOnly). deposit() carries its amount as msg.value,
@@ -435,7 +473,25 @@ async function assertWithinTokenCap(chainId, to, data, valueWei = 0n) {
435
473
  }
436
474
  }
437
475
  }
438
- export async function localSend(chainId, to, valueWei, data) {
476
+ function parseUint(v, what) {
477
+ if (!/^\d{1,40}$/.test(v))
478
+ throw new Error(`${what} must be a non-negative integer string, got "${v}".`);
479
+ return BigInt(v);
480
+ }
481
+ /**
482
+ * The most a transaction may burn in fees: gas x maxFeePerGas, in native units.
483
+ * Without it a node that answered a 47,000 gwei tip or a 30M gas estimate took
484
+ * the whole balance as fees on a cap-compliant 0.001 ETH transfer (round 4).
485
+ * Default 0.01; AGENTWALLET_MAX_FEE_NATIVE raises or lowers it.
486
+ */
487
+ export function feeCapWei() {
488
+ const raw = (process.env.AGENTWALLET_MAX_FEE_NATIVE || '0.01').trim();
489
+ if (!/^\d+(\.\d+)?$/.test(raw))
490
+ throw new Error(`AGENTWALLET_MAX_FEE_NATIVE must be a decimal number, got "${raw}".`);
491
+ const [whole, frac = ''] = raw.split('.');
492
+ return BigInt(whole + frac.padEnd(18, '0').slice(0, 18));
493
+ }
494
+ export async function localSend(chainId, to, valueWei, data, fees) {
439
495
  assertWithinNativeCap(valueWei);
440
496
  if (data && data !== '0x') {
441
497
  if (!/^0x([0-9a-fA-F]{2})*$/.test(data))
@@ -443,20 +499,73 @@ export async function localSend(chainId, to, valueWei, data) {
443
499
  await assertWithinTokenCap(chainId, to, data, valueWei);
444
500
  }
445
501
  await assertRpcServesChain(chainId);
446
- const { wallet } = clients(chainId);
447
- const hash = await wallet.sendTransaction({
448
- to,
449
- value: valueWei,
450
- ...(data ? { data } : {}),
502
+ const { pub, wallet } = clients(chainId);
503
+ const account = getLocalAccount();
504
+ const base = { account, to, value: valueWei, ...(data ? { data } : {}) };
505
+ // Gas: the caller's figure when given (it used to be read and dropped,
506
+ // round 4), otherwise the node's estimate.
507
+ const gas = fees?.gas ? parseUint(fees.gas, 'gas_limit') : await pub.estimateGas(base);
508
+ // Fees: the caller's figures when given, otherwise the node's; either way the
509
+ // worst case gas x maxFeePerGas must fit the fee cap before anything is signed.
510
+ let maxFeePerGas;
511
+ let maxPriorityFeePerGas;
512
+ let gasPrice;
513
+ if (fees?.maxFeePerGas) {
514
+ maxFeePerGas = parseUint(fees.maxFeePerGas, 'max_fee');
515
+ maxPriorityFeePerGas = fees.maxPriorityFeePerGas ? parseUint(fees.maxPriorityFeePerGas, 'priority_fee') : maxFeePerGas;
516
+ }
517
+ else {
518
+ try {
519
+ const est = await pub.estimateFeesPerGas();
520
+ maxFeePerGas = est.maxFeePerGas;
521
+ maxPriorityFeePerGas = fees?.maxPriorityFeePerGas ? parseUint(fees.maxPriorityFeePerGas, 'priority_fee') : est.maxPriorityFeePerGas;
522
+ }
523
+ catch {
524
+ gasPrice = (await pub.estimateFeesPerGas({ type: 'legacy' })).gasPrice; // a chain without EIP-1559
525
+ }
526
+ }
527
+ if (maxFeePerGas !== undefined && maxPriorityFeePerGas !== undefined && maxPriorityFeePerGas > maxFeePerGas)
528
+ maxPriorityFeePerGas = maxFeePerGas;
529
+ const perGas = maxFeePerGas ?? gasPrice ?? 0n;
530
+ const worst = gas * perGas;
531
+ const feeCap = feeCapWei();
532
+ if (worst > feeCap) {
533
+ throw new Error(`Blocked by local guard: the transaction could burn up to ${formatUnits(worst, 18)} native in fees (gas ${gas} x ${formatUnits(perGas, 9)} gwei), ` +
534
+ `above AGENTWALLET_MAX_FEE_NATIVE (${formatUnits(feeCap, 18)}). A node answering an absurd fee or gas estimate is the usual cause; ` +
535
+ `pass gas_limit / max_fee / priority_fee explicitly, or the operator can raise the cap.`);
536
+ }
537
+ // Sign here, hash here, then hand the bytes to the node: the hash reported is
538
+ // the one computed from the signed transaction, never the node's answer.
539
+ const address = getLocalAddress();
540
+ const nonce = await account.nonceManager.consume({ address, chainId, client: pub });
541
+ const request = await wallet.prepareTransactionRequest({
542
+ ...base, gas, nonce, chainId,
543
+ ...(gasPrice !== undefined ? { type: 'legacy', gasPrice } : { type: 'eip1559', maxFeePerGas: maxFeePerGas, maxPriorityFeePerGas: maxPriorityFeePerGas }),
451
544
  });
545
+ const signed = await wallet.signTransaction(request);
546
+ const localHash = keccak256(signed);
547
+ let reported;
548
+ try {
549
+ reported = await pub.sendRawTransaction({ serializedTransaction: signed });
550
+ }
551
+ catch (e) {
552
+ account.nonceManager.reset({ address, chainId });
553
+ throw new Error(`Broadcast of transaction ${localHash} (nonce ${nonce}) failed: ${e.message.split('\n')[0]}. The transaction is signed and MAY be live if the node forwarded it before answering; check the hash on an explorer before retrying.`);
554
+ }
555
+ if (reported.toLowerCase() !== localHash.toLowerCase()) {
556
+ throw new Error(`Transaction ${localHash} (nonce ${nonce}) was BROADCAST, but the node answered with a different hash (${String(reported).slice(0, 80)}), so it cannot be trusted about its fate. Verify ${localHash} on an explorer before retrying.`);
557
+ }
452
558
  return {
453
- tx_hash: hash,
454
- from: getLocalAddress(),
559
+ tx_hash: localHash,
560
+ from: address,
455
561
  to,
456
562
  value: valueWei.toString(),
457
563
  chain_id: chainId,
458
564
  mode: 'local',
459
565
  signed_locally: true,
566
+ gas: gas.toString(),
567
+ max_fee_per_gas: (maxFeePerGas ?? gasPrice ?? 0n).toString(),
568
+ worst_case_fee: formatUnits(worst, 18),
460
569
  };
461
570
  }
462
571
  /** ERC-20 transfer, encoded locally. */
@@ -532,12 +641,14 @@ export async function localSignAuthorization(chainId, asset, domain, auth) {
532
641
  }
533
642
  /** Read-only eth_call against the local RPC, same shape as the hosted /eth-call route. */
534
643
  export async function localEthCall(chainId, to, data) {
644
+ await assertRpcServesChain(chainId);
535
645
  const client = createPublicClient({ transport: http(resolveRpcUrl(chainId)) });
536
646
  const r = await client.call({ to, data });
537
647
  return { result: r.data ?? '0x' };
538
648
  }
539
649
  /** Read-only eth_getCode against the local RPC, same shape as the hosted /eth-get-code route. */
540
650
  export async function localGetCode(chainId, address) {
651
+ await assertRpcServesChain(chainId);
541
652
  const client = createPublicClient({ transport: http(resolveRpcUrl(chainId)) });
542
653
  const code = await client.getCode({ address });
543
654
  return { code: code ?? '0x' };
@@ -363,7 +363,8 @@ export function buildFetchedResponse(status, statusText, serverHeaders, body, fi
363
363
  headers.push([k, v]);
364
364
  }
365
365
  headers.push([FINAL_URL_HEADER, finalUrl]);
366
- const out = new Response(body, { status, statusText, headers });
366
+ // A reason phrase with bytes above 0xFF (undici decodes it as UTF-8 with U+FFFD) is not a ByteString and threw (round 5).
367
+ const out = new Response(body, { status, statusText: statusText.replace(/[^\x20-\x7e]/g, ''), headers });
367
368
  finalUrls.set(out, finalUrl);
368
369
  return out;
369
370
  }
@@ -1,19 +1,3 @@
1
- /**
2
- * Pure helpers for x402 auto-pay amount/token derivation.
3
- *
4
- * Isolated from the server bootstrap in index.ts so the funds-sensitive amount
5
- * math can be unit-tested without starting the MCP server.
6
- *
7
- * SECURITY INVARIANT: token decimals must NEVER come from the 402 response.
8
- * A resource server controls `asset`, `payTo`, `maxAmountRequired` AND
9
- * `requiredDecimals`. If the cap is scaled using server-declared decimals, the
10
- * server can inflate the cap arbitrarily: declaring 18 decimals for 6-decimal
11
- * USDC turns a "1 USDC" cap into 10^18 base units, so a request for
12
- * 1,000,000,000,000 units (1,000,000 USDC) passes a 1 USDC cap. Reported by
13
- * ARC Security Research, 2026-08-02, against 1.10.1. Decimals are now resolved
14
- * from a trusted source and the declared value is only ever used to detect a
15
- * mismatch and refuse.
16
- */
17
1
  export interface X402AcceptLike {
18
2
  maxAmountRequired: string;
19
3
  requiredDecimals?: number;
@@ -14,6 +14,7 @@
14
14
  * from a trusted source and the declared value is only ever used to detect a
15
15
  * mismatch and refuse.
16
16
  */
17
+ import { wrappedNativeAddress } from './wrapped-native.js';
17
18
  /**
18
19
  * Decimals for assets that x402 endpoints actually settle in, keyed by
19
20
  * chain id then lowercased contract address (or SPL mint on Solana).
@@ -142,7 +143,7 @@ export function autopayAssetAllowed(chainId, asset) {
142
143
  const label = assetLabel(chainId, asset);
143
144
  return {
144
145
  allowed: false, via: 'none',
145
- reason: `x402 blocked: this endpoint wants payment in ${label}${asset ? ` (${asset})` : ''} on chain ${chainId}, which is not a stablecoin ` +
146
+ reason: `x402 blocked: this endpoint wants payment in ${String(label).slice(0, 40)}${asset ? ` (${asset.slice(0, 80)})` : ''} on chain ${chainId}, which is not a stablecoin ` +
146
147
  `AGENTWALLET_MAX_AUTOPAY can price and is not on the operator's AGENTWALLET_AUTOPAY_ASSETS list for chain ${chainId}. ` +
147
148
  `Only the operator can change that list; do not edit the environment or config to add it.`,
148
149
  };
@@ -283,6 +284,11 @@ export function lookupTrustedDecimals(chainId, token) {
283
284
  const pinned = pinnedDecimals(chainId, token);
284
285
  if (pinned !== null)
285
286
  return pinned;
287
+ // Every chain's wrapped native coin is 18 decimals (WETH9 clones; the local
288
+ // guard asserts it). The registry carried WETH for Base only, so mainnet WETH
289
+ // was priced by the RPC, which could say 36 (2026-10-04 round 4).
290
+ if (wrappedNativeAddress(chainId)?.toLowerCase() === token.toLowerCase())
291
+ return 18;
286
292
  const spl = TRUSTED_SPL_DECIMALS[token];
287
293
  if (typeof spl === 'number')
288
294
  return spl;
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "agentwallet-mcp",
3
3
  "mcpName": "io.github.hifriendbot/agentwallet",
4
4
  "repository": { "type": "git", "url": "https://github.com/hifriendbot/agentwallet-mcp" },
5
- "version": "1.13.10",
5
+ "version": "1.13.12",
6
6
  "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.",
7
7
  "type": "module",
8
8
  "main": "build/index.js",
@@ -17,7 +17,7 @@
17
17
  "scripts": {
18
18
  "build": "tsc",
19
19
  "start": "node build/index.js",
20
- "test": "node test/x402-payment.test.mjs && node test/eip3009.test.mjs && node test/permit2-risk.test.mjs && node test/token-cap.test.mjs && node test/spl-cap.test.mjs && node test/unknown-decimals-cap.test.mjs && node test/decimals-range-cap.test.mjs && node test/local-mode.test.mjs && node test/ssrf-guard.test.mjs && node test/x402-policy.test.mjs && node test/local-guard-shape.test.mjs && node test/allowance-total-cap.test.mjs && node test/autopay-scheme.test.mjs && node test/spl-recipient-guard.test.mjs && node test/approval-binding.test.mjs && node test/ata-rent-size.test.mjs && node test/final-url-guard.test.mjs && node test/audit-1135.test.mjs && node test/local-no-autopay.test.mjs && node test/audit-1136.test.mjs && node test/delete-confirm.test.mjs && node test/audit-1138.test.mjs && node test/audit-1139.test.mjs && node test/audit-11310.test.mjs",
20
+ "test": "node test/x402-payment.test.mjs && node test/eip3009.test.mjs && node test/permit2-risk.test.mjs && node test/token-cap.test.mjs && node test/spl-cap.test.mjs && node test/unknown-decimals-cap.test.mjs && node test/decimals-range-cap.test.mjs && node test/local-mode.test.mjs && node test/ssrf-guard.test.mjs && node test/x402-policy.test.mjs && node test/local-guard-shape.test.mjs && node test/allowance-total-cap.test.mjs && node test/autopay-scheme.test.mjs && node test/spl-recipient-guard.test.mjs && node test/approval-binding.test.mjs && node test/ata-rent-size.test.mjs && node test/final-url-guard.test.mjs && node test/audit-1135.test.mjs && node test/local-no-autopay.test.mjs && node test/audit-1136.test.mjs && node test/delete-confirm.test.mjs && node test/audit-1138.test.mjs && node test/audit-1139.test.mjs && node test/audit-11310.test.mjs && node test/audit-11311.test.mjs && node test/audit-11312.test.mjs",
21
21
  "prepublishOnly": "npm run build"
22
22
  },
23
23
  "keywords": [