agentwallet-mcp 1.13.1 → 1.13.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
@@ -245,7 +245,7 @@ pay_x402(
245
245
 
246
246
  **Schemes.** `exact` (EIP-3009, gasless for the payer) is preferred and works on every EVM chain AgentWallet knows. `upto` (a Permit2 maximum the seller settles at actual usage) is signed as a `PermitWitnessTransferFrom` bound to the endpoint's facilitator; it needs a one-time `approve_permit2` per token (or `AGENTWALLET_PERMIT2_AUTO_APPROVE=1`). An `upto` option without `extra.facilitatorAddress` is refused, never approximated with an upfront transfer. Native-asset and Solana requests, and AgentWallet's own paywalls, are paid by an on-chain transfer proved by transaction hash, which is what those servers verify.
247
247
 
248
- **Approvals above the cap.** On a hosted wallet, a payment above the cap does not have to fail. With `request_approval` (default on; `AGENTWALLET_APPROVALS=0` turns it off) the wallet owner gets an email with approve and deny links, `pay_x402` returns an `approval_id`, and the agent retries with it once `check_approval` says approved. An approval is bound to one wallet, chain, asset, recipient and maximum, expires in 24 hours, and is consumed by one signature. Local mode has no approval channel: the caps in your environment are the policy.
248
+ **Approvals above the cap.** On a hosted wallet, a payment above the cap does not have to fail. With `request_approval` (default on; `AGENTWALLET_APPROVALS=0` turns it off) the wallet owner gets an email with approve and deny links, `pay_x402` returns an `approval_id`, and the agent retries with it once `check_approval` says approved. An approval is bound to one wallet, chain, asset, recipient and maximum, expires in 24 hours, and is consumed by one signature. Local mode has no approval channel: the caps in your environment are the policy, and an `approval_id` passed there is refused.
249
249
 
250
250
  **Asset risk.** `check_token_risk` (and every `approve_token` result) reports honeypot, tax, owner-power, verified-source, holder-concentration and liquidity flags from GoPlus Security, with an on-chain fallback. A warning for the caller to weigh, never a block.
251
251
 
package/build/index.js CHANGED
@@ -13,7 +13,7 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
13
13
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
14
14
  import { z } from 'zod';
15
15
  import { assertPublicUrl, safeFetch, FINAL_URL_HEADER } from './ssrf-guard.js';
16
- import { deriveX402Payment, isWithinCap, lookupTrustedDecimals, assertDeclaredDecimals, nativeDecimals, SOLANA_CHAIN_IDS, assetLabel, autopayAssetAllowed, autopayEnvCap, effectiveAutopayCap, maxAuthWindowSeconds, parseDecimalPins, } from './x402-payment.js';
16
+ import { deriveX402Payment, isWithinCap, lookupTrustedDecimals, assertDeclaredDecimals, nativeDecimals, SOLANA_CHAIN_IDS, assetLabel, autopayAssetAllowed, autopayEnvCap, effectiveAutopayCap, maxAuthWindowSeconds, parseDecimalPins, approvalRefusal, } from './x402-payment.js';
17
17
  import { isLocalMode, getLocalAddress, localWalletRecord, localSend, localNativeBalance, localTokenBalance, localSignMessage, resolveRpcUrl, } from './local-wallet.js';
18
18
  import { localSignAuthorization, localEthCall, localGetCode, localSignPermit2Upto } from './local-wallet.js';
19
19
  import { WRAPPED_NATIVE } from './wrapped-native.js';
@@ -561,7 +561,7 @@ function resolveChainId(network) {
561
561
  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');
562
562
  const server = new McpServer({
563
563
  name: 'agentwallet',
564
- version: '1.13.1',
564
+ version: '1.13.3',
565
565
  }, {
566
566
  instructions: `AgentWallet gives AI agents their own blockchain wallets. ${anyLocalMode()
567
567
  ? '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 AGENTWALLET_MAX_TX_* environment caps. There is no server-side pause, daily limit or approval email; pause_wallet, unpause_wallet and delete_wallet are refused.'
@@ -1370,13 +1370,14 @@ server.tool('pay_x402', 'Handle an x402 payment flow. Fetches a URL, and if the
1370
1370
  };
1371
1371
  const wantApproval = request_approval ?? (process.env.AGENTWALLET_APPROVALS !== '0');
1372
1372
  if (approval_id) {
1373
- const a = (await api(`/approvals/${encodeURIComponent(approval_id)}`, 'GET', undefined, skip));
1374
- if (a?.status !== 'approved' || Number(a.used) === 1) {
1375
- return jsonResponse({ ...overCap, approval_id, approval_status: a?.status ?? null, error: `Approval ${approval_id} is ${a?.status ?? 'unknown'}, not approved.${a?.error ? ' ' + a.error : ''}` });
1376
- }
1377
- const sameAsset = String(a.asset ?? '').toLowerCase() === tokenAddress.toLowerCase();
1378
- if (!sameAsset || String(a.pay_to || '').toLowerCase() !== option.payTo.toLowerCase() || Number(a.chain_id) !== chainId || BigInt(String(a.value || '0')) < BigInt(rawAmount)) {
1379
- return jsonResponse({ ...overCap, approval_id, error: 'Approval does not cover this payment (asset, recipient, chain or amount differ).' });
1373
+ // Local mode refuses before any lookup: only the hosted signer can consume
1374
+ // an approval, so here the same id would lift the cap on every call.
1375
+ const want = { walletId: wallet_id, chainId, asset: tokenAddress, payTo: option.payTo, rawAmount };
1376
+ const local = anyLocalMode();
1377
+ const a = local ? null : (await api(`/approvals/${encodeURIComponent(approval_id)}`, 'GET', undefined, skip));
1378
+ const refusal = approvalRefusal(approval_id, a, want, local);
1379
+ if (refusal) {
1380
+ return jsonResponse({ ...overCap, approval_id, ...refusal });
1380
1381
  }
1381
1382
  approvalUsed = String(approval_id);
1382
1383
  }
@@ -23,6 +23,16 @@ export declare function localSolBalance(chainId?: number): Promise<{
23
23
  balance: string;
24
24
  mode: string;
25
25
  }>;
26
+ /**
27
+ * Bytes the Associated Token Account program allocates for a new token account
28
+ * of this mint. Classic SPL is always 165. Token-2022 adds an account-type byte
29
+ * and one TLV entry per account extension: ImmutableOwner on every associated
30
+ * account, plus whatever the mint's own extensions require.
31
+ *
32
+ * The rent charged follows this size, so the SOL cap has to be checked against
33
+ * it. A flat 165 under-counted every Token-2022 account (2026-10-02 report).
34
+ */
35
+ export declare function associatedAccountSize(token2022: boolean, mintData: Uint8Array): number;
26
36
  export declare function localSplBalance(mintStr: string, chainId?: number): Promise<{
27
37
  address: string;
28
38
  chain_id: number;
@@ -222,14 +222,69 @@ export async function localSolBalance(chainId = 900) {
222
222
  mode: 'local',
223
223
  };
224
224
  }
225
- /** Which token program owns this mint: classic SPL or Token-2022. */
226
- async function tokenProgramFor(conn, mint) {
225
+ /** Which token program owns this mint, and how big a token account for it is. */
226
+ async function mintLayout(conn, mint) {
227
227
  const info = await conn.getAccountInfo(mint);
228
228
  if (!info)
229
229
  throw new Error(`Mint ${mint.toBase58()} not found on this cluster.`);
230
- if (info.owner.equals(TOKEN_2022_PROGRAM))
231
- return TOKEN_2022_PROGRAM;
232
- return TOKEN_PROGRAM;
230
+ const token2022 = info.owner.equals(TOKEN_2022_PROGRAM);
231
+ return {
232
+ tokenProgram: token2022 ? TOKEN_2022_PROGRAM : TOKEN_PROGRAM,
233
+ ataSize: associatedAccountSize(token2022, info.data),
234
+ };
235
+ }
236
+ /** Which token program owns this mint: classic SPL or Token-2022. */
237
+ async function tokenProgramFor(conn, mint) {
238
+ return (await mintLayout(conn, mint)).tokenProgram;
239
+ }
240
+ /* Token-2022 mint extensions that make every token account for the mint carry
241
+ an extension of its own, and the size of that account extension's value.
242
+ Mirrors required_init_account_extensions in the token-2022 program. */
243
+ const ACCOUNT_EXTENSION_FOR_MINT_EXTENSION = {
244
+ 1: 8, // TransferFeeConfig -> TransferFeeAmount (withheld amount, u64)
245
+ 9: 0, // NonTransferable -> NonTransferableAccount (plus ImmutableOwner, always present below)
246
+ 14: 1, // TransferHook -> TransferHookAccount (one flag byte)
247
+ 26: 0, // Pausable -> PausableAccount
248
+ };
249
+ /** Highest extension type this table was written against (PermissionedBurn). */
250
+ const HIGHEST_KNOWN_EXTENSION = 28;
251
+ /** Charged for a mint extension newer than the table, so the estimate errs high, never low. */
252
+ const UNKNOWN_EXTENSION_ALLOWANCE = 64;
253
+ const TLV_HEADER = 4; // u16 type + u16 length
254
+ const MULTISIG_SIZE = 355; // the program pads an account that would collide with a multisig's size
255
+ /**
256
+ * Bytes the Associated Token Account program allocates for a new token account
257
+ * of this mint. Classic SPL is always 165. Token-2022 adds an account-type byte
258
+ * and one TLV entry per account extension: ImmutableOwner on every associated
259
+ * account, plus whatever the mint's own extensions require.
260
+ *
261
+ * The rent charged follows this size, so the SOL cap has to be checked against
262
+ * it. A flat 165 under-counted every Token-2022 account (2026-10-02 report).
263
+ */
264
+ export function associatedAccountSize(token2022, mintData) {
265
+ if (!token2022)
266
+ return TOKEN_ACCOUNT_SIZE;
267
+ let tlvBytes = TLV_HEADER; // ImmutableOwner, zero-length value
268
+ const seen = new Set();
269
+ // Mint layout: 82 bytes of base state padded to 165, the account-type byte, then TLV entries.
270
+ let offset = TOKEN_ACCOUNT_SIZE + 1;
271
+ while (offset + TLV_HEADER <= mintData.length) {
272
+ const type = mintData[offset] | (mintData[offset + 1] << 8);
273
+ const length = mintData[offset + 2] | (mintData[offset + 3] << 8);
274
+ if (type === 0)
275
+ break; // uninitialized: the rest is padding
276
+ if (!seen.has(type)) {
277
+ seen.add(type);
278
+ const accountValue = ACCOUNT_EXTENSION_FOR_MINT_EXTENSION[type];
279
+ if (accountValue !== undefined)
280
+ tlvBytes += TLV_HEADER + accountValue;
281
+ else if (type > HIGHEST_KNOWN_EXTENSION)
282
+ tlvBytes += TLV_HEADER + UNKNOWN_EXTENSION_ALLOWANCE;
283
+ }
284
+ offset += TLV_HEADER + length;
285
+ }
286
+ const size = TOKEN_ACCOUNT_SIZE + 1 + tlvBytes;
287
+ return size === MULTISIG_SIZE ? size + 2 : size;
233
288
  }
234
289
  function associatedTokenAddress(owner, mint, tokenProgram) {
235
290
  const [ata] = PublicKey.findProgramAddressSync([owner.toBuffer(), tokenProgram.toBuffer(), mint.toBuffer()], ASSOCIATED_TOKEN_PROGRAM);
@@ -327,7 +382,7 @@ export async function localSplTransfer(mintStr, to, rawAmount, decimals, chainId
327
382
  const mint = new PublicKey(mintStr);
328
383
  const recipient = new PublicKey(to);
329
384
  await assertRecipientIsWallet(conn, recipient);
330
- const tokenProgram = await tokenProgramFor(conn, mint);
385
+ const { tokenProgram, ataSize } = await mintLayout(conn, mint);
331
386
  // The instruction's decimals byte is the mint's real value, not whatever the
332
387
  // caller passed; TransferChecked would reject a wrong one on-chain anyway.
333
388
  decimals = await resolveMintDecimals(mintStr, chainId);
@@ -338,7 +393,8 @@ export async function localSplTransfer(mintStr, to, rawAmount, decimals, chainId
338
393
  if (!destInfo) {
339
394
  // Creating the recipient's token account costs the payer rent in SOL, an
340
395
  // outflow the SOL cap must see (0.002 SOL a time adds up over many sends).
341
- const rent = await conn.getMinimumBalanceForRentExemption(TOKEN_ACCOUNT_SIZE);
396
+ // Rent follows the account's real size, which for Token-2022 is above 165.
397
+ const rent = await conn.getMinimumBalanceForRentExemption(ataSize);
342
398
  assertWithinSolCap(BigInt(rent));
343
399
  tx.add(createAtaIdempotentIx(payer.publicKey, destAta, recipient, mint, tokenProgram));
344
400
  }
@@ -355,10 +411,19 @@ export async function localSplTransfer(mintStr, to, rawAmount, decimals, chainId
355
411
  };
356
412
  }
357
413
  const TOKEN_ACCOUNT_SIZE = 165;
414
+ const MINT_SIZE = 82;
415
+ /** Token-2022 pads a mint to the token-account size and tags the next byte: 1 = mint, 2 = token account. */
416
+ const TOKEN_2022_TYPE_MINT = 1;
358
417
  /**
359
418
  * A recipient must be something that can own a token account: a wallet or a
360
- * program-derived wallet (Squads and friends), never a program and never a
361
- * token account. SPL sent to ATA(ATA) or to a program id is gone for good.
419
+ * program-derived wallet (Squads and friends), never a program and never an
420
+ * account the token programs own. SPL sent to ATA(ATA), ATA(mint) or a program
421
+ * id is gone for good, or belongs to whoever kept the mint's keypair.
422
+ *
423
+ * Ownership alone decides. A length gate (>= 165 bytes) used to sit here and
424
+ * let an 82-byte mint through as a "wallet" (2026-10-02 report): everything
425
+ * the token programs own is a mint, a token account or a multisig, and none
426
+ * of those is a wallet.
362
427
  */
363
428
  async function assertRecipientIsWallet(conn, recipient) {
364
429
  const info = await conn.getAccountInfo(recipient);
@@ -366,8 +431,16 @@ async function assertRecipientIsWallet(conn, recipient) {
366
431
  return; // never seen on chain: an ordinary fresh wallet
367
432
  if (info.executable)
368
433
  throw new Error(`Refusing to send to ${recipient.toBase58()}: it is a program, not a wallet.`);
369
- if ((info.owner.equals(TOKEN_PROGRAM) || info.owner.equals(TOKEN_2022_PROGRAM)) && info.data.length >= TOKEN_ACCOUNT_SIZE) {
370
- throw new Error(`Refusing to send to ${recipient.toBase58()}: it is a token account, not a wallet. Pass the owner's wallet address instead.`);
434
+ if (info.owner.equals(TOKEN_PROGRAM) || info.owner.equals(TOKEN_2022_PROGRAM)) {
435
+ const isMint = info.data.length === MINT_SIZE
436
+ || (info.data.length > TOKEN_ACCOUNT_SIZE && info.data[TOKEN_ACCOUNT_SIZE] === TOKEN_2022_TYPE_MINT);
437
+ if (isMint) {
438
+ throw new Error(`Refusing to send to ${recipient.toBase58()}: it is a token mint, not a wallet. Pass the recipient's wallet address; the mint belongs in token_mint.`);
439
+ }
440
+ if (info.data.length >= TOKEN_ACCOUNT_SIZE) {
441
+ throw new Error(`Refusing to send to ${recipient.toBase58()}: it is a token account, not a wallet. Pass the owner's wallet address instead.`);
442
+ }
443
+ throw new Error(`Refusing to send to ${recipient.toBase58()}: it is an account owned by the SPL token program, not a wallet.`);
371
444
  }
372
445
  }
373
446
  export function solanaWalletRecord() {
@@ -163,3 +163,35 @@ export declare const SOLANA_CHAIN_IDS: ReadonlySet<number>;
163
163
  */
164
164
  export declare function nativeDecimals(chainId: number): number;
165
165
  export declare function isWithinCap(rawAmountRequired: string, trustedDecimals: number, capHuman: string): boolean;
166
+ /** The row GET /approvals/{id} answers with. Numbers arrive as strings from the server. */
167
+ export interface ApprovalRow {
168
+ status?: string;
169
+ used?: number | string;
170
+ wallet_id?: number | string;
171
+ chain_id?: number | string;
172
+ asset?: string;
173
+ pay_to?: string;
174
+ value?: string;
175
+ error?: string;
176
+ }
177
+ export interface ApprovalWant {
178
+ walletId: number;
179
+ chainId: number;
180
+ asset: string;
181
+ payTo: string;
182
+ rawAmount: string;
183
+ }
184
+ /**
185
+ * Why an approval cannot stand in for the cap on this payment, or null when it can.
186
+ *
187
+ * An approval is single-use because the hosted signer marks it used in the same
188
+ * request that signs. A local signer never talks to that server, so in local
189
+ * mode nothing could ever consume one and the same id would lift the cap again
190
+ * on every call (2026-10-02 report). Local mode therefore refuses approvals
191
+ * outright, before any lookup. For a hosted wallet every bound field is checked
192
+ * here, the wallet included, and the server checks them again when it signs.
193
+ */
194
+ export declare function approvalRefusal(approvalId: string, row: ApprovalRow | null | undefined, want: ApprovalWant, localMode: boolean): {
195
+ error: string;
196
+ approval_status?: string | null;
197
+ } | null;
@@ -324,3 +324,37 @@ export function isWithinCap(rawAmountRequired, trustedDecimals, capHuman) {
324
324
  const capRaw = toBaseUnits(capHuman, trustedDecimals);
325
325
  return BigInt(rawAmountRequired) <= BigInt(capRaw);
326
326
  }
327
+ /**
328
+ * Why an approval cannot stand in for the cap on this payment, or null when it can.
329
+ *
330
+ * An approval is single-use because the hosted signer marks it used in the same
331
+ * request that signs. A local signer never talks to that server, so in local
332
+ * mode nothing could ever consume one and the same id would lift the cap again
333
+ * on every call (2026-10-02 report). Local mode therefore refuses approvals
334
+ * outright, before any lookup. For a hosted wallet every bound field is checked
335
+ * here, the wallet included, and the server checks them again when it signs.
336
+ */
337
+ export function approvalRefusal(approvalId, row, want, localMode) {
338
+ if (localMode) {
339
+ return {
340
+ error: `Approval ${approvalId} cannot be used in local mode: approvals are consumed by the hosted signer, and this server signs with your own key, so nothing could mark it used. Local mode has no approval channel: raise AGENTWALLET_MAX_AUTOPAY or AGENTWALLET_MAX_TX_TOKEN yourself.`,
341
+ };
342
+ }
343
+ const status = row?.status ?? null;
344
+ // "used" must read as exactly 0. A missing or unparseable field refuses rather than passes.
345
+ if (status !== 'approved' || Number(row?.used) !== 0) {
346
+ const state = status === 'approved' ? 'already used' : `${status ?? 'unknown'}, not approved`;
347
+ return { approval_status: status, error: `Approval ${approvalId} is ${state}.${row?.error ? ' ' + row.error : ''}` };
348
+ }
349
+ const value = String(row?.value ?? '');
350
+ const covers = Number(row?.wallet_id) === want.walletId
351
+ && Number(row?.chain_id) === want.chainId
352
+ && String(row?.asset ?? '').toLowerCase() === want.asset.toLowerCase()
353
+ && String(row?.pay_to ?? '').toLowerCase() === want.payTo.toLowerCase()
354
+ && /^\d+$/.test(value) && /^\d+$/.test(want.rawAmount)
355
+ && BigInt(value) >= BigInt(want.rawAmount);
356
+ if (!covers) {
357
+ return { error: 'Approval does not cover this payment (wallet, asset, recipient, chain or amount differ).' };
358
+ }
359
+ return null;
360
+ }
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.13.1",
4
+ "version": "1.13.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",
@@ -16,7 +16,7 @@
16
16
  "scripts": {
17
17
  "build": "tsc",
18
18
  "start": "node build/index.js",
19
- "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",
19
+ "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",
20
20
  "prepublishOnly": "npm run build"
21
21
  },
22
22
  "keywords": [