agentwallet-mcp 1.12.2 → 1.12.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md 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` and `approve`, the last because an unbounded allowance is a drain waiting to happen. Decimals are resolved locally; unknown tokens are evaluated at 6 decimals, the tightest common value, so it fails closed rather than open.
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` and Permit2 `approve`, the approvals because an unbounded allowance is a drain waiting to happen. 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. Calls to other contracts (routers, bridges) pass, since they can only pull what an approval already allowed. 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.
47
47
 
48
48
  ### Solana local signing
49
49
 
@@ -327,7 +327,8 @@ Which guards apply depends on who holds the key.
327
327
  **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.
328
328
 
329
329
  - `AGENTWALLET_MAX_TX_NATIVE`: ceiling per transaction in native units (ETH, MATIC, and so on)
330
- - `AGENTWALLET_MAX_TX_TOKEN`: ceiling per ERC-20 transfer, in human units of the token
330
+ - `AGENTWALLET_MAX_TX_TOKEN`: ceiling per ERC-20 transfer or approval, in human units of the token
331
+ - `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)
331
332
  - `AGENTWALLET_MAX_TX_SOL`: ceiling per SOL or SPL transfer
332
333
  - `AGENTWALLET_MAX_AUTOPAY`: ceiling per x402 auto-payment (default `1`)
333
334
 
package/build/index.js CHANGED
@@ -461,7 +461,7 @@ function resolveChainId(network) {
461
461
  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');
462
462
  const server = new McpServer({
463
463
  name: 'agentwallet',
464
- version: '1.12.2',
464
+ version: '1.12.3',
465
465
  }, {
466
466
  instructions: `AgentWallet gives AI agents their own blockchain wallets. Private keys are encrypted server-side and never exposed — agents sign and broadcast transactions without ever touching raw keys.
467
467
 
@@ -193,7 +193,12 @@ export async function localTokenBalance(chainId, token) {
193
193
  * 2-decimal token move 10,000x the configured cap.
194
194
  */
195
195
  const decimalsCache = new Map();
196
- async function resolveTokenDecimals(chainId, token) {
196
+ /**
197
+ * Decimals for `token`: the trusted registry, then the contract's own
198
+ * decimals() (cached on success). Null when the address does not answer like
199
+ * an ERC-20 at all, which is how the guard tells a token from a router.
200
+ */
201
+ async function probeTokenDecimals(chainId, token) {
197
202
  const known = lookupTrustedDecimals(chainId, token);
198
203
  if (typeof known === 'number')
199
204
  return known;
@@ -217,11 +222,33 @@ async function resolveTokenDecimals(chainId, token) {
217
222
  }
218
223
  }
219
224
  catch {
220
- // Unreachable RPC, non-standard token, anything at all: fall through to 0.
225
+ // Unreachable RPC, non-standard token, not a token at all.
221
226
  }
222
- return 0;
227
+ return null;
228
+ }
229
+ async function resolveTokenDecimals(chainId, token) {
230
+ return (await probeTokenDecimals(chainId, token)) ?? 0;
223
231
  }
224
- async function assertWithinTokenCap(chainId, token, data) {
232
+ /** Canonical Permit2, the same address on every EVM chain it is deployed to. */
233
+ const PERMIT2 = '0x000000000022d473030f116ddee9f6b43ac78ba3';
234
+ const CAP_LAYOUTS = {
235
+ a9059cbb: { amountIndex: 1, what: 'token transfer' }, // transfer(address,uint256)
236
+ '23b872dd': { amountIndex: 2, what: 'token transfer' }, // transferFrom(address,address,uint256)
237
+ '095ea7b3': { amountIndex: 1, what: 'approval' }, // approve(address,uint256)
238
+ '39509351': { amountIndex: 1, what: 'allowance increase' }, // increaseAllowance(address,uint256)
239
+ '87517c45': { amountIndex: 2, tokenIndex: 0, what: 'Permit2 approval' }, // Permit2.approve(address,address,uint160,uint48)
240
+ };
241
+ /**
242
+ * The selector table used to be an allowlist that returned early for anything
243
+ * it did not recognise, so `increaseAllowance`, a direct Permit2 `approve`, or
244
+ * any other function on a token contract went through uncapped while the guard
245
+ * reported nothing. Reported privately 2026-09-25. Now: priced calls are
246
+ * checked, and unpriced calldata aimed at a token contract or at Permit2 is
247
+ * refused, because any state change there can move funds. Calls to other
248
+ * contracts (routers, bridges) still pass: they can only pull what an approval
249
+ * already allowed, and approvals are what this guard bounds.
250
+ */
251
+ async function assertWithinTokenCap(chainId, to, data) {
225
252
  const cap = (process.env.AGENTWALLET_MAX_TX_TOKEN || '').trim();
226
253
  if (!cap)
227
254
  return;
@@ -230,21 +257,38 @@ async function assertWithinTokenCap(chainId, token, data) {
230
257
  }
231
258
  const hex = data.slice(2);
232
259
  const selector = hex.slice(0, 8).toLowerCase();
233
- // transfer(address,uint256) | approve(address,uint256) | transferFrom(address,address,uint256)
234
- const layouts = { a9059cbb: 1, '095ea7b3': 1, '23b872dd': 2 };
235
- const argIndex = layouts[selector];
236
- if (argIndex === undefined)
237
- return; // not a value-moving ERC-20 call
238
- const word = hex.slice(8 + argIndex * 64, 8 + (argIndex + 1) * 64);
260
+ const isPermit2 = to.toLowerCase() === PERMIT2;
261
+ const layout = CAP_LAYOUTS[selector];
262
+ const priced = layout && (layout.tokenIndex === undefined || isPermit2);
263
+ if (!priced) {
264
+ if (process.env.AGENTWALLET_ALLOW_UNKNOWN_TOKEN_CALLS === '1')
265
+ return;
266
+ const looksLikeToken = isPermit2 || (await probeTokenDecimals(chainId, to)) !== null;
267
+ if (looksLikeToken) {
268
+ throw new Error(`Blocked by local guard: calldata selector 0x${selector} sent to ` +
269
+ `${isPermit2 ? 'Permit2' : 'a token contract'} is not one AGENTWALLET_MAX_TX_TOKEN can price, ` +
270
+ `so it is refused rather than let through uncapped. Use transfer, transferFrom, approve, ` +
271
+ `increaseAllowance or Permit2 approve, or set AGENTWALLET_ALLOW_UNKNOWN_TOKEN_CALLS=1 to allow it deliberately.`);
272
+ }
273
+ return; // an ordinary contract call; it can only pull what an approval already allowed
274
+ }
275
+ const wordAt = (i) => hex.slice(8 + i * 64, 8 + (i + 1) * 64);
276
+ const word = wordAt(layout.amountIndex);
239
277
  if (word.length !== 64)
240
278
  return; // malformed; leave it to the node to reject
241
279
  const amount = BigInt('0x' + word);
280
+ let token = to;
281
+ if (layout.tokenIndex !== undefined) {
282
+ const tw = wordAt(layout.tokenIndex);
283
+ if (tw.length !== 64)
284
+ return;
285
+ token = ('0x' + tw.slice(24));
286
+ }
242
287
  const decimals = await resolveTokenDecimals(chainId, token);
243
288
  const [whole, frac = ''] = cap.split('.');
244
289
  const capRaw = BigInt(whole + frac.padEnd(decimals, '0').slice(0, decimals));
245
290
  if (amount > capRaw) {
246
- const what = selector === '095ea7b3' ? 'approval' : 'token transfer';
247
- throw new Error(`Blocked by local guard: ${what} of ${amount} base units exceeds ` +
291
+ throw new Error(`Blocked by local guard: ${layout.what} of ${amount} base units exceeds ` +
248
292
  `AGENTWALLET_MAX_TX_TOKEN (${cap}, evaluated at ${decimals} decimals). ` +
249
293
  `Raise the cap deliberately if this is intended.`);
250
294
  }
@@ -30,9 +30,17 @@
30
30
  * by an anonymous researcher, 2026-08-02 (the DNS rebinding race), and by
31
31
  * Arthur Kijkrittaya, 2026-09-08 (credentials surviving cross-origin redirects).
32
32
  */
33
+ /** Expand an IPv6 literal (any spelling Node's isIP accepts) into eight 16-bit groups. */
34
+ export declare function expandIPv6(hostRaw: string): number[] | null;
33
35
  /**
34
36
  * Reduce a host to the IP address it actually denotes.
35
- * Strips IPv6 brackets and unwraps IPv4-mapped and IPv4-compatible IPv6.
37
+ * Strips IPv6 brackets and unwraps every IPv6 form that embeds an IPv4
38
+ * address: IPv4-mapped (::ffff:a.b.c.d), IPv4-translated (::ffff:0:a.b.c.d)
39
+ * and the deprecated IPv4-compatible form (::a.b.c.d, also spelled
40
+ * ::7f00:1). The match is on the expanded groups, not on one spelling, so
41
+ * 0:0:0:0:0:0:7f00:1 and ::7F00:1 canonicalize the same way. The compatible
42
+ * form was reported as unclassified on 2026-09-25 (not reachable in practice,
43
+ * modern stacks refuse to route ::/96, but a guard should not depend on that).
36
44
  */
37
45
  export declare function canonicalizeHost(hostRaw: string): {
38
46
  ip: string | null;
@@ -79,9 +79,45 @@ const V4_BLOCKED = [
79
79
  '224.0.0.0/4', // multicast
80
80
  '240.0.0.0/4', // reserved, includes 255.255.255.255
81
81
  ];
82
+ /** Expand an IPv6 literal (any spelling Node's isIP accepts) into eight 16-bit groups. */
83
+ export function expandIPv6(hostRaw) {
84
+ let h = hostRaw.toLowerCase();
85
+ // A trailing dotted IPv4 (::ffff:1.2.3.4, ::1.2.3.4, 64:ff9b::1.2.3.4) is the last two groups.
86
+ const dotted = h.match(/^(.*:)(\d+\.\d+\.\d+\.\d+)$/);
87
+ if (dotted) {
88
+ const v4 = ipv4ToInt(dotted[2]);
89
+ if (v4 === null)
90
+ return null;
91
+ h = dotted[1] + (v4 >>> 16).toString(16) + ':' + (v4 & 0xffff).toString(16);
92
+ }
93
+ const halves = h.split('::');
94
+ if (halves.length > 2)
95
+ return null;
96
+ const head = halves[0] ? halves[0].split(':') : [];
97
+ const tail = halves.length === 2 && halves[1] ? halves[1].split(':') : [];
98
+ const missing = 8 - head.length - tail.length;
99
+ if (halves.length === 2 ? missing < 1 : missing !== 0)
100
+ return null;
101
+ const groups = [...head, ...Array(halves.length === 2 ? missing : 0).fill('0'), ...tail];
102
+ const out = [];
103
+ for (const g of groups) {
104
+ if (!/^[0-9a-f]{1,4}$/.test(g))
105
+ return null;
106
+ out.push(parseInt(g, 16));
107
+ }
108
+ return out;
109
+ }
110
+ const embeddedV4 = (hi, lo) => [(hi >> 8) & 255, hi & 255, (lo >> 8) & 255, lo & 255].join('.');
111
+ const zeroThrough = (g, n) => g.slice(0, n).every(x => x === 0);
82
112
  /**
83
113
  * Reduce a host to the IP address it actually denotes.
84
- * Strips IPv6 brackets and unwraps IPv4-mapped and IPv4-compatible IPv6.
114
+ * Strips IPv6 brackets and unwraps every IPv6 form that embeds an IPv4
115
+ * address: IPv4-mapped (::ffff:a.b.c.d), IPv4-translated (::ffff:0:a.b.c.d)
116
+ * and the deprecated IPv4-compatible form (::a.b.c.d, also spelled
117
+ * ::7f00:1). The match is on the expanded groups, not on one spelling, so
118
+ * 0:0:0:0:0:0:7f00:1 and ::7F00:1 canonicalize the same way. The compatible
119
+ * form was reported as unclassified on 2026-09-25 (not reachable in practice,
120
+ * modern stacks refuse to route ::/96, but a guard should not depend on that).
85
121
  */
86
122
  export function canonicalizeHost(hostRaw) {
87
123
  let host = hostRaw.trim().toLowerCase();
@@ -95,16 +131,15 @@ export function canonicalizeHost(hostRaw) {
95
131
  return { ip: null, family: 0 };
96
132
  if (fam === 4)
97
133
  return { ip: host, family: 4 };
98
- // IPv6: unwrap ::ffff:a.b.c.d and its hex spelling ::ffff:7f00:1
99
- const mapped = host.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/);
100
- if (mapped)
101
- return { ip: mapped[1], family: 4 };
102
- const hexMapped = host.match(/^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/);
103
- if (hexMapped) {
104
- const hi = parseInt(hexMapped[1], 16);
105
- const lo = parseInt(hexMapped[2], 16);
106
- return { ip: [(hi >> 8) & 255, hi & 255, (lo >> 8) & 255, lo & 255].join('.'), family: 4 };
107
- }
134
+ const g = expandIPv6(host);
135
+ if (!g)
136
+ return { ip: host, family: 6 };
137
+ if (zeroThrough(g, 5) && g[5] === 0xffff)
138
+ return { ip: embeddedV4(g[6], g[7]), family: 4 }; // ::ffff:a.b.c.d
139
+ if (zeroThrough(g, 4) && g[4] === 0xffff && g[5] === 0)
140
+ return { ip: embeddedV4(g[6], g[7]), family: 4 }; // ::ffff:0:a.b.c.d
141
+ if (zeroThrough(g, 6) && !(g[6] === 0 && g[7] <= 1))
142
+ return { ip: embeddedV4(g[6], g[7]), family: 4 }; // ::a.b.c.d, but not :: or ::1
108
143
  return { ip: host, family: 6 };
109
144
  }
110
145
  /** True when the address is loopback, private, link-local, multicast or reserved. */
@@ -114,17 +149,23 @@ export function isPrivateAddress(ipRaw) {
114
149
  return false;
115
150
  if (family === 4)
116
151
  return V4_BLOCKED.some(c => inV4Range(ip, c));
117
- const v6 = ip;
118
- if (v6 === '::' || v6 === '::1')
119
- return true; // unspecified, loopback
120
- if (/^f[cd][0-9a-f]{2}:/.test(v6))
152
+ const g = expandIPv6(ip);
153
+ if (!g)
154
+ return true; // isIP said IPv6 but it will not parse: refuse
155
+ if (zeroThrough(g, 7) && g[7] <= 1)
156
+ return true; // :: unspecified, ::1 loopback
157
+ if ((g[0] & 0xfe00) === 0xfc00)
121
158
  return true; // fc00::/7 unique-local
122
- if (/^fe[89ab][0-9a-f]:/.test(v6))
159
+ if ((g[0] & 0xffc0) === 0xfe80)
123
160
  return true; // fe80::/10 link-local
124
- if (/^ff[0-9a-f]{2}:/.test(v6))
161
+ if ((g[0] & 0xff00) === 0xff00)
125
162
  return true; // ff00::/8 multicast
126
- if (/^(64:ff9b|2002):/.test(v6))
127
- return true; // NAT64 / 6to4 translation
163
+ if (g[0] === 0x64 && g[1] === 0xff9b)
164
+ return true; // 64:ff9b::/96 NAT64
165
+ if (g[0] === 0x2002)
166
+ return true; // 2002::/16 6to4
167
+ if (g[0] === 0x2001 && g[1] === 0xdb8)
168
+ return true; // 2001:db8::/32 documentation
128
169
  return false;
129
170
  }
130
171
  /**
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "agentwallet-mcp",
3
3
  "mcpName": "io.github.hifriendbot/agentwallet",
4
- "version": "1.12.2",
4
+ "version": "1.12.3",
5
5
  "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.",
6
6
  "type": "module",
7
7
  "main": "build/index.js",