agentwallet-mcp 1.10.0 → 1.10.2
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 +328 -316
- package/build/index.js +149 -96
- package/build/local-wallet.d.ts +0 -4
- package/build/local-wallet.js +46 -0
- package/build/ssrf-guard.d.ts +38 -0
- package/build/ssrf-guard.js +178 -0
- package/build/x402-payment.d.ts +43 -7
- package/build/x402-payment.js +100 -11
- package/package.json +68 -68
package/build/index.js
CHANGED
|
@@ -12,7 +12,8 @@
|
|
|
12
12
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
13
13
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
14
14
|
import { z } from 'zod';
|
|
15
|
-
import {
|
|
15
|
+
import { assertPublicUrl, safeFetch } from './ssrf-guard.js';
|
|
16
|
+
import { deriveX402Payment, isWithinCap, lookupTrustedDecimals, assertDeclaredDecimals, } from './x402-payment.js';
|
|
16
17
|
import { isLocalMode, getLocalAddress, localWalletRecord, localSend, localNativeBalance, localTokenBalance, localSignMessage, resolveRpcUrl, } from './local-wallet.js';
|
|
17
18
|
import { isSolanaLocalMode, getSolanaAddress, solanaWalletRecord, localSolBalance, localSplBalance, localSolTransfer, localSplTransfer, resolveSolanaRpc, } from './local-solana.js';
|
|
18
19
|
/** True when either chain family is running non-custodially. */
|
|
@@ -186,6 +187,53 @@ async function api(path, method = 'GET', body, extraHeaders) {
|
|
|
186
187
|
}
|
|
187
188
|
return data;
|
|
188
189
|
}
|
|
190
|
+
/**
|
|
191
|
+
* Resolve an asset's decimals from a source the paying party controls, never
|
|
192
|
+
* from the 402 response.
|
|
193
|
+
*
|
|
194
|
+
* Order: known-asset registry first (offline, works in local self-custody mode
|
|
195
|
+
* where there may be no reachable API), then the token contract's own
|
|
196
|
+
* decimals() via eth_call. If neither answers we refuse rather than guess,
|
|
197
|
+
* because guessing is exactly what let a hostile endpoint inflate the cap.
|
|
198
|
+
*/
|
|
199
|
+
const decimalsCache = new Map();
|
|
200
|
+
async function resolveTrustedDecimals(chainId, token) {
|
|
201
|
+
const key = `${chainId}:${token.toLowerCase()}`;
|
|
202
|
+
const cached = decimalsCache.get(key);
|
|
203
|
+
if (typeof cached === 'number')
|
|
204
|
+
return cached;
|
|
205
|
+
const known = lookupTrustedDecimals(chainId, token);
|
|
206
|
+
if (known !== null) {
|
|
207
|
+
decimalsCache.set(key, known);
|
|
208
|
+
return known;
|
|
209
|
+
}
|
|
210
|
+
if (isSolanaChain(chainId)) {
|
|
211
|
+
throw new Error(`x402 blocked: cannot verify decimals for SPL mint ${token} on chain ${chainId}. ` +
|
|
212
|
+
`Unknown mints are refused because the payment cap cannot be checked without trusted decimals.`);
|
|
213
|
+
}
|
|
214
|
+
// decimals() selector
|
|
215
|
+
let onChain = null;
|
|
216
|
+
try {
|
|
217
|
+
const r = (await api('/eth-call', 'POST', {
|
|
218
|
+
chain_id: chainId, to: token, data: '0x313ce567',
|
|
219
|
+
}, { 'X-AGW-SKIP-X402': 'true' }));
|
|
220
|
+
const hex = (r?.result || '').replace(/^0x/, '');
|
|
221
|
+
if (hex && /^[0-9a-fA-F]+$/.test(hex)) {
|
|
222
|
+
const n = parseInt(hex, 16);
|
|
223
|
+
if (Number.isInteger(n) && n >= 0 && n <= 36)
|
|
224
|
+
onChain = n;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
catch {
|
|
228
|
+
onChain = null; // fall through to the refusal below
|
|
229
|
+
}
|
|
230
|
+
if (onChain === null) {
|
|
231
|
+
throw new Error(`x402 blocked: could not resolve on-chain decimals for ${token} on chain ${chainId}. ` +
|
|
232
|
+
`The payment cap cannot be enforced without them, so the payment was refused.`);
|
|
233
|
+
}
|
|
234
|
+
decimalsCache.set(key, onChain);
|
|
235
|
+
return onChain;
|
|
236
|
+
}
|
|
189
237
|
/**
|
|
190
238
|
* Handle x402 auto-payment: pay on-chain, then retry the original request
|
|
191
239
|
*/
|
|
@@ -207,7 +255,11 @@ async function handleX402Payment(x402Data, originalPath, originalMethod, origina
|
|
|
207
255
|
// 402 response cannot drain the wallet. AGENTWALLET_MAX_AUTOPAY is in
|
|
208
256
|
// human-readable units of the asset; default 1.
|
|
209
257
|
const maxAutopay = process.env.AGENTWALLET_MAX_AUTOPAY || '1';
|
|
210
|
-
const
|
|
258
|
+
const assetForDecimals = accept.asset || accept.extra?.token || '';
|
|
259
|
+
const trustedDecimals = assetForDecimals
|
|
260
|
+
? await resolveTrustedDecimals(chainId, assetForDecimals)
|
|
261
|
+
: 18; // native asset: value is already in wei-equivalent base units
|
|
262
|
+
const { tokenAddress, rawAmount, decimals } = deriveX402Payment(accept, maxAutopay, trustedDecimals);
|
|
211
263
|
// Send payment using our wallet (with X-AGW-SKIP-X402 to prevent recursion)
|
|
212
264
|
let txHash;
|
|
213
265
|
try {
|
|
@@ -360,82 +412,82 @@ function resolveChainId(network) {
|
|
|
360
412
|
// ─── Server ──────────────────────────────────────────────────────
|
|
361
413
|
const server = new McpServer({
|
|
362
414
|
name: 'agentwallet',
|
|
363
|
-
version: '1.
|
|
415
|
+
version: '1.10.1',
|
|
364
416
|
}, {
|
|
365
|
-
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.
|
|
366
|
-
|
|
367
|
-
## Getting Started
|
|
368
|
-
1. Call list_wallets to see existing wallets, or create_wallet to make a new one.
|
|
369
|
-
2. Fund the wallet by sending tokens to its address from an external source.
|
|
370
|
-
3. Use transfer (native tokens) or transfer_token (ERC-20/SPL tokens) to send funds.
|
|
371
|
-
|
|
372
|
-
## Supported Chains
|
|
373
|
-
- **EVM chains:** Ethereum (1), Base (8453), Arbitrum (42161), Optimism (10), Polygon (137), Avalanche (43114), BSC (56), Zora (7777777), PulseChain (369)
|
|
374
|
-
- **Solana:** Mainnet (900), Devnet (901)
|
|
375
|
-
- Call get_chains for the full list with native tokens and stablecoin contract addresses.
|
|
376
|
-
|
|
377
|
-
## EVM vs Solana
|
|
378
|
-
- **Addresses:** EVM uses 0x-prefixed hex (42 chars). Solana uses Base58 (32-44 chars).
|
|
379
|
-
- **Native tokens:** EVM = ETH/POL/BNB/etc (18 decimals, wei). Solana = SOL (9 decimals, lamports).
|
|
380
|
-
- **Token standards:** EVM uses ERC-20. Solana uses SPL tokens.
|
|
381
|
-
- **EVM-only tools:** approve_token, get_allowance, wrap_eth, unwrap_eth, call_contract, get_token_info. These do not work on Solana.
|
|
382
|
-
- **Works on both:** create_wallet, transfer, transfer_token, send_transaction, sign_transaction, get_balance, get_token_balance.
|
|
383
|
-
|
|
384
|
-
## Common Workflows
|
|
385
|
-
|
|
386
|
-
### Send native tokens (ETH, SOL, etc.)
|
|
387
|
-
Use the transfer tool with a human-readable amount (e.g. "0.1" ETH). It auto-converts to wei/lamports.
|
|
388
|
-
|
|
389
|
-
### Send ERC-20 or SPL tokens
|
|
390
|
-
Use transfer_token. You need: wallet_id, token contract address, recipient, amount, chain_id, and decimals (6 for USDC, 18 for most ERC-20s). Call get_chains to find stablecoin addresses.
|
|
391
|
-
|
|
392
|
-
### Interact with DeFi (EVM only)
|
|
393
|
-
1. Check allowance with get_allowance.
|
|
394
|
-
2. If needed, call approve_token to let the DeFi contract spend your tokens.
|
|
395
|
-
3. Use send_transaction with the contract's calldata to execute the interaction.
|
|
396
|
-
|
|
397
|
-
### Wrap/unwrap native tokens (EVM only)
|
|
398
|
-
Use wrap_eth to convert ETH → WETH (required by many DeFi protocols). Use unwrap_eth to convert back.
|
|
399
|
-
|
|
400
|
-
### Read on-chain data (EVM only)
|
|
401
|
-
Use call_contract for gas-free read-only calls (eth_call). Provide ABI-encoded calldata.
|
|
402
|
-
|
|
403
|
-
## x402 Payments — Paying for Resources
|
|
404
|
-
The pay_x402 tool handles the full x402 payment flow automatically:
|
|
405
|
-
1. Fetches the URL.
|
|
406
|
-
2. If the server returns HTTP 402, parses the payment requirements.
|
|
407
|
-
3. Pays on-chain using your wallet.
|
|
408
|
-
4. Retries the request with proof of payment.
|
|
409
|
-
Always set max_payment to prevent overspending (e.g. "1.00" for max 1 USDC).
|
|
410
|
-
|
|
411
|
-
## x402 Paywalls — Accepting Payments
|
|
412
|
-
Create paywalls to charge other agents for accessing your resources:
|
|
413
|
-
1. create_paywall — set price, token, chain, and the resource URL to protect.
|
|
414
|
-
2. Share the access URL. Clients that pay on-chain get the resource; others get HTTP 402.
|
|
415
|
-
3. list_paywalls and get_paywall_payments to track revenue.
|
|
416
|
-
4. get_x402_revenue for aggregate stats across all paywalls.
|
|
417
|
-
|
|
418
|
-
## Wallet Security
|
|
419
|
-
- Use pause_wallet to immediately freeze a wallet if compromised. No transactions can be signed while paused.
|
|
420
|
-
- Use unpause_wallet to resume operations.
|
|
421
|
-
- Use delete_wallet to permanently disable a wallet.
|
|
422
|
-
- Wallets have server-enforced spending limits.
|
|
423
|
-
|
|
424
|
-
## Tool Selection Guide
|
|
425
|
-
| Goal | Tool |
|
|
426
|
-
|------|------|
|
|
427
|
-
| Send ETH/SOL/native tokens | transfer |
|
|
428
|
-
| Send USDC/ERC-20/SPL tokens | transfer_token |
|
|
429
|
-
| Check native balance | get_balance |
|
|
430
|
-
| Check token balance | get_token_balance |
|
|
431
|
-
| Look up token details | get_token_info (EVM only) |
|
|
432
|
-
| Approve DeFi spending | approve_token (EVM only) |
|
|
433
|
-
| Check approval amount | get_allowance (EVM only) |
|
|
434
|
-
| Read smart contract | call_contract (EVM only) |
|
|
435
|
-
| Custom transaction | send_transaction |
|
|
436
|
-
| Sign without broadcasting | sign_transaction |
|
|
437
|
-
| Pay for x402 resource | pay_x402 |
|
|
438
|
-
| Charge for your resource | create_paywall |
|
|
417
|
+
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.
|
|
418
|
+
|
|
419
|
+
## Getting Started
|
|
420
|
+
1. Call list_wallets to see existing wallets, or create_wallet to make a new one.
|
|
421
|
+
2. Fund the wallet by sending tokens to its address from an external source.
|
|
422
|
+
3. Use transfer (native tokens) or transfer_token (ERC-20/SPL tokens) to send funds.
|
|
423
|
+
|
|
424
|
+
## Supported Chains
|
|
425
|
+
- **EVM chains:** Ethereum (1), Base (8453), Arbitrum (42161), Optimism (10), Polygon (137), Avalanche (43114), BSC (56), Zora (7777777), PulseChain (369)
|
|
426
|
+
- **Solana:** Mainnet (900), Devnet (901)
|
|
427
|
+
- Call get_chains for the full list with native tokens and stablecoin contract addresses.
|
|
428
|
+
|
|
429
|
+
## EVM vs Solana
|
|
430
|
+
- **Addresses:** EVM uses 0x-prefixed hex (42 chars). Solana uses Base58 (32-44 chars).
|
|
431
|
+
- **Native tokens:** EVM = ETH/POL/BNB/etc (18 decimals, wei). Solana = SOL (9 decimals, lamports).
|
|
432
|
+
- **Token standards:** EVM uses ERC-20. Solana uses SPL tokens.
|
|
433
|
+
- **EVM-only tools:** approve_token, get_allowance, wrap_eth, unwrap_eth, call_contract, get_token_info. These do not work on Solana.
|
|
434
|
+
- **Works on both:** create_wallet, transfer, transfer_token, send_transaction, sign_transaction, get_balance, get_token_balance.
|
|
435
|
+
|
|
436
|
+
## Common Workflows
|
|
437
|
+
|
|
438
|
+
### Send native tokens (ETH, SOL, etc.)
|
|
439
|
+
Use the transfer tool with a human-readable amount (e.g. "0.1" ETH). It auto-converts to wei/lamports.
|
|
440
|
+
|
|
441
|
+
### Send ERC-20 or SPL tokens
|
|
442
|
+
Use transfer_token. You need: wallet_id, token contract address, recipient, amount, chain_id, and decimals (6 for USDC, 18 for most ERC-20s). Call get_chains to find stablecoin addresses.
|
|
443
|
+
|
|
444
|
+
### Interact with DeFi (EVM only)
|
|
445
|
+
1. Check allowance with get_allowance.
|
|
446
|
+
2. If needed, call approve_token to let the DeFi contract spend your tokens.
|
|
447
|
+
3. Use send_transaction with the contract's calldata to execute the interaction.
|
|
448
|
+
|
|
449
|
+
### Wrap/unwrap native tokens (EVM only)
|
|
450
|
+
Use wrap_eth to convert ETH → WETH (required by many DeFi protocols). Use unwrap_eth to convert back.
|
|
451
|
+
|
|
452
|
+
### Read on-chain data (EVM only)
|
|
453
|
+
Use call_contract for gas-free read-only calls (eth_call). Provide ABI-encoded calldata.
|
|
454
|
+
|
|
455
|
+
## x402 Payments — Paying for Resources
|
|
456
|
+
The pay_x402 tool handles the full x402 payment flow automatically:
|
|
457
|
+
1. Fetches the URL.
|
|
458
|
+
2. If the server returns HTTP 402, parses the payment requirements.
|
|
459
|
+
3. Pays on-chain using your wallet.
|
|
460
|
+
4. Retries the request with proof of payment.
|
|
461
|
+
Always set max_payment to prevent overspending (e.g. "1.00" for max 1 USDC).
|
|
462
|
+
|
|
463
|
+
## x402 Paywalls — Accepting Payments
|
|
464
|
+
Create paywalls to charge other agents for accessing your resources:
|
|
465
|
+
1. create_paywall — set price, token, chain, and the resource URL to protect.
|
|
466
|
+
2. Share the access URL. Clients that pay on-chain get the resource; others get HTTP 402.
|
|
467
|
+
3. list_paywalls and get_paywall_payments to track revenue.
|
|
468
|
+
4. get_x402_revenue for aggregate stats across all paywalls.
|
|
469
|
+
|
|
470
|
+
## Wallet Security
|
|
471
|
+
- Use pause_wallet to immediately freeze a wallet if compromised. No transactions can be signed while paused.
|
|
472
|
+
- Use unpause_wallet to resume operations.
|
|
473
|
+
- Use delete_wallet to permanently disable a wallet.
|
|
474
|
+
- Wallets have server-enforced spending limits.
|
|
475
|
+
|
|
476
|
+
## Tool Selection Guide
|
|
477
|
+
| Goal | Tool |
|
|
478
|
+
|------|------|
|
|
479
|
+
| Send ETH/SOL/native tokens | transfer |
|
|
480
|
+
| Send USDC/ERC-20/SPL tokens | transfer_token |
|
|
481
|
+
| Check native balance | get_balance |
|
|
482
|
+
| Check token balance | get_token_balance |
|
|
483
|
+
| Look up token details | get_token_info (EVM only) |
|
|
484
|
+
| Approve DeFi spending | approve_token (EVM only) |
|
|
485
|
+
| Check approval amount | get_allowance (EVM only) |
|
|
486
|
+
| Read smart contract | call_contract (EVM only) |
|
|
487
|
+
| Custom transaction | send_transaction |
|
|
488
|
+
| Sign without broadcasting | sign_transaction |
|
|
489
|
+
| Pay for x402 resource | pay_x402 |
|
|
490
|
+
| Charge for your resource | create_paywall |
|
|
439
491
|
| Check usage/billing | get_usage |`,
|
|
440
492
|
});
|
|
441
493
|
// ─── Tool: create_wallet ─────────────────────────────────────────
|
|
@@ -900,19 +952,10 @@ server.tool('pay_x402', 'Handle an x402 payment flow. Fetches a URL, and if the
|
|
|
900
952
|
throw new Error('Invalid headers JSON. Must be a JSON object (e.g. {"Authorization": "Bearer ..."}).');
|
|
901
953
|
}
|
|
902
954
|
}
|
|
903
|
-
// Validate URL
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
const blockedPrefixes = ['10.', '172.16.', '172.17.', '172.18.', '172.19.', '172.20.', '172.21.',
|
|
908
|
-
'172.22.', '172.23.', '172.24.', '172.25.', '172.26.', '172.27.', '172.28.', '172.29.',
|
|
909
|
-
'172.30.', '172.31.', '192.168.'];
|
|
910
|
-
if (blockedHosts.includes(hostname) || blockedPrefixes.some(p => hostname.startsWith(p))) {
|
|
911
|
-
throw new Error('URL points to a private/internal address. Only public URLs are allowed.');
|
|
912
|
-
}
|
|
913
|
-
if (urlObj.protocol !== 'https:') {
|
|
914
|
-
throw new Error('Only HTTPS URLs are supported for x402 payments.');
|
|
915
|
-
}
|
|
955
|
+
// Validate URL: canonicalizes IP literals (including IPv4-mapped IPv6) and
|
|
956
|
+
// resolves DNS names, rejecting any private/internal destination. Redirect
|
|
957
|
+
// hops are re-validated by safeFetch below.
|
|
958
|
+
await assertPublicUrl(url);
|
|
916
959
|
// Step 1: Make initial request
|
|
917
960
|
const reqOptions = {
|
|
918
961
|
method,
|
|
@@ -924,7 +967,7 @@ server.tool('pay_x402', 'Handle an x402 payment flow. Fetches a URL, and if the
|
|
|
924
967
|
if (!reqHeaders['Content-Type'])
|
|
925
968
|
reqHeaders['Content-Type'] = 'application/json';
|
|
926
969
|
}
|
|
927
|
-
const initialRes = await
|
|
970
|
+
const initialRes = await safeFetch(url, reqOptions);
|
|
928
971
|
// If not 402, return the response as-is (no payment needed)
|
|
929
972
|
if (initialRes.status !== 402) {
|
|
930
973
|
const text = await initialRes.text();
|
|
@@ -966,8 +1009,18 @@ server.tool('pay_x402', 'Handle an x402 payment flow. Fetches a URL, and if the
|
|
|
966
1009
|
throw new Error(`Unsupported x402 network: "${option.network}". ` +
|
|
967
1010
|
`Supported: ${Object.keys(X402_NETWORKS).join(', ')}, or any CAIP-2 / numeric chain ID.`);
|
|
968
1011
|
}
|
|
969
|
-
//
|
|
970
|
-
|
|
1012
|
+
// Decimals come from the registry or the token contract, NEVER from the 402
|
|
1013
|
+
// body. The endpoint controls requiredDecimals, so trusting it lets a
|
|
1014
|
+
// hostile server inflate the cap AND misreport the amount shown to the
|
|
1015
|
+
// agent. The declared value is only used to detect a mismatch and refuse.
|
|
1016
|
+
const assetForDecimals = option.asset || option.extra?.token || '';
|
|
1017
|
+
const trustedDecimals = assetForDecimals
|
|
1018
|
+
? await resolveTrustedDecimals(chainId, assetForDecimals)
|
|
1019
|
+
: 18;
|
|
1020
|
+
assertDeclaredDecimals(option.requiredDecimals, trustedDecimals);
|
|
1021
|
+
// Human-readable amount, computed with trusted decimals so what the agent
|
|
1022
|
+
// is told matches what will actually leave the wallet.
|
|
1023
|
+
const amount = formatUnits(option.maxAmountRequired, trustedDecimals);
|
|
971
1024
|
// Step 4: Enforce a hard per-payment cap. ALWAYS applied — if the caller
|
|
972
1025
|
// omits max_payment we fall back to AGENTWALLET_MAX_AUTOPAY (default "1"),
|
|
973
1026
|
// mirroring the internal auto-pay path (deriveX402Payment). Without this, an
|
|
@@ -975,7 +1028,7 @@ server.tool('pay_x402', 'Handle an x402 payment flow. Fetches a URL, and if the
|
|
|
975
1028
|
// maxAmountRequired and drain the wallet. The cap is never silently skipped.
|
|
976
1029
|
const capSource = max_payment ? 'max_payment' : 'AGENTWALLET_MAX_AUTOPAY';
|
|
977
1030
|
const effectiveMax = max_payment || process.env.AGENTWALLET_MAX_AUTOPAY || '1';
|
|
978
|
-
if (!isWithinCap(option.maxAmountRequired,
|
|
1031
|
+
if (!isWithinCap(option.maxAmountRequired, trustedDecimals, effectiveMax)) {
|
|
979
1032
|
return jsonResponse({
|
|
980
1033
|
status: 402,
|
|
981
1034
|
payment_required: true,
|
|
@@ -1007,7 +1060,7 @@ server.tool('pay_x402', 'Handle an x402 payment flow. Fetches a URL, and if the
|
|
|
1007
1060
|
to: option.payTo,
|
|
1008
1061
|
value: option.maxAmountRequired,
|
|
1009
1062
|
token_mint: tokenAddress,
|
|
1010
|
-
token_decimals:
|
|
1063
|
+
token_decimals: trustedDecimals,
|
|
1011
1064
|
chain_id: chainId,
|
|
1012
1065
|
}));
|
|
1013
1066
|
}
|
|
@@ -1058,7 +1111,7 @@ server.tool('pay_x402', 'Handle an x402 payment flow. Fetches a URL, and if the
|
|
|
1058
1111
|
if (reqBody && method !== 'GET') {
|
|
1059
1112
|
retryOptions.body = reqBody;
|
|
1060
1113
|
}
|
|
1061
|
-
const retryRes = await
|
|
1114
|
+
const retryRes = await safeFetch(url, retryOptions);
|
|
1062
1115
|
const retryText = await retryRes.text();
|
|
1063
1116
|
let retryParsed;
|
|
1064
1117
|
try {
|
package/build/local-wallet.d.ts
CHANGED
|
@@ -64,10 +64,6 @@ export interface LocalSendResult {
|
|
|
64
64
|
mode: 'local';
|
|
65
65
|
signed_locally: true;
|
|
66
66
|
}
|
|
67
|
-
/**
|
|
68
|
-
* Sign and broadcast a transaction. viem fills nonce, gas and EIP-1559 fees
|
|
69
|
-
* from the RPC unless they are supplied.
|
|
70
|
-
*/
|
|
71
67
|
export declare function localSend(chainId: number, to: Address, valueWei: bigint, data?: Hex): Promise<LocalSendResult>;
|
|
72
68
|
/** ERC-20 transfer, encoded locally. */
|
|
73
69
|
export declare function localTransferToken(chainId: number, token: Address, to: Address, rawAmount: string): Promise<LocalSendResult>;
|
package/build/local-wallet.js
CHANGED
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
import { readFileSync } from 'node:fs';
|
|
18
18
|
import { createPublicClient, createWalletClient, http, encodeFunctionData, parseAbi, formatUnits, } from 'viem';
|
|
19
19
|
import { privateKeyToAccount } from 'viem/accounts';
|
|
20
|
+
import { lookupTrustedDecimals } from './x402-payment.js';
|
|
20
21
|
/* ── Key loading ─────────────────────────────────────────────────── */
|
|
21
22
|
/**
|
|
22
23
|
* Read the key once at startup. Kept module-private: nothing exports the key
|
|
@@ -166,8 +167,53 @@ export async function localTokenBalance(chainId, token) {
|
|
|
166
167
|
* Sign and broadcast a transaction. viem fills nonce, gas and EIP-1559 fees
|
|
167
168
|
* from the RPC unless they are supplied.
|
|
168
169
|
*/
|
|
170
|
+
/**
|
|
171
|
+
* Bound ERC-20 movement, which the native cap does not see.
|
|
172
|
+
*
|
|
173
|
+
* A token transfer is `to = tokenContract, value = 0, data = transfer(...)`, so
|
|
174
|
+
* assertWithinNativeCap always passed it. In local mode there is no server-side
|
|
175
|
+
* limit behind this, and the README tells operators the local guard is the only
|
|
176
|
+
* one they have, so a wallet holding USDC was effectively uncapped. Covers
|
|
177
|
+
* transfer, transferFrom and approve, since an unbounded approval is a drain
|
|
178
|
+
* waiting to happen.
|
|
179
|
+
*
|
|
180
|
+
* Opt-in via AGENTWALLET_MAX_TX_TOKEN, expressed in human units of the token.
|
|
181
|
+
* When the token's decimals are not known locally we assume 6, the smallest in
|
|
182
|
+
* common use, which makes the raw ceiling the tightest one and fails closed
|
|
183
|
+
* rather than open.
|
|
184
|
+
*/
|
|
185
|
+
function assertWithinTokenCap(chainId, token, data) {
|
|
186
|
+
const cap = (process.env.AGENTWALLET_MAX_TX_TOKEN || '').trim();
|
|
187
|
+
if (!cap)
|
|
188
|
+
return;
|
|
189
|
+
if (!/^\d+(\.\d+)?$/.test(cap)) {
|
|
190
|
+
throw new Error(`AGENTWALLET_MAX_TX_TOKEN must be a decimal number, got "${cap}".`);
|
|
191
|
+
}
|
|
192
|
+
const hex = data.slice(2);
|
|
193
|
+
const selector = hex.slice(0, 8).toLowerCase();
|
|
194
|
+
// transfer(address,uint256) | approve(address,uint256) | transferFrom(address,address,uint256)
|
|
195
|
+
const layouts = { a9059cbb: 1, '095ea7b3': 1, '23b872dd': 2 };
|
|
196
|
+
const argIndex = layouts[selector];
|
|
197
|
+
if (argIndex === undefined)
|
|
198
|
+
return; // not a value-moving ERC-20 call
|
|
199
|
+
const word = hex.slice(8 + argIndex * 64, 8 + (argIndex + 1) * 64);
|
|
200
|
+
if (word.length !== 64)
|
|
201
|
+
return; // malformed; leave it to the node to reject
|
|
202
|
+
const amount = BigInt('0x' + word);
|
|
203
|
+
const decimals = lookupTrustedDecimals(chainId, token) ?? 6;
|
|
204
|
+
const [whole, frac = ''] = cap.split('.');
|
|
205
|
+
const capRaw = BigInt(whole + frac.padEnd(decimals, '0').slice(0, decimals));
|
|
206
|
+
if (amount > capRaw) {
|
|
207
|
+
const what = selector === '095ea7b3' ? 'approval' : 'token transfer';
|
|
208
|
+
throw new Error(`Blocked by local guard: ${what} of ${amount} base units exceeds ` +
|
|
209
|
+
`AGENTWALLET_MAX_TX_TOKEN (${cap}, evaluated at ${decimals} decimals). ` +
|
|
210
|
+
`Raise the cap deliberately if this is intended.`);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
169
213
|
export async function localSend(chainId, to, valueWei, data) {
|
|
170
214
|
assertWithinNativeCap(valueWei);
|
|
215
|
+
if (data && data.length >= 10)
|
|
216
|
+
assertWithinTokenCap(chainId, to, data);
|
|
171
217
|
const { wallet } = clients(chainId);
|
|
172
218
|
const hash = await wallet.sendTransaction({
|
|
173
219
|
to,
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SSRF guard for outbound x402 requests.
|
|
3
|
+
*
|
|
4
|
+
* The previous guard compared `new URL(url).hostname` against literal strings
|
|
5
|
+
* and dotted-decimal prefixes. That misses every alternate spelling of a
|
|
6
|
+
* private destination: IPv4-mapped IPv6 (`[::ffff:127.0.0.1]` parses to
|
|
7
|
+
* `[::ffff:7f00:1]`), the rest of 127.0.0.0/8, IPv6 unique-local and
|
|
8
|
+
* link-local, the whole 169.254.0.0/16 link-local range rather than only the
|
|
9
|
+
* metadata address, and 100.64.0.0/10.
|
|
10
|
+
*
|
|
11
|
+
* This module canonicalizes the host into actual IP addresses and tests them
|
|
12
|
+
* against numeric ranges, then resolves DNS names and applies the same test to
|
|
13
|
+
* every answer, so a public name pointing at a private address is refused too.
|
|
14
|
+
*
|
|
15
|
+
* Reported privately by ARC Security Research, 2026-07-31.
|
|
16
|
+
*/
|
|
17
|
+
/**
|
|
18
|
+
* Reduce a host to the IP address it actually denotes.
|
|
19
|
+
* Strips IPv6 brackets and unwraps IPv4-mapped and IPv4-compatible IPv6.
|
|
20
|
+
*/
|
|
21
|
+
export declare function canonicalizeHost(hostRaw: string): {
|
|
22
|
+
ip: string | null;
|
|
23
|
+
family: 0 | 4 | 6;
|
|
24
|
+
};
|
|
25
|
+
/** True when the address is loopback, private, link-local, multicast or reserved. */
|
|
26
|
+
export declare function isPrivateAddress(ipRaw: string): boolean;
|
|
27
|
+
/**
|
|
28
|
+
* Throw unless `url` is an https URL pointing at a public destination.
|
|
29
|
+
* DNS names are resolved and every answer is checked, so a public name that
|
|
30
|
+
* maps to a private address is rejected.
|
|
31
|
+
*/
|
|
32
|
+
export declare function assertPublicUrl(url: string): Promise<void>;
|
|
33
|
+
/**
|
|
34
|
+
* fetch() that validates the target, and every redirect hop, against
|
|
35
|
+
* assertPublicUrl. Redirects are followed manually because the automatic
|
|
36
|
+
* follower would happily land on a private address after a public first hop.
|
|
37
|
+
*/
|
|
38
|
+
export declare function safeFetch(url: string, options?: RequestInit, maxHops?: number): Promise<Response>;
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SSRF guard for outbound x402 requests.
|
|
3
|
+
*
|
|
4
|
+
* The previous guard compared `new URL(url).hostname` against literal strings
|
|
5
|
+
* and dotted-decimal prefixes. That misses every alternate spelling of a
|
|
6
|
+
* private destination: IPv4-mapped IPv6 (`[::ffff:127.0.0.1]` parses to
|
|
7
|
+
* `[::ffff:7f00:1]`), the rest of 127.0.0.0/8, IPv6 unique-local and
|
|
8
|
+
* link-local, the whole 169.254.0.0/16 link-local range rather than only the
|
|
9
|
+
* metadata address, and 100.64.0.0/10.
|
|
10
|
+
*
|
|
11
|
+
* This module canonicalizes the host into actual IP addresses and tests them
|
|
12
|
+
* against numeric ranges, then resolves DNS names and applies the same test to
|
|
13
|
+
* every answer, so a public name pointing at a private address is refused too.
|
|
14
|
+
*
|
|
15
|
+
* Reported privately by ARC Security Research, 2026-07-31.
|
|
16
|
+
*/
|
|
17
|
+
import { isIP } from 'node:net';
|
|
18
|
+
import { lookup } from 'node:dns/promises';
|
|
19
|
+
/** Hostnames that must never be reached regardless of what they resolve to. */
|
|
20
|
+
const BLOCKED_NAMES = new Set([
|
|
21
|
+
'localhost',
|
|
22
|
+
'metadata.google.internal',
|
|
23
|
+
'metadata.goog',
|
|
24
|
+
'instance-data',
|
|
25
|
+
]);
|
|
26
|
+
function ipv4ToInt(ip) {
|
|
27
|
+
const parts = ip.split('.');
|
|
28
|
+
if (parts.length !== 4)
|
|
29
|
+
return null;
|
|
30
|
+
let n = 0;
|
|
31
|
+
for (const p of parts) {
|
|
32
|
+
const v = Number(p);
|
|
33
|
+
if (!Number.isInteger(v) || v < 0 || v > 255)
|
|
34
|
+
return null;
|
|
35
|
+
n = (n << 8 >>> 0) + v;
|
|
36
|
+
}
|
|
37
|
+
return n >>> 0;
|
|
38
|
+
}
|
|
39
|
+
function inV4Range(ip, cidr) {
|
|
40
|
+
const [base, bitsRaw] = cidr.split('/');
|
|
41
|
+
const bits = Number(bitsRaw);
|
|
42
|
+
const a = ipv4ToInt(ip);
|
|
43
|
+
const b = ipv4ToInt(base);
|
|
44
|
+
if (a === null || b === null)
|
|
45
|
+
return false;
|
|
46
|
+
if (bits === 0)
|
|
47
|
+
return true;
|
|
48
|
+
const mask = (0xffffffff << (32 - bits)) >>> 0;
|
|
49
|
+
return (a & mask) === (b & mask);
|
|
50
|
+
}
|
|
51
|
+
/** Ranges that are never a legitimate x402 payment endpoint. */
|
|
52
|
+
const V4_BLOCKED = [
|
|
53
|
+
'0.0.0.0/8', // this network / unspecified
|
|
54
|
+
'10.0.0.0/8', // private
|
|
55
|
+
'100.64.0.0/10', // carrier-grade NAT
|
|
56
|
+
'127.0.0.0/8', // loopback, all of it
|
|
57
|
+
'169.254.0.0/16', // link-local, includes cloud metadata
|
|
58
|
+
'172.16.0.0/12', // private
|
|
59
|
+
'192.0.0.0/24', // IETF protocol assignments
|
|
60
|
+
'192.168.0.0/16', // private
|
|
61
|
+
'198.18.0.0/15', // benchmarking
|
|
62
|
+
'224.0.0.0/4', // multicast
|
|
63
|
+
'240.0.0.0/4', // reserved, includes 255.255.255.255
|
|
64
|
+
];
|
|
65
|
+
/**
|
|
66
|
+
* Reduce a host to the IP address it actually denotes.
|
|
67
|
+
* Strips IPv6 brackets and unwraps IPv4-mapped and IPv4-compatible IPv6.
|
|
68
|
+
*/
|
|
69
|
+
export function canonicalizeHost(hostRaw) {
|
|
70
|
+
let host = hostRaw.trim().toLowerCase();
|
|
71
|
+
if (host.startsWith('[') && host.endsWith(']'))
|
|
72
|
+
host = host.slice(1, -1);
|
|
73
|
+
const zone = host.indexOf('%'); // fe80::1%eth0
|
|
74
|
+
if (zone !== -1)
|
|
75
|
+
host = host.slice(0, zone);
|
|
76
|
+
const fam = isIP(host);
|
|
77
|
+
if (fam === 0)
|
|
78
|
+
return { ip: null, family: 0 };
|
|
79
|
+
if (fam === 4)
|
|
80
|
+
return { ip: host, family: 4 };
|
|
81
|
+
// IPv6: unwrap ::ffff:a.b.c.d and its hex spelling ::ffff:7f00:1
|
|
82
|
+
const mapped = host.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/);
|
|
83
|
+
if (mapped)
|
|
84
|
+
return { ip: mapped[1], family: 4 };
|
|
85
|
+
const hexMapped = host.match(/^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/);
|
|
86
|
+
if (hexMapped) {
|
|
87
|
+
const hi = parseInt(hexMapped[1], 16);
|
|
88
|
+
const lo = parseInt(hexMapped[2], 16);
|
|
89
|
+
return { ip: [(hi >> 8) & 255, hi & 255, (lo >> 8) & 255, lo & 255].join('.'), family: 4 };
|
|
90
|
+
}
|
|
91
|
+
return { ip: host, family: 6 };
|
|
92
|
+
}
|
|
93
|
+
/** True when the address is loopback, private, link-local, multicast or reserved. */
|
|
94
|
+
export function isPrivateAddress(ipRaw) {
|
|
95
|
+
const { ip, family } = canonicalizeHost(ipRaw);
|
|
96
|
+
if (!ip)
|
|
97
|
+
return false;
|
|
98
|
+
if (family === 4)
|
|
99
|
+
return V4_BLOCKED.some(c => inV4Range(ip, c));
|
|
100
|
+
const v6 = ip;
|
|
101
|
+
if (v6 === '::' || v6 === '::1')
|
|
102
|
+
return true; // unspecified, loopback
|
|
103
|
+
if (/^f[cd][0-9a-f]{2}:/.test(v6))
|
|
104
|
+
return true; // fc00::/7 unique-local
|
|
105
|
+
if (/^fe[89ab][0-9a-f]:/.test(v6))
|
|
106
|
+
return true; // fe80::/10 link-local
|
|
107
|
+
if (/^ff[0-9a-f]{2}:/.test(v6))
|
|
108
|
+
return true; // ff00::/8 multicast
|
|
109
|
+
if (/^(64:ff9b|2002):/.test(v6))
|
|
110
|
+
return true; // NAT64 / 6to4 translation
|
|
111
|
+
return false;
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Throw unless `url` is an https URL pointing at a public destination.
|
|
115
|
+
* DNS names are resolved and every answer is checked, so a public name that
|
|
116
|
+
* maps to a private address is rejected.
|
|
117
|
+
*/
|
|
118
|
+
export async function assertPublicUrl(url) {
|
|
119
|
+
let u;
|
|
120
|
+
try {
|
|
121
|
+
u = new URL(url);
|
|
122
|
+
}
|
|
123
|
+
catch {
|
|
124
|
+
throw new Error('Invalid URL.');
|
|
125
|
+
}
|
|
126
|
+
if (u.protocol !== 'https:') {
|
|
127
|
+
throw new Error('Only HTTPS URLs are supported for x402 payments.');
|
|
128
|
+
}
|
|
129
|
+
const hostRaw = u.hostname.toLowerCase();
|
|
130
|
+
const bare = hostRaw.startsWith('[') && hostRaw.endsWith(']') ? hostRaw.slice(1, -1) : hostRaw;
|
|
131
|
+
if (BLOCKED_NAMES.has(bare)) {
|
|
132
|
+
throw new Error('URL points to a private/internal address. Only public URLs are allowed.');
|
|
133
|
+
}
|
|
134
|
+
const { ip, family } = canonicalizeHost(hostRaw);
|
|
135
|
+
if (family !== 0) {
|
|
136
|
+
if (isPrivateAddress(ip)) {
|
|
137
|
+
throw new Error('URL points to a private/internal address. Only public URLs are allowed.');
|
|
138
|
+
}
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
// A name: every address it resolves to must be public.
|
|
142
|
+
let answers;
|
|
143
|
+
try {
|
|
144
|
+
answers = await lookup(bare, { all: true });
|
|
145
|
+
}
|
|
146
|
+
catch {
|
|
147
|
+
throw new Error(`Could not resolve host "${bare}".`);
|
|
148
|
+
}
|
|
149
|
+
if (!answers.length)
|
|
150
|
+
throw new Error(`Could not resolve host "${bare}".`);
|
|
151
|
+
for (const a of answers) {
|
|
152
|
+
if (isPrivateAddress(a.address)) {
|
|
153
|
+
throw new Error('URL resolves to a private/internal address. Only public URLs are allowed.');
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* fetch() that validates the target, and every redirect hop, against
|
|
159
|
+
* assertPublicUrl. Redirects are followed manually because the automatic
|
|
160
|
+
* follower would happily land on a private address after a public first hop.
|
|
161
|
+
*/
|
|
162
|
+
export async function safeFetch(url, options = {}, maxHops = 3) {
|
|
163
|
+
let current = url;
|
|
164
|
+
for (let hop = 0; hop <= maxHops; hop++) {
|
|
165
|
+
await assertPublicUrl(current);
|
|
166
|
+
const res = await fetch(current, { ...options, redirect: 'manual' });
|
|
167
|
+
const isRedirect = res.status >= 300 && res.status < 400 && res.headers.has('location');
|
|
168
|
+
if (!isRedirect)
|
|
169
|
+
return res;
|
|
170
|
+
const next = new URL(res.headers.get('location'), current).toString();
|
|
171
|
+
// A redirected request must not replay the body or method blindly.
|
|
172
|
+
if (res.status === 303 || ((res.status === 301 || res.status === 302) && options.method && options.method !== 'GET')) {
|
|
173
|
+
options = { ...options, method: 'GET', body: undefined };
|
|
174
|
+
}
|
|
175
|
+
current = next;
|
|
176
|
+
}
|
|
177
|
+
throw new Error('Too many redirects while contacting the x402 endpoint.');
|
|
178
|
+
}
|