agentwallet-mcp 1.14.10 → 1.14.12
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 +5 -3
- package/build/index.js +29 -7
- package/build/ledger.d.ts +22 -1
- package/build/ledger.js +84 -15
- package/build/policy.d.ts +24 -1
- package/build/policy.js +42 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -369,6 +369,7 @@ A per-transaction cap bounds one signature. It cannot see that the five hundredt
|
|
|
369
369
|
"per_host": { "*.example.com": { "daily": "2", "per_payment": "0.25" } },
|
|
370
370
|
"recipients": { "deny": ["0x0000000000000000000000000000000000000000"] },
|
|
371
371
|
"chains": { "allow": [8453, 900] },
|
|
372
|
+
"contracts": { "allow": ["8453:0x2626664c2603336E57B271c5C0b26F421741e481"] },
|
|
372
373
|
"approval": { "above": "0.50", "max": "20" },
|
|
373
374
|
"tools": { "allow": ["pay_x402", "quote_x402", "discover_x402", "get_balance", "list_payments", "spend_policy", "wallet_mode"] }
|
|
374
375
|
}
|
|
@@ -376,16 +377,17 @@ A per-transaction cap bounds one signature. It cannot see that the five hundredt
|
|
|
376
377
|
|
|
377
378
|
- **`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.
|
|
378
379
|
- **`native_budget`**: the same per chain, in the chain's own coin, counting native transfers, native x402 payments 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) and, in local Solana mode, the rent an SPL send pays to create the recipient's token account (the recipient owns that account and can close it for the lamports; the ledger records it as its own line, method `rent`, to the recipient, and `assets.allow` does not need to list `native` for it). The dollar budget has no price for the chain's coin, so on a chain with no `native_budget` entry a native spend is an unpriced spend: refused while a `budget` or a `per_host` ceiling is set, unless `"unpriced_spends": "allow"`. Give every chain the wallet spends on an entry.
|
|
379
|
-
- **Approvals are spends.** An ERC-20 allowance (`approve_token`, `approve_permit2`, `increaseAllowance`, `increaseApproval`, either `approveAndCall`, Permit2 `approve`, a signed `permit` in its EIP-2612, DAI or Permit2 shape, 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, since no budget can count it; grant an exact amount. An operator grant (`setApprovalForAll`, ERC-777 `authorizeOperator`, EIP-6909 `setOperator`) is unbounded by construction and refused the same way. Every token mover the standards define is a spend to its recipient: ERC-20 `transfer` and `transferFrom`, the ERC-677 and ERC-1363 `transferAndCall` and `transferFromAndCall` shapes, the ERC-223 `transfer` with data, ERC-777 `send` and `operatorSend`, and the ERC-721, ERC-1155 and EIP-6909 transfers, which no budget can price and so are refused under a dollar budget unless `unpriced_spends` is `"allow"`, with the recipient checked either way. A redemption of the wallet's own position is a spend too: ERC-4626 `redeem` and `withdraw` are spends of the vault to the receiver (unpriced, like an NFT), and Aave `Pool.withdraw` is a transfer of the underlying to the receiver. A
|
|
380
|
+
- **Approvals are spends.** An ERC-20 allowance (`approve_token`, `approve_permit2`, `increaseAllowance`, `increaseApproval`, either `approveAndCall`, Permit2 `approve`, a signed `permit` in its EIP-2612, DAI or Permit2 shape, 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, since no budget can count it; grant an exact amount. An operator grant (`setApprovalForAll`, ERC-777 `authorizeOperator`, EIP-6909 `setOperator`) is unbounded by construction and refused the same way. Every token mover the standards define is a spend to its recipient: ERC-20 `transfer` and `transferFrom`, the ERC-677 and ERC-1363 `transferAndCall` and `transferFromAndCall` shapes, the ERC-223 `transfer` with data, ERC-777 `send` and `operatorSend`, and the ERC-721, ERC-1155 and EIP-6909 transfers, which no budget can price and so are refused under a dollar budget unless `unpriced_spends` is `"allow"`, with the recipient checked either way. A redemption of the wallet's own position is a spend too: ERC-4626 `redeem` and `withdraw` are spends of the vault to the receiver (unpriced, like an NFT), and Aave `Pool.withdraw` is a transfer of the underlying to the receiver. A call to any other contract (a router, a bridge, a protocol whose payout shape is not listed here) is refused under a policy unless the contract is in `contracts.allow` or `unknown_calls` is `"allow"`, since 1.14.11: the policy cannot tell what such a call moves, so the operator decides which contracts may receive one. A listed contract's unpriced call reserves nothing beyond the native value it carries: it can only pull what an approval already granted, or pay out a position in a shape the policy does not know, so a policy that lists protocol contracts should set `recipients.allow` too. Permit2 is never admitted this way: a call to it that the policy cannot price (`permitBatch`, `transferFrom`, `permitTransferFrom`, `lockdown`, nonce invalidation, or an unknown selector) is refused under a policy whatever the lists say, since each moves tokens or changes what may be moved.
|
|
380
381
|
- **`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.
|
|
381
|
-
- **`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
|
|
382
|
+
- **`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 call is aimed at (a token being approved, a router) is checked against the deny list, the chain list and the `contracts` lists.
|
|
382
383
|
- **`chains`**, **`assets`**: allow lists; an asset entry can be scoped `"8453:0x..."` or be the word `native`.
|
|
384
|
+
- **`contracts`**, **`unknown_calls`**: what the wallet may call with calldata the policy cannot price. The policy prices the token movers, approvals, permits and redemptions listed under "Approvals are spends" below; any other calldata (a swap, a bridge, a protocol call) is refused under a policy unless the contract is in `contracts.allow` (entries scoped `"8453:0x..."` or bare), because a shape the policy does not know could be moving anything. `"unknown_calls": "allow"` restores the pre-1.14.11 behaviour of passing such calls as zero-value contract calls, with their native value still counted. `contracts.deny` refuses every call to a contract, priced or not. Without a policy nothing is refused on this ground.
|
|
383
385
|
- **`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
386
|
- **`tools`**: the tools the model sees. A payer-only deployment exposes seven; the rest are not registered. `wallet_mode` and `spend_policy` are always present so the operator can see what the server is.
|
|
385
387
|
|
|
386
388
|
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.
|
|
387
389
|
|
|
388
|
-
**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 or answered with a 5xx, any host answer that carries a transaction hash) is kept counted as `unknown` rather than voided; a failure before anything left this process, including a policy or local-mode refusal, or a 4xx refusal from the host, is always voided and never consumes budget. A hosted send is forwarded with the chain the policy priced it on (`chain_id`, else `AGENTWALLET_CHAIN_ID`, else 8453), and a custodian answer naming another chain is written onto the ledger line as a mismatch. 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.
|
|
390
|
+
**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 or answered with a 5xx, any host answer that carries a transaction hash) is kept counted as `unknown` rather than voided; a failure before anything left this process, including a policy or local-mode refusal, or a 4xx refusal from the host, is always voided and never consumes budget. A hosted send is forwarded with the chain the policy priced it on (`chain_id`, else `AGENTWALLET_CHAIN_ID`, else 8453), and a custodian answer naming another chain is written onto the ledger line as a mismatch. 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 and reclaimed in one step through `<ledger>.lock.reclaim`, and a release removes only the lock it took. 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.
|
|
389
391
|
|
|
390
392
|
### Encrypted keyfiles
|
|
391
393
|
|
package/build/index.js
CHANGED
|
@@ -28,13 +28,13 @@ import { deriveX402Payment, isWithinCap, lookupTrustedDecimals, assertDeclaredDe
|
|
|
28
28
|
import { createHash } from 'node:crypto';
|
|
29
29
|
import { isLocalMode, getLocalAddress, localWalletRecord, localSend, localNativeBalance, localTokenBalance, localSignMessage, resolveRpcUrl, } from './local-wallet.js';
|
|
30
30
|
import { localSignAuthorization, localEthCall, localGetCode, localSignPermit2Upto, probeTokenDecimals, assertRpcServesChain } from './local-wallet.js';
|
|
31
|
-
import { WRAPPED_NATIVE } from './wrapped-native.js';
|
|
31
|
+
import { WRAPPED_NATIVE, wrappedNativeAddress } from './wrapped-native.js';
|
|
32
32
|
import { buildUptoAuthorization, uptoPayload, permit2AllowanceCalldata, permit2ApproveCalldata, decodeUint, permit2Address, } from './x402-permit2.js';
|
|
33
33
|
import { assessTokenRisk } from './token-risk.js';
|
|
34
34
|
import { isLegacyAgentWalletAccept, requiredAmount, buildAuthorization, buildPaymentPayload, buildPaymentPayloadRaw, paymentHeaderFor, parsePaymentRequired, parseSettlement, pickOption, knownTokenDomain, decodeAbiString as decodeDomainString, parseEip7702Delegation, erc1271ProbeCalldata, classifyErc1271Probe, settlementVerdict, legacyReceiptAllowed, sanitizeRequired, } from './x402-eip3009.js';
|
|
35
35
|
import { isSolanaLocalMode, getSolanaAddress, solanaWalletRecord, localSolBalance, localSplBalance, localSolTransfer, localSplTransfer, resolveSolanaRpc, mintDecimalsStrict, splAccountRent, } from './local-solana.js';
|
|
36
36
|
import { localSimulate } from './local-wallet.js';
|
|
37
|
-
import { loadPolicy, describePolicy, hostAllowed, recipientAllowed, targetAllowed, chainAllowed, assetAllowed, perHostRule, toolAllowed, hostOf, budgetReport, units as policyUnits, human as policyHuman, rawToUnits, } from './policy.js';
|
|
37
|
+
import { loadPolicy, describePolicy, hostAllowed, recipientAllowed, targetAllowed, unknownCallAllowed, chainAllowed, assetAllowed, perHostRule, toolAllowed, hostOf, budgetReport, units as policyUnits, human as policyHuman, rawToUnits, } from './policy.js';
|
|
38
38
|
import { Ledger } from './ledger.js';
|
|
39
39
|
import { markMaybeLive, isMaybeLive, carryMaybeLive, connectionNeverMade } from './maybe-live.js';
|
|
40
40
|
import { discover } from './discovery.js';
|
|
@@ -121,6 +121,8 @@ function spendVerdict(input) {
|
|
|
121
121
|
function usdOf(chainId, asset, raw, decimals) {
|
|
122
122
|
return autopayAssetAllowed(chainId, asset).via === 'stablecoin' ? policyHuman(rawToUnits(raw, decimals)) : null;
|
|
123
123
|
}
|
|
124
|
+
/** WETH9 deposit() and withdraw(uint256): known shapes when aimed at the chain's own wrapped-native contract (wrap_eth, unwrap_eth). */
|
|
125
|
+
const WRAPPED_NATIVE_SELECTORS = new Set(['d0e30db0', '2e1a7d4d']);
|
|
124
126
|
/* Calldata the policy prices: which word holds the amount, which the recipient
|
|
125
127
|
or spender, and for Permit2.approve which the token (there `to` is Permit2).
|
|
126
128
|
The same functions the local token cap prices, so the two guards agree. */
|
|
@@ -318,8 +320,15 @@ async function describeSend(body, chainId) {
|
|
|
318
320
|
`refused rather than treated as a plain contract call. Use Permit2 approve or a single permit, each of which is reserved as an approval. Nothing was signed.`);
|
|
319
321
|
}
|
|
320
322
|
}
|
|
321
|
-
if (data && data !== '0x')
|
|
322
|
-
|
|
323
|
+
if (data && data !== '0x') {
|
|
324
|
+
// A shape the table does not know. Under a policy it is refused unless
|
|
325
|
+
// the operator listed the contract (assertSendTarget, 1.14.11); the
|
|
326
|
+
// chain's wrapped-native deposit and withdraw are known shapes, since
|
|
327
|
+
// wrap_eth and unwrap_eth build them.
|
|
328
|
+
const selector = /^0x[0-9a-fA-F]{8}/.test(data) ? data.slice(0, 10).toLowerCase() : data.slice(0, 10);
|
|
329
|
+
const isWrapped = to.toLowerCase() === wrappedNativeAddress(chainId) && WRAPPED_NATIVE_SELECTORS.has(selector.slice(2));
|
|
330
|
+
return { kind: 'contract', to, asset: '', raw: value, decimals: nativeDecimals(chainId), ...(isWrapped ? {} : { unknown: selector }) };
|
|
331
|
+
}
|
|
323
332
|
return { kind: 'native', to, asset: '', raw: value, decimals: nativeDecimals(chainId) };
|
|
324
333
|
}
|
|
325
334
|
/** The ledger method a decoded send is recorded under, unless the caller named one. */
|
|
@@ -337,15 +346,28 @@ function assertSendTarget(d, chainId) {
|
|
|
337
346
|
if (!c.ok)
|
|
338
347
|
throw new Error(c.reason + ' Nothing was signed.');
|
|
339
348
|
if (d.kind === 'contract' || d.kind === 'approval') {
|
|
340
|
-
const t = targetAllowed(POL(), d.kind === 'approval' ? d.asset : d.to);
|
|
349
|
+
const t = targetAllowed(POL(), d.kind === 'approval' ? d.asset : d.to, chainId);
|
|
350
|
+
if (!t.ok)
|
|
351
|
+
throw new Error(t.reason + ' Nothing was signed.');
|
|
352
|
+
}
|
|
353
|
+
if (d.kind === 'token' && d.asset) {
|
|
354
|
+
const t = targetAllowed(POL(), d.asset, chainId); // the token or vault being moved; contracts.deny binds it
|
|
341
355
|
if (!t.ok)
|
|
342
356
|
throw new Error(t.reason + ' Nothing was signed.');
|
|
343
357
|
}
|
|
344
358
|
if (d.carried) {
|
|
345
|
-
const t = targetAllowed(POL(), d.carried.to); // the contract that keeps the value
|
|
359
|
+
const t = targetAllowed(POL(), d.carried.to, chainId); // the contract that keeps the value
|
|
346
360
|
if (!t.ok)
|
|
347
361
|
throw new Error(t.reason + ' Nothing was signed.');
|
|
348
362
|
}
|
|
363
|
+
// Calldata the table cannot price is the operator's call, not the table's
|
|
364
|
+
// (1.14.11). Without a policy nothing changes: the local caps and the
|
|
365
|
+
// hosted signer judge the call on their own terms, as before.
|
|
366
|
+
if (d.unknown && POLICY.path) {
|
|
367
|
+
const u = unknownCallAllowed(POL(), chainId, d.to, d.unknown);
|
|
368
|
+
if (!u.ok)
|
|
369
|
+
throw new Error(u.reason + ' Nothing was signed.');
|
|
370
|
+
}
|
|
349
371
|
}
|
|
350
372
|
/**
|
|
351
373
|
* The spends a decoded send makes against the policy, in the order they are
|
|
@@ -1377,7 +1399,7 @@ function resolveChainId(network) {
|
|
|
1377
1399
|
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');
|
|
1378
1400
|
const server = new McpServer({
|
|
1379
1401
|
name: 'agentwallet',
|
|
1380
|
-
version: '1.14.
|
|
1402
|
+
version: '1.14.12',
|
|
1381
1403
|
}, {
|
|
1382
1404
|
instructions: `AgentWallet gives AI agents their own blockchain wallets. ${anyLocalMode()
|
|
1383
1405
|
? '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.'
|
package/build/ledger.d.ts
CHANGED
|
@@ -102,8 +102,29 @@ export declare class Ledger {
|
|
|
102
102
|
* line; otherwise it was rewritten underneath us and nothing is adopted.
|
|
103
103
|
*/
|
|
104
104
|
private sync;
|
|
105
|
-
/**
|
|
105
|
+
/**
|
|
106
|
+
* Take the exclusive lock file; returns the release. A lock a dead process
|
|
107
|
+
* left behind is cleared after LOCK_STALE_MS (reclaimStale). The release
|
|
108
|
+
* removes the lock only while it still carries this call's token: a lock
|
|
109
|
+
* this process held past the stale window and another process reclaimed
|
|
110
|
+
* belongs to whoever took it next, and must not be removed from under them.
|
|
111
|
+
*/
|
|
106
112
|
private lock;
|
|
113
|
+
/**
|
|
114
|
+
* Clear a lock left by a process that died holding it. The judgement that
|
|
115
|
+
* the lock is stale and its removal are one step under a second file, the
|
|
116
|
+
* claim, taken with 'wx' like the lock itself: a waiter that cannot take the
|
|
117
|
+
* claim leaves the lock alone and retries, and the one that holds it looks
|
|
118
|
+
* at the lock again (mtime and first line) before removing it, so a fresh
|
|
119
|
+
* lock another waiter took in the meantime is never the one removed. Before
|
|
120
|
+
* 1.14.12 the stat and the unlink were two calls with nothing between them,
|
|
121
|
+
* and two waiters judging one stale lock could, between them, remove the
|
|
122
|
+
* fresh lock one of them had just taken, after which both held the ledger
|
|
123
|
+
* (reported privately, 2026-11-02). A claim older than the stale window was
|
|
124
|
+
* itself left by a waiter that died inside this step and is removed the old
|
|
125
|
+
* way; the step is a stat and an unlink, so that is the window that remains.
|
|
126
|
+
*/
|
|
127
|
+
private reclaimStale;
|
|
107
128
|
private append;
|
|
108
129
|
private notify;
|
|
109
130
|
/** Spends not voided, newest last. */
|
package/build/ledger.js
CHANGED
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
* file that no longer holds what this process wrote is refused the same way.
|
|
18
18
|
*/
|
|
19
19
|
import { appendFileSync, existsSync, readFileSync, openSync, closeSync, writeSync, unlinkSync, statSync } from 'node:fs';
|
|
20
|
-
import { createHash, createHmac } from 'node:crypto';
|
|
20
|
+
import { createHash, createHmac, randomBytes } from 'node:crypto';
|
|
21
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;
|
|
@@ -25,6 +25,17 @@ const MAX_LINE = 16 * 1024;
|
|
|
25
25
|
const LOCK_WAIT_MS = 10_000;
|
|
26
26
|
/** A lock older than this was left by a process that died holding it. */
|
|
27
27
|
const LOCK_STALE_MS = 30_000;
|
|
28
|
+
/** What a lock file holds: the holder's pid and a random token, so a release can tell its own lock from one taken after it. */
|
|
29
|
+
function lockToken() { return `${process.pid} ${randomBytes(8).toString('hex')}`; }
|
|
30
|
+
/** The lock file as one thing to compare: its mtime and its first line. null when it is gone. */
|
|
31
|
+
function lockIdentity(lockPath) {
|
|
32
|
+
try {
|
|
33
|
+
return { mtimeMs: statSync(lockPath).mtimeMs, line: readFileSync(lockPath, 'utf8').split(/\r?\n/)[0] };
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
28
39
|
const sleeper = new Int32Array(new SharedArrayBuffer(4));
|
|
29
40
|
function sleepSync(ms) { Atomics.wait(sleeper, 0, 0, ms); }
|
|
30
41
|
function hashOf(prev, body) {
|
|
@@ -105,43 +116,101 @@ export class Ledger {
|
|
|
105
116
|
this.last = c.last;
|
|
106
117
|
this.integrity = c.integrity;
|
|
107
118
|
}
|
|
108
|
-
/**
|
|
119
|
+
/**
|
|
120
|
+
* Take the exclusive lock file; returns the release. A lock a dead process
|
|
121
|
+
* left behind is cleared after LOCK_STALE_MS (reclaimStale). The release
|
|
122
|
+
* removes the lock only while it still carries this call's token: a lock
|
|
123
|
+
* this process held past the stale window and another process reclaimed
|
|
124
|
+
* belongs to whoever took it next, and must not be removed from under them.
|
|
125
|
+
*/
|
|
109
126
|
lock() {
|
|
110
127
|
if (!this.path)
|
|
111
128
|
return () => { };
|
|
112
129
|
const lockPath = this.path + '.lock';
|
|
130
|
+
const token = lockToken();
|
|
113
131
|
const deadline = Date.now() + this.lockWaitMs;
|
|
114
132
|
for (;;) {
|
|
115
133
|
try {
|
|
116
134
|
const fd = openSync(lockPath, 'wx', 0o600);
|
|
117
135
|
try {
|
|
118
|
-
writeSync(fd, `${
|
|
136
|
+
writeSync(fd, `${token} ${new Date().toISOString()}\n`);
|
|
119
137
|
}
|
|
120
138
|
finally {
|
|
121
139
|
closeSync(fd);
|
|
122
140
|
}
|
|
123
|
-
return () => {
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
141
|
+
return () => {
|
|
142
|
+
if (!lockIdentity(lockPath)?.line.startsWith(token + ' '))
|
|
143
|
+
return; // reclaimed as stale and retaken, or already gone
|
|
144
|
+
try {
|
|
145
|
+
unlinkSync(lockPath);
|
|
146
|
+
}
|
|
147
|
+
catch { /* already gone */ }
|
|
148
|
+
};
|
|
127
149
|
}
|
|
128
150
|
catch (e) {
|
|
129
151
|
if (e.code !== 'EEXIST')
|
|
130
152
|
throw e;
|
|
131
153
|
}
|
|
154
|
+
this.reclaimStale(lockPath);
|
|
155
|
+
if (Date.now() >= deadline) {
|
|
156
|
+
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.`);
|
|
157
|
+
}
|
|
158
|
+
sleepSync(5);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* Clear a lock left by a process that died holding it. The judgement that
|
|
163
|
+
* the lock is stale and its removal are one step under a second file, the
|
|
164
|
+
* claim, taken with 'wx' like the lock itself: a waiter that cannot take the
|
|
165
|
+
* claim leaves the lock alone and retries, and the one that holds it looks
|
|
166
|
+
* at the lock again (mtime and first line) before removing it, so a fresh
|
|
167
|
+
* lock another waiter took in the meantime is never the one removed. Before
|
|
168
|
+
* 1.14.12 the stat and the unlink were two calls with nothing between them,
|
|
169
|
+
* and two waiters judging one stale lock could, between them, remove the
|
|
170
|
+
* fresh lock one of them had just taken, after which both held the ledger
|
|
171
|
+
* (reported privately, 2026-11-02). A claim older than the stale window was
|
|
172
|
+
* itself left by a waiter that died inside this step and is removed the old
|
|
173
|
+
* way; the step is a stat and an unlink, so that is the window that remains.
|
|
174
|
+
*/
|
|
175
|
+
reclaimStale(lockPath) {
|
|
176
|
+
const seen = lockIdentity(lockPath);
|
|
177
|
+
if (!seen || Date.now() - seen.mtimeMs <= LOCK_STALE_MS)
|
|
178
|
+
return; // gone, or live
|
|
179
|
+
const claimPath = lockPath + '.reclaim';
|
|
180
|
+
let fd;
|
|
181
|
+
try {
|
|
182
|
+
fd = openSync(claimPath, 'wx', 0o600);
|
|
183
|
+
}
|
|
184
|
+
catch (e) {
|
|
185
|
+
if (e.code !== 'EEXIST')
|
|
186
|
+
throw e;
|
|
187
|
+
try {
|
|
188
|
+
if (Date.now() - statSync(claimPath).mtimeMs > LOCK_STALE_MS)
|
|
189
|
+
unlinkSync(claimPath);
|
|
190
|
+
}
|
|
191
|
+
catch { /* finished or cleared meanwhile */ }
|
|
192
|
+
return; // another waiter is clearing it; retry the lock
|
|
193
|
+
}
|
|
194
|
+
try {
|
|
132
195
|
try {
|
|
133
|
-
|
|
196
|
+
writeSync(fd, `${lockToken()} ${new Date().toISOString()}\n`);
|
|
197
|
+
}
|
|
198
|
+
finally {
|
|
199
|
+
closeSync(fd);
|
|
200
|
+
}
|
|
201
|
+
const now = lockIdentity(lockPath);
|
|
202
|
+
if (now && now.mtimeMs === seen.mtimeMs && now.line === seen.line && Date.now() - now.mtimeMs > LOCK_STALE_MS) {
|
|
203
|
+
try {
|
|
134
204
|
unlinkSync(lockPath);
|
|
135
|
-
continue;
|
|
136
205
|
}
|
|
206
|
+
catch { /* gone meanwhile */ }
|
|
137
207
|
}
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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.`);
|
|
208
|
+
}
|
|
209
|
+
finally {
|
|
210
|
+
try {
|
|
211
|
+
unlinkSync(claimPath);
|
|
143
212
|
}
|
|
144
|
-
|
|
213
|
+
catch { /* already gone */ }
|
|
145
214
|
}
|
|
146
215
|
}
|
|
147
216
|
append(body) {
|
package/build/policy.d.ts
CHANGED
|
@@ -35,6 +35,14 @@ export declare const PolicySchema: z.ZodObject<{
|
|
|
35
35
|
assets: z.ZodOptional<z.ZodObject<{
|
|
36
36
|
allow: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
37
37
|
}, z.core.$strict>>;
|
|
38
|
+
contracts: z.ZodOptional<z.ZodObject<{
|
|
39
|
+
allow: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
40
|
+
deny: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
41
|
+
}, z.core.$strict>>;
|
|
42
|
+
unknown_calls: z.ZodOptional<z.ZodEnum<{
|
|
43
|
+
allow: "allow";
|
|
44
|
+
refuse: "refuse";
|
|
45
|
+
}>>;
|
|
38
46
|
approval: z.ZodOptional<z.ZodObject<{
|
|
39
47
|
above: z.ZodOptional<z.ZodString>;
|
|
40
48
|
max: z.ZodOptional<z.ZodString>;
|
|
@@ -96,7 +104,22 @@ export declare function recipientAllowed(p: Policy, to: string): {
|
|
|
96
104
|
reason: string;
|
|
97
105
|
};
|
|
98
106
|
/** A contract target (approve, wrap, arbitrary calldata): the deny list only; an allow list is about where funds go. */
|
|
99
|
-
export declare function targetAllowed(p: Policy, to: string): {
|
|
107
|
+
export declare function targetAllowed(p: Policy, to: string, chainId?: number): {
|
|
108
|
+
ok: true;
|
|
109
|
+
} | {
|
|
110
|
+
ok: false;
|
|
111
|
+
reason: string;
|
|
112
|
+
};
|
|
113
|
+
/**
|
|
114
|
+
* May the wallet send calldata the policy cannot price to this contract?
|
|
115
|
+
* Since 1.14.11 the answer under a policy is no unless the operator listed
|
|
116
|
+
* the contract or set unknown_calls to "allow": the classifier is a table of
|
|
117
|
+
* known shapes, and every shape it did not know was, until then, a
|
|
118
|
+
* zero-value call that reserved nothing (nineteen disclosure rows between
|
|
119
|
+
* 2026-10-14 and 2026-11-02 were that one gap). The open set is now the
|
|
120
|
+
* operator's decision, not the table's.
|
|
121
|
+
*/
|
|
122
|
+
export declare function unknownCallAllowed(p: Policy, chainId: number, to: string, selector: string): {
|
|
100
123
|
ok: true;
|
|
101
124
|
} | {
|
|
102
125
|
ok: false;
|
package/build/policy.js
CHANGED
|
@@ -35,6 +35,10 @@ export const PolicySchema = z.object({
|
|
|
35
35
|
recipients: z.object({ allow: z.array(Address).max(1000).optional(), deny: z.array(Address).max(1000).optional() }).strict().optional(),
|
|
36
36
|
chains: z.object({ allow: z.array(z.number().int().positive()).max(100).optional() }).strict().optional(),
|
|
37
37
|
assets: z.object({ allow: z.array(AssetRef).max(500).optional() }).strict().optional(),
|
|
38
|
+
/** Contracts the wallet may call with calldata the policy cannot price (a router, a bridge, a protocol). Entries may be scoped "chainId:0x...". deny refuses any call to the contract, priced or not. */
|
|
39
|
+
contracts: z.object({ allow: z.array(AssetRef).max(1000).optional(), deny: z.array(AssetRef).max(1000).optional() }).strict().optional(),
|
|
40
|
+
/** What to do with calldata the policy cannot price, aimed at a contract not in contracts.allow. Default refuse (1.14.11); "allow" restores the pre-1.14.11 behaviour of passing it as a zero-value call. */
|
|
41
|
+
unknown_calls: z.enum(['refuse', 'allow']).optional(),
|
|
38
42
|
/** Ask the human (MCP elicitation) above `above`; in local mode an accepted ask may admit up to `max`, above the per-payment cap. */
|
|
39
43
|
approval: z.object({ above: Dec.optional(), max: Dec.optional(), timeout_seconds: z.number().int().min(30).max(3600).optional() }).strict().optional(),
|
|
40
44
|
tools: z.object({ allow: z.array(z.string().regex(/^[a-z_0-9]+$/)).max(100).optional(), deny: z.array(z.string().regex(/^[a-z_0-9]+$/)).max(100).optional() }).strict().optional(),
|
|
@@ -102,6 +106,12 @@ export function parsePolicy(text) {
|
|
|
102
106
|
throw new Error('policy field hosts.allow: an empty allow list would refuse every host; remove it or list hosts.');
|
|
103
107
|
if (p.recipients?.allow && p.recipients.allow.length === 0)
|
|
104
108
|
throw new Error('policy field recipients.allow: an empty allow list would refuse every recipient; remove it or list addresses.');
|
|
109
|
+
if (p.contracts?.allow && p.contracts.allow.length === 0)
|
|
110
|
+
throw new Error('policy field contracts.allow: an empty allow list is the default (unpriced calls refused); remove it or list contracts.');
|
|
111
|
+
for (const k of ['allow', 'deny'])
|
|
112
|
+
for (const e of p.contracts?.[k] || [])
|
|
113
|
+
if (e.endsWith('native'))
|
|
114
|
+
throw new Error(`policy field contracts.${k}: "${e}" is not a contract.`);
|
|
105
115
|
return p;
|
|
106
116
|
}
|
|
107
117
|
/** Load the policy named by AGENTWALLET_POLICY (none set: EMPTY). The ledger path comes from AGENTWALLET_LEDGER, then the policy, then a default next to the policy when a budget needs one. */
|
|
@@ -181,11 +191,40 @@ export function recipientAllowed(p, to) {
|
|
|
181
191
|
return { ok: true };
|
|
182
192
|
}
|
|
183
193
|
/** A contract target (approve, wrap, arbitrary calldata): the deny list only; an allow list is about where funds go. */
|
|
184
|
-
export function targetAllowed(p, to) {
|
|
194
|
+
export function targetAllowed(p, to, chainId) {
|
|
185
195
|
if (p.recipients?.deny?.some(x => sameAddress(x, to)))
|
|
186
196
|
return { ok: false, reason: `policy denies contract target ${to} (recipients.deny).` };
|
|
197
|
+
if (chainId !== undefined && p.contracts?.deny?.some(e => contractRefMatches(e, chainId, to)))
|
|
198
|
+
return { ok: false, reason: `policy denies contract ${to} on chain ${chainId} (contracts.deny).` };
|
|
187
199
|
return { ok: true };
|
|
188
200
|
}
|
|
201
|
+
/** "8453:0xabc" matches that contract on that chain; a bare address matches it on every chain. */
|
|
202
|
+
function contractRefMatches(entry, chainId, to) {
|
|
203
|
+
const i = entry.indexOf(':');
|
|
204
|
+
if (i < 0)
|
|
205
|
+
return sameAddress(entry, to);
|
|
206
|
+
return Number(entry.slice(0, i)) === chainId && sameAddress(entry.slice(i + 1), to);
|
|
207
|
+
}
|
|
208
|
+
/**
|
|
209
|
+
* May the wallet send calldata the policy cannot price to this contract?
|
|
210
|
+
* Since 1.14.11 the answer under a policy is no unless the operator listed
|
|
211
|
+
* the contract or set unknown_calls to "allow": the classifier is a table of
|
|
212
|
+
* known shapes, and every shape it did not know was, until then, a
|
|
213
|
+
* zero-value call that reserved nothing (nineteen disclosure rows between
|
|
214
|
+
* 2026-10-14 and 2026-11-02 were that one gap). The open set is now the
|
|
215
|
+
* operator's decision, not the table's.
|
|
216
|
+
*/
|
|
217
|
+
export function unknownCallAllowed(p, chainId, to, selector) {
|
|
218
|
+
if (p.contracts?.allow?.some(e => contractRefMatches(e, chainId, to)))
|
|
219
|
+
return { ok: true };
|
|
220
|
+
if ((p.unknown_calls ?? 'refuse') === 'allow')
|
|
221
|
+
return { ok: true };
|
|
222
|
+
return {
|
|
223
|
+
ok: false,
|
|
224
|
+
reason: `policy: calldata selector ${selector} to ${to} on chain ${chainId} is not a shape the policy can price (a transfer, an approval, a permit, a redemption), and the policy does not list the contract in contracts.allow; ` +
|
|
225
|
+
`refused rather than passed as a zero-value call. The operator can add "${chainId}:${to}" to contracts.allow, or set unknown_calls to "allow" to let every unpriced call through as before 1.14.11.`,
|
|
226
|
+
};
|
|
227
|
+
}
|
|
189
228
|
export function chainAllowed(p, chainId) {
|
|
190
229
|
if (p.chains?.allow && !p.chains.allow.includes(chainId))
|
|
191
230
|
return { ok: false, reason: `policy does not allow chain ${chainId}: it is not in chains.allow.` };
|
|
@@ -245,6 +284,8 @@ export function describePolicy(lp) {
|
|
|
245
284
|
recipients: p.recipients ? { allow: p.recipients.allow?.length ?? null, deny: p.recipients.deny?.length ?? null } : null,
|
|
246
285
|
chains: p.chains ?? null,
|
|
247
286
|
assets: p.assets ?? null,
|
|
287
|
+
contracts: p.contracts ? { allow: p.contracts.allow?.length ?? null, deny: p.contracts.deny?.length ?? null } : null,
|
|
288
|
+
unknown_calls: lp.path ? (p.unknown_calls ?? 'refuse') : 'allow (no policy set)',
|
|
248
289
|
approval: p.approval ?? null,
|
|
249
290
|
tools: p.tools ?? null,
|
|
250
291
|
ledger: lp.ledgerPath,
|
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.12",
|
|
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",
|