agentwallet-mcp 1.14.3 → 1.14.5

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,10 +373,10 @@ 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, 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).
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"`. An SPL send is priced at the mint's own decimals; a `token_decimals` hint that disagrees with the mint is refused before any request is made.
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), including value carried on a token transfer or approval call (a payable token contract keeps it, so it is a second spend, to that contract).
378
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.
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
+ - **`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. A host is matched by its canonical DNS name (lowercase, the root label's trailing dot removed), so `api.example.com.` and its percent-encoded spelling are `api.example.com` to every list and to the ledger's per-host sums.
380
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.
381
381
  - **`chains`**, **`assets`**: allow lists; an asset entry can be scoped `"8453:0x..."` or be the word `native`.
382
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.
@@ -384,7 +384,7 @@ A per-transaction cap bounds one signature. It cannot see that the five hundredt
384
384
 
385
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.
386
386
 
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. A spend that may already be on the wire (a broadcast that failed after the bytes left, a hosted request the API host took and then dropped) is kept counted as `unknown` rather than voided; a failure before anything left this process, including a policy or local-mode refusal, is always voided and never consumes budget. 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
+ **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. A spend that may already be on the wire (a broadcast that failed after the bytes left, a hosted request the API host took and then dropped) is kept counted as `unknown` rather than voided; a failure before anything left this process, including a policy or local-mode refusal, is always voided and never consumes budget. 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. A call that moves two things (a token transfer that also carries native value) is two lines, reserved in turn and released together when either is refused. `list_payments` reads it, `export_receipts` turns it into CSV or JSONL, and `AGENTWALLET_WEBHOOK_URL` receives every line as it is written.
388
388
 
389
389
  ### Encrypted keyfiles
390
390
 
package/build/index.js CHANGED
@@ -32,7 +32,7 @@ import { assessTokenRisk } from './token-risk.js';
32
32
  import { isLegacyAgentWalletAccept, requiredAmount, buildAuthorization, buildPaymentPayload, buildPaymentPayloadRaw, paymentHeaderFor, parsePaymentRequired, parseSettlement, pickOption, knownTokenDomain, decodeAbiString as decodeDomainString, parseEip7702Delegation, erc1271ProbeCalldata, classifyErc1271Probe, settlementVerdict, legacyReceiptAllowed, sanitizeRequired, } from './x402-eip3009.js';
33
33
  import { isSolanaLocalMode, getSolanaAddress, solanaWalletRecord, localSolBalance, localSplBalance, localSolTransfer, localSplTransfer, resolveSolanaRpc, mintDecimalsStrict, } from './local-solana.js';
34
34
  import { localSimulate } from './local-wallet.js';
35
- import { loadPolicy, describePolicy, hostAllowed, recipientAllowed, targetAllowed, chainAllowed, assetAllowed, perHostRule, toolAllowed, budgetReport, units as policyUnits, human as policyHuman, rawToUnits, } from './policy.js';
35
+ import { loadPolicy, describePolicy, hostAllowed, recipientAllowed, targetAllowed, chainAllowed, assetAllowed, perHostRule, toolAllowed, hostOf, budgetReport, units as policyUnits, human as policyHuman, rawToUnits, } from './policy.js';
36
36
  import { Ledger } from './ledger.js';
37
37
  import { markMaybeLive, isMaybeLive, carryMaybeLive, connectionNeverMade } from './maybe-live.js';
38
38
  import { discover } from './discovery.js';
@@ -135,7 +135,13 @@ async function describeSend(body, chainId) {
135
135
  if (body.token_mint) {
136
136
  const mint = String(body.token_mint);
137
137
  const d = Number(body.token_decimals);
138
- return { kind: 'spl', to: String(body.to || ''), asset: mint, raw: String(body.value ?? '0'), decimals: Number.isInteger(d) && d >= 0 && d <= 36 ? d : await verifiedDecimals(chainId, mint) };
138
+ // The caller's token_decimals only ever confirms the mint's own value: in
139
+ // hosted mode it was taken as given, so "18" for USDC priced a 1,000 USDC
140
+ // send at a trillionth of a dollar against the budget and the request
141
+ // reached the custodian (reported privately 2026-10-24, 1.14.5). The chain
142
+ // would have refused the TransferChecked, but the ledger was already wrong.
143
+ const given = Number.isInteger(d) && d >= 0 && d <= 36 ? d : undefined;
144
+ return { kind: 'spl', to: String(body.to || ''), asset: mint, raw: String(body.value ?? '0'), decimals: await verifiedDecimals(chainId, mint, given) };
139
145
  }
140
146
  const data = String(body.data || '');
141
147
  const to = String(body.to || '');
@@ -155,8 +161,12 @@ async function describeSend(body, chainId) {
155
161
  const token = layout.tokenIndex === undefined ? to : '0x' + wordAt(layout.tokenIndex).slice(24);
156
162
  const counterparty = '0x' + wordAt(layout.toIndex).slice(24);
157
163
  const amount = BigInt('0x' + wordAt(layout.amountIndex));
164
+ // The call's own value does not vanish because the calldata was
165
+ // recognised: the contract keeps whatever rides along. It is a second
166
+ // spend, native, to the contract (reported privately 2026-10-23, 1.14.4).
167
+ const carried = BigInt(value) > 0n ? { raw: value, to } : undefined;
158
168
  if (layout.kind === 'approval' && amount >= UNBOUNDED_APPROVAL) {
159
- return { kind: 'approval', to: counterparty, asset: token, raw: amount.toString(), decimals: 0, unbounded: true };
169
+ return { kind: 'approval', to: counterparty, asset: token, raw: amount.toString(), decimals: 0, unbounded: true, ...(carried ? { carried } : {}) };
160
170
  }
161
171
  // Decimals matter here only to price a registry stablecoin (resolved
162
172
  // offline) and to print the ledger amount. A token the registry and the
@@ -171,7 +181,7 @@ async function describeSend(body, chainId) {
171
181
  catch {
172
182
  decimals = 0;
173
183
  }
174
- return { kind: layout.kind, to: counterparty, asset: token, raw: amount.toString(), decimals };
184
+ return { kind: layout.kind, to: counterparty, asset: token, raw: amount.toString(), decimals, ...(carried ? { carried } : {}) };
175
185
  }
176
186
  }
177
187
  if (data && data !== '0x')
@@ -197,28 +207,40 @@ function assertSendTarget(d, chainId) {
197
207
  if (!t.ok)
198
208
  throw new Error(t.reason + ' Nothing was signed.');
199
209
  }
210
+ if (d.carried) {
211
+ const t = targetAllowed(POL(), d.carried.to); // the contract that keeps the value
212
+ if (!t.ok)
213
+ throw new Error(t.reason + ' Nothing was signed.');
214
+ }
200
215
  }
201
216
  /**
202
- * The spend a decoded send makes against the policy, or null when nothing
203
- * leaves the wallet: a zero-value contract call can only pull what an
204
- * approval already granted, and approvals are counted when they are granted.
205
- * An approval's recipient is its spender, so recipients.allow and .deny bind
206
- * who may be allowed to pull; the native value of a contract call is a spend
207
- * to that contract. Throws for an unbounded approval under a policy.
217
+ * The spends a decoded send makes against the policy, in the order they are
218
+ * reserved; empty when nothing leaves the wallet: a zero-value contract call
219
+ * can only pull what an approval already granted, and approvals are counted
220
+ * when they are granted. An approval's recipient is its spender, so
221
+ * recipients.allow and .deny bind who may be allowed to pull; the native
222
+ * value of a contract call is a spend to that contract; the native value
223
+ * carried on a token or approval call is a second spend, native, to the
224
+ * called contract (1.14.4). Throws for an unbounded approval under a policy.
208
225
  */
209
- function spendInputOf(d, chainId, tool, wallet, ctx = {}) {
210
- if (d.kind === 'contract' && BigInt(d.raw) === 0n)
211
- return null;
226
+ function spendInputsOf(d, chainId, tool, wallet, ctx = {}) {
212
227
  if (d.kind === 'approval' && d.unbounded && POLICY.path) {
213
228
  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. ` +
214
229
  `Approve an exact amount instead. Nothing was signed.`);
215
230
  }
216
- return {
217
- tool, mode: anyLocalMode() ? 'local' : 'hosted', wallet: anyLocalMode() ? 'local' : wallet, chainId,
218
- asset: d.asset, assetLabel: assetLabel(chainId, d.asset), raw: d.raw, decimals: d.decimals,
219
- usd: d.unbounded ? null : usdOf(chainId, d.asset, d.raw, d.decimals),
220
- to: d.to, host: ctx.host ?? null, resource: ctx.resource ?? null, method: ctx.method ?? methodOf(d.kind),
221
- };
231
+ const base = { tool, mode: anyLocalMode() ? 'local' : 'hosted', wallet: anyLocalMode() ? 'local' : wallet, chainId, host: ctx.host ?? null, resource: ctx.resource ?? null };
232
+ const out = [];
233
+ if (!(d.kind === 'contract' && BigInt(d.raw) === 0n)) {
234
+ out.push({
235
+ ...base, asset: d.asset, assetLabel: assetLabel(chainId, d.asset), raw: d.raw, decimals: d.decimals,
236
+ usd: d.unbounded ? null : usdOf(chainId, d.asset, d.raw, d.decimals),
237
+ to: d.to, method: ctx.method ?? methodOf(d.kind),
238
+ });
239
+ }
240
+ if (d.carried) {
241
+ out.push({ ...base, asset: '', assetLabel: assetLabel(chainId, ''), raw: d.carried.raw, decimals: nativeDecimals(chainId), usd: null, to: d.carried.to, method: 'native' });
242
+ }
243
+ return out;
222
244
  }
223
245
  // ─── Configuration ──────────────────────────────────────────────
224
246
  const API_BASE = (process.env.AGENTWALLET_API_URL || 'https://hifriendbot.com/wp-json/agentwallet/v1').trim().replace(/\/+$/, '');
@@ -444,7 +466,7 @@ async function api(path, method = 'GET', body, extraHeaders) {
444
466
  extraHeaders = { ...extraHeaders };
445
467
  delete extraHeaders['X-AGW-CTX'];
446
468
  }
447
- let reserved = null;
469
+ const reserved = [];
448
470
  // /sign with a transaction body is the same spend as /send: the caller gets
449
471
  // a broadcast-ready raw transaction (2026-10-14 report, 1.14.2). A message
450
472
  // signature (body.message) moves nothing and is not gated.
@@ -454,18 +476,28 @@ async function api(path, method = 'GET', body, extraHeaders) {
454
476
  const chainId = localChainId(body, new URLSearchParams(path.split('?')[1] || ''));
455
477
  const d = await describeSend(body, chainId);
456
478
  assertSendTarget(d, chainId);
457
- const input = spendInputOf(d, chainId, ctx.tool || (m[2] === 'sign' ? 'sign_transaction' : 'send'), m[1], ctx);
458
- if (input)
459
- reserved = reserveSpend(input);
479
+ const tool = ctx.tool || (m[2] === 'sign' ? 'sign_transaction' : 'send');
480
+ // One line per spend the call makes (a token call that also carries native
481
+ // value makes two); a refusal of a later one releases the earlier ones.
482
+ for (const input of spendInputsOf(d, chainId, tool, m[1], ctx)) {
483
+ try {
484
+ reserved.push(reserveSpend(input));
485
+ }
486
+ catch (e) {
487
+ for (const seq of reserved)
488
+ LEDGER.void(seq, `released, the same call was refused: ${e.message}`);
489
+ throw e;
490
+ }
491
+ }
460
492
  }
461
493
  try {
462
494
  const out = await apiInner(path, method, body, extraHeaders);
463
- if (reserved !== null) {
495
+ for (const seq of reserved) {
464
496
  const r = out;
465
497
  if (m[2] === 'sign')
466
- LEDGER.settle(reserved, 'signed', 'raw transaction returned to the caller; not broadcast by this process, so it stays counted');
498
+ LEDGER.settle(seq, 'signed', 'raw transaction returned to the caller; not broadcast by this process, so it stays counted');
467
499
  else
468
- LEDGER.settle(reserved, 'broadcast', typeof r?.tx_hash === 'string' ? r.tx_hash : typeof r?.signature === 'string' ? r.signature : null);
500
+ LEDGER.settle(seq, 'broadcast', typeof r?.tx_hash === 'string' ? r.tx_hash : typeof r?.signature === 'string' ? r.signature : null);
469
501
  }
470
502
  return out;
471
503
  }
@@ -476,12 +508,12 @@ async function api(path, method = 'GET', body, extraHeaders) {
476
508
  // message and the word "broadcasts" in the local-mode refusal of
477
509
  // transaction signing made every refused sign_transaction consume the
478
510
  // budget for good (reported privately 2026-10-16, 1.14.3).
479
- if (reserved !== null) {
511
+ for (const seq of reserved) {
480
512
  const msg = e.message;
481
513
  if (isMaybeLive(e))
482
- LEDGER.settle(reserved, 'unknown', msg);
514
+ LEDGER.settle(seq, 'unknown', msg);
483
515
  else
484
- LEDGER.void(reserved, msg);
516
+ LEDGER.void(seq, msg);
485
517
  }
486
518
  throw e;
487
519
  }
@@ -757,8 +789,8 @@ async function handleX402Payment(x402Data, originalPath, originalMethod, origina
757
789
  }
758
790
  // Send payment using our wallet (with X-AGW-SKIP-X402 to prevent recursion).
759
791
  // The ledger context names the API host and route; the /send hook reserves and settles the spend.
760
- const ctx = { 'X-AGW-SKIP-X402': 'true', 'X-AGW-CTX': JSON.stringify({ tool: 'autopay', host: new URL(API_BASE).hostname, resource: `${originalMethod} ${originalPath.split('?')[0]}`, method: 'autopay' }) };
761
- const hostRule = hostAllowed(POL(), new URL(API_BASE).hostname);
792
+ const ctx = { 'X-AGW-SKIP-X402': 'true', 'X-AGW-CTX': JSON.stringify({ tool: 'autopay', host: hostOf(API_BASE), resource: `${originalMethod} ${originalPath.split('?')[0]}`, method: 'autopay' }) };
793
+ const hostRule = hostAllowed(POL(), hostOf(API_BASE));
762
794
  if (!hostRule.ok)
763
795
  throw new Error(`x402 auto-pay refused: ${hostRule.reason}`);
764
796
  let txHash;
@@ -1143,7 +1175,7 @@ function resolveChainId(network) {
1143
1175
  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');
1144
1176
  const server = new McpServer({
1145
1177
  name: 'agentwallet',
1146
- version: '1.14.3',
1178
+ version: '1.14.5',
1147
1179
  }, {
1148
1180
  instructions: `AgentWallet gives AI agents their own blockchain wallets. ${anyLocalMode()
1149
1181
  ? '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.'
@@ -1864,9 +1896,9 @@ tool('pay_x402', 'Handle an x402 payment flow. Fetches a URL, and if the server
1864
1896
  }
1865
1897
  // A host the policy denies is never contacted at all, not even for the quote.
1866
1898
  {
1867
- const early = hostAllowed(POL(), new URL(url).hostname);
1899
+ const early = hostAllowed(POL(), hostOf(url));
1868
1900
  if (!early.ok)
1869
- return jsonResponse({ status: null, payment_required: null, payment_made: false, policy_refusal: true, host: new URL(url).hostname, error: early.reason + ' The endpoint was not contacted.' });
1901
+ return jsonResponse({ status: null, payment_required: null, payment_made: false, policy_refusal: true, host: hostOf(url), error: early.reason + ' The endpoint was not contacted.' });
1870
1902
  }
1871
1903
  // Validate URL: canonicalizes IP literals (including IPv4-mapped IPv6) and
1872
1904
  // resolves DNS names, rejecting any private/internal destination. An early
@@ -2004,7 +2036,7 @@ tool('pay_x402', 'Handle an x402 payment flow. Fetches a URL, and if the server
2004
2036
  // Step 3e: Spend policy. The host, chain, asset and recipient lists, the
2005
2037
  // per-host ceiling, and whether the rolling budgets have room. Nothing is
2006
2038
  // recorded here; the reservation is written at signing time.
2007
- const payHost = new URL(url).hostname;
2039
+ const payHost = hostOf(url);
2008
2040
  const hostLimit = perHostRule(POL(), payHost);
2009
2041
  const amount18 = rawToUnits(rawAmount, trustedDecimals);
2010
2042
  const policyInput = {
@@ -2624,7 +2656,7 @@ tool('quote_x402', 'Ask what an x402 endpoint charges without paying: fetches th
2624
2656
  reqHeaders[k] = v;
2625
2657
  }
2626
2658
  }
2627
- const host = new URL(url).hostname;
2659
+ const host = hostOf(url);
2628
2660
  {
2629
2661
  const early = hostAllowed(POL(), host);
2630
2662
  if (!early.ok)
@@ -2815,8 +2847,12 @@ tool('simulate_transaction', 'Dry-run an EVM transaction with nothing signed: th
2815
2847
  const policy = (() => {
2816
2848
  try {
2817
2849
  assertSendTarget(d, chain_id);
2818
- const input = spendInputOf(d, chain_id, 'simulate_transaction', String(wallet_id));
2819
- return input ? spendVerdict(input) : { ok: true };
2850
+ for (const input of spendInputsOf(d, chain_id, 'simulate_transaction', String(wallet_id))) {
2851
+ const v = spendVerdict(input);
2852
+ if (!v.ok)
2853
+ return v;
2854
+ }
2855
+ return { ok: true };
2820
2856
  }
2821
2857
  catch (e) {
2822
2858
  return { ok: false, reason: e.message };
@@ -2826,6 +2862,7 @@ tool('simulate_transaction', 'Dry-run an EVM transaction with nothing signed: th
2826
2862
  kind: d.kind, to: d.to, asset: d.asset || 'native', asset_label: assetLabel(chain_id, d.asset),
2827
2863
  amount: d.unbounded ? 'unlimited' : formatUnits(d.raw, d.decimals),
2828
2864
  ...(d.kind === 'approval' ? { note: 'an approval: `to` is the spender being allowed to pull `amount` of `asset`' } : {}),
2865
+ ...(d.carried ? { carried_native: { amount: formatUnits(d.carried.raw, nativeDecimals(chain_id)), to: d.carried.to, note: 'native value sent along with the calldata: a second spend, against native_budget, to the called contract' } } : {}),
2829
2866
  };
2830
2867
  if (isLocalMode()) {
2831
2868
  const sim = await localSimulate(chain_id, to, BigInt(valueRaw), data && data !== '0x' ? data : undefined);
package/build/ledger.js CHANGED
@@ -18,7 +18,7 @@
18
18
  */
19
19
  import { appendFileSync, existsSync, readFileSync, openSync, closeSync, writeSync, unlinkSync, statSync } from 'node:fs';
20
20
  import { createHash, createHmac } from 'node:crypto';
21
- import { DAY_MS, MONTH_MS, units, rawToUnits, human } from './policy.js';
21
+ import { DAY_MS, MONTH_MS, units, rawToUnits, human, canonicalHost } from './policy.js';
22
22
  const GENESIS = '0'.repeat(64);
23
23
  const MAX_LINE = 16 * 1024;
24
24
  /** How long a spend waits for another process to finish its own check-and-append. */
@@ -190,7 +190,10 @@ export class Ledger {
190
190
  return this.sumNative(chainId, nowMs);
191
191
  }
192
192
  sumUsd(nowMs, host) {
193
- return this.sum(nowMs, e => (host === undefined || e.host === host) && e.usd !== null ? units(e.usd) : 0n);
193
+ // Lines are written with the canonical host, but a ledger from before 1.14.5
194
+ // may hold "api.example.com." lines; they belong to the same host's sum.
195
+ const want = host === undefined ? undefined : canonicalHost(host);
196
+ return this.sum(nowMs, e => (want === undefined || (e.host !== null && canonicalHost(e.host) === want)) && e.usd !== null ? units(e.usd) : 0n);
194
197
  }
195
198
  sumNative(chainId, nowMs) {
196
199
  return this.sum(nowMs, e => e.chain_id === chainId && e.asset === '' ? rawToUnits(e.raw, nativeDecimalsOf(chainId)) : 0n);
@@ -303,7 +306,7 @@ export class Ledger {
303
306
  const e = this.entries[i];
304
307
  if (e.kind !== 'spend')
305
308
  continue;
306
- if (opts.host !== undefined && e.host !== opts.host)
309
+ if (opts.host !== undefined && (e.host === null || canonicalHost(e.host) !== canonicalHost(opts.host)))
307
310
  continue;
308
311
  if (opts.since !== undefined && Date.parse(e.ts) < opts.since)
309
312
  continue;
package/build/policy.d.ts CHANGED
@@ -218,6 +218,16 @@ export declare function parsePolicy(text: string): Policy;
218
218
  export declare function loadPolicy(env?: NodeJS.ProcessEnv): LoadedPolicy;
219
219
  /** A budget is only meaningful across restarts, so it needs a file. */
220
220
  export declare function needsLedger(p: Policy): boolean;
221
+ /**
222
+ * A DNS name as the policy compares, sums and records it: lowercase, without
223
+ * the root label's trailing dot. "api.example.com." (and its %2E spelling)
224
+ * names the same host as "api.example.com", TLS accepts either, and Node's URL
225
+ * parser keeps the dot, so the raw hostname slipped past hosts.deny and
226
+ * per_host (reported privately 2026-10-24, 1.14.5). IP literals pass through.
227
+ */
228
+ export declare function canonicalHost(host: string): string;
229
+ /** The host of a URL as the policy sees it. */
230
+ export declare function hostOf(url: string): string;
221
231
  export declare function hostMatches(pattern: string, host: string): boolean;
222
232
  export declare function hostAllowed(p: Policy, host: string): {
223
233
  ok: true;
package/build/policy.js CHANGED
@@ -131,9 +131,28 @@ export function needsLedger(p) {
131
131
  return Boolean(p.budget || p.native_budget || (p.per_host && Object.values(p.per_host).some(h => h.daily || h.monthly)));
132
132
  }
133
133
  /* ── Matching ─────────────────────────────────────────────────────── */
134
+ /**
135
+ * A DNS name as the policy compares, sums and records it: lowercase, without
136
+ * the root label's trailing dot. "api.example.com." (and its %2E spelling)
137
+ * names the same host as "api.example.com", TLS accepts either, and Node's URL
138
+ * parser keeps the dot, so the raw hostname slipped past hosts.deny and
139
+ * per_host (reported privately 2026-10-24, 1.14.5). IP literals pass through.
140
+ */
141
+ export function canonicalHost(host) {
142
+ let h = host.trim().toLowerCase();
143
+ if (h.startsWith('['))
144
+ return h;
145
+ while (h.endsWith('.'))
146
+ h = h.slice(0, -1);
147
+ return h;
148
+ }
149
+ /** The host of a URL as the policy sees it. */
150
+ export function hostOf(url) {
151
+ return canonicalHost(new URL(url).hostname);
152
+ }
134
153
  export function hostMatches(pattern, host) {
135
- const p = pattern.toLowerCase();
136
- const h = host.toLowerCase();
154
+ const p = canonicalHost(pattern);
155
+ const h = canonicalHost(host);
137
156
  if (p.startsWith('*.'))
138
157
  return h.endsWith(p.slice(1)) && h.length > p.length - 1;
139
158
  return p === h;
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.3",
8
+ "version": "1.14.5",
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",