agentwallet-mcp 1.13.5 → 1.13.6
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/build/index.js +143 -98
- package/build/local-solana.js +19 -10
- package/build/local-wallet.js +11 -2
- package/build/x402-eip3009.d.ts +10 -0
- package/build/x402-eip3009.js +68 -3
- package/build/x402-payment.d.ts +2 -0
- package/build/x402-payment.js +17 -7
- package/build/x402-permit2.d.ts +4 -3
- package/build/x402-permit2.js +10 -5
- package/package.json +2 -2
package/build/index.js
CHANGED
|
@@ -13,14 +13,14 @@ 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, resolveNetworkChainId, normalizeRawAmount, parseAutopayAssets, } 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, assetSupportsExact, } from './x402-payment.js';
|
|
17
17
|
import { createHash } from 'node:crypto';
|
|
18
18
|
import { isLocalMode, getLocalAddress, localWalletRecord, localSend, localNativeBalance, localTokenBalance, localSignMessage, resolveRpcUrl, } from './local-wallet.js';
|
|
19
19
|
import { localSignAuthorization, localEthCall, localGetCode, localSignPermit2Upto } from './local-wallet.js';
|
|
20
20
|
import { WRAPPED_NATIVE } from './wrapped-native.js';
|
|
21
|
-
import { buildUptoAuthorization, uptoPayload, permit2AllowanceCalldata, permit2ApproveCalldata, decodeUint,
|
|
21
|
+
import { buildUptoAuthorization, uptoPayload, permit2AllowanceCalldata, permit2ApproveCalldata, decodeUint, permit2Address, } from './x402-permit2.js';
|
|
22
22
|
import { assessTokenRisk } from './token-risk.js';
|
|
23
|
-
import { isLegacyAgentWalletAccept, requiredAmount, buildAuthorization, buildPaymentPayload, buildPaymentPayloadRaw, paymentHeaderFor, parsePaymentRequired, parseSettlement, pickOption, knownTokenDomain, parseEip7702Delegation, erc1271ProbeCalldata, classifyErc1271Probe, } from './x402-eip3009.js';
|
|
23
|
+
import { isLegacyAgentWalletAccept, requiredAmount, buildAuthorization, buildPaymentPayload, buildPaymentPayloadRaw, paymentHeaderFor, parsePaymentRequired, parseSettlement, pickOption, knownTokenDomain, parseEip7702Delegation, erc1271ProbeCalldata, classifyErc1271Probe, settlementVerdict, legacyReceiptAllowed, } from './x402-eip3009.js';
|
|
24
24
|
import { isSolanaLocalMode, getSolanaAddress, solanaWalletRecord, localSolBalance, localSplBalance, localSolTransfer, localSplTransfer, resolveSolanaRpc, mintDecimalsStrict, } from './local-solana.js';
|
|
25
25
|
/** True when either chain family is running non-custodially. */
|
|
26
26
|
function anyLocalMode() {
|
|
@@ -232,7 +232,7 @@ async function api(path, method = 'GET', body, extraHeaders) {
|
|
|
232
232
|
// redirect is refused instead of followed, so a request body is never
|
|
233
233
|
// replayed to another origin and another origin's answer is never returned
|
|
234
234
|
// as the API's (2026-10-04 audit).
|
|
235
|
-
const options = { method, headers, redirect: 'manual' };
|
|
235
|
+
const options = { method, headers, redirect: 'manual', signal: AbortSignal.timeout(API_TIMEOUT_MS) };
|
|
236
236
|
if (body && method !== 'GET') {
|
|
237
237
|
options.body = JSON.stringify(body);
|
|
238
238
|
}
|
|
@@ -426,7 +426,8 @@ function isValidAddress(address) {
|
|
|
426
426
|
}
|
|
427
427
|
// ─── Guard helpers ──────────────────────────────────────────────
|
|
428
428
|
/** Request headers the caller may not set on pay_x402: Host would rename the TLS peer, the rest belong to the client. */
|
|
429
|
-
const FORBIDDEN_REQUEST_HEADERS = new Set(['host', 'content-length', 'transfer-encoding', 'connection', 'upgrade', 'expect', 'te', 'trailer', 'keep-alive'
|
|
429
|
+
const FORBIDDEN_REQUEST_HEADERS = new Set(['host', 'content-length', 'transfer-encoding', 'connection', 'upgrade', 'expect', 'te', 'trailer', 'keep-alive',
|
|
430
|
+
'x-payment', 'payment-signature', 'x-payment-response', 'payment-response']); // the payment headers are the tool's own to set
|
|
430
431
|
/** Authorizations signed in this process, keyed by endpoint and requirement, kept until they expire. */
|
|
431
432
|
const signingInFlight = new Map();
|
|
432
433
|
const signedPayments = new Map();
|
|
@@ -450,6 +451,7 @@ function redactUrl(u) {
|
|
|
450
451
|
}
|
|
451
452
|
}
|
|
452
453
|
const API_MAX_RESPONSE_CHARS = 8 * 1024 * 1024;
|
|
454
|
+
const API_TIMEOUT_MS = 60_000; // a hosted hop that hangs must not hold a signing gate open
|
|
453
455
|
/* Error text from the local signing path with every URL reduced to its origin
|
|
454
456
|
and viem's "URL:" / "Request body:" lines dropped: an RPC endpoint carries
|
|
455
457
|
its API key in the path or query, and a tool result is the agent's context
|
|
@@ -458,7 +460,7 @@ function redactRpcSecrets(err) {
|
|
|
458
460
|
const e = err;
|
|
459
461
|
let msg = e?.shortMessage ? [e.shortMessage, e.details].filter(Boolean).join(' ') : String(e?.message ?? err);
|
|
460
462
|
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"'<>)
|
|
463
|
+
return msg.replace(/[a-z][a-z0-9+.-]*:\/\/[^\s"'<>)]+/gi, (u) => redactUrl(u)); // brackets stay in: IPv6 hosts
|
|
462
464
|
}
|
|
463
465
|
/**
|
|
464
466
|
* Decimals to scale a human amount by. Always the token's own value (registry,
|
|
@@ -531,7 +533,7 @@ function formatUnits(raw, decimals) {
|
|
|
531
533
|
const frac = padded.slice(padded.length - decimals);
|
|
532
534
|
// Trim trailing zeros but keep at least one decimal
|
|
533
535
|
const trimmed = frac.replace(/0+$/, '') || '0';
|
|
534
|
-
return `${whole}.${trimmed}`;
|
|
536
|
+
return decimals === 0 ? whole : `${whole}.${trimmed}`; // a 0-decimal amount has no fraction, so it round-trips through parseUnits
|
|
535
537
|
}
|
|
536
538
|
/**
|
|
537
539
|
* Pad an address to 32 bytes (64 hex chars) for ABI encoding.
|
|
@@ -577,7 +579,7 @@ function resolveChainId(network) {
|
|
|
577
579
|
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');
|
|
578
580
|
const server = new McpServer({
|
|
579
581
|
name: 'agentwallet',
|
|
580
|
-
version: '1.13.
|
|
582
|
+
version: '1.13.6',
|
|
581
583
|
}, {
|
|
582
584
|
instructions: `AgentWallet gives AI agents their own blockchain wallets. ${anyLocalMode()
|
|
583
585
|
? '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.'
|
|
@@ -946,7 +948,7 @@ server.tool('approve_token', 'Approve a spender contract to transfer ERC-20 toke
|
|
|
946
948
|
// Asset risk rides along with the approval (issue #13). It never blocks; it is the
|
|
947
949
|
// caller's call. Skipped for Permit2, which only moves what a signature allows.
|
|
948
950
|
let risk = null;
|
|
949
|
-
if (spender.toLowerCase() !==
|
|
951
|
+
if (spender.toLowerCase() !== permit2Address(chain_id).toLowerCase() && process.env.AGENTWALLET_TOKEN_RISK !== '0') {
|
|
950
952
|
try {
|
|
951
953
|
risk = await assessTokenRisk(chain_id, token, (to, data) => ethCallHex(chain_id, to, data));
|
|
952
954
|
}
|
|
@@ -1175,11 +1177,17 @@ async function ethCallHex(chainId, to, data) {
|
|
|
1175
1177
|
* rather than guess.
|
|
1176
1178
|
*/
|
|
1177
1179
|
async function resolveTokenDomain(chainId, asset, extra) {
|
|
1178
|
-
if (extra?.name && extra?.version)
|
|
1179
|
-
return { name: String(extra.name), version: String(extra.version) };
|
|
1180
1180
|
const known = knownTokenDomain(chainId, asset);
|
|
1181
|
-
|
|
1181
|
+
const declared = (typeof extra?.name === 'string' && typeof extra?.version === 'string') ? { name: extra.name, version: extra.version } : null;
|
|
1182
|
+
if (known) {
|
|
1183
|
+
// A registry token's domain is known; a 402 declaring a different one would only yield a signature the token rejects.
|
|
1184
|
+
if (declared && (declared.name !== known.name || declared.version !== known.version)) {
|
|
1185
|
+
throw new Error(`x402: the endpoint declared EIP-712 domain "${serverText(declared.name)}"/"${serverText(declared.version)}" for ${asset} on chain ${chainId}, but that token's domain is "${known.name}"/"${known.version}"; refusing to sign under a domain the token would reject.`);
|
|
1186
|
+
}
|
|
1182
1187
|
return known;
|
|
1188
|
+
}
|
|
1189
|
+
if (declared)
|
|
1190
|
+
return declared;
|
|
1183
1191
|
let name = '', version = '';
|
|
1184
1192
|
try {
|
|
1185
1193
|
name = decodeAbiString(await ethCallHex(chainId, asset, '0x06fdde03')); // name()
|
|
@@ -1215,7 +1223,7 @@ async function signAuthorization(walletId, chainId, asset, domain, auth, approva
|
|
|
1215
1223
|
/** Sign a Permit2 upto authorization with whichever custody mode is active. */
|
|
1216
1224
|
async function signPermit2(walletId, chainId, auth, approvalId) {
|
|
1217
1225
|
const r = (await api(`/wallets/${walletId}/x402/permit2`, 'POST', {
|
|
1218
|
-
chain_id: chainId, asset: auth.permitted.token, amount: auth.permitted.amount, spender: auth.spender, permit2:
|
|
1226
|
+
chain_id: chainId, asset: auth.permitted.token, amount: auth.permitted.amount, spender: auth.spender, permit2: permit2Address(chainId),
|
|
1219
1227
|
nonce: auth.nonce, deadline: auth.deadline, to: auth.witness.to, facilitator: auth.witness.facilitator, valid_after: auth.witness.validAfter,
|
|
1220
1228
|
...(approvalId ? { approval_id: approvalId } : {}),
|
|
1221
1229
|
}, { 'X-AGW-SKIP-X402': 'true' }));
|
|
@@ -1340,7 +1348,8 @@ server.tool('pay_x402', 'Handle an x402 payment flow. Fetches a URL, and if the
|
|
|
1340
1348
|
payment_required: true,
|
|
1341
1349
|
payment_made: false,
|
|
1342
1350
|
error: picked.reason,
|
|
1343
|
-
offered: accepts.map(a => ({ scheme: a.scheme, network: a.network, amount: requiredAmount(a), asset: a.asset ?? a.extra?.token ?? null })),
|
|
1351
|
+
offered: accepts.slice(0, 10).map(a => ({ scheme: serverText(a.scheme), network: serverText(a.network), amount: serverText(requiredAmount(a)), asset: serverText(a.asset ?? a.extra?.token ?? null) })),
|
|
1352
|
+
server_text_is_untrusted: true,
|
|
1344
1353
|
});
|
|
1345
1354
|
}
|
|
1346
1355
|
const option = picked.option;
|
|
@@ -1370,6 +1379,20 @@ server.tool('pay_x402', 'Handle an x402 payment flow. Fetches a URL, and if the
|
|
|
1370
1379
|
error: assetPolicy.reason,
|
|
1371
1380
|
});
|
|
1372
1381
|
}
|
|
1382
|
+
// Step 3c: The tx-hash receipt flow (an on-chain transfer, final the moment
|
|
1383
|
+
// it is broadcast) is only for AgentWallet's own paywalls: the API host or
|
|
1384
|
+
// the origins in AGENTWALLET_LEGACY_PAYWALL_ORIGINS. Any other host sending
|
|
1385
|
+
// the legacy shape gets the standard flow (2026-10-04 round 2). And an
|
|
1386
|
+
// "exact" requirement in a token with no EIP-3009 can never settle, so it
|
|
1387
|
+
// is refused before an approval could be consumed for it.
|
|
1388
|
+
const legacyReceipt = isLegacyAgentWalletAccept(option) && legacyReceiptAllowed(servedFrom, API_BASE, process.env.AGENTWALLET_LEGACY_PAYWALL_ORIGINS);
|
|
1389
|
+
if (!isUpto && !isSolanaChain(chainId) && tokenAddress && !legacyReceipt && !assetSupportsExact(chainId, tokenAddress)) {
|
|
1390
|
+
return jsonResponse({
|
|
1391
|
+
status: 402, payment_required: true, payment_made: false,
|
|
1392
|
+
required_amount: amount, token: tokenLabel, token_address: tokenAddress, network: option.network, chain_id: chainId, pay_to: option.payTo,
|
|
1393
|
+
error: `${tokenLabel} on chain ${chainId} does not implement EIP-3009, so an "exact" x402 payment in it cannot settle; nothing was signed. The endpoint would need an "upto" option or a USDC requirement.`,
|
|
1394
|
+
});
|
|
1395
|
+
}
|
|
1373
1396
|
// Step 4: Hard per-payment cap, ALWAYS applied. If the caller omits max_payment
|
|
1374
1397
|
// we fall back to AGENTWALLET_MAX_AUTOPAY (default "1"). Above the cap a hosted
|
|
1375
1398
|
// wallet can ask its owner instead of refusing: an approval is created, the
|
|
@@ -1384,7 +1407,7 @@ server.tool('pay_x402', 'Handle an x402 payment flow. Fetches a URL, and if the
|
|
|
1384
1407
|
status: 402, payment_required: true, payment_made: false,
|
|
1385
1408
|
required_amount: amount, max_allowed: effectiveMax, cap_source: capSource, token: tokenLabel,
|
|
1386
1409
|
...(clamped ? { max_payment_ignored: `max_payment ${max_payment} exceeds the operator ceiling AGENTWALLET_MAX_AUTOPAY=${effectiveMax}; only the operator can raise it.` } : {}),
|
|
1387
|
-
network: option.network, chain_id: chainId, pay_to: option.payTo, description: option.description,
|
|
1410
|
+
network: option.network, chain_id: chainId, pay_to: option.payTo, description: serverText(option.description), server_text_is_untrusted: true,
|
|
1388
1411
|
};
|
|
1389
1412
|
const wantApproval = request_approval ?? (process.env.AGENTWALLET_APPROVALS !== '0');
|
|
1390
1413
|
if (approval_id) {
|
|
@@ -1428,7 +1451,7 @@ server.tool('pay_x402', 'Handle an x402 payment flow. Fetches a URL, and if the
|
|
|
1428
1451
|
// usage; the payer broadcasts nothing and pays no gas for either. AgentWallet's
|
|
1429
1452
|
// own paywalls (verified by receipt), native-asset requests and Solana still use
|
|
1430
1453
|
// a broadcast transfer proved by hash.
|
|
1431
|
-
const standardExact = !isUpto && !isSolanaChain(chainId) && Boolean(tokenAddress) && !
|
|
1454
|
+
const standardExact = !isUpto && !isSolanaChain(chainId) && Boolean(tokenAddress) && !legacyReceipt;
|
|
1432
1455
|
let headerName = 'X-PAYMENT';
|
|
1433
1456
|
let paymentHeader = '';
|
|
1434
1457
|
let txHash = null;
|
|
@@ -1456,82 +1479,89 @@ server.tool('pay_x402', 'Handle an x402 payment flow. Fetches a URL, and if the
|
|
|
1456
1479
|
let releaseSigning = () => { };
|
|
1457
1480
|
if (!(cachedAuth && cachedAuth.until > Date.now())) {
|
|
1458
1481
|
const mine = new Promise(r => { releaseSigning = r; });
|
|
1459
|
-
// Released
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
// earlier signature. Its nonce is single-use, so a server that already
|
|
1465
|
-
// settled it cannot settle it again, and one that never did now can.
|
|
1466
|
-
({ headerName, paymentHeader, txHash, authorization, uptoAuth, payer } = cachedAuth);
|
|
1467
|
-
authorizationReused = true;
|
|
1468
|
-
}
|
|
1469
|
-
else if (isUpto) {
|
|
1470
|
-
payer = await payerAddress(wallet_id);
|
|
1471
|
-
payerDelegationInfo = await payerDelegation(chainId, payer);
|
|
1472
|
-
const allowance = decodeUint(await ethCallHex(chainId, tokenAddress, permit2AllowanceCalldata(payer)));
|
|
1473
|
-
if (allowance < BigInt(rawAmount)) {
|
|
1474
|
-
if (process.env.AGENTWALLET_PERMIT2_AUTO_APPROVE === '1' && assetPolicy.via === 'stablecoin') {
|
|
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));
|
|
1477
|
-
permit2ApprovalTx = String(r?.tx_hash || '');
|
|
1478
|
-
await new Promise(res => setTimeout(res, 4000));
|
|
1479
|
-
}
|
|
1480
|
-
else {
|
|
1481
|
-
return jsonResponse({
|
|
1482
|
-
status: 402, payment_required: true, payment_made: false, permit2_approval_needed: true,
|
|
1483
|
-
token: tokenLabel, token_address: tokenAddress, chain_id: chainId, payer, permit2: PERMIT2_ADDRESS,
|
|
1484
|
-
error: `This endpoint uses the x402 "upto" scheme, which settles through Permit2. Wallet ${payer} has not approved ` +
|
|
1485
|
-
`${tokenLabel} to Permit2 on chain ${chainId}. Call approve_permit2 once for this token (a normal transaction that needs gas), ` +
|
|
1486
|
-
`or set AGENTWALLET_PERMIT2_AUTO_APPROVE=1, then call pay_x402 again.`,
|
|
1487
|
-
});
|
|
1488
|
-
}
|
|
1489
|
-
}
|
|
1490
|
-
uptoAuth = buildUptoAuthorization(payer, option);
|
|
1491
|
-
const signature = await signPermit2(wallet_id, chainId, uptoAuth, approvalUsed);
|
|
1492
|
-
const payload = buildPaymentPayloadRaw(x402Version, option, uptoPayload(uptoAuth, signature), paymentInfo.resource, paymentInfo.extensions);
|
|
1493
|
-
const h = paymentHeaderFor(x402Version, payload);
|
|
1494
|
-
headerName = h.name;
|
|
1495
|
-
paymentHeader = h.value;
|
|
1496
|
-
}
|
|
1497
|
-
else if (standardExact) {
|
|
1498
|
-
payer = await payerAddress(wallet_id);
|
|
1499
|
-
payerDelegationInfo = await payerDelegation(chainId, payer);
|
|
1500
|
-
const domain = await resolveTokenDomain(chainId, tokenAddress, option.extra);
|
|
1501
|
-
authorization = buildAuthorization(payer, option);
|
|
1502
|
-
const signature = await signAuthorization(wallet_id, chainId, tokenAddress, domain, authorization, approvalUsed);
|
|
1503
|
-
const payload = buildPaymentPayload(x402Version, option, authorization, signature, paymentInfo.resource, paymentInfo.extensions);
|
|
1504
|
-
const h = paymentHeaderFor(x402Version, payload);
|
|
1505
|
-
headerName = h.name;
|
|
1506
|
-
paymentHeader = h.value;
|
|
1482
|
+
// Released in the finally below, whatever path this call takes. A timer
|
|
1483
|
+
// used to stand in for that and opened the gate under a slow signer, so
|
|
1484
|
+
// a second call signed a second nonce (2026-10-04 round 2); every
|
|
1485
|
+
// network hop inside the gate carries its own timeout instead.
|
|
1486
|
+
signingInFlight.set(cacheKey, mine);
|
|
1507
1487
|
}
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1488
|
+
try {
|
|
1489
|
+
if (cachedAuth && cachedAuth.until > Date.now()) {
|
|
1490
|
+
// Same endpoint, same requirement, still inside the window: re-send the
|
|
1491
|
+
// earlier signature. Its nonce is single-use, so a server that already
|
|
1492
|
+
// settled it cannot settle it again, and one that never did now can.
|
|
1493
|
+
({ headerName, paymentHeader, txHash, authorization, uptoAuth, payer } = cachedAuth);
|
|
1494
|
+
authorizationReused = true;
|
|
1495
|
+
}
|
|
1496
|
+
else if (isUpto) {
|
|
1497
|
+
payer = await payerAddress(wallet_id);
|
|
1498
|
+
payerDelegationInfo = await payerDelegation(chainId, payer);
|
|
1499
|
+
const allowance = decodeUint(await ethCallHex(chainId, tokenAddress, permit2AllowanceCalldata(payer, chainId)));
|
|
1500
|
+
if (allowance < BigInt(rawAmount)) {
|
|
1501
|
+
if (process.env.AGENTWALLET_PERMIT2_AUTO_APPROVE === '1' && assetPolicy.via === 'stablecoin') {
|
|
1502
|
+
// Approve exactly this payment's maximum, never unlimited (2026-10-04 audit).
|
|
1503
|
+
const r = (await api(`/wallets/${wallet_id}/send`, 'POST', { to: tokenAddress, value: '0', data: permit2ApproveCalldata(rawAmount, chainId), chain_id: chainId }, skip));
|
|
1504
|
+
permit2ApprovalTx = String(r?.tx_hash || '');
|
|
1505
|
+
await new Promise(res => setTimeout(res, 4000));
|
|
1506
|
+
}
|
|
1507
|
+
else {
|
|
1508
|
+
return jsonResponse({
|
|
1509
|
+
status: 402, payment_required: true, payment_made: false, permit2_approval_needed: true,
|
|
1510
|
+
token: tokenLabel, token_address: tokenAddress, chain_id: chainId, payer, permit2: permit2Address(chainId),
|
|
1511
|
+
error: `This endpoint uses the x402 "upto" scheme, which settles through Permit2. Wallet ${payer} has not approved ` +
|
|
1512
|
+
`${tokenLabel} to Permit2 on chain ${chainId}. Call approve_permit2 once for this token (a normal transaction that needs gas), ` +
|
|
1513
|
+
`or set AGENTWALLET_PERMIT2_AUTO_APPROVE=1, then call pay_x402 again.`,
|
|
1514
|
+
});
|
|
1515
|
+
}
|
|
1516
|
+
}
|
|
1517
|
+
uptoAuth = buildUptoAuthorization(payer, option);
|
|
1518
|
+
const signature = await signPermit2(wallet_id, chainId, uptoAuth, approvalUsed);
|
|
1519
|
+
const payload = buildPaymentPayloadRaw(x402Version, option, uptoPayload(uptoAuth, signature), paymentInfo.resource, paymentInfo.extensions);
|
|
1520
|
+
const h = paymentHeaderFor(x402Version, payload);
|
|
1521
|
+
headerName = h.name;
|
|
1522
|
+
paymentHeader = h.value;
|
|
1514
1523
|
}
|
|
1515
|
-
else if (
|
|
1516
|
-
|
|
1517
|
-
|
|
1524
|
+
else if (standardExact) {
|
|
1525
|
+
payer = await payerAddress(wallet_id);
|
|
1526
|
+
payerDelegationInfo = await payerDelegation(chainId, payer);
|
|
1527
|
+
const domain = await resolveTokenDomain(chainId, tokenAddress, option.extra);
|
|
1528
|
+
authorization = buildAuthorization(payer, option);
|
|
1529
|
+
const signature = await signAuthorization(wallet_id, chainId, tokenAddress, domain, authorization, approvalUsed);
|
|
1530
|
+
const payload = buildPaymentPayload(x402Version, option, authorization, signature, paymentInfo.resource, paymentInfo.extensions);
|
|
1531
|
+
const h = paymentHeaderFor(x402Version, payload);
|
|
1532
|
+
headerName = h.name;
|
|
1533
|
+
paymentHeader = h.value;
|
|
1518
1534
|
}
|
|
1519
1535
|
else {
|
|
1520
|
-
txResult
|
|
1536
|
+
let txResult;
|
|
1537
|
+
if (isSolanaChain(chainId)) {
|
|
1538
|
+
txResult = (await api(`/wallets/${wallet_id}/send`, 'POST', tokenAddress
|
|
1539
|
+
? { to: option.payTo, value: rawAmount, token_mint: tokenAddress, token_decimals: trustedDecimals, chain_id: chainId }
|
|
1540
|
+
: { to: option.payTo, value: rawAmount, chain_id: chainId }));
|
|
1541
|
+
}
|
|
1542
|
+
else if (tokenAddress) {
|
|
1543
|
+
const calldata = '0xa9059cbb' + padAddress(option.payTo) + encodeUint256(rawAmount);
|
|
1544
|
+
txResult = (await api(`/wallets/${wallet_id}/send`, 'POST', { to: tokenAddress, value: '0', data: calldata, chain_id: chainId }));
|
|
1545
|
+
}
|
|
1546
|
+
else {
|
|
1547
|
+
txResult = (await api(`/wallets/${wallet_id}/send`, 'POST', { to: option.payTo, value: rawAmount, data: '', chain_id: chainId }));
|
|
1548
|
+
}
|
|
1549
|
+
txHash = String(txResult.tx_hash || txResult.signature || '');
|
|
1550
|
+
if (!txHash)
|
|
1551
|
+
throw new Error('x402: the payment transaction returned no hash.');
|
|
1552
|
+
paymentHeader = Buffer.from(JSON.stringify({ x402Version, scheme: option.scheme, network: option.network, payload: { txHash } })).toString('base64');
|
|
1521
1553
|
}
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
}
|
|
1533
|
-
releaseSigning();
|
|
1534
|
-
signingInFlight.delete(cacheKey);
|
|
1554
|
+
if (!authorizationReused) {
|
|
1555
|
+
const until = authorization ? Number(authorization.validBefore) * 1000
|
|
1556
|
+
: uptoAuth ? Number(uptoAuth.deadline) * 1000
|
|
1557
|
+
: Date.now() + maxAuthWindowSeconds() * 1000; // a broadcast transfer: the hash stays valid, re-sending it never pays twice
|
|
1558
|
+
signedPayments.set(cacheKey, { headerName, paymentHeader, txHash, authorization, uptoAuth, payer, until });
|
|
1559
|
+
}
|
|
1560
|
+
}
|
|
1561
|
+
finally {
|
|
1562
|
+
releaseSigning();
|
|
1563
|
+
signingInFlight.delete(cacheKey);
|
|
1564
|
+
}
|
|
1535
1565
|
// Step 6: Retry with the payment header and read the settlement receipt.
|
|
1536
1566
|
const retryHeaders = { ...reqHeaders, [headerName]: paymentHeader };
|
|
1537
1567
|
const retryOptions = {
|
|
@@ -1542,7 +1572,19 @@ server.tool('pay_x402', 'Handle an x402 payment flow. Fetches a URL, and if the
|
|
|
1542
1572
|
if (reqBody && method !== 'GET') {
|
|
1543
1573
|
retryOptions.body = reqBody;
|
|
1544
1574
|
}
|
|
1545
|
-
|
|
1575
|
+
let retryRes;
|
|
1576
|
+
try {
|
|
1577
|
+
retryRes = await safeFetch(url, retryOptions);
|
|
1578
|
+
}
|
|
1579
|
+
catch (e) {
|
|
1580
|
+
// The authorization has already been handed over; say so instead of a bare transport error.
|
|
1581
|
+
return jsonResponse({
|
|
1582
|
+
status: 0, payment_required: true, payment_made: null, payment_outcome: 'unknown',
|
|
1583
|
+
error: `The paid request could not be completed: ${e.message}. The authorization was already sent; it stays cached, and calling pay_x402 again with the same URL inside its window re-sends the same one rather than signing a new one.`,
|
|
1584
|
+
authorization: authorization ? { nonce: authorization.nonce, valid_before: authorization.validBefore } : (uptoAuth ? { scheme: 'upto', nonce: uptoAuth.nonce, deadline: uptoAuth.deadline } : null),
|
|
1585
|
+
tx_hash: txHash ?? null, amount, token: tokenLabel, pay_to: option.payTo, chain_id: chainId,
|
|
1586
|
+
});
|
|
1587
|
+
}
|
|
1546
1588
|
const retryText = await retryRes.text();
|
|
1547
1589
|
let retryParsed;
|
|
1548
1590
|
try {
|
|
@@ -1557,6 +1599,7 @@ server.tool('pay_x402', 'Handle an x402 payment flow. Fetches a URL, and if the
|
|
|
1557
1599
|
let retryError = null;
|
|
1558
1600
|
const retryServedFrom = finalUrlOf(retryRes, url);
|
|
1559
1601
|
const retryCrossOrigin = new URL(retryServedFrom).origin !== new URL(url).origin;
|
|
1602
|
+
const verdict = settlementVerdict(retryRes.status, settlement, retryCrossOrigin);
|
|
1560
1603
|
if (retryCrossOrigin) {
|
|
1561
1604
|
retryError = `The paid request was redirected to another origin (${new URL(retryServedFrom).origin}); the payment header is never forwarded across origins, so this response did not see the payment.`;
|
|
1562
1605
|
}
|
|
@@ -1569,10 +1612,11 @@ server.tool('pay_x402', 'Handle an x402 payment flow. Fetches a URL, and if the
|
|
|
1569
1612
|
return jsonResponse({
|
|
1570
1613
|
status: retryRes.status,
|
|
1571
1614
|
payment_required: true,
|
|
1572
|
-
// Paid means
|
|
1573
|
-
//
|
|
1574
|
-
//
|
|
1575
|
-
payment_made:
|
|
1615
|
+
// Paid means a success status after the payment header and no server-reported settlement failure. A 402 or
|
|
1616
|
+
// any other non-2xx is not paid whatever the settlement header claims (a server could send success:true on a
|
|
1617
|
+
// 402 and mislead the agent, 2026-10-04 round 2); the facilitator does not settle a failed request.
|
|
1618
|
+
payment_made: verdict.payment_made,
|
|
1619
|
+
payment_outcome: verdict.outcome,
|
|
1576
1620
|
authorization_reused: authorizationReused,
|
|
1577
1621
|
retry_error: retryError,
|
|
1578
1622
|
payment_method: isUpto ? 'permit2-upto-authorization' : (standardExact ? 'eip3009-authorization' : 'onchain-transfer'),
|
|
@@ -1587,9 +1631,10 @@ server.tool('pay_x402', 'Handle an x402 payment flow. Fetches a URL, and if the
|
|
|
1587
1631
|
pay_to: option.payTo,
|
|
1588
1632
|
payer,
|
|
1589
1633
|
payer_delegation: payerDelegationInfo,
|
|
1590
|
-
//
|
|
1591
|
-
//
|
|
1592
|
-
tx_hash: txHash ??
|
|
1634
|
+
// tx_hash is only ever ours (we broadcast it). The server's claimed hash
|
|
1635
|
+
// is kept apart and unverified: it is not evidence the payment settled.
|
|
1636
|
+
tx_hash: txHash ?? null,
|
|
1637
|
+
server_claimed_tx_hash: looksLikeTxHash(settlement?.transaction) ? settlement?.transaction : null,
|
|
1593
1638
|
settlement_reported_by_server: settlement,
|
|
1594
1639
|
authorization: authorization ? { nonce: authorization.nonce, valid_before: authorization.validBefore }
|
|
1595
1640
|
: (uptoAuth ? { scheme: 'upto', max_amount: amount, nonce: uptoAuth.nonce, deadline: uptoAuth.deadline, facilitator: uptoAuth.witness.facilitator } : null),
|
|
@@ -1613,13 +1658,13 @@ server.tool('approve_permit2', 'One-time ERC-20 approval of a token to the Permi
|
|
|
1613
1658
|
}, async ({ wallet_id, token, chain_id, amount }) => {
|
|
1614
1659
|
if (isSolanaChain(chain_id))
|
|
1615
1660
|
throw new Error('Permit2 is an EVM contract; there is nothing to approve on Solana.');
|
|
1616
|
-
let data = permit2ApproveCalldata();
|
|
1661
|
+
let data = permit2ApproveCalldata(undefined, chain_id);
|
|
1617
1662
|
if (amount !== undefined) {
|
|
1618
1663
|
const raw = parseUnits(amount, await resolveTrustedDecimals(chain_id, token));
|
|
1619
|
-
data =
|
|
1664
|
+
data = permit2ApproveCalldata(raw, chain_id);
|
|
1620
1665
|
}
|
|
1621
1666
|
const result = (await api(`/wallets/${wallet_id}/send`, 'POST', { to: token, value: '0', data, chain_id }));
|
|
1622
|
-
return jsonResponse({ ...result, token, spender:
|
|
1667
|
+
return jsonResponse({ ...result, token, spender: permit2Address(chain_id), amount: amount ?? 'unlimited', note: 'Permit2 only transfers what a signed authorization allows; the allowance itself moves nothing.' });
|
|
1623
1668
|
});
|
|
1624
1669
|
// ─── Tool: check_token_risk ─────────────────────────────────────
|
|
1625
1670
|
server.tool('check_token_risk', 'Assess an ERC-20 token before approving or swapping it: honeypot, taxes, owner powers, verified source, ' +
|
package/build/local-solana.js
CHANGED
|
@@ -416,15 +416,23 @@ export async function localSplTransfer(mintStr, to, rawAmount, decimals, chainId
|
|
|
416
416
|
const destAta = associatedTokenAddress(recipient, mint, tokenProgram);
|
|
417
417
|
const tx = new Transaction();
|
|
418
418
|
const destInfo = await conn.getAccountInfo(destAta);
|
|
419
|
-
|
|
420
|
-
|
|
419
|
+
// The associated-account program creates into an address that already holds
|
|
420
|
+
// plain SOL (system-owned, no data): it tops up the rent, allocates and
|
|
421
|
+
// assigns. Only an address holding something else cannot become the token
|
|
422
|
+
// account (2026-10-04 round 2; the 1.13.5 refusal of any occupant let anyone
|
|
423
|
+
// block a recipient for the price of the rent-exempt minimum).
|
|
424
|
+
const prefunded = destInfo !== null && destInfo.owner.equals(SystemProgram.programId) && destInfo.data.length === 0;
|
|
425
|
+
if (destInfo && !destInfo.owner.equals(tokenProgram) && !prefunded) {
|
|
426
|
+
throw new Error(`Refusing to send: the recipient's token account address ${destAta.toBase58()} is occupied by an account owned by ${destInfo.owner.toBase58()}, so no token account can be created there.`);
|
|
421
427
|
}
|
|
422
|
-
if (!destInfo) {
|
|
428
|
+
if (!destInfo || prefunded) {
|
|
423
429
|
// Creating the recipient's token account costs the payer rent in SOL, an
|
|
424
430
|
// outflow the SOL cap must see (0.002 SOL a time adds up over many sends).
|
|
425
|
-
// Rent follows the account's real size, which for Token-2022 is above 165
|
|
431
|
+
// Rent follows the account's real size, which for Token-2022 is above 165;
|
|
432
|
+
// SOL already sitting at the address reduces what the payer tops up.
|
|
426
433
|
const rent = await conn.getMinimumBalanceForRentExemption(ataSize);
|
|
427
|
-
|
|
434
|
+
const shortfall = Math.max(0, rent - (destInfo?.lamports ?? 0));
|
|
435
|
+
assertWithinSolCap(BigInt(shortfall));
|
|
428
436
|
tx.add(createAtaIdempotentIx(payer.publicKey, destAta, recipient, mint, tokenProgram));
|
|
429
437
|
}
|
|
430
438
|
tx.add(transferCheckedIx(tokenProgram, sourceAta, mint, destAta, payer.publicKey, BigInt(rawAmount), decimals));
|
|
@@ -471,11 +479,12 @@ async function assertRecipientIsWallet(conn, recipient) {
|
|
|
471
479
|
}
|
|
472
480
|
throw new Error(`Refusing to send to ${recipient.toBase58()}: it is an account owned by the SPL token program, not a wallet.`);
|
|
473
481
|
}
|
|
474
|
-
// Everything else that exists must be
|
|
475
|
-
//
|
|
476
|
-
//
|
|
477
|
-
//
|
|
478
|
-
// system-owned,
|
|
482
|
+
// Everything else that exists must be system-owned: a program's data or
|
|
483
|
+
// buffer account, an address lookup table, a stake or vote account is owned
|
|
484
|
+
// by its program and nothing can ever sign for it as a wallet (2026-10-04
|
|
485
|
+
// audit). A program-derived vault that holds tokens is system-owned and
|
|
486
|
+
// passes; so does a durable-nonce account (system-owned, 80 bytes), whose
|
|
487
|
+
// authority can withdraw what lands there.
|
|
479
488
|
if (!info.owner.equals(SystemProgram.programId)) {
|
|
480
489
|
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
490
|
}
|
package/build/local-wallet.js
CHANGED
|
@@ -271,7 +271,7 @@ export function classifyDecimalsError(err) {
|
|
|
271
271
|
const e = err;
|
|
272
272
|
const code = typeof e?.code === 'number' ? e.code : NaN;
|
|
273
273
|
const text = [e?.message, e?.details, e?.shortMessage].filter(Boolean).join(' ');
|
|
274
|
-
const hasRevertData = typeof e?.data === 'string' && /^0x[0-9a-fA-F]
|
|
274
|
+
const hasRevertData = typeof e?.data === 'string' && /^0x[0-9a-fA-F]+$/.test(e.data); // "0x" alone is not revert data
|
|
275
275
|
if (code === 3)
|
|
276
276
|
return 'not-a-token'; // the JSON-RPC "execution error" code
|
|
277
277
|
if ((code === -32000 || code === -32603 || code === -32015) && (/revert/i.test(text) || hasRevertData))
|
|
@@ -295,7 +295,12 @@ export function permit2AddressFor(chainId) { return PERMIT2_BY_CHAIN[chainId] ||
|
|
|
295
295
|
table does not know, and is refused rather than let through as "a router"
|
|
296
296
|
(2026-10-04 audit). approve, permit (2 shapes), transferFrom (2), lockdown,
|
|
297
297
|
permitTransferFrom (2), permitWitnessTransferFrom (2), invalidateNonces/UnorderedNonces. */
|
|
298
|
-
const PERMIT2_SELECTORS = new Set(['87517c45', '2b67b570', '2a2d80d1', '36c78516', '0d58b1db', 'cc53287f', '30f28b7a', 'edd9444b', '137c29fe', 'fe8ec1a7', '3ff9dcb1', '
|
|
298
|
+
const PERMIT2_SELECTORS = new Set(['87517c45', '2b67b570', '2a2d80d1', '36c78516', '0d58b1db', 'cc53287f', '30f28b7a', 'edd9444b', '137c29fe', 'fe8ec1a7', '3ff9dcb1', '65d9723c']);
|
|
299
|
+
/* ERC-721 / ERC-1155 approvals and transfers. A collection declines decimals()
|
|
300
|
+
and so read as "not a token" to the cap, which let setApprovalForAll to an
|
|
301
|
+
attacker through uncapped (2026-10-04 round 2, verified on a live
|
|
302
|
+
collection). Nothing prices an NFT, so these are refused outright. */
|
|
303
|
+
const NFT_SELECTORS = new Set(['a22cb465', '42842e0e', 'b88d4fde', 'f242432a', '2eb2c2d6']);
|
|
299
304
|
const CAP_LAYOUTS = {
|
|
300
305
|
a9059cbb: { amountIndex: 1, what: 'token transfer' }, // transfer(address,uint256)
|
|
301
306
|
'23b872dd': { amountIndex: 2, what: 'token transfer' }, // transferFrom(address,address,uint256)
|
|
@@ -359,6 +364,10 @@ async function assertWithinTokenCap(chainId, to, data, valueWei = 0n) {
|
|
|
359
364
|
`or deposit/withdraw on the chain's wrapped-native contract, ` +
|
|
360
365
|
`or ask the operator, who alone can set AGENTWALLET_ALLOW_UNKNOWN_TOKEN_CALLS=1.`);
|
|
361
366
|
}
|
|
367
|
+
if (NFT_SELECTORS.has(selector)) {
|
|
368
|
+
throw new Error(`Blocked by local guard: calldata selector 0x${selector} is an NFT approval or transfer (ERC-721/1155) aimed at ${to}; AGENTWALLET_MAX_TX_TOKEN cannot price it, ` +
|
|
369
|
+
`so it is refused rather than let through uncapped. Only the operator can allow it (AGENTWALLET_ALLOW_UNKNOWN_TOKEN_CALLS=1).`);
|
|
370
|
+
}
|
|
362
371
|
if (PERMIT2_SELECTORS.has(selector)) {
|
|
363
372
|
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
373
|
`refused rather than let through uncapped. Only the operator can allow it (AGENTWALLET_ALLOW_UNKNOWN_TOKEN_CALLS=1).`);
|
package/build/x402-eip3009.d.ts
CHANGED
|
@@ -158,6 +158,16 @@ export declare function paymentHeaderFor(x402Version: number, payload: Record<st
|
|
|
158
158
|
};
|
|
159
159
|
/** v2 servers put the requirements in PAYMENT-REQUIRED; v1 servers put them in the JSON body. */
|
|
160
160
|
export declare function parsePaymentRequired(getHeader: (name: string) => string | null | undefined, body: unknown): X402PaymentRequired | null;
|
|
161
|
+
export declare function sanitizeRequired(req: X402PaymentRequired): X402PaymentRequired;
|
|
162
|
+
/** What the paid retry proved. The server's own claim never makes a refused request "paid". */
|
|
163
|
+
export declare function settlementVerdict(status: number, settlement: {
|
|
164
|
+
success?: unknown;
|
|
165
|
+
} | null, crossOrigin: boolean): {
|
|
166
|
+
payment_made: boolean;
|
|
167
|
+
outcome: 'paid' | 'refused' | 'server_reports_failure' | 'not_delivered';
|
|
168
|
+
};
|
|
169
|
+
/** The tx-hash receipt flow is for AgentWallet's own paywalls: the API host, or origins the operator lists. */
|
|
170
|
+
export declare function legacyReceiptAllowed(finalUrl: string, apiBase: string, extraOrigins: string | undefined): boolean;
|
|
161
171
|
export declare function parseSettlement(getHeader: (name: string) => string | null | undefined): X402Settlement | null;
|
|
162
172
|
/**
|
|
163
173
|
* Choose which accepts[] entry to pay. `exact` (EIP-3009, gasless for the
|
package/build/x402-eip3009.js
CHANGED
|
@@ -178,12 +178,77 @@ function decodeBase64Json(v) {
|
|
|
178
178
|
export function parsePaymentRequired(getHeader, body) {
|
|
179
179
|
const fromHeader = decodeBase64Json(getHeader('PAYMENT-REQUIRED') ?? getHeader('payment-required'));
|
|
180
180
|
if (fromHeader && Array.isArray(fromHeader.accepts))
|
|
181
|
-
return fromHeader;
|
|
181
|
+
return sanitizeRequired(fromHeader);
|
|
182
182
|
if (body && typeof body === 'object' && Array.isArray(body.accepts)) {
|
|
183
|
-
return body;
|
|
183
|
+
return sanitizeRequired(body);
|
|
184
184
|
}
|
|
185
185
|
return null;
|
|
186
186
|
}
|
|
187
|
+
/* Only entries shaped like requirements survive: plain objects whose scheme,
|
|
188
|
+
network and payTo are strings. A null entry, or an array where an address
|
|
189
|
+
should be, used to reach the pickers and surface a raw TypeError or a
|
|
190
|
+
coerced value (2026-10-04 round 2). Numeric amounts are kept as strings. */
|
|
191
|
+
const MAX_ACCEPTS = 64; // a bound on the picker's work, well above anything a real server lists
|
|
192
|
+
export function sanitizeRequired(req) {
|
|
193
|
+
const str = (v) => (typeof v === 'string' ? v : typeof v === 'number' && Number.isFinite(v) ? String(v) : undefined);
|
|
194
|
+
const accepts = [];
|
|
195
|
+
for (const a of req.accepts.slice(0, MAX_ACCEPTS)) {
|
|
196
|
+
if (!a || typeof a !== 'object' || Array.isArray(a))
|
|
197
|
+
continue;
|
|
198
|
+
const o = a;
|
|
199
|
+
const scheme = str(o.scheme), network = str(o.network), payTo = str(o.payTo);
|
|
200
|
+
if (scheme === undefined || network === undefined || payTo === undefined)
|
|
201
|
+
continue;
|
|
202
|
+
const extraIn = (o.extra && typeof o.extra === 'object' && !Array.isArray(o.extra)) ? o.extra : undefined;
|
|
203
|
+
const extra = extraIn ? { ...extraIn } : undefined;
|
|
204
|
+
if (extra)
|
|
205
|
+
for (const k of ['token', 'name', 'version', 'facilitatorAddress', 'spender', 'permit2']) {
|
|
206
|
+
if (k in extra && typeof extra[k] !== 'string')
|
|
207
|
+
delete extra[k];
|
|
208
|
+
}
|
|
209
|
+
const out = { ...o, scheme, network, payTo };
|
|
210
|
+
for (const k of ['amount', 'maxAmountRequired', 'asset', 'description', 'resource', 'mimeType']) {
|
|
211
|
+
const v = str(o[k]);
|
|
212
|
+
if (v === undefined)
|
|
213
|
+
delete out[k];
|
|
214
|
+
else
|
|
215
|
+
out[k] = v;
|
|
216
|
+
}
|
|
217
|
+
if (extra)
|
|
218
|
+
out.extra = extra;
|
|
219
|
+
else
|
|
220
|
+
delete out.extra;
|
|
221
|
+
accepts.push(out);
|
|
222
|
+
}
|
|
223
|
+
return { ...req, accepts };
|
|
224
|
+
}
|
|
225
|
+
/** What the paid retry proved. The server's own claim never makes a refused request "paid". */
|
|
226
|
+
export function settlementVerdict(status, settlement, crossOrigin) {
|
|
227
|
+
if (crossOrigin)
|
|
228
|
+
return { payment_made: false, outcome: 'not_delivered' };
|
|
229
|
+
if (status < 200 || status >= 300)
|
|
230
|
+
return { payment_made: false, outcome: 'refused' };
|
|
231
|
+
if (settlement && settlement.success === false)
|
|
232
|
+
return { payment_made: false, outcome: 'server_reports_failure' };
|
|
233
|
+
return { payment_made: true, outcome: 'paid' };
|
|
234
|
+
}
|
|
235
|
+
/** The tx-hash receipt flow is for AgentWallet's own paywalls: the API host, or origins the operator lists. */
|
|
236
|
+
export function legacyReceiptAllowed(finalUrl, apiBase, extraOrigins) {
|
|
237
|
+
try {
|
|
238
|
+
const origin = new URL(finalUrl).origin;
|
|
239
|
+
if (origin === new URL(apiBase).origin)
|
|
240
|
+
return true;
|
|
241
|
+
return (extraOrigins || '').split(',').map(s => s.trim()).filter(Boolean).some(o => { try {
|
|
242
|
+
return new URL(o).origin === origin;
|
|
243
|
+
}
|
|
244
|
+
catch {
|
|
245
|
+
return false;
|
|
246
|
+
} });
|
|
247
|
+
}
|
|
248
|
+
catch {
|
|
249
|
+
return false;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
187
252
|
export function parseSettlement(getHeader) {
|
|
188
253
|
return (decodeBase64Json(getHeader('PAYMENT-RESPONSE') ?? getHeader('payment-response'))
|
|
189
254
|
?? decodeBase64Json(getHeader('X-PAYMENT-RESPONSE') ?? getHeader('x-payment-response')));
|
|
@@ -201,7 +266,7 @@ export function pickOption(accepts, resolveChainId, preferChain) {
|
|
|
201
266
|
const upto = onChain.filter(a => isUptoPayable(a));
|
|
202
267
|
const payable = [...exact, ...upto];
|
|
203
268
|
if (payable.length === 0) {
|
|
204
|
-
const schemes = Array.from(new Set(accepts.map(a => a.scheme))).join(', ') || 'none';
|
|
269
|
+
const schemes = Array.from(new Set(accepts.map(a => String(a.scheme).slice(0, 40)))).slice(0, 10).join(', ') || 'none';
|
|
205
270
|
const reason = accepts.some(a => a.scheme === 'upto')
|
|
206
271
|
? 'This endpoint offers the x402 "upto" scheme without an extra.facilitatorAddress (or without an asset), so the ' +
|
|
207
272
|
'Permit2 authorization cannot be bound to a facilitator. AgentWallet will not approximate it with an upfront transfer. ' +
|
package/build/x402-payment.d.ts
CHANGED
|
@@ -40,11 +40,13 @@ export declare const TRUSTED_DECIMALS: Record<number, Record<string, number>>;
|
|
|
40
40
|
* would happily label 5 ETH as "5 USDC". The stable flag decides whether
|
|
41
41
|
* AGENTWALLET_MAX_AUTOPAY (denominated in dollars) can price the asset at all.
|
|
42
42
|
*/
|
|
43
|
+
export declare function assetSupportsExact(chainId: number, asset: string): boolean;
|
|
43
44
|
export declare const KNOWN_ASSETS: ReadonlyArray<{
|
|
44
45
|
chainId: number;
|
|
45
46
|
address: string;
|
|
46
47
|
symbol: string;
|
|
47
48
|
stable: boolean;
|
|
49
|
+
exact?: boolean;
|
|
48
50
|
}>;
|
|
49
51
|
/** Symbol of the chain's native asset, or "native" for a chain this build does not know. */
|
|
50
52
|
export declare function nativeSymbol(chainId: number): string;
|
package/build/x402-payment.js
CHANGED
|
@@ -58,15 +58,25 @@ export const TRUSTED_DECIMALS = {
|
|
|
58
58
|
* would happily label 5 ETH as "5 USDC". The stable flag decides whether
|
|
59
59
|
* AGENTWALLET_MAX_AUTOPAY (denominated in dollars) can price the asset at all.
|
|
60
60
|
*/
|
|
61
|
+
/* exact: false marks a stablecoin that does not implement EIP-3009 (checked on
|
|
62
|
+
chain 2026-10-04: USDT, DAI and USDbC have no authorizationState), so an
|
|
63
|
+
"exact" x402 requirement naming it can never settle; such assets still work
|
|
64
|
+
for "upto" through Permit2. Absent means true. */
|
|
65
|
+
export function assetSupportsExact(chainId, asset) {
|
|
66
|
+
const key = asset.toLowerCase();
|
|
67
|
+
const hit = KNOWN_ASSETS.find(a => a.chainId === chainId && a.address === key);
|
|
68
|
+
return hit ? hit.exact !== false : true;
|
|
69
|
+
}
|
|
61
70
|
export const KNOWN_ASSETS = [
|
|
62
71
|
{ chainId: 1, address: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', symbol: 'USDC', stable: true },
|
|
63
|
-
{ chainId: 1, address: '0xdac17f958d2ee523a2206206994597c13d831ec7', symbol: 'USDT', stable: true },
|
|
64
|
-
{ chainId: 1, address: '0x6b175474e89094c44da98b954eedeac495271d0f', symbol: 'DAI', stable: true },
|
|
72
|
+
{ chainId: 1, address: '0xdac17f958d2ee523a2206206994597c13d831ec7', symbol: 'USDT', stable: true, exact: false },
|
|
73
|
+
{ chainId: 1, address: '0x6b175474e89094c44da98b954eedeac495271d0f', symbol: 'DAI', stable: true, exact: false },
|
|
65
74
|
{ chainId: 8453, address: '0x833589fcd6edb6e08f4c7c32d4f71b54bda02913', symbol: 'USDC', stable: true },
|
|
66
|
-
{ chainId: 8453, address: '0xd9aaec86b65d86f6a7b5b1b0c42ffa531710b6ca', symbol: 'USDbC', stable: true },
|
|
75
|
+
{ chainId: 8453, address: '0xd9aaec86b65d86f6a7b5b1b0c42ffa531710b6ca', symbol: 'USDbC', stable: true, exact: false },
|
|
67
76
|
{ chainId: 8453, address: '0x4200000000000000000000000000000000000006', symbol: 'WETH', stable: false },
|
|
68
77
|
{ chainId: 137, address: '0x3c499c542cef5e3811e1192ce70d8cc03d5c3359', symbol: 'USDC', stable: true },
|
|
69
|
-
{ chainId: 137, address: '0xc2132d05d31c914a87c6611c10748aeb04b58e8f', symbol: '
|
|
78
|
+
{ chainId: 137, address: '0xc2132d05d31c914a87c6611c10748aeb04b58e8f', symbol: 'USDT0', stable: true, exact: false },
|
|
79
|
+
{ chainId: 43114, address: '0xb97ef9ef8734c71904d8002f8b6bc66dd9c48a6e', symbol: 'USDC', stable: true },
|
|
70
80
|
{ chainId: 42161, address: '0xaf88d065e77c8cc2239327c5edb3a432268e5831', symbol: 'USDC', stable: true },
|
|
71
81
|
{ chainId: 10, address: '0x0b2c639c533813f4aa9d7837caf62653d097ff85', symbol: 'USDC', stable: true },
|
|
72
82
|
{ chainId: 900, address: 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', symbol: 'USDC', stable: true },
|
|
@@ -79,7 +89,7 @@ const NATIVE_SYMBOL = {
|
|
|
79
89
|
};
|
|
80
90
|
function assetKey(chainId, asset) {
|
|
81
91
|
// EVM addresses are case-insensitive hex; SPL mints are case-sensitive base58.
|
|
82
|
-
return `${chainId}:${
|
|
92
|
+
return `${chainId}:${/^0x/i.test(asset) ? asset.toLowerCase() : asset}`;
|
|
83
93
|
}
|
|
84
94
|
const KNOWN_BY_KEY = new Map(KNOWN_ASSETS.map(a => [assetKey(a.chainId, a.address), a]));
|
|
85
95
|
/** Symbol of the chain's native asset, or "native" for a chain this build does not know. */
|
|
@@ -119,13 +129,13 @@ export function parseAutopayAssets(raw) {
|
|
|
119
129
|
const m = e.match(/^(\d+):(.+)$/);
|
|
120
130
|
const chainId = m ? parseInt(m[1], 10) : null;
|
|
121
131
|
const a = (m ? m[2] : e).trim();
|
|
122
|
-
return { chainId, asset:
|
|
132
|
+
return { chainId, asset: /^0x/i.test(a) ? a.toLowerCase() : a };
|
|
123
133
|
});
|
|
124
134
|
}
|
|
125
135
|
export function autopayAssetAllowed(chainId, asset) {
|
|
126
136
|
if (isStableAsset(chainId, asset))
|
|
127
137
|
return { allowed: true, via: 'stablecoin' };
|
|
128
|
-
const wanted = asset ? (
|
|
138
|
+
const wanted = asset ? (/^0x/i.test(asset) ? asset.toLowerCase() : asset) : 'native';
|
|
129
139
|
const hit = parseAutopayAssets(process.env.AGENTWALLET_AUTOPAY_ASSETS).some(e => e.asset === wanted && (e.chainId === null || e.chainId === chainId));
|
|
130
140
|
if (hit)
|
|
131
141
|
return { allowed: true, via: 'allowlist' };
|
package/build/x402-permit2.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { X402Requirement } from './x402-eip3009.js';
|
|
2
2
|
export declare const PERMIT2_ADDRESS: "0x000000000022D473030F116dDEE9F6B43aC78BA3";
|
|
3
|
+
export declare function permit2Address(chainId: number): `0x${string}`;
|
|
3
4
|
export declare const X402_UPTO_PERMIT2_PROXY: "0x4020A4f3b7b90ccA423B9fabCc0CE57C6C240002";
|
|
4
5
|
/** EIP-712 types. Referenced types must follow the primary type in alphabetical order (TokenPermissions, Witness). */
|
|
5
6
|
export declare const UPTO_PERMIT2_WITNESS_TYPES: {
|
|
@@ -62,7 +63,7 @@ export declare function uptoTypedData(chainId: number, auth: UptoPermit2Authoriz
|
|
|
62
63
|
domain: {
|
|
63
64
|
name: string;
|
|
64
65
|
chainId: number;
|
|
65
|
-
verifyingContract:
|
|
66
|
+
verifyingContract: `0x${string}`;
|
|
66
67
|
};
|
|
67
68
|
types: {
|
|
68
69
|
readonly PermitWitnessTransferFrom: readonly [{
|
|
@@ -118,7 +119,7 @@ export declare function uptoTypedData(chainId: number, auth: UptoPermit2Authoriz
|
|
|
118
119
|
/** The payload object that goes under `payload` in the x402 payment. */
|
|
119
120
|
export declare function uptoPayload(auth: UptoPermit2Authorization, signature: string): Record<string, unknown>;
|
|
120
121
|
/** allowance(owner, Permit2) calldata. */
|
|
121
|
-
export declare function permit2AllowanceCalldata(owner: string): `0x${string}`;
|
|
122
|
+
export declare function permit2AllowanceCalldata(owner: string, chainId?: number): `0x${string}`;
|
|
122
123
|
/** approve(Permit2, amount) calldata; unlimited only when no amount is given. */
|
|
123
|
-
export declare function permit2ApproveCalldata(amountRaw?: string): `0x${string}`;
|
|
124
|
+
export declare function permit2ApproveCalldata(amountRaw?: string, chainId?: number): `0x${string}`;
|
|
124
125
|
export declare function decodeUint(hex: string): bigint;
|
package/build/x402-permit2.js
CHANGED
|
@@ -10,6 +10,9 @@
|
|
|
10
10
|
import { randomBytes } from 'node:crypto';
|
|
11
11
|
import { maxAuthWindowSeconds } from './x402-payment.js';
|
|
12
12
|
export const PERMIT2_ADDRESS = '0x000000000022D473030F116dDEE9F6B43aC78BA3';
|
|
13
|
+
/** Chains whose Permit2 is not at the canonical address (zkSync Era runs its own deployment). */
|
|
14
|
+
const PERMIT2_BY_CHAIN = { 324: '0x0000000000225e31D15943971F47aD3022F714Fa' };
|
|
15
|
+
export function permit2Address(chainId) { return PERMIT2_BY_CHAIN[chainId] ?? PERMIT2_ADDRESS; }
|
|
13
16
|
export const X402_UPTO_PERMIT2_PROXY = '0x4020A4f3b7b90ccA423B9fabCc0CE57C6C240002';
|
|
14
17
|
/** EIP-712 types. Referenced types must follow the primary type in alphabetical order (TokenPermissions, Witness). */
|
|
15
18
|
export const UPTO_PERMIT2_WITNESS_TYPES = {
|
|
@@ -64,7 +67,7 @@ export function buildUptoAuthorization(from, req, nowSeconds = Math.floor(Date.n
|
|
|
64
67
|
}
|
|
65
68
|
export function uptoTypedData(chainId, auth) {
|
|
66
69
|
return {
|
|
67
|
-
domain: { name: 'Permit2', chainId, verifyingContract:
|
|
70
|
+
domain: { name: 'Permit2', chainId, verifyingContract: permit2Address(chainId) },
|
|
68
71
|
types: UPTO_PERMIT2_WITNESS_TYPES,
|
|
69
72
|
primaryType: 'PermitWitnessTransferFrom',
|
|
70
73
|
message: {
|
|
@@ -82,13 +85,15 @@ export function uptoPayload(auth, signature) {
|
|
|
82
85
|
}
|
|
83
86
|
function pad(addr) { return addr.replace(/^0x/, '').toLowerCase().padStart(64, '0'); }
|
|
84
87
|
/** allowance(owner, Permit2) calldata. */
|
|
85
|
-
export function permit2AllowanceCalldata(owner) {
|
|
86
|
-
return ('0xdd62ed3e' + pad(owner) + pad(
|
|
88
|
+
export function permit2AllowanceCalldata(owner, chainId = 8453) {
|
|
89
|
+
return ('0xdd62ed3e' + pad(owner) + pad(permit2Address(chainId)));
|
|
87
90
|
}
|
|
88
91
|
/** approve(Permit2, amount) calldata; unlimited only when no amount is given. */
|
|
89
|
-
export function permit2ApproveCalldata(amountRaw) {
|
|
92
|
+
export function permit2ApproveCalldata(amountRaw, chainId = 8453) {
|
|
93
|
+
if (amountRaw !== undefined && (!/^\d+$/.test(amountRaw) || BigInt(amountRaw) > (1n << 256n) - 1n))
|
|
94
|
+
throw new Error(`Permit2 approval amount "${amountRaw}" is not a uint256.`);
|
|
90
95
|
const word = amountRaw === undefined ? 'f'.repeat(64) : BigInt(amountRaw).toString(16).padStart(64, '0');
|
|
91
|
-
return ('0x095ea7b3' + pad(
|
|
96
|
+
return ('0x095ea7b3' + pad(permit2Address(chainId)) + word);
|
|
92
97
|
}
|
|
93
98
|
export function decodeUint(hex) {
|
|
94
99
|
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.6",
|
|
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 && node test/audit-1135.test.mjs && node test/local-no-autopay.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 && node test/audit-1136.test.mjs",
|
|
20
20
|
"prepublishOnly": "npm run build"
|
|
21
21
|
},
|
|
22
22
|
"keywords": [
|