agentwallet-mcp 1.13.6 → 1.13.8
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 +194 -34
- package/build/local-wallet.js +4 -1
- 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,7 +13,7 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
|
13
13
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
14
14
|
import { z } from 'zod';
|
|
15
15
|
import { assertPublicUrl, safeFetch, 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';
|
|
@@ -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.8',
|
|
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
|
});
|
|
@@ -1325,7 +1425,7 @@ server.tool('pay_x402', 'Handle an x402 payment flow. Fetches a URL, and if the
|
|
|
1325
1425
|
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
1426
|
final_url: servedFrom });
|
|
1327
1427
|
}
|
|
1328
|
-
const initialText = await initialRes
|
|
1428
|
+
const initialText = await readBounded(initialRes, PAYWALL_MAX_CHARS, 'The endpoint');
|
|
1329
1429
|
let initialBody = null;
|
|
1330
1430
|
try {
|
|
1331
1431
|
initialBody = JSON.parse(initialText);
|
|
@@ -1386,6 +1486,10 @@ server.tool('pay_x402', 'Handle an x402 payment flow. Fetches a URL, and if the
|
|
|
1386
1486
|
// "exact" requirement in a token with no EIP-3009 can never settle, so it
|
|
1387
1487
|
// is refused before an approval could be consumed for it.
|
|
1388
1488
|
const legacyReceipt = isLegacyAgentWalletAccept(option) && legacyReceiptAllowed(servedFrom, API_BASE, process.env.AGENTWALLET_LEGACY_PAYWALL_ORIGINS);
|
|
1489
|
+
// Only a signed authorization (EIP-3009 "exact" or Permit2 "upto") is bound to an
|
|
1490
|
+
// approval by the hosted signer; a broadcast transfer (native coin, Solana, an
|
|
1491
|
+
// AgentWallet receipt paywall) is not, so an approval must never lift the cap for one.
|
|
1492
|
+
const willSign = isUpto || (!isSolanaChain(chainId) && Boolean(tokenAddress) && !legacyReceipt);
|
|
1389
1493
|
if (!isUpto && !isSolanaChain(chainId) && tokenAddress && !legacyReceipt && !assetSupportsExact(chainId, tokenAddress)) {
|
|
1390
1494
|
return jsonResponse({
|
|
1391
1495
|
status: 402, payment_required: true, payment_made: false,
|
|
@@ -1393,6 +1497,20 @@ server.tool('pay_x402', 'Handle an x402 payment flow. Fetches a URL, and if the
|
|
|
1393
1497
|
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
1498
|
});
|
|
1395
1499
|
}
|
|
1500
|
+
// Step 3d: The reuse key. It is computed before the cap so that an
|
|
1501
|
+
// authorization already signed for exactly this purchase is re-sent even when
|
|
1502
|
+
// the cap or approval that admitted it is no longer available (an approval is
|
|
1503
|
+
// consumed by the signature, so a retry after a dropped paid request used to
|
|
1504
|
+
// dead-end on "already used", round 3). The body counts only when it is sent.
|
|
1505
|
+
const METHOD = (method || 'GET').toUpperCase();
|
|
1506
|
+
const bodyDigest = createHash('sha256').update(METHOD !== 'GET' && reqBody ? reqBody : '').digest('hex').slice(0, 16);
|
|
1507
|
+
const walletKey = anyLocalMode() ? 'local' : String(wallet_id); // the local signer has one key whatever wallet_id says
|
|
1508
|
+
const resourceTag = [walletKey, chainId, authorizationResource(url), METHOD, bodyDigest].join('|');
|
|
1509
|
+
const cacheKey = [walletKey, chainId, tokenAddress.toLowerCase(), option.payTo.toLowerCase(), rawAmount, option.scheme, resourceTag].join('|');
|
|
1510
|
+
for (const [k, v] of signedPayments)
|
|
1511
|
+
if (v.until <= Date.now())
|
|
1512
|
+
signedPayments.delete(k);
|
|
1513
|
+
const alreadySigned = !fresh_authorization && (signedPayments.get(cacheKey)?.until ?? 0) > Date.now();
|
|
1396
1514
|
// Step 4: Hard per-payment cap, ALWAYS applied. If the caller omits max_payment
|
|
1397
1515
|
// we fall back to AGENTWALLET_MAX_AUTOPAY (default "1"). Above the cap a hosted
|
|
1398
1516
|
// wallet can ask its owner instead of refusing: an approval is created, the
|
|
@@ -1402,7 +1520,7 @@ server.tool('pay_x402', 'Handle an x402 payment flow. Fetches a URL, and if the
|
|
|
1402
1520
|
// above it is ignored and reported, because the agent is what the cap bounds.
|
|
1403
1521
|
const { cap: effectiveMax, source: capSource, clamped } = effectiveAutopayCap(max_payment);
|
|
1404
1522
|
let approvalUsed = null;
|
|
1405
|
-
if (!isWithinCap(rawAmount, trustedDecimals, effectiveMax)) {
|
|
1523
|
+
if (!alreadySigned && !isWithinCap(rawAmount, trustedDecimals, effectiveMax)) {
|
|
1406
1524
|
const overCap = {
|
|
1407
1525
|
status: 402, payment_required: true, payment_made: false,
|
|
1408
1526
|
required_amount: amount, max_allowed: effectiveMax, cap_source: capSource, token: tokenLabel,
|
|
@@ -1411,9 +1529,12 @@ server.tool('pay_x402', 'Handle an x402 payment flow. Fetches a URL, and if the
|
|
|
1411
1529
|
};
|
|
1412
1530
|
const wantApproval = request_approval ?? (process.env.AGENTWALLET_APPROVALS !== '0');
|
|
1413
1531
|
if (approval_id) {
|
|
1532
|
+
if (!willSign) {
|
|
1533
|
+
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.` });
|
|
1534
|
+
}
|
|
1414
1535
|
// Local mode refuses before any lookup: only the hosted signer can consume
|
|
1415
1536
|
// 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 };
|
|
1537
|
+
const want = { walletId: wallet_id, chainId, asset: tokenAddress, payTo: option.payTo, rawAmount, scheme: option.scheme };
|
|
1417
1538
|
const local = anyLocalMode();
|
|
1418
1539
|
const a = local ? null : (await api(`/approvals/${encodeURIComponent(approval_id)}`, 'GET', undefined, skip));
|
|
1419
1540
|
const refusal = approvalRefusal(approval_id, a, want, local);
|
|
@@ -1422,10 +1543,10 @@ server.tool('pay_x402', 'Handle an x402 payment flow. Fetches a URL, and if the
|
|
|
1422
1543
|
}
|
|
1423
1544
|
approvalUsed = String(approval_id);
|
|
1424
1545
|
}
|
|
1425
|
-
else if (wantApproval && !anyLocalMode()) {
|
|
1546
|
+
else if (wantApproval && !anyLocalMode() && willSign) {
|
|
1426
1547
|
const created = (await api('/approvals', 'POST', {
|
|
1427
1548
|
wallet_id, chain_id: chainId, scheme: option.scheme, asset: tokenAddress, pay_to: option.payTo, value: rawAmount,
|
|
1428
|
-
amount_human: amount, token_name: tokenLabel, url,
|
|
1549
|
+
amount_human: amount, token_name: tokenLabel, url: approvalResourceUrl(url),
|
|
1429
1550
|
}, skip));
|
|
1430
1551
|
if (!created?.id) {
|
|
1431
1552
|
return jsonResponse({ ...overCap, error: `Payment of ${amount} ${tokenLabel} exceeds the ${effectiveMax} cap and the approval request failed: ${created?.error || 'unknown error'}.` });
|
|
@@ -1451,7 +1572,7 @@ server.tool('pay_x402', 'Handle an x402 payment flow. Fetches a URL, and if the
|
|
|
1451
1572
|
// usage; the payer broadcasts nothing and pays no gas for either. AgentWallet's
|
|
1452
1573
|
// own paywalls (verified by receipt), native-asset requests and Solana still use
|
|
1453
1574
|
// a broadcast transfer proved by hash.
|
|
1454
|
-
const standardExact = !isUpto &&
|
|
1575
|
+
const standardExact = !isUpto && willSign;
|
|
1455
1576
|
let headerName = 'X-PAYMENT';
|
|
1456
1577
|
let paymentHeader = '';
|
|
1457
1578
|
let txHash = null;
|
|
@@ -1467,16 +1588,35 @@ server.tool('pay_x402', 'Handle an x402 payment flow. Fetches a URL, and if the
|
|
|
1467
1588
|
// The method and body are part of what was bought: POST {item:A} and
|
|
1468
1589
|
// POST {item:B} to one URL are two purchases. Concurrent calls for one key
|
|
1469
1590
|
// wait for the first signature instead of each signing a nonce.
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1591
|
+
// (bodyDigest and cacheKey were computed in Step 3d, before the cap.)
|
|
1592
|
+
// A mutex, not a one-shot wait: when the first signer fails nothing is cached,
|
|
1593
|
+
// and every waiter used to proceed at once and sign its own nonce (round 3).
|
|
1594
|
+
// The resource is a second mutex key: parallel calls whose requirements differ
|
|
1595
|
+
// (a paywall varying the amount per answer) would otherwise sign side by side
|
|
1596
|
+
// before any of them could see the other's live authorization.
|
|
1597
|
+
const resourceLock = 'resource|' + resourceTag;
|
|
1598
|
+
while (!fresh_authorization && (signingInFlight.get(cacheKey) || signingInFlight.get(resourceLock))) {
|
|
1599
|
+
await (signingInFlight.get(cacheKey) || signingInFlight.get(resourceLock));
|
|
1600
|
+
}
|
|
1475
1601
|
const cachedAuth = fresh_authorization ? undefined : signedPayments.get(cacheKey);
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1602
|
+
if (!(cachedAuth && cachedAuth.until > Date.now()) && !fresh_authorization) {
|
|
1603
|
+
// One live authorization per resource. A paywall that answers every call with
|
|
1604
|
+
// a slightly different requirement (amount 999999, 999998, ...) used to harvest
|
|
1605
|
+
// a cap-sized nonce per call (round 3); the second distinct requirement for the
|
|
1606
|
+
// same purchase is refused until the first expires or fresh_authorization is passed.
|
|
1607
|
+
const other = [...signedPayments.entries()].find(([k, v]) => k !== cacheKey && v.resource === resourceTag && v.until > Date.now());
|
|
1608
|
+
if (other) {
|
|
1609
|
+
const [, live] = other;
|
|
1610
|
+
return jsonResponse({
|
|
1611
|
+
status: 402, payment_required: true, payment_made: false,
|
|
1612
|
+
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; to sign a second authorization for this resource pass fresh_authorization=true.`,
|
|
1613
|
+
live_authorization: { amount: live.amount, valid_until: new Date(live.until).toISOString(), payer: live.payer },
|
|
1614
|
+
requested_amount: amount, token: tokenLabel, pay_to: option.payTo, chain_id: chainId,
|
|
1615
|
+
});
|
|
1616
|
+
}
|
|
1617
|
+
}
|
|
1479
1618
|
let releaseSigning = () => { };
|
|
1619
|
+
let mineLock = null;
|
|
1480
1620
|
if (!(cachedAuth && cachedAuth.until > Date.now())) {
|
|
1481
1621
|
const mine = new Promise(r => { releaseSigning = r; });
|
|
1482
1622
|
// Released in the finally below, whatever path this call takes. A timer
|
|
@@ -1484,6 +1624,10 @@ server.tool('pay_x402', 'Handle an x402 payment flow. Fetches a URL, and if the
|
|
|
1484
1624
|
// a second call signed a second nonce (2026-10-04 round 2); every
|
|
1485
1625
|
// network hop inside the gate carries its own timeout instead.
|
|
1486
1626
|
signingInFlight.set(cacheKey, mine);
|
|
1627
|
+
if (!fresh_authorization) {
|
|
1628
|
+
signingInFlight.set(resourceLock, mine);
|
|
1629
|
+
mineLock = mine;
|
|
1630
|
+
}
|
|
1487
1631
|
}
|
|
1488
1632
|
try {
|
|
1489
1633
|
if (cachedAuth && cachedAuth.until > Date.now()) {
|
|
@@ -1555,12 +1699,17 @@ server.tool('pay_x402', 'Handle an x402 payment flow. Fetches a URL, and if the
|
|
|
1555
1699
|
const until = authorization ? Number(authorization.validBefore) * 1000
|
|
1556
1700
|
: uptoAuth ? Number(uptoAuth.deadline) * 1000
|
|
1557
1701
|
: 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 });
|
|
1702
|
+
signedPayments.set(cacheKey, { headerName, paymentHeader, txHash, authorization, uptoAuth, payer, until, resource: resourceTag, amount, signedAt: Date.now() });
|
|
1703
|
+
// A receipt on stderr the moment a signature exists, so a result the transport
|
|
1704
|
+
// drops (a client crash, an oversized frame) is still auditable (round 3).
|
|
1705
|
+
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
1706
|
}
|
|
1560
1707
|
}
|
|
1561
1708
|
finally {
|
|
1562
1709
|
releaseSigning();
|
|
1563
1710
|
signingInFlight.delete(cacheKey);
|
|
1711
|
+
if (signingInFlight.get(resourceLock) === mineLock)
|
|
1712
|
+
signingInFlight.delete(resourceLock);
|
|
1564
1713
|
}
|
|
1565
1714
|
// Step 6: Retry with the payment header and read the settlement receipt.
|
|
1566
1715
|
const retryHeaders = { ...reqHeaders, [headerName]: paymentHeader };
|
|
@@ -1585,7 +1734,7 @@ server.tool('pay_x402', 'Handle an x402 payment flow. Fetches a URL, and if the
|
|
|
1585
1734
|
tx_hash: txHash ?? null, amount, token: tokenLabel, pay_to: option.payTo, chain_id: chainId,
|
|
1586
1735
|
});
|
|
1587
1736
|
}
|
|
1588
|
-
const retryText = await retryRes
|
|
1737
|
+
const retryText = await readBounded(retryRes, PAYWALL_MAX_CHARS, 'The endpoint');
|
|
1589
1738
|
let retryParsed;
|
|
1590
1739
|
try {
|
|
1591
1740
|
retryParsed = JSON.parse(retryText);
|
|
@@ -1712,10 +1861,17 @@ server.tool('get_chains', 'List all supported chains (EVM + Solana) with their c
|
|
|
1712
1861
|
});
|
|
1713
1862
|
// ─── Tool: delete_wallet ─────────────────────────────────────────
|
|
1714
1863
|
server.tool('delete_wallet', 'Delete (soft-delete) a wallet. The wallet will no longer appear in listings ' +
|
|
1715
|
-
'and cannot be used for transactions.
|
|
1864
|
+
'and cannot be used for transactions. The wallet is read first and the delete names its address, ' +
|
|
1865
|
+
'so a wallet that cannot be read is never deleted.', {
|
|
1716
1866
|
wallet_id: z.number().int().describe('Wallet ID to delete'),
|
|
1717
1867
|
}, async ({ wallet_id }) => {
|
|
1718
|
-
|
|
1868
|
+
// The server asks the caller to name the wallet it means to delete, so a
|
|
1869
|
+
// leaked API key alone cannot destroy a key (2026-10-04 audit, server 1.7.1).
|
|
1870
|
+
const w = (await api(`/wallets/${wallet_id}`, 'GET', undefined, { 'X-AGW-SKIP-X402': 'true' }));
|
|
1871
|
+
const address = String(w?.address || w?.wallet_address || '');
|
|
1872
|
+
if (!address)
|
|
1873
|
+
throw new Error(`Wallet ${wallet_id} could not be read (${serverText(w?.error) || 'no address in the response'}); nothing was deleted.`);
|
|
1874
|
+
const data = await api(`/wallets/${wallet_id}`, 'DELETE', { confirm: address });
|
|
1719
1875
|
return jsonResponse(data);
|
|
1720
1876
|
});
|
|
1721
1877
|
// ─── Tool: create_paywall ────────────────────────────────────────
|
|
@@ -1929,8 +2085,12 @@ server.tool('export_wallet_key', 'Explain how to export the private key of a hos
|
|
|
1929
2085
|
// ─── Start ──────────────────────────────────────────────────────
|
|
1930
2086
|
async function main() {
|
|
1931
2087
|
validateGuardEnv();
|
|
1932
|
-
|
|
2088
|
+
// 64 MB frames before the SDK closes the transport (default 10 MB); errors are logged, not swallowed (round 3).
|
|
2089
|
+
const transport = new StdioServerTransport(process.stdin, process.stdout, { maxBufferSize: 64 * 1024 * 1024 });
|
|
2090
|
+
process.stdout.on('error', (e) => { if (e.code === 'EPIPE')
|
|
2091
|
+
process.exit(0); console.error('[agentwallet] stdout error: ' + e.message); });
|
|
1933
2092
|
await server.connect(transport);
|
|
2093
|
+
server.server.onerror = (e) => console.error('[agentwallet] transport error: ' + e.message);
|
|
1934
2094
|
/* stderr, so it never corrupts the stdio JSON-RPC stream. Announcing custody
|
|
1935
2095
|
at startup means an operator sees which mode they are in without asking. */
|
|
1936
2096
|
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-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.8",
|
|
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",
|
|
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",
|
|
20
20
|
"prepublishOnly": "npm run build"
|
|
21
21
|
},
|
|
22
22
|
"keywords": [
|