agentwallet-mcp 1.14.2 → 1.14.4
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 +2 -2
- package/build/index.js +92 -36
- package/build/local-solana.js +4 -2
- package/build/local-wallet.js +4 -2
- package/build/maybe-live.d.ts +13 -0
- package/build/maybe-live.js +48 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -374,7 +374,7 @@ A per-transaction cap bounds one signature. It cannot see that the five hundredt
|
|
|
374
374
|
```
|
|
375
375
|
|
|
376
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).
|
|
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
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.
|
|
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.
|
|
@@ -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. 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
|
@@ -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') {
|
|
@@ -154,8 +155,12 @@ async function describeSend(body, chainId) {
|
|
|
154
155
|
const token = layout.tokenIndex === undefined ? to : '0x' + wordAt(layout.tokenIndex).slice(24);
|
|
155
156
|
const counterparty = '0x' + wordAt(layout.toIndex).slice(24);
|
|
156
157
|
const amount = BigInt('0x' + wordAt(layout.amountIndex));
|
|
158
|
+
// The call's own value does not vanish because the calldata was
|
|
159
|
+
// recognised: the contract keeps whatever rides along. It is a second
|
|
160
|
+
// spend, native, to the contract (reported privately 2026-10-23, 1.14.4).
|
|
161
|
+
const carried = BigInt(value) > 0n ? { raw: value, to } : undefined;
|
|
157
162
|
if (layout.kind === 'approval' && amount >= UNBOUNDED_APPROVAL) {
|
|
158
|
-
return { kind: 'approval', to: counterparty, asset: token, raw: amount.toString(), decimals: 0, unbounded: true };
|
|
163
|
+
return { kind: 'approval', to: counterparty, asset: token, raw: amount.toString(), decimals: 0, unbounded: true, ...(carried ? { carried } : {}) };
|
|
159
164
|
}
|
|
160
165
|
// Decimals matter here only to price a registry stablecoin (resolved
|
|
161
166
|
// offline) and to print the ledger amount. A token the registry and the
|
|
@@ -170,7 +175,7 @@ async function describeSend(body, chainId) {
|
|
|
170
175
|
catch {
|
|
171
176
|
decimals = 0;
|
|
172
177
|
}
|
|
173
|
-
return { kind: layout.kind, to: counterparty, asset: token, raw: amount.toString(), decimals };
|
|
178
|
+
return { kind: layout.kind, to: counterparty, asset: token, raw: amount.toString(), decimals, ...(carried ? { carried } : {}) };
|
|
174
179
|
}
|
|
175
180
|
}
|
|
176
181
|
if (data && data !== '0x')
|
|
@@ -196,28 +201,40 @@ function assertSendTarget(d, chainId) {
|
|
|
196
201
|
if (!t.ok)
|
|
197
202
|
throw new Error(t.reason + ' Nothing was signed.');
|
|
198
203
|
}
|
|
204
|
+
if (d.carried) {
|
|
205
|
+
const t = targetAllowed(POL(), d.carried.to); // the contract that keeps the value
|
|
206
|
+
if (!t.ok)
|
|
207
|
+
throw new Error(t.reason + ' Nothing was signed.');
|
|
208
|
+
}
|
|
199
209
|
}
|
|
200
210
|
/**
|
|
201
|
-
* The
|
|
202
|
-
* leaves the wallet: a zero-value contract call
|
|
203
|
-
* approval already granted, and approvals are counted
|
|
204
|
-
* An approval's recipient is its spender, so
|
|
205
|
-
* who may be allowed to pull; the native
|
|
206
|
-
*
|
|
211
|
+
* The spends a decoded send makes against the policy, in the order they are
|
|
212
|
+
* reserved; empty when nothing leaves the wallet: a zero-value contract call
|
|
213
|
+
* can only pull what an approval already granted, and approvals are counted
|
|
214
|
+
* when they are granted. An approval's recipient is its spender, so
|
|
215
|
+
* recipients.allow and .deny bind who may be allowed to pull; the native
|
|
216
|
+
* value of a contract call is a spend to that contract; the native value
|
|
217
|
+
* carried on a token or approval call is a second spend, native, to the
|
|
218
|
+
* called contract (1.14.4). Throws for an unbounded approval under a policy.
|
|
207
219
|
*/
|
|
208
|
-
function
|
|
209
|
-
if (d.kind === 'contract' && BigInt(d.raw) === 0n)
|
|
210
|
-
return null;
|
|
220
|
+
function spendInputsOf(d, chainId, tool, wallet, ctx = {}) {
|
|
211
221
|
if (d.kind === 'approval' && d.unbounded && POLICY.path) {
|
|
212
222
|
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
223
|
`Approve an exact amount instead. Nothing was signed.`);
|
|
214
224
|
}
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
225
|
+
const base = { tool, mode: anyLocalMode() ? 'local' : 'hosted', wallet: anyLocalMode() ? 'local' : wallet, chainId, host: ctx.host ?? null, resource: ctx.resource ?? null };
|
|
226
|
+
const out = [];
|
|
227
|
+
if (!(d.kind === 'contract' && BigInt(d.raw) === 0n)) {
|
|
228
|
+
out.push({
|
|
229
|
+
...base, asset: d.asset, assetLabel: assetLabel(chainId, d.asset), raw: d.raw, decimals: d.decimals,
|
|
230
|
+
usd: d.unbounded ? null : usdOf(chainId, d.asset, d.raw, d.decimals),
|
|
231
|
+
to: d.to, method: ctx.method ?? methodOf(d.kind),
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
if (d.carried) {
|
|
235
|
+
out.push({ ...base, asset: '', assetLabel: assetLabel(chainId, ''), raw: d.carried.raw, decimals: nativeDecimals(chainId), usd: null, to: d.carried.to, method: 'native' });
|
|
236
|
+
}
|
|
237
|
+
return out;
|
|
221
238
|
}
|
|
222
239
|
// ─── Configuration ──────────────────────────────────────────────
|
|
223
240
|
const API_BASE = (process.env.AGENTWALLET_API_URL || 'https://hifriendbot.com/wp-json/agentwallet/v1').trim().replace(/\/+$/, '');
|
|
@@ -443,7 +460,7 @@ async function api(path, method = 'GET', body, extraHeaders) {
|
|
|
443
460
|
extraHeaders = { ...extraHeaders };
|
|
444
461
|
delete extraHeaders['X-AGW-CTX'];
|
|
445
462
|
}
|
|
446
|
-
|
|
463
|
+
const reserved = [];
|
|
447
464
|
// /sign with a transaction body is the same spend as /send: the caller gets
|
|
448
465
|
// a broadcast-ready raw transaction (2026-10-14 report, 1.14.2). A message
|
|
449
466
|
// signature (body.message) moves nothing and is not gated.
|
|
@@ -453,29 +470,44 @@ async function api(path, method = 'GET', body, extraHeaders) {
|
|
|
453
470
|
const chainId = localChainId(body, new URLSearchParams(path.split('?')[1] || ''));
|
|
454
471
|
const d = await describeSend(body, chainId);
|
|
455
472
|
assertSendTarget(d, chainId);
|
|
456
|
-
const
|
|
457
|
-
|
|
458
|
-
|
|
473
|
+
const tool = ctx.tool || (m[2] === 'sign' ? 'sign_transaction' : 'send');
|
|
474
|
+
// One line per spend the call makes (a token call that also carries native
|
|
475
|
+
// value makes two); a refusal of a later one releases the earlier ones.
|
|
476
|
+
for (const input of spendInputsOf(d, chainId, tool, m[1], ctx)) {
|
|
477
|
+
try {
|
|
478
|
+
reserved.push(reserveSpend(input));
|
|
479
|
+
}
|
|
480
|
+
catch (e) {
|
|
481
|
+
for (const seq of reserved)
|
|
482
|
+
LEDGER.void(seq, `released, the same call was refused: ${e.message}`);
|
|
483
|
+
throw e;
|
|
484
|
+
}
|
|
485
|
+
}
|
|
459
486
|
}
|
|
460
487
|
try {
|
|
461
488
|
const out = await apiInner(path, method, body, extraHeaders);
|
|
462
|
-
|
|
489
|
+
for (const seq of reserved) {
|
|
463
490
|
const r = out;
|
|
464
491
|
if (m[2] === 'sign')
|
|
465
|
-
LEDGER.settle(
|
|
492
|
+
LEDGER.settle(seq, 'signed', 'raw transaction returned to the caller; not broadcast by this process, so it stays counted');
|
|
466
493
|
else
|
|
467
|
-
LEDGER.settle(
|
|
494
|
+
LEDGER.settle(seq, 'broadcast', typeof r?.tx_hash === 'string' ? r.tx_hash : typeof r?.signature === 'string' ? r.signature : null);
|
|
468
495
|
}
|
|
469
496
|
return out;
|
|
470
497
|
}
|
|
471
498
|
catch (e) {
|
|
472
|
-
// A send that may already be on the wire stays counted; only a spend that
|
|
473
|
-
|
|
499
|
+
// A send that may already be on the wire stays counted; only a spend that
|
|
500
|
+
// never left this process is voided. The signers mark the former on the
|
|
501
|
+
// error object (markMaybeLive); the decision used to be a regex over the
|
|
502
|
+
// message and the word "broadcasts" in the local-mode refusal of
|
|
503
|
+
// transaction signing made every refused sign_transaction consume the
|
|
504
|
+
// budget for good (reported privately 2026-10-16, 1.14.3).
|
|
505
|
+
for (const seq of reserved) {
|
|
474
506
|
const msg = e.message;
|
|
475
|
-
if (
|
|
476
|
-
LEDGER.settle(
|
|
507
|
+
if (isMaybeLive(e))
|
|
508
|
+
LEDGER.settle(seq, 'unknown', msg);
|
|
477
509
|
else
|
|
478
|
-
LEDGER.void(
|
|
510
|
+
LEDGER.void(seq, msg);
|
|
479
511
|
}
|
|
480
512
|
throw e;
|
|
481
513
|
}
|
|
@@ -490,7 +522,7 @@ async function apiInner(path, method = 'GET', body, extraHeaders) {
|
|
|
490
522
|
handled = await routeLocally(path, method, body);
|
|
491
523
|
}
|
|
492
524
|
catch (e) {
|
|
493
|
-
throw new Error(redactRpcSecrets(e)); // viem puts the RPC URL, key and all, in its messages
|
|
525
|
+
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
526
|
}
|
|
495
527
|
if (handled !== NOT_HANDLED)
|
|
496
528
|
return handled;
|
|
@@ -512,7 +544,23 @@ async function apiInner(path, method = 'GET', body, extraHeaders) {
|
|
|
512
544
|
if (body && method !== 'GET') {
|
|
513
545
|
options.body = JSON.stringify(body);
|
|
514
546
|
}
|
|
515
|
-
|
|
547
|
+
// Hosted mode: a connection that was never made (refused, unresolvable,
|
|
548
|
+
// TLS rejected) carried nothing to the host, so a reserved spend is voided.
|
|
549
|
+
// A timeout waiting for the answer or a reset mid-request may have reached
|
|
550
|
+
// the custodian, which may have signed and broadcast; a spend there stays
|
|
551
|
+
// counted (1.14.3).
|
|
552
|
+
let res;
|
|
553
|
+
try {
|
|
554
|
+
res = await fetch(url, options);
|
|
555
|
+
}
|
|
556
|
+
catch (e) {
|
|
557
|
+
if (method === 'POST' && /^\/wallets\/[^/]+\/(send|sign)(\?|$)/.test(path) && !connectionNeverMade(e)) {
|
|
558
|
+
const cause = e.cause;
|
|
559
|
+
const why = e?.name === 'TimeoutError' ? `no answer within ${API_TIMEOUT_MS / 1000} s` : (cause?.code || cause?.message || e?.message || 'connection failed');
|
|
560
|
+
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.`));
|
|
561
|
+
}
|
|
562
|
+
throw e;
|
|
563
|
+
}
|
|
516
564
|
if (res.status >= 300 && res.status < 400) {
|
|
517
565
|
throw new Error(`The AgentWallet API host answered ${path} with a redirect (HTTP ${res.status}); refusing to follow it.`);
|
|
518
566
|
}
|
|
@@ -522,7 +570,9 @@ async function apiInner(path, method = 'GET', body, extraHeaders) {
|
|
|
522
570
|
data = JSON.parse(text);
|
|
523
571
|
}
|
|
524
572
|
catch {
|
|
525
|
-
|
|
573
|
+
// A 2xx the host did not phrase as JSON may still have been a completed send; counted, not voided (1.14.3).
|
|
574
|
+
const err = new Error(`The AgentWallet API host returned a non-JSON response (HTTP ${res.status}).`);
|
|
575
|
+
throw res.ok && method === 'POST' ? markMaybeLive(err) : err;
|
|
526
576
|
}
|
|
527
577
|
data = boundHostData(data, 0); // strings, arrays and depth bounded; the host writes none of the agent's instructions
|
|
528
578
|
// A 402 from the API host is paid automatically only for a HOSTED wallet the
|
|
@@ -552,7 +602,8 @@ async function apiInner(path, method = 'GET', body, extraHeaders) {
|
|
|
552
602
|
if (method === 'POST' && /^\/wallets\/[^/]+\/send(\?|$)/.test(path) && data && typeof data === 'object') {
|
|
553
603
|
const d = data;
|
|
554
604
|
if (!looksLikeTxHash(d.tx_hash) && !(typeof d.signature === 'string' && /^[1-9A-HJ-NP-Za-km-z]{43,120}$/.test(d.signature))) {
|
|
555
|
-
|
|
605
|
+
// 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).
|
|
606
|
+
throw markMaybeLive(new Error('The AgentWallet API host answered the send without a transaction hash or signature; treating it as not sent.'));
|
|
556
607
|
}
|
|
557
608
|
}
|
|
558
609
|
// The marker comes FIRST so a truncated result still carries it, and an
|
|
@@ -1118,7 +1169,7 @@ function resolveChainId(network) {
|
|
|
1118
1169
|
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
1170
|
const server = new McpServer({
|
|
1120
1171
|
name: 'agentwallet',
|
|
1121
|
-
version: '1.14.
|
|
1172
|
+
version: '1.14.4',
|
|
1122
1173
|
}, {
|
|
1123
1174
|
instructions: `AgentWallet gives AI agents their own blockchain wallets. ${anyLocalMode()
|
|
1124
1175
|
? '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.'
|
|
@@ -2790,8 +2841,12 @@ tool('simulate_transaction', 'Dry-run an EVM transaction with nothing signed: th
|
|
|
2790
2841
|
const policy = (() => {
|
|
2791
2842
|
try {
|
|
2792
2843
|
assertSendTarget(d, chain_id);
|
|
2793
|
-
const input
|
|
2794
|
-
|
|
2844
|
+
for (const input of spendInputsOf(d, chain_id, 'simulate_transaction', String(wallet_id))) {
|
|
2845
|
+
const v = spendVerdict(input);
|
|
2846
|
+
if (!v.ok)
|
|
2847
|
+
return v;
|
|
2848
|
+
}
|
|
2849
|
+
return { ok: true };
|
|
2795
2850
|
}
|
|
2796
2851
|
catch (e) {
|
|
2797
2852
|
return { ok: false, reason: e.message };
|
|
@@ -2801,6 +2856,7 @@ tool('simulate_transaction', 'Dry-run an EVM transaction with nothing signed: th
|
|
|
2801
2856
|
kind: d.kind, to: d.to, asset: d.asset || 'native', asset_label: assetLabel(chain_id, d.asset),
|
|
2802
2857
|
amount: d.unbounded ? 'unlimited' : formatUnits(d.raw, d.decimals),
|
|
2803
2858
|
...(d.kind === 'approval' ? { note: 'an approval: `to` is the spender being allowed to pull `amount` of `asset`' } : {}),
|
|
2859
|
+
...(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' } } : {}),
|
|
2804
2860
|
};
|
|
2805
2861
|
if (isLocalMode()) {
|
|
2806
2862
|
const sim = await localSimulate(chain_id, to, BigInt(valueRaw), data && data !== '0x' ? data : undefined);
|
package/build/local-solana.js
CHANGED
|
@@ -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
|
-
|
|
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 (
|
|
259
|
+
if (isMaybeLive(e))
|
|
258
260
|
throw e;
|
|
259
261
|
throw after(`confirmation failed: ${e instanceof Error ? e.message : JSON.stringify(e)}`);
|
|
260
262
|
}
|
package/build/local-wallet.js
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
8
|
+
"version": "1.14.4",
|
|
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",
|