agentwallet-mcp 1.13.9 → 1.13.11
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 +8 -5
- package/build/index.js +138 -57
- package/build/local-solana.d.ts +16 -0
- package/build/local-solana.js +58 -39
- package/build/local-wallet.d.ts +21 -4
- package/build/local-wallet.js +111 -17
- package/build/x402-payment.d.ts +0 -16
- package/build/x402-payment.js +6 -0
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -37,9 +37,9 @@ 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
|
-
`AGENTWALLET_RPC_<chainId>` (or `AGENTWALLET_RPC_URL` for all chains) points at an endpoint you trust. Without it a public RPC is used, and a public RPC can see which addresses you ask about.
|
|
42
|
+
`AGENTWALLET_RPC_<chainId>` (or `AGENTWALLET_RPC_URL` for all chains) points at an endpoint you trust (https; plain http only on localhost). Without it a public RPC is used, and a public RPC can see which addresses you ask about. Decimals for a token outside the registry come from that RPC and are accepted only up to 18; pin anything else with `AGENTWALLET_TOKEN_DECIMALS`.
|
|
43
43
|
|
|
44
44
|
`AGENTWALLET_MAX_TX_NATIVE` is a per-transaction ceiling in native units (ETH, MATIC and so on). In hosted mode the server enforces limits; in local mode there is no server, so these guards are the only ones there are. Set them.
|
|
45
45
|
|
|
@@ -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
|
|
|
@@ -330,6 +330,7 @@ Which guards apply depends on who holds the key.
|
|
|
330
330
|
**Local mode** (you hold the key): there is no server in the signing path, so the server-side limits and pause above cannot see or stop a locally signed transaction, and nobody (including us) can freeze a local wallet. Your protection is the per-transaction caps enforced inside your own process before anything is signed. Set them before you fund the wallet; an unset cap means no limit.
|
|
331
331
|
|
|
332
332
|
- `AGENTWALLET_MAX_TX_NATIVE`: ceiling per transaction in native units (ETH, MATIC, and so on)
|
|
333
|
+
- `AGENTWALLET_MAX_FEE_NATIVE`: the most one transaction may burn in fees, gas x max fee per gas, in native units (default `0.01`). The value cap alone did not bound fees: a node answering an absurd tip or gas estimate could take the whole balance as priority fee on a cap-compliant transfer. Caller-supplied `gas_limit` / `max_fee` / `priority_fee` are honoured and then bounded by this cap
|
|
333
334
|
- `AGENTWALLET_MAX_TX_TOKEN`: ceiling per ERC-20 transfer or approval, in human units of the token
|
|
334
335
|
- `AGENTWALLET_ALLOW_UNKNOWN_TOKEN_CALLS`: set to `1` to let calldata the token cap cannot price reach a token contract or Permit2 (refused by default while the cap is set)
|
|
335
336
|
- `AGENTWALLET_TOKEN_DECIMALS`: pin decimals for tokens outside the registry (`8453:0x<token>=6,<mint>=9`); a pin is used for amounts and caps instead of asking the RPC
|
|
@@ -337,8 +338,10 @@ Which guards apply depends on who holds the key.
|
|
|
337
338
|
- `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
339
|
- `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
340
|
- `AGENTWALLET_TOKEN_RISK=0`: skip the GoPlus lookup `approve_token` performs (a third-party call that reveals which token you are about to approve)
|
|
341
|
+
- `AGENTWALLET_CHAIN_ID`: the EVM chain used when a call names none (default 8453)
|
|
342
|
+
- `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
343
|
|
|
341
|
-
|
|
344
|
+
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
345
|
|
|
343
346
|
**Either mode**, for x402 paywalls you run through the hosted API:
|
|
344
347
|
|
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
|
|
@@ -99,7 +119,12 @@ async function routeLocally(path, method, body) {
|
|
|
99
119
|
throw new Error(`Local mode expects an EVM address, got "${to}".`);
|
|
100
120
|
}
|
|
101
121
|
const hasData = data && data !== '0x';
|
|
102
|
-
|
|
122
|
+
const fees = {
|
|
123
|
+
...(body?.gas_limit ? { gas: String(body.gas_limit) } : {}),
|
|
124
|
+
...(body?.max_fee ? { maxFeePerGas: String(body.max_fee) } : {}),
|
|
125
|
+
...(body?.priority_fee ? { maxPriorityFeePerGas: String(body.priority_fee) } : {}),
|
|
126
|
+
};
|
|
127
|
+
return localSend(chainId, to, value, hasData ? data : undefined, fees);
|
|
103
128
|
}
|
|
104
129
|
// POST /wallets/{id}/x402/authorize (x402 "exact": EIP-3009 authorization, signed here, broadcast by nobody)
|
|
105
130
|
if (method === 'POST' && /^\/wallets\/[^/]*\/x402\/authorize$/.test(rawPath)) {
|
|
@@ -288,7 +313,7 @@ async function api(path, method = 'GET', body, extraHeaders) {
|
|
|
288
313
|
function autopayRouteAllowed(path, method) {
|
|
289
314
|
return method === 'POST' && /^\/wallets(\/\d+\/(sign|send))?(\?|$)/.test(path);
|
|
290
315
|
}
|
|
291
|
-
const AUTOPAY_PER_HOUR = (() => { const
|
|
316
|
+
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
317
|
const autopayLog = [];
|
|
293
318
|
/** Read a body no larger than maxChars, refusing early on a declared length and mid-stream on a real one. */
|
|
294
319
|
async function readBounded(res, maxChars, who) {
|
|
@@ -552,7 +577,8 @@ const PAYWALL_MAX_CHARS = 4 * 1024 * 1024; // a paywall's answer, before and aft
|
|
|
552
577
|
and the client's log (2026-10-04 audit). */
|
|
553
578
|
function redactRpcSecrets(err) {
|
|
554
579
|
const e = err;
|
|
555
|
-
|
|
580
|
+
// A rejection that is not an Error (web3.js rejects with a raw {InstructionError} object) used to read "[object Object]".
|
|
581
|
+
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
582
|
msg = msg.split(/\r?\n/).filter(l => !/^\s*(URL|Request body|Request Arguments|Raw Call Arguments)\s*:/i.test(l)).join('\n').trim();
|
|
557
583
|
return msg.replace(/[a-z][a-z0-9+.-]*:\/\/[^\s"'<>)]+/gi, (u) => redactUrl(u)); // brackets stay in: IPv6 hosts
|
|
558
584
|
}
|
|
@@ -582,7 +608,7 @@ async function verifiedDecimals(chainId, token, given) {
|
|
|
582
608
|
/** Cap variables are read at send time; a typo must fail at startup, not after funding. */
|
|
583
609
|
function validateGuardEnv() {
|
|
584
610
|
parseDecimalPins(process.env.AGENTWALLET_TOKEN_DECIMALS); // throws on a malformed pin
|
|
585
|
-
for (const name of ['AGENTWALLET_MAX_TX_NATIVE', 'AGENTWALLET_MAX_TX_TOKEN', 'AGENTWALLET_MAX_TX_SOL', 'AGENTWALLET_MAX_AUTOPAY']) {
|
|
611
|
+
for (const name of ['AGENTWALLET_MAX_TX_NATIVE', 'AGENTWALLET_MAX_TX_TOKEN', 'AGENTWALLET_MAX_TX_SOL', 'AGENTWALLET_MAX_AUTOPAY', 'AGENTWALLET_MAX_FEE_NATIVE']) {
|
|
586
612
|
const raw = process.env[name];
|
|
587
613
|
const v = (raw || '').trim();
|
|
588
614
|
// Set but blank is a typo, not "no cap": refuse to start rather than run uncapped.
|
|
@@ -600,6 +626,48 @@ function validateGuardEnv() {
|
|
|
600
626
|
if (t && (!/^\d+$/.test(t) || Number(t) < 60 || Number(t) > 31_536_000)) {
|
|
601
627
|
throw new Error(`AGENTWALLET_X402_MAX_TIMEOUT must be a whole number of seconds between 60 and 31536000, got "${t}".`);
|
|
602
628
|
}
|
|
629
|
+
const perHour = process.env.AGENTWALLET_MAX_AUTOPAY_PER_HOUR;
|
|
630
|
+
if (perHour !== undefined && !/^\d+$/.test(perHour.trim())) {
|
|
631
|
+
throw new Error(`AGENTWALLET_MAX_AUTOPAY_PER_HOUR must be a whole number (0 disables internal auto-pay), got "${perHour}".`);
|
|
632
|
+
}
|
|
633
|
+
for (const o of (process.env.AGENTWALLET_LEGACY_PAYWALL_ORIGINS || '').split(',').map(x => x.trim()).filter(Boolean)) {
|
|
634
|
+
let u = null;
|
|
635
|
+
try {
|
|
636
|
+
u = new URL(o);
|
|
637
|
+
}
|
|
638
|
+
catch {
|
|
639
|
+
u = null;
|
|
640
|
+
}
|
|
641
|
+
if (!u || u.protocol !== 'https:' || u.origin !== o.replace(/\/+$/, '')) {
|
|
642
|
+
throw new Error(`AGENTWALLET_LEGACY_PAYWALL_ORIGINS entry "${o}" is not an https origin (scheme and host only, like https://shop.example).`);
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
// Two sources for one key family is ambiguous; refuse rather than pick one silently.
|
|
646
|
+
if ((process.env.AGENTWALLET_PRIVATE_KEY || '').trim() && (process.env.AGENTWALLET_KEYFILE || '').trim()) {
|
|
647
|
+
throw new Error('Both AGENTWALLET_PRIVATE_KEY and AGENTWALLET_KEYFILE are set; keep one.');
|
|
648
|
+
}
|
|
649
|
+
if ((process.env.AGENTWALLET_SOLANA_KEY || '').trim() && (process.env.AGENTWALLET_SOLANA_KEYFILE || '').trim()) {
|
|
650
|
+
throw new Error('Both AGENTWALLET_SOLANA_KEY and AGENTWALLET_SOLANA_KEYFILE are set; keep one.');
|
|
651
|
+
}
|
|
652
|
+
// An unrecognised AGENTWALLET_* name is almost always a misspelled cap, which
|
|
653
|
+
// would run uncapped without a word (round 4). Refuse to start instead.
|
|
654
|
+
for (const k of Object.keys(process.env)) {
|
|
655
|
+
if (!k.startsWith('AGENTWALLET_'))
|
|
656
|
+
continue;
|
|
657
|
+
if (KNOWN_ENV.has(k) || /^AGENTWALLET_(RPC|SOLANA_RPC)_\d+$/.test(k))
|
|
658
|
+
continue;
|
|
659
|
+
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>.`);
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
const KNOWN_ENV = new Set(["AGENTWALLET_ALLOW_UNKNOWN_TOKEN_CALLS", "AGENTWALLET_API_URL", "AGENTWALLET_APPROVALS", "AGENTWALLET_AUTOPAY_ASSETS", "AGENTWALLET_CHAIN_ID", "AGENTWALLET_DASHBOARD_URL", "AGENTWALLET_KEYFILE", "AGENTWALLET_LEGACY_PAYWALL_ORIGINS", "AGENTWALLET_MAX_AUTOPAY", "AGENTWALLET_MAX_AUTOPAY_PER_HOUR", "AGENTWALLET_MAX_FEE_NATIVE", "AGENTWALLET_MAX_TX_NATIVE", "AGENTWALLET_MAX_TX_SOL", "AGENTWALLET_MAX_TX_TOKEN", "AGENTWALLET_PASS", "AGENTWALLET_PERMIT2_AUTO_APPROVE", "AGENTWALLET_PRIVATE_KEY", "AGENTWALLET_RPC_URL", "AGENTWALLET_SOLANA_KEY", "AGENTWALLET_SOLANA_KEYFILE", "AGENTWALLET_SOLANA_RPC", "AGENTWALLET_TOKEN_DECIMALS", "AGENTWALLET_TOKEN_RISK", "AGENTWALLET_USER", "AGENTWALLET_WALLET_ID", "AGENTWALLET_X402_MAX_TIMEOUT"]);
|
|
663
|
+
/**
|
|
664
|
+
* Register a tool whose arguments are strict: an unknown key (max_payemnt for
|
|
665
|
+
* max_payment) is an error that names it, not a silently dropped guard. The
|
|
666
|
+
* advertised schema always said additionalProperties: false; now the runtime
|
|
667
|
+
* agrees (round 4).
|
|
668
|
+
*/
|
|
669
|
+
function tool(name, description, shape, cb) {
|
|
670
|
+
return server.registerTool(name, { description, inputSchema: z.object(shape).strict() }, cb);
|
|
603
671
|
}
|
|
604
672
|
// ─── EVM Helpers ──────────────────────────────────────────────────
|
|
605
673
|
/**
|
|
@@ -679,10 +747,10 @@ function resolveChainId(network) {
|
|
|
679
747
|
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
748
|
const server = new McpServer({
|
|
681
749
|
name: 'agentwallet',
|
|
682
|
-
version: '1.13.
|
|
750
|
+
version: '1.13.11',
|
|
683
751
|
}, {
|
|
684
752
|
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
|
|
753
|
+
? '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
754
|
: 'Private keys are encrypted server-side and never exposed: agents sign and broadcast transactions without ever touching raw keys.'}
|
|
687
755
|
|
|
688
756
|
## Getting Started
|
|
@@ -738,9 +806,7 @@ Create paywalls to charge other agents for accessing your resources:
|
|
|
738
806
|
|
|
739
807
|
## Wallet Security
|
|
740
808
|
- ${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.'}
|
|
809
|
+
${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
810
|
|
|
745
811
|
## Tool Selection Guide
|
|
746
812
|
| Goal | Tool |
|
|
@@ -760,7 +826,7 @@ Create paywalls to charge other agents for accessing your resources:
|
|
|
760
826
|
| Check usage/billing | get_usage |`,
|
|
761
827
|
});
|
|
762
828
|
// ─── Tool: create_wallet ─────────────────────────────────────────
|
|
763
|
-
|
|
829
|
+
tool('create_wallet', 'Create a new EVM or Solana wallet. Returns the wallet ID and address. ' +
|
|
764
830
|
(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
831
|
label: z.string().default('').describe('Friendly name for the wallet'),
|
|
766
832
|
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 +835,13 @@ server.tool('create_wallet', 'Create a new EVM or Solana wallet. Returns the wal
|
|
|
769
835
|
return jsonResponse(data);
|
|
770
836
|
});
|
|
771
837
|
// ─── Tool: list_wallets ──────────────────────────────────────────
|
|
772
|
-
|
|
838
|
+
tool('list_wallets', 'List all wallets owned by the authenticated user. ' +
|
|
773
839
|
'Returns wallet IDs, addresses, labels, chain IDs, and status.', {}, async () => {
|
|
774
840
|
const data = await api('/wallets');
|
|
775
841
|
return jsonResponse(data);
|
|
776
842
|
});
|
|
777
843
|
// ─── Tool: get_wallet ────────────────────────────────────────────
|
|
778
|
-
|
|
844
|
+
tool('get_wallet', 'Get details for a specific wallet by ID. ' +
|
|
779
845
|
'Returns address, label, chain, spending limits, and pause status.', {
|
|
780
846
|
wallet_id: z.number().int().describe('Wallet ID'),
|
|
781
847
|
}, async ({ wallet_id }) => {
|
|
@@ -783,7 +849,7 @@ server.tool('get_wallet', 'Get details for a specific wallet by ID. ' +
|
|
|
783
849
|
return jsonResponse(data);
|
|
784
850
|
});
|
|
785
851
|
// ─── Tool: get_balance ───────────────────────────────────────────
|
|
786
|
-
|
|
852
|
+
tool('get_balance', 'Get the native token balance for a wallet on a specific chain. ' +
|
|
787
853
|
'Returns balance in both wei (or lamports for Solana) and human-readable format.', {
|
|
788
854
|
wallet_id: z.number().int().describe('Wallet ID'),
|
|
789
855
|
chain_id: z.number().int().optional().describe('Chain ID to check (defaults to wallet\'s default chain)'),
|
|
@@ -793,7 +859,7 @@ server.tool('get_balance', 'Get the native token balance for a wallet on a speci
|
|
|
793
859
|
return jsonResponse(data);
|
|
794
860
|
});
|
|
795
861
|
// ─── Tool: sign_transaction ──────────────────────────────────────
|
|
796
|
-
|
|
862
|
+
tool('sign_transaction', 'Sign a transaction with a wallet\'s private key. ' +
|
|
797
863
|
'For EVM: returns signed raw transaction hex. For Solana: returns base64 signed transaction. ' +
|
|
798
864
|
'Does NOT broadcast — use send_transaction for sign + broadcast.', {
|
|
799
865
|
wallet_id: z.number().int().describe('Wallet ID'),
|
|
@@ -839,7 +905,7 @@ server.tool('sign_transaction', 'Sign a transaction with a wallet\'s private key
|
|
|
839
905
|
return jsonResponse(result);
|
|
840
906
|
});
|
|
841
907
|
// ─── Tool: send_transaction ──────────────────────────────────────
|
|
842
|
-
|
|
908
|
+
tool('send_transaction', 'Sign and broadcast a transaction. ' +
|
|
843
909
|
'Returns the transaction hash (EVM) or signature (Solana) on success. ' +
|
|
844
910
|
(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
911
|
wallet_id: z.number().int().describe('Wallet ID'),
|
|
@@ -885,7 +951,7 @@ server.tool('send_transaction', 'Sign and broadcast a transaction. ' +
|
|
|
885
951
|
return jsonResponse(result);
|
|
886
952
|
});
|
|
887
953
|
// ─── Tool: transfer ─────────────────────────────────────────────
|
|
888
|
-
|
|
954
|
+
tool('transfer', 'Send native tokens (ETH, AVAX, BNB, POL, PLS, SOL) to an address. ' +
|
|
889
955
|
'Specify the amount in human-readable format (e.g. "0.1" for 0.1 ETH). ' +
|
|
890
956
|
'The amount is converted to wei/lamports automatically. Signs and broadcasts the transaction.', {
|
|
891
957
|
wallet_id: z.number().int().describe('Wallet ID to send from'),
|
|
@@ -916,7 +982,7 @@ server.tool('transfer', 'Send native tokens (ETH, AVAX, BNB, POL, PLS, SOL) to a
|
|
|
916
982
|
});
|
|
917
983
|
});
|
|
918
984
|
// ─── Tool: get_token_balance ────────────────────────────────────
|
|
919
|
-
|
|
985
|
+
tool('get_token_balance', 'Get the ERC-20 or SPL token balance for a wallet on a specific chain. ' +
|
|
920
986
|
'Returns the raw balance and human-readable balance. ' +
|
|
921
987
|
'Use get_chains to find stablecoin addresses for each chain.', {
|
|
922
988
|
wallet_id: z.number().int().describe('Wallet ID to check'),
|
|
@@ -960,7 +1026,7 @@ server.tool('get_token_balance', 'Get the ERC-20 or SPL token balance for a wall
|
|
|
960
1026
|
});
|
|
961
1027
|
});
|
|
962
1028
|
// ─── Tool: transfer_token ───────────────────────────────────────
|
|
963
|
-
|
|
1029
|
+
tool('transfer_token', 'Send ERC-20 tokens (EVM) or SPL tokens (Solana) to an address. ' +
|
|
964
1030
|
'Specify the amount in human-readable format (e.g. "100" for 100 USDC). ' +
|
|
965
1031
|
'Signs and broadcasts the transaction. Use get_chains to find stablecoin addresses.', {
|
|
966
1032
|
wallet_id: z.number().int().describe('Wallet ID to send from'),
|
|
@@ -1009,7 +1075,7 @@ server.tool('transfer_token', 'Send ERC-20 tokens (EVM) or SPL tokens (Solana) t
|
|
|
1009
1075
|
});
|
|
1010
1076
|
});
|
|
1011
1077
|
// ─── Tool: call_contract ────────────────────────────────────────
|
|
1012
|
-
|
|
1078
|
+
tool('call_contract', 'Execute a read-only call against a smart contract (eth_call). ' +
|
|
1013
1079
|
'Returns the raw hex result. Does not cost gas or modify state. ' +
|
|
1014
1080
|
'Useful for reading on-chain data like token balances, prices, positions.', {
|
|
1015
1081
|
chain_id: z.number().int().describe('Chain ID'),
|
|
@@ -1023,7 +1089,7 @@ server.tool('call_contract', 'Execute a read-only call against a smart contract
|
|
|
1023
1089
|
return jsonResponse(result);
|
|
1024
1090
|
});
|
|
1025
1091
|
// ─── Tool: approve_token ────────────────────────────────────────
|
|
1026
|
-
|
|
1092
|
+
tool('approve_token', 'Approve a spender contract to transfer ERC-20 tokens on your behalf. ' +
|
|
1027
1093
|
'Required before interacting with any DeFi protocol (DEXs, lending, etc.). ' +
|
|
1028
1094
|
'Use amount "max" for unlimited approval, or specify an exact amount.', {
|
|
1029
1095
|
wallet_id: z.number().int().describe('Wallet ID'),
|
|
@@ -1071,7 +1137,7 @@ server.tool('approve_token', 'Approve a spender contract to transfer ERC-20 toke
|
|
|
1071
1137
|
});
|
|
1072
1138
|
});
|
|
1073
1139
|
// ─── Tool: get_allowance ────────────────────────────────────────
|
|
1074
|
-
|
|
1140
|
+
tool('get_allowance', 'Check how many ERC-20 tokens a spender is approved to transfer. ' +
|
|
1075
1141
|
'Returns the allowance in both raw and human-readable format. ' +
|
|
1076
1142
|
'Use this to check if an approval is needed before a DeFi transaction.', {
|
|
1077
1143
|
wallet_id: z.number().int().describe('Wallet ID (used to determine the owner address)'),
|
|
@@ -1107,7 +1173,7 @@ server.tool('get_allowance', 'Check how many ERC-20 tokens a spender is approved
|
|
|
1107
1173
|
});
|
|
1108
1174
|
// ─── WETH / Wrapped Native Token addresses ──────────────────────
|
|
1109
1175
|
// ─── Tool: wrap_eth ─────────────────────────────────────────────
|
|
1110
|
-
|
|
1176
|
+
tool('wrap_eth', 'Wrap native tokens (ETH, AVAX, BNB, POL, PLS) into their wrapped ERC-20 version (WETH, WAVAX, etc.). ' +
|
|
1111
1177
|
'Required for most DeFi protocols that use ERC-20 tokens instead of raw native tokens. ' +
|
|
1112
1178
|
'Specify amount in human-readable format (e.g. "0.5" for 0.5 ETH).', {
|
|
1113
1179
|
wallet_id: z.number().int().describe('Wallet ID'),
|
|
@@ -1137,7 +1203,7 @@ server.tool('wrap_eth', 'Wrap native tokens (ETH, AVAX, BNB, POL, PLS) into thei
|
|
|
1137
1203
|
});
|
|
1138
1204
|
});
|
|
1139
1205
|
// ─── Tool: unwrap_eth ───────────────────────────────────────────
|
|
1140
|
-
|
|
1206
|
+
tool('unwrap_eth', 'Unwrap wrapped tokens (WETH, WAVAX, WBNB, etc.) back to native tokens. ' +
|
|
1141
1207
|
'Specify amount in human-readable format (e.g. "0.5" for 0.5 WETH).', {
|
|
1142
1208
|
wallet_id: z.number().int().describe('Wallet ID'),
|
|
1143
1209
|
amount: z.string().describe('Amount to unwrap in human-readable format (e.g. "0.5")'),
|
|
@@ -1196,7 +1262,7 @@ function decodeAbiString(hex) {
|
|
|
1196
1262
|
return '';
|
|
1197
1263
|
}
|
|
1198
1264
|
}
|
|
1199
|
-
|
|
1265
|
+
tool('get_token_info', 'Get the name, symbol, and decimals of any ERC-20 token by its contract address. ' +
|
|
1200
1266
|
'Useful for discovering token details before transfers or approvals.', {
|
|
1201
1267
|
token: z.string().regex(/^0x[a-fA-F0-9]{40}$/).describe('ERC-20 token contract address'),
|
|
1202
1268
|
chain_id: z.number().int().describe('Chain ID'),
|
|
@@ -1336,7 +1402,7 @@ async function signPermit2(walletId, chainId, auth, approvalId) {
|
|
|
1336
1402
|
return sig;
|
|
1337
1403
|
}
|
|
1338
1404
|
// ─── Tool: pay_x402 ─────────────────────────────────────────────
|
|
1339
|
-
|
|
1405
|
+
tool('pay_x402', 'Handle an x402 payment flow. Fetches a URL, and if the server returns HTTP 402 Payment Required, ' +
|
|
1340
1406
|
'parses the payment requirements (v1 body or v2 PAYMENT-REQUIRED header), signs an EIP-3009 ' +
|
|
1341
1407
|
'TransferWithAuthorization for the "exact" scheme (no gas, nothing broadcast by the payer), and retries ' +
|
|
1342
1408
|
'the request with the payment header. AgentWallet paywalls are paid by on-chain transfer instead. ' +
|
|
@@ -1360,6 +1426,9 @@ server.tool('pay_x402', 'Handle an x402 payment flow. Fetches a URL, and if the
|
|
|
1360
1426
|
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
1427
|
'within the validity window re-sends the earlier signature (same nonce, so it cannot settle twice) instead of paying again.'),
|
|
1362
1428
|
}, async ({ url, wallet_id, method, headers: headersJson, body: reqBody, max_payment, prefer_chain, request_approval, approval_id, fresh_authorization }) => {
|
|
1429
|
+
if (max_payment !== undefined && !/^\d+(\.\d+)?$/.test(String(max_payment).trim())) {
|
|
1430
|
+
throw new Error(`max_payment must be a decimal number of stablecoin units, got "${serverText(max_payment)}"; nothing was fetched.`);
|
|
1431
|
+
}
|
|
1363
1432
|
// Build request headers
|
|
1364
1433
|
const reqHeaders = { Accept: 'application/json' };
|
|
1365
1434
|
if (headersJson) {
|
|
@@ -1801,10 +1870,10 @@ server.tool('pay_x402', 'Handle an x402 payment flow. Fetches a URL, and if the
|
|
|
1801
1870
|
});
|
|
1802
1871
|
});
|
|
1803
1872
|
// ─── Tool: check_approval ───────────────────────────────────────
|
|
1804
|
-
|
|
1873
|
+
tool('check_approval', 'Status of a payment approval created when pay_x402 exceeded the cap on a hosted wallet. ' +
|
|
1805
1874
|
'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
1875
|
// ─── Tool: approve_permit2 ──────────────────────────────────────
|
|
1807
|
-
|
|
1876
|
+
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
1877
|
'A normal on-chain transaction (needs gas). By default approves the maximum, the ecosystem norm, so it never has to be repeated; ' +
|
|
1809
1878
|
'pass amount to grant a bounded allowance instead (required under AGENTWALLET_MAX_TX_TOKEN, which refuses a max approval). ' +
|
|
1810
1879
|
'Permit2 itself only moves what each signed authorization allows.', {
|
|
@@ -1824,20 +1893,20 @@ server.tool('approve_permit2', 'One-time ERC-20 approval of a token to the Permi
|
|
|
1824
1893
|
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
1894
|
});
|
|
1826
1895
|
// ─── Tool: check_token_risk ─────────────────────────────────────
|
|
1827
|
-
|
|
1896
|
+
tool('check_token_risk', 'Assess an ERC-20 token before approving or swapping it: honeypot, taxes, owner powers, verified source, ' +
|
|
1828
1897
|
'holder concentration, DEX liquidity. Uses GoPlus Security (free, no key) with an on-chain fallback. ' +
|
|
1829
1898
|
'A warning for the caller to weigh, never a block.', {
|
|
1830
1899
|
token: z.string().regex(/^0x[a-fA-F0-9]{40}$/).describe('ERC-20 token contract address'),
|
|
1831
1900
|
chain_id: z.number().int().describe('Chain ID'),
|
|
1832
1901
|
}, async ({ token, chain_id }) => jsonResponse({ token, chain_id, risk: await assessTokenRisk(chain_id, token, (to, data) => ethCallHex(chain_id, to, data)) }));
|
|
1833
1902
|
// ─── Tool: get_usage ─────────────────────────────────────────────
|
|
1834
|
-
|
|
1903
|
+
tool('get_usage', 'Get the current month\'s usage statistics. ' +
|
|
1835
1904
|
'Returns operations count, tier info, remaining quota, and fees.', {}, async () => {
|
|
1836
1905
|
const data = await api('/usage');
|
|
1837
1906
|
return jsonResponse(data);
|
|
1838
1907
|
});
|
|
1839
1908
|
// ─── Tool: buy_verification_credits ─────────────────────────────
|
|
1840
|
-
|
|
1909
|
+
tool('buy_verification_credits', 'Buy x402 verification credits with USDC on-chain. ' +
|
|
1841
1910
|
'Paywall owners need credits to process verifications beyond the free tier (1,000/month) ' +
|
|
1842
1911
|
'when they don\'t have Stripe billing configured. ' +
|
|
1843
1912
|
'Returns 402 payment instructions — pay on-chain and retry with proof.', {
|
|
@@ -1848,27 +1917,27 @@ server.tool('buy_verification_credits', 'Buy x402 verification credits with USDC
|
|
|
1848
1917
|
return jsonResponse(data);
|
|
1849
1918
|
});
|
|
1850
1919
|
// ─── Tool: pause_wallet ──────────────────────────────────────────
|
|
1851
|
-
|
|
1920
|
+
tool('pause_wallet', 'Emergency pause a wallet. No transactions can be signed while paused.', {
|
|
1852
1921
|
wallet_id: z.number().int().describe('Wallet ID to pause'),
|
|
1853
1922
|
}, async ({ wallet_id }) => {
|
|
1854
1923
|
const data = await api(`/wallets/${wallet_id}/pause`, 'POST');
|
|
1855
1924
|
return jsonResponse(data);
|
|
1856
1925
|
});
|
|
1857
1926
|
// ─── Tool: unpause_wallet ────────────────────────────────────────
|
|
1858
|
-
|
|
1927
|
+
tool('unpause_wallet', 'Resume a paused wallet so transactions can be signed again.', {
|
|
1859
1928
|
wallet_id: z.number().int().describe('Wallet ID to unpause'),
|
|
1860
1929
|
}, async ({ wallet_id }) => {
|
|
1861
1930
|
const data = await api(`/wallets/${wallet_id}/unpause`, 'POST');
|
|
1862
1931
|
return jsonResponse(data);
|
|
1863
1932
|
});
|
|
1864
1933
|
// ─── Tool: get_chains ────────────────────────────────────────────
|
|
1865
|
-
|
|
1934
|
+
tool('get_chains', 'List all supported chains (EVM + Solana) with their chain IDs, native tokens, ' +
|
|
1866
1935
|
'stablecoins, and RPC configuration status.', {}, async () => {
|
|
1867
1936
|
const data = await api('/chains');
|
|
1868
1937
|
return jsonResponse(data);
|
|
1869
1938
|
});
|
|
1870
1939
|
// ─── Tool: delete_wallet ─────────────────────────────────────────
|
|
1871
|
-
|
|
1940
|
+
tool('delete_wallet', 'Delete (soft-delete) a wallet. The wallet will no longer appear in listings ' +
|
|
1872
1941
|
'and cannot be used for transactions. The wallet is read first and the delete names its address, ' +
|
|
1873
1942
|
'so a wallet that cannot be read is never deleted.', {
|
|
1874
1943
|
wallet_id: z.number().int().describe('Wallet ID to delete'),
|
|
@@ -1883,7 +1952,7 @@ server.tool('delete_wallet', 'Delete (soft-delete) a wallet. The wallet will no
|
|
|
1883
1952
|
return jsonResponse(data);
|
|
1884
1953
|
});
|
|
1885
1954
|
// ─── Tool: create_paywall ────────────────────────────────────────
|
|
1886
|
-
|
|
1955
|
+
tool('create_paywall', 'Create an x402 paywall that charges agents/clients for accessing a resource. ' +
|
|
1887
1956
|
'Returns a public access URL that returns HTTP 402 until paid. ' +
|
|
1888
1957
|
'Agents pay on-chain, then retry with proof to get the content.', {
|
|
1889
1958
|
wallet_id: z.number().int().describe('Wallet ID to receive payments'),
|
|
@@ -1923,7 +1992,7 @@ server.tool('create_paywall', 'Create an x402 paywall that charges agents/client
|
|
|
1923
1992
|
});
|
|
1924
1993
|
});
|
|
1925
1994
|
// ─── Tool: list_paywalls ────────────────────────────────────────
|
|
1926
|
-
|
|
1995
|
+
tool('list_paywalls', 'List all your x402 paywalls. Returns paywall IDs, names, pricing, ' +
|
|
1927
1996
|
'access URLs, payment counts, and revenue totals.', {
|
|
1928
1997
|
page: z.number().int().default(1).describe('Page number'),
|
|
1929
1998
|
per_page: z.number().int().min(1).max(100).default(50).describe('Results per page (max 100)'),
|
|
@@ -1932,7 +2001,7 @@ server.tool('list_paywalls', 'List all your x402 paywalls. Returns paywall IDs,
|
|
|
1932
2001
|
return jsonResponse(data);
|
|
1933
2002
|
});
|
|
1934
2003
|
// ─── Tool: get_paywall ──────────────────────────────────────────
|
|
1935
|
-
|
|
2004
|
+
tool('get_paywall', 'Get details for a specific x402 paywall by ID. ' +
|
|
1936
2005
|
'Returns pricing, access URL, payment stats, and configuration.', {
|
|
1937
2006
|
paywall_id: z.number().int().describe('Paywall ID'),
|
|
1938
2007
|
}, async ({ paywall_id }) => {
|
|
@@ -1940,7 +2009,7 @@ server.tool('get_paywall', 'Get details for a specific x402 paywall by ID. ' +
|
|
|
1940
2009
|
return jsonResponse(data);
|
|
1941
2010
|
});
|
|
1942
2011
|
// ─── Tool: update_paywall ───────────────────────────────────────
|
|
1943
|
-
|
|
2012
|
+
tool('update_paywall', 'Update an x402 paywall configuration. ' +
|
|
1944
2013
|
'Can change price, resource URL, active status, or any other field.', {
|
|
1945
2014
|
paywall_id: z.number().int().describe('Paywall ID to update'),
|
|
1946
2015
|
name: z.string().optional().describe('New paywall name'),
|
|
@@ -1978,14 +2047,14 @@ server.tool('update_paywall', 'Update an x402 paywall configuration. ' +
|
|
|
1978
2047
|
return jsonResponse(data);
|
|
1979
2048
|
});
|
|
1980
2049
|
// ─── Tool: delete_paywall ───────────────────────────────────────
|
|
1981
|
-
|
|
2050
|
+
tool('delete_paywall', 'Delete an x402 paywall. The access URL will return 404 after deletion.', {
|
|
1982
2051
|
paywall_id: z.number().int().describe('Paywall ID to delete'),
|
|
1983
2052
|
}, async ({ paywall_id }) => {
|
|
1984
2053
|
const data = await api(`/x402/paywalls/${paywall_id}`, 'DELETE');
|
|
1985
2054
|
return jsonResponse(data);
|
|
1986
2055
|
});
|
|
1987
2056
|
// ─── Tool: get_paywall_payments ─────────────────────────────────
|
|
1988
|
-
|
|
2057
|
+
tool('get_paywall_payments', 'Get payment history for a specific x402 paywall. ' +
|
|
1989
2058
|
'Returns verified payments with TX hashes, payer addresses, amounts, and timestamps.', {
|
|
1990
2059
|
paywall_id: z.number().int().describe('Paywall ID'),
|
|
1991
2060
|
page: z.number().int().default(1).describe('Page number'),
|
|
@@ -1995,13 +2064,13 @@ server.tool('get_paywall_payments', 'Get payment history for a specific x402 pay
|
|
|
1995
2064
|
return jsonResponse(data);
|
|
1996
2065
|
});
|
|
1997
2066
|
// ─── Tool: get_x402_revenue ─────────────────────────────────────
|
|
1998
|
-
|
|
2067
|
+
tool('get_x402_revenue', 'Get aggregate x402 revenue statistics across all your paywalls. ' +
|
|
1999
2068
|
'Returns total payments and revenue broken down by chain and token.', {}, async () => {
|
|
2000
2069
|
const data = await api('/x402/revenue');
|
|
2001
2070
|
return jsonResponse(data);
|
|
2002
2071
|
});
|
|
2003
2072
|
// ─── Tool: wallet_mode ──────────────────────────────────────────
|
|
2004
|
-
|
|
2073
|
+
tool('wallet_mode', 'Report whether this server is signing locally (self-custody, the private key never leaves this machine) ' +
|
|
2005
2074
|
'or through the hosted AgentWallet API (custodial). Use this to verify custody before moving funds.', {}, async () => {
|
|
2006
2075
|
if (!anyLocalMode()) {
|
|
2007
2076
|
return jsonResponse({
|
|
@@ -2011,8 +2080,13 @@ server.tool('wallet_mode', 'Report whether this server is signing locally (self-
|
|
|
2011
2080
|
api_base: redactUrl(API_BASE),
|
|
2012
2081
|
client_guards: {
|
|
2013
2082
|
autopay_cap_usd: autopayEnvCap(),
|
|
2083
|
+
internal_autopay_armed: Boolean(X402_WALLET_ID),
|
|
2084
|
+
max_autopay_per_hour: AUTOPAY_PER_HOUR,
|
|
2014
2085
|
autopay_assets: (process.env.AGENTWALLET_AUTOPAY_ASSETS || '').trim() || 'registry stablecoins only',
|
|
2015
2086
|
x402_max_timeout_seconds: maxAuthWindowSeconds(),
|
|
2087
|
+
approvals: process.env.AGENTWALLET_APPROVALS !== '0',
|
|
2088
|
+
permit2_auto_approve: process.env.AGENTWALLET_PERMIT2_AUTO_APPROVE === '1',
|
|
2089
|
+
legacy_paywall_origins: (process.env.AGENTWALLET_LEGACY_PAYWALL_ORIGINS || '').trim() || 'none (API host only)',
|
|
2016
2090
|
note: 'These bound pay_x402 on hosted wallets too; the hosted signer adds its own pause, daily limit and approval checks.',
|
|
2017
2091
|
},
|
|
2018
2092
|
to_self_custody: 'Set AGENTWALLET_PRIVATE_KEY for EVM and/or AGENTWALLET_SOLANA_KEY for Solana, then restart. ' +
|
|
@@ -2023,12 +2097,18 @@ server.tool('wallet_mode', 'Report whether this server is signing locally (self-
|
|
|
2023
2097
|
mode: 'local',
|
|
2024
2098
|
custody: 'self',
|
|
2025
2099
|
signing: 'Signed in this process. Keys are never sent to AgentWallet or anyone else.',
|
|
2026
|
-
max_autopay:
|
|
2027
|
-
|
|
2100
|
+
max_autopay: autopayEnvCap(),
|
|
2101
|
+
max_autopay_per_hour: AUTOPAY_PER_HOUR,
|
|
2102
|
+
internal_autopay_armed: false, // internal auto-pay never runs in local mode
|
|
2103
|
+
autopay_assets: (process.env.AGENTWALLET_AUTOPAY_ASSETS || '').trim() || 'registry stablecoins only',
|
|
2028
2104
|
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)',
|
|
2105
|
+
per_tx_cap_token: (process.env.AGENTWALLET_MAX_TX_TOKEN || '').trim() || 'not set (ERC-20 and SPL transfers are uncapped)',
|
|
2030
2106
|
allow_unknown_token_calls: process.env.AGENTWALLET_ALLOW_UNKNOWN_TOKEN_CALLS === '1',
|
|
2031
2107
|
token_decimals_pins: parseDecimalPins(process.env.AGENTWALLET_TOKEN_DECIMALS).size,
|
|
2108
|
+
token_risk_lookup: process.env.AGENTWALLET_TOKEN_RISK !== '0',
|
|
2109
|
+
permit2_auto_approve: process.env.AGENTWALLET_PERMIT2_AUTO_APPROVE === '1',
|
|
2110
|
+
legacy_paywall_origins: (process.env.AGENTWALLET_LEGACY_PAYWALL_ORIGINS || '').trim() || 'none (API host only)',
|
|
2111
|
+
default_chain_id: (process.env.AGENTWALLET_CHAIN_ID || '').trim() || '8453',
|
|
2032
2112
|
};
|
|
2033
2113
|
if (isLocalMode()) {
|
|
2034
2114
|
let rpc = 'default public endpoint';
|
|
@@ -2039,7 +2119,8 @@ server.tool('wallet_mode', 'Report whether this server is signing locally (self-
|
|
|
2039
2119
|
report.evm = {
|
|
2040
2120
|
address: getLocalAddress(),
|
|
2041
2121
|
rpc_endpoint: redactUrl(rpc),
|
|
2042
|
-
per_tx_cap_native: process.env.AGENTWALLET_MAX_TX_NATIVE || 'not set',
|
|
2122
|
+
per_tx_cap_native: (process.env.AGENTWALLET_MAX_TX_NATIVE || '').trim() || 'not set',
|
|
2123
|
+
per_tx_fee_cap_native: (process.env.AGENTWALLET_MAX_FEE_NATIVE || '').trim() || '0.01 (default)',
|
|
2043
2124
|
};
|
|
2044
2125
|
}
|
|
2045
2126
|
else {
|
|
@@ -2054,8 +2135,8 @@ server.tool('wallet_mode', 'Report whether this server is signing locally (self-
|
|
|
2054
2135
|
report.solana = {
|
|
2055
2136
|
address: getSolanaAddress(),
|
|
2056
2137
|
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)',
|
|
2138
|
+
per_tx_cap_sol: (process.env.AGENTWALLET_MAX_TX_SOL || '').trim() || 'not set',
|
|
2139
|
+
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
2140
|
};
|
|
2060
2141
|
}
|
|
2061
2142
|
else {
|
|
@@ -2064,7 +2145,7 @@ server.tool('wallet_mode', 'Report whether this server is signing locally (self-
|
|
|
2064
2145
|
return jsonResponse(report);
|
|
2065
2146
|
});
|
|
2066
2147
|
// ─── Tool: export_wallet_key ────────────────────────────────────
|
|
2067
|
-
|
|
2148
|
+
tool('export_wallet_key', 'Explain how to export the private key of a hosted (custodial) AgentWallet so it can be moved ' +
|
|
2068
2149
|
'to self-custody or any other wallet. Export itself is deliberately human-gated and is not ' +
|
|
2069
2150
|
'performed by this tool.', {
|
|
2070
2151
|
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/build/local-wallet.d.ts
CHANGED
|
@@ -49,14 +49,15 @@ export declare function localNativeBalance(chainId: number): Promise<{
|
|
|
49
49
|
mode: string;
|
|
50
50
|
}>;
|
|
51
51
|
export declare function localTokenBalance(chainId: number, token: Address): Promise<{
|
|
52
|
+
mode: string;
|
|
53
|
+
warning?: string | undefined;
|
|
52
54
|
address: `0x${string}`;
|
|
53
55
|
chain_id: number;
|
|
54
56
|
token: `0x${string}`;
|
|
55
57
|
symbol: string;
|
|
56
|
-
decimals: number;
|
|
58
|
+
decimals: number | null;
|
|
57
59
|
balance_raw: string;
|
|
58
|
-
balance: string;
|
|
59
|
-
mode: string;
|
|
60
|
+
balance: string | null;
|
|
60
61
|
}>;
|
|
61
62
|
export interface LocalSendResult {
|
|
62
63
|
tx_hash: string;
|
|
@@ -66,6 +67,9 @@ export interface LocalSendResult {
|
|
|
66
67
|
chain_id: number;
|
|
67
68
|
mode: 'local';
|
|
68
69
|
signed_locally: true;
|
|
70
|
+
gas?: string;
|
|
71
|
+
max_fee_per_gas?: string;
|
|
72
|
+
worst_case_fee?: string;
|
|
69
73
|
}
|
|
70
74
|
/**
|
|
71
75
|
* What a decimals() probe learned about an address. The four outcomes are
|
|
@@ -89,7 +93,20 @@ type DecimalsProbe = number | 'unusable' | 'not-a-token' | 'unreachable';
|
|
|
89
93
|
/** An eth_call failure is a revert only when the node says so; every other failure is "could not ask". */
|
|
90
94
|
export declare function classifyDecimalsError(err: unknown): DecimalsProbe;
|
|
91
95
|
export declare function permit2AddressFor(chainId: number): string;
|
|
92
|
-
|
|
96
|
+
/** Caller-chosen gas parameters (wei / gas units as decimal strings); honoured, then bounded by the fee cap. */
|
|
97
|
+
export interface FeeOverrides {
|
|
98
|
+
gas?: string;
|
|
99
|
+
maxFeePerGas?: string;
|
|
100
|
+
maxPriorityFeePerGas?: string;
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* The most a transaction may burn in fees: gas x maxFeePerGas, in native units.
|
|
104
|
+
* Without it a node that answered a 47,000 gwei tip or a 30M gas estimate took
|
|
105
|
+
* the whole balance as fees on a cap-compliant 0.001 ETH transfer (round 4).
|
|
106
|
+
* Default 0.01; AGENTWALLET_MAX_FEE_NATIVE raises or lowers it.
|
|
107
|
+
*/
|
|
108
|
+
export declare function feeCapWei(): bigint;
|
|
109
|
+
export declare function localSend(chainId: number, to: Address, valueWei: bigint, data?: Hex, fees?: FeeOverrides): Promise<LocalSendResult>;
|
|
93
110
|
/** ERC-20 transfer, encoded locally. */
|
|
94
111
|
export declare function localTransferToken(chainId: number, token: Address, to: Address, rawAmount: string): Promise<LocalSendResult>;
|
|
95
112
|
/** ERC-20 approve, encoded locally. */
|
package/build/local-wallet.js
CHANGED
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
* on the custodial path and say so explicitly rather than silently downgrading.
|
|
16
16
|
*/
|
|
17
17
|
import { readFileSync, statSync } from 'node:fs';
|
|
18
|
-
import { createPublicClient, createWalletClient, http, encodeFunctionData, parseAbi, formatUnits, } from 'viem';
|
|
18
|
+
import { createPublicClient, createWalletClient, http, encodeFunctionData, parseAbi, formatUnits, keccak256, } from 'viem';
|
|
19
19
|
import { privateKeyToAccount } from 'viem/accounts';
|
|
20
20
|
import { nonceManager } from 'viem/nonce';
|
|
21
21
|
import { lookupTrustedDecimals, maxAuthWindowSeconds } from './x402-payment.js';
|
|
@@ -109,16 +109,31 @@ const DEFAULT_RPC = {
|
|
|
109
109
|
export function resolveRpcUrl(chainId) {
|
|
110
110
|
const perChain = (process.env[`AGENTWALLET_RPC_${chainId}`] || '').trim();
|
|
111
111
|
if (perChain)
|
|
112
|
-
return perChain;
|
|
112
|
+
return assertRpcScheme(perChain, `AGENTWALLET_RPC_${chainId}`);
|
|
113
113
|
const generic = (process.env.AGENTWALLET_RPC_URL || '').trim();
|
|
114
114
|
if (generic)
|
|
115
|
-
return generic;
|
|
115
|
+
return assertRpcScheme(generic, 'AGENTWALLET_RPC_URL');
|
|
116
116
|
const fallback = DEFAULT_RPC[chainId];
|
|
117
117
|
if (fallback)
|
|
118
118
|
return fallback;
|
|
119
119
|
throw new Error(`No RPC endpoint for chain ${chainId} in local mode. ` +
|
|
120
120
|
`Set AGENTWALLET_RPC_${chainId} to an endpoint you trust.`);
|
|
121
121
|
}
|
|
122
|
+
/** An RPC over plain http to anything but loopback is the hostile-node model in full; refused like the API URL is. */
|
|
123
|
+
function assertRpcScheme(url, name) {
|
|
124
|
+
let u;
|
|
125
|
+
try {
|
|
126
|
+
u = new URL(url);
|
|
127
|
+
}
|
|
128
|
+
catch {
|
|
129
|
+
throw new Error(`${name} is not a URL: "${url}".`);
|
|
130
|
+
}
|
|
131
|
+
const loopback = u.hostname === 'localhost' || u.hostname === '127.0.0.1' || u.hostname === '[::1]';
|
|
132
|
+
if (u.protocol !== 'https:' && !(u.protocol === 'http:' && loopback)) {
|
|
133
|
+
throw new Error(`${name} must be https (got ${u.protocol}//${u.host}); plain http is allowed only for localhost.`);
|
|
134
|
+
}
|
|
135
|
+
return url;
|
|
136
|
+
}
|
|
122
137
|
/**
|
|
123
138
|
* Chain ids each RPC URL has been seen to serve, so an operator pointing
|
|
124
139
|
* AGENTWALLET_RPC_URL at one node for every chain, or mistyping a per-chain
|
|
@@ -187,16 +202,21 @@ const ERC20_ABI = parseAbi([
|
|
|
187
202
|
export async function localTokenBalance(chainId, token) {
|
|
188
203
|
const { pub } = clients(chainId);
|
|
189
204
|
const address = getLocalAddress();
|
|
190
|
-
const [raw,
|
|
205
|
+
const [raw, decimalsRaw, symbol] = await Promise.all([
|
|
191
206
|
pub.readContract({ address: token, abi: ERC20_ABI, functionName: 'balanceOf', args: [address] }),
|
|
192
|
-
pub.readContract({ address: token, abi: ERC20_ABI, functionName: 'decimals' }).catch(() =>
|
|
207
|
+
pub.readContract({ address: token, abi: ERC20_ABI, functionName: 'decimals' }).catch(() => null),
|
|
193
208
|
pub.readContract({ address: token, abi: ERC20_ABI, functionName: 'symbol' }).catch(() => ''),
|
|
194
209
|
]);
|
|
210
|
+
// A decimals() of 2^20 handed to viem's formatUnits held the whole server for
|
|
211
|
+
// minutes (quadratic trailing-zero trim, round 4): only 0..36 is formatted.
|
|
212
|
+
const d = Number(decimalsRaw);
|
|
213
|
+
const safe = Number.isInteger(d) && d >= 0 && d <= 36 ? d : null;
|
|
195
214
|
return {
|
|
196
215
|
address, chain_id: chainId, token, symbol,
|
|
197
|
-
decimals:
|
|
216
|
+
decimals: safe,
|
|
198
217
|
balance_raw: raw.toString(),
|
|
199
|
-
balance: formatUnits(raw,
|
|
218
|
+
balance: safe === null ? null : formatUnits(raw, safe),
|
|
219
|
+
...(safe === null ? { warning: 'the token reported decimals outside 0..36 (or none); only the raw balance is shown' } : {}),
|
|
200
220
|
mode: 'local',
|
|
201
221
|
};
|
|
202
222
|
}
|
|
@@ -261,8 +281,11 @@ async function probeTokenDecimals(chainId, token) {
|
|
|
261
281
|
return 'unusable'; // answered, but not one uint8 word
|
|
262
282
|
const d = Number(BigInt(result));
|
|
263
283
|
// Reject nonsense rather than trusting it; an out-of-range value would
|
|
264
|
-
// widen the ceiling exactly like the old assumption did.
|
|
265
|
-
|
|
284
|
+
// widen the ceiling exactly like the old assumption did. Above 18 the RPC is
|
|
285
|
+
// the only witness and a lie there scales the cap by 10^18 (a node saying 36
|
|
286
|
+
// for mainnet WETH, round 4): such tokens must be pinned with
|
|
287
|
+
// AGENTWALLET_TOKEN_DECIMALS.
|
|
288
|
+
if (Number.isInteger(d) && d >= 0 && d <= 18) {
|
|
266
289
|
decimalsCache.set(key, d);
|
|
267
290
|
return d;
|
|
268
291
|
}
|
|
@@ -435,7 +458,25 @@ async function assertWithinTokenCap(chainId, to, data, valueWei = 0n) {
|
|
|
435
458
|
}
|
|
436
459
|
}
|
|
437
460
|
}
|
|
438
|
-
|
|
461
|
+
function parseUint(v, what) {
|
|
462
|
+
if (!/^\d{1,40}$/.test(v))
|
|
463
|
+
throw new Error(`${what} must be a non-negative integer string, got "${v}".`);
|
|
464
|
+
return BigInt(v);
|
|
465
|
+
}
|
|
466
|
+
/**
|
|
467
|
+
* The most a transaction may burn in fees: gas x maxFeePerGas, in native units.
|
|
468
|
+
* Without it a node that answered a 47,000 gwei tip or a 30M gas estimate took
|
|
469
|
+
* the whole balance as fees on a cap-compliant 0.001 ETH transfer (round 4).
|
|
470
|
+
* Default 0.01; AGENTWALLET_MAX_FEE_NATIVE raises or lowers it.
|
|
471
|
+
*/
|
|
472
|
+
export function feeCapWei() {
|
|
473
|
+
const raw = (process.env.AGENTWALLET_MAX_FEE_NATIVE || '0.01').trim();
|
|
474
|
+
if (!/^\d+(\.\d+)?$/.test(raw))
|
|
475
|
+
throw new Error(`AGENTWALLET_MAX_FEE_NATIVE must be a decimal number, got "${raw}".`);
|
|
476
|
+
const [whole, frac = ''] = raw.split('.');
|
|
477
|
+
return BigInt(whole + frac.padEnd(18, '0').slice(0, 18));
|
|
478
|
+
}
|
|
479
|
+
export async function localSend(chainId, to, valueWei, data, fees) {
|
|
439
480
|
assertWithinNativeCap(valueWei);
|
|
440
481
|
if (data && data !== '0x') {
|
|
441
482
|
if (!/^0x([0-9a-fA-F]{2})*$/.test(data))
|
|
@@ -443,20 +484,73 @@ export async function localSend(chainId, to, valueWei, data) {
|
|
|
443
484
|
await assertWithinTokenCap(chainId, to, data, valueWei);
|
|
444
485
|
}
|
|
445
486
|
await assertRpcServesChain(chainId);
|
|
446
|
-
const { wallet } = clients(chainId);
|
|
447
|
-
const
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
487
|
+
const { pub, wallet } = clients(chainId);
|
|
488
|
+
const account = getLocalAccount();
|
|
489
|
+
const base = { account, to, value: valueWei, ...(data ? { data } : {}) };
|
|
490
|
+
// Gas: the caller's figure when given (it used to be read and dropped,
|
|
491
|
+
// round 4), otherwise the node's estimate.
|
|
492
|
+
const gas = fees?.gas ? parseUint(fees.gas, 'gas_limit') : await pub.estimateGas(base);
|
|
493
|
+
// Fees: the caller's figures when given, otherwise the node's; either way the
|
|
494
|
+
// worst case gas x maxFeePerGas must fit the fee cap before anything is signed.
|
|
495
|
+
let maxFeePerGas;
|
|
496
|
+
let maxPriorityFeePerGas;
|
|
497
|
+
let gasPrice;
|
|
498
|
+
if (fees?.maxFeePerGas) {
|
|
499
|
+
maxFeePerGas = parseUint(fees.maxFeePerGas, 'max_fee');
|
|
500
|
+
maxPriorityFeePerGas = fees.maxPriorityFeePerGas ? parseUint(fees.maxPriorityFeePerGas, 'priority_fee') : maxFeePerGas;
|
|
501
|
+
}
|
|
502
|
+
else {
|
|
503
|
+
try {
|
|
504
|
+
const est = await pub.estimateFeesPerGas();
|
|
505
|
+
maxFeePerGas = est.maxFeePerGas;
|
|
506
|
+
maxPriorityFeePerGas = fees?.maxPriorityFeePerGas ? parseUint(fees.maxPriorityFeePerGas, 'priority_fee') : est.maxPriorityFeePerGas;
|
|
507
|
+
}
|
|
508
|
+
catch {
|
|
509
|
+
gasPrice = (await pub.estimateFeesPerGas({ type: 'legacy' })).gasPrice; // a chain without EIP-1559
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
if (maxFeePerGas !== undefined && maxPriorityFeePerGas !== undefined && maxPriorityFeePerGas > maxFeePerGas)
|
|
513
|
+
maxPriorityFeePerGas = maxFeePerGas;
|
|
514
|
+
const perGas = maxFeePerGas ?? gasPrice ?? 0n;
|
|
515
|
+
const worst = gas * perGas;
|
|
516
|
+
const feeCap = feeCapWei();
|
|
517
|
+
if (worst > feeCap) {
|
|
518
|
+
throw new Error(`Blocked by local guard: the transaction could burn up to ${formatUnits(worst, 18)} native in fees (gas ${gas} x ${formatUnits(perGas, 9)} gwei), ` +
|
|
519
|
+
`above AGENTWALLET_MAX_FEE_NATIVE (${formatUnits(feeCap, 18)}). A node answering an absurd fee or gas estimate is the usual cause; ` +
|
|
520
|
+
`pass gas_limit / max_fee / priority_fee explicitly, or the operator can raise the cap.`);
|
|
521
|
+
}
|
|
522
|
+
// Sign here, hash here, then hand the bytes to the node: the hash reported is
|
|
523
|
+
// the one computed from the signed transaction, never the node's answer.
|
|
524
|
+
const address = getLocalAddress();
|
|
525
|
+
const nonce = await account.nonceManager.consume({ address, chainId, client: pub });
|
|
526
|
+
const request = await wallet.prepareTransactionRequest({
|
|
527
|
+
...base, gas, nonce, chainId,
|
|
528
|
+
...(gasPrice !== undefined ? { type: 'legacy', gasPrice } : { type: 'eip1559', maxFeePerGas: maxFeePerGas, maxPriorityFeePerGas: maxPriorityFeePerGas }),
|
|
451
529
|
});
|
|
530
|
+
const signed = await wallet.signTransaction(request);
|
|
531
|
+
const localHash = keccak256(signed);
|
|
532
|
+
let reported;
|
|
533
|
+
try {
|
|
534
|
+
reported = await pub.sendRawTransaction({ serializedTransaction: signed });
|
|
535
|
+
}
|
|
536
|
+
catch (e) {
|
|
537
|
+
account.nonceManager.reset({ address, chainId });
|
|
538
|
+
throw new Error(`Broadcast of transaction ${localHash} (nonce ${nonce}) failed: ${e.message.split('\n')[0]}. The transaction is signed and MAY be live if the node forwarded it before answering; check the hash on an explorer before retrying.`);
|
|
539
|
+
}
|
|
540
|
+
if (reported.toLowerCase() !== localHash.toLowerCase()) {
|
|
541
|
+
throw new Error(`Transaction ${localHash} (nonce ${nonce}) was BROADCAST, but the node answered with a different hash (${String(reported).slice(0, 80)}), so it cannot be trusted about its fate. Verify ${localHash} on an explorer before retrying.`);
|
|
542
|
+
}
|
|
452
543
|
return {
|
|
453
|
-
tx_hash:
|
|
454
|
-
from:
|
|
544
|
+
tx_hash: localHash,
|
|
545
|
+
from: address,
|
|
455
546
|
to,
|
|
456
547
|
value: valueWei.toString(),
|
|
457
548
|
chain_id: chainId,
|
|
458
549
|
mode: 'local',
|
|
459
550
|
signed_locally: true,
|
|
551
|
+
gas: gas.toString(),
|
|
552
|
+
max_fee_per_gas: (maxFeePerGas ?? gasPrice ?? 0n).toString(),
|
|
553
|
+
worst_case_fee: formatUnits(worst, 18),
|
|
460
554
|
};
|
|
461
555
|
}
|
|
462
556
|
/** ERC-20 transfer, encoded locally. */
|
package/build/x402-payment.d.ts
CHANGED
|
@@ -1,19 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Pure helpers for x402 auto-pay amount/token derivation.
|
|
3
|
-
*
|
|
4
|
-
* Isolated from the server bootstrap in index.ts so the funds-sensitive amount
|
|
5
|
-
* math can be unit-tested without starting the MCP server.
|
|
6
|
-
*
|
|
7
|
-
* SECURITY INVARIANT: token decimals must NEVER come from the 402 response.
|
|
8
|
-
* A resource server controls `asset`, `payTo`, `maxAmountRequired` AND
|
|
9
|
-
* `requiredDecimals`. If the cap is scaled using server-declared decimals, the
|
|
10
|
-
* server can inflate the cap arbitrarily: declaring 18 decimals for 6-decimal
|
|
11
|
-
* USDC turns a "1 USDC" cap into 10^18 base units, so a request for
|
|
12
|
-
* 1,000,000,000,000 units (1,000,000 USDC) passes a 1 USDC cap. Reported by
|
|
13
|
-
* ARC Security Research, 2026-08-02, against 1.10.1. Decimals are now resolved
|
|
14
|
-
* from a trusted source and the declared value is only ever used to detect a
|
|
15
|
-
* mismatch and refuse.
|
|
16
|
-
*/
|
|
17
1
|
export interface X402AcceptLike {
|
|
18
2
|
maxAmountRequired: string;
|
|
19
3
|
requiredDecimals?: number;
|
package/build/x402-payment.js
CHANGED
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
* from a trusted source and the declared value is only ever used to detect a
|
|
15
15
|
* mismatch and refuse.
|
|
16
16
|
*/
|
|
17
|
+
import { wrappedNativeAddress } from './wrapped-native.js';
|
|
17
18
|
/**
|
|
18
19
|
* Decimals for assets that x402 endpoints actually settle in, keyed by
|
|
19
20
|
* chain id then lowercased contract address (or SPL mint on Solana).
|
|
@@ -283,6 +284,11 @@ export function lookupTrustedDecimals(chainId, token) {
|
|
|
283
284
|
const pinned = pinnedDecimals(chainId, token);
|
|
284
285
|
if (pinned !== null)
|
|
285
286
|
return pinned;
|
|
287
|
+
// Every chain's wrapped native coin is 18 decimals (WETH9 clones; the local
|
|
288
|
+
// guard asserts it). The registry carried WETH for Base only, so mainnet WETH
|
|
289
|
+
// was priced by the RPC, which could say 36 (2026-10-04 round 4).
|
|
290
|
+
if (wrappedNativeAddress(chainId)?.toLowerCase() === token.toLowerCase())
|
|
291
|
+
return 18;
|
|
286
292
|
const spl = TRUSTED_SPL_DECIMALS[token];
|
|
287
293
|
if (typeof spl === 'number')
|
|
288
294
|
return spl;
|
package/package.json
CHANGED
|
@@ -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.11",
|
|
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 && node test/audit-11311.test.mjs",
|
|
20
21
|
"prepublishOnly": "npm run build"
|
|
21
22
|
},
|
|
22
23
|
"keywords": [
|