agentwallet-mcp 1.14.1 → 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 +1 -1
- package/build/index.js +45 -12
- package/build/ledger.d.ts +24 -7
- package/build/ledger.js +162 -54
- 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
|
@@ -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.
|
|
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') {
|
|
@@ -444,12 +445,16 @@ async function api(path, method = 'GET', body, extraHeaders) {
|
|
|
444
445
|
delete extraHeaders['X-AGW-CTX'];
|
|
445
446
|
}
|
|
446
447
|
let reserved = null;
|
|
447
|
-
|
|
448
|
-
|
|
448
|
+
// /sign with a transaction body is the same spend as /send: the caller gets
|
|
449
|
+
// a broadcast-ready raw transaction (2026-10-14 report, 1.14.2). A message
|
|
450
|
+
// signature (body.message) moves nothing and is not gated.
|
|
451
|
+
const m = /^\/wallets\/([^/]+)\/(send|sign)(\?|$)/.exec(path);
|
|
452
|
+
const signsTx = m !== null && (m[2] === 'send' || typeof body?.message !== 'string');
|
|
453
|
+
if (method === 'POST' && m && body && signsTx) {
|
|
449
454
|
const chainId = localChainId(body, new URLSearchParams(path.split('?')[1] || ''));
|
|
450
455
|
const d = await describeSend(body, chainId);
|
|
451
456
|
assertSendTarget(d, chainId);
|
|
452
|
-
const input = spendInputOf(d, chainId, ctx.tool || 'send', m[1], ctx);
|
|
457
|
+
const input = spendInputOf(d, chainId, ctx.tool || (m[2] === 'sign' ? 'sign_transaction' : 'send'), m[1], ctx);
|
|
453
458
|
if (input)
|
|
454
459
|
reserved = reserveSpend(input);
|
|
455
460
|
}
|
|
@@ -457,15 +462,23 @@ async function api(path, method = 'GET', body, extraHeaders) {
|
|
|
457
462
|
const out = await apiInner(path, method, body, extraHeaders);
|
|
458
463
|
if (reserved !== null) {
|
|
459
464
|
const r = out;
|
|
460
|
-
|
|
465
|
+
if (m[2] === 'sign')
|
|
466
|
+
LEDGER.settle(reserved, 'signed', 'raw transaction returned to the caller; not broadcast by this process, so it stays counted');
|
|
467
|
+
else
|
|
468
|
+
LEDGER.settle(reserved, 'broadcast', typeof r?.tx_hash === 'string' ? r.tx_hash : typeof r?.signature === 'string' ? r.signature : null);
|
|
461
469
|
}
|
|
462
470
|
return out;
|
|
463
471
|
}
|
|
464
472
|
catch (e) {
|
|
465
|
-
// A send that may already be on the wire stays counted; only a spend that
|
|
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).
|
|
466
479
|
if (reserved !== null) {
|
|
467
480
|
const msg = e.message;
|
|
468
|
-
if (
|
|
481
|
+
if (isMaybeLive(e))
|
|
469
482
|
LEDGER.settle(reserved, 'unknown', msg);
|
|
470
483
|
else
|
|
471
484
|
LEDGER.void(reserved, msg);
|
|
@@ -483,7 +496,7 @@ async function apiInner(path, method = 'GET', body, extraHeaders) {
|
|
|
483
496
|
handled = await routeLocally(path, method, body);
|
|
484
497
|
}
|
|
485
498
|
catch (e) {
|
|
486
|
-
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
|
|
487
500
|
}
|
|
488
501
|
if (handled !== NOT_HANDLED)
|
|
489
502
|
return handled;
|
|
@@ -505,7 +518,23 @@ async function apiInner(path, method = 'GET', body, extraHeaders) {
|
|
|
505
518
|
if (body && method !== 'GET') {
|
|
506
519
|
options.body = JSON.stringify(body);
|
|
507
520
|
}
|
|
508
|
-
|
|
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
|
+
}
|
|
509
538
|
if (res.status >= 300 && res.status < 400) {
|
|
510
539
|
throw new Error(`The AgentWallet API host answered ${path} with a redirect (HTTP ${res.status}); refusing to follow it.`);
|
|
511
540
|
}
|
|
@@ -515,7 +544,9 @@ async function apiInner(path, method = 'GET', body, extraHeaders) {
|
|
|
515
544
|
data = JSON.parse(text);
|
|
516
545
|
}
|
|
517
546
|
catch {
|
|
518
|
-
|
|
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;
|
|
519
550
|
}
|
|
520
551
|
data = boundHostData(data, 0); // strings, arrays and depth bounded; the host writes none of the agent's instructions
|
|
521
552
|
// A 402 from the API host is paid automatically only for a HOSTED wallet the
|
|
@@ -545,7 +576,8 @@ async function apiInner(path, method = 'GET', body, extraHeaders) {
|
|
|
545
576
|
if (method === 'POST' && /^\/wallets\/[^/]+\/send(\?|$)/.test(path) && data && typeof data === 'object') {
|
|
546
577
|
const d = data;
|
|
547
578
|
if (!looksLikeTxHash(d.tx_hash) && !(typeof d.signature === 'string' && /^[1-9A-HJ-NP-Za-km-z]{43,120}$/.test(d.signature))) {
|
|
548
|
-
|
|
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.'));
|
|
549
581
|
}
|
|
550
582
|
}
|
|
551
583
|
// The marker comes FIRST so a truncated result still carries it, and an
|
|
@@ -1111,7 +1143,7 @@ function resolveChainId(network) {
|
|
|
1111
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');
|
|
1112
1144
|
const server = new McpServer({
|
|
1113
1145
|
name: 'agentwallet',
|
|
1114
|
-
version: '1.14.
|
|
1146
|
+
version: '1.14.3',
|
|
1115
1147
|
}, {
|
|
1116
1148
|
instructions: `AgentWallet gives AI agents their own blockchain wallets. ${anyLocalMode()
|
|
1117
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.'
|
|
@@ -1225,7 +1257,8 @@ tool('get_balance', 'Get the native token balance for a wallet on a specific cha
|
|
|
1225
1257
|
// ─── Tool: sign_transaction ──────────────────────────────────────
|
|
1226
1258
|
tool('sign_transaction', 'Sign a transaction with a wallet\'s private key. ' +
|
|
1227
1259
|
'For EVM: returns signed raw transaction hex. For Solana: returns base64 signed transaction. ' +
|
|
1228
|
-
'Does NOT broadcast
|
|
1260
|
+
'Does NOT broadcast. Use send_transaction for sign + broadcast. ' +
|
|
1261
|
+
'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.', {
|
|
1229
1262
|
wallet_id: z.number().int().describe('Wallet ID'),
|
|
1230
1263
|
to: AddressSchema.describe('Destination address (0x-prefixed for EVM, Base58 for Solana)'),
|
|
1231
1264
|
chain_id: z.number().int().optional().describe('Chain ID (defaults to wallet\'s default)'),
|
package/build/ledger.d.ts
CHANGED
|
@@ -76,42 +76,59 @@ export interface Limits {
|
|
|
76
76
|
} | null;
|
|
77
77
|
unpriced: 'refuse' | 'allow';
|
|
78
78
|
}
|
|
79
|
+
export interface LedgerOptions {
|
|
80
|
+
/** Tests shorten the wait for a held lock. */
|
|
81
|
+
lockWaitMs?: number;
|
|
82
|
+
}
|
|
79
83
|
export declare class Ledger {
|
|
80
84
|
readonly path: string | null;
|
|
81
85
|
private entries;
|
|
82
86
|
private last;
|
|
83
87
|
private seq;
|
|
84
|
-
/** null = intact; otherwise what broke. */
|
|
88
|
+
/** null = intact; otherwise what broke. Refreshed from the file on every read. */
|
|
85
89
|
integrity: string | null;
|
|
86
90
|
private webhook;
|
|
91
|
+
private lockWaitMs;
|
|
87
92
|
constructor(path: string | null, webhook?: {
|
|
88
93
|
url: string;
|
|
89
94
|
secret: string;
|
|
90
|
-
} | null);
|
|
91
|
-
|
|
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;
|
|
92
104
|
private append;
|
|
93
105
|
private notify;
|
|
94
106
|
/** Spends not voided, newest last. */
|
|
95
107
|
private liveSpends;
|
|
96
|
-
/** Dollar sums over rolling windows; nowMs for tests. */
|
|
108
|
+
/** Dollar sums over rolling windows from a fresh read of the file; nowMs for tests. */
|
|
97
109
|
spentUsd(nowMs?: number, host?: string): Spent;
|
|
98
110
|
spentNative(chainId: number, nowMs?: number): Spent;
|
|
111
|
+
private sumUsd;
|
|
112
|
+
private sumNative;
|
|
99
113
|
private sum;
|
|
100
|
-
/** 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. */
|
|
101
115
|
check(input: ReserveInput, limits: Limits, nowMs?: number): {
|
|
102
116
|
ok: true;
|
|
103
117
|
} | {
|
|
104
118
|
ok: false;
|
|
105
119
|
reason: string;
|
|
106
120
|
};
|
|
107
|
-
|
|
121
|
+
private evaluate;
|
|
122
|
+
/** Re-read, check and record in one step under the lock. Returns the spend's seq. */
|
|
108
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;
|
|
109
126
|
/** The spend never happened (signing or broadcast failed before anything left this process). */
|
|
110
127
|
void(ref: number, detail: string): void;
|
|
111
128
|
/** What the endpoint or chain said afterwards. Never throws: the payment already happened. */
|
|
112
129
|
settle(ref: number, outcome: string, detail: string | null): void;
|
|
113
130
|
size(): number;
|
|
114
|
-
/** 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. */
|
|
115
132
|
payments(opts: {
|
|
116
133
|
limit: number;
|
|
117
134
|
host?: string;
|
package/build/ledger.js
CHANGED
|
@@ -3,69 +3,146 @@
|
|
|
3
3
|
* process reserved, and what became of it.
|
|
4
4
|
*
|
|
5
5
|
* Budgets are enforced from it, so a reservation is written BEFORE the
|
|
6
|
-
* signature exists
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
6
|
+
* signature exists. Check and append happen in one step under an exclusive
|
|
7
|
+
* lock file, after the file has been re-read and the chain re-verified, so
|
|
8
|
+
* two concurrent calls cannot both fit the last dollar, whether they come
|
|
9
|
+
* from one process or from several agentwallet-mcp processes sharing the
|
|
10
|
+
* file (two MCP clients configured with the same wallet and ledger). A
|
|
11
|
+
* reservation whose signing fails is voided by a later line; the spend line
|
|
12
|
+
* itself is never edited, so the chain stays verifiable.
|
|
10
13
|
*
|
|
11
14
|
* Each line carries the sha256 of the previous line's hash plus its own body.
|
|
12
|
-
*
|
|
13
|
-
* refuses every spend until the operator looks at the file
|
|
15
|
+
* The chain is verified on every read; a broken chain with a budget
|
|
16
|
+
* configured refuses every spend until the operator looks at the file, and a
|
|
17
|
+
* file that no longer holds what this process wrote is refused the same way.
|
|
14
18
|
*/
|
|
15
|
-
import { appendFileSync, existsSync, readFileSync, openSync, closeSync } from 'node:fs';
|
|
19
|
+
import { appendFileSync, existsSync, readFileSync, openSync, closeSync, writeSync, unlinkSync, statSync } from 'node:fs';
|
|
16
20
|
import { createHash, createHmac } from 'node:crypto';
|
|
17
21
|
import { DAY_MS, MONTH_MS, units, rawToUnits, human } from './policy.js';
|
|
18
22
|
const GENESIS = '0'.repeat(64);
|
|
19
23
|
const MAX_LINE = 16 * 1024;
|
|
24
|
+
/** How long a spend waits for another process to finish its own check-and-append. */
|
|
25
|
+
const LOCK_WAIT_MS = 10_000;
|
|
26
|
+
/** A lock older than this was left by a process that died holding it. */
|
|
27
|
+
const LOCK_STALE_MS = 30_000;
|
|
28
|
+
const sleeper = new Int32Array(new SharedArrayBuffer(4));
|
|
29
|
+
function sleepSync(ms) { Atomics.wait(sleeper, 0, 0, ms); }
|
|
20
30
|
function hashOf(prev, body) {
|
|
21
31
|
return createHash('sha256').update(prev + '\n' + JSON.stringify(body)).digest('hex');
|
|
22
32
|
}
|
|
33
|
+
/** Parse and verify a ledger file's text. A trailing line without its newline is still being written by another process and is not read. */
|
|
34
|
+
function parseChain(text) {
|
|
35
|
+
const complete = text.endsWith('\n') || !text.includes('\n') && text.trim() === '' ? text : text.slice(0, text.lastIndexOf('\n') + 1);
|
|
36
|
+
const lines = complete.split('\n').filter(l => l.trim());
|
|
37
|
+
const out = { entries: [], last: GENESIS, seq: 0, integrity: null };
|
|
38
|
+
let prev = GENESIS;
|
|
39
|
+
for (let i = 0; i < lines.length; i++) {
|
|
40
|
+
let e;
|
|
41
|
+
try {
|
|
42
|
+
e = JSON.parse(lines[i]);
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
out.integrity = `line ${i + 1} is not JSON`;
|
|
46
|
+
break;
|
|
47
|
+
}
|
|
48
|
+
const { hash, ...body } = e;
|
|
49
|
+
if (e.prev !== prev || hashOf(prev, body) !== hash) {
|
|
50
|
+
out.integrity = `chain broken at line ${i + 1} (seq ${e.seq})`;
|
|
51
|
+
break;
|
|
52
|
+
}
|
|
53
|
+
if (e.seq !== out.seq + 1) {
|
|
54
|
+
out.integrity = `sequence gap at line ${i + 1}`;
|
|
55
|
+
break;
|
|
56
|
+
}
|
|
57
|
+
out.entries.push(e);
|
|
58
|
+
out.seq = e.seq;
|
|
59
|
+
prev = hash;
|
|
60
|
+
}
|
|
61
|
+
out.last = prev;
|
|
62
|
+
return out;
|
|
63
|
+
}
|
|
23
64
|
export class Ledger {
|
|
24
65
|
path;
|
|
25
66
|
entries = [];
|
|
26
67
|
last = GENESIS;
|
|
27
68
|
seq = 0;
|
|
28
|
-
/** null = intact; otherwise what broke. */
|
|
69
|
+
/** null = intact; otherwise what broke. Refreshed from the file on every read. */
|
|
29
70
|
integrity = null;
|
|
30
71
|
webhook = null;
|
|
31
|
-
|
|
72
|
+
lockWaitMs = LOCK_WAIT_MS;
|
|
73
|
+
constructor(path, webhook, opts) {
|
|
32
74
|
this.path = path;
|
|
33
75
|
this.webhook = webhook ?? null;
|
|
34
|
-
if (
|
|
35
|
-
this.
|
|
76
|
+
if (opts?.lockWaitMs !== undefined)
|
|
77
|
+
this.lockWaitMs = opts.lockWaitMs;
|
|
78
|
+
if (path) {
|
|
79
|
+
// Create it now so a permissions problem shows at startup, not at the first spend.
|
|
80
|
+
if (!existsSync(path))
|
|
81
|
+
closeSync(openSync(path, 'a', 0o600));
|
|
82
|
+
this.sync();
|
|
83
|
+
}
|
|
36
84
|
}
|
|
37
|
-
|
|
85
|
+
/**
|
|
86
|
+
* Re-read the file so the sums include what every other process wrote.
|
|
87
|
+
* The file must still begin with what this process has seen, line for
|
|
88
|
+
* line; otherwise it was rewritten underneath us and nothing is adopted.
|
|
89
|
+
*/
|
|
90
|
+
sync() {
|
|
91
|
+
if (!this.path)
|
|
92
|
+
return;
|
|
38
93
|
if (!existsSync(this.path)) {
|
|
39
|
-
|
|
40
|
-
closeSync(openSync(this.path, 'a', 0o600));
|
|
94
|
+
this.integrity = `the file was removed while this process was running (it held seq ${this.seq})`;
|
|
41
95
|
return;
|
|
42
96
|
}
|
|
43
|
-
const
|
|
44
|
-
const
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
97
|
+
const c = parseChain(readFileSync(this.path, 'utf8'));
|
|
98
|
+
const n = this.entries.length;
|
|
99
|
+
if (c.entries.length < n || (n > 0 && c.entries[n - 1].hash !== this.last)) {
|
|
100
|
+
this.integrity = c.integrity && c.entries.length < n ? c.integrity : `the file was rewritten underneath this process (it held seq ${this.seq} ending ${this.last.slice(0, 12)}, the file now ends at seq ${c.seq})`;
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
this.entries = c.entries;
|
|
104
|
+
this.seq = c.seq;
|
|
105
|
+
this.last = c.last;
|
|
106
|
+
this.integrity = c.integrity;
|
|
107
|
+
}
|
|
108
|
+
/** Take the exclusive lock file; returns the release. A lock a dead process left behind is cleared after LOCK_STALE_MS. */
|
|
109
|
+
lock() {
|
|
110
|
+
if (!this.path)
|
|
111
|
+
return () => { };
|
|
112
|
+
const lockPath = this.path + '.lock';
|
|
113
|
+
const deadline = Date.now() + this.lockWaitMs;
|
|
114
|
+
for (;;) {
|
|
48
115
|
try {
|
|
49
|
-
|
|
116
|
+
const fd = openSync(lockPath, 'wx', 0o600);
|
|
117
|
+
try {
|
|
118
|
+
writeSync(fd, `${process.pid} ${new Date().toISOString()}\n`);
|
|
119
|
+
}
|
|
120
|
+
finally {
|
|
121
|
+
closeSync(fd);
|
|
122
|
+
}
|
|
123
|
+
return () => { try {
|
|
124
|
+
unlinkSync(lockPath);
|
|
125
|
+
}
|
|
126
|
+
catch { /* already gone */ } };
|
|
50
127
|
}
|
|
51
|
-
catch {
|
|
52
|
-
|
|
53
|
-
|
|
128
|
+
catch (e) {
|
|
129
|
+
if (e.code !== 'EEXIST')
|
|
130
|
+
throw e;
|
|
54
131
|
}
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
132
|
+
try {
|
|
133
|
+
if (Date.now() - statSync(lockPath).mtimeMs > LOCK_STALE_MS) {
|
|
134
|
+
unlinkSync(lockPath);
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
catch {
|
|
139
|
+
continue; /* released between the two calls */
|
|
59
140
|
}
|
|
60
|
-
if (
|
|
61
|
-
|
|
62
|
-
break;
|
|
141
|
+
if (Date.now() >= deadline) {
|
|
142
|
+
throw new Error(`The ledger at ${this.path} is locked by another agentwallet-mcp process (${lockPath}) that did not finish within ${this.lockWaitMs} ms. Nothing was signed. If no other process is running, remove the lock file.`);
|
|
63
143
|
}
|
|
64
|
-
|
|
65
|
-
this.seq = e.seq;
|
|
66
|
-
prev = hash;
|
|
144
|
+
sleepSync(5);
|
|
67
145
|
}
|
|
68
|
-
this.last = prev;
|
|
69
146
|
}
|
|
70
147
|
append(body) {
|
|
71
148
|
if (this.integrity)
|
|
@@ -103,11 +180,19 @@ export class Ledger {
|
|
|
103
180
|
const voided = new Set(this.entries.filter(e => e.kind === 'void').map(e => e.ref));
|
|
104
181
|
return this.entries.filter((e) => e.kind === 'spend' && !voided.has(e.seq));
|
|
105
182
|
}
|
|
106
|
-
/** Dollar sums over rolling windows; nowMs for tests. */
|
|
183
|
+
/** Dollar sums over rolling windows from a fresh read of the file; nowMs for tests. */
|
|
107
184
|
spentUsd(nowMs = Date.now(), host) {
|
|
108
|
-
|
|
185
|
+
this.sync();
|
|
186
|
+
return this.sumUsd(nowMs, host);
|
|
109
187
|
}
|
|
110
188
|
spentNative(chainId, nowMs = Date.now()) {
|
|
189
|
+
this.sync();
|
|
190
|
+
return this.sumNative(chainId, nowMs);
|
|
191
|
+
}
|
|
192
|
+
sumUsd(nowMs, host) {
|
|
193
|
+
return this.sum(nowMs, e => (host === undefined || e.host === host) && e.usd !== null ? units(e.usd) : 0n);
|
|
194
|
+
}
|
|
195
|
+
sumNative(chainId, nowMs) {
|
|
111
196
|
return this.sum(nowMs, e => e.chain_id === chainId && e.asset === '' ? rawToUnits(e.raw, nativeDecimalsOf(chainId)) : 0n);
|
|
112
197
|
}
|
|
113
198
|
sum(nowMs, f) {
|
|
@@ -125,13 +210,17 @@ export class Ledger {
|
|
|
125
210
|
}
|
|
126
211
|
return out;
|
|
127
212
|
}
|
|
128
|
-
/** Would this spend fit every limit? The reason names the first it does not. */
|
|
213
|
+
/** Would this spend fit every limit, given what every process has written so far? The reason names the first limit it does not. */
|
|
129
214
|
check(input, limits, nowMs = Date.now()) {
|
|
215
|
+
this.sync();
|
|
216
|
+
return this.evaluate(input, limits, nowMs);
|
|
217
|
+
}
|
|
218
|
+
evaluate(input, limits, nowMs) {
|
|
130
219
|
if (this.integrity)
|
|
131
220
|
return { ok: false, reason: `The ledger at ${this.path} is not intact (${this.integrity}); every spend is refused until the operator has looked at it.` };
|
|
132
221
|
const amount18 = rawToUnits(input.raw, input.decimals);
|
|
133
222
|
if (input.asset === '') {
|
|
134
|
-
const r = refusal(limits.native, this.
|
|
223
|
+
const r = refusal(limits.native, this.sumNative(input.chainId, nowMs), amount18, `native_budget.${input.chainId}`);
|
|
135
224
|
if (r)
|
|
136
225
|
return { ok: false, reason: r };
|
|
137
226
|
}
|
|
@@ -142,33 +231,51 @@ export class Ledger {
|
|
|
142
231
|
}
|
|
143
232
|
if (input.usd !== null) {
|
|
144
233
|
const usd18 = units(input.usd);
|
|
145
|
-
const r = refusal(limits.usd, this.
|
|
234
|
+
const r = refusal(limits.usd, this.sumUsd(nowMs), usd18, 'budget');
|
|
146
235
|
if (r)
|
|
147
236
|
return { ok: false, reason: r };
|
|
148
237
|
if (limits.host && input.host) {
|
|
149
|
-
const h = refusal({ daily: limits.host.daily, monthly: limits.host.monthly }, this.
|
|
238
|
+
const h = refusal({ daily: limits.host.daily, monthly: limits.host.monthly }, this.sumUsd(nowMs, input.host), usd18, `per_host["${limits.host.pattern}"]`);
|
|
150
239
|
if (h)
|
|
151
240
|
return { ok: false, reason: h };
|
|
152
241
|
}
|
|
153
242
|
}
|
|
154
243
|
return { ok: true };
|
|
155
244
|
}
|
|
156
|
-
/**
|
|
245
|
+
/** Re-read, check and record in one step under the lock. Returns the spend's seq. */
|
|
157
246
|
reserve(input, limits) {
|
|
158
|
-
const
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
amount
|
|
165
|
-
|
|
166
|
-
|
|
247
|
+
const unlock = this.lock();
|
|
248
|
+
try {
|
|
249
|
+
this.sync();
|
|
250
|
+
const c = this.evaluate(input, limits, Date.now());
|
|
251
|
+
if (!c.ok)
|
|
252
|
+
throw new Error(c.reason);
|
|
253
|
+
const amount = human(rawToUnits(input.raw, input.decimals));
|
|
254
|
+
const e = this.append({
|
|
255
|
+
kind: 'spend', tool: input.tool, mode: input.mode, wallet: input.wallet, chain_id: input.chainId, asset: input.asset, asset_label: input.assetLabel,
|
|
256
|
+
amount, raw: input.raw, usd: input.usd, to: input.to, host: input.host, resource: input.resource, method: input.method, proof: input.proof ?? null,
|
|
257
|
+
});
|
|
258
|
+
return e.seq;
|
|
259
|
+
}
|
|
260
|
+
finally {
|
|
261
|
+
unlock();
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
/** A follow-up line, written under the lock after a fresh read so it chains onto whatever any process wrote last. */
|
|
265
|
+
follow(body) {
|
|
266
|
+
const unlock = this.lock();
|
|
267
|
+
try {
|
|
268
|
+
this.sync();
|
|
269
|
+
this.append(body);
|
|
270
|
+
}
|
|
271
|
+
finally {
|
|
272
|
+
unlock();
|
|
273
|
+
}
|
|
167
274
|
}
|
|
168
275
|
/** The spend never happened (signing or broadcast failed before anything left this process). */
|
|
169
276
|
void(ref, detail) {
|
|
170
277
|
try {
|
|
171
|
-
this.
|
|
278
|
+
this.follow({ kind: 'void', ref, outcome: 'void', detail: detail.slice(0, 300) });
|
|
172
279
|
}
|
|
173
280
|
catch (e) {
|
|
174
281
|
console.error(`[agentwallet] ledger void failed: ${e.message}`);
|
|
@@ -177,15 +284,16 @@ export class Ledger {
|
|
|
177
284
|
/** What the endpoint or chain said afterwards. Never throws: the payment already happened. */
|
|
178
285
|
settle(ref, outcome, detail) {
|
|
179
286
|
try {
|
|
180
|
-
this.
|
|
287
|
+
this.follow({ kind: 'settle', ref, outcome: outcome.slice(0, 40), detail: detail ? detail.slice(0, 300) : null });
|
|
181
288
|
}
|
|
182
289
|
catch (e) {
|
|
183
290
|
console.error(`[agentwallet] ledger settle failed: ${e.message}`);
|
|
184
291
|
}
|
|
185
292
|
}
|
|
186
|
-
size() { return this.entries.length; }
|
|
187
|
-
/** Spends newest first with their latest follow-up folded in. */
|
|
293
|
+
size() { this.sync(); return this.entries.length; }
|
|
294
|
+
/** Spends newest first with their latest follow-up folded in, from a fresh read. */
|
|
188
295
|
payments(opts) {
|
|
296
|
+
this.sync();
|
|
189
297
|
const follow = new Map();
|
|
190
298
|
for (const e of this.entries)
|
|
191
299
|
if (e.kind !== 'spend')
|
package/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.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",
|