agentwallet-mcp 1.14.0 → 1.14.2
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 +5 -4
- package/build/index.js +134 -38
- package/build/ledger.d.ts +26 -8
- package/build/ledger.js +162 -54
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -373,17 +373,18 @@ 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.
|
|
383
384
|
|
|
384
385
|
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
|
|
|
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.
|
|
387
|
+
**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. The chain is verified on every read, and a ledger that does not verify refuses every spend until the operator has looked at it. Several agentwallet-mcp processes may share one ledger (two MCP clients on one wallet): the file is re-read before every check, and the check-and-append step holds an exclusive lock file (`<ledger>.lock`) across processes, so the budget is one budget however many processes enforce it; a lock older than 30 seconds is treated as left by a dead process. A transaction signed without being broadcast (`sign_transaction`) is reserved and counted like a send, since whoever holds it can broadcast 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
|
|
|
388
389
|
### Encrypted keyfiles
|
|
389
390
|
|
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 {
|
|
@@ -356,31 +444,27 @@ async function api(path, method = 'GET', body, extraHeaders) {
|
|
|
356
444
|
delete extraHeaders['X-AGW-CTX'];
|
|
357
445
|
}
|
|
358
446
|
let reserved = null;
|
|
359
|
-
|
|
360
|
-
|
|
447
|
+
// /sign with a transaction body is the same spend as /send: the caller gets
|
|
448
|
+
// a broadcast-ready raw transaction (2026-10-14 report, 1.14.2). A message
|
|
449
|
+
// signature (body.message) moves nothing and is not gated.
|
|
450
|
+
const m = /^\/wallets\/([^/]+)\/(send|sign)(\?|$)/.exec(path);
|
|
451
|
+
const signsTx = m !== null && (m[2] === 'send' || typeof body?.message !== 'string');
|
|
452
|
+
if (method === 'POST' && m && body && signsTx) {
|
|
361
453
|
const chainId = localChainId(body, new URLSearchParams(path.split('?')[1] || ''));
|
|
362
454
|
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
|
-
}
|
|
455
|
+
assertSendTarget(d, chainId);
|
|
456
|
+
const input = spendInputOf(d, chainId, ctx.tool || (m[2] === 'sign' ? 'sign_transaction' : 'send'), m[1], ctx);
|
|
457
|
+
if (input)
|
|
458
|
+
reserved = reserveSpend(input);
|
|
378
459
|
}
|
|
379
460
|
try {
|
|
380
461
|
const out = await apiInner(path, method, body, extraHeaders);
|
|
381
462
|
if (reserved !== null) {
|
|
382
463
|
const r = out;
|
|
383
|
-
|
|
464
|
+
if (m[2] === 'sign')
|
|
465
|
+
LEDGER.settle(reserved, 'signed', 'raw transaction returned to the caller; not broadcast by this process, so it stays counted');
|
|
466
|
+
else
|
|
467
|
+
LEDGER.settle(reserved, 'broadcast', typeof r?.tx_hash === 'string' ? r.tx_hash : typeof r?.signature === 'string' ? r.signature : null);
|
|
384
468
|
}
|
|
385
469
|
return out;
|
|
386
470
|
}
|
|
@@ -1034,7 +1118,7 @@ function resolveChainId(network) {
|
|
|
1034
1118
|
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
1119
|
const server = new McpServer({
|
|
1036
1120
|
name: 'agentwallet',
|
|
1037
|
-
version: '1.14.
|
|
1121
|
+
version: '1.14.2',
|
|
1038
1122
|
}, {
|
|
1039
1123
|
instructions: `AgentWallet gives AI agents their own blockchain wallets. ${anyLocalMode()
|
|
1040
1124
|
? '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.'
|
|
@@ -1148,7 +1232,8 @@ tool('get_balance', 'Get the native token balance for a wallet on a specific cha
|
|
|
1148
1232
|
// ─── Tool: sign_transaction ──────────────────────────────────────
|
|
1149
1233
|
tool('sign_transaction', 'Sign a transaction with a wallet\'s private key. ' +
|
|
1150
1234
|
'For EVM: returns signed raw transaction hex. For Solana: returns base64 signed transaction. ' +
|
|
1151
|
-
'Does NOT broadcast
|
|
1235
|
+
'Does NOT broadcast. Use send_transaction for sign + broadcast. ' +
|
|
1236
|
+
'A signed transaction is a spend: under a spend policy it is checked, reserved on the ledger and counted against the budgets exactly as send_transaction is, because the caller can broadcast it.', {
|
|
1152
1237
|
wallet_id: z.number().int().describe('Wallet ID'),
|
|
1153
1238
|
to: AddressSchema.describe('Destination address (0x-prefixed for EVM, Base58 for Solana)'),
|
|
1154
1239
|
chain_id: z.number().int().optional().describe('Chain ID (defaults to wallet\'s default)'),
|
|
@@ -1378,7 +1463,8 @@ tool('call_contract', 'Execute a read-only call against a smart contract (eth_ca
|
|
|
1378
1463
|
// ─── Tool: approve_token ────────────────────────────────────────
|
|
1379
1464
|
tool('approve_token', 'Approve a spender contract to transfer ERC-20 tokens on your behalf. ' +
|
|
1380
1465
|
'Required before interacting with any DeFi protocol (DEXs, lending, etc.). ' +
|
|
1381
|
-
'Use amount "max" for unlimited approval, or specify an exact amount.'
|
|
1466
|
+
'Use amount "max" for unlimited approval, or specify an exact amount. ' +
|
|
1467
|
+
'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
1468
|
wallet_id: z.number().int().describe('Wallet ID'),
|
|
1383
1469
|
token: z.string().regex(/^0x[a-fA-F0-9]{40}$/).describe('ERC-20 token contract address'),
|
|
1384
1470
|
spender: z.string().regex(/^0x[a-fA-F0-9]{40}$/).describe('Contract address to approve as spender'),
|
|
@@ -2283,7 +2369,7 @@ tool('check_approval', 'Status of a payment approval created when pay_x402 excee
|
|
|
2283
2369
|
// ─── Tool: approve_permit2 ──────────────────────────────────────
|
|
2284
2370
|
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
2371
|
'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
|
|
2372
|
+
'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
2373
|
'Permit2 itself only moves what each signed authorization allows.', {
|
|
2288
2374
|
wallet_id: z.number().int().describe('Wallet ID'),
|
|
2289
2375
|
token: z.string().regex(/^0x[a-fA-F0-9]{40}$/).describe('ERC-20 token address (e.g. USDC on Base)'),
|
|
@@ -2700,12 +2786,22 @@ tool('simulate_transaction', 'Dry-run an EVM transaction with nothing signed: th
|
|
|
2700
2786
|
throw new Error('simulate_transaction is EVM-only.');
|
|
2701
2787
|
const valueRaw = parseUnits(value, 18);
|
|
2702
2788
|
const d = await describeSend({ to, value: valueRaw, data: data || '', chain_id }, chain_id);
|
|
2703
|
-
|
|
2704
|
-
|
|
2705
|
-
|
|
2706
|
-
|
|
2707
|
-
|
|
2708
|
-
|
|
2789
|
+
// The same gate the send path runs, with nothing recorded.
|
|
2790
|
+
const policy = (() => {
|
|
2791
|
+
try {
|
|
2792
|
+
assertSendTarget(d, chain_id);
|
|
2793
|
+
const input = spendInputOf(d, chain_id, 'simulate_transaction', String(wallet_id));
|
|
2794
|
+
return input ? spendVerdict(input) : { ok: true };
|
|
2795
|
+
}
|
|
2796
|
+
catch (e) {
|
|
2797
|
+
return { ok: false, reason: e.message };
|
|
2798
|
+
}
|
|
2799
|
+
})();
|
|
2800
|
+
const moves = {
|
|
2801
|
+
kind: d.kind, to: d.to, asset: d.asset || 'native', asset_label: assetLabel(chain_id, d.asset),
|
|
2802
|
+
amount: d.unbounded ? 'unlimited' : formatUnits(d.raw, d.decimals),
|
|
2803
|
+
...(d.kind === 'approval' ? { note: 'an approval: `to` is the spender being allowed to pull `amount` of `asset`' } : {}),
|
|
2804
|
+
};
|
|
2709
2805
|
if (isLocalMode()) {
|
|
2710
2806
|
const sim = await localSimulate(chain_id, to, BigInt(valueRaw), data && data !== '0x' ? data : undefined);
|
|
2711
2807
|
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;
|
|
@@ -75,42 +76,59 @@ export interface Limits {
|
|
|
75
76
|
} | null;
|
|
76
77
|
unpriced: 'refuse' | 'allow';
|
|
77
78
|
}
|
|
79
|
+
export interface LedgerOptions {
|
|
80
|
+
/** Tests shorten the wait for a held lock. */
|
|
81
|
+
lockWaitMs?: number;
|
|
82
|
+
}
|
|
78
83
|
export declare class Ledger {
|
|
79
84
|
readonly path: string | null;
|
|
80
85
|
private entries;
|
|
81
86
|
private last;
|
|
82
87
|
private seq;
|
|
83
|
-
/** null = intact; otherwise what broke. */
|
|
88
|
+
/** null = intact; otherwise what broke. Refreshed from the file on every read. */
|
|
84
89
|
integrity: string | null;
|
|
85
90
|
private webhook;
|
|
91
|
+
private lockWaitMs;
|
|
86
92
|
constructor(path: string | null, webhook?: {
|
|
87
93
|
url: string;
|
|
88
94
|
secret: string;
|
|
89
|
-
} | null);
|
|
90
|
-
|
|
95
|
+
} | null, opts?: LedgerOptions);
|
|
96
|
+
/**
|
|
97
|
+
* Re-read the file so the sums include what every other process wrote.
|
|
98
|
+
* The file must still begin with what this process has seen, line for
|
|
99
|
+
* line; otherwise it was rewritten underneath us and nothing is adopted.
|
|
100
|
+
*/
|
|
101
|
+
private sync;
|
|
102
|
+
/** Take the exclusive lock file; returns the release. A lock a dead process left behind is cleared after LOCK_STALE_MS. */
|
|
103
|
+
private lock;
|
|
91
104
|
private append;
|
|
92
105
|
private notify;
|
|
93
106
|
/** Spends not voided, newest last. */
|
|
94
107
|
private liveSpends;
|
|
95
|
-
/** Dollar sums over rolling windows; nowMs for tests. */
|
|
108
|
+
/** Dollar sums over rolling windows from a fresh read of the file; nowMs for tests. */
|
|
96
109
|
spentUsd(nowMs?: number, host?: string): Spent;
|
|
97
110
|
spentNative(chainId: number, nowMs?: number): Spent;
|
|
111
|
+
private sumUsd;
|
|
112
|
+
private sumNative;
|
|
98
113
|
private sum;
|
|
99
|
-
/** Would this spend fit every limit? The reason names the first it does not. */
|
|
114
|
+
/** Would this spend fit every limit, given what every process has written so far? The reason names the first limit it does not. */
|
|
100
115
|
check(input: ReserveInput, limits: Limits, nowMs?: number): {
|
|
101
116
|
ok: true;
|
|
102
117
|
} | {
|
|
103
118
|
ok: false;
|
|
104
119
|
reason: string;
|
|
105
120
|
};
|
|
106
|
-
|
|
121
|
+
private evaluate;
|
|
122
|
+
/** Re-read, check and record in one step under the lock. Returns the spend's seq. */
|
|
107
123
|
reserve(input: ReserveInput, limits: Limits): number;
|
|
124
|
+
/** A follow-up line, written under the lock after a fresh read so it chains onto whatever any process wrote last. */
|
|
125
|
+
private follow;
|
|
108
126
|
/** The spend never happened (signing or broadcast failed before anything left this process). */
|
|
109
127
|
void(ref: number, detail: string): void;
|
|
110
128
|
/** What the endpoint or chain said afterwards. Never throws: the payment already happened. */
|
|
111
129
|
settle(ref: number, outcome: string, detail: string | null): void;
|
|
112
130
|
size(): number;
|
|
113
|
-
/** Spends newest first with their latest follow-up folded in. */
|
|
131
|
+
/** Spends newest first with their latest follow-up folded in, from a fresh read. */
|
|
114
132
|
payments(opts: {
|
|
115
133
|
limit: number;
|
|
116
134
|
host?: string;
|
package/build/ledger.js
CHANGED
|
@@ -3,69 +3,146 @@
|
|
|
3
3
|
* process reserved, and what became of it.
|
|
4
4
|
*
|
|
5
5
|
* Budgets are enforced from it, so a reservation is written BEFORE the
|
|
6
|
-
* signature exists
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
6
|
+
* signature exists. Check and append happen in one step under an exclusive
|
|
7
|
+
* lock file, after the file has been re-read and the chain re-verified, so
|
|
8
|
+
* two concurrent calls cannot both fit the last dollar, whether they come
|
|
9
|
+
* from one process or from several agentwallet-mcp processes sharing the
|
|
10
|
+
* file (two MCP clients configured with the same wallet and ledger). A
|
|
11
|
+
* reservation whose signing fails is voided by a later line; the spend line
|
|
12
|
+
* itself is never edited, so the chain stays verifiable.
|
|
10
13
|
*
|
|
11
14
|
* Each line carries the sha256 of the previous line's hash plus its own body.
|
|
12
|
-
*
|
|
13
|
-
* refuses every spend until the operator looks at the file
|
|
15
|
+
* The chain is verified on every read; a broken chain with a budget
|
|
16
|
+
* configured refuses every spend until the operator looks at the file, and a
|
|
17
|
+
* file that no longer holds what this process wrote is refused the same way.
|
|
14
18
|
*/
|
|
15
|
-
import { appendFileSync, existsSync, readFileSync, openSync, closeSync } from 'node:fs';
|
|
19
|
+
import { appendFileSync, existsSync, readFileSync, openSync, closeSync, writeSync, unlinkSync, statSync } from 'node:fs';
|
|
16
20
|
import { createHash, createHmac } from 'node:crypto';
|
|
17
21
|
import { DAY_MS, MONTH_MS, units, rawToUnits, human } from './policy.js';
|
|
18
22
|
const GENESIS = '0'.repeat(64);
|
|
19
23
|
const MAX_LINE = 16 * 1024;
|
|
24
|
+
/** How long a spend waits for another process to finish its own check-and-append. */
|
|
25
|
+
const LOCK_WAIT_MS = 10_000;
|
|
26
|
+
/** A lock older than this was left by a process that died holding it. */
|
|
27
|
+
const LOCK_STALE_MS = 30_000;
|
|
28
|
+
const sleeper = new Int32Array(new SharedArrayBuffer(4));
|
|
29
|
+
function sleepSync(ms) { Atomics.wait(sleeper, 0, 0, ms); }
|
|
20
30
|
function hashOf(prev, body) {
|
|
21
31
|
return createHash('sha256').update(prev + '\n' + JSON.stringify(body)).digest('hex');
|
|
22
32
|
}
|
|
33
|
+
/** Parse and verify a ledger file's text. A trailing line without its newline is still being written by another process and is not read. */
|
|
34
|
+
function parseChain(text) {
|
|
35
|
+
const complete = text.endsWith('\n') || !text.includes('\n') && text.trim() === '' ? text : text.slice(0, text.lastIndexOf('\n') + 1);
|
|
36
|
+
const lines = complete.split('\n').filter(l => l.trim());
|
|
37
|
+
const out = { entries: [], last: GENESIS, seq: 0, integrity: null };
|
|
38
|
+
let prev = GENESIS;
|
|
39
|
+
for (let i = 0; i < lines.length; i++) {
|
|
40
|
+
let e;
|
|
41
|
+
try {
|
|
42
|
+
e = JSON.parse(lines[i]);
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
out.integrity = `line ${i + 1} is not JSON`;
|
|
46
|
+
break;
|
|
47
|
+
}
|
|
48
|
+
const { hash, ...body } = e;
|
|
49
|
+
if (e.prev !== prev || hashOf(prev, body) !== hash) {
|
|
50
|
+
out.integrity = `chain broken at line ${i + 1} (seq ${e.seq})`;
|
|
51
|
+
break;
|
|
52
|
+
}
|
|
53
|
+
if (e.seq !== out.seq + 1) {
|
|
54
|
+
out.integrity = `sequence gap at line ${i + 1}`;
|
|
55
|
+
break;
|
|
56
|
+
}
|
|
57
|
+
out.entries.push(e);
|
|
58
|
+
out.seq = e.seq;
|
|
59
|
+
prev = hash;
|
|
60
|
+
}
|
|
61
|
+
out.last = prev;
|
|
62
|
+
return out;
|
|
63
|
+
}
|
|
23
64
|
export class Ledger {
|
|
24
65
|
path;
|
|
25
66
|
entries = [];
|
|
26
67
|
last = GENESIS;
|
|
27
68
|
seq = 0;
|
|
28
|
-
/** null = intact; otherwise what broke. */
|
|
69
|
+
/** null = intact; otherwise what broke. Refreshed from the file on every read. */
|
|
29
70
|
integrity = null;
|
|
30
71
|
webhook = null;
|
|
31
|
-
|
|
72
|
+
lockWaitMs = LOCK_WAIT_MS;
|
|
73
|
+
constructor(path, webhook, opts) {
|
|
32
74
|
this.path = path;
|
|
33
75
|
this.webhook = webhook ?? null;
|
|
34
|
-
if (
|
|
35
|
-
this.
|
|
76
|
+
if (opts?.lockWaitMs !== undefined)
|
|
77
|
+
this.lockWaitMs = opts.lockWaitMs;
|
|
78
|
+
if (path) {
|
|
79
|
+
// Create it now so a permissions problem shows at startup, not at the first spend.
|
|
80
|
+
if (!existsSync(path))
|
|
81
|
+
closeSync(openSync(path, 'a', 0o600));
|
|
82
|
+
this.sync();
|
|
83
|
+
}
|
|
36
84
|
}
|
|
37
|
-
|
|
85
|
+
/**
|
|
86
|
+
* Re-read the file so the sums include what every other process wrote.
|
|
87
|
+
* The file must still begin with what this process has seen, line for
|
|
88
|
+
* line; otherwise it was rewritten underneath us and nothing is adopted.
|
|
89
|
+
*/
|
|
90
|
+
sync() {
|
|
91
|
+
if (!this.path)
|
|
92
|
+
return;
|
|
38
93
|
if (!existsSync(this.path)) {
|
|
39
|
-
|
|
40
|
-
closeSync(openSync(this.path, 'a', 0o600));
|
|
94
|
+
this.integrity = `the file was removed while this process was running (it held seq ${this.seq})`;
|
|
41
95
|
return;
|
|
42
96
|
}
|
|
43
|
-
const
|
|
44
|
-
const
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
97
|
+
const c = parseChain(readFileSync(this.path, 'utf8'));
|
|
98
|
+
const n = this.entries.length;
|
|
99
|
+
if (c.entries.length < n || (n > 0 && c.entries[n - 1].hash !== this.last)) {
|
|
100
|
+
this.integrity = c.integrity && c.entries.length < n ? c.integrity : `the file was rewritten underneath this process (it held seq ${this.seq} ending ${this.last.slice(0, 12)}, the file now ends at seq ${c.seq})`;
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
this.entries = c.entries;
|
|
104
|
+
this.seq = c.seq;
|
|
105
|
+
this.last = c.last;
|
|
106
|
+
this.integrity = c.integrity;
|
|
107
|
+
}
|
|
108
|
+
/** Take the exclusive lock file; returns the release. A lock a dead process left behind is cleared after LOCK_STALE_MS. */
|
|
109
|
+
lock() {
|
|
110
|
+
if (!this.path)
|
|
111
|
+
return () => { };
|
|
112
|
+
const lockPath = this.path + '.lock';
|
|
113
|
+
const deadline = Date.now() + this.lockWaitMs;
|
|
114
|
+
for (;;) {
|
|
48
115
|
try {
|
|
49
|
-
|
|
116
|
+
const fd = openSync(lockPath, 'wx', 0o600);
|
|
117
|
+
try {
|
|
118
|
+
writeSync(fd, `${process.pid} ${new Date().toISOString()}\n`);
|
|
119
|
+
}
|
|
120
|
+
finally {
|
|
121
|
+
closeSync(fd);
|
|
122
|
+
}
|
|
123
|
+
return () => { try {
|
|
124
|
+
unlinkSync(lockPath);
|
|
125
|
+
}
|
|
126
|
+
catch { /* already gone */ } };
|
|
50
127
|
}
|
|
51
|
-
catch {
|
|
52
|
-
|
|
53
|
-
|
|
128
|
+
catch (e) {
|
|
129
|
+
if (e.code !== 'EEXIST')
|
|
130
|
+
throw e;
|
|
54
131
|
}
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
132
|
+
try {
|
|
133
|
+
if (Date.now() - statSync(lockPath).mtimeMs > LOCK_STALE_MS) {
|
|
134
|
+
unlinkSync(lockPath);
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
catch {
|
|
139
|
+
continue; /* released between the two calls */
|
|
59
140
|
}
|
|
60
|
-
if (
|
|
61
|
-
|
|
62
|
-
break;
|
|
141
|
+
if (Date.now() >= deadline) {
|
|
142
|
+
throw new Error(`The ledger at ${this.path} is locked by another agentwallet-mcp process (${lockPath}) that did not finish within ${this.lockWaitMs} ms. Nothing was signed. If no other process is running, remove the lock file.`);
|
|
63
143
|
}
|
|
64
|
-
|
|
65
|
-
this.seq = e.seq;
|
|
66
|
-
prev = hash;
|
|
144
|
+
sleepSync(5);
|
|
67
145
|
}
|
|
68
|
-
this.last = prev;
|
|
69
146
|
}
|
|
70
147
|
append(body) {
|
|
71
148
|
if (this.integrity)
|
|
@@ -103,11 +180,19 @@ export class Ledger {
|
|
|
103
180
|
const voided = new Set(this.entries.filter(e => e.kind === 'void').map(e => e.ref));
|
|
104
181
|
return this.entries.filter((e) => e.kind === 'spend' && !voided.has(e.seq));
|
|
105
182
|
}
|
|
106
|
-
/** Dollar sums over rolling windows; nowMs for tests. */
|
|
183
|
+
/** Dollar sums over rolling windows from a fresh read of the file; nowMs for tests. */
|
|
107
184
|
spentUsd(nowMs = Date.now(), host) {
|
|
108
|
-
|
|
185
|
+
this.sync();
|
|
186
|
+
return this.sumUsd(nowMs, host);
|
|
109
187
|
}
|
|
110
188
|
spentNative(chainId, nowMs = Date.now()) {
|
|
189
|
+
this.sync();
|
|
190
|
+
return this.sumNative(chainId, nowMs);
|
|
191
|
+
}
|
|
192
|
+
sumUsd(nowMs, host) {
|
|
193
|
+
return this.sum(nowMs, e => (host === undefined || e.host === host) && e.usd !== null ? units(e.usd) : 0n);
|
|
194
|
+
}
|
|
195
|
+
sumNative(chainId, nowMs) {
|
|
111
196
|
return this.sum(nowMs, e => e.chain_id === chainId && e.asset === '' ? rawToUnits(e.raw, nativeDecimalsOf(chainId)) : 0n);
|
|
112
197
|
}
|
|
113
198
|
sum(nowMs, f) {
|
|
@@ -125,13 +210,17 @@ export class Ledger {
|
|
|
125
210
|
}
|
|
126
211
|
return out;
|
|
127
212
|
}
|
|
128
|
-
/** Would this spend fit every limit? The reason names the first it does not. */
|
|
213
|
+
/** Would this spend fit every limit, given what every process has written so far? The reason names the first limit it does not. */
|
|
129
214
|
check(input, limits, nowMs = Date.now()) {
|
|
215
|
+
this.sync();
|
|
216
|
+
return this.evaluate(input, limits, nowMs);
|
|
217
|
+
}
|
|
218
|
+
evaluate(input, limits, nowMs) {
|
|
130
219
|
if (this.integrity)
|
|
131
220
|
return { ok: false, reason: `The ledger at ${this.path} is not intact (${this.integrity}); every spend is refused until the operator has looked at it.` };
|
|
132
221
|
const amount18 = rawToUnits(input.raw, input.decimals);
|
|
133
222
|
if (input.asset === '') {
|
|
134
|
-
const r = refusal(limits.native, this.
|
|
223
|
+
const r = refusal(limits.native, this.sumNative(input.chainId, nowMs), amount18, `native_budget.${input.chainId}`);
|
|
135
224
|
if (r)
|
|
136
225
|
return { ok: false, reason: r };
|
|
137
226
|
}
|
|
@@ -142,33 +231,51 @@ export class Ledger {
|
|
|
142
231
|
}
|
|
143
232
|
if (input.usd !== null) {
|
|
144
233
|
const usd18 = units(input.usd);
|
|
145
|
-
const r = refusal(limits.usd, this.
|
|
234
|
+
const r = refusal(limits.usd, this.sumUsd(nowMs), usd18, 'budget');
|
|
146
235
|
if (r)
|
|
147
236
|
return { ok: false, reason: r };
|
|
148
237
|
if (limits.host && input.host) {
|
|
149
|
-
const h = refusal({ daily: limits.host.daily, monthly: limits.host.monthly }, this.
|
|
238
|
+
const h = refusal({ daily: limits.host.daily, monthly: limits.host.monthly }, this.sumUsd(nowMs, input.host), usd18, `per_host["${limits.host.pattern}"]`);
|
|
150
239
|
if (h)
|
|
151
240
|
return { ok: false, reason: h };
|
|
152
241
|
}
|
|
153
242
|
}
|
|
154
243
|
return { ok: true };
|
|
155
244
|
}
|
|
156
|
-
/**
|
|
245
|
+
/** Re-read, check and record in one step under the lock. Returns the spend's seq. */
|
|
157
246
|
reserve(input, limits) {
|
|
158
|
-
const
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
amount
|
|
165
|
-
|
|
166
|
-
|
|
247
|
+
const unlock = this.lock();
|
|
248
|
+
try {
|
|
249
|
+
this.sync();
|
|
250
|
+
const c = this.evaluate(input, limits, Date.now());
|
|
251
|
+
if (!c.ok)
|
|
252
|
+
throw new Error(c.reason);
|
|
253
|
+
const amount = human(rawToUnits(input.raw, input.decimals));
|
|
254
|
+
const e = this.append({
|
|
255
|
+
kind: 'spend', tool: input.tool, mode: input.mode, wallet: input.wallet, chain_id: input.chainId, asset: input.asset, asset_label: input.assetLabel,
|
|
256
|
+
amount, raw: input.raw, usd: input.usd, to: input.to, host: input.host, resource: input.resource, method: input.method, proof: input.proof ?? null,
|
|
257
|
+
});
|
|
258
|
+
return e.seq;
|
|
259
|
+
}
|
|
260
|
+
finally {
|
|
261
|
+
unlock();
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
/** A follow-up line, written under the lock after a fresh read so it chains onto whatever any process wrote last. */
|
|
265
|
+
follow(body) {
|
|
266
|
+
const unlock = this.lock();
|
|
267
|
+
try {
|
|
268
|
+
this.sync();
|
|
269
|
+
this.append(body);
|
|
270
|
+
}
|
|
271
|
+
finally {
|
|
272
|
+
unlock();
|
|
273
|
+
}
|
|
167
274
|
}
|
|
168
275
|
/** The spend never happened (signing or broadcast failed before anything left this process). */
|
|
169
276
|
void(ref, detail) {
|
|
170
277
|
try {
|
|
171
|
-
this.
|
|
278
|
+
this.follow({ kind: 'void', ref, outcome: 'void', detail: detail.slice(0, 300) });
|
|
172
279
|
}
|
|
173
280
|
catch (e) {
|
|
174
281
|
console.error(`[agentwallet] ledger void failed: ${e.message}`);
|
|
@@ -177,15 +284,16 @@ export class Ledger {
|
|
|
177
284
|
/** What the endpoint or chain said afterwards. Never throws: the payment already happened. */
|
|
178
285
|
settle(ref, outcome, detail) {
|
|
179
286
|
try {
|
|
180
|
-
this.
|
|
287
|
+
this.follow({ kind: 'settle', ref, outcome: outcome.slice(0, 40), detail: detail ? detail.slice(0, 300) : null });
|
|
181
288
|
}
|
|
182
289
|
catch (e) {
|
|
183
290
|
console.error(`[agentwallet] ledger settle failed: ${e.message}`);
|
|
184
291
|
}
|
|
185
292
|
}
|
|
186
|
-
size() { return this.entries.length; }
|
|
187
|
-
/** Spends newest first with their latest follow-up folded in. */
|
|
293
|
+
size() { this.sync(); return this.entries.length; }
|
|
294
|
+
/** Spends newest first with their latest follow-up folded in, from a fresh read. */
|
|
188
295
|
payments(opts) {
|
|
296
|
+
this.sync();
|
|
189
297
|
const follow = new Map();
|
|
190
298
|
for (const e of this.entries)
|
|
191
299
|
if (e.kind !== 'spend')
|
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.2",
|
|
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",
|