agentwallet-mcp 1.12.6 → 1.13.0
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 +381 -376
- package/build/index.js +271 -67
- package/build/local-solana.js +70 -4
- package/build/local-wallet.d.ts +1 -0
- package/build/local-wallet.js +48 -5
- package/build/ssrf-guard.d.ts +9 -0
- package/build/ssrf-guard.js +39 -2
- package/build/x402-eip3009.js +6 -1
- package/build/x402-payment.d.ts +59 -0
- package/build/x402-payment.js +111 -0
- package/build/x402-permit2.js +3 -1
- package/package.json +83 -69
package/build/index.js
CHANGED
|
@@ -12,8 +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 { assertPublicUrl, safeFetch } from './ssrf-guard.js';
|
|
16
|
-
import { deriveX402Payment, isWithinCap, lookupTrustedDecimals, assertDeclaredDecimals, nativeDecimals, SOLANA_CHAIN_IDS, } from './x402-payment.js';
|
|
15
|
+
import { assertPublicUrl, safeFetch, FINAL_URL_HEADER } from './ssrf-guard.js';
|
|
16
|
+
import { deriveX402Payment, isWithinCap, lookupTrustedDecimals, assertDeclaredDecimals, nativeDecimals, SOLANA_CHAIN_IDS, assetLabel, autopayAssetAllowed, autopayEnvCap, effectiveAutopayCap, maxAuthWindowSeconds, } from './x402-payment.js';
|
|
17
17
|
import { isLocalMode, getLocalAddress, localWalletRecord, localSend, localNativeBalance, localTokenBalance, localSignMessage, resolveRpcUrl, } from './local-wallet.js';
|
|
18
18
|
import { localSignAuthorization, localEthCall, localGetCode, localSignPermit2Upto } from './local-wallet.js';
|
|
19
19
|
import { WRAPPED_NATIVE } from './wrapped-native.js';
|
|
@@ -31,6 +31,11 @@ const API_USER = process.env.AGENTWALLET_USER || '';
|
|
|
31
31
|
const API_PASS = process.env.AGENTWALLET_PASS || ''; // WordPress application password
|
|
32
32
|
const X402_WALLET_ID = process.env.AGENTWALLET_WALLET_ID || ''; // Wallet ID for x402 auto-pay
|
|
33
33
|
const DASHBOARD_URL = process.env.AGENTWALLET_DASHBOARD_URL || 'https://hifriendbot.com/wallet/';
|
|
34
|
+
// Basic credentials and 402 auto-pay decisions ride on this URL, so it must be
|
|
35
|
+
// https, or loopback for a local test server. Anything else is refused at start.
|
|
36
|
+
if (/^http:/i.test(API_BASE) && !/^http:\/\/(localhost|127\.0\.0\.1|\[::1\])(:\d+)?(\/|$)/i.test(API_BASE)) {
|
|
37
|
+
throw new Error(`AGENTWALLET_API_URL must be https (got ${API_BASE}); plain http is allowed only for localhost.`);
|
|
38
|
+
}
|
|
34
39
|
/* ── Local-mode routing ──────────────────────────────────────────
|
|
35
40
|
Translates the REST paths the tools already speak into in-process signing
|
|
36
41
|
calls. Anything not listed here falls through to the hosted API, so
|
|
@@ -223,7 +228,7 @@ async function api(path, method = 'GET', body, extraHeaders) {
|
|
|
223
228
|
const res = await fetch(url, options);
|
|
224
229
|
const data = await res.json();
|
|
225
230
|
// Handle 402 Payment Required — auto-pay if a wallet or a local key is configured
|
|
226
|
-
if (res.status === 402 && (X402_WALLET_ID || anyLocalMode()) && !extraHeaders?.['X-PAYMENT']) {
|
|
231
|
+
if (res.status === 402 && (X402_WALLET_ID || anyLocalMode()) && !extraHeaders?.['X-PAYMENT'] && !extraHeaders?.['X-AGW-SKIP-X402']) {
|
|
227
232
|
return handleX402Payment(data, path, method, body);
|
|
228
233
|
}
|
|
229
234
|
if (!res.ok) {
|
|
@@ -288,10 +293,15 @@ async function handleX402Payment(x402Data, originalPath, originalMethod, origina
|
|
|
288
293
|
throw new Error('402 Payment Required but no payment options available.');
|
|
289
294
|
}
|
|
290
295
|
const accept = accepts[0]; // Use first option
|
|
291
|
-
const payTo = accept.payTo;
|
|
296
|
+
const payTo = String(accept.payTo || '');
|
|
292
297
|
// Determine chain_id from network string (CAIP-2, plain name, or raw ID)
|
|
293
298
|
const network = accept.network || '';
|
|
294
|
-
const chainId = resolveChainId(network)
|
|
299
|
+
const chainId = resolveChainId(network);
|
|
300
|
+
if (chainId === null)
|
|
301
|
+
throw new Error(`x402 auto-pay: unknown network "${network}" in the payment requirements; refusing to guess a chain.`);
|
|
302
|
+
if (isSolanaChain(chainId) ? !/^[1-9A-HJ-NP-Za-km-z]{32,44}$/.test(payTo) : !/^0x[0-9a-fA-F]{40}$/.test(payTo)) {
|
|
303
|
+
throw new Error(`x402 auto-pay: payTo "${payTo}" is not a valid address for chain ${chainId}.`);
|
|
304
|
+
}
|
|
295
305
|
// x402 maxAmountRequired is ALREADY in base/atomic units (e.g. "10000" =
|
|
296
306
|
// 0.01 USDC at 6 decimals) and must be used directly. The previous code ran
|
|
297
307
|
// it through parseUnits(), which re-scaled it by 10**decimals and overpaid by
|
|
@@ -299,8 +309,11 @@ async function handleX402Payment(x402Data, originalPath, originalMethod, origina
|
|
|
299
309
|
// the pay_x402 tool) and enforces a hard ceiling so a malformed or malicious
|
|
300
310
|
// 402 response cannot drain the wallet. AGENTWALLET_MAX_AUTOPAY is in
|
|
301
311
|
// human-readable units of the asset; default 1.
|
|
302
|
-
const maxAutopay =
|
|
312
|
+
const maxAutopay = autopayEnvCap();
|
|
303
313
|
const assetForDecimals = accept.asset || accept.extra?.token || '';
|
|
314
|
+
const policy = autopayAssetAllowed(chainId, assetForDecimals);
|
|
315
|
+
if (!policy.allowed)
|
|
316
|
+
throw new Error(policy.reason);
|
|
304
317
|
const trustedDecimals = assetForDecimals
|
|
305
318
|
? await resolveTrustedDecimals(chainId, assetForDecimals)
|
|
306
319
|
: nativeDecimals(chainId); // native asset: 18 on EVM (wei), 9 on Solana (lamports)
|
|
@@ -370,6 +383,66 @@ function isValidAddress(address) {
|
|
|
370
383
|
return true;
|
|
371
384
|
return false;
|
|
372
385
|
}
|
|
386
|
+
// ─── Guard helpers ──────────────────────────────────────────────
|
|
387
|
+
/** Request headers the caller may not set on pay_x402: Host would rename the TLS peer, the rest belong to the client. */
|
|
388
|
+
const FORBIDDEN_REQUEST_HEADERS = new Set(['host', 'content-length', 'transfer-encoding', 'connection', 'upgrade', 'expect', 'te', 'trailer', 'keep-alive']);
|
|
389
|
+
/** Authorizations signed in this process, keyed by endpoint and requirement, kept until they expire. */
|
|
390
|
+
const signedPayments = new Map();
|
|
391
|
+
function looksLikeTxHash(v) {
|
|
392
|
+
return typeof v === 'string' && (/^0x[0-9a-fA-F]{64}$/.test(v) || /^[1-9A-HJ-NP-Za-km-z]{86,88}$/.test(v));
|
|
393
|
+
}
|
|
394
|
+
function assertPayTo(chainId, payTo) {
|
|
395
|
+
const p = String(payTo ?? '');
|
|
396
|
+
const ok = isSolanaChain(chainId) ? /^[1-9A-HJ-NP-Za-km-z]{32,44}$/.test(p) : /^0x[0-9a-fA-F]{40}$/.test(p);
|
|
397
|
+
if (!ok)
|
|
398
|
+
throw new Error(`x402: payTo "${p}" is not a valid address for chain ${chainId}.`);
|
|
399
|
+
}
|
|
400
|
+
/** A URL with credentials in its path or query (Alchemy/Infura style keys) reduced to its origin for display. */
|
|
401
|
+
function redactUrl(u) {
|
|
402
|
+
try {
|
|
403
|
+
const p = new URL(u);
|
|
404
|
+
return p.pathname !== '/' || p.search ? `${p.origin}/…` : p.origin;
|
|
405
|
+
}
|
|
406
|
+
catch {
|
|
407
|
+
return u;
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
/**
|
|
411
|
+
* Decimals to scale a human amount by. Always the token's own value (registry,
|
|
412
|
+
* then decimals() on chain); a caller-supplied value must agree or the call
|
|
413
|
+
* is refused, because "18" for USDC scales the amount by a trillion.
|
|
414
|
+
*/
|
|
415
|
+
async function verifiedDecimals(chainId, token, given) {
|
|
416
|
+
let trusted = null;
|
|
417
|
+
if (isSolanaChain(chainId)) {
|
|
418
|
+
trusted = lookupTrustedDecimals(chainId, token);
|
|
419
|
+
if (trusted === null) {
|
|
420
|
+
if (given === undefined)
|
|
421
|
+
throw new Error(`Decimals for mint ${token} are not in the registry; pass decimals explicitly.`);
|
|
422
|
+
return given; // the token program verifies it on-chain (TransferChecked)
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
else {
|
|
426
|
+
trusted = await resolveTrustedDecimals(chainId, token);
|
|
427
|
+
}
|
|
428
|
+
if (given !== undefined && given !== trusted) {
|
|
429
|
+
throw new Error(`decimals ${given} was passed but ${token} has ${trusted} decimals; refusing to scale the amount by the wrong factor.`);
|
|
430
|
+
}
|
|
431
|
+
return trusted;
|
|
432
|
+
}
|
|
433
|
+
/** Cap variables are read at send time; a typo must fail at startup, not after funding. */
|
|
434
|
+
function validateGuardEnv() {
|
|
435
|
+
for (const name of ['AGENTWALLET_MAX_TX_NATIVE', 'AGENTWALLET_MAX_TX_TOKEN', 'AGENTWALLET_MAX_TX_SOL', 'AGENTWALLET_MAX_AUTOPAY']) {
|
|
436
|
+
const v = (process.env[name] || '').trim();
|
|
437
|
+
if (v && !/^\d+(\.\d+)?$/.test(v))
|
|
438
|
+
throw new Error(`${name} must be a decimal number, got "${v}".`);
|
|
439
|
+
}
|
|
440
|
+
for (const e of (process.env.AGENTWALLET_AUTOPAY_ASSETS || '').split(',').map(s => s.trim()).filter(Boolean)) {
|
|
441
|
+
if (e !== 'native' && !/^0x[0-9a-fA-F]{40}$/.test(e) && !/^[1-9A-HJ-NP-Za-km-z]{32,44}$/.test(e)) {
|
|
442
|
+
throw new Error(`AGENTWALLET_AUTOPAY_ASSETS entry "${e}" is not "native", an EVM address or an SPL mint.`);
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
}
|
|
373
446
|
// ─── EVM Helpers ──────────────────────────────────────────────────
|
|
374
447
|
/**
|
|
375
448
|
* Convert a human-readable amount (e.g. "0.1") to raw units string given decimals.
|
|
@@ -380,8 +453,11 @@ function parseUnits(amount, decimals) {
|
|
|
380
453
|
throw new Error(`Invalid amount "${amount}". Must be a positive number (e.g. "0.1" or "100").`);
|
|
381
454
|
}
|
|
382
455
|
const [whole, frac = ''] = amount.split('.');
|
|
383
|
-
|
|
384
|
-
|
|
456
|
+
// More fractional digits than the token has cannot be sent; truncating would
|
|
457
|
+
// move a different amount than the one echoed back to the caller.
|
|
458
|
+
if (frac.length > decimals)
|
|
459
|
+
throw new Error(`Amount "${amount}" has ${frac.length} decimal places but the token has ${decimals}.`);
|
|
460
|
+
const raw = BigInt(whole + frac.padEnd(decimals, '0'));
|
|
385
461
|
return raw.toString();
|
|
386
462
|
}
|
|
387
463
|
/**
|
|
@@ -399,14 +475,20 @@ function formatUnits(raw, decimals) {
|
|
|
399
475
|
* Pad an address to 32 bytes (64 hex chars) for ABI encoding.
|
|
400
476
|
*/
|
|
401
477
|
function padAddress(address) {
|
|
478
|
+
// An over-long "address" would shift every later calldata word (the amount
|
|
479
|
+
// among them) and the EVM would decode whatever landed in the first 32 bytes.
|
|
480
|
+
if (!/^0x[0-9a-fA-F]{40}$/.test(address))
|
|
481
|
+
throw new Error(`Not an EVM address: "${address}".`);
|
|
402
482
|
return address.slice(2).toLowerCase().padStart(64, '0');
|
|
403
483
|
}
|
|
404
484
|
/**
|
|
405
485
|
* Encode a uint256 as 32 bytes hex (64 chars).
|
|
406
486
|
*/
|
|
407
487
|
function encodeUint256(value) {
|
|
408
|
-
const
|
|
409
|
-
|
|
488
|
+
const v = BigInt(value);
|
|
489
|
+
if (v < 0n || v > (1n << 256n) - 1n)
|
|
490
|
+
throw new Error(`Amount ${value} does not fit in uint256.`);
|
|
491
|
+
return v.toString(16).padStart(64, '0');
|
|
410
492
|
}
|
|
411
493
|
// ─── Server ──────────────────────────────────────────────────────
|
|
412
494
|
// ─── x402 Network Mapping ──────────────────────────────────────
|
|
@@ -468,9 +550,11 @@ function resolveChainId(network) {
|
|
|
468
550
|
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');
|
|
469
551
|
const server = new McpServer({
|
|
470
552
|
name: 'agentwallet',
|
|
471
|
-
version: '1.
|
|
553
|
+
version: '1.13.0',
|
|
472
554
|
}, {
|
|
473
|
-
instructions: `AgentWallet gives AI agents their own blockchain wallets.
|
|
555
|
+
instructions: `AgentWallet gives AI agents their own blockchain wallets. ${anyLocalMode()
|
|
556
|
+
? 'This server runs in LOCAL (self-custody) mode: the operator supplied the key, signing happens in this process, and the only spend guards are the AGENTWALLET_MAX_TX_* environment caps. There is no server-side pause, daily limit or approval email; pause_wallet, unpause_wallet and delete_wallet are refused.'
|
|
557
|
+
: 'Private keys are encrypted server-side and never exposed: agents sign and broadcast transactions without ever touching raw keys.'}
|
|
474
558
|
|
|
475
559
|
## Getting Started
|
|
476
560
|
1. Call list_wallets to see existing wallets, or create_wallet to make a new one.
|
|
@@ -479,7 +563,7 @@ const server = new McpServer({
|
|
|
479
563
|
|
|
480
564
|
## Supported Chains
|
|
481
565
|
- **EVM chains:** Ethereum (1), Base (8453), Arbitrum (42161), Optimism (10), Polygon (137), Avalanche (43114), BSC (56), Zora (7777777), PulseChain (369)
|
|
482
|
-
- **Solana:** Mainnet (900), Devnet (901)
|
|
566
|
+
- **Solana:** Mainnet (900), Devnet (901), Testnet (902)
|
|
483
567
|
- Call get_chains for the full list with native tokens and stablecoin contract addresses.
|
|
484
568
|
|
|
485
569
|
## EVM vs Solana
|
|
@@ -524,10 +608,10 @@ Create paywalls to charge other agents for accessing your resources:
|
|
|
524
608
|
4. get_x402_revenue for aggregate stats across all paywalls.
|
|
525
609
|
|
|
526
610
|
## Wallet Security
|
|
527
|
-
- Use pause_wallet to immediately freeze a wallet if compromised. No transactions can be signed while paused.
|
|
611
|
+
- ${anyLocalMode() ? 'pause_wallet does not exist in local mode: revoke the key (rotate it and move the funds) if the process is compromised.' : 'Use pause_wallet to immediately freeze a wallet if compromised. No transactions can be signed while paused.'}
|
|
528
612
|
- Use unpause_wallet to resume operations.
|
|
529
613
|
- Use delete_wallet to permanently disable a wallet.
|
|
530
|
-
- Wallets have server-enforced spending limits.
|
|
614
|
+
- ${anyLocalMode() ? 'Spend limits are the AGENTWALLET_MAX_TX_NATIVE / _TOKEN / _SOL / _AUTOPAY caps the operator set; run wallet_mode to see them. Unset means no limit.' : 'Wallets have server-enforced spending limits.'}
|
|
531
615
|
|
|
532
616
|
## Tool Selection Guide
|
|
533
617
|
| Goal | Tool |
|
|
@@ -548,7 +632,7 @@ Create paywalls to charge other agents for accessing your resources:
|
|
|
548
632
|
});
|
|
549
633
|
// ─── Tool: create_wallet ─────────────────────────────────────────
|
|
550
634
|
server.tool('create_wallet', 'Create a new EVM or Solana wallet. Returns the wallet ID and address. ' +
|
|
551
|
-
'Private key is encrypted server-side and never exposed.', {
|
|
635
|
+
(anyLocalMode() ? 'In local mode this reports the key already configured; no wallet is created remotely.' : 'Private key is encrypted server-side and never exposed.'), {
|
|
552
636
|
label: z.string().default('').describe('Friendly name for the wallet'),
|
|
553
637
|
chain_id: z.number().int().default(8453).describe('Default chain ID (1=Ethereum, 8453=Base, 42161=Arbitrum, 10=Optimism, 137=Polygon, 43114=Avalanche, 56=BSC, 7777777=Zora, 369=PulseChain, 900=Solana, 901=Solana Devnet)'),
|
|
554
638
|
}, async ({ label, chain_id }) => {
|
|
@@ -586,13 +670,13 @@ server.tool('sign_transaction', 'Sign a transaction with a wallet\'s private key
|
|
|
586
670
|
wallet_id: z.number().int().describe('Wallet ID'),
|
|
587
671
|
to: AddressSchema.describe('Destination address (0x-prefixed for EVM, Base58 for Solana)'),
|
|
588
672
|
chain_id: z.number().int().optional().describe('Chain ID (defaults to wallet\'s default)'),
|
|
589
|
-
value: z.string().default('0').describe('Value in wei/lamports (decimal string)'),
|
|
590
|
-
data: z.string().default('').describe('Hex-encoded calldata (0x-prefixed) for EVM contract calls'),
|
|
673
|
+
value: z.string().regex(/^\d+$/, 'decimal integer string').default('0').describe('Value in wei/lamports (decimal integer string; no hex, no decimals)'),
|
|
674
|
+
data: z.string().regex(/^(0x([0-9a-fA-F]{2})*)?$/, '0x-prefixed even-length hex').default('').describe('Hex-encoded calldata (0x-prefixed) for EVM contract calls'),
|
|
591
675
|
gas_limit: z.string().optional().describe('Gas limit — EVM only (auto-estimated if omitted)'),
|
|
592
676
|
max_fee: z.string().optional().describe('Max fee per gas in wei — EVM only (auto if omitted)'),
|
|
593
677
|
priority_fee: z.string().optional().describe('Max priority fee per gas in wei — EVM only (auto if omitted)'),
|
|
594
678
|
token_mint: z.string().optional().describe('SPL token mint address — Solana only (for SPL token transfers)'),
|
|
595
|
-
token_decimals: z.number().int().optional().describe('SPL token decimals
|
|
679
|
+
token_decimals: z.number().int().min(0).max(36).optional().describe('SPL token decimals, Solana only (6 for USDC)'),
|
|
596
680
|
}, async ({ wallet_id, to, chain_id, value, data, gas_limit, max_fee, priority_fee, token_mint, token_decimals }) => {
|
|
597
681
|
// Validate address format
|
|
598
682
|
if (!isValidAddress(to)) {
|
|
@@ -601,6 +685,10 @@ server.tool('sign_transaction', 'Sign a transaction with a wallet\'s private key
|
|
|
601
685
|
const body = { to, value };
|
|
602
686
|
if (chain_id)
|
|
603
687
|
body.chain_id = chain_id;
|
|
688
|
+
// Without a chain id the SPL fields were silently dropped and the server
|
|
689
|
+
// fell back to a native transfer of the same number, to the same address.
|
|
690
|
+
if (token_mint && !chain_id)
|
|
691
|
+
throw new Error('token_mint needs an explicit Solana chain_id (900 mainnet, 901 devnet).');
|
|
604
692
|
if (isSolanaChain(chain_id ?? 0)) {
|
|
605
693
|
// Solana-specific params
|
|
606
694
|
if (token_mint)
|
|
@@ -624,17 +712,17 @@ server.tool('sign_transaction', 'Sign a transaction with a wallet\'s private key
|
|
|
624
712
|
// ─── Tool: send_transaction ──────────────────────────────────────
|
|
625
713
|
server.tool('send_transaction', 'Sign and broadcast a transaction. ' +
|
|
626
714
|
'Returns the transaction hash (EVM) or signature (Solana) on success. ' +
|
|
627
|
-
'The transaction is signed server-side and broadcast via RPC.', {
|
|
715
|
+
(anyLocalMode() ? 'Signed in this process with the local key and broadcast via the configured RPC.' : 'The transaction is signed server-side and broadcast via RPC.'), {
|
|
628
716
|
wallet_id: z.number().int().describe('Wallet ID'),
|
|
629
717
|
to: AddressSchema.describe('Destination address (0x-prefixed for EVM, Base58 for Solana)'),
|
|
630
718
|
chain_id: z.number().int().optional().describe('Chain ID (defaults to wallet\'s default)'),
|
|
631
|
-
value: z.string().default('0').describe('Value in wei/lamports (decimal string)'),
|
|
632
|
-
data: z.string().default('').describe('Hex-encoded calldata (0x-prefixed) for EVM contract calls'),
|
|
719
|
+
value: z.string().regex(/^\d+$/, 'decimal integer string').default('0').describe('Value in wei/lamports (decimal integer string; no hex, no decimals)'),
|
|
720
|
+
data: z.string().regex(/^(0x([0-9a-fA-F]{2})*)?$/, '0x-prefixed even-length hex').default('').describe('Hex-encoded calldata (0x-prefixed) for EVM contract calls'),
|
|
633
721
|
gas_limit: z.string().optional().describe('Gas limit — EVM only (auto-estimated if omitted)'),
|
|
634
722
|
max_fee: z.string().optional().describe('Max fee per gas in wei — EVM only (auto if omitted)'),
|
|
635
723
|
priority_fee: z.string().optional().describe('Max priority fee per gas in wei — EVM only (auto if omitted)'),
|
|
636
724
|
token_mint: z.string().optional().describe('SPL token mint address — Solana only (for SPL token transfers)'),
|
|
637
|
-
token_decimals: z.number().int().optional().describe('SPL token decimals
|
|
725
|
+
token_decimals: z.number().int().min(0).max(36).optional().describe('SPL token decimals, Solana only (6 for USDC)'),
|
|
638
726
|
}, async ({ wallet_id, to, chain_id, value, data, gas_limit, max_fee, priority_fee, token_mint, token_decimals }) => {
|
|
639
727
|
// Validate address format
|
|
640
728
|
if (!isValidAddress(to)) {
|
|
@@ -643,6 +731,10 @@ server.tool('send_transaction', 'Sign and broadcast a transaction. ' +
|
|
|
643
731
|
const body = { to, value };
|
|
644
732
|
if (chain_id)
|
|
645
733
|
body.chain_id = chain_id;
|
|
734
|
+
// Without a chain id the SPL fields were silently dropped and the server
|
|
735
|
+
// fell back to a native transfer of the same number, to the same address.
|
|
736
|
+
if (token_mint && !chain_id)
|
|
737
|
+
throw new Error('token_mint needs an explicit Solana chain_id (900 mainnet, 901 devnet).');
|
|
646
738
|
if (isSolanaChain(chain_id ?? 0)) {
|
|
647
739
|
// Solana-specific params
|
|
648
740
|
if (token_mint)
|
|
@@ -701,7 +793,7 @@ server.tool('get_token_balance', 'Get the ERC-20 or SPL token balance for a wall
|
|
|
701
793
|
wallet_id: z.number().int().describe('Wallet ID to check'),
|
|
702
794
|
token: z.string().describe('Token address (0x-prefixed ERC-20 contract for EVM, Base58 mint for Solana)'),
|
|
703
795
|
chain_id: z.number().int().describe('Chain ID to check on'),
|
|
704
|
-
decimals: z.number().int().
|
|
796
|
+
decimals: z.number().int().min(0).max(36).optional().describe('Token decimals. Taken from the response or the token contract when omitted'),
|
|
705
797
|
}, async ({ wallet_id, token, chain_id, decimals }) => {
|
|
706
798
|
// Validate token address format
|
|
707
799
|
if (!isValidAddress(token)) {
|
|
@@ -714,15 +806,28 @@ server.tool('get_token_balance', 'Get the ERC-20 or SPL token balance for a wall
|
|
|
714
806
|
return jsonResponse({
|
|
715
807
|
...data,
|
|
716
808
|
balance: data.balance_formatted,
|
|
717
|
-
decimals: data.decimals ?? decimals,
|
|
809
|
+
decimals: data.decimals ?? decimals ?? null,
|
|
718
810
|
});
|
|
719
811
|
}
|
|
720
|
-
//
|
|
721
|
-
|
|
812
|
+
// The response's own decimals (local mode reads them from the contract) win
|
|
813
|
+
// over the caller's guess; a guess of 18 used to reformat a 1 USDC balance
|
|
814
|
+
// as 0.000000000001. When nothing supplies them, resolve them ourselves.
|
|
815
|
+
let trusted = typeof data.decimals === 'number' ? data.decimals : null;
|
|
816
|
+
if (trusted === null) {
|
|
817
|
+
if (typeof decimals === 'number')
|
|
818
|
+
trusted = decimals;
|
|
819
|
+
else if (!isSolanaChain(chain_id))
|
|
820
|
+
trusted = await resolveTrustedDecimals(chain_id, token);
|
|
821
|
+
}
|
|
822
|
+
if (trusted === null)
|
|
823
|
+
throw new Error('Could not determine the token decimals; pass decimals explicitly.');
|
|
824
|
+
const warning = typeof decimals === 'number' && typeof data.decimals === 'number' && decimals !== data.decimals
|
|
825
|
+
? `decimals ${decimals} was passed but the token reports ${data.decimals}; the token's value was used.` : undefined;
|
|
722
826
|
return jsonResponse({
|
|
723
827
|
...data,
|
|
724
|
-
balance:
|
|
725
|
-
decimals,
|
|
828
|
+
balance: data.balance ?? formatUnits(data.balance_raw || '0', trusted),
|
|
829
|
+
decimals: trusted,
|
|
830
|
+
...(warning ? { warning } : {}),
|
|
726
831
|
});
|
|
727
832
|
});
|
|
728
833
|
// ─── Tool: transfer_token ───────────────────────────────────────
|
|
@@ -734,8 +839,8 @@ server.tool('transfer_token', 'Send ERC-20 tokens (EVM) or SPL tokens (Solana) t
|
|
|
734
839
|
to: AddressSchema.describe('Recipient address (0x-prefixed for EVM, Base58 for Solana)'),
|
|
735
840
|
amount: z.string().describe('Amount in human-readable format (e.g. "100" for 100 USDC)'),
|
|
736
841
|
chain_id: z.number().int().describe('Chain ID'),
|
|
737
|
-
decimals: z.number().int().
|
|
738
|
-
}, async ({ wallet_id, token, to, amount, chain_id, decimals }) => {
|
|
842
|
+
decimals: z.number().int().min(0).max(36).optional().describe('Token decimals. Resolved from the registry or the token contract when omitted; if given, it must match'),
|
|
843
|
+
}, async ({ wallet_id, token, to, amount, chain_id, decimals: givenDecimals }) => {
|
|
739
844
|
// Validate addresses
|
|
740
845
|
if (!isValidAddress(token)) {
|
|
741
846
|
throw new Error(`Invalid token address "${token}". Use 0x-prefixed hex for EVM or Base58 for Solana.`);
|
|
@@ -743,6 +848,7 @@ server.tool('transfer_token', 'Send ERC-20 tokens (EVM) or SPL tokens (Solana) t
|
|
|
743
848
|
if (!isValidAddress(to)) {
|
|
744
849
|
throw new Error(`Invalid recipient address "${to}". Use 0x-prefixed hex for EVM or Base58 for Solana.`);
|
|
745
850
|
}
|
|
851
|
+
const decimals = await verifiedDecimals(chain_id, token, givenDecimals);
|
|
746
852
|
const rawAmount = parseUnits(amount, decimals);
|
|
747
853
|
let result;
|
|
748
854
|
if (isSolanaChain(chain_id)) {
|
|
@@ -796,8 +902,8 @@ server.tool('approve_token', 'Approve a spender contract to transfer ERC-20 toke
|
|
|
796
902
|
spender: z.string().regex(/^0x[a-fA-F0-9]{40}$/).describe('Contract address to approve as spender'),
|
|
797
903
|
amount: z.string().describe('Amount to approve in human-readable format (e.g. "1000"), or "max" for unlimited'),
|
|
798
904
|
chain_id: z.number().int().describe('Chain ID'),
|
|
799
|
-
decimals: z.number().int().
|
|
800
|
-
}, async ({ wallet_id, token, spender, amount, chain_id, decimals }) => {
|
|
905
|
+
decimals: z.number().int().min(0).max(36).optional().describe('Token decimals. Resolved from the registry or the token contract when omitted; if given, it must match'),
|
|
906
|
+
}, async ({ wallet_id, token, spender, amount, chain_id, decimals: givenDecimals }) => {
|
|
801
907
|
if (isSolanaChain(chain_id)) {
|
|
802
908
|
throw new Error('approve_token is not supported on Solana. Solana SPL tokens do not use ERC-20 style approvals.');
|
|
803
909
|
}
|
|
@@ -807,13 +913,13 @@ server.tool('approve_token', 'Approve a spender contract to transfer ERC-20 toke
|
|
|
807
913
|
rawAmount = (BigInt(2) ** BigInt(256) - BigInt(1)).toString();
|
|
808
914
|
}
|
|
809
915
|
else {
|
|
810
|
-
rawAmount = parseUnits(amount,
|
|
916
|
+
rawAmount = parseUnits(amount, await verifiedDecimals(chain_id, token, givenDecimals));
|
|
811
917
|
}
|
|
812
918
|
const calldata = '0x095ea7b3' + padAddress(spender) + encodeUint256(rawAmount);
|
|
813
919
|
// Asset risk rides along with the approval (issue #13). It never blocks; it is the
|
|
814
920
|
// caller's call. Skipped for Permit2, which only moves what a signature allows.
|
|
815
921
|
let risk = null;
|
|
816
|
-
if (spender.toLowerCase() !== PERMIT2_ADDRESS.toLowerCase()) {
|
|
922
|
+
if (spender.toLowerCase() !== PERMIT2_ADDRESS.toLowerCase() && process.env.AGENTWALLET_TOKEN_RISK !== '0') {
|
|
817
923
|
try {
|
|
818
924
|
risk = await assessTokenRisk(chain_id, token, (to, data) => ethCallHex(chain_id, to, data));
|
|
819
925
|
}
|
|
@@ -843,8 +949,8 @@ server.tool('get_allowance', 'Check how many ERC-20 tokens a spender is approved
|
|
|
843
949
|
token: z.string().regex(/^0x[a-fA-F0-9]{40}$/).describe('ERC-20 token contract address'),
|
|
844
950
|
spender: z.string().regex(/^0x[a-fA-F0-9]{40}$/).describe('Spender contract address to check'),
|
|
845
951
|
chain_id: z.number().int().describe('Chain ID'),
|
|
846
|
-
decimals: z.number().int().
|
|
847
|
-
}, async ({ wallet_id, token, spender, chain_id, decimals }) => {
|
|
952
|
+
decimals: z.number().int().min(0).max(36).optional().describe('Token decimals. Resolved from the registry or the token contract when omitted; if given, it must match'),
|
|
953
|
+
}, async ({ wallet_id, token, spender, chain_id, decimals: givenDecimals }) => {
|
|
848
954
|
if (isSolanaChain(chain_id)) {
|
|
849
955
|
throw new Error('get_allowance is not supported on Solana. Solana SPL tokens do not use ERC-20 style allowances.');
|
|
850
956
|
}
|
|
@@ -853,9 +959,13 @@ server.tool('get_allowance', 'Check how many ERC-20 tokens a spender is approved
|
|
|
853
959
|
// allowance(address owner, address spender) — selector: 0xdd62ed3e
|
|
854
960
|
const calldata = '0xdd62ed3e' + padAddress(wallet.address) + padAddress(spender);
|
|
855
961
|
const result = await api('/eth-call', 'POST', { chain_id, to: token, data: calldata });
|
|
856
|
-
// Parse uint256
|
|
857
|
-
|
|
962
|
+
// Parse exactly one uint256 word: a hostile token returning more bytes must
|
|
963
|
+
// not turn into a 512-bit "allowance".
|
|
964
|
+
const rawHex = String(result?.result || '').replace(/^0x/, '').slice(0, 64);
|
|
965
|
+
if (rawHex && !/^[0-9a-fA-F]+$/.test(rawHex))
|
|
966
|
+
throw new Error('allowance() returned non-hex data.');
|
|
858
967
|
const raw = BigInt('0x' + (rawHex || '0')).toString();
|
|
968
|
+
const decimals = await verifiedDecimals(chain_id, token, givenDecimals);
|
|
859
969
|
const maxUint256 = (BigInt(2) ** BigInt(256) - BigInt(1)).toString();
|
|
860
970
|
return jsonResponse({
|
|
861
971
|
token,
|
|
@@ -949,7 +1059,9 @@ function decodeAbiString(hex) {
|
|
|
949
1059
|
return '';
|
|
950
1060
|
// Convert hex to UTF-8
|
|
951
1061
|
const bytes = new Uint8Array(pairs.map(b => parseInt(b, 16)));
|
|
952
|
-
|
|
1062
|
+
// Attacker-controlled text: keep it short and printable so a token cannot
|
|
1063
|
+
// smuggle kilobytes of instructions or terminal escapes into the agent's context.
|
|
1064
|
+
return new TextDecoder().decode(bytes).replace(/[\u0000-\u001f\u007f-\u009f]/g, '').slice(0, 64);
|
|
953
1065
|
}
|
|
954
1066
|
catch {
|
|
955
1067
|
return '';
|
|
@@ -971,8 +1083,9 @@ server.tool('get_token_info', 'Get the name, symbol, and decimals of any ERC-20
|
|
|
971
1083
|
]);
|
|
972
1084
|
const name = decodeAbiString(nameResult.result);
|
|
973
1085
|
const symbol = decodeAbiString(symbolResult.result);
|
|
974
|
-
const decimalsHex = decimalsResult
|
|
975
|
-
const
|
|
1086
|
+
const decimalsHex = String(decimalsResult?.result || '').replace(/^0x/, '').slice(0, 64);
|
|
1087
|
+
const parsedDecimals = /^[0-9a-fA-F]+$/.test(decimalsHex) ? Number(BigInt('0x' + decimalsHex)) : NaN;
|
|
1088
|
+
const decimals = Number.isInteger(parsedDecimals) && parsedDecimals >= 0 && parsedDecimals <= 255 ? parsedDecimals : null;
|
|
976
1089
|
return jsonResponse({
|
|
977
1090
|
token,
|
|
978
1091
|
chain_id,
|
|
@@ -1101,16 +1214,32 @@ server.tool('pay_x402', 'Handle an x402 payment flow. Fetches a URL, and if the
|
|
|
1101
1214
|
request_approval: z.boolean().optional().describe('Hosted wallets only. When the payment exceeds the cap, email the wallet owner an approve/deny link ' +
|
|
1102
1215
|
'instead of refusing, and return an approval_id to retry with. Default true unless AGENTWALLET_APPROVALS=0.'),
|
|
1103
1216
|
approval_id: z.string().optional().describe('An approval id from an earlier over-cap attempt. Once the owner has approved it, pass it here to make that one payment.'),
|
|
1104
|
-
|
|
1217
|
+
fresh_authorization: z.boolean().optional().describe('Sign a new authorization even if one for the same endpoint, amount and recipient is still valid. By default a repeat call ' +
|
|
1218
|
+
'within the validity window re-sends the earlier signature (same nonce, so it cannot settle twice) instead of paying again.'),
|
|
1219
|
+
}, async ({ url, wallet_id, method, headers: headersJson, body: reqBody, max_payment, prefer_chain, request_approval, approval_id, fresh_authorization }) => {
|
|
1105
1220
|
// Build request headers
|
|
1106
1221
|
const reqHeaders = { Accept: 'application/json' };
|
|
1107
1222
|
if (headersJson) {
|
|
1223
|
+
let parsed;
|
|
1108
1224
|
try {
|
|
1109
|
-
|
|
1225
|
+
parsed = JSON.parse(headersJson);
|
|
1110
1226
|
}
|
|
1111
1227
|
catch {
|
|
1228
|
+
parsed = null;
|
|
1229
|
+
}
|
|
1230
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
1112
1231
|
throw new Error('Invalid headers JSON. Must be a JSON object (e.g. {"Authorization": "Bearer ..."}).');
|
|
1113
1232
|
}
|
|
1233
|
+
for (const [k, v] of Object.entries(parsed)) {
|
|
1234
|
+
const name = k.toLowerCase();
|
|
1235
|
+
// Host would replace the TLS server name; the rest are hop-by-hop or framing headers the client owns.
|
|
1236
|
+
if (FORBIDDEN_REQUEST_HEADERS.has(name) || name.startsWith('proxy-') || name.startsWith(':')) {
|
|
1237
|
+
throw new Error(`Header "${k}" cannot be set by the caller.`);
|
|
1238
|
+
}
|
|
1239
|
+
if (typeof v !== 'string')
|
|
1240
|
+
throw new Error(`Header "${k}" must be a string.`);
|
|
1241
|
+
reqHeaders[k] = v;
|
|
1242
|
+
}
|
|
1114
1243
|
}
|
|
1115
1244
|
// Validate URL: canonicalizes IP literals (including IPv4-mapped IPv6) and
|
|
1116
1245
|
// resolves DNS names, rejecting any private/internal destination. An early
|
|
@@ -1147,6 +1276,15 @@ server.tool('pay_x402', 'Handle an x402 payment flow. Fetches a URL, and if the
|
|
|
1147
1276
|
});
|
|
1148
1277
|
}
|
|
1149
1278
|
// Step 2: Requirements come from the PAYMENT-REQUIRED header (v2) or the JSON body (v1).
|
|
1279
|
+
// A 402 that arrived through a cross-origin redirect cannot be paid here: the
|
|
1280
|
+
// payment header would be stripped on the same hop and the money would go
|
|
1281
|
+
// to whoever the redirecting server named. The agent should call the final URL.
|
|
1282
|
+
const servedFrom = initialRes.headers.get(FINAL_URL_HEADER) || url;
|
|
1283
|
+
if (new URL(servedFrom).origin !== new URL(url).origin) {
|
|
1284
|
+
return jsonResponse({ status: 402, payment_required: true, payment_made: false,
|
|
1285
|
+
error: `The endpoint redirected to another origin (${new URL(servedFrom).origin}) before asking for payment. Call pay_x402 with that URL directly if you trust it.`,
|
|
1286
|
+
final_url: servedFrom });
|
|
1287
|
+
}
|
|
1150
1288
|
const initialText = await initialRes.text();
|
|
1151
1289
|
let initialBody = null;
|
|
1152
1290
|
try {
|
|
@@ -1190,20 +1328,33 @@ server.tool('pay_x402', 'Handle an x402 payment flow. Fetches a URL, and if the
|
|
|
1190
1328
|
if (typeof option.requiredDecimals === 'number')
|
|
1191
1329
|
assertDeclaredDecimals(option.requiredDecimals, trustedDecimals);
|
|
1192
1330
|
const amount = formatUnits(rawAmount, trustedDecimals);
|
|
1193
|
-
const tokenLabel =
|
|
1331
|
+
const tokenLabel = assetLabel(chainId, tokenAddress); // never the 402's own label
|
|
1194
1332
|
const skip = { 'X-AGW-SKIP-X402': 'true' };
|
|
1333
|
+
assertPayTo(chainId, option.payTo);
|
|
1334
|
+
// Step 3b: Can this asset be priced at all? The cap is in dollars; native
|
|
1335
|
+
// ETH or an arbitrary token is refused unless the operator allowlisted it.
|
|
1336
|
+
const assetPolicy = autopayAssetAllowed(chainId, tokenAddress);
|
|
1337
|
+
if (!assetPolicy.allowed) {
|
|
1338
|
+
return jsonResponse({
|
|
1339
|
+
status: 402, payment_required: true, payment_made: false,
|
|
1340
|
+
required_amount: amount, token: tokenLabel, token_address: tokenAddress || null, network: option.network, chain_id: chainId, pay_to: option.payTo,
|
|
1341
|
+
error: assetPolicy.reason,
|
|
1342
|
+
});
|
|
1343
|
+
}
|
|
1195
1344
|
// Step 4: Hard per-payment cap, ALWAYS applied. If the caller omits max_payment
|
|
1196
1345
|
// we fall back to AGENTWALLET_MAX_AUTOPAY (default "1"). Above the cap a hosted
|
|
1197
1346
|
// wallet can ask its owner instead of refusing: an approval is created, the
|
|
1198
1347
|
// owner gets approve/deny links by email, and the agent retries with the id.
|
|
1199
1348
|
// For upto the cap applies to the MAXIMUM the seller may settle.
|
|
1200
|
-
|
|
1201
|
-
|
|
1349
|
+
// max_payment can only lower the operator's ceiling; an agent-chosen number
|
|
1350
|
+
// above it is ignored and reported, because the agent is what the cap bounds.
|
|
1351
|
+
const { cap: effectiveMax, source: capSource, clamped } = effectiveAutopayCap(max_payment);
|
|
1202
1352
|
let approvalUsed = null;
|
|
1203
1353
|
if (!isWithinCap(rawAmount, trustedDecimals, effectiveMax)) {
|
|
1204
1354
|
const overCap = {
|
|
1205
1355
|
status: 402, payment_required: true, payment_made: false,
|
|
1206
1356
|
required_amount: amount, max_allowed: effectiveMax, cap_source: capSource, token: tokenLabel,
|
|
1357
|
+
...(clamped ? { max_payment_ignored: `max_payment ${max_payment} exceeds the operator ceiling AGENTWALLET_MAX_AUTOPAY=${effectiveMax}; only the operator can raise it.` } : {}),
|
|
1207
1358
|
network: option.network, chain_id: chainId, pay_to: option.payTo, description: option.description,
|
|
1208
1359
|
};
|
|
1209
1360
|
const wantApproval = request_approval ?? (process.env.AGENTWALLET_APPROVALS !== '0');
|
|
@@ -1212,8 +1363,9 @@ server.tool('pay_x402', 'Handle an x402 payment flow. Fetches a URL, and if the
|
|
|
1212
1363
|
if (a?.status !== 'approved' || Number(a.used) === 1) {
|
|
1213
1364
|
return jsonResponse({ ...overCap, approval_id, approval_status: a?.status ?? null, error: `Approval ${approval_id} is ${a?.status ?? 'unknown'}, not approved.${a?.error ? ' ' + a.error : ''}` });
|
|
1214
1365
|
}
|
|
1215
|
-
|
|
1216
|
-
|
|
1366
|
+
const sameAsset = String(a.asset ?? '').toLowerCase() === tokenAddress.toLowerCase();
|
|
1367
|
+
if (!sameAsset || String(a.pay_to || '').toLowerCase() !== option.payTo.toLowerCase() || Number(a.chain_id) !== chainId || BigInt(String(a.value || '0')) < BigInt(rawAmount)) {
|
|
1368
|
+
return jsonResponse({ ...overCap, approval_id, error: 'Approval does not cover this payment (asset, recipient, chain or amount differ).' });
|
|
1217
1369
|
}
|
|
1218
1370
|
approvalUsed = String(approval_id);
|
|
1219
1371
|
}
|
|
@@ -1255,12 +1407,25 @@ server.tool('pay_x402', 'Handle an x402 payment flow. Fetches a URL, and if the
|
|
|
1255
1407
|
let payer = null;
|
|
1256
1408
|
let permit2ApprovalTx = null;
|
|
1257
1409
|
let payerDelegationInfo = null;
|
|
1258
|
-
|
|
1410
|
+
let authorizationReused = false;
|
|
1411
|
+
const cacheKey = [wallet_id, chainId, tokenAddress.toLowerCase(), option.payTo.toLowerCase(), rawAmount, option.scheme, new URL(url).origin + new URL(url).pathname].join('|');
|
|
1412
|
+
const cachedAuth = fresh_authorization ? undefined : signedPayments.get(cacheKey);
|
|
1413
|
+
for (const [k, v] of signedPayments)
|
|
1414
|
+
if (v.until <= Date.now())
|
|
1415
|
+
signedPayments.delete(k);
|
|
1416
|
+
if (cachedAuth && cachedAuth.until > Date.now()) {
|
|
1417
|
+
// Same endpoint, same requirement, still inside the window: re-send the
|
|
1418
|
+
// earlier signature. Its nonce is single-use, so a server that already
|
|
1419
|
+
// settled it cannot settle it again, and one that never did now can.
|
|
1420
|
+
({ headerName, paymentHeader, txHash, authorization, uptoAuth, payer } = cachedAuth);
|
|
1421
|
+
authorizationReused = true;
|
|
1422
|
+
}
|
|
1423
|
+
else if (isUpto) {
|
|
1259
1424
|
payer = await payerAddress(wallet_id);
|
|
1260
1425
|
payerDelegationInfo = await payerDelegation(chainId, payer);
|
|
1261
1426
|
const allowance = decodeUint(await ethCallHex(chainId, tokenAddress, permit2AllowanceCalldata(payer)));
|
|
1262
1427
|
if (allowance < BigInt(rawAmount)) {
|
|
1263
|
-
if (process.env.AGENTWALLET_PERMIT2_AUTO_APPROVE === '1') {
|
|
1428
|
+
if (process.env.AGENTWALLET_PERMIT2_AUTO_APPROVE === '1' && assetPolicy.via === 'stablecoin') {
|
|
1264
1429
|
const r = (await api(`/wallets/${wallet_id}/send`, 'POST', { to: tokenAddress, value: '0', data: permit2ApproveCalldata(), chain_id: chainId }, skip));
|
|
1265
1430
|
permit2ApprovalTx = String(r?.tx_hash || '');
|
|
1266
1431
|
await new Promise(res => setTimeout(res, 4000));
|
|
@@ -1312,6 +1477,12 @@ server.tool('pay_x402', 'Handle an x402 payment flow. Fetches a URL, and if the
|
|
|
1312
1477
|
throw new Error('x402: the payment transaction returned no hash.');
|
|
1313
1478
|
paymentHeader = Buffer.from(JSON.stringify({ x402Version, scheme: option.scheme, network: option.network, payload: { txHash } })).toString('base64');
|
|
1314
1479
|
}
|
|
1480
|
+
if (!authorizationReused) {
|
|
1481
|
+
const until = authorization ? Number(authorization.validBefore) * 1000
|
|
1482
|
+
: uptoAuth ? Number(uptoAuth.deadline) * 1000
|
|
1483
|
+
: Date.now() + maxAuthWindowSeconds() * 1000; // a broadcast transfer: the hash stays valid, re-sending it never pays twice
|
|
1484
|
+
signedPayments.set(cacheKey, { headerName, paymentHeader, txHash, authorization, uptoAuth, payer, until });
|
|
1485
|
+
}
|
|
1315
1486
|
// Step 6: Retry with the payment header and read the settlement receipt.
|
|
1316
1487
|
const retryHeaders = { ...reqHeaders, [headerName]: paymentHeader };
|
|
1317
1488
|
const retryOptions = {
|
|
@@ -1335,6 +1506,11 @@ server.tool('pay_x402', 'Handle an x402 payment flow. Fetches a URL, and if the
|
|
|
1335
1506
|
// A second 402 means the facilitator declined (unfunded payer, expired window, bad domain...). v2 servers
|
|
1336
1507
|
// put the reason in a fresh PAYMENT-REQUIRED header, v1 servers in the body; surface it instead of {}.
|
|
1337
1508
|
let retryError = null;
|
|
1509
|
+
const retryServedFrom = retryRes.headers.get(FINAL_URL_HEADER) || url;
|
|
1510
|
+
const retryCrossOrigin = new URL(retryServedFrom).origin !== new URL(url).origin;
|
|
1511
|
+
if (retryCrossOrigin) {
|
|
1512
|
+
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.`;
|
|
1513
|
+
}
|
|
1338
1514
|
if (retryRes.status === 402) {
|
|
1339
1515
|
const again = parsePaymentRequired(n => retryRes.headers.get(n), retryParsed);
|
|
1340
1516
|
retryError = String(again?.error || retryParsed?.error || 'Payment was not accepted (no reason given).');
|
|
@@ -1347,7 +1523,8 @@ server.tool('pay_x402', 'Handle an x402 payment flow. Fetches a URL, and if the
|
|
|
1347
1523
|
// Paid means the endpoint accepted the payment: a settlement receipt, or a success status after the payment header.
|
|
1348
1524
|
// A 4xx other than 402 (bad body, auth) means the request failed for another reason; the authorization was
|
|
1349
1525
|
// sent but the facilitator normally does not settle a failed request, so it is not reported as paid.
|
|
1350
|
-
payment_made: Boolean(settlement?.success) || (retryRes.status >= 200 && retryRes.status < 300),
|
|
1526
|
+
payment_made: !retryCrossOrigin && (Boolean(settlement?.success) || (retryRes.status >= 200 && retryRes.status < 300)),
|
|
1527
|
+
authorization_reused: authorizationReused,
|
|
1351
1528
|
retry_error: retryError,
|
|
1352
1529
|
payment_method: isUpto ? 'permit2-upto-authorization' : (standardExact ? 'eip3009-authorization' : 'onchain-transfer'),
|
|
1353
1530
|
approval_id: approvalUsed,
|
|
@@ -1361,8 +1538,10 @@ server.tool('pay_x402', 'Handle an x402 payment flow. Fetches a URL, and if the
|
|
|
1361
1538
|
pay_to: option.payTo,
|
|
1362
1539
|
payer,
|
|
1363
1540
|
payer_delegation: payerDelegationInfo,
|
|
1364
|
-
|
|
1365
|
-
|
|
1541
|
+
// txHash is ours (we broadcast it); settlement.transaction is the server's
|
|
1542
|
+
// claim and is surfaced only when it is shaped like a real hash.
|
|
1543
|
+
tx_hash: txHash ?? (looksLikeTxHash(settlement?.transaction) ? settlement?.transaction : null) ?? null,
|
|
1544
|
+
settlement_reported_by_server: settlement,
|
|
1366
1545
|
authorization: authorization ? { nonce: authorization.nonce, valid_before: authorization.validBefore }
|
|
1367
1546
|
: (uptoAuth ? { scheme: 'upto', max_amount: amount, nonce: uptoAuth.nonce, deadline: uptoAuth.deadline, facilitator: uptoAuth.witness.facilitator } : null),
|
|
1368
1547
|
description: option.description,
|
|
@@ -1374,16 +1553,23 @@ server.tool('check_approval', 'Status of a payment approval created when pay_x40
|
|
|
1374
1553
|
'Returns pending, approved, denied or expired. Once approved, call pay_x402 again with approval_id.', { approval_id: z.string().describe('Approval id returned by pay_x402') }, async ({ approval_id }) => jsonResponse(await api(`/approvals/${encodeURIComponent(approval_id)}`, 'GET', undefined, { 'X-AGW-SKIP-X402': 'true' })));
|
|
1375
1554
|
// ─── Tool: approve_permit2 ──────────────────────────────────────
|
|
1376
1555
|
server.tool('approve_permit2', 'One-time ERC-20 approval of a token to the Permit2 contract, needed before paying x402 "upto" endpoints with that token. ' +
|
|
1377
|
-
'A normal on-chain transaction (needs gas).
|
|
1378
|
-
'
|
|
1556
|
+
'A normal on-chain transaction (needs gas). By default approves the maximum, the ecosystem norm, so it never has to be repeated; ' +
|
|
1557
|
+
'pass amount to grant a bounded allowance instead (required under AGENTWALLET_MAX_TX_TOKEN, which refuses a max approval). ' +
|
|
1558
|
+
'Permit2 itself only moves what each signed authorization allows.', {
|
|
1379
1559
|
wallet_id: z.number().int().describe('Wallet ID'),
|
|
1380
1560
|
token: z.string().regex(/^0x[a-fA-F0-9]{40}$/).describe('ERC-20 token address (e.g. USDC on Base)'),
|
|
1381
1561
|
chain_id: z.number().int().describe('Chain ID'),
|
|
1382
|
-
|
|
1562
|
+
amount: z.string().optional().describe('Allowance to grant in human units (e.g. "100"). Omit for unlimited.'),
|
|
1563
|
+
}, async ({ wallet_id, token, chain_id, amount }) => {
|
|
1383
1564
|
if (isSolanaChain(chain_id))
|
|
1384
1565
|
throw new Error('Permit2 is an EVM contract; there is nothing to approve on Solana.');
|
|
1385
|
-
|
|
1386
|
-
|
|
1566
|
+
let data = permit2ApproveCalldata();
|
|
1567
|
+
if (amount !== undefined) {
|
|
1568
|
+
const raw = parseUnits(amount, await resolveTrustedDecimals(chain_id, token));
|
|
1569
|
+
data = '0x095ea7b3' + padAddress(PERMIT2_ADDRESS) + encodeUint256(raw);
|
|
1570
|
+
}
|
|
1571
|
+
const result = (await api(`/wallets/${wallet_id}/send`, 'POST', { to: token, value: '0', data, chain_id }));
|
|
1572
|
+
return jsonResponse({ ...result, token, spender: PERMIT2_ADDRESS, amount: amount ?? 'unlimited', note: 'Permit2 only transfers what a signed authorization allows; the allowance itself moves nothing.' });
|
|
1387
1573
|
});
|
|
1388
1574
|
// ─── Tool: check_token_risk ─────────────────────────────────────
|
|
1389
1575
|
server.tool('check_token_risk', 'Assess an ERC-20 token before approving or swapping it: honeypot, taxes, owner powers, verified source, ' +
|
|
@@ -1447,13 +1633,16 @@ server.tool('create_paywall', 'Create an x402 paywall that charges agents/client
|
|
|
1447
1633
|
amount: z.string().describe('Price in human-readable format (e.g. "0.01" for 0.01 USDC)'),
|
|
1448
1634
|
token_type: z.enum(['erc20', 'spl', 'native']).default('erc20').describe('"erc20" for EVM stablecoins, "spl" for Solana SPL tokens, "native" for ETH/SOL/POL/etc.'),
|
|
1449
1635
|
token_address: z.string().default('').describe('Token contract address (ERC-20 for EVM, SPL mint Base58 for Solana). Required if token_type is "erc20" or "spl". Use get_chains to find stablecoin addresses.'),
|
|
1450
|
-
token_decimals: z.number().int().
|
|
1636
|
+
token_decimals: z.number().int().min(0).max(36).optional().describe('Token decimals. Defaults to the chain native decimals for "native", else 6 (USDC); set it for any other token'),
|
|
1451
1637
|
token_name: z.string().default('USDC').describe('Token display name (e.g. "USDC", "ETH")'),
|
|
1452
1638
|
chain_id: z.number().int().default(8453).describe('Chain ID for payments (8453=Base, 1=Ethereum, etc.)'),
|
|
1453
1639
|
resource_url: z.string().url().describe('URL of the protected resource to serve after payment verification'),
|
|
1454
1640
|
resource_mime: z.string().default('application/json').describe('MIME type of the resource (e.g. "application/json", "text/plain")'),
|
|
1455
1641
|
}, async ({ wallet_id, name, description, amount, token_type, token_address, token_decimals, token_name, chain_id, resource_url, resource_mime }) => {
|
|
1456
|
-
// Convert human-readable amount to raw token units
|
|
1642
|
+
// Convert human-readable amount to raw token units. A native paywall used
|
|
1643
|
+
// to default to 6 decimals and price 0.01 ETH as 10000 wei.
|
|
1644
|
+
if (token_decimals === undefined)
|
|
1645
|
+
token_decimals = token_type === 'native' ? nativeDecimals(chain_id) : (lookupTrustedDecimals(chain_id, token_address) ?? 6);
|
|
1457
1646
|
const rawAmount = parseUnits(amount, token_decimals);
|
|
1458
1647
|
const data = await api('/x402/paywalls', 'POST', {
|
|
1459
1648
|
wallet_id,
|
|
@@ -1478,7 +1667,7 @@ server.tool('create_paywall', 'Create an x402 paywall that charges agents/client
|
|
|
1478
1667
|
server.tool('list_paywalls', 'List all your x402 paywalls. Returns paywall IDs, names, pricing, ' +
|
|
1479
1668
|
'access URLs, payment counts, and revenue totals.', {
|
|
1480
1669
|
page: z.number().int().default(1).describe('Page number'),
|
|
1481
|
-
per_page: z.number().int().default(50).describe('Results per page (max 100)'),
|
|
1670
|
+
per_page: z.number().int().min(1).max(100).default(50).describe('Results per page (max 100)'),
|
|
1482
1671
|
}, async ({ page, per_page }) => {
|
|
1483
1672
|
const data = await api(`/x402/paywalls?page=${page}&per_page=${per_page}`);
|
|
1484
1673
|
return jsonResponse(data);
|
|
@@ -1498,7 +1687,7 @@ server.tool('update_paywall', 'Update an x402 paywall configuration. ' +
|
|
|
1498
1687
|
name: z.string().optional().describe('New paywall name'),
|
|
1499
1688
|
description: z.string().optional().describe('New description'),
|
|
1500
1689
|
amount: z.string().optional().describe('New price in human-readable format (e.g. "0.05")'),
|
|
1501
|
-
token_decimals: z.number().int().optional().describe('Token decimals
|
|
1690
|
+
token_decimals: z.number().int().min(0).max(36).optional().describe('Token decimals. Read from the paywall itself when omitted'),
|
|
1502
1691
|
resource_url: z.string().url().optional().describe('New resource URL'),
|
|
1503
1692
|
resource_mime: z.string().optional().describe('New MIME type'),
|
|
1504
1693
|
is_active: z.boolean().optional().describe('Enable (true) or disable (false) the paywall'),
|
|
@@ -1516,7 +1705,14 @@ server.tool('update_paywall', 'Update an x402 paywall configuration. ' +
|
|
|
1516
1705
|
body.is_active = is_active;
|
|
1517
1706
|
// Convert human-readable amount to raw if provided
|
|
1518
1707
|
if (amount !== undefined) {
|
|
1519
|
-
|
|
1708
|
+
let decimals = token_decimals;
|
|
1709
|
+
if (decimals === undefined) {
|
|
1710
|
+
const existing = (await api(`/x402/paywalls/${paywall_id}`));
|
|
1711
|
+
const d = Number(existing?.token_decimals);
|
|
1712
|
+
if (!Number.isInteger(d) || d < 0 || d > 36)
|
|
1713
|
+
throw new Error('Could not read the paywall decimals; pass token_decimals with the new amount.');
|
|
1714
|
+
decimals = d;
|
|
1715
|
+
}
|
|
1520
1716
|
body.amount = parseUnits(amount, decimals);
|
|
1521
1717
|
}
|
|
1522
1718
|
const data = await api(`/x402/paywalls/${paywall_id}`, 'PUT', body);
|
|
@@ -1534,7 +1730,7 @@ server.tool('get_paywall_payments', 'Get payment history for a specific x402 pay
|
|
|
1534
1730
|
'Returns verified payments with TX hashes, payer addresses, amounts, and timestamps.', {
|
|
1535
1731
|
paywall_id: z.number().int().describe('Paywall ID'),
|
|
1536
1732
|
page: z.number().int().default(1).describe('Page number'),
|
|
1537
|
-
per_page: z.number().int().default(20).describe('Results per page (max 100)'),
|
|
1733
|
+
per_page: z.number().int().min(1).max(100).default(20).describe('Results per page (max 100)'),
|
|
1538
1734
|
}, async ({ paywall_id, page, per_page }) => {
|
|
1539
1735
|
const data = await api(`/x402/paywalls/${paywall_id}/payments?page=${page}&per_page=${per_page}`);
|
|
1540
1736
|
return jsonResponse(data);
|
|
@@ -1563,6 +1759,10 @@ server.tool('wallet_mode', 'Report whether this server is signing locally (self-
|
|
|
1563
1759
|
custody: 'self',
|
|
1564
1760
|
signing: 'Signed in this process. Keys are never sent to AgentWallet or anyone else.',
|
|
1565
1761
|
max_autopay: process.env.AGENTWALLET_MAX_AUTOPAY || '1',
|
|
1762
|
+
autopay_assets: process.env.AGENTWALLET_AUTOPAY_ASSETS || 'registry stablecoins only',
|
|
1763
|
+
x402_max_timeout_seconds: maxAuthWindowSeconds(),
|
|
1764
|
+
per_tx_cap_token: process.env.AGENTWALLET_MAX_TX_TOKEN || 'not set (ERC-20 and SPL transfers are uncapped)',
|
|
1765
|
+
allow_unknown_token_calls: process.env.AGENTWALLET_ALLOW_UNKNOWN_TOKEN_CALLS === '1',
|
|
1566
1766
|
};
|
|
1567
1767
|
if (isLocalMode()) {
|
|
1568
1768
|
let rpc = 'default public endpoint';
|
|
@@ -1572,7 +1772,7 @@ server.tool('wallet_mode', 'Report whether this server is signing locally (self-
|
|
|
1572
1772
|
catch { /* chain has no default; not worth failing the report over */ }
|
|
1573
1773
|
report.evm = {
|
|
1574
1774
|
address: getLocalAddress(),
|
|
1575
|
-
rpc_endpoint: rpc,
|
|
1775
|
+
rpc_endpoint: redactUrl(rpc),
|
|
1576
1776
|
per_tx_cap_native: process.env.AGENTWALLET_MAX_TX_NATIVE || 'not set',
|
|
1577
1777
|
};
|
|
1578
1778
|
}
|
|
@@ -1587,8 +1787,9 @@ server.tool('wallet_mode', 'Report whether this server is signing locally (self-
|
|
|
1587
1787
|
catch { /* fall through to the default label */ }
|
|
1588
1788
|
report.solana = {
|
|
1589
1789
|
address: getSolanaAddress(),
|
|
1590
|
-
rpc_endpoint: rpc,
|
|
1790
|
+
rpc_endpoint: redactUrl(rpc),
|
|
1591
1791
|
per_tx_cap_sol: process.env.AGENTWALLET_MAX_TX_SOL || 'not set',
|
|
1792
|
+
per_tx_cap_token: process.env.AGENTWALLET_MAX_TX_TOKEN || 'not set (SPL transfers are uncapped; AGENTWALLET_MAX_TX_SOL does not cover them)',
|
|
1592
1793
|
};
|
|
1593
1794
|
}
|
|
1594
1795
|
else {
|
|
@@ -1625,6 +1826,7 @@ server.tool('export_wallet_key', 'Explain how to export the private key of a hos
|
|
|
1625
1826
|
});
|
|
1626
1827
|
// ─── Start ──────────────────────────────────────────────────────
|
|
1627
1828
|
async function main() {
|
|
1829
|
+
validateGuardEnv();
|
|
1628
1830
|
const transport = new StdioServerTransport();
|
|
1629
1831
|
await server.connect(transport);
|
|
1630
1832
|
/* stderr, so it never corrupts the stdio JSON-RPC stream. Announcing custody
|
|
@@ -1637,6 +1839,8 @@ async function main() {
|
|
|
1637
1839
|
if (isSolanaLocalMode())
|
|
1638
1840
|
parts.push(`Solana ${getSolanaAddress()}`);
|
|
1639
1841
|
console.error(`AgentWallet MCP: LOCAL signing mode. ${parts.join(', ')}. Keys never leave this machine.`);
|
|
1842
|
+
if (!(process.env.AGENTWALLET_MAX_TX_TOKEN || '').trim())
|
|
1843
|
+
console.error('AgentWallet MCP: warning, AGENTWALLET_MAX_TX_TOKEN is not set; ERC-20 and SPL transfers have no per-transaction ceiling.');
|
|
1640
1844
|
}
|
|
1641
1845
|
catch (e) {
|
|
1642
1846
|
console.error(`AgentWallet MCP: local signing configured but a key could not be loaded: ${e.message}`);
|