agentwallet-mcp 1.13.7 → 1.13.9
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 +197 -36
- package/build/local-wallet.js +4 -1
- package/build/x402-eip3009.js +23 -4
- package/build/x402-payment.d.ts +4 -0
- package/build/x402-payment.js +7 -1
- 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, assetSupportsExact, } 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, approvalResourceUrl, } 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
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, settlementVerdict, legacyReceiptAllowed, } from './x402-eip3009.js';
|
|
23
|
+
import { isLegacyAgentWalletAccept, requiredAmount, buildAuthorization, buildPaymentPayload, buildPaymentPayloadRaw, paymentHeaderFor, parsePaymentRequired, parseSettlement, pickOption, knownTokenDomain, decodeAbiString as decodeDomainString, 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() {
|
|
@@ -223,6 +223,7 @@ async function api(path, method = 'GET', body, extraHeaders) {
|
|
|
223
223
|
const url = `${API_BASE}${path}`;
|
|
224
224
|
const headers = {
|
|
225
225
|
'Content-Type': 'application/json',
|
|
226
|
+
'Accept-Encoding': 'identity', // a 500 KB gzip body inflated to 500 MB before the size check (round 3)
|
|
226
227
|
...extraHeaders,
|
|
227
228
|
};
|
|
228
229
|
if (API_USER && API_PASS) {
|
|
@@ -240,9 +241,7 @@ async function api(path, method = 'GET', body, extraHeaders) {
|
|
|
240
241
|
if (res.status >= 300 && res.status < 400) {
|
|
241
242
|
throw new Error(`The AgentWallet API host answered ${path} with a redirect (HTTP ${res.status}); refusing to follow it.`);
|
|
242
243
|
}
|
|
243
|
-
const text = await res
|
|
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.`);
|
|
244
|
+
const text = await readBounded(res, API_MAX_RESPONSE_CHARS, 'The AgentWallet API host');
|
|
246
245
|
let data;
|
|
247
246
|
try {
|
|
248
247
|
data = JSON.parse(text);
|
|
@@ -250,6 +249,7 @@ async function api(path, method = 'GET', body, extraHeaders) {
|
|
|
250
249
|
catch {
|
|
251
250
|
throw new Error(`The AgentWallet API host returned a non-JSON response (HTTP ${res.status}).`);
|
|
252
251
|
}
|
|
252
|
+
data = boundHostData(data, 0); // strings, arrays and depth bounded; the host writes none of the agent's instructions
|
|
253
253
|
// A 402 from the API host is paid automatically only for a HOSTED wallet the
|
|
254
254
|
// operator named with AGENTWALLET_WALLET_ID. In local mode the host is not a
|
|
255
255
|
// custodian and must never be able to make the local key sign: a 402 is
|
|
@@ -259,15 +259,79 @@ async function api(path, method = 'GET', body, extraHeaders) {
|
|
|
259
259
|
if (anyLocalMode()) {
|
|
260
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
261
|
}
|
|
262
|
-
if (X402_WALLET_ID)
|
|
262
|
+
if (X402_WALLET_ID) {
|
|
263
|
+
// Only the routes the hosted billing meters (create, sign, send) are paid for
|
|
264
|
+
// silently; a 402 on a read or anything else is reported, never paid (round 3).
|
|
265
|
+
if (!autopayRouteAllowed(path, method)) {
|
|
266
|
+
throw new Error(`The AgentWallet API host asked for payment on ${method} ${path}, a route the hosted billing does not meter; nothing was paid. If you trust it, call pay_x402 with that URL so the payment is explicit and reported.`);
|
|
267
|
+
}
|
|
263
268
|
return handleX402Payment(data, path, method, body);
|
|
269
|
+
}
|
|
264
270
|
}
|
|
265
271
|
if (!res.ok) {
|
|
266
|
-
const error = data.error || `HTTP ${res.status}`;
|
|
272
|
+
const error = serverText(data.error) || `HTTP ${res.status}`;
|
|
267
273
|
throw new Error(error);
|
|
268
274
|
}
|
|
275
|
+
// A send that comes back without a hash was not sent, whatever the body says
|
|
276
|
+
// ("Transfer complete. 5 ETH sent." with no tx_hash, round 3).
|
|
277
|
+
if (method === 'POST' && /^\/wallets\/[^/]+\/send(\?|$)/.test(path) && data && typeof data === 'object') {
|
|
278
|
+
const d = data;
|
|
279
|
+
if (!looksLikeTxHash(d.tx_hash) && !(typeof d.signature === 'string' && /^[1-9A-HJ-NP-Za-km-z]{43,120}$/.test(d.signature))) {
|
|
280
|
+
throw new Error('The AgentWallet API host answered the send without a transaction hash or signature; treating it as not sent.');
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
if (data && typeof data === 'object' && !Array.isArray(data))
|
|
284
|
+
data.server_text_is_untrusted = true;
|
|
269
285
|
return data;
|
|
270
286
|
}
|
|
287
|
+
/** Internal auto-pay covers the metered wallet operations only. */
|
|
288
|
+
function autopayRouteAllowed(path, method) {
|
|
289
|
+
return method === 'POST' && /^\/wallets(\/\d+\/(sign|send))?(\?|$)/.test(path);
|
|
290
|
+
}
|
|
291
|
+
const AUTOPAY_PER_HOUR = (() => { const n = Number(process.env.AGENTWALLET_MAX_AUTOPAY_PER_HOUR ?? '20'); return Number.isInteger(n) && n >= 0 ? n : 20; })();
|
|
292
|
+
const autopayLog = [];
|
|
293
|
+
/** Read a body no larger than maxChars, refusing early on a declared length and mid-stream on a real one. */
|
|
294
|
+
async function readBounded(res, maxChars, who) {
|
|
295
|
+
const declared = Number(res.headers.get('content-length') || 0);
|
|
296
|
+
if (declared > maxChars)
|
|
297
|
+
throw new Error(`${who} declared a ${declared}-byte body; refusing to read more than ${maxChars}.`);
|
|
298
|
+
if (!res.body)
|
|
299
|
+
return '';
|
|
300
|
+
const reader = res.body.getReader();
|
|
301
|
+
const chunks = [];
|
|
302
|
+
let total = 0;
|
|
303
|
+
for (;;) {
|
|
304
|
+
const { done, value } = await reader.read();
|
|
305
|
+
if (done)
|
|
306
|
+
break;
|
|
307
|
+
total += value.length;
|
|
308
|
+
if (total > maxChars) {
|
|
309
|
+
await reader.cancel().catch(() => { });
|
|
310
|
+
throw new Error(`${who} sent more than ${maxChars} bytes; refusing to read further.`);
|
|
311
|
+
}
|
|
312
|
+
chunks.push(value);
|
|
313
|
+
}
|
|
314
|
+
return Buffer.concat(chunks).toString('utf8');
|
|
315
|
+
}
|
|
316
|
+
const HOST_MAX_DEPTH = 32;
|
|
317
|
+
const HOST_MAX_STRING = 4000;
|
|
318
|
+
const HOST_MAX_ARRAY = 1000;
|
|
319
|
+
/** Host JSON with every string, array and nesting level bounded (an 8 KB body of nested arrays became a 32 MB result, round 3). */
|
|
320
|
+
function boundHostData(v, depth) {
|
|
321
|
+
if (depth > HOST_MAX_DEPTH)
|
|
322
|
+
throw new Error(`The AgentWallet API host returned JSON nested deeper than ${HOST_MAX_DEPTH} levels; refusing it.`);
|
|
323
|
+
if (typeof v === 'string')
|
|
324
|
+
return v.length > HOST_MAX_STRING ? v.slice(0, HOST_MAX_STRING) + ' [truncated by agentwallet-mcp]' : v;
|
|
325
|
+
if (Array.isArray(v))
|
|
326
|
+
return v.slice(0, HOST_MAX_ARRAY).map(x => boundHostData(x, depth + 1));
|
|
327
|
+
if (v && typeof v === 'object') {
|
|
328
|
+
const out = {};
|
|
329
|
+
for (const [k, x] of Object.entries(v))
|
|
330
|
+
out[k.slice(0, 200)] = boundHostData(x, depth + 1);
|
|
331
|
+
return out;
|
|
332
|
+
}
|
|
333
|
+
return v;
|
|
334
|
+
}
|
|
271
335
|
/**
|
|
272
336
|
* Resolve an asset's decimals from a source the paying party controls, never
|
|
273
337
|
* from the 402 response.
|
|
@@ -359,6 +423,14 @@ async function handleX402Payment(x402Data, originalPath, originalMethod, origina
|
|
|
359
423
|
? await resolveTrustedDecimals(chainId, assetForDecimals)
|
|
360
424
|
: nativeDecimals(chainId); // native asset: 18 on EVM (wei), 9 on Solana (lamports)
|
|
361
425
|
const { tokenAddress, rawAmount, decimals } = deriveX402Payment(accept, maxAutopay, trustedDecimals);
|
|
426
|
+
// A hostile or broken host that answers 402 on every call must not drain the
|
|
427
|
+
// wallet one cap at a time: at most AUTOPAY_PER_HOUR payments an hour (round 3).
|
|
428
|
+
const hourAgo = Date.now() - 3_600_000;
|
|
429
|
+
while (autopayLog.length && autopayLog[0] < hourAgo)
|
|
430
|
+
autopayLog.shift();
|
|
431
|
+
if (autopayLog.length >= AUTOPAY_PER_HOUR) {
|
|
432
|
+
throw new Error(`x402 auto-pay: ${autopayLog.length} automatic payments were already made in the last hour (AGENTWALLET_MAX_AUTOPAY_PER_HOUR=${AUTOPAY_PER_HOUR}); refusing another. Original error: ${serverText(x402Data.error)}`);
|
|
433
|
+
}
|
|
362
434
|
// Send payment using our wallet (with X-AGW-SKIP-X402 to prevent recursion)
|
|
363
435
|
let txHash;
|
|
364
436
|
try {
|
|
@@ -377,11 +449,18 @@ async function handleX402Payment(x402Data, originalPath, originalMethod, origina
|
|
|
377
449
|
}
|
|
378
450
|
}
|
|
379
451
|
catch (e) {
|
|
380
|
-
throw new Error(`x402 auto-pay failed: ${e.message}. Original error: ${x402Data.error}`);
|
|
452
|
+
throw new Error(`x402 auto-pay failed: ${e.message}. Original error: ${serverText(x402Data.error)}`);
|
|
381
453
|
}
|
|
382
454
|
if (!txHash) {
|
|
383
455
|
throw new Error('x402 auto-pay: no transaction hash returned.');
|
|
384
456
|
}
|
|
457
|
+
autopayLog.push(Date.now());
|
|
458
|
+
const autopayNotice = {
|
|
459
|
+
amount: formatUnits(rawAmount, decimals), token: assetLabel(chainId, tokenAddress), pay_to: payTo, chain_id: chainId, tx_hash: txHash,
|
|
460
|
+
route: `${originalMethod} ${originalPath}`,
|
|
461
|
+
note: 'Paid automatically from the hosted wallet named by AGENTWALLET_WALLET_ID because the API host answered 402 on this operation.',
|
|
462
|
+
};
|
|
463
|
+
console.error(`[agentwallet] auto-paid ${autopayNotice.amount} ${autopayNotice.token} to ${payTo} on chain ${chainId} for ${originalMethod} ${originalPath} (tx ${txHash})`);
|
|
385
464
|
// Build X-PAYMENT header (base64-encoded JSON proof)
|
|
386
465
|
const proof = {
|
|
387
466
|
x402Version: 1,
|
|
@@ -393,8 +472,12 @@ async function handleX402Payment(x402Data, originalPath, originalMethod, origina
|
|
|
393
472
|
// Wait briefly for tx confirmation (EVM ~2s, Solana ~6s)
|
|
394
473
|
const waitMs = isSolanaChain(chainId) ? 8000 : 3000;
|
|
395
474
|
await new Promise(resolve => setTimeout(resolve, waitMs));
|
|
396
|
-
// Retry original request with payment proof
|
|
397
|
-
|
|
475
|
+
// Retry original request with payment proof; the payment rides along in the result,
|
|
476
|
+
// which used to say nothing about it (round 3).
|
|
477
|
+
const paid = await api(originalPath, originalMethod, originalBody, { 'X-PAYMENT': paymentHeader });
|
|
478
|
+
if (paid && typeof paid === 'object' && !Array.isArray(paid))
|
|
479
|
+
return { ...paid, autopay_payment: autopayNotice };
|
|
480
|
+
return { result: paid, autopay_payment: autopayNotice };
|
|
398
481
|
}
|
|
399
482
|
/**
|
|
400
483
|
* Build ERC-20 transfer(address,uint256) calldata
|
|
@@ -405,8 +488,18 @@ function buildErc20TransferData(to, amount) {
|
|
|
405
488
|
const amountHex = BigInt(amount).toString(16).padStart(64, '0');
|
|
406
489
|
return '0xa9059cbb' + addressPadded + amountHex;
|
|
407
490
|
}
|
|
491
|
+
const RESULT_MAX_CHARS = 1_000_000;
|
|
408
492
|
function jsonResponse(data) {
|
|
409
|
-
|
|
493
|
+
let text;
|
|
494
|
+
try {
|
|
495
|
+
text = JSON.stringify(data, null, 2);
|
|
496
|
+
}
|
|
497
|
+
catch (e) {
|
|
498
|
+
text = JSON.stringify({ error: 'The result could not be serialised: ' + e.message });
|
|
499
|
+
}
|
|
500
|
+
if (text.length > RESULT_MAX_CHARS)
|
|
501
|
+
text = text.slice(0, RESULT_MAX_CHARS) + `\n... [result truncated at ${RESULT_MAX_CHARS} characters by agentwallet-mcp]`;
|
|
502
|
+
return { content: [{ type: 'text', text }] };
|
|
410
503
|
}
|
|
411
504
|
// ─── Solana Helpers ───────────────────────────────────────────────
|
|
412
505
|
function isSolanaChain(chainId) {
|
|
@@ -452,6 +545,7 @@ function redactUrl(u) {
|
|
|
452
545
|
}
|
|
453
546
|
const API_MAX_RESPONSE_CHARS = 8 * 1024 * 1024;
|
|
454
547
|
const API_TIMEOUT_MS = 60_000; // a hosted hop that hangs must not hold a signing gate open
|
|
548
|
+
const PAYWALL_MAX_CHARS = 4 * 1024 * 1024; // a paywall's answer, before and after payment
|
|
455
549
|
/* Error text from the local signing path with every URL reduced to its origin
|
|
456
550
|
and viem's "URL:" / "Request body:" lines dropped: an RPC endpoint carries
|
|
457
551
|
its API key in the path or query, and a tool result is the agent's context
|
|
@@ -528,11 +622,17 @@ function parseUnits(amount, decimals) {
|
|
|
528
622
|
* Format raw units to human-readable string given decimals.
|
|
529
623
|
*/
|
|
530
624
|
function formatUnits(raw, decimals) {
|
|
625
|
+
// decimals 300000 from a host once held the whole server for 36 s in the regex below (round 3)
|
|
626
|
+
if (!Number.isInteger(decimals) || decimals < 0 || decimals > 36)
|
|
627
|
+
throw new Error(`decimals must be an integer from 0 to 36, got ${String(decimals)}.`);
|
|
531
628
|
const padded = raw.padStart(decimals + 1, '0');
|
|
532
629
|
const whole = padded.slice(0, padded.length - decimals) || '0';
|
|
533
630
|
const frac = padded.slice(padded.length - decimals);
|
|
534
|
-
// Trim trailing zeros but keep at least one decimal
|
|
535
|
-
|
|
631
|
+
// Trim trailing zeros but keep at least one decimal (a loop, not /0+$/, which backtracks quadratically)
|
|
632
|
+
let end = frac.length;
|
|
633
|
+
while (end > 0 && frac[end - 1] === '0')
|
|
634
|
+
end--;
|
|
635
|
+
const trimmed = frac.slice(0, end) || '0';
|
|
536
636
|
return decimals === 0 ? whole : `${whole}.${trimmed}`; // a 0-decimal amount has no fraction, so it round-trips through parseUnits
|
|
537
637
|
}
|
|
538
638
|
/**
|
|
@@ -579,7 +679,7 @@ function resolveChainId(network) {
|
|
|
579
679
|
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');
|
|
580
680
|
const server = new McpServer({
|
|
581
681
|
name: 'agentwallet',
|
|
582
|
-
version: '1.13.
|
|
682
|
+
version: '1.13.9',
|
|
583
683
|
}, {
|
|
584
684
|
instructions: `AgentWallet gives AI agents their own blockchain wallets. ${anyLocalMode()
|
|
585
685
|
? '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.'
|
|
@@ -841,7 +941,7 @@ server.tool('get_token_balance', 'Get the ERC-20 or SPL token balance for a wall
|
|
|
841
941
|
// The response's own decimals (local mode reads them from the contract) win
|
|
842
942
|
// over the caller's guess; a guess of 18 used to reformat a 1 USDC balance
|
|
843
943
|
// as 0.000000000001. When nothing supplies them, resolve them ourselves.
|
|
844
|
-
let trusted = typeof data.decimals === 'number' ? data.decimals : null;
|
|
944
|
+
let trusted = (typeof data.decimals === 'number' && Number.isInteger(data.decimals) && data.decimals >= 0 && data.decimals <= 36) ? data.decimals : null;
|
|
845
945
|
if (trusted === null) {
|
|
846
946
|
if (typeof decimals === 'number')
|
|
847
947
|
trusted = decimals;
|
|
@@ -854,7 +954,7 @@ server.tool('get_token_balance', 'Get the ERC-20 or SPL token balance for a wall
|
|
|
854
954
|
? `decimals ${decimals} was passed but the token reports ${data.decimals}; the token's value was used.` : undefined;
|
|
855
955
|
return jsonResponse({
|
|
856
956
|
...data,
|
|
857
|
-
balance: data.
|
|
957
|
+
balance: /^\d+$/.test(String(data.balance_raw ?? '')) ? formatUnits(String(data.balance_raw), trusted) : serverText(data.balance), // computed here, not the host's word
|
|
858
958
|
decimals: trusted,
|
|
859
959
|
...(warning ? { warning } : {}),
|
|
860
960
|
});
|
|
@@ -1190,8 +1290,11 @@ async function resolveTokenDomain(chainId, asset, extra) {
|
|
|
1190
1290
|
return declared;
|
|
1191
1291
|
let name = '', version = '';
|
|
1192
1292
|
try {
|
|
1193
|
-
|
|
1194
|
-
|
|
1293
|
+
// The signing decoder (256 bytes, strict UTF-8), not the 64-char display sanitiser
|
|
1294
|
+
// get_token_info uses: a truncated or replaced name signs under a domain the
|
|
1295
|
+
// token never had (round 3).
|
|
1296
|
+
name = decodeDomainString(await ethCallHex(chainId, asset, '0x06fdde03')); // name()
|
|
1297
|
+
version = decodeDomainString(await ethCallHex(chainId, asset, '0x54fd4d50')); // version()
|
|
1195
1298
|
}
|
|
1196
1299
|
catch { /* refused below */ }
|
|
1197
1300
|
if (!name || !version) {
|
|
@@ -1325,7 +1428,7 @@ server.tool('pay_x402', 'Handle an x402 payment flow. Fetches a URL, and if the
|
|
|
1325
1428
|
error: `The endpoint redirected to another origin (${new URL(servedFrom).origin}) before asking for payment. Call pay_x402 with that URL directly if you trust it.`,
|
|
1326
1429
|
final_url: servedFrom });
|
|
1327
1430
|
}
|
|
1328
|
-
const initialText = await initialRes
|
|
1431
|
+
const initialText = await readBounded(initialRes, PAYWALL_MAX_CHARS, 'The endpoint');
|
|
1329
1432
|
let initialBody = null;
|
|
1330
1433
|
try {
|
|
1331
1434
|
initialBody = JSON.parse(initialText);
|
|
@@ -1386,6 +1489,10 @@ server.tool('pay_x402', 'Handle an x402 payment flow. Fetches a URL, and if the
|
|
|
1386
1489
|
// "exact" requirement in a token with no EIP-3009 can never settle, so it
|
|
1387
1490
|
// is refused before an approval could be consumed for it.
|
|
1388
1491
|
const legacyReceipt = isLegacyAgentWalletAccept(option) && legacyReceiptAllowed(servedFrom, API_BASE, process.env.AGENTWALLET_LEGACY_PAYWALL_ORIGINS);
|
|
1492
|
+
// Only a signed authorization (EIP-3009 "exact" or Permit2 "upto") is bound to an
|
|
1493
|
+
// approval by the hosted signer; a broadcast transfer (native coin, Solana, an
|
|
1494
|
+
// AgentWallet receipt paywall) is not, so an approval must never lift the cap for one.
|
|
1495
|
+
const willSign = isUpto || (!isSolanaChain(chainId) && Boolean(tokenAddress) && !legacyReceipt);
|
|
1389
1496
|
if (!isUpto && !isSolanaChain(chainId) && tokenAddress && !legacyReceipt && !assetSupportsExact(chainId, tokenAddress)) {
|
|
1390
1497
|
return jsonResponse({
|
|
1391
1498
|
status: 402, payment_required: true, payment_made: false,
|
|
@@ -1393,6 +1500,20 @@ server.tool('pay_x402', 'Handle an x402 payment flow. Fetches a URL, and if the
|
|
|
1393
1500
|
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
1501
|
});
|
|
1395
1502
|
}
|
|
1503
|
+
// Step 3d: The reuse key. It is computed before the cap so that an
|
|
1504
|
+
// authorization already signed for exactly this purchase is re-sent even when
|
|
1505
|
+
// the cap or approval that admitted it is no longer available (an approval is
|
|
1506
|
+
// consumed by the signature, so a retry after a dropped paid request used to
|
|
1507
|
+
// dead-end on "already used", round 3). The body counts only when it is sent.
|
|
1508
|
+
const METHOD = (method || 'GET').toUpperCase();
|
|
1509
|
+
const bodyDigest = createHash('sha256').update(METHOD !== 'GET' && reqBody ? reqBody : '').digest('hex').slice(0, 16);
|
|
1510
|
+
const walletKey = anyLocalMode() ? 'local' : String(wallet_id); // the local signer has one key whatever wallet_id says
|
|
1511
|
+
const resourceTag = [walletKey, chainId, authorizationResource(url), METHOD, bodyDigest].join('|');
|
|
1512
|
+
const cacheKey = [walletKey, chainId, tokenAddress.toLowerCase(), option.payTo.toLowerCase(), rawAmount, option.scheme, resourceTag].join('|');
|
|
1513
|
+
for (const [k, v] of signedPayments)
|
|
1514
|
+
if (v.until <= Date.now())
|
|
1515
|
+
signedPayments.delete(k);
|
|
1516
|
+
const alreadySigned = !fresh_authorization && (signedPayments.get(cacheKey)?.until ?? 0) > Date.now();
|
|
1396
1517
|
// Step 4: Hard per-payment cap, ALWAYS applied. If the caller omits max_payment
|
|
1397
1518
|
// we fall back to AGENTWALLET_MAX_AUTOPAY (default "1"). Above the cap a hosted
|
|
1398
1519
|
// wallet can ask its owner instead of refusing: an approval is created, the
|
|
@@ -1402,7 +1523,7 @@ server.tool('pay_x402', 'Handle an x402 payment flow. Fetches a URL, and if the
|
|
|
1402
1523
|
// above it is ignored and reported, because the agent is what the cap bounds.
|
|
1403
1524
|
const { cap: effectiveMax, source: capSource, clamped } = effectiveAutopayCap(max_payment);
|
|
1404
1525
|
let approvalUsed = null;
|
|
1405
|
-
if (!isWithinCap(rawAmount, trustedDecimals, effectiveMax)) {
|
|
1526
|
+
if (!alreadySigned && !isWithinCap(rawAmount, trustedDecimals, effectiveMax)) {
|
|
1406
1527
|
const overCap = {
|
|
1407
1528
|
status: 402, payment_required: true, payment_made: false,
|
|
1408
1529
|
required_amount: amount, max_allowed: effectiveMax, cap_source: capSource, token: tokenLabel,
|
|
@@ -1411,9 +1532,12 @@ server.tool('pay_x402', 'Handle an x402 payment flow. Fetches a URL, and if the
|
|
|
1411
1532
|
};
|
|
1412
1533
|
const wantApproval = request_approval ?? (process.env.AGENTWALLET_APPROVALS !== '0');
|
|
1413
1534
|
if (approval_id) {
|
|
1535
|
+
if (!willSign) {
|
|
1536
|
+
return jsonResponse({ ...overCap, approval_id, error: `Approval ${approval_id} cannot cover this payment: it is a broadcast transfer (native coin, Solana, or an AgentWallet receipt paywall), which the hosted signer does not bind to an approval, so the same id would lift the cap on every call. Approvals cover EIP-3009 "exact" and Permit2 "upto" authorizations only.` });
|
|
1537
|
+
}
|
|
1414
1538
|
// Local mode refuses before any lookup: only the hosted signer can consume
|
|
1415
1539
|
// an approval, so here the same id would lift the cap on every call.
|
|
1416
|
-
const want = { walletId: wallet_id, chainId, asset: tokenAddress, payTo: option.payTo, rawAmount };
|
|
1540
|
+
const want = { walletId: wallet_id, chainId, asset: tokenAddress, payTo: option.payTo, rawAmount, scheme: option.scheme };
|
|
1417
1541
|
const local = anyLocalMode();
|
|
1418
1542
|
const a = local ? null : (await api(`/approvals/${encodeURIComponent(approval_id)}`, 'GET', undefined, skip));
|
|
1419
1543
|
const refusal = approvalRefusal(approval_id, a, want, local);
|
|
@@ -1422,10 +1546,10 @@ server.tool('pay_x402', 'Handle an x402 payment flow. Fetches a URL, and if the
|
|
|
1422
1546
|
}
|
|
1423
1547
|
approvalUsed = String(approval_id);
|
|
1424
1548
|
}
|
|
1425
|
-
else if (wantApproval && !anyLocalMode()) {
|
|
1549
|
+
else if (wantApproval && !anyLocalMode() && willSign) {
|
|
1426
1550
|
const created = (await api('/approvals', 'POST', {
|
|
1427
1551
|
wallet_id, chain_id: chainId, scheme: option.scheme, asset: tokenAddress, pay_to: option.payTo, value: rawAmount,
|
|
1428
|
-
amount_human: amount, token_name: tokenLabel, url,
|
|
1552
|
+
amount_human: amount, token_name: tokenLabel, url: approvalResourceUrl(url),
|
|
1429
1553
|
}, skip));
|
|
1430
1554
|
if (!created?.id) {
|
|
1431
1555
|
return jsonResponse({ ...overCap, error: `Payment of ${amount} ${tokenLabel} exceeds the ${effectiveMax} cap and the approval request failed: ${created?.error || 'unknown error'}.` });
|
|
@@ -1451,7 +1575,7 @@ server.tool('pay_x402', 'Handle an x402 payment flow. Fetches a URL, and if the
|
|
|
1451
1575
|
// usage; the payer broadcasts nothing and pays no gas for either. AgentWallet's
|
|
1452
1576
|
// own paywalls (verified by receipt), native-asset requests and Solana still use
|
|
1453
1577
|
// a broadcast transfer proved by hash.
|
|
1454
|
-
const standardExact = !isUpto &&
|
|
1578
|
+
const standardExact = !isUpto && willSign;
|
|
1455
1579
|
let headerName = 'X-PAYMENT';
|
|
1456
1580
|
let paymentHeader = '';
|
|
1457
1581
|
let txHash = null;
|
|
@@ -1467,16 +1591,35 @@ server.tool('pay_x402', 'Handle an x402 payment flow. Fetches a URL, and if the
|
|
|
1467
1591
|
// The method and body are part of what was bought: POST {item:A} and
|
|
1468
1592
|
// POST {item:B} to one URL are two purchases. Concurrent calls for one key
|
|
1469
1593
|
// wait for the first signature instead of each signing a nonce.
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1594
|
+
// (bodyDigest and cacheKey were computed in Step 3d, before the cap.)
|
|
1595
|
+
// A mutex, not a one-shot wait: when the first signer fails nothing is cached,
|
|
1596
|
+
// and every waiter used to proceed at once and sign its own nonce (round 3).
|
|
1597
|
+
// The resource is a second mutex key: parallel calls whose requirements differ
|
|
1598
|
+
// (a paywall varying the amount per answer) would otherwise sign side by side
|
|
1599
|
+
// before any of them could see the other's live authorization.
|
|
1600
|
+
const resourceLock = 'resource|' + resourceTag;
|
|
1601
|
+
while (!fresh_authorization && (signingInFlight.get(cacheKey) || signingInFlight.get(resourceLock))) {
|
|
1602
|
+
await (signingInFlight.get(cacheKey) || signingInFlight.get(resourceLock));
|
|
1603
|
+
}
|
|
1475
1604
|
const cachedAuth = fresh_authorization ? undefined : signedPayments.get(cacheKey);
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1605
|
+
if (!(cachedAuth && cachedAuth.until > Date.now()) && !fresh_authorization) {
|
|
1606
|
+
// One live authorization per resource. A paywall that answers every call with
|
|
1607
|
+
// a slightly different requirement (amount 999999, 999998, ...) used to harvest
|
|
1608
|
+
// a cap-sized nonce per call (round 3); the second distinct requirement for the
|
|
1609
|
+
// same purchase is refused until the first expires or fresh_authorization is passed.
|
|
1610
|
+
const other = [...signedPayments.entries()].find(([k, v]) => k !== cacheKey && v.resource === resourceTag && v.until > Date.now());
|
|
1611
|
+
if (other) {
|
|
1612
|
+
const [, live] = other;
|
|
1613
|
+
return jsonResponse({
|
|
1614
|
+
status: 402, payment_required: true, payment_made: false,
|
|
1615
|
+
error: `This resource already holds a live authorization signed ${Math.round((Date.now() - live.signedAt) / 1000)} s ago for ${live.amount} ${tokenLabel} (the endpoint's requirement changed between answers: amount, recipient, asset or scheme). It is re-sent by calling pay_x402 again with the same arguments once the endpoint asks for the same requirement. Nothing more is signed for this resource until that authorization expires.`,
|
|
1616
|
+
live_authorization: { amount: live.amount, valid_until: new Date(live.until).toISOString(), payer: live.payer },
|
|
1617
|
+
requested_amount: amount, token: tokenLabel, pay_to: option.payTo, chain_id: chainId,
|
|
1618
|
+
});
|
|
1619
|
+
}
|
|
1620
|
+
}
|
|
1479
1621
|
let releaseSigning = () => { };
|
|
1622
|
+
let mineLock = null;
|
|
1480
1623
|
if (!(cachedAuth && cachedAuth.until > Date.now())) {
|
|
1481
1624
|
const mine = new Promise(r => { releaseSigning = r; });
|
|
1482
1625
|
// Released in the finally below, whatever path this call takes. A timer
|
|
@@ -1484,6 +1627,10 @@ server.tool('pay_x402', 'Handle an x402 payment flow. Fetches a URL, and if the
|
|
|
1484
1627
|
// a second call signed a second nonce (2026-10-04 round 2); every
|
|
1485
1628
|
// network hop inside the gate carries its own timeout instead.
|
|
1486
1629
|
signingInFlight.set(cacheKey, mine);
|
|
1630
|
+
if (!fresh_authorization) {
|
|
1631
|
+
signingInFlight.set(resourceLock, mine);
|
|
1632
|
+
mineLock = mine;
|
|
1633
|
+
}
|
|
1487
1634
|
}
|
|
1488
1635
|
try {
|
|
1489
1636
|
if (cachedAuth && cachedAuth.until > Date.now()) {
|
|
@@ -1510,7 +1657,7 @@ server.tool('pay_x402', 'Handle an x402 payment flow. Fetches a URL, and if the
|
|
|
1510
1657
|
token: tokenLabel, token_address: tokenAddress, chain_id: chainId, payer, permit2: permit2Address(chainId),
|
|
1511
1658
|
error: `This endpoint uses the x402 "upto" scheme, which settles through Permit2. Wallet ${payer} has not approved ` +
|
|
1512
1659
|
`${tokenLabel} to Permit2 on chain ${chainId}. Call approve_permit2 once for this token (a normal transaction that needs gas), ` +
|
|
1513
|
-
`
|
|
1660
|
+
`then call pay_x402 again.` + (assetPolicy.via === 'stablecoin' ? ' The operator may instead set AGENTWALLET_PERMIT2_AUTO_APPROVE=1 (registry stablecoins only); that is the operator\'s setting, not the agent\'s.' : ''),
|
|
1514
1661
|
});
|
|
1515
1662
|
}
|
|
1516
1663
|
}
|
|
@@ -1555,12 +1702,17 @@ server.tool('pay_x402', 'Handle an x402 payment flow. Fetches a URL, and if the
|
|
|
1555
1702
|
const until = authorization ? Number(authorization.validBefore) * 1000
|
|
1556
1703
|
: uptoAuth ? Number(uptoAuth.deadline) * 1000
|
|
1557
1704
|
: 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 });
|
|
1705
|
+
signedPayments.set(cacheKey, { headerName, paymentHeader, txHash, authorization, uptoAuth, payer, until, resource: resourceTag, amount, signedAt: Date.now() });
|
|
1706
|
+
// A receipt on stderr the moment a signature exists, so a result the transport
|
|
1707
|
+
// drops (a client crash, an oversized frame) is still auditable (round 3).
|
|
1708
|
+
console.error(`[agentwallet] x402 authorization sent: ${amount} ${tokenLabel} to ${option.payTo} on chain ${chainId} for ${authorizationResource(url)} (${authorization ? 'nonce ' + authorization.nonce : uptoAuth ? 'permit2 nonce ' + uptoAuth.nonce : 'tx ' + txHash})`);
|
|
1559
1709
|
}
|
|
1560
1710
|
}
|
|
1561
1711
|
finally {
|
|
1562
1712
|
releaseSigning();
|
|
1563
1713
|
signingInFlight.delete(cacheKey);
|
|
1714
|
+
if (signingInFlight.get(resourceLock) === mineLock)
|
|
1715
|
+
signingInFlight.delete(resourceLock);
|
|
1564
1716
|
}
|
|
1565
1717
|
// Step 6: Retry with the payment header and read the settlement receipt.
|
|
1566
1718
|
const retryHeaders = { ...reqHeaders, [headerName]: paymentHeader };
|
|
@@ -1585,7 +1737,7 @@ server.tool('pay_x402', 'Handle an x402 payment flow. Fetches a URL, and if the
|
|
|
1585
1737
|
tx_hash: txHash ?? null, amount, token: tokenLabel, pay_to: option.payTo, chain_id: chainId,
|
|
1586
1738
|
});
|
|
1587
1739
|
}
|
|
1588
|
-
const retryText = await retryRes
|
|
1740
|
+
const retryText = await readBounded(retryRes, PAYWALL_MAX_CHARS, 'The endpoint');
|
|
1589
1741
|
let retryParsed;
|
|
1590
1742
|
try {
|
|
1591
1743
|
retryParsed = JSON.parse(retryText);
|
|
@@ -1600,6 +1752,8 @@ server.tool('pay_x402', 'Handle an x402 payment flow. Fetches a URL, and if the
|
|
|
1600
1752
|
const retryServedFrom = finalUrlOf(retryRes, url);
|
|
1601
1753
|
const retryCrossOrigin = new URL(retryServedFrom).origin !== new URL(url).origin;
|
|
1602
1754
|
const verdict = settlementVerdict(retryRes.status, settlement, retryCrossOrigin);
|
|
1755
|
+
const claimedRaw = typeof settlement?.amount === 'string' && /^\d+$/.test(settlement.amount) ? settlement.amount : null;
|
|
1756
|
+
const claimedAboveMax = claimedRaw !== null && BigInt(claimedRaw) > BigInt(rawAmount);
|
|
1603
1757
|
if (retryCrossOrigin) {
|
|
1604
1758
|
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.`;
|
|
1605
1759
|
}
|
|
@@ -1624,6 +1778,9 @@ server.tool('pay_x402', 'Handle an x402 payment flow. Fetches a URL, and if the
|
|
|
1624
1778
|
permit2_approval_tx: permit2ApprovalTx,
|
|
1625
1779
|
x402_version: x402Version,
|
|
1626
1780
|
amount,
|
|
1781
|
+
// For upto the amount is the MAXIMUM the facilitator may settle, not what was
|
|
1782
|
+
// taken; the server's claimed settled amount is shown apart and unverified (round 3).
|
|
1783
|
+
...(isUpto ? { amount_is_maximum: true, authorized_max: amount, settled_amount_claimed: serverText(settlement?.amount), settled_claim_inconsistent: claimedAboveMax } : {}),
|
|
1627
1784
|
token: tokenLabel,
|
|
1628
1785
|
token_address: tokenAddress || null,
|
|
1629
1786
|
network: option.network,
|
|
@@ -1936,8 +2093,12 @@ server.tool('export_wallet_key', 'Explain how to export the private key of a hos
|
|
|
1936
2093
|
// ─── Start ──────────────────────────────────────────────────────
|
|
1937
2094
|
async function main() {
|
|
1938
2095
|
validateGuardEnv();
|
|
1939
|
-
|
|
2096
|
+
// 64 MB frames before the SDK closes the transport (default 10 MB); errors are logged, not swallowed (round 3).
|
|
2097
|
+
const transport = new StdioServerTransport(process.stdin, process.stdout, { maxBufferSize: 64 * 1024 * 1024 });
|
|
2098
|
+
process.stdout.on('error', (e) => { if (e.code === 'EPIPE')
|
|
2099
|
+
process.exit(0); console.error('[agentwallet] stdout error: ' + e.message); });
|
|
1940
2100
|
await server.connect(transport);
|
|
2101
|
+
server.server.onerror = (e) => console.error('[agentwallet] transport error: ' + e.message);
|
|
1941
2102
|
/* stderr, so it never corrupts the stdio JSON-RPC stream. Announcing custody
|
|
1942
2103
|
at startup means an operator sees which mode they are in without asking. */
|
|
1943
2104
|
if (anyLocalMode()) {
|
package/build/local-wallet.js
CHANGED
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
import { readFileSync, statSync } from 'node:fs';
|
|
18
18
|
import { createPublicClient, createWalletClient, http, encodeFunctionData, parseAbi, formatUnits, } from 'viem';
|
|
19
19
|
import { privateKeyToAccount } from 'viem/accounts';
|
|
20
|
+
import { nonceManager } from 'viem/nonce';
|
|
20
21
|
import { lookupTrustedDecimals, maxAuthWindowSeconds } from './x402-payment.js';
|
|
21
22
|
import { wrappedNativeAddress } from './wrapped-native.js';
|
|
22
23
|
import { typedDataFor } from './x402-eip3009.js';
|
|
@@ -79,7 +80,9 @@ export function getLocalAccount() {
|
|
|
79
80
|
throw new Error(keyLoadError); // the keyfile's own reason, before the generic line
|
|
80
81
|
if (!raw)
|
|
81
82
|
throw new Error('Local signing mode is not configured.');
|
|
82
|
-
|
|
83
|
+
// The nonce manager hands parallel sends consecutive nonces; without it every
|
|
84
|
+
// concurrent send read the same pending nonce and all but one failed (round 3).
|
|
85
|
+
cachedAccount = privateKeyToAccount(normalizeKey(raw), { nonceManager });
|
|
83
86
|
return cachedAccount;
|
|
84
87
|
}
|
|
85
88
|
/** The address the local key controls. Safe to log. */
|
package/build/x402-eip3009.js
CHANGED
|
@@ -121,7 +121,14 @@ export function decodeAbiString(hex) {
|
|
|
121
121
|
if (!Number.isFinite(len) || len <= 0 || len > 256)
|
|
122
122
|
return '';
|
|
123
123
|
const bytes = Buffer.from(h.slice(128, 128 + len * 2), 'hex');
|
|
124
|
-
|
|
124
|
+
// The domain separator is keccak of the raw bytes: a name that is not valid
|
|
125
|
+
// UTF-8 cannot be reproduced from a string, so it is refused, never guessed.
|
|
126
|
+
try {
|
|
127
|
+
return new TextDecoder('utf-8', { fatal: true }).decode(bytes);
|
|
128
|
+
}
|
|
129
|
+
catch {
|
|
130
|
+
return '';
|
|
131
|
+
}
|
|
125
132
|
}
|
|
126
133
|
/** Build the payment payload the resource server expects for this x402 version. */
|
|
127
134
|
export function buildPaymentPayload(x402Version, req, auth, signature, resource, extensions) {
|
|
@@ -188,7 +195,11 @@ export function parsePaymentRequired(getHeader, body) {
|
|
|
188
195
|
network and payTo are strings. A null entry, or an array where an address
|
|
189
196
|
should be, used to reach the pickers and surface a raw TypeError or a
|
|
190
197
|
coerced value (2026-10-04 round 2). Numeric amounts are kept as strings. */
|
|
191
|
-
const MAX_ACCEPTS = 64;
|
|
198
|
+
const MAX_ACCEPTS = 64;
|
|
199
|
+
/** An all-uppercase hex address is not an EIP-55 checksum and viem refuses it at signing; lowercase has no checksum to fail (round 3). Mixed case is kept: it IS a checksum. */
|
|
200
|
+
function lowerIfAllCaps(v) {
|
|
201
|
+
return (typeof v === 'string' && /^0x[0-9A-F]{40}$/.test(v) && /[A-F]/.test(v)) ? v.toLowerCase() : v;
|
|
202
|
+
} // a bound on the picker's work, well above anything a real server lists
|
|
192
203
|
export function sanitizeRequired(req) {
|
|
193
204
|
const str = (v) => (typeof v === 'string' ? v : typeof v === 'number' && Number.isFinite(v) ? String(v) : undefined);
|
|
194
205
|
const accepts = [];
|
|
@@ -196,7 +207,7 @@ export function sanitizeRequired(req) {
|
|
|
196
207
|
if (!a || typeof a !== 'object' || Array.isArray(a))
|
|
197
208
|
continue;
|
|
198
209
|
const o = a;
|
|
199
|
-
const scheme = str(o.scheme), network = str(o.network), payTo = str(o.payTo);
|
|
210
|
+
const scheme = str(o.scheme), network = str(o.network), payTo = lowerIfAllCaps(str(o.payTo));
|
|
200
211
|
if (scheme === undefined || network === undefined || payTo === undefined)
|
|
201
212
|
continue;
|
|
202
213
|
const extraIn = (o.extra && typeof o.extra === 'object' && !Array.isArray(o.extra)) ? o.extra : undefined;
|
|
@@ -206,6 +217,8 @@ export function sanitizeRequired(req) {
|
|
|
206
217
|
if (k in extra && typeof extra[k] !== 'string')
|
|
207
218
|
delete extra[k];
|
|
208
219
|
}
|
|
220
|
+
if (extra && typeof extra.token === 'string')
|
|
221
|
+
extra.token = lowerIfAllCaps(extra.token);
|
|
209
222
|
const out = { ...o, scheme, network, payTo };
|
|
210
223
|
for (const k of ['amount', 'maxAmountRequired', 'asset', 'description', 'resource', 'mimeType']) {
|
|
211
224
|
const v = str(o[k]);
|
|
@@ -214,6 +227,8 @@ export function sanitizeRequired(req) {
|
|
|
214
227
|
else
|
|
215
228
|
out[k] = v;
|
|
216
229
|
}
|
|
230
|
+
if (typeof out.asset === 'string')
|
|
231
|
+
out.asset = lowerIfAllCaps(out.asset);
|
|
217
232
|
if (extra)
|
|
218
233
|
out.extra = extra;
|
|
219
234
|
else
|
|
@@ -267,7 +282,11 @@ export function pickOption(accepts, resolveChainId, preferChain) {
|
|
|
267
282
|
const payable = [...exact, ...upto];
|
|
268
283
|
if (payable.length === 0) {
|
|
269
284
|
const schemes = Array.from(new Set(accepts.map(a => String(a.scheme).slice(0, 40)))).slice(0, 10).join(', ') || 'none';
|
|
270
|
-
|
|
285
|
+
if (onChain.length === 0 && accepts.length > 0) {
|
|
286
|
+
const nets = Array.from(new Set(accepts.map(a => String(a.network).slice(0, 40)))).slice(0, 10).join(', ');
|
|
287
|
+
return { option: null, chainId: null, reason: `No payable option: the offered network(s) ${nets} are not ones this client knows (pass a CAIP-2 id like eip155:8453 or a chain id).` };
|
|
288
|
+
}
|
|
289
|
+
const reason = onChain.some(a => a.scheme === 'upto')
|
|
271
290
|
? 'This endpoint offers the x402 "upto" scheme without an extra.facilitatorAddress (or without an asset), so the ' +
|
|
272
291
|
'Permit2 authorization cannot be bound to a facilitator. AgentWallet will not approximate it with an upfront transfer. ' +
|
|
273
292
|
'Ask the endpoint operator to publish facilitatorAddress, or an "exact" option.'
|
package/build/x402-payment.d.ts
CHANGED
|
@@ -177,6 +177,7 @@ export declare function isWithinCap(rawAmountRequired: string, trustedDecimals:
|
|
|
177
177
|
/** The row GET /approvals/{id} answers with. Numbers arrive as strings from the server. */
|
|
178
178
|
export interface ApprovalRow {
|
|
179
179
|
status?: string;
|
|
180
|
+
scheme?: string;
|
|
180
181
|
used?: number | string;
|
|
181
182
|
wallet_id?: number | string;
|
|
182
183
|
chain_id?: number | string;
|
|
@@ -191,7 +192,10 @@ export interface ApprovalWant {
|
|
|
191
192
|
asset: string;
|
|
192
193
|
payTo: string;
|
|
193
194
|
rawAmount: string;
|
|
195
|
+
scheme?: string;
|
|
194
196
|
}
|
|
197
|
+
/** The url stored with an approval request: origin and path only, never userinfo or a query (round 3). */
|
|
198
|
+
export declare function approvalResourceUrl(url: string): string;
|
|
195
199
|
/**
|
|
196
200
|
* Why an approval cannot stand in for the cap on this payment, or null when it can.
|
|
197
201
|
*
|
package/build/x402-payment.js
CHANGED
|
@@ -386,6 +386,11 @@ export function isWithinCap(rawAmountRequired, trustedDecimals, capHuman) {
|
|
|
386
386
|
const capRaw = toBaseUnits(capHuman, trustedDecimals);
|
|
387
387
|
return BigInt(rawAmountRequired) <= BigInt(capRaw);
|
|
388
388
|
}
|
|
389
|
+
/** The url stored with an approval request: origin and path only, never userinfo or a query (round 3). */
|
|
390
|
+
export function approvalResourceUrl(url) {
|
|
391
|
+
const u = new URL(url);
|
|
392
|
+
return u.origin + u.pathname;
|
|
393
|
+
}
|
|
389
394
|
/**
|
|
390
395
|
* Why an approval cannot stand in for the cap on this payment, or null when it can.
|
|
391
396
|
*
|
|
@@ -424,10 +429,11 @@ export function approvalRefusal(approvalId, row, want, localMode) {
|
|
|
424
429
|
&& Number(row?.chain_id) === want.chainId
|
|
425
430
|
&& String(row?.asset ?? '').toLowerCase() === want.asset.toLowerCase()
|
|
426
431
|
&& String(row?.pay_to ?? '').toLowerCase() === want.payTo.toLowerCase()
|
|
432
|
+
&& (!want.scheme || !row?.scheme || String(row.scheme) === want.scheme)
|
|
427
433
|
&& /^\d+$/.test(value) && /^\d+$/.test(want.rawAmount)
|
|
428
434
|
&& BigInt(value) >= BigInt(want.rawAmount);
|
|
429
435
|
if (!covers) {
|
|
430
|
-
return { error: 'Approval does not cover this payment (wallet, asset, recipient, chain or amount differ).' };
|
|
436
|
+
return { error: 'Approval does not cover this payment (wallet, asset, recipient, chain, scheme or amount differ).' };
|
|
431
437
|
}
|
|
432
438
|
return null;
|
|
433
439
|
}
|
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.9",
|
|
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 && node test/audit-1136.test.mjs && node test/delete-confirm.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 && node test/delete-confirm.test.mjs && node test/audit-1138.test.mjs && node test/audit-1139.test.mjs",
|
|
20
20
|
"prepublishOnly": "npm run build"
|
|
21
21
|
},
|
|
22
22
|
"keywords": [
|