agentwallet-mcp 1.13.4 → 1.13.5
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 +1 -1
- package/build/index.js +118 -77
- package/build/local-solana.d.ts +2 -0
- package/build/local-solana.js +43 -6
- package/build/local-wallet.d.ts +23 -0
- package/build/local-wallet.js +58 -32
- package/build/ssrf-guard.d.ts +2 -0
- package/build/ssrf-guard.js +9 -1
- package/build/x402-eip3009.d.ts +2 -0
- package/build/x402-eip3009.js +5 -1
- package/build/x402-payment.d.ts +9 -0
- package/build/x402-payment.js +62 -8
- package/build/x402-permit2.d.ts +2 -2
- package/build/x402-permit2.js +5 -4
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -66,7 +66,7 @@ Set either key, or both. They are independent: run EVM locally and Solana hosted
|
|
|
66
66
|
|
|
67
67
|
Local signing uses [viem](https://viem.sh) for EVM and `@solana/web3.js` for Solana, plus `bs58` for key parsing. SPL instructions are built by hand rather than with `@solana/spl-token`, because that package pulls in `bigint-buffer`, which carries a high severity buffer overflow advisory. A wallet has no business shipping that to save a dozen lines of instruction encoding.
|
|
68
68
|
|
|
69
|
-
`npm audit`
|
|
69
|
+
`npm audit` reports no advisories as of 1.13.5; the overrides in package.json keep two advisories under `@solana/web3.js`'s jayson closed. Earlier releases carried advisories inside `@modelcontextprotocol/sdk`'s HTTP transport dependencies. This server speaks stdio, so that code never loads, and the SDK is not something this package can patch. Run the audit yourself. Publishing a tree you can inspect is the point.
|
|
70
70
|
|
|
71
71
|
<p align="center">
|
|
72
72
|
<img src="assets/demo.svg" alt="AgentWallet demo, AI agent pays x402 invoice automatically" width="800">
|
package/build/index.js
CHANGED
|
@@ -13,14 +13,15 @@ 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, finalUrlOf } from './ssrf-guard.js';
|
|
16
|
-
import { deriveX402Payment, isWithinCap, lookupTrustedDecimals, assertDeclaredDecimals, nativeDecimals, SOLANA_CHAIN_IDS, assetLabel, autopayAssetAllowed, autopayEnvCap, effectiveAutopayCap, maxAuthWindowSeconds, parseDecimalPins, approvalRefusal, authorizationResource, } from './x402-payment.js';
|
|
16
|
+
import { deriveX402Payment, isWithinCap, lookupTrustedDecimals, assertDeclaredDecimals, nativeDecimals, SOLANA_CHAIN_IDS, assetLabel, autopayAssetAllowed, autopayEnvCap, effectiveAutopayCap, maxAuthWindowSeconds, parseDecimalPins, approvalRefusal, authorizationResource, resolveNetworkChainId, normalizeRawAmount, parseAutopayAssets, } from './x402-payment.js';
|
|
17
|
+
import { createHash } from 'node:crypto';
|
|
17
18
|
import { isLocalMode, getLocalAddress, localWalletRecord, localSend, localNativeBalance, localTokenBalance, localSignMessage, resolveRpcUrl, } from './local-wallet.js';
|
|
18
19
|
import { localSignAuthorization, localEthCall, localGetCode, localSignPermit2Upto } from './local-wallet.js';
|
|
19
20
|
import { WRAPPED_NATIVE } from './wrapped-native.js';
|
|
20
21
|
import { buildUptoAuthorization, uptoPayload, permit2AllowanceCalldata, permit2ApproveCalldata, decodeUint, PERMIT2_ADDRESS, } from './x402-permit2.js';
|
|
21
22
|
import { assessTokenRisk } from './token-risk.js';
|
|
22
23
|
import { isLegacyAgentWalletAccept, requiredAmount, buildAuthorization, buildPaymentPayload, buildPaymentPayloadRaw, paymentHeaderFor, parsePaymentRequired, parseSettlement, pickOption, knownTokenDomain, parseEip7702Delegation, erc1271ProbeCalldata, classifyErc1271Probe, } from './x402-eip3009.js';
|
|
23
|
-
import { isSolanaLocalMode, getSolanaAddress, solanaWalletRecord, localSolBalance, localSplBalance, localSolTransfer, localSplTransfer, resolveSolanaRpc, } from './local-solana.js';
|
|
24
|
+
import { isSolanaLocalMode, getSolanaAddress, solanaWalletRecord, localSolBalance, localSplBalance, localSolTransfer, localSplTransfer, resolveSolanaRpc, mintDecimalsStrict, } from './local-solana.js';
|
|
24
25
|
/** True when either chain family is running non-custodially. */
|
|
25
26
|
function anyLocalMode() {
|
|
26
27
|
return isLocalMode() || isSolanaLocalMode();
|
|
@@ -76,7 +77,7 @@ async function routeLocally(path, method, body) {
|
|
|
76
77
|
const [rawPath, rawQs = ''] = path.split('?');
|
|
77
78
|
const qs = new URLSearchParams(rawQs);
|
|
78
79
|
// POST /wallets/{id}/send
|
|
79
|
-
if (method === 'POST' && /^\/wallets\/[^/]
|
|
80
|
+
if (method === 'POST' && /^\/wallets\/[^/]+\/send$/.test(rawPath)) {
|
|
80
81
|
const chainId = localChainId(body, qs);
|
|
81
82
|
const solanaOp = SOLANA_CHAIN_IDS.has(chainId) || Boolean(body?.token_mint);
|
|
82
83
|
if (solanaOp) {
|
|
@@ -209,7 +210,13 @@ async function api(path, method = 'GET', body, extraHeaders) {
|
|
|
209
210
|
single choke point every funds operation already passes through means no
|
|
210
211
|
tool can quietly stay custodial because someone forgot to update it. */
|
|
211
212
|
if (anyLocalMode()) {
|
|
212
|
-
|
|
213
|
+
let handled;
|
|
214
|
+
try {
|
|
215
|
+
handled = await routeLocally(path, method, body);
|
|
216
|
+
}
|
|
217
|
+
catch (e) {
|
|
218
|
+
throw new Error(redactRpcSecrets(e)); // viem puts the RPC URL, key and all, in its messages
|
|
219
|
+
}
|
|
213
220
|
if (handled !== NOT_HANDLED)
|
|
214
221
|
return handled;
|
|
215
222
|
}
|
|
@@ -221,15 +228,39 @@ async function api(path, method = 'GET', body, extraHeaders) {
|
|
|
221
228
|
if (API_USER && API_PASS) {
|
|
222
229
|
headers['Authorization'] = 'Basic ' + Buffer.from(`${API_USER}:${API_PASS}`).toString('base64');
|
|
223
230
|
}
|
|
224
|
-
|
|
231
|
+
// The API host has no reason to send a wallet operation elsewhere: a
|
|
232
|
+
// redirect is refused instead of followed, so a request body is never
|
|
233
|
+
// replayed to another origin and another origin's answer is never returned
|
|
234
|
+
// as the API's (2026-10-04 audit).
|
|
235
|
+
const options = { method, headers, redirect: 'manual' };
|
|
225
236
|
if (body && method !== 'GET') {
|
|
226
237
|
options.body = JSON.stringify(body);
|
|
227
238
|
}
|
|
228
239
|
const res = await fetch(url, options);
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
240
|
+
if (res.status >= 300 && res.status < 400) {
|
|
241
|
+
throw new Error(`The AgentWallet API host answered ${path} with a redirect (HTTP ${res.status}); refusing to follow it.`);
|
|
242
|
+
}
|
|
243
|
+
const text = await res.text();
|
|
244
|
+
if (text.length > API_MAX_RESPONSE_CHARS)
|
|
245
|
+
throw new Error(`The AgentWallet API host returned an oversized response (${text.length} characters); refusing to parse it.`);
|
|
246
|
+
let data;
|
|
247
|
+
try {
|
|
248
|
+
data = JSON.parse(text);
|
|
249
|
+
}
|
|
250
|
+
catch {
|
|
251
|
+
throw new Error(`The AgentWallet API host returned a non-JSON response (HTTP ${res.status}).`);
|
|
252
|
+
}
|
|
253
|
+
// A 402 from the API host is paid automatically only for a HOSTED wallet the
|
|
254
|
+
// operator named with AGENTWALLET_WALLET_ID. In local mode the host is not a
|
|
255
|
+
// custodian and must never be able to make the local key sign: a 402 is
|
|
256
|
+
// returned as an error and the agent can call pay_x402 explicitly, which
|
|
257
|
+
// reports what it does (2026-10-04 audit).
|
|
258
|
+
if (res.status === 402 && !extraHeaders?.['X-PAYMENT'] && !extraHeaders?.['X-AGW-SKIP-X402']) {
|
|
259
|
+
if (anyLocalMode()) {
|
|
260
|
+
throw new Error(`The AgentWallet API host asked for payment on ${path}; nothing is paid automatically in local mode. If you trust it, call pay_x402 with that URL so the payment is explicit and reported.`);
|
|
261
|
+
}
|
|
262
|
+
if (X402_WALLET_ID)
|
|
263
|
+
return handleX402Payment(data, path, method, body);
|
|
233
264
|
}
|
|
234
265
|
if (!res.ok) {
|
|
235
266
|
const error = data.error || `HTTP ${res.status}`;
|
|
@@ -397,6 +428,7 @@ function isValidAddress(address) {
|
|
|
397
428
|
/** Request headers the caller may not set on pay_x402: Host would rename the TLS peer, the rest belong to the client. */
|
|
398
429
|
const FORBIDDEN_REQUEST_HEADERS = new Set(['host', 'content-length', 'transfer-encoding', 'connection', 'upgrade', 'expect', 'te', 'trailer', 'keep-alive']);
|
|
399
430
|
/** Authorizations signed in this process, keyed by endpoint and requirement, kept until they expire. */
|
|
431
|
+
const signingInFlight = new Map();
|
|
400
432
|
const signedPayments = new Map();
|
|
401
433
|
function looksLikeTxHash(v) {
|
|
402
434
|
return typeof v === 'string' && (/^0x[0-9a-fA-F]{64}$/.test(v) || /^[1-9A-HJ-NP-Za-km-z]{86,88}$/.test(v));
|
|
@@ -417,6 +449,17 @@ function redactUrl(u) {
|
|
|
417
449
|
return u;
|
|
418
450
|
}
|
|
419
451
|
}
|
|
452
|
+
const API_MAX_RESPONSE_CHARS = 8 * 1024 * 1024;
|
|
453
|
+
/* Error text from the local signing path with every URL reduced to its origin
|
|
454
|
+
and viem's "URL:" / "Request body:" lines dropped: an RPC endpoint carries
|
|
455
|
+
its API key in the path or query, and a tool result is the agent's context
|
|
456
|
+
and the client's log (2026-10-04 audit). */
|
|
457
|
+
function redactRpcSecrets(err) {
|
|
458
|
+
const e = err;
|
|
459
|
+
let msg = e?.shortMessage ? [e.shortMessage, e.details].filter(Boolean).join(' ') : String(e?.message ?? err);
|
|
460
|
+
msg = msg.split(/\r?\n/).filter(l => !/^\s*(URL|Request body|Request Arguments|Raw Call Arguments)\s*:/i.test(l)).join('\n').trim();
|
|
461
|
+
return msg.replace(/[a-z][a-z0-9+.-]*:\/\/[^\s"'<>)\]]+/gi, (u) => redactUrl(u));
|
|
462
|
+
}
|
|
420
463
|
/**
|
|
421
464
|
* Decimals to scale a human amount by. Always the token's own value (registry,
|
|
422
465
|
* then decimals() on chain); a caller-supplied value must agree or the call
|
|
@@ -425,12 +468,12 @@ function redactUrl(u) {
|
|
|
425
468
|
async function verifiedDecimals(chainId, token, given) {
|
|
426
469
|
let trusted = null;
|
|
427
470
|
if (isSolanaChain(chainId)) {
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
471
|
+
// The mint itself says how many decimals it has; a caller's value only
|
|
472
|
+
// ever confirms it (2026-10-04 audit: the substituted byte could not catch
|
|
473
|
+
// an amount already scaled by a wrong guess).
|
|
474
|
+
trusted = await mintDecimalsStrict(token, chainId);
|
|
475
|
+
if (trusted === null)
|
|
476
|
+
throw new Error(`Could not read the decimals of mint ${token} from the chain; refusing to guess the scale of the amount.`);
|
|
434
477
|
}
|
|
435
478
|
else {
|
|
436
479
|
trusted = await resolveTrustedDecimals(chainId, token);
|
|
@@ -444,15 +487,23 @@ async function verifiedDecimals(chainId, token, given) {
|
|
|
444
487
|
function validateGuardEnv() {
|
|
445
488
|
parseDecimalPins(process.env.AGENTWALLET_TOKEN_DECIMALS); // throws on a malformed pin
|
|
446
489
|
for (const name of ['AGENTWALLET_MAX_TX_NATIVE', 'AGENTWALLET_MAX_TX_TOKEN', 'AGENTWALLET_MAX_TX_SOL', 'AGENTWALLET_MAX_AUTOPAY']) {
|
|
447
|
-
const
|
|
490
|
+
const raw = process.env[name];
|
|
491
|
+
const v = (raw || '').trim();
|
|
492
|
+
// Set but blank is a typo, not "no cap": refuse to start rather than run uncapped.
|
|
493
|
+
if (raw !== undefined && v === '')
|
|
494
|
+
throw new Error(`${name} is set but blank. Set it to a number or unset it.`);
|
|
448
495
|
if (v && !/^\d+(\.\d+)?$/.test(v))
|
|
449
496
|
throw new Error(`${name} must be a decimal number, got "${v}".`);
|
|
450
497
|
}
|
|
451
|
-
for (const e of (process.env.AGENTWALLET_AUTOPAY_ASSETS
|
|
452
|
-
if (e !== 'native' && !/^0x[0-9a-
|
|
453
|
-
throw new Error(`AGENTWALLET_AUTOPAY_ASSETS entry "${e}" is not "native", an EVM address or an SPL mint.`);
|
|
498
|
+
for (const e of parseAutopayAssets(process.env.AGENTWALLET_AUTOPAY_ASSETS)) {
|
|
499
|
+
if (e.asset !== 'native' && !/^0x[0-9a-f]{40}$/.test(e.asset) && !/^[1-9A-HJ-NP-Za-km-z]{32,44}$/.test(e.asset)) {
|
|
500
|
+
throw new Error(`AGENTWALLET_AUTOPAY_ASSETS entry "${e.asset}" is not "native", an EVM address or an SPL mint (optionally prefixed "chainId:").`);
|
|
454
501
|
}
|
|
455
502
|
}
|
|
503
|
+
const t = (process.env.AGENTWALLET_X402_MAX_TIMEOUT || '').trim();
|
|
504
|
+
if (t && (!/^\d+$/.test(t) || Number(t) < 60 || Number(t) > 31_536_000)) {
|
|
505
|
+
throw new Error(`AGENTWALLET_X402_MAX_TIMEOUT must be a whole number of seconds between 60 and 31536000, got "${t}".`);
|
|
506
|
+
}
|
|
456
507
|
}
|
|
457
508
|
// ─── EVM Helpers ──────────────────────────────────────────────────
|
|
458
509
|
/**
|
|
@@ -503,48 +554,13 @@ function encodeUint256(value) {
|
|
|
503
554
|
}
|
|
504
555
|
// ─── Server ──────────────────────────────────────────────────────
|
|
505
556
|
// ─── x402 Network Mapping ──────────────────────────────────────
|
|
506
|
-
const X402_NETWORKS = {
|
|
507
|
-
'ethereum': 1,
|
|
508
|
-
'base': 8453,
|
|
509
|
-
'base-sepolia': 84532,
|
|
510
|
-
'polygon': 137,
|
|
511
|
-
'arbitrum': 42161,
|
|
512
|
-
'optimism': 10,
|
|
513
|
-
'bsc': 56,
|
|
514
|
-
'avalanche': 43114,
|
|
515
|
-
'zora': 7777777,
|
|
516
|
-
'pulsechain': 369,
|
|
517
|
-
'solana': 900,
|
|
518
|
-
'solana-devnet': 901,
|
|
519
|
-
};
|
|
520
|
-
// Solana genesis hash prefixes for CAIP-2
|
|
521
|
-
const SOLANA_GENESIS = {
|
|
522
|
-
900: '5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp', // mainnet-beta
|
|
523
|
-
901: 'EtWTRABZaYq6iMfeYKouRu166VU2xqa1', // devnet
|
|
524
|
-
};
|
|
525
557
|
/**
|
|
526
|
-
* Resolve an x402 network identifier to a chain ID
|
|
527
|
-
*
|
|
558
|
+
* Resolve an x402 network identifier to a chain ID: plain names ("base"),
|
|
559
|
+
* CAIP-2 ("eip155:8453", "solana:<genesis prefix>") and raw ids ("8453").
|
|
560
|
+
* The table and the parsing live in x402-payment.ts so they can be tested.
|
|
528
561
|
*/
|
|
529
562
|
function resolveChainId(network) {
|
|
530
|
-
|
|
531
|
-
if (X402_NETWORKS[lower])
|
|
532
|
-
return X402_NETWORKS[lower];
|
|
533
|
-
const caipMatch = network.match(/^eip155:(\d+)$/);
|
|
534
|
-
if (caipMatch)
|
|
535
|
-
return parseInt(caipMatch[1], 10);
|
|
536
|
-
// Solana CAIP-2: solana:<genesis_prefix>
|
|
537
|
-
const solanaMatch = network.match(/^solana:(.+)$/);
|
|
538
|
-
if (solanaMatch) {
|
|
539
|
-
for (const [chainId, genesis] of Object.entries(SOLANA_GENESIS)) {
|
|
540
|
-
if (solanaMatch[1] === genesis)
|
|
541
|
-
return parseInt(chainId, 10);
|
|
542
|
-
}
|
|
543
|
-
}
|
|
544
|
-
const num = parseInt(network, 10);
|
|
545
|
-
if (!isNaN(num) && num > 0)
|
|
546
|
-
return num;
|
|
547
|
-
return null;
|
|
563
|
+
return resolveNetworkChainId(network);
|
|
548
564
|
}
|
|
549
565
|
// ─── Server ──────────────────────────────────────────────────────
|
|
550
566
|
/**
|
|
@@ -561,7 +577,7 @@ function resolveChainId(network) {
|
|
|
561
577
|
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
578
|
const server = new McpServer({
|
|
563
579
|
name: 'agentwallet',
|
|
564
|
-
version: '1.13.
|
|
580
|
+
version: '1.13.5',
|
|
565
581
|
}, {
|
|
566
582
|
instructions: `AgentWallet gives AI agents their own blockchain wallets. ${anyLocalMode()
|
|
567
583
|
? '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.'
|
|
@@ -1176,6 +1192,13 @@ async function resolveTokenDomain(chainId, asset, extra) {
|
|
|
1176
1192
|
}
|
|
1177
1193
|
return { name, version };
|
|
1178
1194
|
}
|
|
1195
|
+
/** Text an endpoint wrote, bounded so it cannot flood the agent's context. */
|
|
1196
|
+
function serverText(s) {
|
|
1197
|
+
if (s === undefined || s === null)
|
|
1198
|
+
return null;
|
|
1199
|
+
const t = String(s);
|
|
1200
|
+
return t.length > 300 ? t.slice(0, 300) + '…' : t;
|
|
1201
|
+
}
|
|
1179
1202
|
/** Sign the authorization with whichever custody mode is active (local key, or the hosted signer). */
|
|
1180
1203
|
async function signAuthorization(walletId, chainId, asset, domain, auth, approvalId) {
|
|
1181
1204
|
const r = (await api(`/wallets/${walletId}/x402/authorize`, 'POST', {
|
|
@@ -1206,8 +1229,8 @@ server.tool('pay_x402', 'Handle an x402 payment flow. Fetches a URL, and if the
|
|
|
1206
1229
|
'parses the payment requirements (v1 body or v2 PAYMENT-REQUIRED header), signs an EIP-3009 ' +
|
|
1207
1230
|
'TransferWithAuthorization for the "exact" scheme (no gas, nothing broadcast by the payer), and retries ' +
|
|
1208
1231
|
'the request with the payment header. AgentWallet paywalls are paid by on-chain transfer instead. ' +
|
|
1209
|
-
'The "upto" scheme is
|
|
1210
|
-
'receipt. Supports the x402 open payment standard (https://x402.org).
|
|
1232
|
+
'The "upto" scheme is settled with a Permit2 authorization bounded by the cap. Returns the final response and the settlement ' +
|
|
1233
|
+
'receipt. Supports the x402 open payment standard (https://x402.org). max_payment can only lower the operator\'s cap for one call.', {
|
|
1211
1234
|
url: z.string().url().describe('The URL to access (will handle 402 payment if required)'),
|
|
1212
1235
|
wallet_id: z.number().int().describe('Wallet ID to pay from'),
|
|
1213
1236
|
method: z.string().default('GET').describe('HTTP method (GET, POST, PUT, DELETE)'),
|
|
@@ -1216,10 +1239,8 @@ server.tool('pay_x402', 'Handle an x402 payment flow. Fetches a URL, and if the
|
|
|
1216
1239
|
'endpoint redirects to a different origin they are dropped, not forwarded.'),
|
|
1217
1240
|
body: z.string().optional().describe('Optional request body for POST/PUT requests'),
|
|
1218
1241
|
max_payment: z.string().optional().describe('Maximum payment in human-readable format (e.g. "1.00" for 1 USDC). ' +
|
|
1219
|
-
'
|
|
1220
|
-
'
|
|
1221
|
-
'endpoint can never authorize an unbounded payment. Set this explicitly ' +
|
|
1222
|
-
'to allow a larger single payment.'),
|
|
1242
|
+
'Lowers the operator\'s AGENTWALLET_MAX_AUTOPAY ceiling (default "1") for this one call; ' +
|
|
1243
|
+
'it can never raise it, and a value above the ceiling is ignored and reported as max_payment_ignored.'),
|
|
1223
1244
|
prefer_chain: z.number().int().optional().describe('Preferred chain ID if the server accepts payment on multiple chains ' +
|
|
1224
1245
|
'(e.g. 8453 for Base, 1 for Ethereum)'),
|
|
1225
1246
|
request_approval: z.boolean().optional().describe('Hosted wallets only. When the payment exceeds the cap, email the wallet owner an approve/deny link ' +
|
|
@@ -1325,10 +1346,7 @@ server.tool('pay_x402', 'Handle an x402 payment flow. Fetches a URL, and if the
|
|
|
1325
1346
|
const option = picked.option;
|
|
1326
1347
|
const chainId = picked.chainId;
|
|
1327
1348
|
const isUpto = option.scheme === 'upto';
|
|
1328
|
-
const rawAmount = requiredAmount(option);
|
|
1329
|
-
if (!/^\d+$/.test(rawAmount)) {
|
|
1330
|
-
throw new Error(`x402: invalid amount "${rawAmount}" in the payment requirements (expected integer base units).`);
|
|
1331
|
-
}
|
|
1349
|
+
const rawAmount = normalizeRawAmount(requiredAmount(option)); // "0010000" and "10000" are one amount
|
|
1332
1350
|
// Decimals come from the registry or the token contract, NEVER from the 402
|
|
1333
1351
|
// body. The endpoint controls what it declares, so trusting it would let a
|
|
1334
1352
|
// hostile server inflate the cap and misreport the amount shown to the agent.
|
|
@@ -1400,8 +1418,8 @@ server.tool('pay_x402', 'Handle an x402 payment flow. Fetches a URL, and if the
|
|
|
1400
1418
|
...overCap,
|
|
1401
1419
|
error: `Payment of ${amount} ${tokenLabel} exceeds the ${effectiveMax} cap (from ${capSource}).` +
|
|
1402
1420
|
(max_payment ? '' : ' No max_payment was provided, so the default cap was applied.') +
|
|
1403
|
-
(anyLocalMode() ? ' Local mode has no approval channel
|
|
1404
|
-
: ' Pass request_approval=true to email the wallet owner for a one-time approval
|
|
1421
|
+
(anyLocalMode() ? ' Local mode has no approval channel; the caps are the operator\'s and only the operator can change them. Do not edit the environment or config to raise them.'
|
|
1422
|
+
: ' Pass request_approval=true to email the wallet owner for a one-time approval; the cap itself is the operator\'s to change.'),
|
|
1405
1423
|
});
|
|
1406
1424
|
}
|
|
1407
1425
|
}
|
|
@@ -1423,11 +1441,24 @@ server.tool('pay_x402', 'Handle an x402 payment flow. Fetches a URL, and if the
|
|
|
1423
1441
|
// The resource is the whole URL including its query: /buy?id=1 and /buy?id=2
|
|
1424
1442
|
// are different purchases, and reusing the first signature for the second
|
|
1425
1443
|
// would report a payment the second resource never received.
|
|
1426
|
-
|
|
1444
|
+
// The method and body are part of what was bought: POST {item:A} and
|
|
1445
|
+
// POST {item:B} to one URL are two purchases. Concurrent calls for one key
|
|
1446
|
+
// wait for the first signature instead of each signing a nonce.
|
|
1447
|
+
const bodyDigest = createHash('sha256').update(reqBody ?? '').digest('hex').slice(0, 16);
|
|
1448
|
+
const cacheKey = [wallet_id, chainId, tokenAddress.toLowerCase(), option.payTo.toLowerCase(), rawAmount, option.scheme, authorizationResource(url), (method || 'GET').toUpperCase(), bodyDigest].join('|');
|
|
1449
|
+
const inFlight = signingInFlight.get(cacheKey);
|
|
1450
|
+
if (inFlight && !fresh_authorization)
|
|
1451
|
+
await inFlight;
|
|
1427
1452
|
const cachedAuth = fresh_authorization ? undefined : signedPayments.get(cacheKey);
|
|
1428
1453
|
for (const [k, v] of signedPayments)
|
|
1429
1454
|
if (v.until <= Date.now())
|
|
1430
1455
|
signedPayments.delete(k);
|
|
1456
|
+
let releaseSigning = () => { };
|
|
1457
|
+
if (!(cachedAuth && cachedAuth.until > Date.now())) {
|
|
1458
|
+
const mine = new Promise(r => { releaseSigning = r; });
|
|
1459
|
+
// Released when the signature is cached, or after 30 s if this call throws first, so a failure never wedges the key.
|
|
1460
|
+
signingInFlight.set(cacheKey, Promise.race([mine, new Promise(r => setTimeout(r, 30_000).unref())]));
|
|
1461
|
+
}
|
|
1431
1462
|
if (cachedAuth && cachedAuth.until > Date.now()) {
|
|
1432
1463
|
// Same endpoint, same requirement, still inside the window: re-send the
|
|
1433
1464
|
// earlier signature. Its nonce is single-use, so a server that already
|
|
@@ -1441,7 +1472,8 @@ server.tool('pay_x402', 'Handle an x402 payment flow. Fetches a URL, and if the
|
|
|
1441
1472
|
const allowance = decodeUint(await ethCallHex(chainId, tokenAddress, permit2AllowanceCalldata(payer)));
|
|
1442
1473
|
if (allowance < BigInt(rawAmount)) {
|
|
1443
1474
|
if (process.env.AGENTWALLET_PERMIT2_AUTO_APPROVE === '1' && assetPolicy.via === 'stablecoin') {
|
|
1444
|
-
|
|
1475
|
+
// Approve exactly this payment's maximum, never unlimited (2026-10-04 audit).
|
|
1476
|
+
const r = (await api(`/wallets/${wallet_id}/send`, 'POST', { to: tokenAddress, value: '0', data: permit2ApproveCalldata(rawAmount), chain_id: chainId }, skip));
|
|
1445
1477
|
permit2ApprovalTx = String(r?.tx_hash || '');
|
|
1446
1478
|
await new Promise(res => setTimeout(res, 4000));
|
|
1447
1479
|
}
|
|
@@ -1498,6 +1530,8 @@ server.tool('pay_x402', 'Handle an x402 payment flow. Fetches a URL, and if the
|
|
|
1498
1530
|
: Date.now() + maxAuthWindowSeconds() * 1000; // a broadcast transfer: the hash stays valid, re-sending it never pays twice
|
|
1499
1531
|
signedPayments.set(cacheKey, { headerName, paymentHeader, txHash, authorization, uptoAuth, payer, until });
|
|
1500
1532
|
}
|
|
1533
|
+
releaseSigning();
|
|
1534
|
+
signingInFlight.delete(cacheKey);
|
|
1501
1535
|
// Step 6: Retry with the payment header and read the settlement receipt.
|
|
1502
1536
|
const retryHeaders = { ...reqHeaders, [headerName]: paymentHeader };
|
|
1503
1537
|
const retryOptions = {
|
|
@@ -1528,7 +1562,7 @@ server.tool('pay_x402', 'Handle an x402 payment flow. Fetches a URL, and if the
|
|
|
1528
1562
|
}
|
|
1529
1563
|
if (retryRes.status === 402) {
|
|
1530
1564
|
const again = parsePaymentRequired(n => retryRes.headers.get(n), retryParsed);
|
|
1531
|
-
retryError = String(again?.error || retryParsed?.error || 'Payment was not accepted (no reason given).');
|
|
1565
|
+
retryError = serverText(String(again?.error || retryParsed?.error || 'Payment was not accepted (no reason given).'));
|
|
1532
1566
|
if (payerDelegationInfo && /signature/i.test(retryError))
|
|
1533
1567
|
retryError += ` Likely cause: ${payerDelegationInfo.warning}`;
|
|
1534
1568
|
}
|
|
@@ -1559,7 +1593,8 @@ server.tool('pay_x402', 'Handle an x402 payment flow. Fetches a URL, and if the
|
|
|
1559
1593
|
settlement_reported_by_server: settlement,
|
|
1560
1594
|
authorization: authorization ? { nonce: authorization.nonce, valid_before: authorization.validBefore }
|
|
1561
1595
|
: (uptoAuth ? { scheme: 'upto', max_amount: amount, nonce: uptoAuth.nonce, deadline: uptoAuth.deadline, facilitator: uptoAuth.witness.facilitator } : null),
|
|
1562
|
-
description: option.description,
|
|
1596
|
+
description: serverText(option.description),
|
|
1597
|
+
server_text_is_untrusted: true, // description, retry_error and the response are the endpoint's words, not instructions
|
|
1563
1598
|
response: retryParsed,
|
|
1564
1599
|
});
|
|
1565
1600
|
});
|
|
@@ -1764,7 +1799,13 @@ server.tool('wallet_mode', 'Report whether this server is signing locally (self-
|
|
|
1764
1799
|
mode: 'custodial',
|
|
1765
1800
|
custody: 'agentwallet',
|
|
1766
1801
|
signing: 'AgentWallet servers hold an encrypted key and sign on your behalf.',
|
|
1767
|
-
api_base: API_BASE,
|
|
1802
|
+
api_base: redactUrl(API_BASE),
|
|
1803
|
+
client_guards: {
|
|
1804
|
+
autopay_cap_usd: autopayEnvCap(),
|
|
1805
|
+
autopay_assets: (process.env.AGENTWALLET_AUTOPAY_ASSETS || '').trim() || 'registry stablecoins only',
|
|
1806
|
+
x402_max_timeout_seconds: maxAuthWindowSeconds(),
|
|
1807
|
+
note: 'These bound pay_x402 on hosted wallets too; the hosted signer adds its own pause, daily limit and approval checks.',
|
|
1808
|
+
},
|
|
1768
1809
|
to_self_custody: 'Set AGENTWALLET_PRIVATE_KEY for EVM and/or AGENTWALLET_SOLANA_KEY for Solana, then restart. ' +
|
|
1769
1810
|
'Use export_wallet_key first if you want to carry an existing hosted wallet across.',
|
|
1770
1811
|
});
|
|
@@ -1864,7 +1905,7 @@ async function main() {
|
|
|
1864
1905
|
}
|
|
1865
1906
|
}
|
|
1866
1907
|
else {
|
|
1867
|
-
console.error('AgentWallet MCP: custodial mode via ' + API_BASE + '. Run wallet_mode for details.');
|
|
1908
|
+
console.error('AgentWallet MCP: custodial mode via ' + redactUrl(API_BASE) + '. Run wallet_mode for details.');
|
|
1868
1909
|
}
|
|
1869
1910
|
}
|
|
1870
1911
|
main().catch((error) => {
|
package/build/local-solana.d.ts
CHANGED
|
@@ -16,6 +16,8 @@ export declare function isSolanaLocalMode(): boolean;
|
|
|
16
16
|
export declare function getSolanaKeypair(): Keypair;
|
|
17
17
|
export declare function getSolanaAddress(): string;
|
|
18
18
|
export declare function resolveSolanaRpc(chainId?: number): string;
|
|
19
|
+
/** The mint's decimals from the chain, or null when they cannot be read. Never guesses. */
|
|
20
|
+
export declare function mintDecimalsStrict(mint: string, chainId: number): Promise<number | null>;
|
|
19
21
|
export declare function localSolBalance(chainId?: number): Promise<{
|
|
20
22
|
address: string;
|
|
21
23
|
chain_id: number;
|
package/build/local-solana.js
CHANGED
|
@@ -53,6 +53,8 @@ function parseSolanaKey(raw) {
|
|
|
53
53
|
}
|
|
54
54
|
return Keypair.fromSecretKey(Uint8Array.from(buf.subarray(0, 64)));
|
|
55
55
|
}
|
|
56
|
+
if (text.length > 256)
|
|
57
|
+
throw new Error('Solana key text is too long to be a key.'); // base58 decoding is quadratic; cap before decoding
|
|
56
58
|
const decoded = bs58.decode(text);
|
|
57
59
|
if (decoded.length !== 64) {
|
|
58
60
|
throw new Error(`Base58 Solana key must decode to 64 bytes, got ${decoded.length}.`);
|
|
@@ -170,6 +172,23 @@ function assertWithinSolCap(lamports) {
|
|
|
170
172
|
* times too permissive, and SPL mints with 0 to 5 decimals are common.
|
|
171
173
|
*/
|
|
172
174
|
const splDecimalsCache = new Map();
|
|
175
|
+
/** The mint's decimals from the chain, or null when they cannot be read. Never guesses. */
|
|
176
|
+
export async function mintDecimalsStrict(mint, chainId) {
|
|
177
|
+
const known = lookupTrustedDecimals(chainId, mint);
|
|
178
|
+
if (typeof known === 'number')
|
|
179
|
+
return known;
|
|
180
|
+
try {
|
|
181
|
+
const conn = connection(chainId);
|
|
182
|
+
await assertCluster(conn, chainId);
|
|
183
|
+
const info = await conn.getParsedAccountInfo(new PublicKey(mint));
|
|
184
|
+
const data = info?.value?.data;
|
|
185
|
+
const d = Number(data?.parsed?.info?.decimals);
|
|
186
|
+
return Number.isInteger(d) && d >= 0 && d <= 36 ? d : null;
|
|
187
|
+
}
|
|
188
|
+
catch {
|
|
189
|
+
return null;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
173
192
|
async function resolveMintDecimals(mint, chainId) {
|
|
174
193
|
const known = lookupTrustedDecimals(chainId, mint);
|
|
175
194
|
if (typeof known === 'number')
|
|
@@ -373,23 +392,33 @@ function createAtaIdempotentIx(payer, ata, owner, mint, tokenProgram) {
|
|
|
373
392
|
* not exist, using the idempotent instruction so a race cannot fail the send.
|
|
374
393
|
*/
|
|
375
394
|
export async function localSplTransfer(mintStr, to, rawAmount, decimals, chainId = 900) {
|
|
376
|
-
// Checked before any RPC round trip: a blocked transfer should cost nothing
|
|
377
|
-
// and should not announce itself to the RPC provider first.
|
|
378
|
-
await assertWithinSplCap(mintStr, BigInt(rawAmount), chainId);
|
|
379
395
|
const conn = connection(chainId);
|
|
396
|
+
// The cluster is verified before the cap reads decimals from it, so the
|
|
397
|
+
// decimals cache is never filled from a node serving another chain.
|
|
380
398
|
await assertCluster(conn, chainId);
|
|
399
|
+
await assertWithinSplCap(mintStr, BigInt(rawAmount), chainId);
|
|
381
400
|
const payer = getSolanaKeypair();
|
|
382
401
|
const mint = new PublicKey(mintStr);
|
|
383
402
|
const recipient = new PublicKey(to);
|
|
384
403
|
await assertRecipientIsWallet(conn, recipient);
|
|
385
404
|
const { tokenProgram, ataSize } = await mintLayout(conn, mint);
|
|
386
|
-
// The
|
|
387
|
-
//
|
|
388
|
-
|
|
405
|
+
// The caller scaled the amount by `decimals`. That value must be the mint's
|
|
406
|
+
// real one: substituting the real value into the instruction (as 1.13.0
|
|
407
|
+
// did) let a wrong guess through, because the amount had already been
|
|
408
|
+
// scaled by the guess and TransferChecked only sees the real byte
|
|
409
|
+
// (2026-10-04 audit). A mismatch refuses.
|
|
410
|
+
const real = await mintDecimalsStrict(mintStr, chainId);
|
|
411
|
+
if (real === null)
|
|
412
|
+
throw new Error(`Could not read the decimals of mint ${mintStr} from the chain; refusing to guess the scale of the amount.`);
|
|
413
|
+
if (decimals !== real)
|
|
414
|
+
throw new Error(`decimals ${decimals} was passed but mint ${mintStr} has ${real} decimals; refusing to scale the amount by the wrong factor.`);
|
|
389
415
|
const sourceAta = associatedTokenAddress(payer.publicKey, mint, tokenProgram);
|
|
390
416
|
const destAta = associatedTokenAddress(recipient, mint, tokenProgram);
|
|
391
417
|
const tx = new Transaction();
|
|
392
418
|
const destInfo = await conn.getAccountInfo(destAta);
|
|
419
|
+
if (destInfo && !destInfo.owner.equals(tokenProgram)) {
|
|
420
|
+
throw new Error(`Refusing to send: the recipient's token account address ${destAta.toBase58()} is already occupied by an account the token program does not own, so no token account can be created there.`);
|
|
421
|
+
}
|
|
393
422
|
if (!destInfo) {
|
|
394
423
|
// Creating the recipient's token account costs the payer rent in SOL, an
|
|
395
424
|
// outflow the SOL cap must see (0.002 SOL a time adds up over many sends).
|
|
@@ -442,6 +471,14 @@ async function assertRecipientIsWallet(conn, recipient) {
|
|
|
442
471
|
}
|
|
443
472
|
throw new Error(`Refusing to send to ${recipient.toBase58()}: it is an account owned by the SPL token program, not a wallet.`);
|
|
444
473
|
}
|
|
474
|
+
// Everything else that exists must be a plain system account: a program's
|
|
475
|
+
// data or buffer account, an address lookup table, a stake or vote account
|
|
476
|
+
// is owned by its program and nothing can ever sign for it as a wallet
|
|
477
|
+
// (2026-10-04 audit). A program-derived vault that holds tokens is still
|
|
478
|
+
// system-owned, so this costs nothing legitimate.
|
|
479
|
+
if (!info.owner.equals(SystemProgram.programId)) {
|
|
480
|
+
throw new Error(`Refusing to send to ${recipient.toBase58()}: it is owned by program ${info.owner.toBase58()}, not a wallet. Pass a wallet address.`);
|
|
481
|
+
}
|
|
445
482
|
}
|
|
446
483
|
export function solanaWalletRecord() {
|
|
447
484
|
return {
|
package/build/local-wallet.d.ts
CHANGED
|
@@ -67,6 +67,28 @@ export interface LocalSendResult {
|
|
|
67
67
|
mode: 'local';
|
|
68
68
|
signed_locally: true;
|
|
69
69
|
}
|
|
70
|
+
/**
|
|
71
|
+
* What a decimals() probe learned about an address. The four outcomes are
|
|
72
|
+
* kept apart on purpose: only `not-a-token` may ever reach an allow branch.
|
|
73
|
+
*
|
|
74
|
+
* - a number: the contract answered decimals() with a usable value
|
|
75
|
+
* - `unusable`: it answered, but with a value no real token has (outside
|
|
76
|
+
* 0..36) or data that does not decode; it IS a contract that implements
|
|
77
|
+
* decimals(), so treat it as a token and price nothing on trust
|
|
78
|
+
* - `not-a-token`: the call reverted or returned no data, which is how a
|
|
79
|
+
* router, bridge or plain account answers
|
|
80
|
+
* - `unreachable`: the RPC failed, so nothing is known either way
|
|
81
|
+
*
|
|
82
|
+
* Reported privately 2026-09-26: the previous shape collapsed `unusable` and
|
|
83
|
+
* `unreachable` into the same null as `not-a-token`, so a token whose
|
|
84
|
+
* decimals() returned 77 was classified as "not a token" and unpriced
|
|
85
|
+
* calldata to it went through the cap unrefused. Byte-identical contracts
|
|
86
|
+
* differing only in that immutable were blocked (18) and sent (77).
|
|
87
|
+
*/
|
|
88
|
+
type DecimalsProbe = number | 'unusable' | 'not-a-token' | 'unreachable';
|
|
89
|
+
/** An eth_call failure is a revert only when the node says so; every other failure is "could not ask". */
|
|
90
|
+
export declare function classifyDecimalsError(err: unknown): DecimalsProbe;
|
|
91
|
+
export declare function permit2AddressFor(chainId: number): string;
|
|
70
92
|
export declare function localSend(chainId: number, to: Address, valueWei: bigint, data?: Hex): Promise<LocalSendResult>;
|
|
71
93
|
/** ERC-20 transfer, encoded locally. */
|
|
72
94
|
export declare function localTransferToken(chainId: number, token: Address, to: Address, rawAmount: string): Promise<LocalSendResult>;
|
|
@@ -119,3 +141,4 @@ export declare function localSignPermit2Upto(chainId: number, auth: UptoPermit2A
|
|
|
119
141
|
from: Address;
|
|
120
142
|
mode: 'local';
|
|
121
143
|
}>;
|
|
144
|
+
export {};
|
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,
|
|
18
|
+
import { createPublicClient, createWalletClient, http, encodeFunctionData, parseAbi, formatUnits, } from 'viem';
|
|
19
19
|
import { privateKeyToAccount } from 'viem/accounts';
|
|
20
20
|
import { lookupTrustedDecimals, maxAuthWindowSeconds } from './x402-payment.js';
|
|
21
21
|
import { wrappedNativeAddress } from './wrapped-native.js';
|
|
@@ -49,12 +49,19 @@ function loadKeyMaterial() {
|
|
|
49
49
|
}
|
|
50
50
|
return null;
|
|
51
51
|
}
|
|
52
|
+
const SECP256K1_N = BigInt('0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141');
|
|
52
53
|
function normalizeKey(raw) {
|
|
53
54
|
const hex = raw.startsWith('0x') ? raw.slice(2) : raw;
|
|
54
55
|
if (!/^[0-9a-fA-F]{64}$/.test(hex)) {
|
|
55
56
|
throw new Error('Invalid private key. Expected 64 hex characters, with or without a 0x prefix. ' +
|
|
56
57
|
'The value itself is never logged.');
|
|
57
58
|
}
|
|
59
|
+
// Checked here with a fixed message: the curve library's own range error
|
|
60
|
+
// prints the offending value in decimal (2026-10-04 audit).
|
|
61
|
+
const n = BigInt('0x' + hex);
|
|
62
|
+
if (n === 0n || n >= SECP256K1_N) {
|
|
63
|
+
throw new Error('Invalid private key: the value is outside the secp256k1 range. The value itself is never logged.');
|
|
64
|
+
}
|
|
58
65
|
return `0x${hex}`;
|
|
59
66
|
}
|
|
60
67
|
/** True when the server is running non-custodially. */
|
|
@@ -68,10 +75,10 @@ export function getLocalAccount() {
|
|
|
68
75
|
if (keyLoadError)
|
|
69
76
|
throw new Error(keyLoadError);
|
|
70
77
|
const raw = loadKeyMaterial();
|
|
78
|
+
if (keyLoadError)
|
|
79
|
+
throw new Error(keyLoadError); // the keyfile's own reason, before the generic line
|
|
71
80
|
if (!raw)
|
|
72
81
|
throw new Error('Local signing mode is not configured.');
|
|
73
|
-
if (keyLoadError)
|
|
74
|
-
throw new Error(keyLoadError);
|
|
75
82
|
cachedAccount = privateKeyToAccount(normalizeKey(raw));
|
|
76
83
|
return cachedAccount;
|
|
77
84
|
}
|
|
@@ -155,8 +162,8 @@ function assertWithinNativeCap(valueWei) {
|
|
|
155
162
|
const [whole, frac = ''] = cap.split('.');
|
|
156
163
|
const capWei = BigInt(whole + frac.padEnd(18, '0').slice(0, 18));
|
|
157
164
|
if (valueWei > capWei) {
|
|
158
|
-
throw new Error(`Blocked by local guard: transaction value exceeds AGENTWALLET_MAX_TX_NATIVE (${cap}). ` +
|
|
159
|
-
`
|
|
165
|
+
throw new Error(`Blocked by local guard: transaction value exceeds AGENTWALLET_MAX_TX_NATIVE (${cap}), a cap only the operator can change. ` +
|
|
166
|
+
`Do not edit the environment or config to raise it.`);
|
|
160
167
|
}
|
|
161
168
|
}
|
|
162
169
|
/* ── Read operations ─────────────────────────────────────────────── */
|
|
@@ -230,32 +237,26 @@ async function probeTokenDecimals(chainId, token) {
|
|
|
230
237
|
const cached = decimalsCache.get(key);
|
|
231
238
|
if (typeof cached === 'number')
|
|
232
239
|
return cached;
|
|
233
|
-
|
|
240
|
+
// The raw JSON-RPC envelope is classified here rather than through viem's
|
|
241
|
+
// readContract: viem maps error code -32603 ("Internal error", what an
|
|
242
|
+
// overloaded node or a proxy answers) and an empty envelope to "the contract
|
|
243
|
+
// reverted", which is the one answer this guard treats as evidence of a
|
|
244
|
+
// non-token (2026-10-04 audit). Only an explicit revert counts as that.
|
|
245
|
+
let result;
|
|
234
246
|
try {
|
|
235
247
|
const { pub } = clients(chainId);
|
|
236
|
-
|
|
237
|
-
address: token,
|
|
238
|
-
abi: ERC20_ABI,
|
|
239
|
-
functionName: 'decimals',
|
|
240
|
-
});
|
|
248
|
+
result = await pub.request({ method: 'eth_call', params: [{ to: token, data: DECIMALS_SELECTOR }, 'latest'] });
|
|
241
249
|
}
|
|
242
250
|
catch (err) {
|
|
243
|
-
|
|
244
|
-
// the cause says whether the contract itself declined (revert, empty
|
|
245
|
-
// return: not an ERC-20) or the answer never arrived (RPC down, timeout,
|
|
246
|
-
// malformed response). Only the first is evidence of anything.
|
|
247
|
-
const declined = err instanceof BaseError && err.walk((e) => e instanceof ContractFunctionZeroDataError
|
|
248
|
-
|| e instanceof ContractFunctionRevertedError
|
|
249
|
-
|| e instanceof ExecutionRevertedError // nodes that report a revert as -32000 rather than 3
|
|
250
|
-
) !== null;
|
|
251
|
-
if (declined)
|
|
252
|
-
return 'not-a-token';
|
|
253
|
-
// A decode failure on non-empty data means the contract answered
|
|
254
|
-
// decimals() with something that is not a uint8: a token, just a broken one.
|
|
255
|
-
const decodeFailed = err instanceof BaseError && err.walk((e) => e instanceof AbiDecodingDataSizeTooSmallError || e instanceof AbiDecodingDataSizeInvalidError) !== null;
|
|
256
|
-
return decodeFailed ? 'unusable' : 'unreachable';
|
|
251
|
+
return classifyDecimalsError(err);
|
|
257
252
|
}
|
|
258
|
-
|
|
253
|
+
if (result === '0x')
|
|
254
|
+
return 'not-a-token'; // answered with nothing: no decimals() here
|
|
255
|
+
if (typeof result !== 'string' || !/^0x([0-9a-fA-F]{2})+$/.test(result))
|
|
256
|
+
return 'unreachable'; // not an answer at all
|
|
257
|
+
if (result.length !== 66)
|
|
258
|
+
return 'unusable'; // answered, but not one uint8 word
|
|
259
|
+
const d = Number(BigInt(result));
|
|
259
260
|
// Reject nonsense rather than trusting it; an out-of-range value would
|
|
260
261
|
// widen the ceiling exactly like the old assumption did.
|
|
261
262
|
if (Number.isInteger(d) && d >= 0 && d <= 36) {
|
|
@@ -264,6 +265,19 @@ async function probeTokenDecimals(chainId, token) {
|
|
|
264
265
|
}
|
|
265
266
|
return 'unusable';
|
|
266
267
|
}
|
|
268
|
+
const DECIMALS_SELECTOR = '0x313ce567';
|
|
269
|
+
/** An eth_call failure is a revert only when the node says so; every other failure is "could not ask". */
|
|
270
|
+
export function classifyDecimalsError(err) {
|
|
271
|
+
const e = err;
|
|
272
|
+
const code = typeof e?.code === 'number' ? e.code : NaN;
|
|
273
|
+
const text = [e?.message, e?.details, e?.shortMessage].filter(Boolean).join(' ');
|
|
274
|
+
const hasRevertData = typeof e?.data === 'string' && /^0x[0-9a-fA-F]*$/.test(e.data);
|
|
275
|
+
if (code === 3)
|
|
276
|
+
return 'not-a-token'; // the JSON-RPC "execution error" code
|
|
277
|
+
if ((code === -32000 || code === -32603 || code === -32015) && (/revert/i.test(text) || hasRevertData))
|
|
278
|
+
return 'not-a-token';
|
|
279
|
+
return 'unreachable';
|
|
280
|
+
}
|
|
267
281
|
/**
|
|
268
282
|
* Decimals to price a known transfer or approval at. Anything short of a
|
|
269
283
|
* usable answer is evaluated at 0, the only value that cannot fail open.
|
|
@@ -272,8 +286,16 @@ async function resolveTokenDecimals(chainId, token) {
|
|
|
272
286
|
const probe = await probeTokenDecimals(chainId, token);
|
|
273
287
|
return typeof probe === 'number' ? probe : 0;
|
|
274
288
|
}
|
|
275
|
-
/** Canonical Permit2, the
|
|
289
|
+
/** Canonical Permit2, plus the chains that run a different deployment. */
|
|
276
290
|
const PERMIT2 = '0x000000000022d473030f116ddee9f6b43ac78ba3';
|
|
291
|
+
const PERMIT2_BY_CHAIN = { 324: '0x0000000000225e31d15943971f47ad3022f714fa' }; // zkSync Era
|
|
292
|
+
export function permit2AddressFor(chainId) { return PERMIT2_BY_CHAIN[chainId] || PERMIT2; }
|
|
293
|
+
/* Permit2's own function selectors. Calldata with one of these aimed at a
|
|
294
|
+
contract that declines decimals() is a Permit2 call on a deployment this
|
|
295
|
+
table does not know, and is refused rather than let through as "a router"
|
|
296
|
+
(2026-10-04 audit). approve, permit (2 shapes), transferFrom (2), lockdown,
|
|
297
|
+
permitTransferFrom (2), permitWitnessTransferFrom (2), invalidateNonces/UnorderedNonces. */
|
|
298
|
+
const PERMIT2_SELECTORS = new Set(['87517c45', '2b67b570', '2a2d80d1', '36c78516', '0d58b1db', 'cc53287f', '30f28b7a', 'edd9444b', '137c29fe', 'fe8ec1a7', '3ff9dcb1', '0d58b1db']);
|
|
277
299
|
const CAP_LAYOUTS = {
|
|
278
300
|
a9059cbb: { amountIndex: 1, what: 'token transfer' }, // transfer(address,uint256)
|
|
279
301
|
'23b872dd': { amountIndex: 2, what: 'token transfer' }, // transferFrom(address,address,uint256)
|
|
@@ -314,7 +336,7 @@ async function assertWithinTokenCap(chainId, to, data, valueWei = 0n) {
|
|
|
314
336
|
}
|
|
315
337
|
const hex = data.slice(2);
|
|
316
338
|
const selector = hex.slice(0, 8).toLowerCase();
|
|
317
|
-
const isPermit2 = to.toLowerCase() === PERMIT2;
|
|
339
|
+
const isPermit2 = to.toLowerCase() === PERMIT2 || to.toLowerCase() === permit2AddressFor(chainId);
|
|
318
340
|
const layout = CAP_LAYOUTS[selector];
|
|
319
341
|
const isWrappedNative = to.toLowerCase() === wrappedNativeAddress(chainId);
|
|
320
342
|
const priced = layout
|
|
@@ -335,7 +357,11 @@ async function assertWithinTokenCap(chainId, to, data, valueWei = 0n) {
|
|
|
335
357
|
throw new Error(`Blocked by local guard: calldata selector 0x${selector} refused rather than let through uncapped: ${why}. ` +
|
|
336
358
|
`Use transfer, transferFrom, approve, increaseAllowance, Permit2 approve, ` +
|
|
337
359
|
`or deposit/withdraw on the chain's wrapped-native contract, ` +
|
|
338
|
-
`or set AGENTWALLET_ALLOW_UNKNOWN_TOKEN_CALLS=1
|
|
360
|
+
`or ask the operator, who alone can set AGENTWALLET_ALLOW_UNKNOWN_TOKEN_CALLS=1.`);
|
|
361
|
+
}
|
|
362
|
+
if (PERMIT2_SELECTORS.has(selector)) {
|
|
363
|
+
throw new Error(`Blocked by local guard: calldata selector 0x${selector} is a Permit2 function aimed at ${to}, which is not the Permit2 deployment this guard knows for chain ${chainId}; ` +
|
|
364
|
+
`refused rather than let through uncapped. Only the operator can allow it (AGENTWALLET_ALLOW_UNKNOWN_TOKEN_CALLS=1).`);
|
|
339
365
|
}
|
|
340
366
|
return; // an ordinary contract call; it can only pull what an existing approval allows (see the note above)
|
|
341
367
|
}
|
|
@@ -367,8 +393,8 @@ async function assertWithinTokenCap(chainId, to, data, valueWei = 0n) {
|
|
|
367
393
|
const capRaw = BigInt(whole + frac.padEnd(decimals, '0').slice(0, decimals));
|
|
368
394
|
if (amount > capRaw) {
|
|
369
395
|
throw new Error(`Blocked by local guard: ${layout.what} of ${amount} base units exceeds ` +
|
|
370
|
-
`AGENTWALLET_MAX_TX_TOKEN (${cap}, evaluated at ${decimals} decimals). ` +
|
|
371
|
-
`
|
|
396
|
+
`AGENTWALLET_MAX_TX_TOKEN (${cap}, evaluated at ${decimals} decimals), a cap only the operator can change. ` +
|
|
397
|
+
`Do not edit the environment or config to raise it.`);
|
|
372
398
|
}
|
|
373
399
|
// increaseAllowance ADDS to what a spender may already pull. Checking the
|
|
374
400
|
// increment alone let two individually compliant calls leave a standing
|
|
@@ -393,7 +419,7 @@ async function assertWithinTokenCap(chainId, to, data, valueWei = 0n) {
|
|
|
393
419
|
if (total > capRaw) {
|
|
394
420
|
throw new Error(`Blocked by local guard: ${layout.what} of ${amount} base units would leave a standing allowance of ${total} base units ` +
|
|
395
421
|
`for ${spender}, above AGENTWALLET_MAX_TX_TOKEN (${cap}, evaluated at ${decimals} decimals). ` +
|
|
396
|
-
`Use approve with an absolute amount
|
|
422
|
+
`Use approve with an absolute amount; the cap itself is the operator's to change.`);
|
|
397
423
|
}
|
|
398
424
|
}
|
|
399
425
|
}
|
package/build/ssrf-guard.d.ts
CHANGED
|
@@ -96,6 +96,8 @@ export declare function headersForRedirect(headers: HeadersInit | undefined, fro
|
|
|
96
96
|
* take the wallet process down. Wire bytes are capped separately on the Agent.
|
|
97
97
|
*/
|
|
98
98
|
export declare const MAX_RESPONSE_BYTES: number;
|
|
99
|
+
/** Deadline applied when the caller passes no AbortSignal. */
|
|
100
|
+
export declare const DEFAULT_FETCH_TIMEOUT_MS = 30000;
|
|
99
101
|
/** Header safeFetch adds to every response: the URL the body actually came from, after redirects. */
|
|
100
102
|
export declare const FINAL_URL_HEADER = "x-agw-final-url";
|
|
101
103
|
/** The URL a safeFetch response actually came from, after every redirect. Only safeFetch can set it. */
|
package/build/ssrf-guard.js
CHANGED
|
@@ -215,6 +215,11 @@ export async function resolvePublicUrl(url) {
|
|
|
215
215
|
throw new Error(`Could not resolve host "${bare}".`);
|
|
216
216
|
const addresses = [];
|
|
217
217
|
for (const a of answers) {
|
|
218
|
+
// Only a canonical IP string is judged; anything else from a resolver is
|
|
219
|
+
// refused rather than passed through isPrivateAddress, which cannot
|
|
220
|
+
// classify a non-canonical spelling and would answer "public".
|
|
221
|
+
if (isIP(a.address) === 0)
|
|
222
|
+
throw new Error(`Resolver returned a non-canonical address for "${bare}"; refusing.`);
|
|
218
223
|
if (isPrivateAddress(a.address)) {
|
|
219
224
|
throw new Error('URL resolves to a private/internal address. Only public URLs are allowed.');
|
|
220
225
|
}
|
|
@@ -328,6 +333,8 @@ export function headersForRedirect(headers, from, to, bodyDropped) {
|
|
|
328
333
|
* take the wallet process down. Wire bytes are capped separately on the Agent.
|
|
329
334
|
*/
|
|
330
335
|
export const MAX_RESPONSE_BYTES = 8 * 1024 * 1024;
|
|
336
|
+
/** Deadline applied when the caller passes no AbortSignal. */
|
|
337
|
+
export const DEFAULT_FETCH_TIMEOUT_MS = 30_000;
|
|
331
338
|
/** Header safeFetch adds to every response: the URL the body actually came from, after redirects. */
|
|
332
339
|
export const FINAL_URL_HEADER = 'x-agw-final-url';
|
|
333
340
|
/* The final URL is also recorded here, keyed by the Response object, and
|
|
@@ -397,7 +404,8 @@ async function readBounded(stream, limit) {
|
|
|
397
404
|
*/
|
|
398
405
|
export async function safeFetch(url, options = {}, maxHops = 3) {
|
|
399
406
|
let current = url;
|
|
400
|
-
|
|
407
|
+
// A caller that forgets a signal still gets a deadline; a silent server must not hold the process.
|
|
408
|
+
let opts = options.signal ? options : { ...options, signal: AbortSignal.timeout(DEFAULT_FETCH_TIMEOUT_MS) };
|
|
401
409
|
for (let hop = 0; hop <= maxHops; hop++) {
|
|
402
410
|
const { addresses } = await resolvePublicUrl(current);
|
|
403
411
|
// One agent per hop, carrying that hop's pinned addresses. The hostname is
|
package/build/x402-eip3009.d.ts
CHANGED
|
@@ -81,6 +81,8 @@ export declare function isLegacyAgentWalletAccept(a: X402Requirement): boolean;
|
|
|
81
81
|
export declare function requiredAmount(a: X402Requirement): string;
|
|
82
82
|
export declare function createNonce(): `0x${string}`;
|
|
83
83
|
/** Authorization window mirrors the reference client: valid from 10 minutes ago until maxTimeoutSeconds from now. */
|
|
84
|
+
/** Shortest validity window signed, whatever the server asks for. */
|
|
85
|
+
export declare const MIN_WINDOW_SECONDS = 60;
|
|
84
86
|
export declare function buildAuthorization(from: string, req: X402Requirement, nowSeconds?: number, nonce?: `0x${string}`): Eip3009Authorization;
|
|
85
87
|
/** EIP-712 typed data for the authorization. `name`/`version` are the token's own domain values. */
|
|
86
88
|
export declare function typedDataFor(chainId: number, asset: string, name: string, version: string, auth: Eip3009Authorization): {
|
package/build/x402-eip3009.js
CHANGED
|
@@ -52,6 +52,8 @@ export function createNonce() {
|
|
|
52
52
|
}
|
|
53
53
|
const DEFAULT_TIMEOUT_SECONDS = 300;
|
|
54
54
|
/** Authorization window mirrors the reference client: valid from 10 minutes ago until maxTimeoutSeconds from now. */
|
|
55
|
+
/** Shortest validity window signed, whatever the server asks for. */
|
|
56
|
+
export const MIN_WINDOW_SECONDS = 60;
|
|
55
57
|
export function buildAuthorization(from, req, nowSeconds = Math.floor(Date.now() / 1000), nonce = createNonce()) {
|
|
56
58
|
const value = requiredAmount(req);
|
|
57
59
|
if (!/^\d+$/.test(value))
|
|
@@ -63,7 +65,9 @@ export function buildAuthorization(from, req, nowSeconds = Math.floor(Date.now()
|
|
|
63
65
|
// asking for years would otherwise hold one until the wallet is refilled.
|
|
64
66
|
const requested = Number.isFinite(req.maxTimeoutSeconds) && req.maxTimeoutSeconds > 0
|
|
65
67
|
? Math.floor(req.maxTimeoutSeconds) : DEFAULT_TIMEOUT_SECONDS;
|
|
66
|
-
|
|
68
|
+
// Floor as well as cap: a server asking for a 2-second window would make
|
|
69
|
+
// every retry sign a fresh nonce it can also settle (2026-10-04 audit).
|
|
70
|
+
const timeout = Math.max(Math.min(requested, maxAuthWindowSeconds()), Math.min(MIN_WINDOW_SECONDS, maxAuthWindowSeconds()));
|
|
67
71
|
return {
|
|
68
72
|
from: from,
|
|
69
73
|
to: req.payTo,
|
package/build/x402-payment.d.ts
CHANGED
|
@@ -66,11 +66,20 @@ export declare function assetLabel(chainId: number, asset: string): string;
|
|
|
66
66
|
* list of token addresses / mints, or the word "native". Listed assets are
|
|
67
67
|
* then capped in their own units, which the operator has opted into knowingly.
|
|
68
68
|
*/
|
|
69
|
+
export declare function parseAutopayAssets(raw: string | undefined): Array<{
|
|
70
|
+
chainId: number | null;
|
|
71
|
+
asset: string;
|
|
72
|
+
}>;
|
|
69
73
|
export declare function autopayAssetAllowed(chainId: number, asset: string): {
|
|
70
74
|
allowed: boolean;
|
|
71
75
|
via: 'stablecoin' | 'allowlist' | 'none';
|
|
72
76
|
reason?: string;
|
|
73
77
|
};
|
|
78
|
+
export declare const X402_NETWORK_IDS: Record<string, number>;
|
|
79
|
+
/** A positive integer chain id for an x402 network string, or null. Never guesses. */
|
|
80
|
+
export declare function resolveNetworkChainId(network: string): number | null;
|
|
81
|
+
/** An integer base-unit amount in canonical form: "0010000" and "10000" are one amount, not two cache keys. */
|
|
82
|
+
export declare function normalizeRawAmount(raw: string): string;
|
|
74
83
|
/** The operator's per-payment ceiling, as configured. Validated so a typo never reads as "unlimited". */
|
|
75
84
|
export declare function autopayEnvCap(): string;
|
|
76
85
|
/**
|
package/build/x402-payment.js
CHANGED
|
@@ -110,21 +110,73 @@ export function assetLabel(chainId, asset) {
|
|
|
110
110
|
* list of token addresses / mints, or the word "native". Listed assets are
|
|
111
111
|
* then capped in their own units, which the operator has opted into knowingly.
|
|
112
112
|
*/
|
|
113
|
+
/* One AGENTWALLET_AUTOPAY_ASSETS entry: "8453:0x..." or "900:native" names one
|
|
114
|
+
chain; a bare "0x..." / "native" applies on every chain (the original
|
|
115
|
+
grammar, kept so existing configs keep working, but the same address is a
|
|
116
|
+
different contract on another chain, so scoped entries are the safer form). */
|
|
117
|
+
export function parseAutopayAssets(raw) {
|
|
118
|
+
return (raw || '').split(',').map(s => s.trim()).filter(Boolean).map(e => {
|
|
119
|
+
const m = e.match(/^(\d+):(.+)$/);
|
|
120
|
+
const chainId = m ? parseInt(m[1], 10) : null;
|
|
121
|
+
const a = (m ? m[2] : e).trim();
|
|
122
|
+
return { chainId, asset: a.startsWith('0x') ? a.toLowerCase() : a };
|
|
123
|
+
});
|
|
124
|
+
}
|
|
113
125
|
export function autopayAssetAllowed(chainId, asset) {
|
|
114
126
|
if (isStableAsset(chainId, asset))
|
|
115
127
|
return { allowed: true, via: 'stablecoin' };
|
|
116
|
-
const list = (process.env.AGENTWALLET_AUTOPAY_ASSETS || '').split(',').map(s => s.trim()).filter(Boolean);
|
|
117
128
|
const wanted = asset ? (asset.startsWith('0x') ? asset.toLowerCase() : asset) : 'native';
|
|
118
|
-
|
|
129
|
+
const hit = parseAutopayAssets(process.env.AGENTWALLET_AUTOPAY_ASSETS).some(e => e.asset === wanted && (e.chainId === null || e.chainId === chainId));
|
|
130
|
+
if (hit)
|
|
119
131
|
return { allowed: true, via: 'allowlist' };
|
|
120
132
|
const label = assetLabel(chainId, asset);
|
|
121
133
|
return {
|
|
122
134
|
allowed: false, via: 'none',
|
|
123
135
|
reason: `x402 blocked: this endpoint wants payment in ${label}${asset ? ` (${asset})` : ''} on chain ${chainId}, which is not a stablecoin ` +
|
|
124
|
-
`AGENTWALLET_MAX_AUTOPAY can price
|
|
125
|
-
`the
|
|
136
|
+
`AGENTWALLET_MAX_AUTOPAY can price and is not on the operator's AGENTWALLET_AUTOPAY_ASSETS list for chain ${chainId}. ` +
|
|
137
|
+
`Only the operator can change that list; do not edit the environment or config to add it.`,
|
|
126
138
|
};
|
|
127
139
|
}
|
|
140
|
+
/* The x402 network names this client knows. Looked up with Object.hasOwn so a
|
|
141
|
+
name like "constructor" or "__proto__" (inherited from Object.prototype)
|
|
142
|
+
is unknown rather than a truthy function (2026-10-04 audit). */
|
|
143
|
+
export const X402_NETWORK_IDS = {
|
|
144
|
+
'ethereum': 1, 'base': 8453, 'base-sepolia': 84532, 'polygon': 137, 'arbitrum': 42161, 'optimism': 10,
|
|
145
|
+
'bsc': 56, 'avalanche': 43114, 'zora': 7777777, 'pulsechain': 369, 'solana': 900, 'solana-devnet': 901,
|
|
146
|
+
};
|
|
147
|
+
const SOLANA_GENESIS_PREFIX = { 900: '5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp', 901: 'EtWTRABZaYq6iMfeYKouRu166VU2xqa1' };
|
|
148
|
+
/** A positive integer chain id for an x402 network string, or null. Never guesses. */
|
|
149
|
+
export function resolveNetworkChainId(network) {
|
|
150
|
+
if (typeof network !== 'string')
|
|
151
|
+
return null;
|
|
152
|
+
const lower = network.toLowerCase();
|
|
153
|
+
if (Object.hasOwn(X402_NETWORK_IDS, lower))
|
|
154
|
+
return X402_NETWORK_IDS[lower];
|
|
155
|
+
const caip = network.match(/^eip155:(\d{1,12})$/);
|
|
156
|
+
if (caip) {
|
|
157
|
+
const n = Number(caip[1]);
|
|
158
|
+
return Number.isSafeInteger(n) && n > 0 ? n : null;
|
|
159
|
+
}
|
|
160
|
+
const sol = network.match(/^solana:(.+)$/);
|
|
161
|
+
if (sol) {
|
|
162
|
+
for (const [id, g] of Object.entries(SOLANA_GENESIS_PREFIX)) {
|
|
163
|
+
if (sol[1] === g)
|
|
164
|
+
return Number(id);
|
|
165
|
+
}
|
|
166
|
+
return null;
|
|
167
|
+
}
|
|
168
|
+
if (/^\d{1,12}$/.test(network)) {
|
|
169
|
+
const n = Number(network);
|
|
170
|
+
return n > 0 ? n : null;
|
|
171
|
+
}
|
|
172
|
+
return null;
|
|
173
|
+
}
|
|
174
|
+
/** An integer base-unit amount in canonical form: "0010000" and "10000" are one amount, not two cache keys. */
|
|
175
|
+
export function normalizeRawAmount(raw) {
|
|
176
|
+
if (!/^\d+$/.test(raw))
|
|
177
|
+
throw new Error(`x402: invalid amount "${raw}" in the payment requirements (expected integer base units).`);
|
|
178
|
+
return BigInt(raw).toString();
|
|
179
|
+
}
|
|
128
180
|
/** The operator's per-payment ceiling, as configured. Validated so a typo never reads as "unlimited". */
|
|
129
181
|
export function autopayEnvCap() {
|
|
130
182
|
const raw = (process.env.AGENTWALLET_MAX_AUTOPAY || '').trim() || '1';
|
|
@@ -287,7 +339,7 @@ export function deriveX402Payment(accept, maxAutopayHuman, trustedDecimals) {
|
|
|
287
339
|
const capRaw = toBaseUnits(maxAutopayHuman, trustedDecimals);
|
|
288
340
|
if (BigInt(rawAmount) > BigInt(capRaw)) {
|
|
289
341
|
throw new Error(`x402 auto-pay blocked: required amount (${rawAmount} base units) exceeds the ` +
|
|
290
|
-
`AGENTWALLET_MAX_AUTOPAY cap of ${maxAutopayHuman}
|
|
342
|
+
`AGENTWALLET_MAX_AUTOPAY cap of ${maxAutopayHuman}, which only the operator can change.`);
|
|
291
343
|
}
|
|
292
344
|
return { tokenAddress, rawAmount, decimals: trustedDecimals };
|
|
293
345
|
}
|
|
@@ -346,12 +398,14 @@ export function authorizationResource(url) {
|
|
|
346
398
|
export function approvalRefusal(approvalId, row, want, localMode) {
|
|
347
399
|
if (localMode) {
|
|
348
400
|
return {
|
|
349
|
-
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
|
|
401
|
+
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; the caps are the operator's and only the operator can change them.`,
|
|
350
402
|
};
|
|
351
403
|
}
|
|
352
404
|
const status = row?.status ?? null;
|
|
353
|
-
// "used" must
|
|
354
|
-
|
|
405
|
+
// "used" must be exactly the number 0 or the string "0" (wpdb rows carry
|
|
406
|
+
// strings). null, "", false and anything else refuse: Number(null) is 0.
|
|
407
|
+
const unused = row?.used === 0 || row?.used === '0';
|
|
408
|
+
if (status !== 'approved' || !unused) {
|
|
355
409
|
const state = status === 'approved' ? 'already used' : `${status ?? 'unknown'}, not approved`;
|
|
356
410
|
return { approval_status: status, error: `Approval ${approvalId} is ${state}.${row?.error ? ' ' + row.error : ''}` };
|
|
357
411
|
}
|
package/build/x402-permit2.d.ts
CHANGED
|
@@ -119,6 +119,6 @@ export declare function uptoTypedData(chainId: number, auth: UptoPermit2Authoriz
|
|
|
119
119
|
export declare function uptoPayload(auth: UptoPermit2Authorization, signature: string): Record<string, unknown>;
|
|
120
120
|
/** allowance(owner, Permit2) calldata. */
|
|
121
121
|
export declare function permit2AllowanceCalldata(owner: string): `0x${string}`;
|
|
122
|
-
/** approve(Permit2,
|
|
123
|
-
export declare function permit2ApproveCalldata(): `0x${string}`;
|
|
122
|
+
/** approve(Permit2, amount) calldata; unlimited only when no amount is given. */
|
|
123
|
+
export declare function permit2ApproveCalldata(amountRaw?: string): `0x${string}`;
|
|
124
124
|
export declare function decodeUint(hex: string): bigint;
|
package/build/x402-permit2.js
CHANGED
|
@@ -52,7 +52,7 @@ export function buildUptoAuthorization(from, req, nowSeconds = Math.floor(Date.n
|
|
|
52
52
|
if (!/^0x[0-9a-fA-F]{40}$/.test(req.payTo))
|
|
53
53
|
throw new Error(`x402 upto: payTo is not an EVM address: "${req.payTo}".`);
|
|
54
54
|
const requested = Number.isFinite(req.maxTimeoutSeconds) && req.maxTimeoutSeconds > 0 ? Math.floor(req.maxTimeoutSeconds) : 300;
|
|
55
|
-
const timeout = Math.min(requested, maxAuthWindowSeconds()); // see buildAuthorization
|
|
55
|
+
const timeout = Math.max(Math.min(requested, maxAuthWindowSeconds()), Math.min(60, maxAuthWindowSeconds())); // see buildAuthorization
|
|
56
56
|
return {
|
|
57
57
|
from: from,
|
|
58
58
|
permitted: { token: req.asset, amount },
|
|
@@ -85,9 +85,10 @@ function pad(addr) { return addr.replace(/^0x/, '').toLowerCase().padStart(64, '
|
|
|
85
85
|
export function permit2AllowanceCalldata(owner) {
|
|
86
86
|
return ('0xdd62ed3e' + pad(owner) + pad(PERMIT2_ADDRESS));
|
|
87
87
|
}
|
|
88
|
-
/** approve(Permit2,
|
|
89
|
-
export function permit2ApproveCalldata() {
|
|
90
|
-
|
|
88
|
+
/** approve(Permit2, amount) calldata; unlimited only when no amount is given. */
|
|
89
|
+
export function permit2ApproveCalldata(amountRaw) {
|
|
90
|
+
const word = amountRaw === undefined ? 'f'.repeat(64) : BigInt(amountRaw).toString(16).padStart(64, '0');
|
|
91
|
+
return ('0x095ea7b3' + pad(PERMIT2_ADDRESS) + word);
|
|
91
92
|
}
|
|
92
93
|
export function decodeUint(hex) {
|
|
93
94
|
const h = (hex || '').replace(/^0x/, '');
|
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.
|
|
4
|
+
"version": "1.13.5",
|
|
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 && 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",
|
|
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 && node test/final-url-guard.test.mjs && node test/audit-1135.test.mjs && node test/local-no-autopay.test.mjs",
|
|
20
20
|
"prepublishOnly": "npm run build"
|
|
21
21
|
},
|
|
22
22
|
"keywords": [
|