agentwallet-mcp 1.12.2 → 1.12.4
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 +5 -2
- package/build/index.js +48 -3
- package/build/local-wallet.d.ts +4 -0
- package/build/local-wallet.js +62 -12
- package/build/ssrf-guard.d.ts +9 -1
- package/build/ssrf-guard.js +60 -19
- package/build/x402-eip3009.d.ts +19 -0
- package/build/x402-eip3009.js +37 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -43,7 +43,7 @@ Use `AGENTWALLET_KEYFILE=/path/to/key` instead if you would rather keep the key
|
|
|
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
|
|
|
46
|
-
`AGENTWALLET_MAX_TX_TOKEN` is the equivalent ceiling for ERC-20 movement, in human units of the token. **Set this one too if you hold stablecoins.** The native cap cannot see a token transfer: an ERC-20 send carries `value = 0` with the amount in the calldata, so `AGENTWALLET_MAX_TX_NATIVE` alone leaves a USDC balance uncapped. `AGENTWALLET_MAX_TX_TOKEN` covers `transfer`, `transferFrom` and `approve`, the
|
|
46
|
+
`AGENTWALLET_MAX_TX_TOKEN` is the equivalent ceiling for ERC-20 movement, in human units of the token. **Set this one too if you hold stablecoins.** The native cap cannot see a token transfer: an ERC-20 send carries `value = 0` with the amount in the calldata, so `AGENTWALLET_MAX_TX_NATIVE` alone leaves a USDC balance uncapped. `AGENTWALLET_MAX_TX_TOKEN` covers `transfer`, `transferFrom`, `approve`, `increaseAllowance` and Permit2 `approve`, the approvals because an unbounded allowance is a drain waiting to happen. Any other calldata sent to a token contract or to Permit2 is refused while the cap is set, because the guard cannot price it; set `AGENTWALLET_ALLOW_UNKNOWN_TOKEN_CALLS=1` to allow such calls deliberately. Calls to other contracts (routers, bridges) pass, since they can only pull what an approval already allowed. Decimals are resolved locally from the trusted registry, then the token's own `decimals()`; a token that resolves neither way is evaluated at 0 decimals, the only value that cannot fail open.
|
|
47
47
|
|
|
48
48
|
### Solana local signing
|
|
49
49
|
|
|
@@ -259,6 +259,8 @@ pay_x402(
|
|
|
259
259
|
|
|
260
260
|
**Token domains.** The EIP-712 domain comes from the endpoint's `extra.name` / `extra.version`, then a short registry of USDC deployments, then the token contract's own `name()` / `version()`. If none of those answer, the payment is refused rather than signed with a guessed domain.
|
|
261
261
|
|
|
262
|
+
**Delegated payers.** If the paying address is an EIP-7702 delegated account, `pay_x402` reports it in `payer_delegation` (the delegate and whether it answers ERC-1271). A delegate without ERC-1271 is declined by facilitators that check account code before recovering the signer, and the reason they return reads like a signature fault; the warning names the real cause. Pay from a plain EOA for reliable settlement.
|
|
263
|
+
|
|
262
264
|
## x402 Acceptance
|
|
263
265
|
|
|
264
266
|
AgentWallet also lets you **accept** x402 payments. Create a paywall, point it at any resource, and get a public URL that charges agents automatically:
|
|
@@ -327,7 +329,8 @@ Which guards apply depends on who holds the key.
|
|
|
327
329
|
**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.
|
|
328
330
|
|
|
329
331
|
- `AGENTWALLET_MAX_TX_NATIVE`: ceiling per transaction in native units (ETH, MATIC, and so on)
|
|
330
|
-
- `AGENTWALLET_MAX_TX_TOKEN`: ceiling per ERC-20 transfer, in human units of the token
|
|
332
|
+
- `AGENTWALLET_MAX_TX_TOKEN`: ceiling per ERC-20 transfer or approval, in human units of the token
|
|
333
|
+
- `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)
|
|
331
334
|
- `AGENTWALLET_MAX_TX_SOL`: ceiling per SOL or SPL transfer
|
|
332
335
|
- `AGENTWALLET_MAX_AUTOPAY`: ceiling per x402 auto-payment (default `1`)
|
|
333
336
|
|
package/build/index.js
CHANGED
|
@@ -15,10 +15,10 @@ import { z } from 'zod';
|
|
|
15
15
|
import { assertPublicUrl, safeFetch } from './ssrf-guard.js';
|
|
16
16
|
import { deriveX402Payment, isWithinCap, lookupTrustedDecimals, assertDeclaredDecimals, nativeDecimals, SOLANA_CHAIN_IDS, } from './x402-payment.js';
|
|
17
17
|
import { isLocalMode, getLocalAddress, localWalletRecord, localSend, localNativeBalance, localTokenBalance, localSignMessage, resolveRpcUrl, } from './local-wallet.js';
|
|
18
|
-
import { localSignAuthorization, localEthCall, localSignPermit2Upto } from './local-wallet.js';
|
|
18
|
+
import { localSignAuthorization, localEthCall, localGetCode, localSignPermit2Upto } from './local-wallet.js';
|
|
19
19
|
import { buildUptoAuthorization, uptoPayload, permit2AllowanceCalldata, permit2ApproveCalldata, decodeUint, PERMIT2_ADDRESS, } from './x402-permit2.js';
|
|
20
20
|
import { assessTokenRisk } from './token-risk.js';
|
|
21
|
-
import { isLegacyAgentWalletAccept, requiredAmount, buildAuthorization, buildPaymentPayload, buildPaymentPayloadRaw, paymentHeaderFor, parsePaymentRequired, parseSettlement, pickOption, knownTokenDomain, } from './x402-eip3009.js';
|
|
21
|
+
import { isLegacyAgentWalletAccept, requiredAmount, buildAuthorization, buildPaymentPayload, buildPaymentPayloadRaw, paymentHeaderFor, parsePaymentRequired, parseSettlement, pickOption, knownTokenDomain, parseEip7702Delegation, erc1271ProbeCalldata, classifyErc1271Probe, } from './x402-eip3009.js';
|
|
22
22
|
import { isSolanaLocalMode, getSolanaAddress, solanaWalletRecord, localSolBalance, localSplBalance, localSolTransfer, localSplTransfer, resolveSolanaRpc, } from './local-solana.js';
|
|
23
23
|
/** True when either chain family is running non-custodially. */
|
|
24
24
|
function anyLocalMode() {
|
|
@@ -128,6 +128,12 @@ async function routeLocally(path, method, body) {
|
|
|
128
128
|
return NOT_HANDLED;
|
|
129
129
|
return localEthCall(localChainId(body, qs), String(body?.to || ''), String(body?.data || '0x'));
|
|
130
130
|
}
|
|
131
|
+
// POST /eth-get-code (read-only; the payer's account code, checked for an EIP-7702 delegation before an x402 authorization)
|
|
132
|
+
if (method === 'POST' && rawPath === '/eth-get-code') {
|
|
133
|
+
if (!isLocalMode())
|
|
134
|
+
return NOT_HANDLED;
|
|
135
|
+
return localGetCode(localChainId(body, qs), String(body?.address || ''));
|
|
136
|
+
}
|
|
131
137
|
// GET /wallets/{id}/balance
|
|
132
138
|
if (method === 'GET' && /^\/wallets\/[^/]*\/balance$/.test(rawPath)) {
|
|
133
139
|
const chainId = localChainId(body, qs);
|
|
@@ -461,7 +467,7 @@ function resolveChainId(network) {
|
|
|
461
467
|
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');
|
|
462
468
|
const server = new McpServer({
|
|
463
469
|
name: 'agentwallet',
|
|
464
|
-
version: '1.12.
|
|
470
|
+
version: '1.12.4',
|
|
465
471
|
}, {
|
|
466
472
|
instructions: `AgentWallet gives AI agents their own blockchain wallets. Private keys are encrypted server-side and never exposed — agents sign and broadcast transactions without ever touching raw keys.
|
|
467
473
|
|
|
@@ -994,6 +1000,39 @@ async function payerAddress(walletId) {
|
|
|
994
1000
|
throw new Error(`x402: could not resolve the EVM address of wallet ${walletId} (got "${a}").`);
|
|
995
1001
|
return a;
|
|
996
1002
|
}
|
|
1003
|
+
/**
|
|
1004
|
+
* Best-effort look at the payer's account code. An EIP-7702 delegated EOA whose delegate
|
|
1005
|
+
* does not implement ERC-1271 is declined by facilitators that check code before recovering
|
|
1006
|
+
* the signer, with an error that reads like a signature fault (issue #9). Nothing is refused
|
|
1007
|
+
* here: the authorization costs nothing to sign and other facilitators accept it. The result
|
|
1008
|
+
* carries the warning so the agent can explain a signature-shaped rejection. A lookup failure
|
|
1009
|
+
* (a hosted server without the route, RPC trouble) reports nothing rather than blocking the payment.
|
|
1010
|
+
*/
|
|
1011
|
+
async function payerDelegation(chainId, payer) {
|
|
1012
|
+
try {
|
|
1013
|
+
const r = (await api('/eth-get-code', 'POST', { chain_id: chainId, address: payer }, { 'X-AGW-SKIP-X402': 'true' }));
|
|
1014
|
+
const delegate = parseEip7702Delegation(r?.code);
|
|
1015
|
+
if (!delegate)
|
|
1016
|
+
return null;
|
|
1017
|
+
let erc1271 = 'unknown';
|
|
1018
|
+
try {
|
|
1019
|
+
const probe = (await api('/eth-call', 'POST', { chain_id: chainId, to: payer, data: erc1271ProbeCalldata() }, { 'X-AGW-SKIP-X402': 'true' }));
|
|
1020
|
+
erc1271 = classifyErc1271Probe(probe?.result);
|
|
1021
|
+
}
|
|
1022
|
+
catch {
|
|
1023
|
+
erc1271 = 'unknown';
|
|
1024
|
+
}
|
|
1025
|
+
const warning = erc1271 === 'no'
|
|
1026
|
+
? `Payer ${payer} is an EIP-7702 delegated account (delegate ${delegate}) and the delegate does not answer ERC-1271 isValidSignature. ` +
|
|
1027
|
+
`Facilitators that check account code before recovering the signer decline this authorization with a signature error. ` +
|
|
1028
|
+
`Pay from a plain EOA, or clear the delegation, for reliable x402 settlement.`
|
|
1029
|
+
: `Payer ${payer} is an EIP-7702 delegated account (delegate ${delegate}); a facilitator may verify the authorization through ERC-1271 on the delegate.`;
|
|
1030
|
+
return { delegate, erc1271, warning };
|
|
1031
|
+
}
|
|
1032
|
+
catch {
|
|
1033
|
+
return null;
|
|
1034
|
+
}
|
|
1035
|
+
}
|
|
997
1036
|
async function ethCallHex(chainId, to, data) {
|
|
998
1037
|
const r = (await api('/eth-call', 'POST', { chain_id: chainId, to, data }, { 'X-AGW-SKIP-X402': 'true' }));
|
|
999
1038
|
return String(r?.result || '');
|
|
@@ -1225,8 +1264,10 @@ server.tool('pay_x402', 'Handle an x402 payment flow. Fetches a URL, and if the
|
|
|
1225
1264
|
let uptoAuth = null;
|
|
1226
1265
|
let payer = null;
|
|
1227
1266
|
let permit2ApprovalTx = null;
|
|
1267
|
+
let payerDelegationInfo = null;
|
|
1228
1268
|
if (isUpto) {
|
|
1229
1269
|
payer = await payerAddress(wallet_id);
|
|
1270
|
+
payerDelegationInfo = await payerDelegation(chainId, payer);
|
|
1230
1271
|
const allowance = decodeUint(await ethCallHex(chainId, tokenAddress, permit2AllowanceCalldata(payer)));
|
|
1231
1272
|
if (allowance < BigInt(rawAmount)) {
|
|
1232
1273
|
if (process.env.AGENTWALLET_PERMIT2_AUTO_APPROVE === '1') {
|
|
@@ -1253,6 +1294,7 @@ server.tool('pay_x402', 'Handle an x402 payment flow. Fetches a URL, and if the
|
|
|
1253
1294
|
}
|
|
1254
1295
|
else if (standardExact) {
|
|
1255
1296
|
payer = await payerAddress(wallet_id);
|
|
1297
|
+
payerDelegationInfo = await payerDelegation(chainId, payer);
|
|
1256
1298
|
const domain = await resolveTokenDomain(chainId, tokenAddress, option.extra);
|
|
1257
1299
|
authorization = buildAuthorization(payer, option);
|
|
1258
1300
|
const signature = await signAuthorization(wallet_id, chainId, tokenAddress, domain, authorization, approvalUsed);
|
|
@@ -1306,6 +1348,8 @@ server.tool('pay_x402', 'Handle an x402 payment flow. Fetches a URL, and if the
|
|
|
1306
1348
|
if (retryRes.status === 402) {
|
|
1307
1349
|
const again = parsePaymentRequired(n => retryRes.headers.get(n), retryParsed);
|
|
1308
1350
|
retryError = String(again?.error || retryParsed?.error || 'Payment was not accepted (no reason given).');
|
|
1351
|
+
if (payerDelegationInfo && /signature/i.test(retryError))
|
|
1352
|
+
retryError += ` Likely cause: ${payerDelegationInfo.warning}`;
|
|
1309
1353
|
}
|
|
1310
1354
|
return jsonResponse({
|
|
1311
1355
|
status: retryRes.status,
|
|
@@ -1326,6 +1370,7 @@ server.tool('pay_x402', 'Handle an x402 payment flow. Fetches a URL, and if the
|
|
|
1326
1370
|
chain_id: chainId,
|
|
1327
1371
|
pay_to: option.payTo,
|
|
1328
1372
|
payer,
|
|
1373
|
+
payer_delegation: payerDelegationInfo,
|
|
1329
1374
|
tx_hash: txHash ?? settlement?.transaction ?? null,
|
|
1330
1375
|
settlement,
|
|
1331
1376
|
authorization: authorization ? { nonce: authorization.nonce, valid_before: authorization.validBefore }
|
package/build/local-wallet.d.ts
CHANGED
|
@@ -108,6 +108,10 @@ export declare function localSignAuthorization(chainId: number, asset: Address,
|
|
|
108
108
|
export declare function localEthCall(chainId: number, to: Address, data: Hex): Promise<{
|
|
109
109
|
result: string;
|
|
110
110
|
}>;
|
|
111
|
+
/** Read-only eth_getCode against the local RPC, same shape as the hosted /eth-get-code route. */
|
|
112
|
+
export declare function localGetCode(chainId: number, address: Address): Promise<{
|
|
113
|
+
code: string;
|
|
114
|
+
}>;
|
|
111
115
|
/** x402 "upto": sign a Permit2 max-authorization in-process. The maximum goes through the token cap like a transfer would. */
|
|
112
116
|
export declare function localSignPermit2Upto(chainId: number, auth: UptoPermit2Authorization): Promise<{
|
|
113
117
|
signature: `0x${string}`;
|
package/build/local-wallet.js
CHANGED
|
@@ -193,7 +193,12 @@ export async function localTokenBalance(chainId, token) {
|
|
|
193
193
|
* 2-decimal token move 10,000x the configured cap.
|
|
194
194
|
*/
|
|
195
195
|
const decimalsCache = new Map();
|
|
196
|
-
|
|
196
|
+
/**
|
|
197
|
+
* Decimals for `token`: the trusted registry, then the contract's own
|
|
198
|
+
* decimals() (cached on success). Null when the address does not answer like
|
|
199
|
+
* an ERC-20 at all, which is how the guard tells a token from a router.
|
|
200
|
+
*/
|
|
201
|
+
async function probeTokenDecimals(chainId, token) {
|
|
197
202
|
const known = lookupTrustedDecimals(chainId, token);
|
|
198
203
|
if (typeof known === 'number')
|
|
199
204
|
return known;
|
|
@@ -217,11 +222,33 @@ async function resolveTokenDecimals(chainId, token) {
|
|
|
217
222
|
}
|
|
218
223
|
}
|
|
219
224
|
catch {
|
|
220
|
-
// Unreachable RPC, non-standard token,
|
|
225
|
+
// Unreachable RPC, non-standard token, not a token at all.
|
|
221
226
|
}
|
|
222
|
-
return
|
|
227
|
+
return null;
|
|
223
228
|
}
|
|
224
|
-
async function
|
|
229
|
+
async function resolveTokenDecimals(chainId, token) {
|
|
230
|
+
return (await probeTokenDecimals(chainId, token)) ?? 0;
|
|
231
|
+
}
|
|
232
|
+
/** Canonical Permit2, the same address on every EVM chain it is deployed to. */
|
|
233
|
+
const PERMIT2 = '0x000000000022d473030f116ddee9f6b43ac78ba3';
|
|
234
|
+
const CAP_LAYOUTS = {
|
|
235
|
+
a9059cbb: { amountIndex: 1, what: 'token transfer' }, // transfer(address,uint256)
|
|
236
|
+
'23b872dd': { amountIndex: 2, what: 'token transfer' }, // transferFrom(address,address,uint256)
|
|
237
|
+
'095ea7b3': { amountIndex: 1, what: 'approval' }, // approve(address,uint256)
|
|
238
|
+
'39509351': { amountIndex: 1, what: 'allowance increase' }, // increaseAllowance(address,uint256)
|
|
239
|
+
'87517c45': { amountIndex: 2, tokenIndex: 0, what: 'Permit2 approval' }, // Permit2.approve(address,address,uint160,uint48)
|
|
240
|
+
};
|
|
241
|
+
/**
|
|
242
|
+
* The selector table used to be an allowlist that returned early for anything
|
|
243
|
+
* it did not recognise, so `increaseAllowance`, a direct Permit2 `approve`, or
|
|
244
|
+
* any other function on a token contract went through uncapped while the guard
|
|
245
|
+
* reported nothing. Reported privately 2026-09-25. Now: priced calls are
|
|
246
|
+
* checked, and unpriced calldata aimed at a token contract or at Permit2 is
|
|
247
|
+
* refused, because any state change there can move funds. Calls to other
|
|
248
|
+
* contracts (routers, bridges) still pass: they can only pull what an approval
|
|
249
|
+
* already allowed, and approvals are what this guard bounds.
|
|
250
|
+
*/
|
|
251
|
+
async function assertWithinTokenCap(chainId, to, data) {
|
|
225
252
|
const cap = (process.env.AGENTWALLET_MAX_TX_TOKEN || '').trim();
|
|
226
253
|
if (!cap)
|
|
227
254
|
return;
|
|
@@ -230,21 +257,38 @@ async function assertWithinTokenCap(chainId, token, data) {
|
|
|
230
257
|
}
|
|
231
258
|
const hex = data.slice(2);
|
|
232
259
|
const selector = hex.slice(0, 8).toLowerCase();
|
|
233
|
-
|
|
234
|
-
const
|
|
235
|
-
const
|
|
236
|
-
if (
|
|
237
|
-
|
|
238
|
-
|
|
260
|
+
const isPermit2 = to.toLowerCase() === PERMIT2;
|
|
261
|
+
const layout = CAP_LAYOUTS[selector];
|
|
262
|
+
const priced = layout && (layout.tokenIndex === undefined || isPermit2);
|
|
263
|
+
if (!priced) {
|
|
264
|
+
if (process.env.AGENTWALLET_ALLOW_UNKNOWN_TOKEN_CALLS === '1')
|
|
265
|
+
return;
|
|
266
|
+
const looksLikeToken = isPermit2 || (await probeTokenDecimals(chainId, to)) !== null;
|
|
267
|
+
if (looksLikeToken) {
|
|
268
|
+
throw new Error(`Blocked by local guard: calldata selector 0x${selector} sent to ` +
|
|
269
|
+
`${isPermit2 ? 'Permit2' : 'a token contract'} is not one AGENTWALLET_MAX_TX_TOKEN can price, ` +
|
|
270
|
+
`so it is refused rather than let through uncapped. Use transfer, transferFrom, approve, ` +
|
|
271
|
+
`increaseAllowance or Permit2 approve, or set AGENTWALLET_ALLOW_UNKNOWN_TOKEN_CALLS=1 to allow it deliberately.`);
|
|
272
|
+
}
|
|
273
|
+
return; // an ordinary contract call; it can only pull what an approval already allowed
|
|
274
|
+
}
|
|
275
|
+
const wordAt = (i) => hex.slice(8 + i * 64, 8 + (i + 1) * 64);
|
|
276
|
+
const word = wordAt(layout.amountIndex);
|
|
239
277
|
if (word.length !== 64)
|
|
240
278
|
return; // malformed; leave it to the node to reject
|
|
241
279
|
const amount = BigInt('0x' + word);
|
|
280
|
+
let token = to;
|
|
281
|
+
if (layout.tokenIndex !== undefined) {
|
|
282
|
+
const tw = wordAt(layout.tokenIndex);
|
|
283
|
+
if (tw.length !== 64)
|
|
284
|
+
return;
|
|
285
|
+
token = ('0x' + tw.slice(24));
|
|
286
|
+
}
|
|
242
287
|
const decimals = await resolveTokenDecimals(chainId, token);
|
|
243
288
|
const [whole, frac = ''] = cap.split('.');
|
|
244
289
|
const capRaw = BigInt(whole + frac.padEnd(decimals, '0').slice(0, decimals));
|
|
245
290
|
if (amount > capRaw) {
|
|
246
|
-
|
|
247
|
-
throw new Error(`Blocked by local guard: ${what} of ${amount} base units exceeds ` +
|
|
291
|
+
throw new Error(`Blocked by local guard: ${layout.what} of ${amount} base units exceeds ` +
|
|
248
292
|
`AGENTWALLET_MAX_TX_TOKEN (${cap}, evaluated at ${decimals} decimals). ` +
|
|
249
293
|
`Raise the cap deliberately if this is intended.`);
|
|
250
294
|
}
|
|
@@ -336,6 +380,12 @@ export async function localEthCall(chainId, to, data) {
|
|
|
336
380
|
const r = await client.call({ to, data });
|
|
337
381
|
return { result: r.data ?? '0x' };
|
|
338
382
|
}
|
|
383
|
+
/** Read-only eth_getCode against the local RPC, same shape as the hosted /eth-get-code route. */
|
|
384
|
+
export async function localGetCode(chainId, address) {
|
|
385
|
+
const client = createPublicClient({ transport: http(resolveRpcUrl(chainId)) });
|
|
386
|
+
const code = await client.getCode({ address });
|
|
387
|
+
return { code: code ?? '0x' };
|
|
388
|
+
}
|
|
339
389
|
/** x402 "upto": sign a Permit2 max-authorization in-process. The maximum goes through the token cap like a transfer would. */
|
|
340
390
|
export async function localSignPermit2Upto(chainId, auth) {
|
|
341
391
|
const token = auth.permitted.token;
|
package/build/ssrf-guard.d.ts
CHANGED
|
@@ -30,9 +30,17 @@
|
|
|
30
30
|
* by an anonymous researcher, 2026-08-02 (the DNS rebinding race), and by
|
|
31
31
|
* Arthur Kijkrittaya, 2026-09-08 (credentials surviving cross-origin redirects).
|
|
32
32
|
*/
|
|
33
|
+
/** Expand an IPv6 literal (any spelling Node's isIP accepts) into eight 16-bit groups. */
|
|
34
|
+
export declare function expandIPv6(hostRaw: string): number[] | null;
|
|
33
35
|
/**
|
|
34
36
|
* Reduce a host to the IP address it actually denotes.
|
|
35
|
-
* Strips IPv6 brackets and unwraps
|
|
37
|
+
* Strips IPv6 brackets and unwraps every IPv6 form that embeds an IPv4
|
|
38
|
+
* address: IPv4-mapped (::ffff:a.b.c.d), IPv4-translated (::ffff:0:a.b.c.d)
|
|
39
|
+
* and the deprecated IPv4-compatible form (::a.b.c.d, also spelled
|
|
40
|
+
* ::7f00:1). The match is on the expanded groups, not on one spelling, so
|
|
41
|
+
* 0:0:0:0:0:0:7f00:1 and ::7F00:1 canonicalize the same way. The compatible
|
|
42
|
+
* form was reported as unclassified on 2026-09-25 (not reachable in practice,
|
|
43
|
+
* modern stacks refuse to route ::/96, but a guard should not depend on that).
|
|
36
44
|
*/
|
|
37
45
|
export declare function canonicalizeHost(hostRaw: string): {
|
|
38
46
|
ip: string | null;
|
package/build/ssrf-guard.js
CHANGED
|
@@ -79,9 +79,45 @@ const V4_BLOCKED = [
|
|
|
79
79
|
'224.0.0.0/4', // multicast
|
|
80
80
|
'240.0.0.0/4', // reserved, includes 255.255.255.255
|
|
81
81
|
];
|
|
82
|
+
/** Expand an IPv6 literal (any spelling Node's isIP accepts) into eight 16-bit groups. */
|
|
83
|
+
export function expandIPv6(hostRaw) {
|
|
84
|
+
let h = hostRaw.toLowerCase();
|
|
85
|
+
// A trailing dotted IPv4 (::ffff:1.2.3.4, ::1.2.3.4, 64:ff9b::1.2.3.4) is the last two groups.
|
|
86
|
+
const dotted = h.match(/^(.*:)(\d+\.\d+\.\d+\.\d+)$/);
|
|
87
|
+
if (dotted) {
|
|
88
|
+
const v4 = ipv4ToInt(dotted[2]);
|
|
89
|
+
if (v4 === null)
|
|
90
|
+
return null;
|
|
91
|
+
h = dotted[1] + (v4 >>> 16).toString(16) + ':' + (v4 & 0xffff).toString(16);
|
|
92
|
+
}
|
|
93
|
+
const halves = h.split('::');
|
|
94
|
+
if (halves.length > 2)
|
|
95
|
+
return null;
|
|
96
|
+
const head = halves[0] ? halves[0].split(':') : [];
|
|
97
|
+
const tail = halves.length === 2 && halves[1] ? halves[1].split(':') : [];
|
|
98
|
+
const missing = 8 - head.length - tail.length;
|
|
99
|
+
if (halves.length === 2 ? missing < 1 : missing !== 0)
|
|
100
|
+
return null;
|
|
101
|
+
const groups = [...head, ...Array(halves.length === 2 ? missing : 0).fill('0'), ...tail];
|
|
102
|
+
const out = [];
|
|
103
|
+
for (const g of groups) {
|
|
104
|
+
if (!/^[0-9a-f]{1,4}$/.test(g))
|
|
105
|
+
return null;
|
|
106
|
+
out.push(parseInt(g, 16));
|
|
107
|
+
}
|
|
108
|
+
return out;
|
|
109
|
+
}
|
|
110
|
+
const embeddedV4 = (hi, lo) => [(hi >> 8) & 255, hi & 255, (lo >> 8) & 255, lo & 255].join('.');
|
|
111
|
+
const zeroThrough = (g, n) => g.slice(0, n).every(x => x === 0);
|
|
82
112
|
/**
|
|
83
113
|
* Reduce a host to the IP address it actually denotes.
|
|
84
|
-
* Strips IPv6 brackets and unwraps
|
|
114
|
+
* Strips IPv6 brackets and unwraps every IPv6 form that embeds an IPv4
|
|
115
|
+
* address: IPv4-mapped (::ffff:a.b.c.d), IPv4-translated (::ffff:0:a.b.c.d)
|
|
116
|
+
* and the deprecated IPv4-compatible form (::a.b.c.d, also spelled
|
|
117
|
+
* ::7f00:1). The match is on the expanded groups, not on one spelling, so
|
|
118
|
+
* 0:0:0:0:0:0:7f00:1 and ::7F00:1 canonicalize the same way. The compatible
|
|
119
|
+
* form was reported as unclassified on 2026-09-25 (not reachable in practice,
|
|
120
|
+
* modern stacks refuse to route ::/96, but a guard should not depend on that).
|
|
85
121
|
*/
|
|
86
122
|
export function canonicalizeHost(hostRaw) {
|
|
87
123
|
let host = hostRaw.trim().toLowerCase();
|
|
@@ -95,16 +131,15 @@ export function canonicalizeHost(hostRaw) {
|
|
|
95
131
|
return { ip: null, family: 0 };
|
|
96
132
|
if (fam === 4)
|
|
97
133
|
return { ip: host, family: 4 };
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
if (
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
return { ip: [
|
|
107
|
-
}
|
|
134
|
+
const g = expandIPv6(host);
|
|
135
|
+
if (!g)
|
|
136
|
+
return { ip: host, family: 6 };
|
|
137
|
+
if (zeroThrough(g, 5) && g[5] === 0xffff)
|
|
138
|
+
return { ip: embeddedV4(g[6], g[7]), family: 4 }; // ::ffff:a.b.c.d
|
|
139
|
+
if (zeroThrough(g, 4) && g[4] === 0xffff && g[5] === 0)
|
|
140
|
+
return { ip: embeddedV4(g[6], g[7]), family: 4 }; // ::ffff:0:a.b.c.d
|
|
141
|
+
if (zeroThrough(g, 6) && !(g[6] === 0 && g[7] <= 1))
|
|
142
|
+
return { ip: embeddedV4(g[6], g[7]), family: 4 }; // ::a.b.c.d, but not :: or ::1
|
|
108
143
|
return { ip: host, family: 6 };
|
|
109
144
|
}
|
|
110
145
|
/** True when the address is loopback, private, link-local, multicast or reserved. */
|
|
@@ -114,17 +149,23 @@ export function isPrivateAddress(ipRaw) {
|
|
|
114
149
|
return false;
|
|
115
150
|
if (family === 4)
|
|
116
151
|
return V4_BLOCKED.some(c => inV4Range(ip, c));
|
|
117
|
-
const
|
|
118
|
-
if (
|
|
119
|
-
return true; //
|
|
120
|
-
if (
|
|
152
|
+
const g = expandIPv6(ip);
|
|
153
|
+
if (!g)
|
|
154
|
+
return true; // isIP said IPv6 but it will not parse: refuse
|
|
155
|
+
if (zeroThrough(g, 7) && g[7] <= 1)
|
|
156
|
+
return true; // :: unspecified, ::1 loopback
|
|
157
|
+
if ((g[0] & 0xfe00) === 0xfc00)
|
|
121
158
|
return true; // fc00::/7 unique-local
|
|
122
|
-
if (
|
|
159
|
+
if ((g[0] & 0xffc0) === 0xfe80)
|
|
123
160
|
return true; // fe80::/10 link-local
|
|
124
|
-
if (
|
|
161
|
+
if ((g[0] & 0xff00) === 0xff00)
|
|
125
162
|
return true; // ff00::/8 multicast
|
|
126
|
-
if (
|
|
127
|
-
return true; // NAT64
|
|
163
|
+
if (g[0] === 0x64 && g[1] === 0xff9b)
|
|
164
|
+
return true; // 64:ff9b::/96 NAT64
|
|
165
|
+
if (g[0] === 0x2002)
|
|
166
|
+
return true; // 2002::/16 6to4
|
|
167
|
+
if (g[0] === 0x2001 && g[1] === 0xdb8)
|
|
168
|
+
return true; // 2001:db8::/32 documentation
|
|
128
169
|
return false;
|
|
129
170
|
}
|
|
130
171
|
/**
|
package/build/x402-eip3009.d.ts
CHANGED
|
@@ -169,3 +169,22 @@ export declare function pickOption(accepts: X402Requirement[], resolveChainId: (
|
|
|
169
169
|
chainId: number | null;
|
|
170
170
|
reason?: string;
|
|
171
171
|
};
|
|
172
|
+
/**
|
|
173
|
+
* An EIP-7702 delegated EOA has code 0xef0100 || delegate (23 bytes). A facilitator that
|
|
174
|
+
* checks account code before recovering the signer treats such a payer as a contract
|
|
175
|
+
* wallet and calls ERC-1271 isValidSignature on it; a delegate that does not implement
|
|
176
|
+
* it answers with empty data, and the payment is declined with a signature-shaped error
|
|
177
|
+
* that has nothing to do with the signature (issue #9: a well-known test key that
|
|
178
|
+
* sweeper bots had delegated on Base mainnet).
|
|
179
|
+
*/
|
|
180
|
+
export declare function parseEip7702Delegation(code: string | null | undefined): string | null;
|
|
181
|
+
export declare const ERC1271_MAGIC = "0x1626ba7e";
|
|
182
|
+
/** Calldata for isValidSignature(bytes32, bytes) with a placeholder hash and a 65-byte placeholder signature. */
|
|
183
|
+
export declare function erc1271ProbeCalldata(): `0x${string}`;
|
|
184
|
+
export type Erc1271Support = 'yes' | 'no' | 'unknown';
|
|
185
|
+
/**
|
|
186
|
+
* Classify an isValidSignature probe. Empty return data means the account has no such
|
|
187
|
+
* function (the failure shape from issue #9); a 4-byte word means the interface exists,
|
|
188
|
+
* whatever it thought of the placeholder signature; a revert or transport failure is unknown.
|
|
189
|
+
*/
|
|
190
|
+
export declare function classifyErc1271Probe(result: string | null | undefined, failed?: boolean): Erc1271Support;
|
package/build/x402-eip3009.js
CHANGED
|
@@ -205,3 +205,40 @@ export function pickOption(accepts, resolveChainId, preferChain) {
|
|
|
205
205
|
option = payable.find(a => resolveChainId(a.network) === preferChain) ?? option;
|
|
206
206
|
return { option, chainId: resolveChainId(option.network) };
|
|
207
207
|
}
|
|
208
|
+
// ─── Payer account shape (EIP-7702) ──────────────────────────────
|
|
209
|
+
/**
|
|
210
|
+
* An EIP-7702 delegated EOA has code 0xef0100 || delegate (23 bytes). A facilitator that
|
|
211
|
+
* checks account code before recovering the signer treats such a payer as a contract
|
|
212
|
+
* wallet and calls ERC-1271 isValidSignature on it; a delegate that does not implement
|
|
213
|
+
* it answers with empty data, and the payment is declined with a signature-shaped error
|
|
214
|
+
* that has nothing to do with the signature (issue #9: a well-known test key that
|
|
215
|
+
* sweeper bots had delegated on Base mainnet).
|
|
216
|
+
*/
|
|
217
|
+
export function parseEip7702Delegation(code) {
|
|
218
|
+
const m = /^0xef0100([0-9a-f]{40})$/.exec(String(code || '').toLowerCase());
|
|
219
|
+
return m ? `0x${m[1]}` : null;
|
|
220
|
+
}
|
|
221
|
+
export const ERC1271_MAGIC = '0x1626ba7e';
|
|
222
|
+
/** Calldata for isValidSignature(bytes32, bytes) with a placeholder hash and a 65-byte placeholder signature. */
|
|
223
|
+
export function erc1271ProbeCalldata() {
|
|
224
|
+
const hash = '11'.repeat(32);
|
|
225
|
+
const offset = (64).toString(16).padStart(64, '0');
|
|
226
|
+
const length = (65).toString(16).padStart(64, '0');
|
|
227
|
+
const sig = 'aa'.repeat(65).padEnd(192, '0');
|
|
228
|
+
return `${ERC1271_MAGIC}${hash}${offset}${length}${sig}`;
|
|
229
|
+
}
|
|
230
|
+
/**
|
|
231
|
+
* Classify an isValidSignature probe. Empty return data means the account has no such
|
|
232
|
+
* function (the failure shape from issue #9); a 4-byte word means the interface exists,
|
|
233
|
+
* whatever it thought of the placeholder signature; a revert or transport failure is unknown.
|
|
234
|
+
*/
|
|
235
|
+
export function classifyErc1271Probe(result, failed = false) {
|
|
236
|
+
if (failed)
|
|
237
|
+
return 'unknown';
|
|
238
|
+
const r = String(result || '').toLowerCase();
|
|
239
|
+
if (r === '' || r === '0x')
|
|
240
|
+
return 'no';
|
|
241
|
+
if (/^0x[0-9a-f]{8}0{56}$/.test(r))
|
|
242
|
+
return 'yes';
|
|
243
|
+
return 'unknown';
|
|
244
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agentwallet-mcp",
|
|
3
3
|
"mcpName": "io.github.hifriendbot/agentwallet",
|
|
4
|
-
"version": "1.12.
|
|
4
|
+
"version": "1.12.4",
|
|
5
5
|
"description": "MCP server for AgentWallet: self-custody local signing on EVM and Solana, or hosted wallets. Sign, broadcast, pay x402 invoices, and accept x402 payments.",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"main": "build/index.js",
|