agentwallet-mcp 1.13.4 → 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/README.md +1 -1
- package/build/index.js +248 -162
- package/build/local-solana.d.ts +2 -0
- package/build/local-solana.js +55 -9
- package/build/local-wallet.d.ts +23 -0
- package/build/local-wallet.js +67 -32
- package/build/ssrf-guard.d.ts +2 -0
- package/build/ssrf-guard.js +9 -1
- package/build/x402-eip3009.d.ts +12 -0
- package/build/x402-eip3009.js +73 -4
- package/build/x402-payment.d.ts +11 -0
- package/build/x402-payment.js +78 -14
- package/build/x402-permit2.d.ts +5 -4
- package/build/x402-permit2.js +13 -7
- package/package.json +2 -2
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, assetSupportsExact, } 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
|
-
import { buildUptoAuthorization, uptoPayload, permit2AllowanceCalldata, permit2ApproveCalldata, decodeUint,
|
|
21
|
+
import { buildUptoAuthorization, uptoPayload, permit2AllowanceCalldata, permit2ApproveCalldata, decodeUint, permit2Address, } from './x402-permit2.js';
|
|
21
22
|
import { assessTokenRisk } from './token-risk.js';
|
|
22
|
-
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';
|
|
23
|
+
import { isLegacyAgentWalletAccept, requiredAmount, buildAuthorization, buildPaymentPayload, buildPaymentPayloadRaw, paymentHeaderFor, parsePaymentRequired, parseSettlement, pickOption, knownTokenDomain, parseEip7702Delegation, erc1271ProbeCalldata, classifyErc1271Probe, settlementVerdict, legacyReceiptAllowed, } from './x402-eip3009.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', signal: AbortSignal.timeout(API_TIMEOUT_MS) };
|
|
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}`;
|
|
@@ -395,8 +426,10 @@ function isValidAddress(address) {
|
|
|
395
426
|
}
|
|
396
427
|
// ─── Guard helpers ──────────────────────────────────────────────
|
|
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
|
-
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
|
|
399
431
|
/** Authorizations signed in this process, keyed by endpoint and requirement, kept until they expire. */
|
|
432
|
+
const signingInFlight = new Map();
|
|
400
433
|
const signedPayments = new Map();
|
|
401
434
|
function looksLikeTxHash(v) {
|
|
402
435
|
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 +450,18 @@ function redactUrl(u) {
|
|
|
417
450
|
return u;
|
|
418
451
|
}
|
|
419
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
|
|
455
|
+
/* Error text from the local signing path with every URL reduced to its origin
|
|
456
|
+
and viem's "URL:" / "Request body:" lines dropped: an RPC endpoint carries
|
|
457
|
+
its API key in the path or query, and a tool result is the agent's context
|
|
458
|
+
and the client's log (2026-10-04 audit). */
|
|
459
|
+
function redactRpcSecrets(err) {
|
|
460
|
+
const e = err;
|
|
461
|
+
let msg = e?.shortMessage ? [e.shortMessage, e.details].filter(Boolean).join(' ') : String(e?.message ?? err);
|
|
462
|
+
msg = msg.split(/\r?\n/).filter(l => !/^\s*(URL|Request body|Request Arguments|Raw Call Arguments)\s*:/i.test(l)).join('\n').trim();
|
|
463
|
+
return msg.replace(/[a-z][a-z0-9+.-]*:\/\/[^\s"'<>)]+/gi, (u) => redactUrl(u)); // brackets stay in: IPv6 hosts
|
|
464
|
+
}
|
|
420
465
|
/**
|
|
421
466
|
* Decimals to scale a human amount by. Always the token's own value (registry,
|
|
422
467
|
* then decimals() on chain); a caller-supplied value must agree or the call
|
|
@@ -425,12 +470,12 @@ function redactUrl(u) {
|
|
|
425
470
|
async function verifiedDecimals(chainId, token, given) {
|
|
426
471
|
let trusted = null;
|
|
427
472
|
if (isSolanaChain(chainId)) {
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
473
|
+
// The mint itself says how many decimals it has; a caller's value only
|
|
474
|
+
// ever confirms it (2026-10-04 audit: the substituted byte could not catch
|
|
475
|
+
// an amount already scaled by a wrong guess).
|
|
476
|
+
trusted = await mintDecimalsStrict(token, chainId);
|
|
477
|
+
if (trusted === null)
|
|
478
|
+
throw new Error(`Could not read the decimals of mint ${token} from the chain; refusing to guess the scale of the amount.`);
|
|
434
479
|
}
|
|
435
480
|
else {
|
|
436
481
|
trusted = await resolveTrustedDecimals(chainId, token);
|
|
@@ -444,15 +489,23 @@ async function verifiedDecimals(chainId, token, given) {
|
|
|
444
489
|
function validateGuardEnv() {
|
|
445
490
|
parseDecimalPins(process.env.AGENTWALLET_TOKEN_DECIMALS); // throws on a malformed pin
|
|
446
491
|
for (const name of ['AGENTWALLET_MAX_TX_NATIVE', 'AGENTWALLET_MAX_TX_TOKEN', 'AGENTWALLET_MAX_TX_SOL', 'AGENTWALLET_MAX_AUTOPAY']) {
|
|
447
|
-
const
|
|
492
|
+
const raw = process.env[name];
|
|
493
|
+
const v = (raw || '').trim();
|
|
494
|
+
// Set but blank is a typo, not "no cap": refuse to start rather than run uncapped.
|
|
495
|
+
if (raw !== undefined && v === '')
|
|
496
|
+
throw new Error(`${name} is set but blank. Set it to a number or unset it.`);
|
|
448
497
|
if (v && !/^\d+(\.\d+)?$/.test(v))
|
|
449
498
|
throw new Error(`${name} must be a decimal number, got "${v}".`);
|
|
450
499
|
}
|
|
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.`);
|
|
500
|
+
for (const e of parseAutopayAssets(process.env.AGENTWALLET_AUTOPAY_ASSETS)) {
|
|
501
|
+
if (e.asset !== 'native' && !/^0x[0-9a-f]{40}$/.test(e.asset) && !/^[1-9A-HJ-NP-Za-km-z]{32,44}$/.test(e.asset)) {
|
|
502
|
+
throw new Error(`AGENTWALLET_AUTOPAY_ASSETS entry "${e.asset}" is not "native", an EVM address or an SPL mint (optionally prefixed "chainId:").`);
|
|
454
503
|
}
|
|
455
504
|
}
|
|
505
|
+
const t = (process.env.AGENTWALLET_X402_MAX_TIMEOUT || '').trim();
|
|
506
|
+
if (t && (!/^\d+$/.test(t) || Number(t) < 60 || Number(t) > 31_536_000)) {
|
|
507
|
+
throw new Error(`AGENTWALLET_X402_MAX_TIMEOUT must be a whole number of seconds between 60 and 31536000, got "${t}".`);
|
|
508
|
+
}
|
|
456
509
|
}
|
|
457
510
|
// ─── EVM Helpers ──────────────────────────────────────────────────
|
|
458
511
|
/**
|
|
@@ -480,7 +533,7 @@ function formatUnits(raw, decimals) {
|
|
|
480
533
|
const frac = padded.slice(padded.length - decimals);
|
|
481
534
|
// Trim trailing zeros but keep at least one decimal
|
|
482
535
|
const trimmed = frac.replace(/0+$/, '') || '0';
|
|
483
|
-
return `${whole}.${trimmed}`;
|
|
536
|
+
return decimals === 0 ? whole : `${whole}.${trimmed}`; // a 0-decimal amount has no fraction, so it round-trips through parseUnits
|
|
484
537
|
}
|
|
485
538
|
/**
|
|
486
539
|
* Pad an address to 32 bytes (64 hex chars) for ABI encoding.
|
|
@@ -503,48 +556,13 @@ function encodeUint256(value) {
|
|
|
503
556
|
}
|
|
504
557
|
// ─── Server ──────────────────────────────────────────────────────
|
|
505
558
|
// ─── 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
559
|
/**
|
|
526
|
-
* Resolve an x402 network identifier to a chain ID
|
|
527
|
-
*
|
|
560
|
+
* Resolve an x402 network identifier to a chain ID: plain names ("base"),
|
|
561
|
+
* CAIP-2 ("eip155:8453", "solana:<genesis prefix>") and raw ids ("8453").
|
|
562
|
+
* The table and the parsing live in x402-payment.ts so they can be tested.
|
|
528
563
|
*/
|
|
529
564
|
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;
|
|
565
|
+
return resolveNetworkChainId(network);
|
|
548
566
|
}
|
|
549
567
|
// ─── Server ──────────────────────────────────────────────────────
|
|
550
568
|
/**
|
|
@@ -561,7 +579,7 @@ function resolveChainId(network) {
|
|
|
561
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');
|
|
562
580
|
const server = new McpServer({
|
|
563
581
|
name: 'agentwallet',
|
|
564
|
-
version: '1.13.
|
|
582
|
+
version: '1.13.6',
|
|
565
583
|
}, {
|
|
566
584
|
instructions: `AgentWallet gives AI agents their own blockchain wallets. ${anyLocalMode()
|
|
567
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.'
|
|
@@ -930,7 +948,7 @@ server.tool('approve_token', 'Approve a spender contract to transfer ERC-20 toke
|
|
|
930
948
|
// Asset risk rides along with the approval (issue #13). It never blocks; it is the
|
|
931
949
|
// caller's call. Skipped for Permit2, which only moves what a signature allows.
|
|
932
950
|
let risk = null;
|
|
933
|
-
if (spender.toLowerCase() !==
|
|
951
|
+
if (spender.toLowerCase() !== permit2Address(chain_id).toLowerCase() && process.env.AGENTWALLET_TOKEN_RISK !== '0') {
|
|
934
952
|
try {
|
|
935
953
|
risk = await assessTokenRisk(chain_id, token, (to, data) => ethCallHex(chain_id, to, data));
|
|
936
954
|
}
|
|
@@ -1159,11 +1177,17 @@ async function ethCallHex(chainId, to, data) {
|
|
|
1159
1177
|
* rather than guess.
|
|
1160
1178
|
*/
|
|
1161
1179
|
async function resolveTokenDomain(chainId, asset, extra) {
|
|
1162
|
-
if (extra?.name && extra?.version)
|
|
1163
|
-
return { name: String(extra.name), version: String(extra.version) };
|
|
1164
1180
|
const known = knownTokenDomain(chainId, asset);
|
|
1165
|
-
|
|
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
|
+
}
|
|
1166
1187
|
return known;
|
|
1188
|
+
}
|
|
1189
|
+
if (declared)
|
|
1190
|
+
return declared;
|
|
1167
1191
|
let name = '', version = '';
|
|
1168
1192
|
try {
|
|
1169
1193
|
name = decodeAbiString(await ethCallHex(chainId, asset, '0x06fdde03')); // name()
|
|
@@ -1176,6 +1200,13 @@ async function resolveTokenDomain(chainId, asset, extra) {
|
|
|
1176
1200
|
}
|
|
1177
1201
|
return { name, version };
|
|
1178
1202
|
}
|
|
1203
|
+
/** Text an endpoint wrote, bounded so it cannot flood the agent's context. */
|
|
1204
|
+
function serverText(s) {
|
|
1205
|
+
if (s === undefined || s === null)
|
|
1206
|
+
return null;
|
|
1207
|
+
const t = String(s);
|
|
1208
|
+
return t.length > 300 ? t.slice(0, 300) + '…' : t;
|
|
1209
|
+
}
|
|
1179
1210
|
/** Sign the authorization with whichever custody mode is active (local key, or the hosted signer). */
|
|
1180
1211
|
async function signAuthorization(walletId, chainId, asset, domain, auth, approvalId) {
|
|
1181
1212
|
const r = (await api(`/wallets/${walletId}/x402/authorize`, 'POST', {
|
|
@@ -1192,7 +1223,7 @@ async function signAuthorization(walletId, chainId, asset, domain, auth, approva
|
|
|
1192
1223
|
/** Sign a Permit2 upto authorization with whichever custody mode is active. */
|
|
1193
1224
|
async function signPermit2(walletId, chainId, auth, approvalId) {
|
|
1194
1225
|
const r = (await api(`/wallets/${walletId}/x402/permit2`, 'POST', {
|
|
1195
|
-
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),
|
|
1196
1227
|
nonce: auth.nonce, deadline: auth.deadline, to: auth.witness.to, facilitator: auth.witness.facilitator, valid_after: auth.witness.validAfter,
|
|
1197
1228
|
...(approvalId ? { approval_id: approvalId } : {}),
|
|
1198
1229
|
}, { 'X-AGW-SKIP-X402': 'true' }));
|
|
@@ -1206,8 +1237,8 @@ server.tool('pay_x402', 'Handle an x402 payment flow. Fetches a URL, and if the
|
|
|
1206
1237
|
'parses the payment requirements (v1 body or v2 PAYMENT-REQUIRED header), signs an EIP-3009 ' +
|
|
1207
1238
|
'TransferWithAuthorization for the "exact" scheme (no gas, nothing broadcast by the payer), and retries ' +
|
|
1208
1239
|
'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).
|
|
1240
|
+
'The "upto" scheme is settled with a Permit2 authorization bounded by the cap. Returns the final response and the settlement ' +
|
|
1241
|
+
'receipt. Supports the x402 open payment standard (https://x402.org). max_payment can only lower the operator\'s cap for one call.', {
|
|
1211
1242
|
url: z.string().url().describe('The URL to access (will handle 402 payment if required)'),
|
|
1212
1243
|
wallet_id: z.number().int().describe('Wallet ID to pay from'),
|
|
1213
1244
|
method: z.string().default('GET').describe('HTTP method (GET, POST, PUT, DELETE)'),
|
|
@@ -1216,10 +1247,8 @@ server.tool('pay_x402', 'Handle an x402 payment flow. Fetches a URL, and if the
|
|
|
1216
1247
|
'endpoint redirects to a different origin they are dropped, not forwarded.'),
|
|
1217
1248
|
body: z.string().optional().describe('Optional request body for POST/PUT requests'),
|
|
1218
1249
|
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.'),
|
|
1250
|
+
'Lowers the operator\'s AGENTWALLET_MAX_AUTOPAY ceiling (default "1") for this one call; ' +
|
|
1251
|
+
'it can never raise it, and a value above the ceiling is ignored and reported as max_payment_ignored.'),
|
|
1223
1252
|
prefer_chain: z.number().int().optional().describe('Preferred chain ID if the server accepts payment on multiple chains ' +
|
|
1224
1253
|
'(e.g. 8453 for Base, 1 for Ethereum)'),
|
|
1225
1254
|
request_approval: z.boolean().optional().describe('Hosted wallets only. When the payment exceeds the cap, email the wallet owner an approve/deny link ' +
|
|
@@ -1319,16 +1348,14 @@ server.tool('pay_x402', 'Handle an x402 payment flow. Fetches a URL, and if the
|
|
|
1319
1348
|
payment_required: true,
|
|
1320
1349
|
payment_made: false,
|
|
1321
1350
|
error: picked.reason,
|
|
1322
|
-
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,
|
|
1323
1353
|
});
|
|
1324
1354
|
}
|
|
1325
1355
|
const option = picked.option;
|
|
1326
1356
|
const chainId = picked.chainId;
|
|
1327
1357
|
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
|
-
}
|
|
1358
|
+
const rawAmount = normalizeRawAmount(requiredAmount(option)); // "0010000" and "10000" are one amount
|
|
1332
1359
|
// Decimals come from the registry or the token contract, NEVER from the 402
|
|
1333
1360
|
// body. The endpoint controls what it declares, so trusting it would let a
|
|
1334
1361
|
// hostile server inflate the cap and misreport the amount shown to the agent.
|
|
@@ -1352,6 +1379,20 @@ server.tool('pay_x402', 'Handle an x402 payment flow. Fetches a URL, and if the
|
|
|
1352
1379
|
error: assetPolicy.reason,
|
|
1353
1380
|
});
|
|
1354
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
|
+
}
|
|
1355
1396
|
// Step 4: Hard per-payment cap, ALWAYS applied. If the caller omits max_payment
|
|
1356
1397
|
// we fall back to AGENTWALLET_MAX_AUTOPAY (default "1"). Above the cap a hosted
|
|
1357
1398
|
// wallet can ask its owner instead of refusing: an approval is created, the
|
|
@@ -1366,7 +1407,7 @@ server.tool('pay_x402', 'Handle an x402 payment flow. Fetches a URL, and if the
|
|
|
1366
1407
|
status: 402, payment_required: true, payment_made: false,
|
|
1367
1408
|
required_amount: amount, max_allowed: effectiveMax, cap_source: capSource, token: tokenLabel,
|
|
1368
1409
|
...(clamped ? { max_payment_ignored: `max_payment ${max_payment} exceeds the operator ceiling AGENTWALLET_MAX_AUTOPAY=${effectiveMax}; only the operator can raise it.` } : {}),
|
|
1369
|
-
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,
|
|
1370
1411
|
};
|
|
1371
1412
|
const wantApproval = request_approval ?? (process.env.AGENTWALLET_APPROVALS !== '0');
|
|
1372
1413
|
if (approval_id) {
|
|
@@ -1400,8 +1441,8 @@ server.tool('pay_x402', 'Handle an x402 payment flow. Fetches a URL, and if the
|
|
|
1400
1441
|
...overCap,
|
|
1401
1442
|
error: `Payment of ${amount} ${tokenLabel} exceeds the ${effectiveMax} cap (from ${capSource}).` +
|
|
1402
1443
|
(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
|
|
1444
|
+
(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.'
|
|
1445
|
+
: ' Pass request_approval=true to email the wallet owner for a one-time approval; the cap itself is the operator\'s to change.'),
|
|
1405
1446
|
});
|
|
1406
1447
|
}
|
|
1407
1448
|
}
|
|
@@ -1410,7 +1451,7 @@ server.tool('pay_x402', 'Handle an x402 payment flow. Fetches a URL, and if the
|
|
|
1410
1451
|
// usage; the payer broadcasts nothing and pays no gas for either. AgentWallet's
|
|
1411
1452
|
// own paywalls (verified by receipt), native-asset requests and Solana still use
|
|
1412
1453
|
// a broadcast transfer proved by hash.
|
|
1413
|
-
const standardExact = !isUpto && !isSolanaChain(chainId) && Boolean(tokenAddress) && !
|
|
1454
|
+
const standardExact = !isUpto && !isSolanaChain(chainId) && Boolean(tokenAddress) && !legacyReceipt;
|
|
1414
1455
|
let headerName = 'X-PAYMENT';
|
|
1415
1456
|
let paymentHeader = '';
|
|
1416
1457
|
let txHash = null;
|
|
@@ -1423,80 +1464,103 @@ server.tool('pay_x402', 'Handle an x402 payment flow. Fetches a URL, and if the
|
|
|
1423
1464
|
// The resource is the whole URL including its query: /buy?id=1 and /buy?id=2
|
|
1424
1465
|
// are different purchases, and reusing the first signature for the second
|
|
1425
1466
|
// would report a payment the second resource never received.
|
|
1426
|
-
|
|
1467
|
+
// The method and body are part of what was bought: POST {item:A} and
|
|
1468
|
+
// POST {item:B} to one URL are two purchases. Concurrent calls for one key
|
|
1469
|
+
// wait for the first signature instead of each signing a nonce.
|
|
1470
|
+
const bodyDigest = createHash('sha256').update(reqBody ?? '').digest('hex').slice(0, 16);
|
|
1471
|
+
const cacheKey = [wallet_id, chainId, tokenAddress.toLowerCase(), option.payTo.toLowerCase(), rawAmount, option.scheme, authorizationResource(url), (method || 'GET').toUpperCase(), bodyDigest].join('|');
|
|
1472
|
+
const inFlight = signingInFlight.get(cacheKey);
|
|
1473
|
+
if (inFlight && !fresh_authorization)
|
|
1474
|
+
await inFlight;
|
|
1427
1475
|
const cachedAuth = fresh_authorization ? undefined : signedPayments.get(cacheKey);
|
|
1428
1476
|
for (const [k, v] of signedPayments)
|
|
1429
1477
|
if (v.until <= Date.now())
|
|
1430
1478
|
signedPayments.delete(k);
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
//
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
payer = await payerAddress(wallet_id);
|
|
1440
|
-
payerDelegationInfo = await payerDelegation(chainId, payer);
|
|
1441
|
-
const allowance = decodeUint(await ethCallHex(chainId, tokenAddress, permit2AllowanceCalldata(payer)));
|
|
1442
|
-
if (allowance < BigInt(rawAmount)) {
|
|
1443
|
-
if (process.env.AGENTWALLET_PERMIT2_AUTO_APPROVE === '1' && assetPolicy.via === 'stablecoin') {
|
|
1444
|
-
const r = (await api(`/wallets/${wallet_id}/send`, 'POST', { to: tokenAddress, value: '0', data: permit2ApproveCalldata(), chain_id: chainId }, skip));
|
|
1445
|
-
permit2ApprovalTx = String(r?.tx_hash || '');
|
|
1446
|
-
await new Promise(res => setTimeout(res, 4000));
|
|
1447
|
-
}
|
|
1448
|
-
else {
|
|
1449
|
-
return jsonResponse({
|
|
1450
|
-
status: 402, payment_required: true, payment_made: false, permit2_approval_needed: true,
|
|
1451
|
-
token: tokenLabel, token_address: tokenAddress, chain_id: chainId, payer, permit2: PERMIT2_ADDRESS,
|
|
1452
|
-
error: `This endpoint uses the x402 "upto" scheme, which settles through Permit2. Wallet ${payer} has not approved ` +
|
|
1453
|
-
`${tokenLabel} to Permit2 on chain ${chainId}. Call approve_permit2 once for this token (a normal transaction that needs gas), ` +
|
|
1454
|
-
`or set AGENTWALLET_PERMIT2_AUTO_APPROVE=1, then call pay_x402 again.`,
|
|
1455
|
-
});
|
|
1456
|
-
}
|
|
1457
|
-
}
|
|
1458
|
-
uptoAuth = buildUptoAuthorization(payer, option);
|
|
1459
|
-
const signature = await signPermit2(wallet_id, chainId, uptoAuth, approvalUsed);
|
|
1460
|
-
const payload = buildPaymentPayloadRaw(x402Version, option, uptoPayload(uptoAuth, signature), paymentInfo.resource, paymentInfo.extensions);
|
|
1461
|
-
const h = paymentHeaderFor(x402Version, payload);
|
|
1462
|
-
headerName = h.name;
|
|
1463
|
-
paymentHeader = h.value;
|
|
1464
|
-
}
|
|
1465
|
-
else if (standardExact) {
|
|
1466
|
-
payer = await payerAddress(wallet_id);
|
|
1467
|
-
payerDelegationInfo = await payerDelegation(chainId, payer);
|
|
1468
|
-
const domain = await resolveTokenDomain(chainId, tokenAddress, option.extra);
|
|
1469
|
-
authorization = buildAuthorization(payer, option);
|
|
1470
|
-
const signature = await signAuthorization(wallet_id, chainId, tokenAddress, domain, authorization, approvalUsed);
|
|
1471
|
-
const payload = buildPaymentPayload(x402Version, option, authorization, signature, paymentInfo.resource, paymentInfo.extensions);
|
|
1472
|
-
const h = paymentHeaderFor(x402Version, payload);
|
|
1473
|
-
headerName = h.name;
|
|
1474
|
-
paymentHeader = h.value;
|
|
1479
|
+
let releaseSigning = () => { };
|
|
1480
|
+
if (!(cachedAuth && cachedAuth.until > Date.now())) {
|
|
1481
|
+
const mine = new Promise(r => { releaseSigning = r; });
|
|
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);
|
|
1475
1487
|
}
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
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;
|
|
1482
1495
|
}
|
|
1483
|
-
else if (
|
|
1484
|
-
|
|
1485
|
-
|
|
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;
|
|
1523
|
+
}
|
|
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;
|
|
1486
1534
|
}
|
|
1487
1535
|
else {
|
|
1488
|
-
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');
|
|
1553
|
+
}
|
|
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 });
|
|
1489
1559
|
}
|
|
1490
|
-
txHash = String(txResult.tx_hash || txResult.signature || '');
|
|
1491
|
-
if (!txHash)
|
|
1492
|
-
throw new Error('x402: the payment transaction returned no hash.');
|
|
1493
|
-
paymentHeader = Buffer.from(JSON.stringify({ x402Version, scheme: option.scheme, network: option.network, payload: { txHash } })).toString('base64');
|
|
1494
1560
|
}
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
: Date.now() + maxAuthWindowSeconds() * 1000; // a broadcast transfer: the hash stays valid, re-sending it never pays twice
|
|
1499
|
-
signedPayments.set(cacheKey, { headerName, paymentHeader, txHash, authorization, uptoAuth, payer, until });
|
|
1561
|
+
finally {
|
|
1562
|
+
releaseSigning();
|
|
1563
|
+
signingInFlight.delete(cacheKey);
|
|
1500
1564
|
}
|
|
1501
1565
|
// Step 6: Retry with the payment header and read the settlement receipt.
|
|
1502
1566
|
const retryHeaders = { ...reqHeaders, [headerName]: paymentHeader };
|
|
@@ -1508,7 +1572,19 @@ server.tool('pay_x402', 'Handle an x402 payment flow. Fetches a URL, and if the
|
|
|
1508
1572
|
if (reqBody && method !== 'GET') {
|
|
1509
1573
|
retryOptions.body = reqBody;
|
|
1510
1574
|
}
|
|
1511
|
-
|
|
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
|
+
}
|
|
1512
1588
|
const retryText = await retryRes.text();
|
|
1513
1589
|
let retryParsed;
|
|
1514
1590
|
try {
|
|
@@ -1523,22 +1599,24 @@ server.tool('pay_x402', 'Handle an x402 payment flow. Fetches a URL, and if the
|
|
|
1523
1599
|
let retryError = null;
|
|
1524
1600
|
const retryServedFrom = finalUrlOf(retryRes, url);
|
|
1525
1601
|
const retryCrossOrigin = new URL(retryServedFrom).origin !== new URL(url).origin;
|
|
1602
|
+
const verdict = settlementVerdict(retryRes.status, settlement, retryCrossOrigin);
|
|
1526
1603
|
if (retryCrossOrigin) {
|
|
1527
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.`;
|
|
1528
1605
|
}
|
|
1529
1606
|
if (retryRes.status === 402) {
|
|
1530
1607
|
const again = parsePaymentRequired(n => retryRes.headers.get(n), retryParsed);
|
|
1531
|
-
retryError = String(again?.error || retryParsed?.error || 'Payment was not accepted (no reason given).');
|
|
1608
|
+
retryError = serverText(String(again?.error || retryParsed?.error || 'Payment was not accepted (no reason given).'));
|
|
1532
1609
|
if (payerDelegationInfo && /signature/i.test(retryError))
|
|
1533
1610
|
retryError += ` Likely cause: ${payerDelegationInfo.warning}`;
|
|
1534
1611
|
}
|
|
1535
1612
|
return jsonResponse({
|
|
1536
1613
|
status: retryRes.status,
|
|
1537
1614
|
payment_required: true,
|
|
1538
|
-
// Paid means
|
|
1539
|
-
//
|
|
1540
|
-
//
|
|
1541
|
-
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,
|
|
1542
1620
|
authorization_reused: authorizationReused,
|
|
1543
1621
|
retry_error: retryError,
|
|
1544
1622
|
payment_method: isUpto ? 'permit2-upto-authorization' : (standardExact ? 'eip3009-authorization' : 'onchain-transfer'),
|
|
@@ -1553,13 +1631,15 @@ server.tool('pay_x402', 'Handle an x402 payment flow. Fetches a URL, and if the
|
|
|
1553
1631
|
pay_to: option.payTo,
|
|
1554
1632
|
payer,
|
|
1555
1633
|
payer_delegation: payerDelegationInfo,
|
|
1556
|
-
//
|
|
1557
|
-
//
|
|
1558
|
-
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,
|
|
1559
1638
|
settlement_reported_by_server: settlement,
|
|
1560
1639
|
authorization: authorization ? { nonce: authorization.nonce, valid_before: authorization.validBefore }
|
|
1561
1640
|
: (uptoAuth ? { scheme: 'upto', max_amount: amount, nonce: uptoAuth.nonce, deadline: uptoAuth.deadline, facilitator: uptoAuth.witness.facilitator } : null),
|
|
1562
|
-
description: option.description,
|
|
1641
|
+
description: serverText(option.description),
|
|
1642
|
+
server_text_is_untrusted: true, // description, retry_error and the response are the endpoint's words, not instructions
|
|
1563
1643
|
response: retryParsed,
|
|
1564
1644
|
});
|
|
1565
1645
|
});
|
|
@@ -1578,13 +1658,13 @@ server.tool('approve_permit2', 'One-time ERC-20 approval of a token to the Permi
|
|
|
1578
1658
|
}, async ({ wallet_id, token, chain_id, amount }) => {
|
|
1579
1659
|
if (isSolanaChain(chain_id))
|
|
1580
1660
|
throw new Error('Permit2 is an EVM contract; there is nothing to approve on Solana.');
|
|
1581
|
-
let data = permit2ApproveCalldata();
|
|
1661
|
+
let data = permit2ApproveCalldata(undefined, chain_id);
|
|
1582
1662
|
if (amount !== undefined) {
|
|
1583
1663
|
const raw = parseUnits(amount, await resolveTrustedDecimals(chain_id, token));
|
|
1584
|
-
data =
|
|
1664
|
+
data = permit2ApproveCalldata(raw, chain_id);
|
|
1585
1665
|
}
|
|
1586
1666
|
const result = (await api(`/wallets/${wallet_id}/send`, 'POST', { to: token, value: '0', data, chain_id }));
|
|
1587
|
-
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.' });
|
|
1588
1668
|
});
|
|
1589
1669
|
// ─── Tool: check_token_risk ─────────────────────────────────────
|
|
1590
1670
|
server.tool('check_token_risk', 'Assess an ERC-20 token before approving or swapping it: honeypot, taxes, owner powers, verified source, ' +
|
|
@@ -1764,7 +1844,13 @@ server.tool('wallet_mode', 'Report whether this server is signing locally (self-
|
|
|
1764
1844
|
mode: 'custodial',
|
|
1765
1845
|
custody: 'agentwallet',
|
|
1766
1846
|
signing: 'AgentWallet servers hold an encrypted key and sign on your behalf.',
|
|
1767
|
-
api_base: API_BASE,
|
|
1847
|
+
api_base: redactUrl(API_BASE),
|
|
1848
|
+
client_guards: {
|
|
1849
|
+
autopay_cap_usd: autopayEnvCap(),
|
|
1850
|
+
autopay_assets: (process.env.AGENTWALLET_AUTOPAY_ASSETS || '').trim() || 'registry stablecoins only',
|
|
1851
|
+
x402_max_timeout_seconds: maxAuthWindowSeconds(),
|
|
1852
|
+
note: 'These bound pay_x402 on hosted wallets too; the hosted signer adds its own pause, daily limit and approval checks.',
|
|
1853
|
+
},
|
|
1768
1854
|
to_self_custody: 'Set AGENTWALLET_PRIVATE_KEY for EVM and/or AGENTWALLET_SOLANA_KEY for Solana, then restart. ' +
|
|
1769
1855
|
'Use export_wallet_key first if you want to carry an existing hosted wallet across.',
|
|
1770
1856
|
});
|
|
@@ -1864,7 +1950,7 @@ async function main() {
|
|
|
1864
1950
|
}
|
|
1865
1951
|
}
|
|
1866
1952
|
else {
|
|
1867
|
-
console.error('AgentWallet MCP: custodial mode via ' + API_BASE + '. Run wallet_mode for details.');
|
|
1953
|
+
console.error('AgentWallet MCP: custodial mode via ' + redactUrl(API_BASE) + '. Run wallet_mode for details.');
|
|
1868
1954
|
}
|
|
1869
1955
|
}
|
|
1870
1956
|
main().catch((error) => {
|