agentwallet-mcp 1.13.16 → 1.14.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 CHANGED
@@ -74,7 +74,8 @@ Local signing uses [viem](https://viem.sh) for EVM and `@solana/web3.js` for Sol
74
74
 
75
75
  ## Features
76
76
 
77
- - **34 MCP tools**: create wallets, send transactions, approve tokens, wrap ETH, transfer SPL tokens, pay and accept x402 payments, verify custody mode, and more
77
+ - **40 MCP tools**: create wallets, send transactions, approve tokens, wrap ETH, transfer SPL tokens, quote, discover, pay and accept x402 payments, verify custody mode, and more
78
+ - **Spend policy**: rolling daily and monthly budgets, per-host ceilings, host and recipient lists, human approval through MCP elicitation, and a tool allowlist, in one JSON file, enforced from a hash-chained ledger. The key stays yours and the agent still cannot overspend.
78
79
  - **EVM + Solana**: Ethereum, Base, Polygon, BSC, Arbitrum, Optimism, Avalanche, Zora, PulseChain, Solana, and any other EVM-compatible chain
79
80
  - **SOL + SPL tokens**: native SOL transfers and SPL token transfers (USDC, USDT, etc.) with automatic account creation
80
81
  - **Built-in guards**: in hosted mode, daily spending limits, gas price protection, emergency pause and rate limiting are enforced server-side by default. In local mode your protection is the per-transaction caps you set (`AGENTWALLET_MAX_TX_NATIVE`, `AGENTWALLET_MAX_TX_TOKEN`, `AGENTWALLET_MAX_TX_SOL`, `AGENTWALLET_MAX_AUTOPAY`). x402 replay protection and on-chain verification apply either way
@@ -174,6 +175,12 @@ Add to your settings:
174
175
  | `wrap_eth` | Wrap native tokens to WETH/WAVAX/etc. |
175
176
  | `unwrap_eth` | Unwrap WETH back to native tokens |
176
177
  | `pay_x402` | Pay x402 invoices (exact via EIP-3009, upto via Permit2), with approvals above the cap |
178
+ | `quote_x402` | What an x402 endpoint charges and whether the cap, policy and budgets would allow it; nothing signed |
179
+ | `discover_x402` | Find x402-paid APIs by subject in the public x402 index (the Bazaar) |
180
+ | `list_payments` | Spends on the ledger with their outcomes, newest first |
181
+ | `export_receipts` | The ledger as CSV or JSONL for expense records |
182
+ | `spend_policy` | The policy in force, its sha256, budgets spent and remaining, ledger integrity |
183
+ | `simulate_transaction` | Dry-run an EVM transaction: guards, policy, gas estimate, fee, return data, nothing signed |
177
184
  | `check_approval` | Status of a human approval created by an over-cap `pay_x402` |
178
185
  | `approve_permit2` | One-time token approval to Permit2 for `upto` endpoints |
179
186
  | `check_token_risk` | Honeypot, tax, owner-power and holder-concentration flags for a token |
@@ -341,8 +348,53 @@ Which guards apply depends on who holds the key.
341
348
  - `AGENTWALLET_CHAIN_ID`: the EVM chain used when a call names none (default 8453)
342
349
  - `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
343
350
 
351
+ - `AGENTWALLET_POLICY`: a spend policy file (see below) that bounds sums, hosts, recipients, chains, assets and tools; `AGENTWALLET_LEDGER` names the ledger it is enforced from
352
+ - `AGENTWALLET_KEYFILE_PASSPHRASE` or `AGENTWALLET_KEYFILE_PASSPHRASE_FILE`: opens an encrypted keyfile (see below)
353
+ - `AGENTWALLET_WEBHOOK_URL` (https, or http on localhost) and `AGENTWALLET_WEBHOOK_SECRET`: every ledger line is POSTed as JSON, signed `X-AgentWallet-Signature: sha256=<hmac>` when a secret is set; a webhook never delays or fails a signature
354
+
344
355
  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.
345
356
 
357
+ ### Spend policy: bounding sums, not just single payments
358
+
359
+ A per-transaction cap bounds one signature. It cannot see that the five hundredth payment of 0.99 under a cap of 1 is the five hundredth. A spend policy can. Point `AGENTWALLET_POLICY` at a JSON file:
360
+
361
+ ```json
362
+ {
363
+ "version": 1,
364
+ "name": "research agent",
365
+ "budget": { "daily": "5", "monthly": "50" },
366
+ "native_budget": { "8453": { "daily": "0.01" } },
367
+ "hosts": { "allow": ["*.example.com", "api.ozdreamtools.de"], "deny": ["evil.example.com"] },
368
+ "per_host": { "*.example.com": { "daily": "2", "per_payment": "0.25" } },
369
+ "recipients": { "deny": ["0x0000000000000000000000000000000000000000"] },
370
+ "chains": { "allow": [8453, 900] },
371
+ "approval": { "above": "0.50", "max": "20" },
372
+ "tools": { "allow": ["pay_x402", "quote_x402", "discover_x402", "get_balance", "list_payments", "spend_policy", "wallet_mode"] }
373
+ }
374
+ ```
375
+
376
+ - **`budget`**: rolling dollar budgets (last 24 hours, last 30 days, lifetime) across every chain, counting x402 payments and stablecoin transfers in registry stablecoins. A spend the budget cannot price (a token outside the registry) is refused while a budget is set, unless `"unpriced_spends": "allow"`.
377
+ - **`native_budget`**: the same per chain, in the chain's own coin.
378
+ - **`hosts`**, **`per_host`**: which endpoints `pay_x402` may pay, and how much per host per day, per month and per payment. A denied host is never contacted, not even for a quote.
379
+ - **`recipients`**: where funds may go (transfers, x402 `payTo`). The allow list covers funds destinations; contract targets (approve, wrap, arbitrary calldata) are checked against the deny list and the chain list.
380
+ - **`chains`**, **`assets`**: allow lists; an asset entry can be scoped `"8453:0x..."` or be the word `native`.
381
+ - **`approval`**: above `above`, every x402 payment is put to the human at the MCP client through [elicitation](https://modelcontextprotocol.io/specification/2025-06-18/client/elicitation) (the client shows the amount, token, recipient and resource and asks yes or no; a client that declared no elicitation support cannot answer, so the payment is refused). In local mode an accepted answer may admit a payment above the per-payment cap, up to `max`. Budgets are never lifted by anyone's approval.
382
+ - **`tools`**: the tools the model sees. A payer-only deployment exposes seven; the rest are not registered. `wallet_mode` and `spend_policy` are always present so the operator can see what the server is.
383
+
384
+ An unknown key stops the server, so a misspelled `budgets` cannot run unbounded. `spend_policy` reports the file, its sha256 (compare it with the file you reviewed), what each budget has spent and has left, and which tools the policy hid. `quote_x402` answers "would this be paid?" for a URL before anything is signed.
385
+
386
+ **The ledger.** Budgets are enforced from an append-only, hash-chained JSONL file (`AGENTWALLET_LEDGER`, or the policy's `ledger`, or `agentwallet-ledger.jsonl` beside the policy when a budget needs one). A spend is reserved on it before the signature exists; a reservation whose signing fails is voided by a later line; the endpoint's or chain's answer is settled onto it afterwards. On startup the chain is verified, and a ledger that does not verify refuses every spend until the operator has looked at it. `list_payments` reads it, `export_receipts` turns it into CSV or JSONL, and `AGENTWALLET_WEBHOOK_URL` receives every line as it is written.
387
+
388
+ ### Encrypted keyfiles
389
+
390
+ A plaintext key in a shell config or a file is readable by anything that can read the disk. Seal it:
391
+
392
+ ```bash
393
+ npx agentwallet-mcp encrypt-keyfile ./plain.key ./agent.keyfile
394
+ ```
395
+
396
+ The passphrase is asked for on the terminal (or taken from `AGENTWALLET_KEYFILE_PASSPHRASE` for scripted use); scrypt derives the key and AES-256-GCM seals the material, with Node's own crypto and no new dependency. Point `AGENTWALLET_KEYFILE` or `AGENTWALLET_SOLANA_KEYFILE` at the result, set `AGENTWALLET_KEYFILE_PASSPHRASE` or `AGENTWALLET_KEYFILE_PASSPHRASE_FILE`, confirm with `wallet_mode` that the same address appears, then delete the plaintext. A wrong passphrase fails with a fixed message that never includes the key.
397
+
346
398
  **Either mode**, for x402 paywalls you run through the hosted API:
347
399
 
348
400
  - **Replay protection**: every x402 payment verified on-chain with unique transaction tracking
@@ -0,0 +1,69 @@
1
+ export declare const BAZAAR_URL = "https://api.cdp.coinbase.com/platform/v2/x402/discovery/resources";
2
+ export interface BazaarItem {
3
+ resource: string;
4
+ type?: string;
5
+ description?: string;
6
+ serviceName?: string;
7
+ tags?: unknown;
8
+ lastUpdated?: string;
9
+ accepts?: Array<{
10
+ scheme?: string;
11
+ network?: string;
12
+ amount?: string;
13
+ maxAmountRequired?: string;
14
+ asset?: string;
15
+ payTo?: string;
16
+ }>;
17
+ extensions?: {
18
+ bazaar?: {
19
+ info?: {
20
+ input?: {
21
+ method?: string;
22
+ type?: string;
23
+ bodyType?: string;
24
+ discoverable?: boolean;
25
+ };
26
+ output?: {
27
+ type?: string;
28
+ };
29
+ };
30
+ };
31
+ };
32
+ }
33
+ export interface DiscoveredResource {
34
+ resource: string;
35
+ method: string;
36
+ service: string | null;
37
+ description: string | null;
38
+ prices: Array<{
39
+ amount: string | null;
40
+ asset: string;
41
+ network: string;
42
+ chain_id: number | null;
43
+ scheme: string;
44
+ }>;
45
+ tags: string[];
46
+ last_updated: string | null;
47
+ }
48
+ /** A raw item to the bounded shape the agent sees. */
49
+ export declare function summarize(it: BazaarItem): DiscoveredResource | null;
50
+ /** Case-insensitive match of every query word against resource, service, description and tags. */
51
+ export declare function matches(r: DiscoveredResource, query: string): boolean;
52
+ export interface DiscoverOptions {
53
+ query?: string;
54
+ limit: number;
55
+ offset: number;
56
+ chainId?: number;
57
+ fetcher?: (url: string) => Promise<unknown>;
58
+ }
59
+ /**
60
+ * Walk the index from `offset`, keeping items that match. With no query one
61
+ * page is enough; with one, up to MAX_SCAN items are read so the agent sees
62
+ * matches rather than the first page's noise. `next_offset` continues the walk.
63
+ */
64
+ export declare function discover(opts: DiscoverOptions): Promise<{
65
+ items: DiscoveredResource[];
66
+ scanned: number;
67
+ total: number | null;
68
+ next_offset: number | null;
69
+ }>;
@@ -0,0 +1,115 @@
1
+ /**
2
+ * x402 discovery: the Bazaar index of x402 resources published by the CDP
3
+ * facilitator (public, no key). The agent can find a paid API by subject
4
+ * instead of being handed a URL.
5
+ *
6
+ * Everything the index says is a seller's own words. Descriptions are
7
+ * bounded and marked untrusted; prices are re-derived from the registry
8
+ * (never the seller's decimals), and nothing here pays.
9
+ */
10
+ import { safeFetch } from './ssrf-guard.js';
11
+ import { lookupTrustedDecimals, assetLabel, resolveNetworkChainId } from './x402-payment.js';
12
+ export const BAZAAR_URL = 'https://api.cdp.coinbase.com/platform/v2/x402/discovery/resources';
13
+ const PAGE = 100;
14
+ const MAX_SCAN = 500;
15
+ const MAX_BYTES = 4 * 1024 * 1024;
16
+ const cut = (v, n) => v === undefined || v === null ? null : String(v).slice(0, n);
17
+ /** A raw item to the bounded shape the agent sees. */
18
+ export function summarize(it) {
19
+ if (!it || typeof it.resource !== 'string' || !/^https:\/\//i.test(it.resource))
20
+ return null;
21
+ const prices = (Array.isArray(it.accepts) ? it.accepts : []).slice(0, 6).map(a => {
22
+ const network = String(a?.network ?? '').slice(0, 60);
23
+ const chainId = resolveNetworkChainId(network);
24
+ const asset = String(a?.asset ?? '').slice(0, 80);
25
+ const raw = String(a?.amount ?? a?.maxAmountRequired ?? '');
26
+ let amount = null;
27
+ if (chainId !== null && /^\d{1,40}$/.test(raw)) {
28
+ const d = asset ? lookupTrustedDecimals(chainId, asset) : (chainId >= 900 ? 9 : 18);
29
+ if (d !== null)
30
+ amount = formatRaw(raw, d);
31
+ }
32
+ return { amount, asset: chainId !== null && asset ? assetLabel(chainId, asset) : (asset || 'native'), network, chain_id: chainId, scheme: String(a?.scheme ?? '').slice(0, 20) };
33
+ });
34
+ const tags = Array.isArray(it.tags) ? it.tags.slice(0, 10).map(t => String(t).slice(0, 40)) : [];
35
+ return {
36
+ resource: it.resource.slice(0, 500),
37
+ method: String(it.extensions?.bazaar?.info?.input?.method ?? 'GET').toUpperCase().slice(0, 10),
38
+ service: cut(it.serviceName, 120),
39
+ description: cut(it.description, 400),
40
+ prices, tags, last_updated: cut(it.lastUpdated, 40),
41
+ };
42
+ }
43
+ function formatRaw(raw, decimals) {
44
+ const padded = raw.padStart(decimals + 1, '0');
45
+ const whole = padded.slice(0, padded.length - decimals) || '0';
46
+ let frac = padded.slice(padded.length - decimals);
47
+ let end = frac.length;
48
+ while (end > 0 && frac[end - 1] === '0')
49
+ end--;
50
+ frac = frac.slice(0, end);
51
+ return frac ? `${whole}.${frac}` : whole;
52
+ }
53
+ /** Case-insensitive match of every query word against resource, service, description and tags. */
54
+ export function matches(r, query) {
55
+ const words = query.toLowerCase().split(/\s+/).filter(Boolean);
56
+ if (!words.length)
57
+ return true;
58
+ const hay = [r.resource, r.service ?? '', r.description ?? '', ...r.tags].join(' ').toLowerCase();
59
+ return words.every(w => hay.includes(w));
60
+ }
61
+ async function fetchPage(url) {
62
+ const res = await safeFetch(url, { headers: { Accept: 'application/json', 'User-Agent': 'agentwallet-mcp' }, signal: AbortSignal.timeout(20_000) });
63
+ if (res.status !== 200)
64
+ throw new Error(`The x402 index answered HTTP ${res.status}.`);
65
+ const text = await res.text();
66
+ if (text.length > MAX_BYTES)
67
+ throw new Error('The x402 index answered with more data than this client will read.');
68
+ try {
69
+ return JSON.parse(text);
70
+ }
71
+ catch {
72
+ throw new Error('The x402 index answered with something other than JSON.');
73
+ }
74
+ }
75
+ /**
76
+ * Walk the index from `offset`, keeping items that match. With no query one
77
+ * page is enough; with one, up to MAX_SCAN items are read so the agent sees
78
+ * matches rather than the first page's noise. `next_offset` continues the walk.
79
+ */
80
+ export async function discover(opts) {
81
+ const fetcher = opts.fetcher ?? fetchPage;
82
+ const out = [];
83
+ let offset = opts.offset;
84
+ let scanned = 0;
85
+ let total = null;
86
+ const want = Math.max(1, Math.min(50, opts.limit));
87
+ while (out.length < want && scanned < MAX_SCAN) {
88
+ const page = (await fetcher(`${BAZAAR_URL}?limit=${PAGE}&offset=${offset}`));
89
+ const items = Array.isArray(page?.items) ? page.items : [];
90
+ if (typeof page?.pagination?.total === 'number')
91
+ total = page.pagination.total;
92
+ for (const it of items) {
93
+ offset++;
94
+ scanned++;
95
+ const s = summarize(it);
96
+ if (!s)
97
+ continue;
98
+ if (opts.chainId !== undefined && !s.prices.some(p => p.chain_id === opts.chainId))
99
+ continue;
100
+ if (opts.query && !matches(s, opts.query))
101
+ continue;
102
+ out.push(s);
103
+ if (out.length >= want)
104
+ break;
105
+ }
106
+ if (items.length < PAGE) {
107
+ if (out.length < want)
108
+ return { items: out, scanned, total, next_offset: null };
109
+ break;
110
+ }
111
+ if (!opts.query && opts.chainId === undefined)
112
+ break;
113
+ }
114
+ return { items: out, scanned, total, next_offset: total !== null && offset >= total ? null : offset };
115
+ }