agentwallet-mcp 1.13.10 → 1.13.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -1
- package/build/index.js +10 -4
- package/build/local-wallet.d.ts +21 -4
- package/build/local-wallet.js +111 -17
- package/build/x402-payment.d.ts +0 -16
- package/build/x402-payment.js +6 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -39,7 +39,7 @@ Run `wallet_mode` at any time and the server will tell you which one you are in,
|
|
|
39
39
|
|
|
40
40
|
Use `AGENTWALLET_KEYFILE=/path/to/key` instead if you would rather keep the key out of your shell config (one or the other: the server refuses to start when both are set). The key is read once, never written to disk, never logged, and never included in an error message.
|
|
41
41
|
|
|
42
|
-
`AGENTWALLET_RPC_<chainId>` (or `AGENTWALLET_RPC_URL` for all chains) points at an endpoint you trust. Without it a public RPC is used, and a public RPC can see which addresses you ask about.
|
|
42
|
+
`AGENTWALLET_RPC_<chainId>` (or `AGENTWALLET_RPC_URL` for all chains) points at an endpoint you trust (https; plain http only on localhost). Without it a public RPC is used, and a public RPC can see which addresses you ask about. Decimals for a token outside the registry come from that RPC and are accepted only up to 18; pin anything else with `AGENTWALLET_TOKEN_DECIMALS`.
|
|
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
|
|
|
@@ -330,6 +330,7 @@ Which guards apply depends on who holds the key.
|
|
|
330
330
|
**Local mode** (you hold the key): there is no server in the signing path, so the server-side limits and pause above cannot see or stop a locally signed transaction, and nobody (including us) can freeze a local wallet. Your protection is the per-transaction caps enforced inside your own process before anything is signed. Set them before you fund the wallet; an unset cap means no limit.
|
|
331
331
|
|
|
332
332
|
- `AGENTWALLET_MAX_TX_NATIVE`: ceiling per transaction in native units (ETH, MATIC, and so on)
|
|
333
|
+
- `AGENTWALLET_MAX_FEE_NATIVE`: the most one transaction may burn in fees, gas x max fee per gas, in native units (default `0.01`). The value cap alone did not bound fees: a node answering an absurd tip or gas estimate could take the whole balance as priority fee on a cap-compliant transfer. Caller-supplied `gas_limit` / `max_fee` / `priority_fee` are honoured and then bounded by this cap
|
|
333
334
|
- `AGENTWALLET_MAX_TX_TOKEN`: ceiling per ERC-20 transfer or approval, in human units of the token
|
|
334
335
|
- `AGENTWALLET_ALLOW_UNKNOWN_TOKEN_CALLS`: set to `1` to let calldata the token cap cannot price reach a token contract or Permit2 (refused by default while the cap is set)
|
|
335
336
|
- `AGENTWALLET_TOKEN_DECIMALS`: pin decimals for tokens outside the registry (`8453:0x<token>=6,<mint>=9`); a pin is used for amounts and caps instead of asking the RPC
|
package/build/index.js
CHANGED
|
@@ -119,7 +119,12 @@ async function routeLocally(path, method, body) {
|
|
|
119
119
|
throw new Error(`Local mode expects an EVM address, got "${to}".`);
|
|
120
120
|
}
|
|
121
121
|
const hasData = data && data !== '0x';
|
|
122
|
-
|
|
122
|
+
const fees = {
|
|
123
|
+
...(body?.gas_limit ? { gas: String(body.gas_limit) } : {}),
|
|
124
|
+
...(body?.max_fee ? { maxFeePerGas: String(body.max_fee) } : {}),
|
|
125
|
+
...(body?.priority_fee ? { maxPriorityFeePerGas: String(body.priority_fee) } : {}),
|
|
126
|
+
};
|
|
127
|
+
return localSend(chainId, to, value, hasData ? data : undefined, fees);
|
|
123
128
|
}
|
|
124
129
|
// POST /wallets/{id}/x402/authorize (x402 "exact": EIP-3009 authorization, signed here, broadcast by nobody)
|
|
125
130
|
if (method === 'POST' && /^\/wallets\/[^/]*\/x402\/authorize$/.test(rawPath)) {
|
|
@@ -603,7 +608,7 @@ async function verifiedDecimals(chainId, token, given) {
|
|
|
603
608
|
/** Cap variables are read at send time; a typo must fail at startup, not after funding. */
|
|
604
609
|
function validateGuardEnv() {
|
|
605
610
|
parseDecimalPins(process.env.AGENTWALLET_TOKEN_DECIMALS); // throws on a malformed pin
|
|
606
|
-
for (const name of ['AGENTWALLET_MAX_TX_NATIVE', 'AGENTWALLET_MAX_TX_TOKEN', 'AGENTWALLET_MAX_TX_SOL', 'AGENTWALLET_MAX_AUTOPAY']) {
|
|
611
|
+
for (const name of ['AGENTWALLET_MAX_TX_NATIVE', 'AGENTWALLET_MAX_TX_TOKEN', 'AGENTWALLET_MAX_TX_SOL', 'AGENTWALLET_MAX_AUTOPAY', 'AGENTWALLET_MAX_FEE_NATIVE']) {
|
|
607
612
|
const raw = process.env[name];
|
|
608
613
|
const v = (raw || '').trim();
|
|
609
614
|
// Set but blank is a typo, not "no cap": refuse to start rather than run uncapped.
|
|
@@ -654,7 +659,7 @@ function validateGuardEnv() {
|
|
|
654
659
|
throw new Error(`Unknown environment variable ${k}. AgentWallet refuses to start on an unrecognised AGENTWALLET_* name so that a misspelled cap cannot run uncapped. Known names: ${[...KNOWN_ENV].join(', ')}, AGENTWALLET_RPC_<chainId>, AGENTWALLET_SOLANA_RPC_<chainId>.`);
|
|
655
660
|
}
|
|
656
661
|
}
|
|
657
|
-
const KNOWN_ENV = new Set(["AGENTWALLET_ALLOW_UNKNOWN_TOKEN_CALLS", "AGENTWALLET_API_URL", "AGENTWALLET_APPROVALS", "AGENTWALLET_AUTOPAY_ASSETS", "AGENTWALLET_CHAIN_ID", "AGENTWALLET_DASHBOARD_URL", "AGENTWALLET_KEYFILE", "AGENTWALLET_LEGACY_PAYWALL_ORIGINS", "AGENTWALLET_MAX_AUTOPAY", "AGENTWALLET_MAX_AUTOPAY_PER_HOUR", "AGENTWALLET_MAX_TX_NATIVE", "AGENTWALLET_MAX_TX_SOL", "AGENTWALLET_MAX_TX_TOKEN", "AGENTWALLET_PASS", "AGENTWALLET_PERMIT2_AUTO_APPROVE", "AGENTWALLET_PRIVATE_KEY", "AGENTWALLET_RPC_URL", "AGENTWALLET_SOLANA_KEY", "AGENTWALLET_SOLANA_KEYFILE", "AGENTWALLET_SOLANA_RPC", "AGENTWALLET_TOKEN_DECIMALS", "AGENTWALLET_TOKEN_RISK", "AGENTWALLET_USER", "AGENTWALLET_WALLET_ID", "AGENTWALLET_X402_MAX_TIMEOUT"]);
|
|
662
|
+
const KNOWN_ENV = new Set(["AGENTWALLET_ALLOW_UNKNOWN_TOKEN_CALLS", "AGENTWALLET_API_URL", "AGENTWALLET_APPROVALS", "AGENTWALLET_AUTOPAY_ASSETS", "AGENTWALLET_CHAIN_ID", "AGENTWALLET_DASHBOARD_URL", "AGENTWALLET_KEYFILE", "AGENTWALLET_LEGACY_PAYWALL_ORIGINS", "AGENTWALLET_MAX_AUTOPAY", "AGENTWALLET_MAX_AUTOPAY_PER_HOUR", "AGENTWALLET_MAX_FEE_NATIVE", "AGENTWALLET_MAX_TX_NATIVE", "AGENTWALLET_MAX_TX_SOL", "AGENTWALLET_MAX_TX_TOKEN", "AGENTWALLET_PASS", "AGENTWALLET_PERMIT2_AUTO_APPROVE", "AGENTWALLET_PRIVATE_KEY", "AGENTWALLET_RPC_URL", "AGENTWALLET_SOLANA_KEY", "AGENTWALLET_SOLANA_KEYFILE", "AGENTWALLET_SOLANA_RPC", "AGENTWALLET_TOKEN_DECIMALS", "AGENTWALLET_TOKEN_RISK", "AGENTWALLET_USER", "AGENTWALLET_WALLET_ID", "AGENTWALLET_X402_MAX_TIMEOUT"]);
|
|
658
663
|
/**
|
|
659
664
|
* Register a tool whose arguments are strict: an unknown key (max_payemnt for
|
|
660
665
|
* max_payment) is an error that names it, not a silently dropped guard. The
|
|
@@ -742,7 +747,7 @@ function resolveChainId(network) {
|
|
|
742
747
|
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');
|
|
743
748
|
const server = new McpServer({
|
|
744
749
|
name: 'agentwallet',
|
|
745
|
-
version: '1.13.
|
|
750
|
+
version: '1.13.11',
|
|
746
751
|
}, {
|
|
747
752
|
instructions: `AgentWallet gives AI agents their own blockchain wallets. ${anyLocalMode()
|
|
748
753
|
? '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.'
|
|
@@ -2115,6 +2120,7 @@ tool('wallet_mode', 'Report whether this server is signing locally (self-custody
|
|
|
2115
2120
|
address: getLocalAddress(),
|
|
2116
2121
|
rpc_endpoint: redactUrl(rpc),
|
|
2117
2122
|
per_tx_cap_native: (process.env.AGENTWALLET_MAX_TX_NATIVE || '').trim() || 'not set',
|
|
2123
|
+
per_tx_fee_cap_native: (process.env.AGENTWALLET_MAX_FEE_NATIVE || '').trim() || '0.01 (default)',
|
|
2118
2124
|
};
|
|
2119
2125
|
}
|
|
2120
2126
|
else {
|
package/build/local-wallet.d.ts
CHANGED
|
@@ -49,14 +49,15 @@ export declare function localNativeBalance(chainId: number): Promise<{
|
|
|
49
49
|
mode: string;
|
|
50
50
|
}>;
|
|
51
51
|
export declare function localTokenBalance(chainId: number, token: Address): Promise<{
|
|
52
|
+
mode: string;
|
|
53
|
+
warning?: string | undefined;
|
|
52
54
|
address: `0x${string}`;
|
|
53
55
|
chain_id: number;
|
|
54
56
|
token: `0x${string}`;
|
|
55
57
|
symbol: string;
|
|
56
|
-
decimals: number;
|
|
58
|
+
decimals: number | null;
|
|
57
59
|
balance_raw: string;
|
|
58
|
-
balance: string;
|
|
59
|
-
mode: string;
|
|
60
|
+
balance: string | null;
|
|
60
61
|
}>;
|
|
61
62
|
export interface LocalSendResult {
|
|
62
63
|
tx_hash: string;
|
|
@@ -66,6 +67,9 @@ export interface LocalSendResult {
|
|
|
66
67
|
chain_id: number;
|
|
67
68
|
mode: 'local';
|
|
68
69
|
signed_locally: true;
|
|
70
|
+
gas?: string;
|
|
71
|
+
max_fee_per_gas?: string;
|
|
72
|
+
worst_case_fee?: string;
|
|
69
73
|
}
|
|
70
74
|
/**
|
|
71
75
|
* What a decimals() probe learned about an address. The four outcomes are
|
|
@@ -89,7 +93,20 @@ type DecimalsProbe = number | 'unusable' | 'not-a-token' | 'unreachable';
|
|
|
89
93
|
/** An eth_call failure is a revert only when the node says so; every other failure is "could not ask". */
|
|
90
94
|
export declare function classifyDecimalsError(err: unknown): DecimalsProbe;
|
|
91
95
|
export declare function permit2AddressFor(chainId: number): string;
|
|
92
|
-
|
|
96
|
+
/** Caller-chosen gas parameters (wei / gas units as decimal strings); honoured, then bounded by the fee cap. */
|
|
97
|
+
export interface FeeOverrides {
|
|
98
|
+
gas?: string;
|
|
99
|
+
maxFeePerGas?: string;
|
|
100
|
+
maxPriorityFeePerGas?: string;
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* The most a transaction may burn in fees: gas x maxFeePerGas, in native units.
|
|
104
|
+
* Without it a node that answered a 47,000 gwei tip or a 30M gas estimate took
|
|
105
|
+
* the whole balance as fees on a cap-compliant 0.001 ETH transfer (round 4).
|
|
106
|
+
* Default 0.01; AGENTWALLET_MAX_FEE_NATIVE raises or lowers it.
|
|
107
|
+
*/
|
|
108
|
+
export declare function feeCapWei(): bigint;
|
|
109
|
+
export declare function localSend(chainId: number, to: Address, valueWei: bigint, data?: Hex, fees?: FeeOverrides): Promise<LocalSendResult>;
|
|
93
110
|
/** ERC-20 transfer, encoded locally. */
|
|
94
111
|
export declare function localTransferToken(chainId: number, token: Address, to: Address, rawAmount: string): Promise<LocalSendResult>;
|
|
95
112
|
/** ERC-20 approve, encoded locally. */
|
package/build/local-wallet.js
CHANGED
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
* on the custodial path and say so explicitly rather than silently downgrading.
|
|
16
16
|
*/
|
|
17
17
|
import { readFileSync, statSync } from 'node:fs';
|
|
18
|
-
import { createPublicClient, createWalletClient, http, encodeFunctionData, parseAbi, formatUnits, } from 'viem';
|
|
18
|
+
import { createPublicClient, createWalletClient, http, encodeFunctionData, parseAbi, formatUnits, keccak256, } from 'viem';
|
|
19
19
|
import { privateKeyToAccount } from 'viem/accounts';
|
|
20
20
|
import { nonceManager } from 'viem/nonce';
|
|
21
21
|
import { lookupTrustedDecimals, maxAuthWindowSeconds } from './x402-payment.js';
|
|
@@ -109,16 +109,31 @@ const DEFAULT_RPC = {
|
|
|
109
109
|
export function resolveRpcUrl(chainId) {
|
|
110
110
|
const perChain = (process.env[`AGENTWALLET_RPC_${chainId}`] || '').trim();
|
|
111
111
|
if (perChain)
|
|
112
|
-
return perChain;
|
|
112
|
+
return assertRpcScheme(perChain, `AGENTWALLET_RPC_${chainId}`);
|
|
113
113
|
const generic = (process.env.AGENTWALLET_RPC_URL || '').trim();
|
|
114
114
|
if (generic)
|
|
115
|
-
return generic;
|
|
115
|
+
return assertRpcScheme(generic, 'AGENTWALLET_RPC_URL');
|
|
116
116
|
const fallback = DEFAULT_RPC[chainId];
|
|
117
117
|
if (fallback)
|
|
118
118
|
return fallback;
|
|
119
119
|
throw new Error(`No RPC endpoint for chain ${chainId} in local mode. ` +
|
|
120
120
|
`Set AGENTWALLET_RPC_${chainId} to an endpoint you trust.`);
|
|
121
121
|
}
|
|
122
|
+
/** An RPC over plain http to anything but loopback is the hostile-node model in full; refused like the API URL is. */
|
|
123
|
+
function assertRpcScheme(url, name) {
|
|
124
|
+
let u;
|
|
125
|
+
try {
|
|
126
|
+
u = new URL(url);
|
|
127
|
+
}
|
|
128
|
+
catch {
|
|
129
|
+
throw new Error(`${name} is not a URL: "${url}".`);
|
|
130
|
+
}
|
|
131
|
+
const loopback = u.hostname === 'localhost' || u.hostname === '127.0.0.1' || u.hostname === '[::1]';
|
|
132
|
+
if (u.protocol !== 'https:' && !(u.protocol === 'http:' && loopback)) {
|
|
133
|
+
throw new Error(`${name} must be https (got ${u.protocol}//${u.host}); plain http is allowed only for localhost.`);
|
|
134
|
+
}
|
|
135
|
+
return url;
|
|
136
|
+
}
|
|
122
137
|
/**
|
|
123
138
|
* Chain ids each RPC URL has been seen to serve, so an operator pointing
|
|
124
139
|
* AGENTWALLET_RPC_URL at one node for every chain, or mistyping a per-chain
|
|
@@ -187,16 +202,21 @@ const ERC20_ABI = parseAbi([
|
|
|
187
202
|
export async function localTokenBalance(chainId, token) {
|
|
188
203
|
const { pub } = clients(chainId);
|
|
189
204
|
const address = getLocalAddress();
|
|
190
|
-
const [raw,
|
|
205
|
+
const [raw, decimalsRaw, symbol] = await Promise.all([
|
|
191
206
|
pub.readContract({ address: token, abi: ERC20_ABI, functionName: 'balanceOf', args: [address] }),
|
|
192
|
-
pub.readContract({ address: token, abi: ERC20_ABI, functionName: 'decimals' }).catch(() =>
|
|
207
|
+
pub.readContract({ address: token, abi: ERC20_ABI, functionName: 'decimals' }).catch(() => null),
|
|
193
208
|
pub.readContract({ address: token, abi: ERC20_ABI, functionName: 'symbol' }).catch(() => ''),
|
|
194
209
|
]);
|
|
210
|
+
// A decimals() of 2^20 handed to viem's formatUnits held the whole server for
|
|
211
|
+
// minutes (quadratic trailing-zero trim, round 4): only 0..36 is formatted.
|
|
212
|
+
const d = Number(decimalsRaw);
|
|
213
|
+
const safe = Number.isInteger(d) && d >= 0 && d <= 36 ? d : null;
|
|
195
214
|
return {
|
|
196
215
|
address, chain_id: chainId, token, symbol,
|
|
197
|
-
decimals:
|
|
216
|
+
decimals: safe,
|
|
198
217
|
balance_raw: raw.toString(),
|
|
199
|
-
balance: formatUnits(raw,
|
|
218
|
+
balance: safe === null ? null : formatUnits(raw, safe),
|
|
219
|
+
...(safe === null ? { warning: 'the token reported decimals outside 0..36 (or none); only the raw balance is shown' } : {}),
|
|
200
220
|
mode: 'local',
|
|
201
221
|
};
|
|
202
222
|
}
|
|
@@ -261,8 +281,11 @@ async function probeTokenDecimals(chainId, token) {
|
|
|
261
281
|
return 'unusable'; // answered, but not one uint8 word
|
|
262
282
|
const d = Number(BigInt(result));
|
|
263
283
|
// Reject nonsense rather than trusting it; an out-of-range value would
|
|
264
|
-
// widen the ceiling exactly like the old assumption did.
|
|
265
|
-
|
|
284
|
+
// widen the ceiling exactly like the old assumption did. Above 18 the RPC is
|
|
285
|
+
// the only witness and a lie there scales the cap by 10^18 (a node saying 36
|
|
286
|
+
// for mainnet WETH, round 4): such tokens must be pinned with
|
|
287
|
+
// AGENTWALLET_TOKEN_DECIMALS.
|
|
288
|
+
if (Number.isInteger(d) && d >= 0 && d <= 18) {
|
|
266
289
|
decimalsCache.set(key, d);
|
|
267
290
|
return d;
|
|
268
291
|
}
|
|
@@ -435,7 +458,25 @@ async function assertWithinTokenCap(chainId, to, data, valueWei = 0n) {
|
|
|
435
458
|
}
|
|
436
459
|
}
|
|
437
460
|
}
|
|
438
|
-
|
|
461
|
+
function parseUint(v, what) {
|
|
462
|
+
if (!/^\d{1,40}$/.test(v))
|
|
463
|
+
throw new Error(`${what} must be a non-negative integer string, got "${v}".`);
|
|
464
|
+
return BigInt(v);
|
|
465
|
+
}
|
|
466
|
+
/**
|
|
467
|
+
* The most a transaction may burn in fees: gas x maxFeePerGas, in native units.
|
|
468
|
+
* Without it a node that answered a 47,000 gwei tip or a 30M gas estimate took
|
|
469
|
+
* the whole balance as fees on a cap-compliant 0.001 ETH transfer (round 4).
|
|
470
|
+
* Default 0.01; AGENTWALLET_MAX_FEE_NATIVE raises or lowers it.
|
|
471
|
+
*/
|
|
472
|
+
export function feeCapWei() {
|
|
473
|
+
const raw = (process.env.AGENTWALLET_MAX_FEE_NATIVE || '0.01').trim();
|
|
474
|
+
if (!/^\d+(\.\d+)?$/.test(raw))
|
|
475
|
+
throw new Error(`AGENTWALLET_MAX_FEE_NATIVE must be a decimal number, got "${raw}".`);
|
|
476
|
+
const [whole, frac = ''] = raw.split('.');
|
|
477
|
+
return BigInt(whole + frac.padEnd(18, '0').slice(0, 18));
|
|
478
|
+
}
|
|
479
|
+
export async function localSend(chainId, to, valueWei, data, fees) {
|
|
439
480
|
assertWithinNativeCap(valueWei);
|
|
440
481
|
if (data && data !== '0x') {
|
|
441
482
|
if (!/^0x([0-9a-fA-F]{2})*$/.test(data))
|
|
@@ -443,20 +484,73 @@ export async function localSend(chainId, to, valueWei, data) {
|
|
|
443
484
|
await assertWithinTokenCap(chainId, to, data, valueWei);
|
|
444
485
|
}
|
|
445
486
|
await assertRpcServesChain(chainId);
|
|
446
|
-
const { wallet } = clients(chainId);
|
|
447
|
-
const
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
487
|
+
const { pub, wallet } = clients(chainId);
|
|
488
|
+
const account = getLocalAccount();
|
|
489
|
+
const base = { account, to, value: valueWei, ...(data ? { data } : {}) };
|
|
490
|
+
// Gas: the caller's figure when given (it used to be read and dropped,
|
|
491
|
+
// round 4), otherwise the node's estimate.
|
|
492
|
+
const gas = fees?.gas ? parseUint(fees.gas, 'gas_limit') : await pub.estimateGas(base);
|
|
493
|
+
// Fees: the caller's figures when given, otherwise the node's; either way the
|
|
494
|
+
// worst case gas x maxFeePerGas must fit the fee cap before anything is signed.
|
|
495
|
+
let maxFeePerGas;
|
|
496
|
+
let maxPriorityFeePerGas;
|
|
497
|
+
let gasPrice;
|
|
498
|
+
if (fees?.maxFeePerGas) {
|
|
499
|
+
maxFeePerGas = parseUint(fees.maxFeePerGas, 'max_fee');
|
|
500
|
+
maxPriorityFeePerGas = fees.maxPriorityFeePerGas ? parseUint(fees.maxPriorityFeePerGas, 'priority_fee') : maxFeePerGas;
|
|
501
|
+
}
|
|
502
|
+
else {
|
|
503
|
+
try {
|
|
504
|
+
const est = await pub.estimateFeesPerGas();
|
|
505
|
+
maxFeePerGas = est.maxFeePerGas;
|
|
506
|
+
maxPriorityFeePerGas = fees?.maxPriorityFeePerGas ? parseUint(fees.maxPriorityFeePerGas, 'priority_fee') : est.maxPriorityFeePerGas;
|
|
507
|
+
}
|
|
508
|
+
catch {
|
|
509
|
+
gasPrice = (await pub.estimateFeesPerGas({ type: 'legacy' })).gasPrice; // a chain without EIP-1559
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
if (maxFeePerGas !== undefined && maxPriorityFeePerGas !== undefined && maxPriorityFeePerGas > maxFeePerGas)
|
|
513
|
+
maxPriorityFeePerGas = maxFeePerGas;
|
|
514
|
+
const perGas = maxFeePerGas ?? gasPrice ?? 0n;
|
|
515
|
+
const worst = gas * perGas;
|
|
516
|
+
const feeCap = feeCapWei();
|
|
517
|
+
if (worst > feeCap) {
|
|
518
|
+
throw new Error(`Blocked by local guard: the transaction could burn up to ${formatUnits(worst, 18)} native in fees (gas ${gas} x ${formatUnits(perGas, 9)} gwei), ` +
|
|
519
|
+
`above AGENTWALLET_MAX_FEE_NATIVE (${formatUnits(feeCap, 18)}). A node answering an absurd fee or gas estimate is the usual cause; ` +
|
|
520
|
+
`pass gas_limit / max_fee / priority_fee explicitly, or the operator can raise the cap.`);
|
|
521
|
+
}
|
|
522
|
+
// Sign here, hash here, then hand the bytes to the node: the hash reported is
|
|
523
|
+
// the one computed from the signed transaction, never the node's answer.
|
|
524
|
+
const address = getLocalAddress();
|
|
525
|
+
const nonce = await account.nonceManager.consume({ address, chainId, client: pub });
|
|
526
|
+
const request = await wallet.prepareTransactionRequest({
|
|
527
|
+
...base, gas, nonce, chainId,
|
|
528
|
+
...(gasPrice !== undefined ? { type: 'legacy', gasPrice } : { type: 'eip1559', maxFeePerGas: maxFeePerGas, maxPriorityFeePerGas: maxPriorityFeePerGas }),
|
|
451
529
|
});
|
|
530
|
+
const signed = await wallet.signTransaction(request);
|
|
531
|
+
const localHash = keccak256(signed);
|
|
532
|
+
let reported;
|
|
533
|
+
try {
|
|
534
|
+
reported = await pub.sendRawTransaction({ serializedTransaction: signed });
|
|
535
|
+
}
|
|
536
|
+
catch (e) {
|
|
537
|
+
account.nonceManager.reset({ address, chainId });
|
|
538
|
+
throw new Error(`Broadcast of transaction ${localHash} (nonce ${nonce}) failed: ${e.message.split('\n')[0]}. The transaction is signed and MAY be live if the node forwarded it before answering; check the hash on an explorer before retrying.`);
|
|
539
|
+
}
|
|
540
|
+
if (reported.toLowerCase() !== localHash.toLowerCase()) {
|
|
541
|
+
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.`);
|
|
542
|
+
}
|
|
452
543
|
return {
|
|
453
|
-
tx_hash:
|
|
454
|
-
from:
|
|
544
|
+
tx_hash: localHash,
|
|
545
|
+
from: address,
|
|
455
546
|
to,
|
|
456
547
|
value: valueWei.toString(),
|
|
457
548
|
chain_id: chainId,
|
|
458
549
|
mode: 'local',
|
|
459
550
|
signed_locally: true,
|
|
551
|
+
gas: gas.toString(),
|
|
552
|
+
max_fee_per_gas: (maxFeePerGas ?? gasPrice ?? 0n).toString(),
|
|
553
|
+
worst_case_fee: formatUnits(worst, 18),
|
|
460
554
|
};
|
|
461
555
|
}
|
|
462
556
|
/** ERC-20 transfer, encoded locally. */
|
package/build/x402-payment.d.ts
CHANGED
|
@@ -1,19 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Pure helpers for x402 auto-pay amount/token derivation.
|
|
3
|
-
*
|
|
4
|
-
* Isolated from the server bootstrap in index.ts so the funds-sensitive amount
|
|
5
|
-
* math can be unit-tested without starting the MCP server.
|
|
6
|
-
*
|
|
7
|
-
* SECURITY INVARIANT: token decimals must NEVER come from the 402 response.
|
|
8
|
-
* A resource server controls `asset`, `payTo`, `maxAmountRequired` AND
|
|
9
|
-
* `requiredDecimals`. If the cap is scaled using server-declared decimals, the
|
|
10
|
-
* server can inflate the cap arbitrarily: declaring 18 decimals for 6-decimal
|
|
11
|
-
* USDC turns a "1 USDC" cap into 10^18 base units, so a request for
|
|
12
|
-
* 1,000,000,000,000 units (1,000,000 USDC) passes a 1 USDC cap. Reported by
|
|
13
|
-
* ARC Security Research, 2026-08-02, against 1.10.1. Decimals are now resolved
|
|
14
|
-
* from a trusted source and the declared value is only ever used to detect a
|
|
15
|
-
* mismatch and refuse.
|
|
16
|
-
*/
|
|
17
1
|
export interface X402AcceptLike {
|
|
18
2
|
maxAmountRequired: string;
|
|
19
3
|
requiredDecimals?: number;
|
package/build/x402-payment.js
CHANGED
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
* from a trusted source and the declared value is only ever used to detect a
|
|
15
15
|
* mismatch and refuse.
|
|
16
16
|
*/
|
|
17
|
+
import { wrappedNativeAddress } from './wrapped-native.js';
|
|
17
18
|
/**
|
|
18
19
|
* Decimals for assets that x402 endpoints actually settle in, keyed by
|
|
19
20
|
* chain id then lowercased contract address (or SPL mint on Solana).
|
|
@@ -283,6 +284,11 @@ export function lookupTrustedDecimals(chainId, token) {
|
|
|
283
284
|
const pinned = pinnedDecimals(chainId, token);
|
|
284
285
|
if (pinned !== null)
|
|
285
286
|
return pinned;
|
|
287
|
+
// Every chain's wrapped native coin is 18 decimals (WETH9 clones; the local
|
|
288
|
+
// guard asserts it). The registry carried WETH for Base only, so mainnet WETH
|
|
289
|
+
// was priced by the RPC, which could say 36 (2026-10-04 round 4).
|
|
290
|
+
if (wrappedNativeAddress(chainId)?.toLowerCase() === token.toLowerCase())
|
|
291
|
+
return 18;
|
|
286
292
|
const spl = TRUSTED_SPL_DECIMALS[token];
|
|
287
293
|
if (typeof spl === 'number')
|
|
288
294
|
return spl;
|
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "agentwallet-mcp",
|
|
3
3
|
"mcpName": "io.github.hifriendbot/agentwallet",
|
|
4
4
|
"repository": { "type": "git", "url": "https://github.com/hifriendbot/agentwallet-mcp" },
|
|
5
|
-
"version": "1.13.
|
|
5
|
+
"version": "1.13.11",
|
|
6
6
|
"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.",
|
|
7
7
|
"type": "module",
|
|
8
8
|
"main": "build/index.js",
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
"scripts": {
|
|
18
18
|
"build": "tsc",
|
|
19
19
|
"start": "node build/index.js",
|
|
20
|
-
"test": "node test/x402-payment.test.mjs && node test/eip3009.test.mjs && node test/permit2-risk.test.mjs && node test/token-cap.test.mjs && node test/spl-cap.test.mjs && node test/unknown-decimals-cap.test.mjs && node test/decimals-range-cap.test.mjs && node test/local-mode.test.mjs && node test/ssrf-guard.test.mjs && node test/x402-policy.test.mjs && node test/local-guard-shape.test.mjs && node test/allowance-total-cap.test.mjs && node test/autopay-scheme.test.mjs && node test/spl-recipient-guard.test.mjs && node test/approval-binding.test.mjs && node test/ata-rent-size.test.mjs && node test/final-url-guard.test.mjs && node test/audit-1135.test.mjs && node test/local-no-autopay.test.mjs && node test/audit-1136.test.mjs && node test/delete-confirm.test.mjs && node test/audit-1138.test.mjs && node test/audit-1139.test.mjs && node test/audit-11310.test.mjs",
|
|
20
|
+
"test": "node test/x402-payment.test.mjs && node test/eip3009.test.mjs && node test/permit2-risk.test.mjs && node test/token-cap.test.mjs && node test/spl-cap.test.mjs && node test/unknown-decimals-cap.test.mjs && node test/decimals-range-cap.test.mjs && node test/local-mode.test.mjs && node test/ssrf-guard.test.mjs && node test/x402-policy.test.mjs && node test/local-guard-shape.test.mjs && node test/allowance-total-cap.test.mjs && node test/autopay-scheme.test.mjs && node test/spl-recipient-guard.test.mjs && node test/approval-binding.test.mjs && node test/ata-rent-size.test.mjs && node test/final-url-guard.test.mjs && node test/audit-1135.test.mjs && node test/local-no-autopay.test.mjs && node test/audit-1136.test.mjs && node test/delete-confirm.test.mjs && node test/audit-1138.test.mjs && node test/audit-1139.test.mjs && node test/audit-11310.test.mjs && node test/audit-11311.test.mjs",
|
|
21
21
|
"prepublishOnly": "npm run build"
|
|
22
22
|
},
|
|
23
23
|
"keywords": [
|