agentwallet-mcp 1.13.9 → 1.13.10
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 +6 -4
- package/build/index.js +130 -55
- package/build/local-solana.d.ts +16 -0
- package/build/local-solana.js +58 -39
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -37,7 +37,7 @@ Run `wallet_mode` at any time and the server will tell you which one you are in,
|
|
|
37
37
|
}
|
|
38
38
|
```
|
|
39
39
|
|
|
40
|
-
Use `AGENTWALLET_KEYFILE=/path/to/key` instead if you would rather keep the key out of your shell config. The key is read once, never written to disk, never logged, and never included in an error message.
|
|
40
|
+
Use `AGENTWALLET_KEYFILE=/path/to/key` instead if you would rather keep the key out of your shell config (one or the other: the server refuses to start when both are set). The key is read once, never written to disk, never logged, and never included in an error message.
|
|
41
41
|
|
|
42
42
|
`AGENTWALLET_RPC_<chainId>` (or `AGENTWALLET_RPC_URL` for all chains) points at an endpoint you trust. Without it a public RPC is used, and a public RPC can see which addresses you ask about.
|
|
43
43
|
|
|
@@ -69,7 +69,7 @@ Local signing uses [viem](https://viem.sh) for EVM and `@solana/web3.js` for Sol
|
|
|
69
69
|
`npm audit` reports no advisories as of 1.13.5; the overrides in package.json keep two advisories under `@solana/web3.js`'s jayson closed. Earlier releases carried advisories inside `@modelcontextprotocol/sdk`'s HTTP transport dependencies. This server speaks stdio, so that code never loads, and the SDK is not something this package can patch. Run the audit yourself. Publishing a tree you can inspect is the point.
|
|
70
70
|
|
|
71
71
|
<p align="center">
|
|
72
|
-
<img src="assets/demo.svg" alt="AgentWallet demo, AI agent pays x402 invoice automatically" width="800">
|
|
72
|
+
<img src="https://raw.githubusercontent.com/hifriendbot/agentwallet-mcp/main/assets/demo.svg" alt="AgentWallet demo, AI agent pays x402 invoice automatically" width="800">
|
|
73
73
|
</p>
|
|
74
74
|
|
|
75
75
|
## Features
|
|
@@ -118,7 +118,7 @@ Add to your config:
|
|
|
118
118
|
}
|
|
119
119
|
```
|
|
120
120
|
|
|
121
|
-
> `AGENTWALLET_WALLET_ID` is optional. Set it to enable x402 auto-pay: when you exceed the free tier without a credit card, the MCP server automatically pays for operations with USDC from this wallet.
|
|
121
|
+
> `AGENTWALLET_WALLET_ID` is optional. Set it to enable x402 auto-pay: when you exceed the free tier without a credit card, the MCP server automatically pays for operations with USDC from this wallet. Only the metered routes (creating a wallet, signing, sending) are paid this way, at most `AGENTWALLET_MAX_AUTOPAY_PER_HOUR` times an hour (default 20), and every automatic payment is reported in the tool result under `autopay_payment`. A 402 on any other route is reported, never paid.
|
|
122
122
|
>
|
|
123
123
|
> **Auto-pay safety cap.** `AGENTWALLET_MAX_AUTOPAY` (optional, default `1`) is the most one x402 payment may authorize, in stablecoin units: `1` means one dollar. It prices registry stablecoins only (USDC, USDT, USDbC, DAI on the supported chains, USDC and USDT on Solana). A requirement in the chain's native asset or in any other token is refused, because "1" measured in ETH is a few thousand dollars; list such assets in `AGENTWALLET_AUTOPAY_ASSETS` (comma-separated addresses or mints, or the word `native`) to allow them, and the cap then applies in that asset's own units. Any requirement above the cap is rejected instead of paid, so a malformed or tampered payment requirement cannot drain the wallet. This automatic path settles `exact` requirements only; an `upto` offer from the API is refused before any wallet call, because paying a usage maximum upfront is not what the offer means (use `pay_x402`, which signs a Permit2 authorization for it). The cap is the operator's ceiling: a `max_payment` argument can lower it for one call but never raise it. Raise the variable itself if you genuinely need larger automatic payments (for example `"5"` for up to 5 USDC per call).
|
|
124
124
|
|
|
@@ -337,8 +337,10 @@ Which guards apply depends on who holds the key.
|
|
|
337
337
|
- `AGENTWALLET_MAX_AUTOPAY`: ceiling per x402 auto-payment in stablecoin units (default `1`); `AGENTWALLET_AUTOPAY_ASSETS` opts native or non-stable assets in; `AGENTWALLET_X402_MAX_TIMEOUT` bounds how long a signed authorization stays valid (default 3600 seconds)
|
|
338
338
|
- `AGENTWALLET_SOLANA_RPC_<chainId>` (or `AGENTWALLET_SOLANA_RPC` for all clusters): the RPC's genesis hash is checked against the chain id before anything is sent, and an EVM RPC's `eth_chainId` likewise, so one URL cannot silently serve a different network than the one requested
|
|
339
339
|
- `AGENTWALLET_TOKEN_RISK=0`: skip the GoPlus lookup `approve_token` performs (a third-party call that reveals which token you are about to approve)
|
|
340
|
+
- `AGENTWALLET_CHAIN_ID`: the EVM chain used when a call names none (default 8453)
|
|
341
|
+
- `AGENTWALLET_LEGACY_PAYWALL_ORIGINS`: comma-separated https origins whose legacy-shaped 402 may be settled by an on-chain transfer proved by hash; every other host gets a signed authorization
|
|
340
342
|
|
|
341
|
-
|
|
343
|
+
Every `AGENTWALLET_*` variable is validated at startup: a malformed cap stops the server rather than reading as "no limit", and an unrecognised name (a misspelled cap) stops it too. `wallet_mode` reports every guard with its parsed value. Tool arguments are strict: an unknown argument name (a typo in `max_payment`) is an error, never silently ignored. `pay_x402` signs one live authorization per resource; a second, different requirement for the same resource is refused until the first expires unless `fresh_authorization` is passed. Node.js 22.19 or newer is required.
|
|
342
344
|
|
|
343
345
|
**Either mode**, for x402 paywalls you run through the hosted API:
|
|
344
346
|
|
package/build/index.js
CHANGED
|
@@ -27,15 +27,35 @@ function anyLocalMode() {
|
|
|
27
27
|
return isLocalMode() || isSolanaLocalMode();
|
|
28
28
|
}
|
|
29
29
|
// ─── Configuration ──────────────────────────────────────────────
|
|
30
|
-
const API_BASE = process.env.AGENTWALLET_API_URL || 'https://hifriendbot.com/wp-json/agentwallet/v1';
|
|
31
|
-
const API_USER = process.env.AGENTWALLET_USER || '';
|
|
32
|
-
const API_PASS = process.env.AGENTWALLET_PASS || ''; // WordPress application password
|
|
33
|
-
const X402_WALLET_ID = process.env.AGENTWALLET_WALLET_ID || ''; // Wallet ID for x402 auto-pay
|
|
34
|
-
const DASHBOARD_URL = process.env.AGENTWALLET_DASHBOARD_URL || 'https://hifriendbot.com/wallet/';
|
|
30
|
+
const API_BASE = (process.env.AGENTWALLET_API_URL || 'https://hifriendbot.com/wp-json/agentwallet/v1').trim().replace(/\/+$/, '');
|
|
31
|
+
const API_USER = (process.env.AGENTWALLET_USER || '').trim();
|
|
32
|
+
const API_PASS = (process.env.AGENTWALLET_PASS || '').trim(); // WordPress application password
|
|
33
|
+
const X402_WALLET_ID = (process.env.AGENTWALLET_WALLET_ID || '').trim(); // Wallet ID for x402 auto-pay
|
|
34
|
+
const DASHBOARD_URL = (process.env.AGENTWALLET_DASHBOARD_URL || 'https://hifriendbot.com/wallet/').trim();
|
|
35
35
|
// Basic credentials and 402 auto-pay decisions ride on this URL, so it must be
|
|
36
|
-
// https, or loopback for a local test server.
|
|
37
|
-
|
|
38
|
-
|
|
36
|
+
// https, or loopback for a local test server. The value is parsed, not
|
|
37
|
+
// pattern-matched: a leading space defeated the old regex while fetch still
|
|
38
|
+
// sent the credentials in plaintext (2026-10-04 round 4).
|
|
39
|
+
{
|
|
40
|
+
let u;
|
|
41
|
+
try {
|
|
42
|
+
u = new URL(API_BASE);
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
throw new Error(`AGENTWALLET_API_URL is not a URL: "${API_BASE}".`);
|
|
46
|
+
}
|
|
47
|
+
const loopback = u.hostname === 'localhost' || u.hostname === '127.0.0.1' || u.hostname === '[::1]';
|
|
48
|
+
if (u.username || u.password)
|
|
49
|
+
throw new Error('AGENTWALLET_API_URL must not carry credentials; use AGENTWALLET_USER and AGENTWALLET_PASS.');
|
|
50
|
+
if (u.protocol !== 'https:' && !(u.protocol === 'http:' && loopback)) {
|
|
51
|
+
throw new Error(`AGENTWALLET_API_URL must be https (got ${u.protocol}//${u.host}); plain http is allowed only for localhost.`);
|
|
52
|
+
}
|
|
53
|
+
if (API_USER.includes(':'))
|
|
54
|
+
throw new Error('AGENTWALLET_USER must not contain ":" (the Basic auth separator).');
|
|
55
|
+
if ((API_USER === '') !== (API_PASS === ''))
|
|
56
|
+
console.error('AgentWallet MCP: warning, only one of AGENTWALLET_USER / AGENTWALLET_PASS is set; hosted calls will carry no credentials.');
|
|
57
|
+
if (X402_WALLET_ID && !/^\d+$/.test(X402_WALLET_ID))
|
|
58
|
+
throw new Error(`AGENTWALLET_WALLET_ID must be a wallet id (digits only), got "${X402_WALLET_ID}".`);
|
|
39
59
|
}
|
|
40
60
|
/* ── Local-mode routing ──────────────────────────────────────────
|
|
41
61
|
Translates the REST paths the tools already speak into in-process signing
|
|
@@ -288,7 +308,7 @@ async function api(path, method = 'GET', body, extraHeaders) {
|
|
|
288
308
|
function autopayRouteAllowed(path, method) {
|
|
289
309
|
return method === 'POST' && /^\/wallets(\/\d+\/(sign|send))?(\?|$)/.test(path);
|
|
290
310
|
}
|
|
291
|
-
const AUTOPAY_PER_HOUR = (() => { const
|
|
311
|
+
const AUTOPAY_PER_HOUR = (() => { const raw = (process.env.AGENTWALLET_MAX_AUTOPAY_PER_HOUR ?? '20').trim(); return /^\d+$/.test(raw) ? Number(raw) : 20; })(); // validated in validateGuardEnv
|
|
292
312
|
const autopayLog = [];
|
|
293
313
|
/** Read a body no larger than maxChars, refusing early on a declared length and mid-stream on a real one. */
|
|
294
314
|
async function readBounded(res, maxChars, who) {
|
|
@@ -552,7 +572,8 @@ const PAYWALL_MAX_CHARS = 4 * 1024 * 1024; // a paywall's answer, before and aft
|
|
|
552
572
|
and the client's log (2026-10-04 audit). */
|
|
553
573
|
function redactRpcSecrets(err) {
|
|
554
574
|
const e = err;
|
|
555
|
-
|
|
575
|
+
// A rejection that is not an Error (web3.js rejects with a raw {InstructionError} object) used to read "[object Object]".
|
|
576
|
+
let msg = e?.shortMessage ? [e.shortMessage, e.details].filter(Boolean).join(' ') : (e?.message !== undefined ? String(e.message) : (err && typeof err === 'object' ? JSON.stringify(err) : String(err)));
|
|
556
577
|
msg = msg.split(/\r?\n/).filter(l => !/^\s*(URL|Request body|Request Arguments|Raw Call Arguments)\s*:/i.test(l)).join('\n').trim();
|
|
557
578
|
return msg.replace(/[a-z][a-z0-9+.-]*:\/\/[^\s"'<>)]+/gi, (u) => redactUrl(u)); // brackets stay in: IPv6 hosts
|
|
558
579
|
}
|
|
@@ -600,6 +621,48 @@ function validateGuardEnv() {
|
|
|
600
621
|
if (t && (!/^\d+$/.test(t) || Number(t) < 60 || Number(t) > 31_536_000)) {
|
|
601
622
|
throw new Error(`AGENTWALLET_X402_MAX_TIMEOUT must be a whole number of seconds between 60 and 31536000, got "${t}".`);
|
|
602
623
|
}
|
|
624
|
+
const perHour = process.env.AGENTWALLET_MAX_AUTOPAY_PER_HOUR;
|
|
625
|
+
if (perHour !== undefined && !/^\d+$/.test(perHour.trim())) {
|
|
626
|
+
throw new Error(`AGENTWALLET_MAX_AUTOPAY_PER_HOUR must be a whole number (0 disables internal auto-pay), got "${perHour}".`);
|
|
627
|
+
}
|
|
628
|
+
for (const o of (process.env.AGENTWALLET_LEGACY_PAYWALL_ORIGINS || '').split(',').map(x => x.trim()).filter(Boolean)) {
|
|
629
|
+
let u = null;
|
|
630
|
+
try {
|
|
631
|
+
u = new URL(o);
|
|
632
|
+
}
|
|
633
|
+
catch {
|
|
634
|
+
u = null;
|
|
635
|
+
}
|
|
636
|
+
if (!u || u.protocol !== 'https:' || u.origin !== o.replace(/\/+$/, '')) {
|
|
637
|
+
throw new Error(`AGENTWALLET_LEGACY_PAYWALL_ORIGINS entry "${o}" is not an https origin (scheme and host only, like https://shop.example).`);
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
// Two sources for one key family is ambiguous; refuse rather than pick one silently.
|
|
641
|
+
if ((process.env.AGENTWALLET_PRIVATE_KEY || '').trim() && (process.env.AGENTWALLET_KEYFILE || '').trim()) {
|
|
642
|
+
throw new Error('Both AGENTWALLET_PRIVATE_KEY and AGENTWALLET_KEYFILE are set; keep one.');
|
|
643
|
+
}
|
|
644
|
+
if ((process.env.AGENTWALLET_SOLANA_KEY || '').trim() && (process.env.AGENTWALLET_SOLANA_KEYFILE || '').trim()) {
|
|
645
|
+
throw new Error('Both AGENTWALLET_SOLANA_KEY and AGENTWALLET_SOLANA_KEYFILE are set; keep one.');
|
|
646
|
+
}
|
|
647
|
+
// An unrecognised AGENTWALLET_* name is almost always a misspelled cap, which
|
|
648
|
+
// would run uncapped without a word (round 4). Refuse to start instead.
|
|
649
|
+
for (const k of Object.keys(process.env)) {
|
|
650
|
+
if (!k.startsWith('AGENTWALLET_'))
|
|
651
|
+
continue;
|
|
652
|
+
if (KNOWN_ENV.has(k) || /^AGENTWALLET_(RPC|SOLANA_RPC)_\d+$/.test(k))
|
|
653
|
+
continue;
|
|
654
|
+
throw new Error(`Unknown environment variable ${k}. AgentWallet refuses to start on an unrecognised AGENTWALLET_* name so that a misspelled cap cannot run uncapped. Known names: ${[...KNOWN_ENV].join(', ')}, AGENTWALLET_RPC_<chainId>, AGENTWALLET_SOLANA_RPC_<chainId>.`);
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
const KNOWN_ENV = new Set(["AGENTWALLET_ALLOW_UNKNOWN_TOKEN_CALLS", "AGENTWALLET_API_URL", "AGENTWALLET_APPROVALS", "AGENTWALLET_AUTOPAY_ASSETS", "AGENTWALLET_CHAIN_ID", "AGENTWALLET_DASHBOARD_URL", "AGENTWALLET_KEYFILE", "AGENTWALLET_LEGACY_PAYWALL_ORIGINS", "AGENTWALLET_MAX_AUTOPAY", "AGENTWALLET_MAX_AUTOPAY_PER_HOUR", "AGENTWALLET_MAX_TX_NATIVE", "AGENTWALLET_MAX_TX_SOL", "AGENTWALLET_MAX_TX_TOKEN", "AGENTWALLET_PASS", "AGENTWALLET_PERMIT2_AUTO_APPROVE", "AGENTWALLET_PRIVATE_KEY", "AGENTWALLET_RPC_URL", "AGENTWALLET_SOLANA_KEY", "AGENTWALLET_SOLANA_KEYFILE", "AGENTWALLET_SOLANA_RPC", "AGENTWALLET_TOKEN_DECIMALS", "AGENTWALLET_TOKEN_RISK", "AGENTWALLET_USER", "AGENTWALLET_WALLET_ID", "AGENTWALLET_X402_MAX_TIMEOUT"]);
|
|
658
|
+
/**
|
|
659
|
+
* Register a tool whose arguments are strict: an unknown key (max_payemnt for
|
|
660
|
+
* max_payment) is an error that names it, not a silently dropped guard. The
|
|
661
|
+
* advertised schema always said additionalProperties: false; now the runtime
|
|
662
|
+
* agrees (round 4).
|
|
663
|
+
*/
|
|
664
|
+
function tool(name, description, shape, cb) {
|
|
665
|
+
return server.registerTool(name, { description, inputSchema: z.object(shape).strict() }, cb);
|
|
603
666
|
}
|
|
604
667
|
// ─── EVM Helpers ──────────────────────────────────────────────────
|
|
605
668
|
/**
|
|
@@ -679,10 +742,10 @@ function resolveChainId(network) {
|
|
|
679
742
|
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');
|
|
680
743
|
const server = new McpServer({
|
|
681
744
|
name: 'agentwallet',
|
|
682
|
-
version: '1.13.
|
|
745
|
+
version: '1.13.10',
|
|
683
746
|
}, {
|
|
684
747
|
instructions: `AgentWallet gives AI agents their own blockchain wallets. ${anyLocalMode()
|
|
685
|
-
? '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
|
|
748
|
+
? 'This server runs in LOCAL (self-custody) mode: the operator supplied the key, signing happens in this process, and the only spend guards are the environment caps (AGENTWALLET_MAX_TX_NATIVE / _TOKEN / _SOL per transaction, AGENTWALLET_MAX_AUTOPAY and AGENTWALLET_AUTOPAY_ASSETS for x402, AGENTWALLET_X402_MAX_TIMEOUT for authorization windows). There is no server-side pause, daily limit or approval email; pause_wallet, unpause_wallet and delete_wallet are refused.'
|
|
686
749
|
: 'Private keys are encrypted server-side and never exposed: agents sign and broadcast transactions without ever touching raw keys.'}
|
|
687
750
|
|
|
688
751
|
## Getting Started
|
|
@@ -738,9 +801,7 @@ Create paywalls to charge other agents for accessing your resources:
|
|
|
738
801
|
|
|
739
802
|
## Wallet Security
|
|
740
803
|
- ${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.'}
|
|
741
|
-
- Use unpause_wallet to resume operations.
|
|
742
|
-
- Use delete_wallet to permanently disable a wallet.
|
|
743
|
-
- ${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.'}
|
|
804
|
+
${anyLocalMode() ? '' : '- Use unpause_wallet to resume operations.\n- Use delete_wallet to permanently disable a wallet.\n'}- ${anyLocalMode() ? 'Spend limits are the AGENTWALLET_MAX_TX_NATIVE / _TOKEN / _SOL caps the operator set (unset means no limit) and AGENTWALLET_MAX_AUTOPAY for x402 (default 1); run wallet_mode to see them.' : 'Wallets have server-enforced spending limits.'}
|
|
744
805
|
|
|
745
806
|
## Tool Selection Guide
|
|
746
807
|
| Goal | Tool |
|
|
@@ -760,7 +821,7 @@ Create paywalls to charge other agents for accessing your resources:
|
|
|
760
821
|
| Check usage/billing | get_usage |`,
|
|
761
822
|
});
|
|
762
823
|
// ─── Tool: create_wallet ─────────────────────────────────────────
|
|
763
|
-
|
|
824
|
+
tool('create_wallet', 'Create a new EVM or Solana wallet. Returns the wallet ID and address. ' +
|
|
764
825
|
(anyLocalMode() ? 'In local mode this reports the key already configured; no wallet is created remotely.' : 'Private key is encrypted server-side and never exposed.'), {
|
|
765
826
|
label: z.string().default('').describe('Friendly name for the wallet'),
|
|
766
827
|
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)'),
|
|
@@ -769,13 +830,13 @@ server.tool('create_wallet', 'Create a new EVM or Solana wallet. Returns the wal
|
|
|
769
830
|
return jsonResponse(data);
|
|
770
831
|
});
|
|
771
832
|
// ─── Tool: list_wallets ──────────────────────────────────────────
|
|
772
|
-
|
|
833
|
+
tool('list_wallets', 'List all wallets owned by the authenticated user. ' +
|
|
773
834
|
'Returns wallet IDs, addresses, labels, chain IDs, and status.', {}, async () => {
|
|
774
835
|
const data = await api('/wallets');
|
|
775
836
|
return jsonResponse(data);
|
|
776
837
|
});
|
|
777
838
|
// ─── Tool: get_wallet ────────────────────────────────────────────
|
|
778
|
-
|
|
839
|
+
tool('get_wallet', 'Get details for a specific wallet by ID. ' +
|
|
779
840
|
'Returns address, label, chain, spending limits, and pause status.', {
|
|
780
841
|
wallet_id: z.number().int().describe('Wallet ID'),
|
|
781
842
|
}, async ({ wallet_id }) => {
|
|
@@ -783,7 +844,7 @@ server.tool('get_wallet', 'Get details for a specific wallet by ID. ' +
|
|
|
783
844
|
return jsonResponse(data);
|
|
784
845
|
});
|
|
785
846
|
// ─── Tool: get_balance ───────────────────────────────────────────
|
|
786
|
-
|
|
847
|
+
tool('get_balance', 'Get the native token balance for a wallet on a specific chain. ' +
|
|
787
848
|
'Returns balance in both wei (or lamports for Solana) and human-readable format.', {
|
|
788
849
|
wallet_id: z.number().int().describe('Wallet ID'),
|
|
789
850
|
chain_id: z.number().int().optional().describe('Chain ID to check (defaults to wallet\'s default chain)'),
|
|
@@ -793,7 +854,7 @@ server.tool('get_balance', 'Get the native token balance for a wallet on a speci
|
|
|
793
854
|
return jsonResponse(data);
|
|
794
855
|
});
|
|
795
856
|
// ─── Tool: sign_transaction ──────────────────────────────────────
|
|
796
|
-
|
|
857
|
+
tool('sign_transaction', 'Sign a transaction with a wallet\'s private key. ' +
|
|
797
858
|
'For EVM: returns signed raw transaction hex. For Solana: returns base64 signed transaction. ' +
|
|
798
859
|
'Does NOT broadcast — use send_transaction for sign + broadcast.', {
|
|
799
860
|
wallet_id: z.number().int().describe('Wallet ID'),
|
|
@@ -839,7 +900,7 @@ server.tool('sign_transaction', 'Sign a transaction with a wallet\'s private key
|
|
|
839
900
|
return jsonResponse(result);
|
|
840
901
|
});
|
|
841
902
|
// ─── Tool: send_transaction ──────────────────────────────────────
|
|
842
|
-
|
|
903
|
+
tool('send_transaction', 'Sign and broadcast a transaction. ' +
|
|
843
904
|
'Returns the transaction hash (EVM) or signature (Solana) on success. ' +
|
|
844
905
|
(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.'), {
|
|
845
906
|
wallet_id: z.number().int().describe('Wallet ID'),
|
|
@@ -885,7 +946,7 @@ server.tool('send_transaction', 'Sign and broadcast a transaction. ' +
|
|
|
885
946
|
return jsonResponse(result);
|
|
886
947
|
});
|
|
887
948
|
// ─── Tool: transfer ─────────────────────────────────────────────
|
|
888
|
-
|
|
949
|
+
tool('transfer', 'Send native tokens (ETH, AVAX, BNB, POL, PLS, SOL) to an address. ' +
|
|
889
950
|
'Specify the amount in human-readable format (e.g. "0.1" for 0.1 ETH). ' +
|
|
890
951
|
'The amount is converted to wei/lamports automatically. Signs and broadcasts the transaction.', {
|
|
891
952
|
wallet_id: z.number().int().describe('Wallet ID to send from'),
|
|
@@ -916,7 +977,7 @@ server.tool('transfer', 'Send native tokens (ETH, AVAX, BNB, POL, PLS, SOL) to a
|
|
|
916
977
|
});
|
|
917
978
|
});
|
|
918
979
|
// ─── Tool: get_token_balance ────────────────────────────────────
|
|
919
|
-
|
|
980
|
+
tool('get_token_balance', 'Get the ERC-20 or SPL token balance for a wallet on a specific chain. ' +
|
|
920
981
|
'Returns the raw balance and human-readable balance. ' +
|
|
921
982
|
'Use get_chains to find stablecoin addresses for each chain.', {
|
|
922
983
|
wallet_id: z.number().int().describe('Wallet ID to check'),
|
|
@@ -960,7 +1021,7 @@ server.tool('get_token_balance', 'Get the ERC-20 or SPL token balance for a wall
|
|
|
960
1021
|
});
|
|
961
1022
|
});
|
|
962
1023
|
// ─── Tool: transfer_token ───────────────────────────────────────
|
|
963
|
-
|
|
1024
|
+
tool('transfer_token', 'Send ERC-20 tokens (EVM) or SPL tokens (Solana) to an address. ' +
|
|
964
1025
|
'Specify the amount in human-readable format (e.g. "100" for 100 USDC). ' +
|
|
965
1026
|
'Signs and broadcasts the transaction. Use get_chains to find stablecoin addresses.', {
|
|
966
1027
|
wallet_id: z.number().int().describe('Wallet ID to send from'),
|
|
@@ -1009,7 +1070,7 @@ server.tool('transfer_token', 'Send ERC-20 tokens (EVM) or SPL tokens (Solana) t
|
|
|
1009
1070
|
});
|
|
1010
1071
|
});
|
|
1011
1072
|
// ─── Tool: call_contract ────────────────────────────────────────
|
|
1012
|
-
|
|
1073
|
+
tool('call_contract', 'Execute a read-only call against a smart contract (eth_call). ' +
|
|
1013
1074
|
'Returns the raw hex result. Does not cost gas or modify state. ' +
|
|
1014
1075
|
'Useful for reading on-chain data like token balances, prices, positions.', {
|
|
1015
1076
|
chain_id: z.number().int().describe('Chain ID'),
|
|
@@ -1023,7 +1084,7 @@ server.tool('call_contract', 'Execute a read-only call against a smart contract
|
|
|
1023
1084
|
return jsonResponse(result);
|
|
1024
1085
|
});
|
|
1025
1086
|
// ─── Tool: approve_token ────────────────────────────────────────
|
|
1026
|
-
|
|
1087
|
+
tool('approve_token', 'Approve a spender contract to transfer ERC-20 tokens on your behalf. ' +
|
|
1027
1088
|
'Required before interacting with any DeFi protocol (DEXs, lending, etc.). ' +
|
|
1028
1089
|
'Use amount "max" for unlimited approval, or specify an exact amount.', {
|
|
1029
1090
|
wallet_id: z.number().int().describe('Wallet ID'),
|
|
@@ -1071,7 +1132,7 @@ server.tool('approve_token', 'Approve a spender contract to transfer ERC-20 toke
|
|
|
1071
1132
|
});
|
|
1072
1133
|
});
|
|
1073
1134
|
// ─── Tool: get_allowance ────────────────────────────────────────
|
|
1074
|
-
|
|
1135
|
+
tool('get_allowance', 'Check how many ERC-20 tokens a spender is approved to transfer. ' +
|
|
1075
1136
|
'Returns the allowance in both raw and human-readable format. ' +
|
|
1076
1137
|
'Use this to check if an approval is needed before a DeFi transaction.', {
|
|
1077
1138
|
wallet_id: z.number().int().describe('Wallet ID (used to determine the owner address)'),
|
|
@@ -1107,7 +1168,7 @@ server.tool('get_allowance', 'Check how many ERC-20 tokens a spender is approved
|
|
|
1107
1168
|
});
|
|
1108
1169
|
// ─── WETH / Wrapped Native Token addresses ──────────────────────
|
|
1109
1170
|
// ─── Tool: wrap_eth ─────────────────────────────────────────────
|
|
1110
|
-
|
|
1171
|
+
tool('wrap_eth', 'Wrap native tokens (ETH, AVAX, BNB, POL, PLS) into their wrapped ERC-20 version (WETH, WAVAX, etc.). ' +
|
|
1111
1172
|
'Required for most DeFi protocols that use ERC-20 tokens instead of raw native tokens. ' +
|
|
1112
1173
|
'Specify amount in human-readable format (e.g. "0.5" for 0.5 ETH).', {
|
|
1113
1174
|
wallet_id: z.number().int().describe('Wallet ID'),
|
|
@@ -1137,7 +1198,7 @@ server.tool('wrap_eth', 'Wrap native tokens (ETH, AVAX, BNB, POL, PLS) into thei
|
|
|
1137
1198
|
});
|
|
1138
1199
|
});
|
|
1139
1200
|
// ─── Tool: unwrap_eth ───────────────────────────────────────────
|
|
1140
|
-
|
|
1201
|
+
tool('unwrap_eth', 'Unwrap wrapped tokens (WETH, WAVAX, WBNB, etc.) back to native tokens. ' +
|
|
1141
1202
|
'Specify amount in human-readable format (e.g. "0.5" for 0.5 WETH).', {
|
|
1142
1203
|
wallet_id: z.number().int().describe('Wallet ID'),
|
|
1143
1204
|
amount: z.string().describe('Amount to unwrap in human-readable format (e.g. "0.5")'),
|
|
@@ -1196,7 +1257,7 @@ function decodeAbiString(hex) {
|
|
|
1196
1257
|
return '';
|
|
1197
1258
|
}
|
|
1198
1259
|
}
|
|
1199
|
-
|
|
1260
|
+
tool('get_token_info', 'Get the name, symbol, and decimals of any ERC-20 token by its contract address. ' +
|
|
1200
1261
|
'Useful for discovering token details before transfers or approvals.', {
|
|
1201
1262
|
token: z.string().regex(/^0x[a-fA-F0-9]{40}$/).describe('ERC-20 token contract address'),
|
|
1202
1263
|
chain_id: z.number().int().describe('Chain ID'),
|
|
@@ -1336,7 +1397,7 @@ async function signPermit2(walletId, chainId, auth, approvalId) {
|
|
|
1336
1397
|
return sig;
|
|
1337
1398
|
}
|
|
1338
1399
|
// ─── Tool: pay_x402 ─────────────────────────────────────────────
|
|
1339
|
-
|
|
1400
|
+
tool('pay_x402', 'Handle an x402 payment flow. Fetches a URL, and if the server returns HTTP 402 Payment Required, ' +
|
|
1340
1401
|
'parses the payment requirements (v1 body or v2 PAYMENT-REQUIRED header), signs an EIP-3009 ' +
|
|
1341
1402
|
'TransferWithAuthorization for the "exact" scheme (no gas, nothing broadcast by the payer), and retries ' +
|
|
1342
1403
|
'the request with the payment header. AgentWallet paywalls are paid by on-chain transfer instead. ' +
|
|
@@ -1360,6 +1421,9 @@ server.tool('pay_x402', 'Handle an x402 payment flow. Fetches a URL, and if the
|
|
|
1360
1421
|
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 ' +
|
|
1361
1422
|
'within the validity window re-sends the earlier signature (same nonce, so it cannot settle twice) instead of paying again.'),
|
|
1362
1423
|
}, async ({ url, wallet_id, method, headers: headersJson, body: reqBody, max_payment, prefer_chain, request_approval, approval_id, fresh_authorization }) => {
|
|
1424
|
+
if (max_payment !== undefined && !/^\d+(\.\d+)?$/.test(String(max_payment).trim())) {
|
|
1425
|
+
throw new Error(`max_payment must be a decimal number of stablecoin units, got "${serverText(max_payment)}"; nothing was fetched.`);
|
|
1426
|
+
}
|
|
1363
1427
|
// Build request headers
|
|
1364
1428
|
const reqHeaders = { Accept: 'application/json' };
|
|
1365
1429
|
if (headersJson) {
|
|
@@ -1801,10 +1865,10 @@ server.tool('pay_x402', 'Handle an x402 payment flow. Fetches a URL, and if the
|
|
|
1801
1865
|
});
|
|
1802
1866
|
});
|
|
1803
1867
|
// ─── Tool: check_approval ───────────────────────────────────────
|
|
1804
|
-
|
|
1868
|
+
tool('check_approval', 'Status of a payment approval created when pay_x402 exceeded the cap on a hosted wallet. ' +
|
|
1805
1869
|
'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' })));
|
|
1806
1870
|
// ─── Tool: approve_permit2 ──────────────────────────────────────
|
|
1807
|
-
|
|
1871
|
+
tool('approve_permit2', 'One-time ERC-20 approval of a token to the Permit2 contract, needed before paying x402 "upto" endpoints with that token. ' +
|
|
1808
1872
|
'A normal on-chain transaction (needs gas). By default approves the maximum, the ecosystem norm, so it never has to be repeated; ' +
|
|
1809
1873
|
'pass amount to grant a bounded allowance instead (required under AGENTWALLET_MAX_TX_TOKEN, which refuses a max approval). ' +
|
|
1810
1874
|
'Permit2 itself only moves what each signed authorization allows.', {
|
|
@@ -1824,20 +1888,20 @@ server.tool('approve_permit2', 'One-time ERC-20 approval of a token to the Permi
|
|
|
1824
1888
|
return jsonResponse({ ...result, token, spender: permit2Address(chain_id), amount: amount ?? 'unlimited', note: 'Permit2 only transfers what a signed authorization allows; the allowance itself moves nothing.' });
|
|
1825
1889
|
});
|
|
1826
1890
|
// ─── Tool: check_token_risk ─────────────────────────────────────
|
|
1827
|
-
|
|
1891
|
+
tool('check_token_risk', 'Assess an ERC-20 token before approving or swapping it: honeypot, taxes, owner powers, verified source, ' +
|
|
1828
1892
|
'holder concentration, DEX liquidity. Uses GoPlus Security (free, no key) with an on-chain fallback. ' +
|
|
1829
1893
|
'A warning for the caller to weigh, never a block.', {
|
|
1830
1894
|
token: z.string().regex(/^0x[a-fA-F0-9]{40}$/).describe('ERC-20 token contract address'),
|
|
1831
1895
|
chain_id: z.number().int().describe('Chain ID'),
|
|
1832
1896
|
}, async ({ token, chain_id }) => jsonResponse({ token, chain_id, risk: await assessTokenRisk(chain_id, token, (to, data) => ethCallHex(chain_id, to, data)) }));
|
|
1833
1897
|
// ─── Tool: get_usage ─────────────────────────────────────────────
|
|
1834
|
-
|
|
1898
|
+
tool('get_usage', 'Get the current month\'s usage statistics. ' +
|
|
1835
1899
|
'Returns operations count, tier info, remaining quota, and fees.', {}, async () => {
|
|
1836
1900
|
const data = await api('/usage');
|
|
1837
1901
|
return jsonResponse(data);
|
|
1838
1902
|
});
|
|
1839
1903
|
// ─── Tool: buy_verification_credits ─────────────────────────────
|
|
1840
|
-
|
|
1904
|
+
tool('buy_verification_credits', 'Buy x402 verification credits with USDC on-chain. ' +
|
|
1841
1905
|
'Paywall owners need credits to process verifications beyond the free tier (1,000/month) ' +
|
|
1842
1906
|
'when they don\'t have Stripe billing configured. ' +
|
|
1843
1907
|
'Returns 402 payment instructions — pay on-chain and retry with proof.', {
|
|
@@ -1848,27 +1912,27 @@ server.tool('buy_verification_credits', 'Buy x402 verification credits with USDC
|
|
|
1848
1912
|
return jsonResponse(data);
|
|
1849
1913
|
});
|
|
1850
1914
|
// ─── Tool: pause_wallet ──────────────────────────────────────────
|
|
1851
|
-
|
|
1915
|
+
tool('pause_wallet', 'Emergency pause a wallet. No transactions can be signed while paused.', {
|
|
1852
1916
|
wallet_id: z.number().int().describe('Wallet ID to pause'),
|
|
1853
1917
|
}, async ({ wallet_id }) => {
|
|
1854
1918
|
const data = await api(`/wallets/${wallet_id}/pause`, 'POST');
|
|
1855
1919
|
return jsonResponse(data);
|
|
1856
1920
|
});
|
|
1857
1921
|
// ─── Tool: unpause_wallet ────────────────────────────────────────
|
|
1858
|
-
|
|
1922
|
+
tool('unpause_wallet', 'Resume a paused wallet so transactions can be signed again.', {
|
|
1859
1923
|
wallet_id: z.number().int().describe('Wallet ID to unpause'),
|
|
1860
1924
|
}, async ({ wallet_id }) => {
|
|
1861
1925
|
const data = await api(`/wallets/${wallet_id}/unpause`, 'POST');
|
|
1862
1926
|
return jsonResponse(data);
|
|
1863
1927
|
});
|
|
1864
1928
|
// ─── Tool: get_chains ────────────────────────────────────────────
|
|
1865
|
-
|
|
1929
|
+
tool('get_chains', 'List all supported chains (EVM + Solana) with their chain IDs, native tokens, ' +
|
|
1866
1930
|
'stablecoins, and RPC configuration status.', {}, async () => {
|
|
1867
1931
|
const data = await api('/chains');
|
|
1868
1932
|
return jsonResponse(data);
|
|
1869
1933
|
});
|
|
1870
1934
|
// ─── Tool: delete_wallet ─────────────────────────────────────────
|
|
1871
|
-
|
|
1935
|
+
tool('delete_wallet', 'Delete (soft-delete) a wallet. The wallet will no longer appear in listings ' +
|
|
1872
1936
|
'and cannot be used for transactions. The wallet is read first and the delete names its address, ' +
|
|
1873
1937
|
'so a wallet that cannot be read is never deleted.', {
|
|
1874
1938
|
wallet_id: z.number().int().describe('Wallet ID to delete'),
|
|
@@ -1883,7 +1947,7 @@ server.tool('delete_wallet', 'Delete (soft-delete) a wallet. The wallet will no
|
|
|
1883
1947
|
return jsonResponse(data);
|
|
1884
1948
|
});
|
|
1885
1949
|
// ─── Tool: create_paywall ────────────────────────────────────────
|
|
1886
|
-
|
|
1950
|
+
tool('create_paywall', 'Create an x402 paywall that charges agents/clients for accessing a resource. ' +
|
|
1887
1951
|
'Returns a public access URL that returns HTTP 402 until paid. ' +
|
|
1888
1952
|
'Agents pay on-chain, then retry with proof to get the content.', {
|
|
1889
1953
|
wallet_id: z.number().int().describe('Wallet ID to receive payments'),
|
|
@@ -1923,7 +1987,7 @@ server.tool('create_paywall', 'Create an x402 paywall that charges agents/client
|
|
|
1923
1987
|
});
|
|
1924
1988
|
});
|
|
1925
1989
|
// ─── Tool: list_paywalls ────────────────────────────────────────
|
|
1926
|
-
|
|
1990
|
+
tool('list_paywalls', 'List all your x402 paywalls. Returns paywall IDs, names, pricing, ' +
|
|
1927
1991
|
'access URLs, payment counts, and revenue totals.', {
|
|
1928
1992
|
page: z.number().int().default(1).describe('Page number'),
|
|
1929
1993
|
per_page: z.number().int().min(1).max(100).default(50).describe('Results per page (max 100)'),
|
|
@@ -1932,7 +1996,7 @@ server.tool('list_paywalls', 'List all your x402 paywalls. Returns paywall IDs,
|
|
|
1932
1996
|
return jsonResponse(data);
|
|
1933
1997
|
});
|
|
1934
1998
|
// ─── Tool: get_paywall ──────────────────────────────────────────
|
|
1935
|
-
|
|
1999
|
+
tool('get_paywall', 'Get details for a specific x402 paywall by ID. ' +
|
|
1936
2000
|
'Returns pricing, access URL, payment stats, and configuration.', {
|
|
1937
2001
|
paywall_id: z.number().int().describe('Paywall ID'),
|
|
1938
2002
|
}, async ({ paywall_id }) => {
|
|
@@ -1940,7 +2004,7 @@ server.tool('get_paywall', 'Get details for a specific x402 paywall by ID. ' +
|
|
|
1940
2004
|
return jsonResponse(data);
|
|
1941
2005
|
});
|
|
1942
2006
|
// ─── Tool: update_paywall ───────────────────────────────────────
|
|
1943
|
-
|
|
2007
|
+
tool('update_paywall', 'Update an x402 paywall configuration. ' +
|
|
1944
2008
|
'Can change price, resource URL, active status, or any other field.', {
|
|
1945
2009
|
paywall_id: z.number().int().describe('Paywall ID to update'),
|
|
1946
2010
|
name: z.string().optional().describe('New paywall name'),
|
|
@@ -1978,14 +2042,14 @@ server.tool('update_paywall', 'Update an x402 paywall configuration. ' +
|
|
|
1978
2042
|
return jsonResponse(data);
|
|
1979
2043
|
});
|
|
1980
2044
|
// ─── Tool: delete_paywall ───────────────────────────────────────
|
|
1981
|
-
|
|
2045
|
+
tool('delete_paywall', 'Delete an x402 paywall. The access URL will return 404 after deletion.', {
|
|
1982
2046
|
paywall_id: z.number().int().describe('Paywall ID to delete'),
|
|
1983
2047
|
}, async ({ paywall_id }) => {
|
|
1984
2048
|
const data = await api(`/x402/paywalls/${paywall_id}`, 'DELETE');
|
|
1985
2049
|
return jsonResponse(data);
|
|
1986
2050
|
});
|
|
1987
2051
|
// ─── Tool: get_paywall_payments ─────────────────────────────────
|
|
1988
|
-
|
|
2052
|
+
tool('get_paywall_payments', 'Get payment history for a specific x402 paywall. ' +
|
|
1989
2053
|
'Returns verified payments with TX hashes, payer addresses, amounts, and timestamps.', {
|
|
1990
2054
|
paywall_id: z.number().int().describe('Paywall ID'),
|
|
1991
2055
|
page: z.number().int().default(1).describe('Page number'),
|
|
@@ -1995,13 +2059,13 @@ server.tool('get_paywall_payments', 'Get payment history for a specific x402 pay
|
|
|
1995
2059
|
return jsonResponse(data);
|
|
1996
2060
|
});
|
|
1997
2061
|
// ─── Tool: get_x402_revenue ─────────────────────────────────────
|
|
1998
|
-
|
|
2062
|
+
tool('get_x402_revenue', 'Get aggregate x402 revenue statistics across all your paywalls. ' +
|
|
1999
2063
|
'Returns total payments and revenue broken down by chain and token.', {}, async () => {
|
|
2000
2064
|
const data = await api('/x402/revenue');
|
|
2001
2065
|
return jsonResponse(data);
|
|
2002
2066
|
});
|
|
2003
2067
|
// ─── Tool: wallet_mode ──────────────────────────────────────────
|
|
2004
|
-
|
|
2068
|
+
tool('wallet_mode', 'Report whether this server is signing locally (self-custody, the private key never leaves this machine) ' +
|
|
2005
2069
|
'or through the hosted AgentWallet API (custodial). Use this to verify custody before moving funds.', {}, async () => {
|
|
2006
2070
|
if (!anyLocalMode()) {
|
|
2007
2071
|
return jsonResponse({
|
|
@@ -2011,8 +2075,13 @@ server.tool('wallet_mode', 'Report whether this server is signing locally (self-
|
|
|
2011
2075
|
api_base: redactUrl(API_BASE),
|
|
2012
2076
|
client_guards: {
|
|
2013
2077
|
autopay_cap_usd: autopayEnvCap(),
|
|
2078
|
+
internal_autopay_armed: Boolean(X402_WALLET_ID),
|
|
2079
|
+
max_autopay_per_hour: AUTOPAY_PER_HOUR,
|
|
2014
2080
|
autopay_assets: (process.env.AGENTWALLET_AUTOPAY_ASSETS || '').trim() || 'registry stablecoins only',
|
|
2015
2081
|
x402_max_timeout_seconds: maxAuthWindowSeconds(),
|
|
2082
|
+
approvals: process.env.AGENTWALLET_APPROVALS !== '0',
|
|
2083
|
+
permit2_auto_approve: process.env.AGENTWALLET_PERMIT2_AUTO_APPROVE === '1',
|
|
2084
|
+
legacy_paywall_origins: (process.env.AGENTWALLET_LEGACY_PAYWALL_ORIGINS || '').trim() || 'none (API host only)',
|
|
2016
2085
|
note: 'These bound pay_x402 on hosted wallets too; the hosted signer adds its own pause, daily limit and approval checks.',
|
|
2017
2086
|
},
|
|
2018
2087
|
to_self_custody: 'Set AGENTWALLET_PRIVATE_KEY for EVM and/or AGENTWALLET_SOLANA_KEY for Solana, then restart. ' +
|
|
@@ -2023,12 +2092,18 @@ server.tool('wallet_mode', 'Report whether this server is signing locally (self-
|
|
|
2023
2092
|
mode: 'local',
|
|
2024
2093
|
custody: 'self',
|
|
2025
2094
|
signing: 'Signed in this process. Keys are never sent to AgentWallet or anyone else.',
|
|
2026
|
-
max_autopay:
|
|
2027
|
-
|
|
2095
|
+
max_autopay: autopayEnvCap(),
|
|
2096
|
+
max_autopay_per_hour: AUTOPAY_PER_HOUR,
|
|
2097
|
+
internal_autopay_armed: false, // internal auto-pay never runs in local mode
|
|
2098
|
+
autopay_assets: (process.env.AGENTWALLET_AUTOPAY_ASSETS || '').trim() || 'registry stablecoins only',
|
|
2028
2099
|
x402_max_timeout_seconds: maxAuthWindowSeconds(),
|
|
2029
|
-
per_tx_cap_token: process.env.AGENTWALLET_MAX_TX_TOKEN || 'not set (ERC-20 and SPL transfers are uncapped)',
|
|
2100
|
+
per_tx_cap_token: (process.env.AGENTWALLET_MAX_TX_TOKEN || '').trim() || 'not set (ERC-20 and SPL transfers are uncapped)',
|
|
2030
2101
|
allow_unknown_token_calls: process.env.AGENTWALLET_ALLOW_UNKNOWN_TOKEN_CALLS === '1',
|
|
2031
2102
|
token_decimals_pins: parseDecimalPins(process.env.AGENTWALLET_TOKEN_DECIMALS).size,
|
|
2103
|
+
token_risk_lookup: process.env.AGENTWALLET_TOKEN_RISK !== '0',
|
|
2104
|
+
permit2_auto_approve: process.env.AGENTWALLET_PERMIT2_AUTO_APPROVE === '1',
|
|
2105
|
+
legacy_paywall_origins: (process.env.AGENTWALLET_LEGACY_PAYWALL_ORIGINS || '').trim() || 'none (API host only)',
|
|
2106
|
+
default_chain_id: (process.env.AGENTWALLET_CHAIN_ID || '').trim() || '8453',
|
|
2032
2107
|
};
|
|
2033
2108
|
if (isLocalMode()) {
|
|
2034
2109
|
let rpc = 'default public endpoint';
|
|
@@ -2039,7 +2114,7 @@ server.tool('wallet_mode', 'Report whether this server is signing locally (self-
|
|
|
2039
2114
|
report.evm = {
|
|
2040
2115
|
address: getLocalAddress(),
|
|
2041
2116
|
rpc_endpoint: redactUrl(rpc),
|
|
2042
|
-
per_tx_cap_native: process.env.AGENTWALLET_MAX_TX_NATIVE || 'not set',
|
|
2117
|
+
per_tx_cap_native: (process.env.AGENTWALLET_MAX_TX_NATIVE || '').trim() || 'not set',
|
|
2043
2118
|
};
|
|
2044
2119
|
}
|
|
2045
2120
|
else {
|
|
@@ -2054,8 +2129,8 @@ server.tool('wallet_mode', 'Report whether this server is signing locally (self-
|
|
|
2054
2129
|
report.solana = {
|
|
2055
2130
|
address: getSolanaAddress(),
|
|
2056
2131
|
rpc_endpoint: redactUrl(rpc),
|
|
2057
|
-
per_tx_cap_sol: process.env.AGENTWALLET_MAX_TX_SOL || 'not set',
|
|
2058
|
-
per_tx_cap_token: process.env.AGENTWALLET_MAX_TX_TOKEN || 'not set (SPL transfers are uncapped; AGENTWALLET_MAX_TX_SOL does not cover them)',
|
|
2132
|
+
per_tx_cap_sol: (process.env.AGENTWALLET_MAX_TX_SOL || '').trim() || 'not set',
|
|
2133
|
+
per_tx_cap_token: (process.env.AGENTWALLET_MAX_TX_TOKEN || '').trim() || 'not set (SPL transfers are uncapped; AGENTWALLET_MAX_TX_SOL does not cover them)',
|
|
2059
2134
|
};
|
|
2060
2135
|
}
|
|
2061
2136
|
else {
|
|
@@ -2064,7 +2139,7 @@ server.tool('wallet_mode', 'Report whether this server is signing locally (self-
|
|
|
2064
2139
|
return jsonResponse(report);
|
|
2065
2140
|
});
|
|
2066
2141
|
// ─── Tool: export_wallet_key ────────────────────────────────────
|
|
2067
|
-
|
|
2142
|
+
tool('export_wallet_key', 'Explain how to export the private key of a hosted (custodial) AgentWallet so it can be moved ' +
|
|
2068
2143
|
'to self-custody or any other wallet. Export itself is deliberately human-gated and is not ' +
|
|
2069
2144
|
'performed by this tool.', {
|
|
2070
2145
|
wallet_id: z.number().int().optional().describe('Hosted wallet ID to export'),
|
package/build/local-solana.d.ts
CHANGED
|
@@ -16,6 +16,22 @@ export declare function isSolanaLocalMode(): boolean;
|
|
|
16
16
|
export declare function getSolanaKeypair(): Keypair;
|
|
17
17
|
export declare function getSolanaAddress(): string;
|
|
18
18
|
export declare function resolveSolanaRpc(chainId?: number): string;
|
|
19
|
+
/**
|
|
20
|
+
* Bound SPL movement. AGENTWALLET_MAX_TX_SOL only ever covered native SOL, so
|
|
21
|
+
* an SPL transfer, which is how USDC moves on Solana and the whole point of the
|
|
22
|
+
* x402 rail here, went out with no local ceiling at all. Mirrors
|
|
23
|
+
* AGENTWALLET_MAX_TX_TOKEN on the EVM side and uses the same variable so an
|
|
24
|
+
* operator sets one token cap, not one per chain family.
|
|
25
|
+
*
|
|
26
|
+
* Decimals for the cap come from the trusted table when the mint is known, then
|
|
27
|
+
* from the mint account itself, and finally 0. The caller-supplied value is
|
|
28
|
+
* never used for the ceiling: an inflated one would inflate the cap exactly
|
|
29
|
+
* like the x402 bypass.
|
|
30
|
+
*
|
|
31
|
+
* 0 is the only safe fallback. Assuming 6 for an unknown mint is what AW-001
|
|
32
|
+
* exploited: for a mint with d decimals, a ceiling evaluated at 6 is 10**(6-d)
|
|
33
|
+
* times too permissive, and SPL mints with 0 to 5 decimals are common.
|
|
34
|
+
*/
|
|
19
35
|
/** The mint's decimals from the chain, or null when they cannot be read. Never guesses. */
|
|
20
36
|
export declare function mintDecimalsStrict(mint: string, chainId: number): Promise<number | null>;
|
|
21
37
|
export declare function localSolBalance(chainId?: number): Promise<{
|
package/build/local-solana.js
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
* that when the two instructions it needs are a dozen lines each.
|
|
13
13
|
*/
|
|
14
14
|
import { readFileSync } from 'node:fs';
|
|
15
|
-
import { Connection, Keypair, PublicKey, SystemProgram, Transaction, TransactionInstruction,
|
|
15
|
+
import { Connection, Keypair, PublicKey, SystemProgram, Transaction, TransactionInstruction, LAMPORTS_PER_SOL, } from '@solana/web3.js';
|
|
16
16
|
import bs58 from 'bs58';
|
|
17
17
|
import { lookupTrustedDecimals } from './x402-payment.js';
|
|
18
18
|
/* ── Program IDs ─────────────────────────────────────────────────── */
|
|
@@ -171,7 +171,10 @@ function assertWithinSolCap(lamports) {
|
|
|
171
171
|
* exploited: for a mint with d decimals, a ceiling evaluated at 6 is 10**(6-d)
|
|
172
172
|
* times too permissive, and SPL mints with 0 to 5 decimals are common.
|
|
173
173
|
*/
|
|
174
|
-
|
|
174
|
+
/* The cap and the instruction read the mint's decimals through ONE call now:
|
|
175
|
+
a cached cap-side read and an uncached instruction-side read let a hostile
|
|
176
|
+
RPC answer 6 to one and 18 to the other, which scaled the cap by 10^12
|
|
177
|
+
while the instruction carried the real byte (2026-10-04 round 4). */
|
|
175
178
|
/** The mint's decimals from the chain, or null when they cannot be read. Never guesses. */
|
|
176
179
|
export async function mintDecimalsStrict(mint, chainId) {
|
|
177
180
|
const known = lookupTrustedDecimals(chainId, mint);
|
|
@@ -189,37 +192,14 @@ export async function mintDecimalsStrict(mint, chainId) {
|
|
|
189
192
|
return null;
|
|
190
193
|
}
|
|
191
194
|
}
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
if (typeof known === 'number')
|
|
195
|
-
return known;
|
|
196
|
-
const cacheKey = `${chainId}:${mint}`; // the same mint address can exist on two clusters with different decimals
|
|
197
|
-
const cached = splDecimalsCache.get(cacheKey);
|
|
198
|
-
if (typeof cached === 'number')
|
|
199
|
-
return cached;
|
|
200
|
-
try {
|
|
201
|
-
const conn = connection(chainId);
|
|
202
|
-
const info = await conn.getParsedAccountInfo(new PublicKey(mint));
|
|
203
|
-
const data = info?.value?.data;
|
|
204
|
-
const d = Number(data?.parsed?.info?.decimals);
|
|
205
|
-
if (Number.isInteger(d) && d >= 0 && d <= 36) {
|
|
206
|
-
splDecimalsCache.set(cacheKey, d);
|
|
207
|
-
return d;
|
|
208
|
-
}
|
|
209
|
-
}
|
|
210
|
-
catch {
|
|
211
|
-
// Unreachable RPC or non-standard mint: fall through to the safe floor.
|
|
212
|
-
}
|
|
213
|
-
return 0;
|
|
214
|
-
}
|
|
215
|
-
async function assertWithinSplCap(mint, rawAmount, chainId = 900) {
|
|
195
|
+
/** The cap is evaluated at exactly the decimals the instruction will carry; the caller passes the value it just verified against the chain. */
|
|
196
|
+
function assertWithinSplCap(rawAmount, decimals) {
|
|
216
197
|
const cap = (process.env.AGENTWALLET_MAX_TX_TOKEN || '').trim();
|
|
217
198
|
if (!cap)
|
|
218
199
|
return;
|
|
219
200
|
if (!/^\d+(\.\d+)?$/.test(cap)) {
|
|
220
201
|
throw new Error(`AGENTWALLET_MAX_TX_TOKEN must be a decimal number, got "${cap}".`);
|
|
221
202
|
}
|
|
222
|
-
const decimals = await resolveMintDecimals(mint, chainId);
|
|
223
203
|
const [whole, frac = ''] = cap.split('.');
|
|
224
204
|
const capRaw = BigInt(whole + frac.padEnd(decimals, '0').slice(0, decimals));
|
|
225
205
|
if (rawAmount > capRaw) {
|
|
@@ -228,6 +208,41 @@ async function assertWithinSplCap(mint, rawAmount, chainId = 900) {
|
|
|
228
208
|
`Raise the cap deliberately if this is intended.`);
|
|
229
209
|
}
|
|
230
210
|
}
|
|
211
|
+
const CONFIRM_TIMEOUT_MS = 90_000;
|
|
212
|
+
/**
|
|
213
|
+
* Sign, broadcast and confirm. The signature reported is the one computed
|
|
214
|
+
* here, never the node's answer (a node that returned a different string had
|
|
215
|
+
* the agent confirming and reporting a signature that was not this
|
|
216
|
+
* transaction, round 4); anything that fails after the broadcast says so and
|
|
217
|
+
* carries the signature, so an agent does not retry a transfer that went out.
|
|
218
|
+
*/
|
|
219
|
+
async function sendSigned(conn, tx, payer) {
|
|
220
|
+
const { blockhash, lastValidBlockHeight } = await conn.getLatestBlockhash();
|
|
221
|
+
tx.recentBlockhash = blockhash;
|
|
222
|
+
tx.feePayer = payer.publicKey;
|
|
223
|
+
tx.sign(payer);
|
|
224
|
+
if (!tx.signature)
|
|
225
|
+
throw new Error('The transaction could not be signed.');
|
|
226
|
+
const local = bs58.encode(tx.signature);
|
|
227
|
+
const reported = await conn.sendRawTransaction(tx.serialize());
|
|
228
|
+
const after = (what) => new Error(`Transaction ${local} was BROADCAST but ${what}. Check it on an explorer before retrying: a retry is a second transfer.`);
|
|
229
|
+
if (reported !== local)
|
|
230
|
+
throw after(`the RPC answered with a different signature (${String(reported).slice(0, 90)}), so the node cannot be trusted about its fate`);
|
|
231
|
+
const confirm = conn.confirmTransaction({ signature: local, blockhash, lastValidBlockHeight }, 'confirmed');
|
|
232
|
+
const timeout = new Promise((_, reject) => setTimeout(() => reject(after(`was not confirmed within ${CONFIRM_TIMEOUT_MS / 1000} s`)), CONFIRM_TIMEOUT_MS).unref());
|
|
233
|
+
let result;
|
|
234
|
+
try {
|
|
235
|
+
result = await Promise.race([confirm, timeout]);
|
|
236
|
+
}
|
|
237
|
+
catch (e) {
|
|
238
|
+
if (e instanceof Error && /was BROADCAST/.test(e.message))
|
|
239
|
+
throw e;
|
|
240
|
+
throw after(`confirmation failed: ${e instanceof Error ? e.message : JSON.stringify(e)}`);
|
|
241
|
+
}
|
|
242
|
+
if (result.value.err)
|
|
243
|
+
throw after(`it failed on chain: ${JSON.stringify(result.value.err)}`);
|
|
244
|
+
return local;
|
|
245
|
+
}
|
|
231
246
|
/* ── Reads ───────────────────────────────────────────────────────── */
|
|
232
247
|
export async function localSolBalance(chainId = 900) {
|
|
233
248
|
const conn = connection(chainId);
|
|
@@ -344,7 +359,7 @@ export async function localSolTransfer(to, lamports, chainId = 900) {
|
|
|
344
359
|
toPubkey: new PublicKey(to),
|
|
345
360
|
lamports: Number(amount),
|
|
346
361
|
}));
|
|
347
|
-
const signature = await
|
|
362
|
+
const signature = await sendSigned(conn, tx, payer);
|
|
348
363
|
return {
|
|
349
364
|
signature,
|
|
350
365
|
from: payer.publicKey.toBase58(),
|
|
@@ -396,22 +411,23 @@ export async function localSplTransfer(mintStr, to, rawAmount, decimals, chainId
|
|
|
396
411
|
// The cluster is verified before the cap reads decimals from it, so the
|
|
397
412
|
// decimals cache is never filled from a node serving another chain.
|
|
398
413
|
await assertCluster(conn, chainId);
|
|
399
|
-
await assertWithinSplCap(mintStr, BigInt(rawAmount), chainId);
|
|
400
|
-
const payer = getSolanaKeypair();
|
|
401
|
-
const mint = new PublicKey(mintStr);
|
|
402
|
-
const recipient = new PublicKey(to);
|
|
403
|
-
await assertRecipientIsWallet(conn, recipient);
|
|
404
|
-
const { tokenProgram, ataSize } = await mintLayout(conn, mint);
|
|
405
414
|
// The caller scaled the amount by `decimals`. That value must be the mint's
|
|
406
415
|
// real one: substituting the real value into the instruction (as 1.13.0
|
|
407
416
|
// did) let a wrong guess through, because the amount had already been
|
|
408
417
|
// scaled by the guess and TransferChecked only sees the real byte
|
|
409
|
-
// (2026-10-04 audit). A mismatch refuses.
|
|
418
|
+
// (2026-10-04 audit). A mismatch refuses. Read ONCE, and the cap below is
|
|
419
|
+
// evaluated at this same value (round 4).
|
|
410
420
|
const real = await mintDecimalsStrict(mintStr, chainId);
|
|
411
421
|
if (real === null)
|
|
412
422
|
throw new Error(`Could not read the decimals of mint ${mintStr} from the chain; refusing to guess the scale of the amount.`);
|
|
413
423
|
if (decimals !== real)
|
|
414
424
|
throw new Error(`decimals ${decimals} was passed but mint ${mintStr} has ${real} decimals; refusing to scale the amount by the wrong factor.`);
|
|
425
|
+
assertWithinSplCap(BigInt(rawAmount), real);
|
|
426
|
+
const payer = getSolanaKeypair();
|
|
427
|
+
const mint = new PublicKey(mintStr);
|
|
428
|
+
const recipient = new PublicKey(to);
|
|
429
|
+
await assertRecipientIsWallet(conn, recipient);
|
|
430
|
+
const { tokenProgram, ataSize } = await mintLayout(conn, mint);
|
|
415
431
|
const sourceAta = associatedTokenAddress(payer.publicKey, mint, tokenProgram);
|
|
416
432
|
const destAta = associatedTokenAddress(recipient, mint, tokenProgram);
|
|
417
433
|
const tx = new Transaction();
|
|
@@ -430,13 +446,16 @@ export async function localSplTransfer(mintStr, to, rawAmount, decimals, chainId
|
|
|
430
446
|
// outflow the SOL cap must see (0.002 SOL a time adds up over many sends).
|
|
431
447
|
// Rent follows the account's real size, which for Token-2022 is above 165;
|
|
432
448
|
// SOL already sitting at the address reduces what the payer tops up.
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
449
|
+
// The node's rent figure is floored at the cluster constant and the SOL the
|
|
450
|
+
// node says already sits at the address is not credited: both are numbers a
|
|
451
|
+
// lying RPC used to zero so the create went out under the cap (round 4).
|
|
452
|
+
const claimed = Number(await conn.getMinimumBalanceForRentExemption(ataSize));
|
|
453
|
+
const rent = Math.max(Number.isFinite(claimed) ? Math.trunc(claimed) : 0, (128 + ataSize) * 6960);
|
|
454
|
+
assertWithinSolCap(BigInt(rent));
|
|
436
455
|
tx.add(createAtaIdempotentIx(payer.publicKey, destAta, recipient, mint, tokenProgram));
|
|
437
456
|
}
|
|
438
457
|
tx.add(transferCheckedIx(tokenProgram, sourceAta, mint, destAta, payer.publicKey, BigInt(rawAmount), decimals));
|
|
439
|
-
const signature = await
|
|
458
|
+
const signature = await sendSigned(conn, tx, payer);
|
|
440
459
|
return {
|
|
441
460
|
signature,
|
|
442
461
|
from: payer.publicKey.toBase58(),
|
package/package.json
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agentwallet-mcp",
|
|
3
3
|
"mcpName": "io.github.hifriendbot/agentwallet",
|
|
4
|
-
"
|
|
4
|
+
"repository": { "type": "git", "url": "https://github.com/hifriendbot/agentwallet-mcp" },
|
|
5
|
+
"version": "1.13.10",
|
|
5
6
|
"description": "MCP server for AgentWallet: self-custody local signing on EVM and Solana, or hosted wallets. Sign, broadcast, pay x402 invoices, and accept x402 payments.",
|
|
6
7
|
"type": "module",
|
|
7
8
|
"main": "build/index.js",
|
|
@@ -16,7 +17,7 @@
|
|
|
16
17
|
"scripts": {
|
|
17
18
|
"build": "tsc",
|
|
18
19
|
"start": "node build/index.js",
|
|
19
|
-
"test": "node test/x402-payment.test.mjs && node test/eip3009.test.mjs && node test/permit2-risk.test.mjs && node test/token-cap.test.mjs && node test/spl-cap.test.mjs && node test/unknown-decimals-cap.test.mjs && node test/decimals-range-cap.test.mjs && node test/local-mode.test.mjs && node test/ssrf-guard.test.mjs && node test/x402-policy.test.mjs && node test/local-guard-shape.test.mjs && node test/allowance-total-cap.test.mjs && node test/autopay-scheme.test.mjs && node test/spl-recipient-guard.test.mjs && node test/approval-binding.test.mjs && node test/ata-rent-size.test.mjs && node test/final-url-guard.test.mjs && node test/audit-1135.test.mjs && node test/local-no-autopay.test.mjs && node test/audit-1136.test.mjs && node test/delete-confirm.test.mjs && node test/audit-1138.test.mjs && node test/audit-1139.test.mjs",
|
|
20
|
+
"test": "node test/x402-payment.test.mjs && node test/eip3009.test.mjs && node test/permit2-risk.test.mjs && node test/token-cap.test.mjs && node test/spl-cap.test.mjs && node test/unknown-decimals-cap.test.mjs && node test/decimals-range-cap.test.mjs && node test/local-mode.test.mjs && node test/ssrf-guard.test.mjs && node test/x402-policy.test.mjs && node test/local-guard-shape.test.mjs && node test/allowance-total-cap.test.mjs && node test/autopay-scheme.test.mjs && node test/spl-recipient-guard.test.mjs && node test/approval-binding.test.mjs && node test/ata-rent-size.test.mjs && node test/final-url-guard.test.mjs && node test/audit-1135.test.mjs && node test/local-no-autopay.test.mjs && node test/audit-1136.test.mjs && node test/delete-confirm.test.mjs && node test/audit-1138.test.mjs && node test/audit-1139.test.mjs && node test/audit-11310.test.mjs",
|
|
20
21
|
"prepublishOnly": "npm run build"
|
|
21
22
|
},
|
|
22
23
|
"keywords": [
|