agentwallet-mcp 1.14.2 → 1.14.3

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
@@ -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. 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. `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
@@ -34,6 +34,7 @@ import { isSolanaLocalMode, getSolanaAddress, solanaWalletRecord, localSolBalanc
34
34
  import { localSimulate } from './local-wallet.js';
35
35
  import { loadPolicy, describePolicy, hostAllowed, recipientAllowed, targetAllowed, chainAllowed, assetAllowed, perHostRule, toolAllowed, budgetReport, units as policyUnits, human as policyHuman, rawToUnits, } from './policy.js';
36
36
  import { Ledger } from './ledger.js';
37
+ import { markMaybeLive, isMaybeLive, carryMaybeLive, connectionNeverMade } from './maybe-live.js';
37
38
  import { discover } from './discovery.js';
38
39
  /* `agentwallet-mcp encrypt-keyfile <in> <out>` runs the keyfile tool instead of the server. */
39
40
  if (process.argv[2] === 'encrypt-keyfile') {
@@ -469,10 +470,15 @@ async function api(path, method = 'GET', body, extraHeaders) {
469
470
  return out;
470
471
  }
471
472
  catch (e) {
472
- // A send that may already be on the wire stays counted; only a spend that never left this process is voided.
473
+ // A send that may already be on the wire stays counted; only a spend that
474
+ // never left this process is voided. The signers mark the former on the
475
+ // error object (markMaybeLive); the decision used to be a regex over the
476
+ // message and the word "broadcasts" in the local-mode refusal of
477
+ // transaction signing made every refused sign_transaction consume the
478
+ // budget for good (reported privately 2026-10-16, 1.14.3).
473
479
  if (reserved !== null) {
474
480
  const msg = e.message;
475
- if (/BROADCAST|MAY be live/i.test(msg))
481
+ if (isMaybeLive(e))
476
482
  LEDGER.settle(reserved, 'unknown', msg);
477
483
  else
478
484
  LEDGER.void(reserved, msg);
@@ -490,7 +496,7 @@ async function apiInner(path, method = 'GET', body, extraHeaders) {
490
496
  handled = await routeLocally(path, method, body);
491
497
  }
492
498
  catch (e) {
493
- throw new Error(redactRpcSecrets(e)); // viem puts the RPC URL, key and all, in its messages
499
+ throw carryMaybeLive(e, new Error(redactRpcSecrets(e))); // viem puts the RPC URL, key and all, in its messages; the may-be-live marker rides along
494
500
  }
495
501
  if (handled !== NOT_HANDLED)
496
502
  return handled;
@@ -512,7 +518,23 @@ async function apiInner(path, method = 'GET', body, extraHeaders) {
512
518
  if (body && method !== 'GET') {
513
519
  options.body = JSON.stringify(body);
514
520
  }
515
- const res = await fetch(url, options);
521
+ // Hosted mode: a connection that was never made (refused, unresolvable,
522
+ // TLS rejected) carried nothing to the host, so a reserved spend is voided.
523
+ // A timeout waiting for the answer or a reset mid-request may have reached
524
+ // the custodian, which may have signed and broadcast; a spend there stays
525
+ // counted (1.14.3).
526
+ let res;
527
+ try {
528
+ res = await fetch(url, options);
529
+ }
530
+ catch (e) {
531
+ if (method === 'POST' && /^\/wallets\/[^/]+\/(send|sign)(\?|$)/.test(path) && !connectionNeverMade(e)) {
532
+ const cause = e.cause;
533
+ const why = e?.name === 'TimeoutError' ? `no answer within ${API_TIMEOUT_MS / 1000} s` : (cause?.code || cause?.message || e?.message || 'connection failed');
534
+ throw markMaybeLive(new Error(`The AgentWallet API host did not answer ${path} (${why}). The request may have reached it, so a transaction may be live; check the wallet's history before retrying.`));
535
+ }
536
+ throw e;
537
+ }
516
538
  if (res.status >= 300 && res.status < 400) {
517
539
  throw new Error(`The AgentWallet API host answered ${path} with a redirect (HTTP ${res.status}); refusing to follow it.`);
518
540
  }
@@ -522,7 +544,9 @@ async function apiInner(path, method = 'GET', body, extraHeaders) {
522
544
  data = JSON.parse(text);
523
545
  }
524
546
  catch {
525
- throw new Error(`The AgentWallet API host returned a non-JSON response (HTTP ${res.status}).`);
547
+ // A 2xx the host did not phrase as JSON may still have been a completed send; counted, not voided (1.14.3).
548
+ const err = new Error(`The AgentWallet API host returned a non-JSON response (HTTP ${res.status}).`);
549
+ throw res.ok && method === 'POST' ? markMaybeLive(err) : err;
526
550
  }
527
551
  data = boundHostData(data, 0); // strings, arrays and depth bounded; the host writes none of the agent's instructions
528
552
  // A 402 from the API host is paid automatically only for a HOSTED wallet the
@@ -552,7 +576,8 @@ async function apiInner(path, method = 'GET', body, extraHeaders) {
552
576
  if (method === 'POST' && /^\/wallets\/[^/]+\/send(\?|$)/.test(path) && data && typeof data === 'object') {
553
577
  const d = data;
554
578
  if (!looksLikeTxHash(d.tx_hash) && !(typeof d.signature === 'string' && /^[1-9A-HJ-NP-Za-km-z]{43,120}$/.test(d.signature))) {
555
- throw new Error('The AgentWallet API host answered the send without a transaction hash or signature; treating it as not sent.');
579
+ // The agent is told it was not sent (no hash to act on); the ledger keeps it counted because the host may disagree (1.14.3).
580
+ throw markMaybeLive(new Error('The AgentWallet API host answered the send without a transaction hash or signature; treating it as not sent.'));
556
581
  }
557
582
  }
558
583
  // The marker comes FIRST so a truncated result still carries it, and an
@@ -1118,7 +1143,7 @@ function resolveChainId(network) {
1118
1143
  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');
1119
1144
  const server = new McpServer({
1120
1145
  name: 'agentwallet',
1121
- version: '1.14.2',
1146
+ version: '1.14.3',
1122
1147
  }, {
1123
1148
  instructions: `AgentWallet gives AI agents their own blockchain wallets. ${anyLocalMode()
1124
1149
  ? '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.'
@@ -16,6 +16,7 @@ import { Connection, Keypair, PublicKey, SystemProgram, Transaction, Transaction
16
16
  import bs58 from 'bs58';
17
17
  import { lookupTrustedDecimals } from './x402-payment.js';
18
18
  import { openKeyfile } from './keyfile.js';
19
+ import { markMaybeLive, isMaybeLive } from './maybe-live.js';
19
20
  /* ── Program IDs ─────────────────────────────────────────────────── */
20
21
  const TOKEN_PROGRAM = new PublicKey('TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA');
21
22
  const TOKEN_2022_PROGRAM = new PublicKey('TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb');
@@ -244,7 +245,8 @@ async function sendSigned(conn, tx, payer) {
244
245
  throw new Error('The transaction could not be signed.');
245
246
  const local = bs58.encode(tx.signature);
246
247
  const reported = await conn.sendRawTransaction(tx.serialize());
247
- const after = (what) => new Error(`Transaction ${local} was BROADCAST but ${what}. Check it on an explorer before retrying: a retry is a second transfer.`);
248
+ // Marked, not worded: the ledger keeps the spend counted because of the marker, never because of the text (1.14.3).
249
+ const after = (what) => markMaybeLive(new Error(`Transaction ${local} was BROADCAST but ${what}. Check it on an explorer before retrying: a retry is a second transfer.`));
248
250
  if (reported !== local)
249
251
  throw after(`the RPC answered with a different signature (${String(reported).slice(0, 90)}), so the node cannot be trusted about its fate`);
250
252
  const confirm = conn.confirmTransaction({ signature: local, blockhash, lastValidBlockHeight }, 'confirmed');
@@ -254,7 +256,7 @@ async function sendSigned(conn, tx, payer) {
254
256
  result = await Promise.race([confirm, timeout]);
255
257
  }
256
258
  catch (e) {
257
- if (e instanceof Error && /was BROADCAST/.test(e.message))
259
+ if (isMaybeLive(e))
258
260
  throw e;
259
261
  throw after(`confirmation failed: ${e instanceof Error ? e.message : JSON.stringify(e)}`);
260
262
  }
@@ -23,6 +23,7 @@ import { wrappedNativeAddress } from './wrapped-native.js';
23
23
  import { typedDataFor } from './x402-eip3009.js';
24
24
  import { uptoTypedData } from './x402-permit2.js';
25
25
  import { openKeyfile } from './keyfile.js';
26
+ import { markMaybeLive } from './maybe-live.js';
26
27
  /* ── Key loading ─────────────────────────────────────────────────── */
27
28
  /**
28
29
  * Read the key once at startup. Kept module-private: nothing exports the key
@@ -552,10 +553,11 @@ export async function localSend(chainId, to, valueWei, data, fees) {
552
553
  }
553
554
  catch (e) {
554
555
  account.nonceManager.reset({ address, chainId });
555
- throw new Error(`Broadcast of transaction ${localHash} (nonce ${nonce}) failed: ${e.message.split('\n')[0]}. The transaction is signed and MAY be live if the node forwarded it before answering; check the hash on an explorer before retrying.`);
556
+ // Marked, not worded: the ledger keeps this spend counted because of the marker, never because of the text (1.14.3).
557
+ throw markMaybeLive(new Error(`Broadcast of transaction ${localHash} (nonce ${nonce}) failed: ${e.message.split('\n')[0]}. The transaction is signed and MAY be live if the node forwarded it before answering; check the hash on an explorer before retrying.`));
556
558
  }
557
559
  if (reported.toLowerCase() !== localHash.toLowerCase()) {
558
- throw new Error(`Transaction ${localHash} (nonce ${nonce}) was BROADCAST, but the node answered with a different hash (${String(reported).slice(0, 80)}), so it cannot be trusted about its fate. Verify ${localHash} on an explorer before retrying.`);
560
+ throw markMaybeLive(new Error(`Transaction ${localHash} (nonce ${nonce}) was BROADCAST, but the node answered with a different hash (${String(reported).slice(0, 80)}), so it cannot be trusted about its fate. Verify ${localHash} on an explorer before retrying.`));
559
561
  }
560
562
  return {
561
563
  tx_hash: localHash,
@@ -0,0 +1,13 @@
1
+ /** Mark `err` as thrown after the transaction may have gone out. Returns it. */
2
+ export declare function markMaybeLive<T extends object>(err: T): T;
3
+ /** Was this error thrown after the transaction may have gone out? */
4
+ export declare function isMaybeLive(err: unknown): boolean;
5
+ /** Copy the marker from `from` onto `to` (for errors re-thrown with a redacted message). */
6
+ export declare function carryMaybeLive<T extends object>(from: unknown, to: T): T;
7
+ /**
8
+ * A fetch() rejection whose cause says the connection was never made: nothing
9
+ * reached the host, so a reserved spend can be voided. Anything else (a timeout
10
+ * waiting for the answer, a reset mid-request, an unknown cause) may have
11
+ * reached the host, and a send there is treated as possibly live.
12
+ */
13
+ export declare function connectionNeverMade(err: unknown): boolean;
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Marks an error thrown after a transaction may already have left this
3
+ * process, so the ledger keeps the reservation counted instead of voiding it.
4
+ *
5
+ * The decision used to be a regex over the error text (/BROADCAST|MAY be live/i)
6
+ * and misfired on any message that merely contained the word "broadcasts",
7
+ * including the local-mode refusal of transaction signing, so every refused
8
+ * sign_transaction consumed the budget for good (reported privately, 2026-10-16,
9
+ * fixed in 1.14.3). A marker on the error object cannot misfire on wording.
10
+ *
11
+ * Symbol.for so the marker survives a duplicated module in the bundle; a plain
12
+ * `instanceof` would not.
13
+ */
14
+ const MARK = Symbol.for('agentwallet.maybeLive');
15
+ /** Mark `err` as thrown after the transaction may have gone out. Returns it. */
16
+ export function markMaybeLive(err) {
17
+ err[MARK] = true;
18
+ return err;
19
+ }
20
+ /** Was this error thrown after the transaction may have gone out? */
21
+ export function isMaybeLive(err) {
22
+ return typeof err === 'object' && err !== null && err[MARK] === true;
23
+ }
24
+ /** Copy the marker from `from` onto `to` (for errors re-thrown with a redacted message). */
25
+ export function carryMaybeLive(from, to) {
26
+ return isMaybeLive(from) ? markMaybeLive(to) : to;
27
+ }
28
+ /**
29
+ * A fetch() rejection whose cause says the connection was never made: nothing
30
+ * reached the host, so a reserved spend can be voided. Anything else (a timeout
31
+ * waiting for the answer, a reset mid-request, an unknown cause) may have
32
+ * reached the host, and a send there is treated as possibly live.
33
+ */
34
+ export function connectionNeverMade(err) {
35
+ const e = err;
36
+ const code = e?.cause?.code ?? '';
37
+ // fetch() refuses some requests before opening a socket (a blocked port such
38
+ // as 1, an unsupported scheme, a URL it will not parse); undici reports those
39
+ // as "fetch failed" with a bare-message cause and no errno.
40
+ if (!code && /^(bad port|invalid url|unsupported scheme|unknown scheme|url scheme ".*" is not supported|fetch failed)$/i.test(e?.cause?.message ?? ''))
41
+ return true;
42
+ if (/^(ECONNREFUSED|ENOTFOUND|EAI_AGAIN|EHOSTUNREACH|ENETUNREACH|EADDRNOTAVAIL|UND_ERR_CONNECT_TIMEOUT|ERR_TLS_CERT_ALTNAME_INVALID|CERT_HAS_EXPIRED|DEPTH_ZERO_SELF_SIGNED_CERT|SELF_SIGNED_CERT_IN_CHAIN|UNABLE_TO_VERIFY_LEAF_SIGNATURE|UNABLE_TO_GET_ISSUER_CERT_LOCALLY|ERR_INVALID_URL)$/.test(code))
43
+ return true;
44
+ // Node's own TLS errors arrive as the cause with a name and no errno code.
45
+ if (/^(ERR_TLS_|ERR_SSL_)/.test(code))
46
+ return true;
47
+ return false;
48
+ }
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.2",
8
+ "version": "1.14.3",
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",