agentwallet-mcp 1.14.8 → 1.14.10
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 -5
- package/build/index.js +131 -9
- package/build/ledger.d.ts +2 -0
- package/build/ledger.js +20 -3
- package/build/local-wallet.js +20 -3
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -43,7 +43,7 @@ Use `AGENTWALLET_KEYFILE=/path/to/key` instead if you would rather keep the key
|
|
|
43
43
|
|
|
44
44
|
`AGENTWALLET_MAX_TX_NATIVE` is a per-transaction ceiling in native units (ETH, MATIC and so on). In hosted mode the server enforces limits; in local mode there is no server, so these guards are the only ones there are. Set them.
|
|
45
45
|
|
|
46
|
-
`AGENTWALLET_MAX_TX_TOKEN` is the equivalent ceiling for ERC-20 movement, in human units of the token. **Set this one too if you hold stablecoins.** The native cap cannot see a token transfer: an ERC-20 send carries `value = 0` with the amount in the calldata, so `AGENTWALLET_MAX_TX_NATIVE` alone leaves a USDC balance uncapped. `AGENTWALLET_MAX_TX_TOKEN` covers `transfer`, `transferFrom`, `approve`, `increaseAllowance`, Permit2 `approve`, and `deposit()` / `withdraw(uint256)` on the chain's own wrapped-native contract (so `wrap_eth` and `unwrap_eth` keep working under the cap), the approvals because an unbounded allowance is a drain waiting to happen. ERC-721 and ERC-1155 calldata (`setApprovalForAll`, `safeTransferFrom`) is refused while the cap is set, since nothing prices an NFT; without the cap it passes the way an ERC-20 transfer of any size does, except that an operator grant is an unlimited approval and is refused everywhere by default (see `AGENTWALLET_ALLOW_UNLIMITED_APPROVAL`). Any other calldata sent to a token contract or to Permit2 is refused while the cap is set, because the guard cannot price it; set `AGENTWALLET_ALLOW_UNKNOWN_TOKEN_CALLS=1` to allow such calls deliberately. `increaseAllowance` is judged on the resulting total: the current allowance is read first and the call refuses if it cannot be, so two compliant increments cannot stack a standing allowance above the cap. Calls to other contracts (routers, bridges) pass, since they can only pull what an approval already allowed; a contract counts as "other" only when it demonstrably declines `decimals()`, so an absurd or malformed answer, or an RPC that cannot be reached, refuses rather than allows. Decimals are resolved locally from the trusted registry, then the token's own `decimals()`; a token that resolves neither way is evaluated at 0 decimals, the only value that cannot fail open. For a token outside the registry the RPC is the only source of decimals, and an RPC you do not control could scale the amount and the cap together; pin such tokens with `AGENTWALLET_TOKEN_DECIMALS` (for example `8453:0x<token>=6`) and the pin is used everywhere the RPC's answer would have been.
|
|
46
|
+
`AGENTWALLET_MAX_TX_TOKEN` is the equivalent ceiling for ERC-20 movement, in human units of the token. **Set this one too if you hold stablecoins.** The native cap cannot see a token transfer: an ERC-20 send carries `value = 0` with the amount in the calldata, so `AGENTWALLET_MAX_TX_NATIVE` alone leaves a USDC balance uncapped. `AGENTWALLET_MAX_TX_TOKEN` covers `transfer`, `transferFrom`, `approve`, `increaseAllowance`, EIP-2612 `permit`, Permit2 `approve`, and `deposit()` / `withdraw(uint256)` on the chain's own wrapped-native contract (so `wrap_eth` and `unwrap_eth` keep working under the cap), the approvals because an unbounded allowance is a drain waiting to happen. ERC-721 and ERC-1155 calldata (`setApprovalForAll`, `safeTransferFrom`) is refused while the cap is set, since nothing prices an NFT; without the cap it passes the way an ERC-20 transfer of any size does, except that an operator grant is an unlimited approval and is refused everywhere by default (see `AGENTWALLET_ALLOW_UNLIMITED_APPROVAL`). Any other calldata sent to a token contract or to Permit2 is refused while the cap is set, because the guard cannot price it; set `AGENTWALLET_ALLOW_UNKNOWN_TOKEN_CALLS=1` to allow such calls deliberately. `increaseAllowance` is judged on the resulting total: the current allowance is read first and the call refuses if it cannot be, so two compliant increments cannot stack a standing allowance above the cap. Calls to other contracts (routers, bridges) pass, since they can only pull what an approval already allowed; a contract counts as "other" only when it demonstrably declines `decimals()`, so an absurd or malformed answer, or an RPC that cannot be reached, refuses rather than allows. Decimals are resolved locally from the trusted registry, then the token's own `decimals()`; a token that resolves neither way is evaluated at 0 decimals, the only value that cannot fail open. For a token outside the registry the RPC is the only source of decimals, and an RPC you do not control could scale the amount and the cap together; pin such tokens with `AGENTWALLET_TOKEN_DECIMALS` (for example `8453:0x<token>=6`) and the pin is used everywhere the RPC's answer would have been.
|
|
47
47
|
|
|
48
48
|
### Solana local signing
|
|
49
49
|
|
|
@@ -119,7 +119,7 @@ Add to your config:
|
|
|
119
119
|
}
|
|
120
120
|
```
|
|
121
121
|
|
|
122
|
-
> `AGENTWALLET_WALLET_ID` is optional. Set it to enable x402 auto-pay: when you exceed the free tier without a credit card, the MCP server automatically pays for operations with USDC from this wallet. Only the metered routes (creating a wallet, signing, sending) are paid this way, at most `AGENTWALLET_MAX_AUTOPAY_PER_HOUR` times an hour (default 20), and every automatic payment is reported in the tool result under `autopay_payment`. A 402 on any other route is reported, never paid.
|
|
122
|
+
> `AGENTWALLET_WALLET_ID` is optional. Set it to enable x402 auto-pay: when you exceed the free tier without a credit card, the MCP server automatically pays for operations with USDC from this wallet. Only the metered routes (creating a wallet, signing, sending) are paid this way, at most `AGENTWALLET_MAX_AUTOPAY_PER_HOUR` times an hour (default 20; the slot is taken before the payment is sent, so parallel calls cannot overrun it, and processes sharing a ledger file share the hour, while without one the count is per process), and every automatic payment is reported in the tool result under `autopay_payment`. A 402 on any other route is reported, never paid.
|
|
123
123
|
>
|
|
124
124
|
> **Auto-pay safety cap.** `AGENTWALLET_MAX_AUTOPAY` (optional, default `1`) is the most one x402 payment may authorize, in stablecoin units: `1` means one dollar. It prices registry stablecoins only (USDC, USDT, USDbC, DAI on the supported chains, USDC and USDT on Solana). A requirement in the chain's native asset or in any other token is refused, because "1" measured in ETH is a few thousand dollars; list such assets in `AGENTWALLET_AUTOPAY_ASSETS` (comma-separated addresses or mints, or the word `native`) to allow them, and the cap then applies in that asset's own units. Any requirement above the cap is rejected instead of paid, so a malformed or tampered payment requirement cannot drain the wallet. This automatic path settles `exact` requirements only; an `upto` offer from the API is refused before any wallet call, because paying a usage maximum upfront is not what the offer means (use `pay_x402`, which signs a Permit2 authorization for it). The cap is the operator's ceiling: a `max_payment` argument can lower it for one call but never raise it. Raise the variable itself if you genuinely need larger automatic payments (for example `"5"` for up to 5 USDC per call).
|
|
125
125
|
|
|
@@ -375,8 +375,8 @@ A per-transaction cap bounds one signature. It cannot see that the five hundredt
|
|
|
375
375
|
```
|
|
376
376
|
|
|
377
377
|
- **`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
|
-
- **`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) 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).
|
|
379
|
-
- **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, since no budget can count it; grant an exact amount. An
|
|
378
|
+
- **`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 zero-value call to any other contract (a router, a bridge, a protocol whose payout shape is not listed here) reserves nothing: 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 holds positions elsewhere should set `recipients.allow`. The exception is Permit2 itself: a call to it that the policy cannot price (`permitBatch`, `transferFrom`, `permitTransferFrom`, `lockdown`, nonce invalidation, or an unknown selector) is refused under a policy, since each moves tokens or changes what may be moved.
|
|
380
380
|
- **`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
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 zero-value call is aimed at (a token being approved, a router) is checked against the deny list and the chain list.
|
|
382
382
|
- **`chains`**, **`assets`**: allow lists; an asset entry can be scoped `"8453:0x..."` or be the word `native`.
|
|
@@ -385,7 +385,7 @@ A per-transaction cap bounds one signature. It cannot see that the five hundredt
|
|
|
385
385
|
|
|
386
386
|
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
387
|
|
|
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) 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
|
+
**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.
|
|
389
389
|
|
|
390
390
|
### Encrypted keyfiles
|
|
391
391
|
|
package/build/index.js
CHANGED
|
@@ -150,6 +150,68 @@ const SPEND_LAYOUTS = {
|
|
|
150
150
|
b88d4fde: { kind: 'token', what: 'ERC-721 safeTransferFrom', toIndex: 1, amountIndex: -1, nft: true }, // safeTransferFrom(address,address,uint256,bytes)
|
|
151
151
|
f242432a: { kind: 'token', what: 'ERC-1155 safeTransferFrom', toIndex: 1, amountIndex: 3, nft: true }, // safeTransferFrom(address,address,uint256,uint256,bytes)
|
|
152
152
|
'2eb2c2d6': { kind: 'token', what: 'ERC-1155 safeBatchTransferFrom', toIndex: 1, amountIndex: 3, nft: true, batch: true }, // safeBatchTransferFrom(address,address,uint256[],uint256[],bytes)
|
|
153
|
+
// Signed allowances (reported privately 2026-11-01, 1.14.9): permit() sets
|
|
154
|
+
// allowance[owner][spender] exactly as approve() does, and the wallet can
|
|
155
|
+
// produce the owner's signature in-process, so one send_transaction both
|
|
156
|
+
// signs and broadcasts a standing allowance. None of these was decoded, so
|
|
157
|
+
// a permit fell through as a zero-value contract call with no budget line,
|
|
158
|
+
// no ledger line, no spender check and no unlimited refusal: the 1.14.1
|
|
159
|
+
// bypass again, through a selector that fix did not cover. The owner word
|
|
160
|
+
// is not compared with the wallet: a permit relayed for another owner is
|
|
161
|
+
// counted as if it were the wallet's own, which over-counts, never under.
|
|
162
|
+
d505accf: { kind: 'approval', what: 'EIP-2612 permit', toIndex: 1, amountIndex: 2 }, // permit(address,address,uint256,uint256,uint8,bytes32,bytes32)
|
|
163
|
+
'8fcbaf0c': { kind: 'approval', what: 'DAI permit', toIndex: 1, amountIndex: 4, operator: true }, // permit(address,address,uint256,uint256,bool,uint8,bytes32,bytes32): allowed=true is unbounded by construction
|
|
164
|
+
'2b67b570': { kind: 'approval', what: 'Permit2 permit', toIndex: 5, amountIndex: 2, tokenIndex: 1, permit2Only: true }, // Permit2.permit(address,((address,uint160,uint48,uint48),address,uint256),bytes): PermitSingle is static, so inlined
|
|
165
|
+
// Sibling shapes of the movers and grants above (reported privately
|
|
166
|
+
// 2026-11-02, 1.14.10, four reports): the legacy StandardToken
|
|
167
|
+
// increaseApproval, the ERC-1363 two-argument transferAndCall and
|
|
168
|
+
// approveAndCall and both transferFromAndCall shapes, the ERC-223 transfer
|
|
169
|
+
// with data, and the EIP-6909 multi-token movers and grants. Each moves the
|
|
170
|
+
// token or grants a standing allowance exactly as the shape already decoded
|
|
171
|
+
// beside it, and each fell through as a zero-value contract call.
|
|
172
|
+
d73dd623: { kind: 'approval', what: 'increaseApproval', toIndex: 0, amountIndex: 1 }, // increaseApproval(address,uint256)
|
|
173
|
+
'3177029f': { kind: 'approval', what: 'approveAndCall', toIndex: 0, amountIndex: 1 }, // approveAndCall(address,uint256) (ERC-1363)
|
|
174
|
+
'1296ee62': { kind: 'token', what: 'transferAndCall', toIndex: 0, amountIndex: 1 }, // transferAndCall(address,uint256) (ERC-1363)
|
|
175
|
+
d8fbe994: { kind: 'token', what: 'transferFromAndCall', toIndex: 1, amountIndex: 2 }, // transferFromAndCall(address,address,uint256) (ERC-1363)
|
|
176
|
+
c1d34b89: { kind: 'token', what: 'transferFromAndCall', toIndex: 1, amountIndex: 2 }, // transferFromAndCall(address,address,uint256,bytes) (ERC-1363)
|
|
177
|
+
be45fd62: { kind: 'token', what: 'ERC-223 transfer', toIndex: 0, amountIndex: 1 }, // transfer(address,uint256,bytes)
|
|
178
|
+
// EIP-6909: setOperator is the setApprovalForAll of a multi-token contract
|
|
179
|
+
// (unbounded by construction); the per-id approve and the movers carry an
|
|
180
|
+
// id word before the amount, and the contract has no decimals or price, so
|
|
181
|
+
// they are treated as the ERC-1155 shapes are: unpriced, recipient-checked.
|
|
182
|
+
'558a7297': { kind: 'approval', what: 'EIP-6909 setOperator', toIndex: 0, amountIndex: 1, nft: true, operator: true }, // setOperator(address,bool)
|
|
183
|
+
'426a8493': { kind: 'approval', what: 'EIP-6909 approve', toIndex: 0, amountIndex: 2, nft: true }, // approve(address,uint256,uint256)
|
|
184
|
+
'095bcdb6': { kind: 'token', what: 'EIP-6909 transfer', toIndex: 0, amountIndex: 2, nft: true }, // transfer(address,uint256,uint256)
|
|
185
|
+
fe99049a: { kind: 'token', what: 'EIP-6909 transferFrom', toIndex: 1, amountIndex: 3, nft: true }, // transferFrom(address,address,uint256,uint256)
|
|
186
|
+
// Redemptions (reported privately 2026-11-02, 1.14.10): a vault or lending
|
|
187
|
+
// position is the wallet's asset, and its withdraw and redeem calls name a
|
|
188
|
+
// receiver, so a zero-value call paid the whole position to any address
|
|
189
|
+
// with nothing reserved. An ERC-4626 vault is itself the share token, so
|
|
190
|
+
// the spend is recorded under the vault (shares for redeem; for withdraw
|
|
191
|
+
// the amount is in the underlying, printed at the vault's decimals, which
|
|
192
|
+
// the standard keeps equal to the underlying's in every common vault), and
|
|
193
|
+
// priced only if the registry knows it, which a vault never is: refused
|
|
194
|
+
// under a dollar budget unless unpriced_spends allows, recipient-checked
|
|
195
|
+
// either way. Aave's Pool.withdraw names the underlying in the calldata, so
|
|
196
|
+
// that one is priced like a transfer of it. Any other protocol's shape is
|
|
197
|
+
// outside this table: a policy that holds positions elsewhere should list
|
|
198
|
+
// recipients.allow and, where it can, the contracts it may call.
|
|
199
|
+
ba087652: { kind: 'token', what: 'ERC-4626 redeem', toIndex: 1, amountIndex: 0 }, // redeem(uint256,address,address)
|
|
200
|
+
b460af94: { kind: 'token', what: 'ERC-4626 withdraw', toIndex: 1, amountIndex: 0 }, // withdraw(uint256,address,address)
|
|
201
|
+
'69328dec': { kind: 'token', what: 'Aave withdraw', toIndex: 2, amountIndex: 1, tokenIndex: 0 }, // Pool.withdraw(address,uint256,address)
|
|
202
|
+
};
|
|
203
|
+
/* The rest of Permit2's surface, by selector: permitBatch, transferFrom (two
|
|
204
|
+
shapes), lockdown, permitTransferFrom and permitWitnessTransferFrom (two
|
|
205
|
+
each), invalidateNonces and invalidateUnorderedNonces. Each moves tokens or
|
|
206
|
+
changes what may be moved, in shapes this table does not price (a batch is
|
|
207
|
+
several allowances in one call), so under a policy such a call to the
|
|
208
|
+
chain's Permit2 is refused rather than let through as a zero-value call to
|
|
209
|
+
"some contract" (1.14.9). The local token cap has refused the same calls
|
|
210
|
+
since the 2026-10-04 audit. */
|
|
211
|
+
const PERMIT2_OTHER = {
|
|
212
|
+
'2a2d80d1': 'permit (batch)', '36c78516': 'transferFrom', '0d58b1db': 'transferFrom (batch)', cc53287f: 'lockdown',
|
|
213
|
+
'30f28b7a': 'permitTransferFrom', edd9444b: 'permitTransferFrom (batch)', '137c29fe': 'permitWitnessTransferFrom', fe8ec1a7: 'permitWitnessTransferFrom (batch)',
|
|
214
|
+
'3ff9dcb1': 'invalidateNonces', '65d9723c': 'invalidateUnorderedNonces',
|
|
153
215
|
};
|
|
154
216
|
const UNBOUNDED_APPROVAL = 1n << 128n;
|
|
155
217
|
const UINT256_MAX = (1n << 256n) - 1n;
|
|
@@ -248,6 +310,13 @@ async function describeSend(body, chainId) {
|
|
|
248
310
|
}
|
|
249
311
|
return { kind: layout.kind, to: counterparty, asset: token, raw: amount.toString(), decimals, ...(carried ? { carried } : {}) };
|
|
250
312
|
}
|
|
313
|
+
// Every priced layout has returned by now; a Permit2 target here is a call
|
|
314
|
+
// the table does not know.
|
|
315
|
+
if (isPermit2 && POLICY.path) {
|
|
316
|
+
const other = PERMIT2_OTHER[data.slice(2, 10).toLowerCase()];
|
|
317
|
+
throw new Error(`policy: calldata selector 0x${data.slice(2, 10)} aimed at Permit2 is ${other ? `Permit2 ${other}` : 'not a Permit2 call this policy can price'}, which moves tokens or changes what may be moved in a shape the policy cannot count; ` +
|
|
318
|
+
`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
|
+
}
|
|
251
320
|
}
|
|
252
321
|
if (data && data !== '0x')
|
|
253
322
|
return { kind: 'contract', to, asset: '', raw: value, decimals: nativeDecimals(chainId) };
|
|
@@ -369,7 +438,13 @@ function localChainId(body, qs) {
|
|
|
369
438
|
const fromEnv = (process.env.AGENTWALLET_CHAIN_ID || '').trim();
|
|
370
439
|
if (fromEnv)
|
|
371
440
|
return parseInt(fromEnv, 10);
|
|
372
|
-
|
|
441
|
+
// Base. This used to say "matching the custodial default", and it did not:
|
|
442
|
+
// the hosted signer falls back to chain 1 when a body names no chain, so a
|
|
443
|
+
// chain-less hosted send was priced and ledgered here on 8453 and executed
|
|
444
|
+
// there on Ethereum (reported privately 2026-11-02, 1.14.10). The hosted
|
|
445
|
+
// path now writes the chain it priced into the body it forwards (api()), so
|
|
446
|
+
// whatever this returns, the custodian is told the same chain.
|
|
447
|
+
return 8453;
|
|
373
448
|
}
|
|
374
449
|
/**
|
|
375
450
|
* Refuse rather than silently hand the operation to someone else's signer.
|
|
@@ -552,8 +627,18 @@ async function api(path, method = 'GET', body, extraHeaders) {
|
|
|
552
627
|
// signature (body.message) moves nothing and is not gated.
|
|
553
628
|
const m = /^\/wallets\/([^/]+)\/(send|sign)(\?|$)/.exec(path);
|
|
554
629
|
const signsTx = m !== null && (m[2] === 'send' || typeof body?.message !== 'string');
|
|
630
|
+
let reservedChain = null;
|
|
555
631
|
if (method === 'POST' && m && body && signsTx) {
|
|
556
632
|
const chainId = localChainId(body, new URLSearchParams(path.split('?')[1] || ''));
|
|
633
|
+
// The custodian must execute on the chain the policy priced. A body with
|
|
634
|
+
// no chain_id used to go out as it came and the hosted signer chose its
|
|
635
|
+
// own default (chain 1), while the ledger said 8453 (reported privately
|
|
636
|
+
// 2026-11-02, 1.14.10). The chain is written into the body here, before
|
|
637
|
+
// the decode, so the two cannot differ; the custodian's answer is still
|
|
638
|
+
// compared with it below.
|
|
639
|
+
if (!anyLocalMode() && typeof body.chain_id !== 'number')
|
|
640
|
+
body = { ...body, chain_id: chainId };
|
|
641
|
+
reservedChain = chainId;
|
|
557
642
|
const d = await describeSend(body, chainId);
|
|
558
643
|
assertSendTarget(d, chainId);
|
|
559
644
|
const tool = ctx.tool || (m[2] === 'sign' ? 'sign_transaction' : 'send');
|
|
@@ -572,12 +657,21 @@ async function api(path, method = 'GET', body, extraHeaders) {
|
|
|
572
657
|
}
|
|
573
658
|
try {
|
|
574
659
|
const out = await apiInner(path, method, body, extraHeaders);
|
|
660
|
+
// A custodian that answers with a chain other than the one it was asked
|
|
661
|
+
// for executed somewhere the ledger did not count; the line cannot be
|
|
662
|
+
// re-booked (the spend is live), so the mismatch is written onto it and
|
|
663
|
+
// onto stderr for the operator.
|
|
664
|
+
const reported = out?.chain_id;
|
|
665
|
+
const mismatch = reserved.length && reservedChain !== null && typeof reported === 'number' && reported !== reservedChain
|
|
666
|
+
? ` CHAIN MISMATCH: reserved on chain ${reservedChain}, the API host reports chain ${reported}; the budget of chain ${reported} did not count this spend.` : '';
|
|
667
|
+
if (mismatch)
|
|
668
|
+
console.error(`[agentwallet] ${path}:${mismatch}`);
|
|
575
669
|
for (const seq of reserved) {
|
|
576
670
|
const r = out;
|
|
577
671
|
if (m[2] === 'sign')
|
|
578
|
-
LEDGER.settle(seq, 'signed', 'raw transaction returned to the caller; not broadcast by this process, so it stays counted');
|
|
672
|
+
LEDGER.settle(seq, 'signed', 'raw transaction returned to the caller; not broadcast by this process, so it stays counted' + mismatch);
|
|
579
673
|
else
|
|
580
|
-
LEDGER.settle(seq, 'broadcast', typeof r?.tx_hash === 'string' ? r.tx_hash : typeof r?.signature === 'string' ? r.signature : null);
|
|
674
|
+
LEDGER.settle(seq, 'broadcast', (typeof r?.tx_hash === 'string' ? r.tx_hash : typeof r?.signature === 'string' ? r.signature : '') + mismatch || null);
|
|
581
675
|
}
|
|
582
676
|
return out;
|
|
583
677
|
}
|
|
@@ -681,6 +775,21 @@ async function apiInner(path, method = 'GET', body, extraHeaders) {
|
|
|
681
775
|
}
|
|
682
776
|
if (!res.ok) {
|
|
683
777
|
const error = serverText(data.error) || `HTTP ${res.status}`;
|
|
778
|
+
// The host was reached and answered. A 4xx is its refusal and the
|
|
779
|
+
// reservation is voided; a 5xx on a send or sign came after it took the
|
|
780
|
+
// request, and the custodian may have broadcast before whatever failed
|
|
781
|
+
// (a downstream write, a proxy), so the spend stays counted as unknown.
|
|
782
|
+
// Until 1.14.10 every non-2xx voided, and a 500 that carried the
|
|
783
|
+
// transaction hash in its body restored the whole budget and dropped
|
|
784
|
+
// the hash (reported privately 2026-11-02).
|
|
785
|
+
if (method === 'POST' && /^\/wallets\/[^/]+\/(send|sign)(\?|$)/.test(path)) {
|
|
786
|
+
const d = data;
|
|
787
|
+
const hash = looksLikeTxHash(d?.tx_hash) ? d.tx_hash : typeof d?.signature === 'string' && /^[1-9A-HJ-NP-Za-km-z]{43,120}$/.test(d.signature) ? d.signature : null;
|
|
788
|
+
if (hash)
|
|
789
|
+
throw markMaybeLive(new Error(`${error} The AgentWallet API host answered ${path} with HTTP ${res.status} but reported a transaction (${hash}), so the spend is live and stays counted; check the wallet's history before retrying.`));
|
|
790
|
+
if (res.status >= 500)
|
|
791
|
+
throw markMaybeLive(new Error(`${error} The AgentWallet API host answered ${path} with HTTP ${res.status} after taking the request; whether the custodian broadcast is unknown, so the spend stays counted. Check the wallet's history before retrying.`));
|
|
792
|
+
}
|
|
684
793
|
throw new Error(error);
|
|
685
794
|
}
|
|
686
795
|
// A send that comes back without a hash was not sent, whatever the body says
|
|
@@ -861,12 +970,24 @@ async function handleX402Payment(x402Data, originalPath, originalMethod, origina
|
|
|
861
970
|
const { tokenAddress, rawAmount, decimals } = deriveX402Payment(accept, maxAutopay, trustedDecimals);
|
|
862
971
|
// A hostile or broken host that answers 402 on every call must not drain the
|
|
863
972
|
// wallet one cap at a time: at most AUTOPAY_PER_HOUR payments an hour (round 3).
|
|
973
|
+
// The slot is taken here, synchronously, before anything is awaited: the
|
|
974
|
+
// count used to be read here and written after the payment, so eight calls
|
|
975
|
+
// pipelined at once all read 0 and all paid under a limit of 2 (reported
|
|
976
|
+
// privately 2026-11-02, 1.14.10). A payment that never left this process
|
|
977
|
+
// gives its slot back below; one that may be live keeps it. The ledger's
|
|
978
|
+
// own auto-pay lines are counted too, so several processes sharing one
|
|
979
|
+
// ledger share the hour; without a ledger file the count is per process.
|
|
864
980
|
const hourAgo = Date.now() - 3_600_000;
|
|
865
981
|
while (autopayLog.length && autopayLog[0] < hourAgo)
|
|
866
982
|
autopayLog.shift();
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
983
|
+
const made = Math.max(autopayLog.length, LEDGER.countSince('autopay', hourAgo));
|
|
984
|
+
if (made >= AUTOPAY_PER_HOUR) {
|
|
985
|
+
throw new Error(`x402 auto-pay: ${made} automatic payments were already made in the last hour (AGENTWALLET_MAX_AUTOPAY_PER_HOUR=${AUTOPAY_PER_HOUR}); refusing another. Original error: ${serverText(x402Data.error)}`);
|
|
986
|
+
}
|
|
987
|
+
const slot = Date.now();
|
|
988
|
+
autopayLog.push(slot);
|
|
989
|
+
const giveBack = () => { const i = autopayLog.indexOf(slot); if (i >= 0)
|
|
990
|
+
autopayLog.splice(i, 1); };
|
|
870
991
|
// Send payment using our wallet (with X-AGW-SKIP-X402 to prevent recursion).
|
|
871
992
|
// The ledger context names the API host and route; the /send hook reserves and settles the spend.
|
|
872
993
|
const ctx = { 'X-AGW-SKIP-X402': 'true', 'X-AGW-CTX': JSON.stringify({ tool: 'autopay', host: hostOf(API_BASE), resource: `${originalMethod} ${originalPath.split('?')[0]}`, method: 'autopay' }) };
|
|
@@ -890,12 +1011,13 @@ async function handleX402Payment(x402Data, originalPath, originalMethod, origina
|
|
|
890
1011
|
}
|
|
891
1012
|
}
|
|
892
1013
|
catch (e) {
|
|
1014
|
+
if (!isMaybeLive(e))
|
|
1015
|
+
giveBack(); // refused, or never reached the host: the slot is free again
|
|
893
1016
|
throw new Error(`x402 auto-pay failed: ${e.message}. Original error: ${serverText(x402Data.error)}`);
|
|
894
1017
|
}
|
|
895
1018
|
if (!txHash) {
|
|
896
|
-
throw new Error('x402 auto-pay: no transaction hash returned.');
|
|
1019
|
+
throw new Error('x402 auto-pay: no transaction hash returned.'); // the host answered the send; the slot stays taken
|
|
897
1020
|
}
|
|
898
|
-
autopayLog.push(Date.now());
|
|
899
1021
|
const autopayNotice = {
|
|
900
1022
|
amount: formatUnits(rawAmount, decimals), token: assetLabel(chainId, tokenAddress), pay_to: payTo, chain_id: chainId, tx_hash: txHash,
|
|
901
1023
|
route: `${originalMethod} ${originalPath}`,
|
|
@@ -1255,7 +1377,7 @@ function resolveChainId(network) {
|
|
|
1255
1377
|
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');
|
|
1256
1378
|
const server = new McpServer({
|
|
1257
1379
|
name: 'agentwallet',
|
|
1258
|
-
version: '1.14.
|
|
1380
|
+
version: '1.14.10',
|
|
1259
1381
|
}, {
|
|
1260
1382
|
instructions: `AgentWallet gives AI agents their own blockchain wallets. ${anyLocalMode()
|
|
1261
1383
|
? '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
|
@@ -110,6 +110,8 @@ export declare class Ledger {
|
|
|
110
110
|
private liveSpends;
|
|
111
111
|
/** Dollar sums over rolling windows from a fresh read of the file; nowMs for tests. */
|
|
112
112
|
spentUsd(nowMs?: number, host?: string): Spent;
|
|
113
|
+
/** Live spends (not voided) with this method written since `sinceMs`, from a fresh read: the hourly auto-pay count across every process sharing the file (1.14.10). */
|
|
114
|
+
countSince(method: SpendMethod, sinceMs: number): number;
|
|
113
115
|
spentNative(chainId: number, nowMs?: number): Spent;
|
|
114
116
|
private sumUsd;
|
|
115
117
|
private sumNative;
|
package/build/ledger.js
CHANGED
|
@@ -185,6 +185,11 @@ export class Ledger {
|
|
|
185
185
|
this.sync();
|
|
186
186
|
return this.sumUsd(nowMs, host);
|
|
187
187
|
}
|
|
188
|
+
/** Live spends (not voided) with this method written since `sinceMs`, from a fresh read: the hourly auto-pay count across every process sharing the file (1.14.10). */
|
|
189
|
+
countSince(method, sinceMs) {
|
|
190
|
+
this.sync();
|
|
191
|
+
return this.liveSpends().filter(e => e.method === method && Date.parse(e.ts) >= sinceMs).length;
|
|
192
|
+
}
|
|
188
193
|
spentNative(chainId, nowMs = Date.now()) {
|
|
189
194
|
this.sync();
|
|
190
195
|
return this.sumNative(chainId, nowMs);
|
|
@@ -223,9 +228,21 @@ export class Ledger {
|
|
|
223
228
|
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.` };
|
|
224
229
|
const amount18 = rawToUnits(input.raw, input.decimals);
|
|
225
230
|
if (input.asset === '') {
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
231
|
+
// The chain's own coin is counted by native_budget alone; the dollar
|
|
232
|
+
// budget has no price for it. Until 1.14.10 a chain with no
|
|
233
|
+
// native_budget entry fell through here with nothing checked, so a
|
|
234
|
+
// policy of "budget daily 0.5, unpriced_spends refuse" bounded every
|
|
235
|
+
// stablecoin payment and no native one (reported privately
|
|
236
|
+
// 2026-11-02). Now such a spend is what it is to the dollar budget, an
|
|
237
|
+
// unpriced spend, and the unpriced rule decides it.
|
|
238
|
+
if (limits.native) {
|
|
239
|
+
const r = refusal(limits.native, this.sumNative(input.chainId, nowMs), amount18, `native_budget.${input.chainId}`);
|
|
240
|
+
if (r)
|
|
241
|
+
return { ok: false, reason: r };
|
|
242
|
+
}
|
|
243
|
+
else if (limits.unpriced === 'refuse' && (limits.usd || limits.host)) {
|
|
244
|
+
return { ok: false, reason: `policy: ${input.assetLabel} on chain ${input.chainId} is the chain's own coin, which the dollar budget cannot price, and the policy has no native_budget.${input.chainId}; refused (add native_budget.${input.chainId} to the policy to count it, or set unpriced_spends to "allow" to let native spends through uncounted). Nothing was signed.` };
|
|
245
|
+
}
|
|
229
246
|
}
|
|
230
247
|
else if (input.usd === null) {
|
|
231
248
|
if (limits.unpriced === 'refuse' && (limits.usd || limits.host)) {
|
package/build/local-wallet.js
CHANGED
|
@@ -337,7 +337,8 @@ const PERMIT2_SELECTORS = new Set(['87517c45', '2b67b570', '2a2d80d1', '36c78516
|
|
|
337
337
|
and so read as "not a token" to the cap, which let setApprovalForAll to an
|
|
338
338
|
attacker through uncapped (2026-10-04 round 2, verified on a live
|
|
339
339
|
collection). Nothing prices an NFT, so these are refused outright. */
|
|
340
|
-
const NFT_SELECTORS = new Set(['a22cb465', '42842e0e', 'b88d4fde', 'f242432a', '2eb2c2d6', '959b8c3f'
|
|
340
|
+
const NFT_SELECTORS = new Set(['a22cb465', '42842e0e', 'b88d4fde', 'f242432a', '2eb2c2d6', '959b8c3f', // plus ERC-777 authorizeOperator: an unbounded delegation
|
|
341
|
+
'558a7297', '426a8493', '095bcdb6', 'fe99049a']); // EIP-6909 setOperator, approve, transfer, transferFrom: a multi-token contract has no decimals() either (1.14.10)
|
|
341
342
|
const CAP_LAYOUTS = {
|
|
342
343
|
a9059cbb: { amountIndex: 1, what: 'token transfer' }, // transfer(address,uint256)
|
|
343
344
|
'23b872dd': { amountIndex: 2, what: 'token transfer' }, // transferFrom(address,address,uint256)
|
|
@@ -351,6 +352,22 @@ const CAP_LAYOUTS = {
|
|
|
351
352
|
'9bd9bbc6': { amountIndex: 1, what: 'token transfer (ERC-777 send)' }, // send(address,uint256,bytes)
|
|
352
353
|
'62ad1b83': { amountIndex: 2, what: 'token transfer (ERC-777 operatorSend)' }, // operatorSend(address,address,uint256,bytes,bytes)
|
|
353
354
|
'87517c45': { amountIndex: 2, tokenIndex: 0, what: 'Permit2 approval' }, // Permit2.approve(address,address,uint160,uint48)
|
|
355
|
+
// EIP-2612 permit sets the allowance exactly as approve does, with the
|
|
356
|
+
// owner's signature in the calldata (the local key can produce it), so it
|
|
357
|
+
// is priced as an approval (1.14.9, reported privately 2026-11-01). The DAI
|
|
358
|
+
// permit (0x8fcbaf0c, a bool grant) and Permit2.permit stay unpriced here
|
|
359
|
+
// and are refused by the rule below, as every unpriced call to a token or to
|
|
360
|
+
// Permit2 is; the spend policy classifies both.
|
|
361
|
+
d505accf: { amountIndex: 2, what: 'approval (EIP-2612 permit)' }, // permit(address,address,uint256,uint256,uint8,bytes32,bytes32)
|
|
362
|
+
// Sibling shapes priced by the spend policy since 1.14.10 (reported
|
|
363
|
+
// privately 2026-11-02): the same amount word, the same treatment, so the
|
|
364
|
+
// two guards keep pricing the same calls.
|
|
365
|
+
d73dd623: { amountIndex: 1, what: 'approval (increaseApproval)' }, // increaseApproval(address,uint256)
|
|
366
|
+
'3177029f': { amountIndex: 1, what: 'approval (approveAndCall)' }, // approveAndCall(address,uint256)
|
|
367
|
+
'1296ee62': { amountIndex: 1, what: 'token transfer (transferAndCall)' }, // transferAndCall(address,uint256)
|
|
368
|
+
d8fbe994: { amountIndex: 2, what: 'token transfer (transferFromAndCall)' }, // transferFromAndCall(address,address,uint256)
|
|
369
|
+
c1d34b89: { amountIndex: 2, what: 'token transfer (transferFromAndCall)' }, // transferFromAndCall(address,address,uint256,bytes)
|
|
370
|
+
be45fd62: { amountIndex: 1, what: 'token transfer (ERC-223)' }, // transfer(address,uint256,bytes)
|
|
354
371
|
// WETH9 calls, priced only when `to` is the chain's own wrapped-native
|
|
355
372
|
// contract (wrappedNativeOnly). deposit() carries its amount as msg.value,
|
|
356
373
|
// not in calldata, hence amountIndex -1; withdraw(uint256) has it at word 0.
|
|
@@ -404,12 +421,12 @@ async function assertWithinTokenCap(chainId, to, data, valueWei = 0n) {
|
|
|
404
421
|
? `the RPC for chain ${chainId} could not be reached to classify the target, so the guard cannot tell a token from a router`
|
|
405
422
|
: `the target is ${isPermit2 ? 'Permit2' : 'a token contract'} and this calldata is not one AGENTWALLET_MAX_TX_TOKEN can price`;
|
|
406
423
|
throw new Error(`Blocked by local guard: calldata selector 0x${selector} refused rather than let through uncapped: ${why}. ` +
|
|
407
|
-
`Use transfer, transferFrom, approve, increaseAllowance, Permit2 approve, ` +
|
|
424
|
+
`Use transfer, transferFrom, approve, increaseAllowance, EIP-2612 permit, Permit2 approve, ` +
|
|
408
425
|
`or deposit/withdraw on the chain's wrapped-native contract, ` +
|
|
409
426
|
`or ask the operator, who alone can set AGENTWALLET_ALLOW_UNKNOWN_TOKEN_CALLS=1.`);
|
|
410
427
|
}
|
|
411
428
|
if (NFT_SELECTORS.has(selector)) {
|
|
412
|
-
throw new Error(`Blocked by local guard: calldata selector 0x${selector} is an NFT approval or transfer (ERC-721/1155) aimed at ${to}; AGENTWALLET_MAX_TX_TOKEN cannot price it, ` +
|
|
429
|
+
throw new Error(`Blocked by local guard: calldata selector 0x${selector} is an NFT approval or transfer (ERC-721/1155, or an EIP-6909 multi-token call) aimed at ${to}; AGENTWALLET_MAX_TX_TOKEN cannot price it, ` +
|
|
413
430
|
`so it is refused rather than let through uncapped. Only the operator can allow it (AGENTWALLET_ALLOW_UNKNOWN_TOKEN_CALLS=1).`);
|
|
414
431
|
}
|
|
415
432
|
if (PERMIT2_SELECTORS.has(selector)) {
|
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.10",
|
|
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",
|