agentwallet-mcp 1.14.0 → 1.14.1
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 +4 -3
- package/build/index.js +122 -34
- package/build/ledger.d.ts +2 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -373,10 +373,11 @@ A per-transaction cap bounds one signature. It cannot see that the five hundredt
|
|
|
373
373
|
}
|
|
374
374
|
```
|
|
375
375
|
|
|
376
|
-
- **`budget`**: rolling dollar budgets (last 24 hours, last 30 days, lifetime) across every chain, counting x402 payments and stablecoin
|
|
377
|
-
- **`native_budget`**: the same per chain, in the chain's own coin.
|
|
376
|
+
- **`budget`**: rolling dollar budgets (last 24 hours, last 30 days, lifetime) across every chain, counting x402 payments, stablecoin transfers and stablecoin approvals 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, counting native transfers and the native value sent into any contract (`wrap_eth`, `send_transaction` with value and calldata).
|
|
378
|
+
- **Approvals are spends.** An ERC-20 allowance (`approve_token`, `approve_permit2`, `increaseAllowance`, `approveAndCall`, Permit2 `approve`, or the same calldata through `send_transaction`) is what the spender will take later, so it is priced, checked and reserved on the ledger when it is granted, the spender is checked against the recipient lists, and an unlimited approval (2^128 base units or more) is refused outright, since no budget can count it; grant an exact amount. A zero-value call to any other contract (a router, a bridge) reserves nothing: it can only pull what an approval already granted.
|
|
378
379
|
- **`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
|
|
380
|
+
- **`recipients`**: where funds may go (transfers, x402 `payTo`, approval spenders, contracts that receive native value). The allow list covers those funds destinations; the contract a zero-value call is aimed at (a token being approved, a router) is checked against the deny list and the chain list.
|
|
380
381
|
- **`chains`**, **`assets`**: allow lists; an asset entry can be scoped `"8453:0x..."` or be the word `native`.
|
|
381
382
|
- **`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
383
|
- **`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.
|
package/build/index.js
CHANGED
|
@@ -115,7 +115,21 @@ function spendVerdict(input) {
|
|
|
115
115
|
function usdOf(chainId, asset, raw, decimals) {
|
|
116
116
|
return autopayAssetAllowed(chainId, asset).via === 'stablecoin' ? policyHuman(rawToUnits(raw, decimals)) : null;
|
|
117
117
|
}
|
|
118
|
-
|
|
118
|
+
/* Calldata the policy prices: which word holds the amount, which the recipient
|
|
119
|
+
or spender, and for Permit2.approve which the token (there `to` is Permit2).
|
|
120
|
+
The same functions the local token cap prices, so the two guards agree. */
|
|
121
|
+
const SPEND_LAYOUTS = {
|
|
122
|
+
a9059cbb: { kind: 'token', what: 'transfer', toIndex: 0, amountIndex: 1 }, // transfer(address,uint256)
|
|
123
|
+
'23b872dd': { kind: 'token', what: 'transferFrom', toIndex: 1, amountIndex: 2 }, // transferFrom(address,address,uint256)
|
|
124
|
+
'4000aea0': { kind: 'token', what: 'transferAndCall', toIndex: 0, amountIndex: 1 }, // transferAndCall(address,uint256,bytes)
|
|
125
|
+
'9bd9bbc6': { kind: 'token', what: 'ERC-777 send', toIndex: 0, amountIndex: 1 }, // send(address,uint256,bytes)
|
|
126
|
+
'62ad1b83': { kind: 'token', what: 'ERC-777 operatorSend', toIndex: 1, amountIndex: 2 }, // operatorSend(address,address,uint256,bytes,bytes)
|
|
127
|
+
'095ea7b3': { kind: 'approval', what: 'approve', toIndex: 0, amountIndex: 1 }, // approve(address,uint256)
|
|
128
|
+
'39509351': { kind: 'approval', what: 'increaseAllowance', toIndex: 0, amountIndex: 1 }, // increaseAllowance(address,uint256)
|
|
129
|
+
cae9ca51: { kind: 'approval', what: 'approveAndCall', toIndex: 0, amountIndex: 1 }, // approveAndCall(address,uint256,bytes)
|
|
130
|
+
'87517c45': { kind: 'approval', what: 'Permit2 approve', toIndex: 1, amountIndex: 2, tokenIndex: 0, permit2Only: true }, // Permit2.approve(address,address,uint160,uint48)
|
|
131
|
+
};
|
|
132
|
+
const UNBOUNDED_APPROVAL = 1n << 128n;
|
|
119
133
|
async function describeSend(body, chainId) {
|
|
120
134
|
if (body.token_mint) {
|
|
121
135
|
const mint = String(body.token_mint);
|
|
@@ -124,14 +138,86 @@ async function describeSend(body, chainId) {
|
|
|
124
138
|
}
|
|
125
139
|
const data = String(body.data || '');
|
|
126
140
|
const to = String(body.to || '');
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
const
|
|
130
|
-
|
|
141
|
+
const value = String(body.value ?? '0');
|
|
142
|
+
if (!isSolanaChain(chainId) && /^0x[0-9a-fA-F]{8}/.test(data)) {
|
|
143
|
+
const layout = SPEND_LAYOUTS[data.slice(2, 10).toLowerCase()];
|
|
144
|
+
const isPermit2 = to.toLowerCase() === permit2Address(chainId).toLowerCase();
|
|
145
|
+
if (layout && (!layout.permit2Only || isPermit2)) {
|
|
146
|
+
// A short word is refused, never read as "not a transfer after all": the
|
|
147
|
+
// local guard treats truncated calldata the same way, for the same reason.
|
|
148
|
+
const wordAt = (i) => {
|
|
149
|
+
const w = data.slice(10 + i * 64, 10 + (i + 1) * 64);
|
|
150
|
+
if (w.length !== 64)
|
|
151
|
+
throw new Error(`policy: calldata for ${layout.what} is truncated (word ${i} has ${w.length / 2} bytes, expected 32); refused rather than treated as a plain contract call. Nothing was signed.`);
|
|
152
|
+
return w;
|
|
153
|
+
};
|
|
154
|
+
const token = layout.tokenIndex === undefined ? to : '0x' + wordAt(layout.tokenIndex).slice(24);
|
|
155
|
+
const counterparty = '0x' + wordAt(layout.toIndex).slice(24);
|
|
156
|
+
const amount = BigInt('0x' + wordAt(layout.amountIndex));
|
|
157
|
+
if (layout.kind === 'approval' && amount >= UNBOUNDED_APPROVAL) {
|
|
158
|
+
return { kind: 'approval', to: counterparty, asset: token, raw: amount.toString(), decimals: 0, unbounded: true };
|
|
159
|
+
}
|
|
160
|
+
// Decimals matter here only to price a registry stablecoin (resolved
|
|
161
|
+
// offline) and to print the ledger amount. A token the registry and the
|
|
162
|
+
// node cannot place is unpriced whatever its decimals, so a failed lookup
|
|
163
|
+
// is evaluated at 0 (the fail-closed figure the local cap uses) and the
|
|
164
|
+
// call goes on to the local guard or the hosted signer, which refuse on
|
|
165
|
+
// their own terms (audit-11312: transferAndCall on a not-a-token target).
|
|
166
|
+
let decimals = 0;
|
|
167
|
+
try {
|
|
168
|
+
decimals = await resolveTrustedDecimals(chainId, token);
|
|
169
|
+
}
|
|
170
|
+
catch {
|
|
171
|
+
decimals = 0;
|
|
172
|
+
}
|
|
173
|
+
return { kind: layout.kind, to: counterparty, asset: token, raw: amount.toString(), decimals };
|
|
174
|
+
}
|
|
131
175
|
}
|
|
132
176
|
if (data && data !== '0x')
|
|
133
|
-
return { kind: 'contract', to, asset: '', raw:
|
|
134
|
-
return { kind: 'native', to, asset: '', raw:
|
|
177
|
+
return { kind: 'contract', to, asset: '', raw: value, decimals: nativeDecimals(chainId) };
|
|
178
|
+
return { kind: 'native', to, asset: '', raw: value, decimals: nativeDecimals(chainId) };
|
|
179
|
+
}
|
|
180
|
+
/** The ledger method a decoded send is recorded under, unless the caller named one. */
|
|
181
|
+
function methodOf(kind) {
|
|
182
|
+
return kind === 'spl' ? 'spl' : kind === 'token' ? 'transfer' : kind === 'approval' ? 'approval' : 'native';
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* The list checks every send gets before anything is reserved: the chain, and
|
|
186
|
+
* for a contract call or an approval the contract itself against the deny
|
|
187
|
+
* list (an approval's contract is the token; its spender is checked as a
|
|
188
|
+
* recipient when the spend is reserved). Throws the refusal.
|
|
189
|
+
*/
|
|
190
|
+
function assertSendTarget(d, chainId) {
|
|
191
|
+
const c = chainAllowed(POL(), chainId);
|
|
192
|
+
if (!c.ok)
|
|
193
|
+
throw new Error(c.reason + ' Nothing was signed.');
|
|
194
|
+
if (d.kind === 'contract' || d.kind === 'approval') {
|
|
195
|
+
const t = targetAllowed(POL(), d.kind === 'approval' ? d.asset : d.to);
|
|
196
|
+
if (!t.ok)
|
|
197
|
+
throw new Error(t.reason + ' Nothing was signed.');
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* The spend a decoded send makes against the policy, or null when nothing
|
|
202
|
+
* leaves the wallet: a zero-value contract call can only pull what an
|
|
203
|
+
* approval already granted, and approvals are counted when they are granted.
|
|
204
|
+
* An approval's recipient is its spender, so recipients.allow and .deny bind
|
|
205
|
+
* who may be allowed to pull; the native value of a contract call is a spend
|
|
206
|
+
* to that contract. Throws for an unbounded approval under a policy.
|
|
207
|
+
*/
|
|
208
|
+
function spendInputOf(d, chainId, tool, wallet, ctx = {}) {
|
|
209
|
+
if (d.kind === 'contract' && BigInt(d.raw) === 0n)
|
|
210
|
+
return null;
|
|
211
|
+
if (d.kind === 'approval' && d.unbounded && POLICY.path) {
|
|
212
|
+
throw new Error(`policy: an unlimited approval of ${assetLabel(chainId, d.asset)} to ${d.to} cannot be counted against any budget, and under a spend policy every approval is a bounded spend. ` +
|
|
213
|
+
`Approve an exact amount instead. Nothing was signed.`);
|
|
214
|
+
}
|
|
215
|
+
return {
|
|
216
|
+
tool, mode: anyLocalMode() ? 'local' : 'hosted', wallet: anyLocalMode() ? 'local' : wallet, chainId,
|
|
217
|
+
asset: d.asset, assetLabel: assetLabel(chainId, d.asset), raw: d.raw, decimals: d.decimals,
|
|
218
|
+
usd: d.unbounded ? null : usdOf(chainId, d.asset, d.raw, d.decimals),
|
|
219
|
+
to: d.to, host: ctx.host ?? null, resource: ctx.resource ?? null, method: ctx.method ?? methodOf(d.kind),
|
|
220
|
+
};
|
|
135
221
|
}
|
|
136
222
|
// ─── Configuration ──────────────────────────────────────────────
|
|
137
223
|
const API_BASE = (process.env.AGENTWALLET_API_URL || 'https://hifriendbot.com/wp-json/agentwallet/v1').trim().replace(/\/+$/, '');
|
|
@@ -341,9 +427,11 @@ async function api(path, method = 'GET', body, extraHeaders) {
|
|
|
341
427
|
/* Spend policy at the one choke point every broadcast funds path passes
|
|
342
428
|
through (transfer, transfer_token, send_transaction, wrap, approve, the
|
|
343
429
|
x402 broadcast paths, hosted auto-pay): the body is decoded into what it
|
|
344
|
-
moves, checked against the policy, reserved in the ledger before
|
|
345
|
-
is signed, and settled or voided afterwards.
|
|
346
|
-
|
|
430
|
+
moves or allows, checked against the policy, reserved in the ledger before
|
|
431
|
+
anything is signed, and settled or voided afterwards. Approvals and the
|
|
432
|
+
native value of a contract call are spends too (1.14.1); only a zero-value
|
|
433
|
+
contract call reserves nothing. X-AGW-CTX carries the calling tool's
|
|
434
|
+
context for the ledger and never leaves this process. */
|
|
347
435
|
let ctx = {};
|
|
348
436
|
if (extraHeaders && extraHeaders['X-AGW-CTX'] !== undefined) {
|
|
349
437
|
try {
|
|
@@ -360,21 +448,10 @@ async function api(path, method = 'GET', body, extraHeaders) {
|
|
|
360
448
|
if (method === 'POST' && m && body) {
|
|
361
449
|
const chainId = localChainId(body, new URLSearchParams(path.split('?')[1] || ''));
|
|
362
450
|
const d = await describeSend(body, chainId);
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
const t = targetAllowed(POL(), d.to);
|
|
368
|
-
if (!t.ok)
|
|
369
|
-
throw new Error(t.reason + ' Nothing was signed.');
|
|
370
|
-
}
|
|
371
|
-
else {
|
|
372
|
-
reserved = reserveSpend({
|
|
373
|
-
tool: ctx.tool || 'send', mode: anyLocalMode() ? 'local' : 'hosted', wallet: anyLocalMode() ? 'local' : m[1], chainId,
|
|
374
|
-
asset: d.asset, assetLabel: assetLabel(chainId, d.asset), raw: d.raw, decimals: d.decimals, usd: usdOf(chainId, d.asset, d.raw, d.decimals),
|
|
375
|
-
to: d.to, host: ctx.host ?? null, resource: ctx.resource ?? null, method: ctx.method ?? (d.kind === 'spl' ? 'spl' : d.kind === 'token' ? 'transfer' : 'native'),
|
|
376
|
-
});
|
|
377
|
-
}
|
|
451
|
+
assertSendTarget(d, chainId);
|
|
452
|
+
const input = spendInputOf(d, chainId, ctx.tool || 'send', m[1], ctx);
|
|
453
|
+
if (input)
|
|
454
|
+
reserved = reserveSpend(input);
|
|
378
455
|
}
|
|
379
456
|
try {
|
|
380
457
|
const out = await apiInner(path, method, body, extraHeaders);
|
|
@@ -1034,7 +1111,7 @@ function resolveChainId(network) {
|
|
|
1034
1111
|
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');
|
|
1035
1112
|
const server = new McpServer({
|
|
1036
1113
|
name: 'agentwallet',
|
|
1037
|
-
version: '1.14.
|
|
1114
|
+
version: '1.14.1',
|
|
1038
1115
|
}, {
|
|
1039
1116
|
instructions: `AgentWallet gives AI agents their own blockchain wallets. ${anyLocalMode()
|
|
1040
1117
|
? '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.'
|
|
@@ -1378,7 +1455,8 @@ tool('call_contract', 'Execute a read-only call against a smart contract (eth_ca
|
|
|
1378
1455
|
// ─── Tool: approve_token ────────────────────────────────────────
|
|
1379
1456
|
tool('approve_token', 'Approve a spender contract to transfer ERC-20 tokens on your behalf. ' +
|
|
1380
1457
|
'Required before interacting with any DeFi protocol (DEXs, lending, etc.). ' +
|
|
1381
|
-
'Use amount "max" for unlimited approval, or specify an exact amount.'
|
|
1458
|
+
'Use amount "max" for unlimited approval, or specify an exact amount. ' +
|
|
1459
|
+
'Under a spend policy an approval is a spend: it counts against the budgets, the spender is checked as a recipient, and "max" is refused.', {
|
|
1382
1460
|
wallet_id: z.number().int().describe('Wallet ID'),
|
|
1383
1461
|
token: z.string().regex(/^0x[a-fA-F0-9]{40}$/).describe('ERC-20 token contract address'),
|
|
1384
1462
|
spender: z.string().regex(/^0x[a-fA-F0-9]{40}$/).describe('Contract address to approve as spender'),
|
|
@@ -2283,7 +2361,7 @@ tool('check_approval', 'Status of a payment approval created when pay_x402 excee
|
|
|
2283
2361
|
// ─── Tool: approve_permit2 ──────────────────────────────────────
|
|
2284
2362
|
tool('approve_permit2', 'One-time ERC-20 approval of a token to the Permit2 contract, needed before paying x402 "upto" endpoints with that token. ' +
|
|
2285
2363
|
'A normal on-chain transaction (needs gas). By default approves the maximum, the ecosystem norm, so it never has to be repeated; ' +
|
|
2286
|
-
'pass amount to grant a bounded allowance instead (required under AGENTWALLET_MAX_TX_TOKEN, which
|
|
2364
|
+
'pass amount to grant a bounded allowance instead (required under AGENTWALLET_MAX_TX_TOKEN and under a spend policy, both of which refuse a max approval; under a policy the allowance counts against the budgets). ' +
|
|
2287
2365
|
'Permit2 itself only moves what each signed authorization allows.', {
|
|
2288
2366
|
wallet_id: z.number().int().describe('Wallet ID'),
|
|
2289
2367
|
token: z.string().regex(/^0x[a-fA-F0-9]{40}$/).describe('ERC-20 token address (e.g. USDC on Base)'),
|
|
@@ -2700,12 +2778,22 @@ tool('simulate_transaction', 'Dry-run an EVM transaction with nothing signed: th
|
|
|
2700
2778
|
throw new Error('simulate_transaction is EVM-only.');
|
|
2701
2779
|
const valueRaw = parseUnits(value, 18);
|
|
2702
2780
|
const d = await describeSend({ to, value: valueRaw, data: data || '', chain_id }, chain_id);
|
|
2703
|
-
|
|
2704
|
-
|
|
2705
|
-
|
|
2706
|
-
|
|
2707
|
-
|
|
2708
|
-
|
|
2781
|
+
// The same gate the send path runs, with nothing recorded.
|
|
2782
|
+
const policy = (() => {
|
|
2783
|
+
try {
|
|
2784
|
+
assertSendTarget(d, chain_id);
|
|
2785
|
+
const input = spendInputOf(d, chain_id, 'simulate_transaction', String(wallet_id));
|
|
2786
|
+
return input ? spendVerdict(input) : { ok: true };
|
|
2787
|
+
}
|
|
2788
|
+
catch (e) {
|
|
2789
|
+
return { ok: false, reason: e.message };
|
|
2790
|
+
}
|
|
2791
|
+
})();
|
|
2792
|
+
const moves = {
|
|
2793
|
+
kind: d.kind, to: d.to, asset: d.asset || 'native', asset_label: assetLabel(chain_id, d.asset),
|
|
2794
|
+
amount: d.unbounded ? 'unlimited' : formatUnits(d.raw, d.decimals),
|
|
2795
|
+
...(d.kind === 'approval' ? { note: 'an approval: `to` is the spender being allowed to pull `amount` of `asset`' } : {}),
|
|
2796
|
+
};
|
|
2709
2797
|
if (isLocalMode()) {
|
|
2710
2798
|
const sim = await localSimulate(chain_id, to, BigInt(valueRaw), data && data !== '0x' ? data : undefined);
|
|
2711
2799
|
return jsonResponse({ ...sim, ok: Boolean(sim.ok) && policy.ok, moves, policy: policy.ok ? 'ok' : policy.reason, mode: 'local' });
|
package/build/ledger.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { type Spent } from './policy.js';
|
|
2
|
-
|
|
2
|
+
/** 'approval' is an ERC-20 allowance granted (approve, increaseAllowance, Permit2.approve): a spend the spender executes later, counted when granted. */
|
|
3
|
+
export type SpendMethod = 'eip3009' | 'permit2' | 'transfer' | 'native' | 'spl' | 'autopay' | 'approval';
|
|
3
4
|
export interface SpendEntry {
|
|
4
5
|
seq: number;
|
|
5
6
|
ts: string;
|
package/package.json
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
"type": "git",
|
|
6
6
|
"url": "git+https://github.com/hifriendbot/agentwallet-mcp.git"
|
|
7
7
|
},
|
|
8
|
-
"version": "1.14.
|
|
8
|
+
"version": "1.14.1",
|
|
9
9
|
"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.",
|
|
10
10
|
"type": "module",
|
|
11
11
|
"main": "build/index.js",
|