agentwallet-mcp 1.7.5 → 1.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +53 -8
- package/build/index.js +205 -28
- package/build/local-wallet.d.ts +94 -0
- package/build/local-wallet.js +225 -0
- package/build/x402-payment.d.ts +14 -0
- package/build/x402-payment.js +20 -0
- package/package.json +8 -4
package/README.md
CHANGED
|
@@ -1,29 +1,70 @@
|
|
|
1
1
|
# AgentWallet MCP Server
|
|
2
2
|
|
|
3
|
-
Permissionless wallet infrastructure for AI agents. Create wallets, sign transactions, and broadcast on-chain
|
|
3
|
+
Permissionless wallet infrastructure for AI agents. Create wallets, sign transactions, and broadcast on-chain, on any EVM chain and Solana. Built-in guards. No KYC. The only AI agent wallet that accepts crypto for its own API fees.
|
|
4
|
+
|
|
5
|
+
**Your keys can stay on your machine.** Set one environment variable and every signature happens in your own process. No server sees the key, no company can freeze the wallet, and you can verify that claim by reading `src/local-wallet.ts` or by running the server with the API pointed at a closed port.
|
|
4
6
|
|
|
5
7
|
**No KYC. No KYT. No approval process. No transaction monitoring. No one can block your wallet. Pay with USDC on-chain — no credit card required.**
|
|
6
8
|
|
|
9
|
+
## Two modes
|
|
10
|
+
|
|
11
|
+
| | Local (self-custody) | Hosted (custodial) |
|
|
12
|
+
|---|---|---|
|
|
13
|
+
| Who holds the key | You, in your own process | Encrypted on AgentWallet servers |
|
|
14
|
+
| Set up | One env var, no account needed | API key |
|
|
15
|
+
| Chains | EVM | EVM + Solana |
|
|
16
|
+
| Can anyone freeze it | No | Yes, that is what pause is for |
|
|
17
|
+
| Spend guards | `AGENTWALLET_MAX_TX_NATIVE`, `AGENTWALLET_MAX_AUTOPAY` | Server-side limits, pause, rate limits |
|
|
18
|
+
| Paywalls, usage, billing | Needs an API key too | Included |
|
|
19
|
+
|
|
20
|
+
Run `wallet_mode` at any time and the server will tell you which one you are in, and which address it controls.
|
|
21
|
+
|
|
22
|
+
### Local signing in one variable
|
|
23
|
+
|
|
24
|
+
```json
|
|
25
|
+
{
|
|
26
|
+
"mcpServers": {
|
|
27
|
+
"agentwallet": {
|
|
28
|
+
"command": "npx",
|
|
29
|
+
"args": ["-y", "agentwallet-mcp"],
|
|
30
|
+
"env": {
|
|
31
|
+
"AGENTWALLET_PRIVATE_KEY": "0xyour_key",
|
|
32
|
+
"AGENTWALLET_RPC_8453": "https://your-own-rpc",
|
|
33
|
+
"AGENTWALLET_MAX_TX_NATIVE": "0.05"
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
```
|
|
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.
|
|
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.
|
|
43
|
+
|
|
44
|
+
`AGENTWALLET_MAX_TX_NATIVE` is a per-transaction ceiling in native units. In hosted mode the server enforces limits; in local mode there is no server, so this guard is the only one there is. Set it.
|
|
45
|
+
|
|
46
|
+
**Local mode is EVM only right now.** Solana calls are refused with a clear error rather than quietly falling back to the custodial API, because silently moving your funds onto someone else's signer is exactly the behavior this mode exists to prevent.
|
|
47
|
+
|
|
7
48
|
<p align="center">
|
|
8
49
|
<img src="assets/demo.svg" alt="AgentWallet demo — AI agent pays x402 invoice automatically" width="800">
|
|
9
50
|
</p>
|
|
10
51
|
|
|
11
52
|
## Features
|
|
12
53
|
|
|
13
|
-
- **
|
|
54
|
+
- **31 MCP tools** — create wallets, send transactions, approve tokens, wrap ETH, transfer SPL tokens, pay and accept x402 payments, verify custody mode, and more
|
|
14
55
|
- **EVM + Solana** — Ethereum, Base, Polygon, BSC, Arbitrum, Optimism, Avalanche, Zora, PulseChain, Solana, and any other EVM-compatible chain
|
|
15
56
|
- **SOL + SPL tokens** — native SOL transfers and SPL token transfers (USDC, USDT, etc.) with automatic account creation
|
|
16
57
|
- **Built-in guards** — daily spending limits, gas price protection, emergency pause, rate limiting, replay protection, and on-chain verification — all active by default
|
|
17
58
|
- **x402 payments** — pay for x402-enabled APIs automatically, or accept x402 payments on your own endpoints (EVM and Solana)
|
|
18
|
-
- **
|
|
59
|
+
- **Self-custody option** — run local mode and the key never leaves your machine. In hosted mode, keys are encrypted at rest, decrypted only during signing, and zeroed from memory immediately after. Either way you can export and walk away.
|
|
19
60
|
- **Permissionless** — No KYC. No KYT. No identity verification. No approval process. No compliance gatekeeping. Sign up, get an API key, and start transacting immediately.
|
|
20
61
|
- **30-second setup** — three lines of config. No SDK to install. No dependencies to manage.
|
|
21
62
|
|
|
22
63
|
## Pricing
|
|
23
64
|
|
|
24
|
-
- **$0.00345 per operation** (31%
|
|
65
|
+
- **$0.00345 per operation** (31% under Coinbase CDP list pricing, checked 2026-07-29)
|
|
25
66
|
- **6,000 free operations/month**
|
|
26
|
-
- **$0.0005 per x402 verification** (50%
|
|
67
|
+
- **$0.0005 per x402 verification** (50% under Coinbase, checked 2026-07-29)
|
|
27
68
|
- **1,000 free x402 verifications/month**
|
|
28
69
|
- **Pay with USDC on-chain** via x402 — no credit card required
|
|
29
70
|
- No monthly fee, no tiers — just pay as you go
|
|
@@ -115,6 +156,8 @@ Add to your settings:
|
|
|
115
156
|
| `delete_paywall` | Delete a paywall |
|
|
116
157
|
| `get_paywall_payments` | View payment history for a paywall |
|
|
117
158
|
| `get_x402_revenue` | Aggregate revenue stats across all paywalls |
|
|
159
|
+
| `wallet_mode` | Report whether signing is local (self-custody) or hosted, and which address is in use |
|
|
160
|
+
| `export_wallet_key` | How to export a hosted wallet key and move to self-custody |
|
|
118
161
|
| `buy_verification_credits` | Buy x402 verification credits with USDC on-chain |
|
|
119
162
|
| `get_usage` | Check your monthly usage and billing |
|
|
120
163
|
| `get_chains` | List all supported chains |
|
|
@@ -160,7 +203,7 @@ AgentWallet natively supports the [x402 open payment standard](https://x402.org)
|
|
|
160
203
|
4. Retries the request with proof of payment
|
|
161
204
|
5. Returns the final response
|
|
162
205
|
|
|
163
|
-
**
|
|
206
|
+
**Set `max_payment` to control spending:**
|
|
164
207
|
|
|
165
208
|
```
|
|
166
209
|
pay_x402(
|
|
@@ -170,7 +213,9 @@ pay_x402(
|
|
|
170
213
|
)
|
|
171
214
|
```
|
|
172
215
|
|
|
173
|
-
|
|
216
|
+
`max_payment` is enforced as a hard per-payment cap. If you omit it, `pay_x402` falls back to `AGENTWALLET_MAX_AUTOPAY` (default `1`), the same cap the auto-pay path uses, so a malicious or compromised 402 endpoint can never authorize an unbounded payment. Set `max_payment` explicitly (or raise `AGENTWALLET_MAX_AUTOPAY`) to allow a larger single payment.
|
|
217
|
+
|
|
218
|
+
Supports ERC-20 tokens, SPL tokens, and native tokens on EVM and Solana. Compatible with x402 V1 and V2 (CAIP-2 chain identifiers), and reads the token address from the standard x402 `asset` field (falling back to `extra.token`).
|
|
174
219
|
|
|
175
220
|
## x402 Acceptance
|
|
176
221
|
|
|
@@ -194,7 +239,7 @@ When an agent hits the paywall URL:
|
|
|
194
239
|
3. Retries with proof of payment
|
|
195
240
|
4. Receives the protected content
|
|
196
241
|
|
|
197
|
-
On-chain verification ensures every payment is real. Replay protection prevents double-spending. Revenue tracking shows you who paid, how much, and when. 1,000 free verifications/month, then $0.0005 each
|
|
242
|
+
On-chain verification ensures every payment is real. Replay protection prevents double-spending. Revenue tracking shows you who paid, how much, and when. 1,000 free verifications/month, then $0.0005 each, 50% under Coinbase as of 2026-07-29.
|
|
198
243
|
|
|
199
244
|
## How We Compare
|
|
200
245
|
|
package/build/index.js
CHANGED
|
@@ -12,17 +12,111 @@
|
|
|
12
12
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
13
13
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
14
14
|
import { z } from 'zod';
|
|
15
|
-
import { deriveX402Payment } from './x402-payment.js';
|
|
15
|
+
import { deriveX402Payment, isWithinCap } from './x402-payment.js';
|
|
16
|
+
import { isLocalMode, getLocalAddress, localWalletRecord, localSend, localNativeBalance, localTokenBalance, localSignMessage, resolveRpcUrl, } from './local-wallet.js';
|
|
16
17
|
// ─── Configuration ──────────────────────────────────────────────
|
|
17
18
|
const API_BASE = process.env.AGENTWALLET_API_URL || 'https://hifriendbot.com/wp-json/agentwallet/v1';
|
|
18
19
|
const API_USER = process.env.AGENTWALLET_USER || '';
|
|
19
20
|
const API_PASS = process.env.AGENTWALLET_PASS || ''; // WordPress application password
|
|
20
21
|
const X402_WALLET_ID = process.env.AGENTWALLET_WALLET_ID || ''; // Wallet ID for x402 auto-pay
|
|
22
|
+
const DASHBOARD_URL = process.env.AGENTWALLET_DASHBOARD_URL || 'https://hifriendbot.com/wallet/';
|
|
23
|
+
/* ── Local-mode routing ──────────────────────────────────────────
|
|
24
|
+
Translates the REST paths the tools already speak into in-process signing
|
|
25
|
+
calls. Anything not listed here falls through to the hosted API, so
|
|
26
|
+
non-custodial concerns (paywalls, billing, usage) still work if the operator
|
|
27
|
+
has also configured credentials. */
|
|
28
|
+
const NOT_HANDLED = Symbol('not-handled');
|
|
29
|
+
function localChainId(body, qs) {
|
|
30
|
+
const fromBody = body?.chain_id;
|
|
31
|
+
if (typeof fromBody === 'number')
|
|
32
|
+
return fromBody;
|
|
33
|
+
const fromQs = qs?.get('chain_id');
|
|
34
|
+
if (fromQs)
|
|
35
|
+
return parseInt(fromQs, 10);
|
|
36
|
+
const fromEnv = (process.env.AGENTWALLET_CHAIN_ID || '').trim();
|
|
37
|
+
if (fromEnv)
|
|
38
|
+
return parseInt(fromEnv, 10);
|
|
39
|
+
return 8453; // Base, matching the custodial default
|
|
40
|
+
}
|
|
41
|
+
function refuseSolanaLocally() {
|
|
42
|
+
throw new Error('Local signing mode currently supports EVM chains only. Solana keys are not signed in-process yet, ' +
|
|
43
|
+
'so this call was refused rather than silently routed through the hosted custodial API. ' +
|
|
44
|
+
'Unset AGENTWALLET_PRIVATE_KEY to use the custodial path for Solana.');
|
|
45
|
+
}
|
|
46
|
+
async function routeLocally(path, method, body) {
|
|
47
|
+
const [rawPath, rawQs = ''] = path.split('?');
|
|
48
|
+
const qs = new URLSearchParams(rawQs);
|
|
49
|
+
// POST /wallets/{id}/send
|
|
50
|
+
if (method === 'POST' && /^\/wallets\/[^/]*\/send$/.test(rawPath)) {
|
|
51
|
+
if (body?.token_mint)
|
|
52
|
+
refuseSolanaLocally();
|
|
53
|
+
const chainId = localChainId(body, qs);
|
|
54
|
+
if (SOLANA_CHAIN_IDS.has(chainId))
|
|
55
|
+
refuseSolanaLocally();
|
|
56
|
+
const to = String(body?.to || '');
|
|
57
|
+
const value = BigInt(String(body?.value ?? '0'));
|
|
58
|
+
const data = String(body?.data || '');
|
|
59
|
+
if (!/^0x[a-fA-F0-9]{40}$/.test(to)) {
|
|
60
|
+
throw new Error(`Local mode expects an EVM address, got "${to}".`);
|
|
61
|
+
}
|
|
62
|
+
const hasData = data && data !== '0x';
|
|
63
|
+
return localSend(chainId, to, value, hasData ? data : undefined);
|
|
64
|
+
}
|
|
65
|
+
// GET /wallets/{id}/balance
|
|
66
|
+
if (method === 'GET' && /^\/wallets\/[^/]*\/balance$/.test(rawPath)) {
|
|
67
|
+
const chainId = localChainId(body, qs);
|
|
68
|
+
if (SOLANA_CHAIN_IDS.has(chainId))
|
|
69
|
+
refuseSolanaLocally();
|
|
70
|
+
return localNativeBalance(chainId);
|
|
71
|
+
}
|
|
72
|
+
// GET /wallets/{id}/token-balance
|
|
73
|
+
if (method === 'GET' && /^\/wallets\/[^/]*\/token-balance$/.test(rawPath)) {
|
|
74
|
+
const chainId = localChainId(body, qs);
|
|
75
|
+
if (SOLANA_CHAIN_IDS.has(chainId))
|
|
76
|
+
refuseSolanaLocally();
|
|
77
|
+
const token = qs.get('token') || qs.get('token_address') || '';
|
|
78
|
+
if (!/^0x[a-fA-F0-9]{40}$/.test(token)) {
|
|
79
|
+
throw new Error(`Local mode needs an EVM token address, got "${token}".`);
|
|
80
|
+
}
|
|
81
|
+
return localTokenBalance(chainId, token);
|
|
82
|
+
}
|
|
83
|
+
// POST /wallets/{id}/sign (message signing only; tx signing goes through send)
|
|
84
|
+
if (method === 'POST' && /^\/wallets\/[^/]*\/sign$/.test(rawPath)) {
|
|
85
|
+
const message = body?.message;
|
|
86
|
+
if (typeof message === 'string' && message)
|
|
87
|
+
return localSignMessage(message);
|
|
88
|
+
throw new Error('Local mode signs messages in-process. For transactions use send_transaction, ' +
|
|
89
|
+
'which signs and broadcasts locally in one step.');
|
|
90
|
+
}
|
|
91
|
+
// Wallet listing and lookup describe the one local key.
|
|
92
|
+
if (method === 'GET' && rawPath === '/wallets')
|
|
93
|
+
return { wallets: [localWalletRecord()] };
|
|
94
|
+
if (method === 'GET' && /^\/wallets\/[^/]+$/.test(rawPath))
|
|
95
|
+
return localWalletRecord();
|
|
96
|
+
if (method === 'POST' && rawPath === '/wallets') {
|
|
97
|
+
throw new Error('Local signing mode uses the single key you supplied, so there is nothing to create. ' +
|
|
98
|
+
'Generate another key yourself and run a second instance with a different ' +
|
|
99
|
+
'AGENTWALLET_PRIVATE_KEY, or unset it to create hosted wallets.');
|
|
100
|
+
}
|
|
101
|
+
if (/^\/wallets\/[^/]*\/(pause|unpause)$/.test(rawPath) || (method === 'DELETE' && /^\/wallets\/[^/]+$/.test(rawPath))) {
|
|
102
|
+
throw new Error('Pause, unpause and delete are custodial controls: they work by refusing to sign on the server. ' +
|
|
103
|
+
'In local mode the key is yours, so nothing can freeze it. Stop the process or move the funds.');
|
|
104
|
+
}
|
|
105
|
+
return NOT_HANDLED;
|
|
106
|
+
}
|
|
21
107
|
/**
|
|
22
108
|
* Make an API call. If the response is 402 and auto-pay is configured,
|
|
23
109
|
* automatically pay via x402 and retry the request.
|
|
24
110
|
*/
|
|
25
111
|
async function api(path, method = 'GET', body, extraHeaders) {
|
|
112
|
+
/* Local mode intercepts here rather than inside each tool. Routing at the
|
|
113
|
+
single choke point every funds operation already passes through means no
|
|
114
|
+
tool can quietly stay custodial because someone forgot to update it. */
|
|
115
|
+
if (isLocalMode()) {
|
|
116
|
+
const handled = await routeLocally(path, method, body);
|
|
117
|
+
if (handled !== NOT_HANDLED)
|
|
118
|
+
return handled;
|
|
119
|
+
}
|
|
26
120
|
const url = `${API_BASE}${path}`;
|
|
27
121
|
const headers = {
|
|
28
122
|
'Content-Type': 'application/json',
|
|
@@ -37,8 +131,8 @@ async function api(path, method = 'GET', body, extraHeaders) {
|
|
|
37
131
|
}
|
|
38
132
|
const res = await fetch(url, options);
|
|
39
133
|
const data = await res.json();
|
|
40
|
-
// Handle 402 Payment Required — auto-pay if wallet configured
|
|
41
|
-
if (res.status === 402 && X402_WALLET_ID && !extraHeaders?.['X-PAYMENT']) {
|
|
134
|
+
// Handle 402 Payment Required — auto-pay if a wallet or a local key is configured
|
|
135
|
+
if (res.status === 402 && (X402_WALLET_ID || isLocalMode()) && !extraHeaders?.['X-PAYMENT']) {
|
|
42
136
|
return handleX402Payment(data, path, method, body);
|
|
43
137
|
}
|
|
44
138
|
if (!res.ok) {
|
|
@@ -221,7 +315,7 @@ function resolveChainId(network) {
|
|
|
221
315
|
// ─── Server ──────────────────────────────────────────────────────
|
|
222
316
|
const server = new McpServer({
|
|
223
317
|
name: 'agentwallet',
|
|
224
|
-
version: '1.
|
|
318
|
+
version: '1.8.0',
|
|
225
319
|
}, {
|
|
226
320
|
instructions: `AgentWallet gives AI agents their own blockchain wallets. Private keys are encrypted server-side and never exposed — agents sign and broadcast transactions without ever touching raw keys.
|
|
227
321
|
|
|
@@ -744,7 +838,10 @@ server.tool('pay_x402', 'Handle an x402 payment flow. Fetches a URL, and if the
|
|
|
744
838
|
headers: z.string().optional().describe('Optional JSON string of additional request headers'),
|
|
745
839
|
body: z.string().optional().describe('Optional request body for POST/PUT requests'),
|
|
746
840
|
max_payment: z.string().optional().describe('Maximum payment in human-readable format (e.g. "1.00" for 1 USDC). ' +
|
|
747
|
-
'Rejects payments above this amount.
|
|
841
|
+
'Rejects payments above this amount. If omitted, a default cap of ' +
|
|
842
|
+
'AGENTWALLET_MAX_AUTOPAY (default "1") is applied, so an untrusted 402 ' +
|
|
843
|
+
'endpoint can never authorize an unbounded payment. Set this explicitly ' +
|
|
844
|
+
'to allow a larger single payment.'),
|
|
748
845
|
prefer_chain: z.number().int().optional().describe('Preferred chain ID if the server accepts payment on multiple chains ' +
|
|
749
846
|
'(e.g. 8453 for Base, 1 for Ethereum)'),
|
|
750
847
|
}, async ({ url, wallet_id, method, headers: headersJson, body: reqBody, max_payment, prefer_chain }) => {
|
|
@@ -826,35 +923,45 @@ server.tool('pay_x402', 'Handle an x402 payment flow. Fetches a URL, and if the
|
|
|
826
923
|
}
|
|
827
924
|
// Calculate human-readable amount
|
|
828
925
|
const amount = formatUnits(option.maxAmountRequired, option.requiredDecimals);
|
|
829
|
-
// Step 4:
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
926
|
+
// Step 4: Enforce a hard per-payment cap. ALWAYS applied — if the caller
|
|
927
|
+
// omits max_payment we fall back to AGENTWALLET_MAX_AUTOPAY (default "1"),
|
|
928
|
+
// mirroring the internal auto-pay path (deriveX402Payment). Without this, an
|
|
929
|
+
// untrusted or compromised x402 endpoint could return an arbitrarily large
|
|
930
|
+
// maxAmountRequired and drain the wallet. The cap is never silently skipped.
|
|
931
|
+
const capSource = max_payment ? 'max_payment' : 'AGENTWALLET_MAX_AUTOPAY';
|
|
932
|
+
const effectiveMax = max_payment || process.env.AGENTWALLET_MAX_AUTOPAY || '1';
|
|
933
|
+
if (!isWithinCap(option.maxAmountRequired, option.requiredDecimals, effectiveMax)) {
|
|
934
|
+
return jsonResponse({
|
|
935
|
+
status: 402,
|
|
936
|
+
payment_required: true,
|
|
937
|
+
payment_made: false,
|
|
938
|
+
error: `Payment of ${amount} ${option.extra?.name || 'tokens'} exceeds the ` +
|
|
939
|
+
`${effectiveMax} cap (from ${capSource}).` +
|
|
940
|
+
(max_payment ? '' : ' No max_payment was provided, so the default cap was applied. ' +
|
|
941
|
+
'Pass max_payment (or raise AGENTWALLET_MAX_AUTOPAY) to allow a larger payment.'),
|
|
942
|
+
required_amount: amount,
|
|
943
|
+
max_allowed: effectiveMax,
|
|
944
|
+
cap_source: capSource,
|
|
945
|
+
token: option.extra?.name || 'native',
|
|
946
|
+
network: option.network,
|
|
947
|
+
chain_id: chainId,
|
|
948
|
+
pay_to: option.payTo,
|
|
949
|
+
description: option.description,
|
|
950
|
+
});
|
|
847
951
|
}
|
|
952
|
+
// Standard x402 puts the token address in `asset`; AgentWallet's own server
|
|
953
|
+
// currently emits it under extra.token. Prefer the standard field, fall back.
|
|
954
|
+
const tokenAddress = option.asset || option.extra?.token || '';
|
|
848
955
|
// Step 5: Execute payment
|
|
849
956
|
let txResult;
|
|
850
957
|
if (isSolanaChain(chainId)) {
|
|
851
958
|
// Solana payment
|
|
852
|
-
if (
|
|
959
|
+
if (tokenAddress) {
|
|
853
960
|
// SPL token payment (e.g. USDC on Solana)
|
|
854
961
|
txResult = (await api(`/wallets/${wallet_id}/send`, 'POST', {
|
|
855
962
|
to: option.payTo,
|
|
856
963
|
value: option.maxAmountRequired,
|
|
857
|
-
token_mint:
|
|
964
|
+
token_mint: tokenAddress,
|
|
858
965
|
token_decimals: option.requiredDecimals,
|
|
859
966
|
chain_id: chainId,
|
|
860
967
|
}));
|
|
@@ -868,11 +975,11 @@ server.tool('pay_x402', 'Handle an x402 payment flow. Fetches a URL, and if the
|
|
|
868
975
|
}));
|
|
869
976
|
}
|
|
870
977
|
}
|
|
871
|
-
else if (
|
|
978
|
+
else if (tokenAddress) {
|
|
872
979
|
// EVM ERC-20 token payment (e.g. USDC)
|
|
873
980
|
const calldata = '0xa9059cbb' + padAddress(option.payTo) + encodeUint256(option.maxAmountRequired);
|
|
874
981
|
txResult = (await api(`/wallets/${wallet_id}/send`, 'POST', {
|
|
875
|
-
to:
|
|
982
|
+
to: tokenAddress,
|
|
876
983
|
value: '0',
|
|
877
984
|
data: calldata,
|
|
878
985
|
chain_id: chainId,
|
|
@@ -921,7 +1028,7 @@ server.tool('pay_x402', 'Handle an x402 payment flow. Fetches a URL, and if the
|
|
|
921
1028
|
payment_made: true,
|
|
922
1029
|
amount,
|
|
923
1030
|
token: option.extra?.name || 'native',
|
|
924
|
-
token_address: option.extra?.token || null,
|
|
1031
|
+
token_address: option.asset || option.extra?.token || null,
|
|
925
1032
|
network: option.network,
|
|
926
1033
|
chain_id: chainId,
|
|
927
1034
|
pay_to: option.payTo,
|
|
@@ -1083,10 +1190,80 @@ server.tool('get_x402_revenue', 'Get aggregate x402 revenue statistics across al
|
|
|
1083
1190
|
const data = await api('/x402/revenue');
|
|
1084
1191
|
return jsonResponse(data);
|
|
1085
1192
|
});
|
|
1193
|
+
// ─── Tool: wallet_mode ──────────────────────────────────────────
|
|
1194
|
+
server.tool('wallet_mode', 'Report whether this server is signing locally (self-custody, the private key never leaves this machine) ' +
|
|
1195
|
+
'or through the hosted AgentWallet API (custodial). Use this to verify custody before moving funds.', {}, async () => {
|
|
1196
|
+
if (!isLocalMode()) {
|
|
1197
|
+
return jsonResponse({
|
|
1198
|
+
mode: 'custodial',
|
|
1199
|
+
custody: 'agentwallet',
|
|
1200
|
+
signing: 'AgentWallet servers hold an encrypted key and sign on your behalf.',
|
|
1201
|
+
api_base: API_BASE,
|
|
1202
|
+
to_self_custody: 'Set AGENTWALLET_PRIVATE_KEY (or AGENTWALLET_KEYFILE) and restart. ' +
|
|
1203
|
+
'Use export_wallet_key first if you want to carry an existing hosted wallet across.',
|
|
1204
|
+
});
|
|
1205
|
+
}
|
|
1206
|
+
let rpc = 'default public endpoint';
|
|
1207
|
+
try {
|
|
1208
|
+
rpc = resolveRpcUrl(parseInt(process.env.AGENTWALLET_CHAIN_ID || '8453', 10));
|
|
1209
|
+
}
|
|
1210
|
+
catch { /* chain has no default; not worth failing the report over */ }
|
|
1211
|
+
return jsonResponse({
|
|
1212
|
+
mode: 'local',
|
|
1213
|
+
custody: 'self',
|
|
1214
|
+
address: getLocalAddress(),
|
|
1215
|
+
signing: 'Signed in this process. The private key is never sent to AgentWallet or anyone else.',
|
|
1216
|
+
rpc_endpoint: rpc,
|
|
1217
|
+
chains: 'EVM only in local mode. Solana calls are refused rather than routed to the custodial API.',
|
|
1218
|
+
per_tx_cap_native: process.env.AGENTWALLET_MAX_TX_NATIVE || 'not set',
|
|
1219
|
+
max_autopay: process.env.AGENTWALLET_MAX_AUTOPAY || '1',
|
|
1220
|
+
});
|
|
1221
|
+
});
|
|
1222
|
+
// ─── Tool: export_wallet_key ────────────────────────────────────
|
|
1223
|
+
server.tool('export_wallet_key', 'Explain how to export the private key of a hosted (custodial) AgentWallet so it can be moved ' +
|
|
1224
|
+
'to self-custody or any other wallet. Export itself is deliberately human-gated and is not ' +
|
|
1225
|
+
'performed by this tool.', {
|
|
1226
|
+
wallet_id: z.number().int().optional().describe('Hosted wallet ID to export'),
|
|
1227
|
+
}, async ({ wallet_id }) => {
|
|
1228
|
+
if (isLocalMode()) {
|
|
1229
|
+
return jsonResponse({
|
|
1230
|
+
exportable: true,
|
|
1231
|
+
mode: 'local',
|
|
1232
|
+
note: 'You are already in self-custody. The key is the one you supplied via AGENTWALLET_PRIVATE_KEY ' +
|
|
1233
|
+
'or AGENTWALLET_KEYFILE, and this server has no copy of it beyond this process.',
|
|
1234
|
+
});
|
|
1235
|
+
}
|
|
1236
|
+
return jsonResponse({
|
|
1237
|
+
exportable: true,
|
|
1238
|
+
mode: 'custodial',
|
|
1239
|
+
how: `Sign in at ${DASHBOARD_URL} and use Export key on the wallet. ` +
|
|
1240
|
+
'The key is shown once and the export is logged.',
|
|
1241
|
+
wallet_id: wallet_id ?? 'all wallets are exportable',
|
|
1242
|
+
why_not_here: 'Export is gated behind a browser login rather than the API key on purpose. An API key can ' +
|
|
1243
|
+
'spend within your limits; if it could also export keys, a leaked key would mean instant total loss. ' +
|
|
1244
|
+
'Your funds stay portable either way: nothing here is locked in.',
|
|
1245
|
+
after_export: 'Set AGENTWALLET_PRIVATE_KEY to the exported key and restart to run non-custodially, ' +
|
|
1246
|
+
'or import it into any wallet you like.',
|
|
1247
|
+
});
|
|
1248
|
+
});
|
|
1086
1249
|
// ─── Start ──────────────────────────────────────────────────────
|
|
1087
1250
|
async function main() {
|
|
1088
1251
|
const transport = new StdioServerTransport();
|
|
1089
1252
|
await server.connect(transport);
|
|
1253
|
+
/* stderr, so it never corrupts the stdio JSON-RPC stream. Announcing custody
|
|
1254
|
+
at startup means an operator sees which mode they are in without asking. */
|
|
1255
|
+
if (isLocalMode()) {
|
|
1256
|
+
try {
|
|
1257
|
+
console.error(`AgentWallet MCP: LOCAL signing mode. Address ${getLocalAddress()}. Key never leaves this machine.`);
|
|
1258
|
+
}
|
|
1259
|
+
catch (e) {
|
|
1260
|
+
console.error(`AgentWallet MCP: local signing configured but the key could not be loaded: ${e.message}`);
|
|
1261
|
+
process.exit(1);
|
|
1262
|
+
}
|
|
1263
|
+
}
|
|
1264
|
+
else {
|
|
1265
|
+
console.error('AgentWallet MCP: custodial mode via ' + API_BASE + '. Run wallet_mode for details.');
|
|
1266
|
+
}
|
|
1090
1267
|
}
|
|
1091
1268
|
main().catch((error) => {
|
|
1092
1269
|
console.error('AgentWallet MCP server failed to start:', error);
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local (non-custodial) signing mode.
|
|
3
|
+
*
|
|
4
|
+
* When AGENTWALLET_PRIVATE_KEY or AGENTWALLET_KEYFILE is set, every signing
|
|
5
|
+
* operation happens in this process with a key that never leaves the machine.
|
|
6
|
+
* The AgentWallet API is not called, not consulted, and not trusted for any
|
|
7
|
+
* funds-moving step. Transactions are broadcast straight to an RPC endpoint.
|
|
8
|
+
*
|
|
9
|
+
* This is the mode that makes the custody claim verifiable: an operator can
|
|
10
|
+
* read this file, run the server offline against their own RPC, and confirm the
|
|
11
|
+
* key is never transmitted. Nothing here writes the key to disk, logs it, or
|
|
12
|
+
* puts it in an error message.
|
|
13
|
+
*
|
|
14
|
+
* EVM only for now. Solana local signing is not implemented; those calls stay
|
|
15
|
+
* on the custodial path and say so explicitly rather than silently downgrading.
|
|
16
|
+
*/
|
|
17
|
+
import { type Address, type Hex } from 'viem';
|
|
18
|
+
/** True when the server is running non-custodially. */
|
|
19
|
+
export declare function isLocalMode(): boolean;
|
|
20
|
+
export declare function getLocalAccount(): {
|
|
21
|
+
address: Address;
|
|
22
|
+
nonceManager?: import("viem").NonceManager | undefined;
|
|
23
|
+
sign: (parameters: {
|
|
24
|
+
hash: import("viem").Hash;
|
|
25
|
+
}) => Promise<Hex>;
|
|
26
|
+
signAuthorization: (parameters: import("viem").AuthorizationRequest) => Promise<import("viem/accounts").SignAuthorizationReturnType>;
|
|
27
|
+
signMessage: ({ message }: {
|
|
28
|
+
message: import("viem").SignableMessage;
|
|
29
|
+
}) => Promise<Hex>;
|
|
30
|
+
signTransaction: <serializer extends import("viem").SerializeTransactionFn<import("viem").TransactionSerializable> = import("viem").SerializeTransactionFn<import("viem").TransactionSerializable>, transaction extends Parameters<serializer>[0] = Parameters<serializer>[0]>(transaction: transaction, options?: {
|
|
31
|
+
serializer?: serializer | undefined;
|
|
32
|
+
} | undefined) => Promise<Hex>;
|
|
33
|
+
signTypedData: <const typedData extends import("viem").TypedData | Record<string, unknown>, primaryType extends keyof typedData | "EIP712Domain" = keyof typedData>(parameters: import("viem").TypedDataDefinition<typedData, primaryType>) => Promise<Hex>;
|
|
34
|
+
publicKey: Hex;
|
|
35
|
+
source: "privateKey";
|
|
36
|
+
type: "local";
|
|
37
|
+
};
|
|
38
|
+
/** The address the local key controls. Safe to log. */
|
|
39
|
+
export declare function getLocalAddress(): Address;
|
|
40
|
+
export declare function resolveRpcUrl(chainId: number): string;
|
|
41
|
+
export declare function localNativeBalance(chainId: number): Promise<{
|
|
42
|
+
address: `0x${string}`;
|
|
43
|
+
chain_id: number;
|
|
44
|
+
balance_wei: string;
|
|
45
|
+
balance: string;
|
|
46
|
+
mode: string;
|
|
47
|
+
}>;
|
|
48
|
+
export declare function localTokenBalance(chainId: number, token: Address): Promise<{
|
|
49
|
+
address: `0x${string}`;
|
|
50
|
+
chain_id: number;
|
|
51
|
+
token: `0x${string}`;
|
|
52
|
+
symbol: string;
|
|
53
|
+
decimals: number;
|
|
54
|
+
balance_raw: string;
|
|
55
|
+
balance: string;
|
|
56
|
+
mode: string;
|
|
57
|
+
}>;
|
|
58
|
+
export interface LocalSendResult {
|
|
59
|
+
tx_hash: string;
|
|
60
|
+
from: string;
|
|
61
|
+
to: string;
|
|
62
|
+
value: string;
|
|
63
|
+
chain_id: number;
|
|
64
|
+
mode: 'local';
|
|
65
|
+
signed_locally: true;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Sign and broadcast a transaction. viem fills nonce, gas and EIP-1559 fees
|
|
69
|
+
* from the RPC unless they are supplied.
|
|
70
|
+
*/
|
|
71
|
+
export declare function localSend(chainId: number, to: Address, valueWei: bigint, data?: Hex): Promise<LocalSendResult>;
|
|
72
|
+
/** ERC-20 transfer, encoded locally. */
|
|
73
|
+
export declare function localTransferToken(chainId: number, token: Address, to: Address, rawAmount: string): Promise<LocalSendResult>;
|
|
74
|
+
/** ERC-20 approve, encoded locally. */
|
|
75
|
+
export declare function localApproveToken(chainId: number, token: Address, spender: Address, rawAmount: string): Promise<LocalSendResult>;
|
|
76
|
+
/** Sign a message without broadcasting anything. */
|
|
77
|
+
export declare function localSignMessage(message: string): Promise<{
|
|
78
|
+
address: string;
|
|
79
|
+
message: string;
|
|
80
|
+
signature: string;
|
|
81
|
+
mode: 'local';
|
|
82
|
+
}>;
|
|
83
|
+
/**
|
|
84
|
+
* One-line summary for tools that report wallet identity. Deliberately mirrors
|
|
85
|
+
* the shape of a custodial wallet record so callers do not need to branch.
|
|
86
|
+
*/
|
|
87
|
+
export declare function localWalletRecord(): {
|
|
88
|
+
id: string;
|
|
89
|
+
address: `0x${string}`;
|
|
90
|
+
wallet_type: string;
|
|
91
|
+
mode: string;
|
|
92
|
+
custody: string;
|
|
93
|
+
note: string;
|
|
94
|
+
};
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local (non-custodial) signing mode.
|
|
3
|
+
*
|
|
4
|
+
* When AGENTWALLET_PRIVATE_KEY or AGENTWALLET_KEYFILE is set, every signing
|
|
5
|
+
* operation happens in this process with a key that never leaves the machine.
|
|
6
|
+
* The AgentWallet API is not called, not consulted, and not trusted for any
|
|
7
|
+
* funds-moving step. Transactions are broadcast straight to an RPC endpoint.
|
|
8
|
+
*
|
|
9
|
+
* This is the mode that makes the custody claim verifiable: an operator can
|
|
10
|
+
* read this file, run the server offline against their own RPC, and confirm the
|
|
11
|
+
* key is never transmitted. Nothing here writes the key to disk, logs it, or
|
|
12
|
+
* puts it in an error message.
|
|
13
|
+
*
|
|
14
|
+
* EVM only for now. Solana local signing is not implemented; those calls stay
|
|
15
|
+
* on the custodial path and say so explicitly rather than silently downgrading.
|
|
16
|
+
*/
|
|
17
|
+
import { readFileSync } from 'node:fs';
|
|
18
|
+
import { createPublicClient, createWalletClient, http, encodeFunctionData, parseAbi, formatUnits, } from 'viem';
|
|
19
|
+
import { privateKeyToAccount } from 'viem/accounts';
|
|
20
|
+
/* ── Key loading ─────────────────────────────────────────────────── */
|
|
21
|
+
/**
|
|
22
|
+
* Read the key once at startup. Kept module-private: nothing exports the key
|
|
23
|
+
* itself, only an account object that can sign.
|
|
24
|
+
*/
|
|
25
|
+
let cachedAccount = null;
|
|
26
|
+
let keyLoadError = null;
|
|
27
|
+
function loadKeyMaterial() {
|
|
28
|
+
const inline = (process.env.AGENTWALLET_PRIVATE_KEY || '').trim();
|
|
29
|
+
if (inline)
|
|
30
|
+
return inline;
|
|
31
|
+
const file = (process.env.AGENTWALLET_KEYFILE || '').trim();
|
|
32
|
+
if (file) {
|
|
33
|
+
try {
|
|
34
|
+
return readFileSync(file, 'utf8').trim();
|
|
35
|
+
}
|
|
36
|
+
catch (e) {
|
|
37
|
+
keyLoadError = `Could not read AGENTWALLET_KEYFILE at ${file}: ${e.message}`;
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
function normalizeKey(raw) {
|
|
44
|
+
const hex = raw.startsWith('0x') ? raw.slice(2) : raw;
|
|
45
|
+
if (!/^[0-9a-fA-F]{64}$/.test(hex)) {
|
|
46
|
+
throw new Error('Invalid private key. Expected 64 hex characters, with or without a 0x prefix. ' +
|
|
47
|
+
'The value itself is never logged.');
|
|
48
|
+
}
|
|
49
|
+
return `0x${hex}`;
|
|
50
|
+
}
|
|
51
|
+
/** True when the server is running non-custodially. */
|
|
52
|
+
export function isLocalMode() {
|
|
53
|
+
return Boolean((process.env.AGENTWALLET_PRIVATE_KEY || '').trim() ||
|
|
54
|
+
(process.env.AGENTWALLET_KEYFILE || '').trim());
|
|
55
|
+
}
|
|
56
|
+
export function getLocalAccount() {
|
|
57
|
+
if (cachedAccount)
|
|
58
|
+
return cachedAccount;
|
|
59
|
+
if (keyLoadError)
|
|
60
|
+
throw new Error(keyLoadError);
|
|
61
|
+
const raw = loadKeyMaterial();
|
|
62
|
+
if (!raw)
|
|
63
|
+
throw new Error('Local signing mode is not configured.');
|
|
64
|
+
if (keyLoadError)
|
|
65
|
+
throw new Error(keyLoadError);
|
|
66
|
+
cachedAccount = privateKeyToAccount(normalizeKey(raw));
|
|
67
|
+
return cachedAccount;
|
|
68
|
+
}
|
|
69
|
+
/** The address the local key controls. Safe to log. */
|
|
70
|
+
export function getLocalAddress() {
|
|
71
|
+
return getLocalAccount().address;
|
|
72
|
+
}
|
|
73
|
+
/* ── Chains and RPC ──────────────────────────────────────────────── */
|
|
74
|
+
/**
|
|
75
|
+
* Public RPC endpoints used only when the operator has not supplied their own.
|
|
76
|
+
* Anyone serious about privacy should set AGENTWALLET_RPC_<chainId>, because
|
|
77
|
+
* a public RPC sees every address you query.
|
|
78
|
+
*/
|
|
79
|
+
const DEFAULT_RPC = {
|
|
80
|
+
1: 'https://eth.llamarpc.com',
|
|
81
|
+
10: 'https://mainnet.optimism.io',
|
|
82
|
+
56: 'https://bsc-dataseed.binance.org',
|
|
83
|
+
137: 'https://polygon-rpc.com',
|
|
84
|
+
8453: 'https://mainnet.base.org',
|
|
85
|
+
42161: 'https://arb1.arbitrum.io/rpc',
|
|
86
|
+
43114: 'https://api.avax.network/ext/bc/C/rpc',
|
|
87
|
+
7777777: 'https://rpc.zora.energy',
|
|
88
|
+
369: 'https://rpc.pulsechain.com',
|
|
89
|
+
};
|
|
90
|
+
export function resolveRpcUrl(chainId) {
|
|
91
|
+
const perChain = (process.env[`AGENTWALLET_RPC_${chainId}`] || '').trim();
|
|
92
|
+
if (perChain)
|
|
93
|
+
return perChain;
|
|
94
|
+
const generic = (process.env.AGENTWALLET_RPC_URL || '').trim();
|
|
95
|
+
if (generic)
|
|
96
|
+
return generic;
|
|
97
|
+
const fallback = DEFAULT_RPC[chainId];
|
|
98
|
+
if (fallback)
|
|
99
|
+
return fallback;
|
|
100
|
+
throw new Error(`No RPC endpoint for chain ${chainId} in local mode. ` +
|
|
101
|
+
`Set AGENTWALLET_RPC_${chainId} to an endpoint you trust.`);
|
|
102
|
+
}
|
|
103
|
+
function clients(chainId) {
|
|
104
|
+
const url = resolveRpcUrl(chainId);
|
|
105
|
+
const transport = http(url);
|
|
106
|
+
// The chain object is deliberately minimal: viem only needs the id for
|
|
107
|
+
// signing, and not pinning a chain definition keeps any EVM network usable.
|
|
108
|
+
const chain = { id: chainId, name: `chain-${chainId}`, nativeCurrency: { name: 'Native', symbol: 'NATIVE', decimals: 18 }, rpcUrls: { default: { http: [url] } } };
|
|
109
|
+
return {
|
|
110
|
+
pub: createPublicClient({ chain, transport }),
|
|
111
|
+
wallet: createWalletClient({ account: getLocalAccount(), chain, transport }),
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
/* ── Spend guards ────────────────────────────────────────────────── */
|
|
115
|
+
/**
|
|
116
|
+
* Per-transaction ceiling for local mode, in native units (ETH, MATIC...).
|
|
117
|
+
* Custodial mode enforces limits server-side; local mode has no server, so the
|
|
118
|
+
* guard has to live here or it does not exist at all.
|
|
119
|
+
*/
|
|
120
|
+
function assertWithinNativeCap(valueWei) {
|
|
121
|
+
const cap = (process.env.AGENTWALLET_MAX_TX_NATIVE || '').trim();
|
|
122
|
+
if (!cap)
|
|
123
|
+
return;
|
|
124
|
+
if (!/^\d+(\.\d+)?$/.test(cap)) {
|
|
125
|
+
throw new Error(`AGENTWALLET_MAX_TX_NATIVE must be a decimal number, got "${cap}".`);
|
|
126
|
+
}
|
|
127
|
+
const [whole, frac = ''] = cap.split('.');
|
|
128
|
+
const capWei = BigInt(whole + frac.padEnd(18, '0').slice(0, 18));
|
|
129
|
+
if (valueWei > capWei) {
|
|
130
|
+
throw new Error(`Blocked by local guard: transaction value exceeds AGENTWALLET_MAX_TX_NATIVE (${cap}). ` +
|
|
131
|
+
`Raise the cap deliberately if this is intended.`);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
/* ── Read operations ─────────────────────────────────────────────── */
|
|
135
|
+
export async function localNativeBalance(chainId) {
|
|
136
|
+
const { pub } = clients(chainId);
|
|
137
|
+
const address = getLocalAddress();
|
|
138
|
+
const wei = await pub.getBalance({ address });
|
|
139
|
+
return { address, chain_id: chainId, balance_wei: wei.toString(), balance: formatUnits(wei, 18), mode: 'local' };
|
|
140
|
+
}
|
|
141
|
+
const ERC20_ABI = parseAbi([
|
|
142
|
+
'function balanceOf(address) view returns (uint256)',
|
|
143
|
+
'function decimals() view returns (uint8)',
|
|
144
|
+
'function symbol() view returns (string)',
|
|
145
|
+
'function transfer(address,uint256) returns (bool)',
|
|
146
|
+
'function approve(address,uint256) returns (bool)',
|
|
147
|
+
'function allowance(address,address) view returns (uint256)',
|
|
148
|
+
]);
|
|
149
|
+
export async function localTokenBalance(chainId, token) {
|
|
150
|
+
const { pub } = clients(chainId);
|
|
151
|
+
const address = getLocalAddress();
|
|
152
|
+
const [raw, decimals, symbol] = await Promise.all([
|
|
153
|
+
pub.readContract({ address: token, abi: ERC20_ABI, functionName: 'balanceOf', args: [address] }),
|
|
154
|
+
pub.readContract({ address: token, abi: ERC20_ABI, functionName: 'decimals' }).catch(() => 18),
|
|
155
|
+
pub.readContract({ address: token, abi: ERC20_ABI, functionName: 'symbol' }).catch(() => ''),
|
|
156
|
+
]);
|
|
157
|
+
return {
|
|
158
|
+
address, chain_id: chainId, token, symbol,
|
|
159
|
+
decimals: Number(decimals),
|
|
160
|
+
balance_raw: raw.toString(),
|
|
161
|
+
balance: formatUnits(raw, Number(decimals)),
|
|
162
|
+
mode: 'local',
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* Sign and broadcast a transaction. viem fills nonce, gas and EIP-1559 fees
|
|
167
|
+
* from the RPC unless they are supplied.
|
|
168
|
+
*/
|
|
169
|
+
export async function localSend(chainId, to, valueWei, data) {
|
|
170
|
+
assertWithinNativeCap(valueWei);
|
|
171
|
+
const { wallet } = clients(chainId);
|
|
172
|
+
const hash = await wallet.sendTransaction({
|
|
173
|
+
to,
|
|
174
|
+
value: valueWei,
|
|
175
|
+
...(data ? { data } : {}),
|
|
176
|
+
});
|
|
177
|
+
return {
|
|
178
|
+
tx_hash: hash,
|
|
179
|
+
from: getLocalAddress(),
|
|
180
|
+
to,
|
|
181
|
+
value: valueWei.toString(),
|
|
182
|
+
chain_id: chainId,
|
|
183
|
+
mode: 'local',
|
|
184
|
+
signed_locally: true,
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
/** ERC-20 transfer, encoded locally. */
|
|
188
|
+
export async function localTransferToken(chainId, token, to, rawAmount) {
|
|
189
|
+
const data = encodeFunctionData({
|
|
190
|
+
abi: ERC20_ABI,
|
|
191
|
+
functionName: 'transfer',
|
|
192
|
+
args: [to, BigInt(rawAmount)],
|
|
193
|
+
});
|
|
194
|
+
const res = await localSend(chainId, token, 0n, data);
|
|
195
|
+
return { ...res, to, value: rawAmount };
|
|
196
|
+
}
|
|
197
|
+
/** ERC-20 approve, encoded locally. */
|
|
198
|
+
export async function localApproveToken(chainId, token, spender, rawAmount) {
|
|
199
|
+
const data = encodeFunctionData({
|
|
200
|
+
abi: ERC20_ABI,
|
|
201
|
+
functionName: 'approve',
|
|
202
|
+
args: [spender, BigInt(rawAmount)],
|
|
203
|
+
});
|
|
204
|
+
const res = await localSend(chainId, token, 0n, data);
|
|
205
|
+
return { ...res, to: spender, value: rawAmount };
|
|
206
|
+
}
|
|
207
|
+
/** Sign a message without broadcasting anything. */
|
|
208
|
+
export async function localSignMessage(message) {
|
|
209
|
+
const signature = await getLocalAccount().signMessage({ message });
|
|
210
|
+
return { address: getLocalAddress(), message, signature, mode: 'local' };
|
|
211
|
+
}
|
|
212
|
+
/**
|
|
213
|
+
* One-line summary for tools that report wallet identity. Deliberately mirrors
|
|
214
|
+
* the shape of a custodial wallet record so callers do not need to branch.
|
|
215
|
+
*/
|
|
216
|
+
export function localWalletRecord() {
|
|
217
|
+
return {
|
|
218
|
+
id: 'local',
|
|
219
|
+
address: getLocalAddress(),
|
|
220
|
+
wallet_type: 'evm',
|
|
221
|
+
mode: 'local',
|
|
222
|
+
custody: 'self',
|
|
223
|
+
note: 'Signed in-process. The private key is never sent to AgentWallet servers.',
|
|
224
|
+
};
|
|
225
|
+
}
|
package/build/x402-payment.d.ts
CHANGED
|
@@ -32,3 +32,17 @@ export declare function deriveX402Payment(accept: X402AcceptLike, maxAutopayHuma
|
|
|
32
32
|
rawAmount: string;
|
|
33
33
|
decimals: number;
|
|
34
34
|
};
|
|
35
|
+
/**
|
|
36
|
+
* Returns true if an x402 required amount (already in base/atomic units) is
|
|
37
|
+
* within a human-readable per-payment cap.
|
|
38
|
+
*
|
|
39
|
+
* Shared by both x402 payment paths so neither can authorize an unbounded
|
|
40
|
+
* transfer: the internal auto-pay path and the public pay_x402 MCP tool both
|
|
41
|
+
* gate on this. pay_x402 uses it (instead of deriveX402Payment's throw) so it
|
|
42
|
+
* can return a structured rejection to the calling agent.
|
|
43
|
+
*
|
|
44
|
+
* @param rawAmountRequired x402 maxAmountRequired, already in base units
|
|
45
|
+
* @param decimals token decimals
|
|
46
|
+
* @param capHuman cap in human-readable units of the asset (e.g. "1")
|
|
47
|
+
*/
|
|
48
|
+
export declare function isWithinCap(rawAmountRequired: string, decimals: number, capHuman: string): boolean;
|
package/build/x402-payment.js
CHANGED
|
@@ -44,3 +44,23 @@ export function deriveX402Payment(accept, maxAutopayHuman) {
|
|
|
44
44
|
}
|
|
45
45
|
return { tokenAddress, rawAmount, decimals };
|
|
46
46
|
}
|
|
47
|
+
/**
|
|
48
|
+
* Returns true if an x402 required amount (already in base/atomic units) is
|
|
49
|
+
* within a human-readable per-payment cap.
|
|
50
|
+
*
|
|
51
|
+
* Shared by both x402 payment paths so neither can authorize an unbounded
|
|
52
|
+
* transfer: the internal auto-pay path and the public pay_x402 MCP tool both
|
|
53
|
+
* gate on this. pay_x402 uses it (instead of deriveX402Payment's throw) so it
|
|
54
|
+
* can return a structured rejection to the calling agent.
|
|
55
|
+
*
|
|
56
|
+
* @param rawAmountRequired x402 maxAmountRequired, already in base units
|
|
57
|
+
* @param decimals token decimals
|
|
58
|
+
* @param capHuman cap in human-readable units of the asset (e.g. "1")
|
|
59
|
+
*/
|
|
60
|
+
export function isWithinCap(rawAmountRequired, decimals, capHuman) {
|
|
61
|
+
if (!/^\d+$/.test(rawAmountRequired)) {
|
|
62
|
+
throw new Error(`Invalid maxAmountRequired "${rawAmountRequired}" (expected an integer base-unit amount).`);
|
|
63
|
+
}
|
|
64
|
+
const capRaw = toBaseUnits(capHuman, decimals);
|
|
65
|
+
return BigInt(rawAmountRequired) <= BigInt(capRaw);
|
|
66
|
+
}
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agentwallet-mcp",
|
|
3
3
|
"mcpName": "io.github.hifriendbot/agentwallet",
|
|
4
|
-
"version": "1.
|
|
5
|
-
"description": "MCP server for AgentWallet:
|
|
4
|
+
"version": "1.9.0",
|
|
5
|
+
"description": "MCP server for AgentWallet: self-custody local signing or hosted wallets. Lets AI agents sign and broadcast on EVM + Solana, pay x402 invoices, and accept x402 payments.",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"main": "build/index.js",
|
|
8
8
|
"bin": {
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
"scripts": {
|
|
17
17
|
"build": "tsc",
|
|
18
18
|
"start": "node build/index.js",
|
|
19
|
-
"test": "node test/x402-payment.test.mjs",
|
|
19
|
+
"test": "node test/x402-payment.test.mjs && node test/local-mode.test.mjs",
|
|
20
20
|
"prepublishOnly": "npm run build"
|
|
21
21
|
},
|
|
22
22
|
"keywords": [
|
|
@@ -43,7 +43,10 @@
|
|
|
43
43
|
"x402",
|
|
44
44
|
"http-402",
|
|
45
45
|
"agent-payments",
|
|
46
|
-
"agentwallet"
|
|
46
|
+
"agentwallet",
|
|
47
|
+
"self-custody",
|
|
48
|
+
"non-custodial",
|
|
49
|
+
"local-signing"
|
|
47
50
|
],
|
|
48
51
|
"author": "HiFriendbot (https://hifriendbot.com)",
|
|
49
52
|
"license": "MIT",
|
|
@@ -53,6 +56,7 @@
|
|
|
53
56
|
},
|
|
54
57
|
"dependencies": {
|
|
55
58
|
"@modelcontextprotocol/sdk": "^1.11.4",
|
|
59
|
+
"viem": "^2.55.10",
|
|
56
60
|
"zod": "^3.24.0"
|
|
57
61
|
},
|
|
58
62
|
"devDependencies": {
|