agentwallet-mcp 1.10.1 → 1.10.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 +3 -1
- package/build/index.js +83 -10
- package/build/local-solana.js +37 -0
- package/build/local-wallet.d.ts +0 -4
- package/build/local-wallet.js +46 -0
- package/build/x402-payment.d.ts +43 -7
- package/build/x402-payment.js +100 -11
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -41,7 +41,9 @@ Use `AGENTWALLET_KEYFILE=/path/to/key` instead if you would rather keep the key
|
|
|
41
41
|
|
|
42
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.
|
|
43
43
|
|
|
44
|
-
`AGENTWALLET_MAX_TX_NATIVE` is a per-transaction ceiling in native units. In hosted mode the server enforces limits; in local mode there is no server, so
|
|
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
|
+
|
|
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.
|
|
45
47
|
|
|
46
48
|
### Solana local signing
|
|
47
49
|
|
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 } from './ssrf-guard.js';
|
|
16
|
-
import { deriveX402Payment, isWithinCap } from './x402-payment.js';
|
|
16
|
+
import { deriveX402Payment, isWithinCap, lookupTrustedDecimals, assertDeclaredDecimals, } from './x402-payment.js';
|
|
17
17
|
import { isLocalMode, getLocalAddress, localWalletRecord, localSend, localNativeBalance, localTokenBalance, localSignMessage, resolveRpcUrl, } from './local-wallet.js';
|
|
18
18
|
import { isSolanaLocalMode, getSolanaAddress, solanaWalletRecord, localSolBalance, localSplBalance, localSolTransfer, localSplTransfer, resolveSolanaRpc, } from './local-solana.js';
|
|
19
19
|
/** True when either chain family is running non-custodially. */
|
|
@@ -187,6 +187,53 @@ async function api(path, method = 'GET', body, extraHeaders) {
|
|
|
187
187
|
}
|
|
188
188
|
return data;
|
|
189
189
|
}
|
|
190
|
+
/**
|
|
191
|
+
* Resolve an asset's decimals from a source the paying party controls, never
|
|
192
|
+
* from the 402 response.
|
|
193
|
+
*
|
|
194
|
+
* Order: known-asset registry first (offline, works in local self-custody mode
|
|
195
|
+
* where there may be no reachable API), then the token contract's own
|
|
196
|
+
* decimals() via eth_call. If neither answers we refuse rather than guess,
|
|
197
|
+
* because guessing is exactly what let a hostile endpoint inflate the cap.
|
|
198
|
+
*/
|
|
199
|
+
const decimalsCache = new Map();
|
|
200
|
+
async function resolveTrustedDecimals(chainId, token) {
|
|
201
|
+
const key = `${chainId}:${token.toLowerCase()}`;
|
|
202
|
+
const cached = decimalsCache.get(key);
|
|
203
|
+
if (typeof cached === 'number')
|
|
204
|
+
return cached;
|
|
205
|
+
const known = lookupTrustedDecimals(chainId, token);
|
|
206
|
+
if (known !== null) {
|
|
207
|
+
decimalsCache.set(key, known);
|
|
208
|
+
return known;
|
|
209
|
+
}
|
|
210
|
+
if (isSolanaChain(chainId)) {
|
|
211
|
+
throw new Error(`x402 blocked: cannot verify decimals for SPL mint ${token} on chain ${chainId}. ` +
|
|
212
|
+
`Unknown mints are refused because the payment cap cannot be checked without trusted decimals.`);
|
|
213
|
+
}
|
|
214
|
+
// decimals() selector
|
|
215
|
+
let onChain = null;
|
|
216
|
+
try {
|
|
217
|
+
const r = (await api('/eth-call', 'POST', {
|
|
218
|
+
chain_id: chainId, to: token, data: '0x313ce567',
|
|
219
|
+
}, { 'X-AGW-SKIP-X402': 'true' }));
|
|
220
|
+
const hex = (r?.result || '').replace(/^0x/, '');
|
|
221
|
+
if (hex && /^[0-9a-fA-F]+$/.test(hex)) {
|
|
222
|
+
const n = parseInt(hex, 16);
|
|
223
|
+
if (Number.isInteger(n) && n >= 0 && n <= 36)
|
|
224
|
+
onChain = n;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
catch {
|
|
228
|
+
onChain = null; // fall through to the refusal below
|
|
229
|
+
}
|
|
230
|
+
if (onChain === null) {
|
|
231
|
+
throw new Error(`x402 blocked: could not resolve on-chain decimals for ${token} on chain ${chainId}. ` +
|
|
232
|
+
`The payment cap cannot be enforced without them, so the payment was refused.`);
|
|
233
|
+
}
|
|
234
|
+
decimalsCache.set(key, onChain);
|
|
235
|
+
return onChain;
|
|
236
|
+
}
|
|
190
237
|
/**
|
|
191
238
|
* Handle x402 auto-payment: pay on-chain, then retry the original request
|
|
192
239
|
*/
|
|
@@ -208,7 +255,11 @@ async function handleX402Payment(x402Data, originalPath, originalMethod, origina
|
|
|
208
255
|
// 402 response cannot drain the wallet. AGENTWALLET_MAX_AUTOPAY is in
|
|
209
256
|
// human-readable units of the asset; default 1.
|
|
210
257
|
const maxAutopay = process.env.AGENTWALLET_MAX_AUTOPAY || '1';
|
|
211
|
-
const
|
|
258
|
+
const assetForDecimals = accept.asset || accept.extra?.token || '';
|
|
259
|
+
const trustedDecimals = assetForDecimals
|
|
260
|
+
? await resolveTrustedDecimals(chainId, assetForDecimals)
|
|
261
|
+
: 18; // native asset: value is already in wei-equivalent base units
|
|
262
|
+
const { tokenAddress, rawAmount, decimals } = deriveX402Payment(accept, maxAutopay, trustedDecimals);
|
|
212
263
|
// Send payment using our wallet (with X-AGW-SKIP-X402 to prevent recursion)
|
|
213
264
|
let txHash;
|
|
214
265
|
try {
|
|
@@ -359,6 +410,18 @@ function resolveChainId(network) {
|
|
|
359
410
|
return null;
|
|
360
411
|
}
|
|
361
412
|
// ─── Server ──────────────────────────────────────────────────────
|
|
413
|
+
/**
|
|
414
|
+
* Destination address shape, EVM or Solana.
|
|
415
|
+
*
|
|
416
|
+
* The contract-address params were regex-guarded but the DESTINATION on every
|
|
417
|
+
* transfer tool was a bare z.string(), so a truncated or mistyped address went
|
|
418
|
+
* straight into a signed transaction. On-chain sends are irreversible, and the
|
|
419
|
+
* chain will happily accept any well-formed address, so the cheapest place to
|
|
420
|
+
* catch a malformed one is before it is signed. This does not validate the
|
|
421
|
+
* EIP-55 checksum, only the shape; a wrong-but-well-formed address is still the
|
|
422
|
+
* caller's responsibility.
|
|
423
|
+
*/
|
|
424
|
+
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');
|
|
362
425
|
const server = new McpServer({
|
|
363
426
|
name: 'agentwallet',
|
|
364
427
|
version: '1.10.1',
|
|
@@ -477,7 +540,7 @@ server.tool('sign_transaction', 'Sign a transaction with a wallet\'s private key
|
|
|
477
540
|
'For EVM: returns signed raw transaction hex. For Solana: returns base64 signed transaction. ' +
|
|
478
541
|
'Does NOT broadcast — use send_transaction for sign + broadcast.', {
|
|
479
542
|
wallet_id: z.number().int().describe('Wallet ID'),
|
|
480
|
-
to:
|
|
543
|
+
to: AddressSchema.describe('Destination address (0x-prefixed for EVM, Base58 for Solana)'),
|
|
481
544
|
chain_id: z.number().int().optional().describe('Chain ID (defaults to wallet\'s default)'),
|
|
482
545
|
value: z.string().default('0').describe('Value in wei/lamports (decimal string)'),
|
|
483
546
|
data: z.string().default('').describe('Hex-encoded calldata (0x-prefixed) for EVM contract calls'),
|
|
@@ -519,7 +582,7 @@ server.tool('send_transaction', 'Sign and broadcast a transaction. ' +
|
|
|
519
582
|
'Returns the transaction hash (EVM) or signature (Solana) on success. ' +
|
|
520
583
|
'The transaction is signed server-side and broadcast via RPC.', {
|
|
521
584
|
wallet_id: z.number().int().describe('Wallet ID'),
|
|
522
|
-
to:
|
|
585
|
+
to: AddressSchema.describe('Destination address (0x-prefixed for EVM, Base58 for Solana)'),
|
|
523
586
|
chain_id: z.number().int().optional().describe('Chain ID (defaults to wallet\'s default)'),
|
|
524
587
|
value: z.string().default('0').describe('Value in wei/lamports (decimal string)'),
|
|
525
588
|
data: z.string().default('').describe('Hex-encoded calldata (0x-prefixed) for EVM contract calls'),
|
|
@@ -561,7 +624,7 @@ server.tool('transfer', 'Send native tokens (ETH, AVAX, BNB, POL, PLS, SOL) to a
|
|
|
561
624
|
'Specify the amount in human-readable format (e.g. "0.1" for 0.1 ETH). ' +
|
|
562
625
|
'The amount is converted to wei/lamports automatically. Signs and broadcasts the transaction.', {
|
|
563
626
|
wallet_id: z.number().int().describe('Wallet ID to send from'),
|
|
564
|
-
to:
|
|
627
|
+
to: AddressSchema.describe('Destination address (0x-prefixed for EVM, Base58 for Solana)'),
|
|
565
628
|
amount: z.string().describe('Amount to send in human-readable format (e.g. "0.1" for 0.1 ETH)'),
|
|
566
629
|
chain_id: z.number().int().describe('Chain ID (1=Ethereum, 8453=Base, 42161=Arbitrum, 10=Optimism, 137=Polygon, 43114=Avalanche, 56=BSC, 7777777=Zora, 369=PulseChain, 900=Solana, 901=Solana Devnet)'),
|
|
567
630
|
}, async ({ wallet_id, to, amount, chain_id }) => {
|
|
@@ -624,7 +687,7 @@ server.tool('transfer_token', 'Send ERC-20 tokens (EVM) or SPL tokens (Solana) t
|
|
|
624
687
|
'Signs and broadcasts the transaction. Use get_chains to find stablecoin addresses.', {
|
|
625
688
|
wallet_id: z.number().int().describe('Wallet ID to send from'),
|
|
626
689
|
token: z.string().describe('Token address (0x-prefixed ERC-20 contract for EVM, Base58 mint for Solana)'),
|
|
627
|
-
to:
|
|
690
|
+
to: AddressSchema.describe('Recipient address (0x-prefixed for EVM, Base58 for Solana)'),
|
|
628
691
|
amount: z.string().describe('Amount in human-readable format (e.g. "100" for 100 USDC)'),
|
|
629
692
|
chain_id: z.number().int().describe('Chain ID'),
|
|
630
693
|
decimals: z.number().int().default(18).describe('Token decimals (6 for USDC, 18 for most tokens)'),
|
|
@@ -958,8 +1021,18 @@ server.tool('pay_x402', 'Handle an x402 payment flow. Fetches a URL, and if the
|
|
|
958
1021
|
throw new Error(`Unsupported x402 network: "${option.network}". ` +
|
|
959
1022
|
`Supported: ${Object.keys(X402_NETWORKS).join(', ')}, or any CAIP-2 / numeric chain ID.`);
|
|
960
1023
|
}
|
|
961
|
-
//
|
|
962
|
-
|
|
1024
|
+
// Decimals come from the registry or the token contract, NEVER from the 402
|
|
1025
|
+
// body. The endpoint controls requiredDecimals, so trusting it lets a
|
|
1026
|
+
// hostile server inflate the cap AND misreport the amount shown to the
|
|
1027
|
+
// agent. The declared value is only used to detect a mismatch and refuse.
|
|
1028
|
+
const assetForDecimals = option.asset || option.extra?.token || '';
|
|
1029
|
+
const trustedDecimals = assetForDecimals
|
|
1030
|
+
? await resolveTrustedDecimals(chainId, assetForDecimals)
|
|
1031
|
+
: 18;
|
|
1032
|
+
assertDeclaredDecimals(option.requiredDecimals, trustedDecimals);
|
|
1033
|
+
// Human-readable amount, computed with trusted decimals so what the agent
|
|
1034
|
+
// is told matches what will actually leave the wallet.
|
|
1035
|
+
const amount = formatUnits(option.maxAmountRequired, trustedDecimals);
|
|
963
1036
|
// Step 4: Enforce a hard per-payment cap. ALWAYS applied — if the caller
|
|
964
1037
|
// omits max_payment we fall back to AGENTWALLET_MAX_AUTOPAY (default "1"),
|
|
965
1038
|
// mirroring the internal auto-pay path (deriveX402Payment). Without this, an
|
|
@@ -967,7 +1040,7 @@ server.tool('pay_x402', 'Handle an x402 payment flow. Fetches a URL, and if the
|
|
|
967
1040
|
// maxAmountRequired and drain the wallet. The cap is never silently skipped.
|
|
968
1041
|
const capSource = max_payment ? 'max_payment' : 'AGENTWALLET_MAX_AUTOPAY';
|
|
969
1042
|
const effectiveMax = max_payment || process.env.AGENTWALLET_MAX_AUTOPAY || '1';
|
|
970
|
-
if (!isWithinCap(option.maxAmountRequired,
|
|
1043
|
+
if (!isWithinCap(option.maxAmountRequired, trustedDecimals, effectiveMax)) {
|
|
971
1044
|
return jsonResponse({
|
|
972
1045
|
status: 402,
|
|
973
1046
|
payment_required: true,
|
|
@@ -999,7 +1072,7 @@ server.tool('pay_x402', 'Handle an x402 payment flow. Fetches a URL, and if the
|
|
|
999
1072
|
to: option.payTo,
|
|
1000
1073
|
value: option.maxAmountRequired,
|
|
1001
1074
|
token_mint: tokenAddress,
|
|
1002
|
-
token_decimals:
|
|
1075
|
+
token_decimals: trustedDecimals,
|
|
1003
1076
|
chain_id: chainId,
|
|
1004
1077
|
}));
|
|
1005
1078
|
}
|
package/build/local-solana.js
CHANGED
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
import { readFileSync } from 'node:fs';
|
|
15
15
|
import { Connection, Keypair, PublicKey, SystemProgram, Transaction, TransactionInstruction, sendAndConfirmTransaction, LAMPORTS_PER_SOL, } from '@solana/web3.js';
|
|
16
16
|
import bs58 from 'bs58';
|
|
17
|
+
import { lookupTrustedDecimals } from './x402-payment.js';
|
|
17
18
|
/* ── Program IDs ─────────────────────────────────────────────────── */
|
|
18
19
|
const TOKEN_PROGRAM = new PublicKey('TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA');
|
|
19
20
|
const TOKEN_2022_PROGRAM = new PublicKey('TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb');
|
|
@@ -114,6 +115,34 @@ function assertWithinSolCap(lamports) {
|
|
|
114
115
|
throw new Error(`Blocked by local guard: amount exceeds AGENTWALLET_MAX_TX_SOL (${cap} SOL).`);
|
|
115
116
|
}
|
|
116
117
|
}
|
|
118
|
+
/**
|
|
119
|
+
* Bound SPL movement. AGENTWALLET_MAX_TX_SOL only ever covered native SOL, so
|
|
120
|
+
* an SPL transfer, which is how USDC moves on Solana and the whole point of the
|
|
121
|
+
* x402 rail here, went out with no local ceiling at all. Mirrors
|
|
122
|
+
* AGENTWALLET_MAX_TX_TOKEN on the EVM side and uses the same variable so an
|
|
123
|
+
* operator sets one token cap, not one per chain family.
|
|
124
|
+
*
|
|
125
|
+
* Decimals for the cap come from the trusted table when the mint is known.
|
|
126
|
+
* The caller-supplied value is not used for the ceiling: an inflated one would
|
|
127
|
+
* inflate the cap exactly like the x402 bypass. When the mint is unknown we
|
|
128
|
+
* assume 6, the tightest common value, so it fails closed.
|
|
129
|
+
*/
|
|
130
|
+
function assertWithinSplCap(mint, rawAmount) {
|
|
131
|
+
const cap = (process.env.AGENTWALLET_MAX_TX_TOKEN || '').trim();
|
|
132
|
+
if (!cap)
|
|
133
|
+
return;
|
|
134
|
+
if (!/^\d+(\.\d+)?$/.test(cap)) {
|
|
135
|
+
throw new Error(`AGENTWALLET_MAX_TX_TOKEN must be a decimal number, got "${cap}".`);
|
|
136
|
+
}
|
|
137
|
+
const decimals = lookupTrustedDecimals(900, mint) ?? 6;
|
|
138
|
+
const [whole, frac = ''] = cap.split('.');
|
|
139
|
+
const capRaw = BigInt(whole + frac.padEnd(decimals, '0').slice(0, decimals));
|
|
140
|
+
if (rawAmount > capRaw) {
|
|
141
|
+
throw new Error(`Blocked by local guard: SPL transfer of ${rawAmount} base units exceeds ` +
|
|
142
|
+
`AGENTWALLET_MAX_TX_TOKEN (${cap}, evaluated at ${decimals} decimals). ` +
|
|
143
|
+
`Raise the cap deliberately if this is intended.`);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
117
146
|
/* ── Reads ───────────────────────────────────────────────────────── */
|
|
118
147
|
export async function localSolBalance(chainId = 900) {
|
|
119
148
|
const conn = connection(chainId);
|
|
@@ -161,6 +190,11 @@ export async function localSplBalance(mintStr, chainId = 900) {
|
|
|
161
190
|
export async function localSolTransfer(to, lamports, chainId = 900) {
|
|
162
191
|
const amount = BigInt(lamports);
|
|
163
192
|
assertWithinSolCap(amount);
|
|
193
|
+
// Number() silently loses precision past 2**53 lamports. Refuse rather than
|
|
194
|
+
// sign a transaction for an amount other than the one that was requested.
|
|
195
|
+
if (amount > BigInt(Number.MAX_SAFE_INTEGER)) {
|
|
196
|
+
throw new Error(`Amount ${lamports} lamports exceeds safe integer range and was refused.`);
|
|
197
|
+
}
|
|
164
198
|
const conn = connection(chainId);
|
|
165
199
|
const payer = getSolanaKeypair();
|
|
166
200
|
const tx = new Transaction().add(SystemProgram.transfer({
|
|
@@ -216,6 +250,9 @@ function createAtaIdempotentIx(payer, ata, owner, mint, tokenProgram) {
|
|
|
216
250
|
* not exist, using the idempotent instruction so a race cannot fail the send.
|
|
217
251
|
*/
|
|
218
252
|
export async function localSplTransfer(mintStr, to, rawAmount, decimals, chainId = 900) {
|
|
253
|
+
// Checked before any RPC round trip: a blocked transfer should cost nothing
|
|
254
|
+
// and should not announce itself to the RPC provider first.
|
|
255
|
+
assertWithinSplCap(mintStr, BigInt(rawAmount));
|
|
219
256
|
const conn = connection(chainId);
|
|
220
257
|
const payer = getSolanaKeypair();
|
|
221
258
|
const mint = new PublicKey(mintStr);
|
package/build/local-wallet.d.ts
CHANGED
|
@@ -64,10 +64,6 @@ export interface LocalSendResult {
|
|
|
64
64
|
mode: 'local';
|
|
65
65
|
signed_locally: true;
|
|
66
66
|
}
|
|
67
|
-
/**
|
|
68
|
-
* Sign and broadcast a transaction. viem fills nonce, gas and EIP-1559 fees
|
|
69
|
-
* from the RPC unless they are supplied.
|
|
70
|
-
*/
|
|
71
67
|
export declare function localSend(chainId: number, to: Address, valueWei: bigint, data?: Hex): Promise<LocalSendResult>;
|
|
72
68
|
/** ERC-20 transfer, encoded locally. */
|
|
73
69
|
export declare function localTransferToken(chainId: number, token: Address, to: Address, rawAmount: string): Promise<LocalSendResult>;
|
package/build/local-wallet.js
CHANGED
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
import { readFileSync } from 'node:fs';
|
|
18
18
|
import { createPublicClient, createWalletClient, http, encodeFunctionData, parseAbi, formatUnits, } from 'viem';
|
|
19
19
|
import { privateKeyToAccount } from 'viem/accounts';
|
|
20
|
+
import { lookupTrustedDecimals } from './x402-payment.js';
|
|
20
21
|
/* ── Key loading ─────────────────────────────────────────────────── */
|
|
21
22
|
/**
|
|
22
23
|
* Read the key once at startup. Kept module-private: nothing exports the key
|
|
@@ -166,8 +167,53 @@ export async function localTokenBalance(chainId, token) {
|
|
|
166
167
|
* Sign and broadcast a transaction. viem fills nonce, gas and EIP-1559 fees
|
|
167
168
|
* from the RPC unless they are supplied.
|
|
168
169
|
*/
|
|
170
|
+
/**
|
|
171
|
+
* Bound ERC-20 movement, which the native cap does not see.
|
|
172
|
+
*
|
|
173
|
+
* A token transfer is `to = tokenContract, value = 0, data = transfer(...)`, so
|
|
174
|
+
* assertWithinNativeCap always passed it. In local mode there is no server-side
|
|
175
|
+
* limit behind this, and the README tells operators the local guard is the only
|
|
176
|
+
* one they have, so a wallet holding USDC was effectively uncapped. Covers
|
|
177
|
+
* transfer, transferFrom and approve, since an unbounded approval is a drain
|
|
178
|
+
* waiting to happen.
|
|
179
|
+
*
|
|
180
|
+
* Opt-in via AGENTWALLET_MAX_TX_TOKEN, expressed in human units of the token.
|
|
181
|
+
* When the token's decimals are not known locally we assume 6, the smallest in
|
|
182
|
+
* common use, which makes the raw ceiling the tightest one and fails closed
|
|
183
|
+
* rather than open.
|
|
184
|
+
*/
|
|
185
|
+
function assertWithinTokenCap(chainId, token, data) {
|
|
186
|
+
const cap = (process.env.AGENTWALLET_MAX_TX_TOKEN || '').trim();
|
|
187
|
+
if (!cap)
|
|
188
|
+
return;
|
|
189
|
+
if (!/^\d+(\.\d+)?$/.test(cap)) {
|
|
190
|
+
throw new Error(`AGENTWALLET_MAX_TX_TOKEN must be a decimal number, got "${cap}".`);
|
|
191
|
+
}
|
|
192
|
+
const hex = data.slice(2);
|
|
193
|
+
const selector = hex.slice(0, 8).toLowerCase();
|
|
194
|
+
// transfer(address,uint256) | approve(address,uint256) | transferFrom(address,address,uint256)
|
|
195
|
+
const layouts = { a9059cbb: 1, '095ea7b3': 1, '23b872dd': 2 };
|
|
196
|
+
const argIndex = layouts[selector];
|
|
197
|
+
if (argIndex === undefined)
|
|
198
|
+
return; // not a value-moving ERC-20 call
|
|
199
|
+
const word = hex.slice(8 + argIndex * 64, 8 + (argIndex + 1) * 64);
|
|
200
|
+
if (word.length !== 64)
|
|
201
|
+
return; // malformed; leave it to the node to reject
|
|
202
|
+
const amount = BigInt('0x' + word);
|
|
203
|
+
const decimals = lookupTrustedDecimals(chainId, token) ?? 6;
|
|
204
|
+
const [whole, frac = ''] = cap.split('.');
|
|
205
|
+
const capRaw = BigInt(whole + frac.padEnd(decimals, '0').slice(0, decimals));
|
|
206
|
+
if (amount > capRaw) {
|
|
207
|
+
const what = selector === '095ea7b3' ? 'approval' : 'token transfer';
|
|
208
|
+
throw new Error(`Blocked by local guard: ${what} of ${amount} base units exceeds ` +
|
|
209
|
+
`AGENTWALLET_MAX_TX_TOKEN (${cap}, evaluated at ${decimals} decimals). ` +
|
|
210
|
+
`Raise the cap deliberately if this is intended.`);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
169
213
|
export async function localSend(chainId, to, valueWei, data) {
|
|
170
214
|
assertWithinNativeCap(valueWei);
|
|
215
|
+
if (data && data.length >= 10)
|
|
216
|
+
assertWithinTokenCap(chainId, to, data);
|
|
171
217
|
const { wallet } = clients(chainId);
|
|
172
218
|
const hash = await wallet.sendTransaction({
|
|
173
219
|
to,
|
package/build/x402-payment.d.ts
CHANGED
|
@@ -3,6 +3,16 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Isolated from the server bootstrap in index.ts so the funds-sensitive amount
|
|
5
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.
|
|
6
16
|
*/
|
|
7
17
|
export interface X402AcceptLike {
|
|
8
18
|
maxAmountRequired: string;
|
|
@@ -12,22 +22,48 @@ export interface X402AcceptLike {
|
|
|
12
22
|
token?: string;
|
|
13
23
|
};
|
|
14
24
|
}
|
|
25
|
+
/**
|
|
26
|
+
* Decimals for assets that x402 endpoints actually settle in, keyed by
|
|
27
|
+
* chain id then lowercased contract address (or SPL mint on Solana).
|
|
28
|
+
*
|
|
29
|
+
* This exists so the common path needs no network call, and so local
|
|
30
|
+
* self-custody mode (which may have no reachable API) can still verify a cap.
|
|
31
|
+
* It is intentionally small: every entry is a value that can be checked by
|
|
32
|
+
* hand. Anything not listed is resolved on-chain instead, and if that fails
|
|
33
|
+
* the payment is refused rather than guessed.
|
|
34
|
+
*/
|
|
35
|
+
export declare const TRUSTED_DECIMALS: Record<number, Record<string, number>>;
|
|
36
|
+
/** Solana SPL mints, keyed by mint address (case-sensitive base58). */
|
|
37
|
+
export declare const TRUSTED_SPL_DECIMALS: Record<string, number>;
|
|
38
|
+
/**
|
|
39
|
+
* Look up decimals we already know to be correct. Returns null when the asset
|
|
40
|
+
* is unknown, which means the caller must resolve it on-chain or refuse.
|
|
41
|
+
*/
|
|
42
|
+
export declare function lookupTrustedDecimals(chainId: number, token: string): number | null;
|
|
43
|
+
/**
|
|
44
|
+
* Guard the server's declared decimals against the trusted value.
|
|
45
|
+
*
|
|
46
|
+
* A mismatch is not a formatting quirk. It is the exact shape of the cap-bypass
|
|
47
|
+
* attack, so it fails closed and says so.
|
|
48
|
+
*/
|
|
49
|
+
export declare function assertDeclaredDecimals(declared: unknown, trusted: number): void;
|
|
15
50
|
/**
|
|
16
51
|
* Derive the on-chain transfer parameters from an x402 payment requirement.
|
|
17
52
|
*
|
|
18
53
|
* CRITICAL: x402 `maxAmountRequired` is ALREADY denominated in base/atomic
|
|
19
54
|
* units (e.g. "10000" = 0.01 USDC at 6 decimals). It MUST be used directly.
|
|
20
55
|
* Running it through parseUnits() re-scales it by 10**decimals and overpays by
|
|
21
|
-
* that factor (1,000,000x for USDC).
|
|
22
|
-
* always used maxAmountRequired directly.
|
|
56
|
+
* that factor (1,000,000x for USDC).
|
|
23
57
|
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
58
|
+
* Enforces a hard ceiling so a malformed or malicious 402 response can never
|
|
59
|
+
* authorize a transfer larger than `maxAutopayHuman` units of the asset.
|
|
26
60
|
*
|
|
27
61
|
* @param accept the chosen x402 accept option
|
|
28
62
|
* @param maxAutopayHuman max auto-pay, in human-readable units of the asset
|
|
63
|
+
* @param trustedDecimals decimals resolved from a registry or the token
|
|
64
|
+
* contract. MUST NOT be taken from the 402 response.
|
|
29
65
|
*/
|
|
30
|
-
export declare function deriveX402Payment(accept: X402AcceptLike, maxAutopayHuman: string): {
|
|
66
|
+
export declare function deriveX402Payment(accept: X402AcceptLike, maxAutopayHuman: string, trustedDecimals: number): {
|
|
31
67
|
tokenAddress: string;
|
|
32
68
|
rawAmount: string;
|
|
33
69
|
decimals: number;
|
|
@@ -42,7 +78,7 @@ export declare function deriveX402Payment(accept: X402AcceptLike, maxAutopayHuma
|
|
|
42
78
|
* can return a structured rejection to the calling agent.
|
|
43
79
|
*
|
|
44
80
|
* @param rawAmountRequired x402 maxAmountRequired, already in base units
|
|
45
|
-
* @param
|
|
81
|
+
* @param trustedDecimals token decimals from a trusted source, NOT the 402 body
|
|
46
82
|
* @param capHuman cap in human-readable units of the asset (e.g. "1")
|
|
47
83
|
*/
|
|
48
|
-
export declare function isWithinCap(rawAmountRequired: string,
|
|
84
|
+
export declare function isWithinCap(rawAmountRequired: string, trustedDecimals: number, capHuman: string): boolean;
|
package/build/x402-payment.js
CHANGED
|
@@ -3,12 +3,100 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Isolated from the server bootstrap in index.ts so the funds-sensitive amount
|
|
5
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
|
+
/**
|
|
18
|
+
* Decimals for assets that x402 endpoints actually settle in, keyed by
|
|
19
|
+
* chain id then lowercased contract address (or SPL mint on Solana).
|
|
20
|
+
*
|
|
21
|
+
* This exists so the common path needs no network call, and so local
|
|
22
|
+
* self-custody mode (which may have no reachable API) can still verify a cap.
|
|
23
|
+
* It is intentionally small: every entry is a value that can be checked by
|
|
24
|
+
* hand. Anything not listed is resolved on-chain instead, and if that fails
|
|
25
|
+
* the payment is refused rather than guessed.
|
|
26
|
+
*/
|
|
27
|
+
export const TRUSTED_DECIMALS = {
|
|
28
|
+
// Ethereum
|
|
29
|
+
1: {
|
|
30
|
+
'0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48': 6, // USDC
|
|
31
|
+
'0xdac17f958d2ee523a2206206994597c13d831ec7': 6, // USDT
|
|
32
|
+
'0x6b175474e89094c44da98b954eedeac495271d0f': 18, // DAI
|
|
33
|
+
},
|
|
34
|
+
// Base
|
|
35
|
+
8453: {
|
|
36
|
+
'0x833589fcd6edb6e08f4c7c32d4f71b54bda02913': 6, // USDC
|
|
37
|
+
'0xd9aaec86b65d86f6a7b5b1b0c42ffa531710b6ca': 6, // USDbC
|
|
38
|
+
'0x4200000000000000000000000000000000000006': 18, // WETH
|
|
39
|
+
},
|
|
40
|
+
// Polygon
|
|
41
|
+
137: {
|
|
42
|
+
'0x3c499c542cef5e3811e1192ce70d8cc03d5c3359': 6, // USDC (native)
|
|
43
|
+
'0xc2132d05d31c914a87c6611c10748aeb04b58e8f': 6, // USDT
|
|
44
|
+
},
|
|
45
|
+
// Arbitrum One
|
|
46
|
+
42161: {
|
|
47
|
+
'0xaf88d065e77c8cc2239327c5edb3a432268e5831': 6, // USDC
|
|
48
|
+
},
|
|
49
|
+
// Optimism
|
|
50
|
+
10: {
|
|
51
|
+
'0x0b2c639c533813f4aa9d7837caf62653d097ff85': 6, // USDC
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
/** Solana SPL mints, keyed by mint address (case-sensitive base58). */
|
|
55
|
+
export const TRUSTED_SPL_DECIMALS = {
|
|
56
|
+
EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v: 6, // USDC
|
|
57
|
+
Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB: 6, // USDT
|
|
58
|
+
};
|
|
59
|
+
/**
|
|
60
|
+
* Look up decimals we already know to be correct. Returns null when the asset
|
|
61
|
+
* is unknown, which means the caller must resolve it on-chain or refuse.
|
|
62
|
+
*/
|
|
63
|
+
export function lookupTrustedDecimals(chainId, token) {
|
|
64
|
+
if (!token)
|
|
65
|
+
return null;
|
|
66
|
+
const spl = TRUSTED_SPL_DECIMALS[token];
|
|
67
|
+
if (typeof spl === 'number')
|
|
68
|
+
return spl;
|
|
69
|
+
const forChain = TRUSTED_DECIMALS[chainId];
|
|
70
|
+
if (!forChain)
|
|
71
|
+
return null;
|
|
72
|
+
const d = forChain[token.toLowerCase()];
|
|
73
|
+
return typeof d === 'number' ? d : null;
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Guard the server's declared decimals against the trusted value.
|
|
77
|
+
*
|
|
78
|
+
* A mismatch is not a formatting quirk. It is the exact shape of the cap-bypass
|
|
79
|
+
* attack, so it fails closed and says so.
|
|
6
80
|
*/
|
|
81
|
+
export function assertDeclaredDecimals(declared, trusted) {
|
|
82
|
+
if (declared === undefined || declared === null)
|
|
83
|
+
return; // absent is fine, we use trusted
|
|
84
|
+
if (typeof declared !== 'number' || !Number.isInteger(declared) || declared < 0 || declared > 36) {
|
|
85
|
+
throw new Error(`x402: malformed requiredDecimals (${String(declared)}). Refusing to derive a payment amount.`);
|
|
86
|
+
}
|
|
87
|
+
if (declared !== trusted) {
|
|
88
|
+
throw new Error(`x402 blocked: the endpoint declared ${declared} decimals for this asset but its ` +
|
|
89
|
+
`on-chain value is ${trusted}. This is how a payment cap is bypassed, so the payment was refused.`);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
7
92
|
/** Convert a human-readable decimal amount (e.g. "0.01") to base/atomic units. */
|
|
8
93
|
function toBaseUnits(amount, decimals) {
|
|
9
94
|
if (!/^\d+(\.\d+)?$/.test(amount)) {
|
|
10
95
|
throw new Error(`Invalid amount "${amount}".`);
|
|
11
96
|
}
|
|
97
|
+
if (!Number.isInteger(decimals) || decimals < 0 || decimals > 36) {
|
|
98
|
+
throw new Error(`Invalid decimals "${decimals}".`);
|
|
99
|
+
}
|
|
12
100
|
const [whole, frac = ''] = amount.split('.');
|
|
13
101
|
const fracPadded = frac.slice(0, decimals).padEnd(decimals, '0');
|
|
14
102
|
return BigInt(whole + fracPadded).toString();
|
|
@@ -19,30 +107,31 @@ function toBaseUnits(amount, decimals) {
|
|
|
19
107
|
* CRITICAL: x402 `maxAmountRequired` is ALREADY denominated in base/atomic
|
|
20
108
|
* units (e.g. "10000" = 0.01 USDC at 6 decimals). It MUST be used directly.
|
|
21
109
|
* Running it through parseUnits() re-scales it by 10**decimals and overpays by
|
|
22
|
-
* that factor (1,000,000x for USDC).
|
|
23
|
-
* always used maxAmountRequired directly.
|
|
110
|
+
* that factor (1,000,000x for USDC).
|
|
24
111
|
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
112
|
+
* Enforces a hard ceiling so a malformed or malicious 402 response can never
|
|
113
|
+
* authorize a transfer larger than `maxAutopayHuman` units of the asset.
|
|
27
114
|
*
|
|
28
115
|
* @param accept the chosen x402 accept option
|
|
29
116
|
* @param maxAutopayHuman max auto-pay, in human-readable units of the asset
|
|
117
|
+
* @param trustedDecimals decimals resolved from a registry or the token
|
|
118
|
+
* contract. MUST NOT be taken from the 402 response.
|
|
30
119
|
*/
|
|
31
|
-
export function deriveX402Payment(accept, maxAutopayHuman) {
|
|
32
|
-
const decimals = accept.requiredDecimals || 6;
|
|
120
|
+
export function deriveX402Payment(accept, maxAutopayHuman, trustedDecimals) {
|
|
33
121
|
// Standard x402 uses `asset` for the token address; AgentWallet's own server
|
|
34
122
|
// currently emits it under extra.token. Prefer the standard field, fall back.
|
|
35
123
|
const tokenAddress = accept.asset || accept.extra?.token || '';
|
|
124
|
+
assertDeclaredDecimals(accept.requiredDecimals, trustedDecimals);
|
|
36
125
|
const rawAmount = accept.maxAmountRequired;
|
|
37
126
|
if (!/^\d+$/.test(rawAmount)) {
|
|
38
127
|
throw new Error(`x402 auto-pay: invalid maxAmountRequired "${rawAmount}" (expected an integer base-unit amount).`);
|
|
39
128
|
}
|
|
40
|
-
const capRaw = toBaseUnits(maxAutopayHuman,
|
|
129
|
+
const capRaw = toBaseUnits(maxAutopayHuman, trustedDecimals);
|
|
41
130
|
if (BigInt(rawAmount) > BigInt(capRaw)) {
|
|
42
131
|
throw new Error(`x402 auto-pay blocked: required amount (${rawAmount} base units) exceeds the ` +
|
|
43
132
|
`AGENTWALLET_MAX_AUTOPAY cap of ${maxAutopayHuman}. Raise AGENTWALLET_MAX_AUTOPAY to allow it.`);
|
|
44
133
|
}
|
|
45
|
-
return { tokenAddress, rawAmount, decimals };
|
|
134
|
+
return { tokenAddress, rawAmount, decimals: trustedDecimals };
|
|
46
135
|
}
|
|
47
136
|
/**
|
|
48
137
|
* Returns true if an x402 required amount (already in base/atomic units) is
|
|
@@ -54,13 +143,13 @@ export function deriveX402Payment(accept, maxAutopayHuman) {
|
|
|
54
143
|
* can return a structured rejection to the calling agent.
|
|
55
144
|
*
|
|
56
145
|
* @param rawAmountRequired x402 maxAmountRequired, already in base units
|
|
57
|
-
* @param
|
|
146
|
+
* @param trustedDecimals token decimals from a trusted source, NOT the 402 body
|
|
58
147
|
* @param capHuman cap in human-readable units of the asset (e.g. "1")
|
|
59
148
|
*/
|
|
60
|
-
export function isWithinCap(rawAmountRequired,
|
|
149
|
+
export function isWithinCap(rawAmountRequired, trustedDecimals, capHuman) {
|
|
61
150
|
if (!/^\d+$/.test(rawAmountRequired)) {
|
|
62
151
|
throw new Error(`Invalid maxAmountRequired "${rawAmountRequired}" (expected an integer base-unit amount).`);
|
|
63
152
|
}
|
|
64
|
-
const capRaw = toBaseUnits(capHuman,
|
|
153
|
+
const capRaw = toBaseUnits(capHuman, trustedDecimals);
|
|
65
154
|
return BigInt(rawAmountRequired) <= BigInt(capRaw);
|
|
66
155
|
}
|
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.10.
|
|
4
|
+
"version": "1.10.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/local-mode.test.mjs && node test/ssrf-guard.test.mjs",
|
|
19
|
+
"test": "node test/x402-payment.test.mjs && node test/token-cap.test.mjs && node test/spl-cap.test.mjs && node test/local-mode.test.mjs && node test/ssrf-guard.test.mjs",
|
|
20
20
|
"prepublishOnly": "npm run build"
|
|
21
21
|
},
|
|
22
22
|
"keywords": [
|