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 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 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.
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`). The allow list covers funds destinations; contract targets (approve, wrap, arbitrary calldata) are checked against the deny list and the chain list.
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. 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
+ **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
- /** Decode the spend a /send body describes: native value, an ERC-20 transfer, or an SPL transfer. */
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
- if (!isSolanaChain(chainId) && /^0xa9059cbb[0-9a-fA-F]{128}$/.test(data)) {
128
- const recipient = '0x' + data.slice(34, 74);
129
- const raw = BigInt('0x' + data.slice(74, 138)).toString();
130
- return { kind: 'token', to: recipient, asset: to, raw, decimals: await resolveTrustedDecimals(chainId, to) };
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: String(body.value ?? '0'), decimals: nativeDecimals(chainId) };
134
- return { kind: 'native', to, asset: '', raw: String(body.value ?? '0'), decimals: nativeDecimals(chainId) };
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 anything
345
- is signed, and settled or voided afterwards. X-AGW-CTX carries the calling
346
- tool's context for the ledger and never leaves this process. */
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
- const m = /^\/wallets\/([^/]+)\/send(\?|$)/.exec(path);
360
- if (method === 'POST' && m && body) {
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
- if (d.kind === 'contract') {
364
- const c = chainAllowed(POL(), chainId);
365
- if (!c.ok)
366
- throw new Error(c.reason + ' Nothing was signed.');
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
- LEDGER.settle(reserved, 'broadcast', typeof r?.tx_hash === 'string' ? r.tx_hash : typeof r?.signature === 'string' ? r.signature : null);
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.0',
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 — use send_transaction for sign + 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 refuses a max approval). ' +
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
- const policy = d.kind === 'contract'
2704
- ? (() => { for (const r of [chainAllowed(POL(), chain_id), targetAllowed(POL(), d.to)])
2705
- if (!r.ok)
2706
- return r; return { ok: true }; })()
2707
- : spendVerdict({ tool: 'simulate_transaction', mode: anyLocalMode() ? 'local' : 'hosted', wallet: anyLocalMode() ? 'local' : String(wallet_id), chainId: chain_id, asset: d.asset, assetLabel: assetLabel(chain_id, d.asset), raw: d.raw, decimals: d.decimals, usd: usdOf(chain_id, d.asset, d.raw, d.decimals), to: d.to, host: null, resource: null, method: d.kind === 'token' ? 'transfer' : 'native' });
2708
- const moves = { kind: d.kind, to: d.to, asset: d.asset || 'native', asset_label: assetLabel(chain_id, d.asset), amount: formatUnits(d.raw, d.decimals) };
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
- export type SpendMethod = 'eip3009' | 'permit2' | 'transfer' | 'native' | 'spl' | 'autopay';
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
- private load;
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
- /** Check and record in one synchronous step. Returns the spend's seq. */
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: check and append happen in one synchronous step, which in
7
- * a single-threaded process means two concurrent calls cannot both fit the
8
- * last dollar. A reservation whose signing fails is voided by a later line;
9
- * the spend line itself is never edited, so the chain stays verifiable.
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
- * On load the chain is verified; a broken chain with a budget configured
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
- constructor(path, webhook) {
72
+ lockWaitMs = LOCK_WAIT_MS;
73
+ constructor(path, webhook, opts) {
32
74
  this.path = path;
33
75
  this.webhook = webhook ?? null;
34
- if (path)
35
- this.load();
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
- load() {
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
- // Create it now so a permissions problem shows at startup, not at the first spend.
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 text = readFileSync(this.path, 'utf8');
44
- const lines = text.split('\n').filter(l => l.trim());
45
- let prev = GENESIS;
46
- for (let i = 0; i < lines.length; i++) {
47
- let e;
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
- e = JSON.parse(lines[i]);
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
- this.integrity = `line ${i + 1} is not JSON`;
53
- break;
128
+ catch (e) {
129
+ if (e.code !== 'EEXIST')
130
+ throw e;
54
131
  }
55
- const { hash, ...body } = e;
56
- if (e.prev !== prev || hashOf(prev, body) !== hash) {
57
- this.integrity = `chain broken at line ${i + 1} (seq ${e.seq})`;
58
- break;
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 (e.seq !== this.seq + 1) {
61
- this.integrity = `sequence gap at line ${i + 1}`;
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
- this.entries.push(e);
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
- return this.sum(nowMs, e => (host === undefined || e.host === host) && e.usd !== null ? units(e.usd) : 0n);
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.spentNative(input.chainId, nowMs), amount18, `native_budget.${input.chainId}`);
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.spentUsd(nowMs), usd18, 'budget');
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.spentUsd(nowMs, input.host), usd18, `per_host["${limits.host.pattern}"]`);
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
- /** Check and record in one synchronous step. Returns the spend's seq. */
245
+ /** Re-read, check and record in one step under the lock. Returns the spend's seq. */
157
246
  reserve(input, limits) {
158
- const c = this.check(input, limits);
159
- if (!c.ok)
160
- throw new Error(c.reason);
161
- const amount = human(rawToUnits(input.raw, input.decimals));
162
- const e = this.append({
163
- kind: 'spend', tool: input.tool, mode: input.mode, wallet: input.wallet, chain_id: input.chainId, asset: input.asset, asset_label: input.assetLabel,
164
- amount, raw: input.raw, usd: input.usd, to: input.to, host: input.host, resource: input.resource, method: input.method, proof: input.proof ?? null,
165
- });
166
- return e.seq;
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.append({ kind: 'void', ref, outcome: 'void', detail: detail.slice(0, 300) });
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.append({ kind: 'settle', ref, outcome: outcome.slice(0, 40), detail: detail ? detail.slice(0, 300) : null });
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.0",
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",