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 +2 -1
- package/build/index.js +102 -29
- package/build/local-solana.js +3 -0
- package/build/local-wallet.d.ts +26 -4
- package/build/local-wallet.js +130 -19
- package/build/ssrf-guard.js +2 -1
- package/build/x402-payment.d.ts +0 -16
- package/build/x402-payment.js +7 -1
- package/package.json +2 -2
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
|
-
|
|
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
|
-
|
|
382
|
-
const
|
|
383
|
-
|
|
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
|
-
|
|
393
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
1351
|
-
|
|
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 { /*
|
|
1361
|
-
if (
|
|
1362
|
-
|
|
1363
|
-
`
|
|
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
|
-
|
|
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
|
-
|
|
1820
|
-
const
|
|
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 }) =>
|
|
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 {
|
package/build/local-solana.js
CHANGED
|
@@ -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;
|
package/build/local-wallet.d.ts
CHANGED
|
@@ -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
|
-
|
|
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. */
|
package/build/local-wallet.js
CHANGED
|
@@ -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,
|
|
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(() =>
|
|
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:
|
|
224
|
+
decimals: safe,
|
|
198
225
|
balance_raw: raw.toString(),
|
|
199
|
-
balance: formatUnits(raw,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
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:
|
|
454
|
-
from:
|
|
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' };
|
package/build/ssrf-guard.js
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|
package/build/x402-payment.d.ts
CHANGED
|
@@ -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;
|
package/build/x402-payment.js
CHANGED
|
@@ -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.
|
|
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": [
|