agentwallet-mcp 1.10.8 → 1.11.0
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 +11 -9
- package/build/index.js +179 -103
- package/build/local-wallet.d.ts +19 -0
- package/build/local-wallet.js +29 -0
- package/build/x402-eip3009.d.ts +168 -0
- package/build/x402-eip3009.js +199 -0
- package/build/x402-payment.d.ts +11 -0
- package/build/x402-payment.js +13 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -219,15 +219,15 @@ All on-chain. All through MCP. No frontend needed.
|
|
|
219
219
|
|
|
220
220
|
## x402 Payments
|
|
221
221
|
|
|
222
|
-
AgentWallet
|
|
222
|
+
AgentWallet speaks the [x402 open payment standard](https://x402.org) the way the spec defines it. When your Ai agent hits an API that answers HTTP 402, `pay_x402` does the whole flow:
|
|
223
223
|
|
|
224
|
-
1. Fetches the URL and
|
|
225
|
-
2.
|
|
226
|
-
3.
|
|
227
|
-
4. Retries the request with
|
|
228
|
-
5. Returns the
|
|
224
|
+
1. Fetches the URL and reads the payment requirements, from the v2 `PAYMENT-REQUIRED` header or the v1 JSON body.
|
|
225
|
+
2. Picks an `exact` option (you can steer it with `prefer_chain`).
|
|
226
|
+
3. Signs an **EIP-3009 `TransferWithAuthorization`** for exactly that amount. Nothing is broadcast and no gas is paid by the payer; the endpoint's facilitator settles it on-chain.
|
|
227
|
+
4. Retries the request with the payment header (`PAYMENT-SIGNATURE` for v2, `X-PAYMENT` for v1).
|
|
228
|
+
5. Returns the response, the settlement receipt (`PAYMENT-RESPONSE` / `X-PAYMENT-RESPONSE`) and, if the endpoint declined, its stated reason in `retry_error`.
|
|
229
229
|
|
|
230
|
-
|
|
230
|
+
Verified against Coinbase's public facilitator (`isValid: true` for v1 and v2 payloads) and live x402 servers on Base. Works in both custody modes: the local key signs in-process; hosted wallets sign through `POST /wallets/{id}/x402/authorize`, a narrow endpoint that only ever signs this one struct and runs the same pause and token-cap checks as a transfer.
|
|
231
231
|
|
|
232
232
|
```
|
|
233
233
|
pay_x402(
|
|
@@ -237,9 +237,11 @@ pay_x402(
|
|
|
237
237
|
)
|
|
238
238
|
```
|
|
239
239
|
|
|
240
|
-
`max_payment` is
|
|
240
|
+
`max_payment` is a hard per-payment cap. If you omit it, `pay_x402` falls back to `AGENTWALLET_MAX_AUTOPAY` (default `1`), so a malicious or compromised 402 endpoint can never authorize an unbounded payment. In local mode `AGENTWALLET_MAX_TX_TOKEN` applies to authorizations too, because an authorization is a transfer somebody else executes.
|
|
241
241
|
|
|
242
|
-
|
|
242
|
+
**Schemes.** `exact` is fully supported on every EVM chain AgentWallet knows. `upto` (a Permit2 maximum the seller settles below) is refused with a clear message rather than approximated with an upfront transfer; Permit2 support is next. Native-asset and Solana requests, and AgentWallet's own paywalls, are paid by an on-chain transfer proved by transaction hash, which is what those servers verify.
|
|
243
|
+
|
|
244
|
+
**Token domains.** The EIP-712 domain comes from the endpoint's `extra.name` / `extra.version`, then a short registry of USDC deployments, then the token contract's own `name()` / `version()`. If none of those answer, the payment is refused rather than signed with a guessed domain.
|
|
243
245
|
|
|
244
246
|
## x402 Acceptance
|
|
245
247
|
|
package/build/index.js
CHANGED
|
@@ -13,8 +13,10 @@ 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 } from './ssrf-guard.js';
|
|
16
|
-
import { deriveX402Payment, isWithinCap, lookupTrustedDecimals, assertDeclaredDecimals, } from './x402-payment.js';
|
|
16
|
+
import { deriveX402Payment, isWithinCap, lookupTrustedDecimals, assertDeclaredDecimals, nativeDecimals, SOLANA_CHAIN_IDS, } from './x402-payment.js';
|
|
17
17
|
import { isLocalMode, getLocalAddress, localWalletRecord, localSend, localNativeBalance, localTokenBalance, localSignMessage, resolveRpcUrl, } from './local-wallet.js';
|
|
18
|
+
import { localSignAuthorization, localEthCall } from './local-wallet.js';
|
|
19
|
+
import { isLegacyAgentWalletAccept, requiredAmount, buildAuthorization, buildPaymentPayload, paymentHeaderFor, parsePaymentRequired, parseSettlement, pickOption, knownTokenDomain, } from './x402-eip3009.js';
|
|
18
20
|
import { isSolanaLocalMode, getSolanaAddress, solanaWalletRecord, localSolBalance, localSplBalance, localSolTransfer, localSplTransfer, resolveSolanaRpc, } from './local-solana.js';
|
|
19
21
|
/** True when either chain family is running non-custodially. */
|
|
20
22
|
function anyLocalMode() {
|
|
@@ -90,6 +92,26 @@ async function routeLocally(path, method, body) {
|
|
|
90
92
|
const hasData = data && data !== '0x';
|
|
91
93
|
return localSend(chainId, to, value, hasData ? data : undefined);
|
|
92
94
|
}
|
|
95
|
+
// POST /wallets/{id}/x402/authorize (x402 "exact": EIP-3009 authorization, signed here, broadcast by nobody)
|
|
96
|
+
if (method === 'POST' && /^\/wallets\/[^/]*\/x402\/authorize$/.test(rawPath)) {
|
|
97
|
+
if (!isLocalMode())
|
|
98
|
+
refuseMissingLocalKey('evm');
|
|
99
|
+
const chainId = localChainId(body, qs);
|
|
100
|
+
return localSignAuthorization(chainId, String(body?.asset || ''), { name: String(body?.name || ''), version: String(body?.version || '') }, {
|
|
101
|
+
from: getLocalAddress(),
|
|
102
|
+
to: String(body?.to || ''),
|
|
103
|
+
value: String(body?.value ?? '0'),
|
|
104
|
+
validAfter: String(body?.valid_after ?? '0'),
|
|
105
|
+
validBefore: String(body?.valid_before ?? '0'),
|
|
106
|
+
nonce: String(body?.nonce || ''),
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
// POST /eth-call (read-only; answered from the local RPC so pure self-custody needs no hosted credentials)
|
|
110
|
+
if (method === 'POST' && rawPath === '/eth-call') {
|
|
111
|
+
if (!isLocalMode())
|
|
112
|
+
return NOT_HANDLED;
|
|
113
|
+
return localEthCall(localChainId(body, qs), String(body?.to || ''), String(body?.data || '0x'));
|
|
114
|
+
}
|
|
93
115
|
// GET /wallets/{id}/balance
|
|
94
116
|
if (method === 'GET' && /^\/wallets\/[^/]*\/balance$/.test(rawPath)) {
|
|
95
117
|
const chainId = localChainId(body, qs);
|
|
@@ -258,7 +280,7 @@ async function handleX402Payment(x402Data, originalPath, originalMethod, origina
|
|
|
258
280
|
const assetForDecimals = accept.asset || accept.extra?.token || '';
|
|
259
281
|
const trustedDecimals = assetForDecimals
|
|
260
282
|
? await resolveTrustedDecimals(chainId, assetForDecimals)
|
|
261
|
-
:
|
|
283
|
+
: nativeDecimals(chainId); // native asset: 18 on EVM (wei), 9 on Solana (lamports)
|
|
262
284
|
const { tokenAddress, rawAmount, decimals } = deriveX402Payment(accept, maxAutopay, trustedDecimals);
|
|
263
285
|
// Send payment using our wallet (with X-AGW-SKIP-X402 to prevent recursion)
|
|
264
286
|
let txHash;
|
|
@@ -310,7 +332,6 @@ function jsonResponse(data) {
|
|
|
310
332
|
return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
|
|
311
333
|
}
|
|
312
334
|
// ─── Solana Helpers ───────────────────────────────────────────────
|
|
313
|
-
const SOLANA_CHAIN_IDS = new Set([900, 901, 902]);
|
|
314
335
|
function isSolanaChain(chainId) {
|
|
315
336
|
return SOLANA_CHAIN_IDS.has(chainId);
|
|
316
337
|
}
|
|
@@ -424,7 +445,7 @@ function resolveChainId(network) {
|
|
|
424
445
|
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');
|
|
425
446
|
const server = new McpServer({
|
|
426
447
|
name: 'agentwallet',
|
|
427
|
-
version: '1.
|
|
448
|
+
version: '1.11.0',
|
|
428
449
|
}, {
|
|
429
450
|
instructions: `AgentWallet gives AI agents their own blockchain wallets. Private keys are encrypted server-side and never exposed — agents sign and broadcast transactions without ever touching raw keys.
|
|
430
451
|
|
|
@@ -936,11 +957,63 @@ server.tool('get_token_info', 'Get the name, symbol, and decimals of any ERC-20
|
|
|
936
957
|
decimals,
|
|
937
958
|
});
|
|
938
959
|
});
|
|
960
|
+
// ─── x402 "exact" helpers ────────────────────────────────────────
|
|
961
|
+
/** The paying address: the local key in self-custody mode, else the hosted wallet record. */
|
|
962
|
+
async function payerAddress(walletId) {
|
|
963
|
+
const w = (await api(`/wallets/${walletId}`, 'GET', undefined, { 'X-AGW-SKIP-X402': 'true' }));
|
|
964
|
+
const a = String(w?.address || w?.wallet_address || '');
|
|
965
|
+
if (!/^0x[0-9a-fA-F]{40}$/.test(a))
|
|
966
|
+
throw new Error(`x402: could not resolve the EVM address of wallet ${walletId} (got "${a}").`);
|
|
967
|
+
return a;
|
|
968
|
+
}
|
|
969
|
+
async function ethCallHex(chainId, to, data) {
|
|
970
|
+
const r = (await api('/eth-call', 'POST', { chain_id: chainId, to, data }, { 'X-AGW-SKIP-X402': 'true' }));
|
|
971
|
+
return String(r?.result || '');
|
|
972
|
+
}
|
|
973
|
+
/**
|
|
974
|
+
* The token's EIP-712 domain. Order: what the 402 declared (extra.name/version,
|
|
975
|
+
* which is what the reference client requires), then a short list of USDC
|
|
976
|
+
* deployments we know, then the contract's own name()/version(). A wrong
|
|
977
|
+
* domain produces a signature that recovers to a stranger, so we refuse
|
|
978
|
+
* rather than guess.
|
|
979
|
+
*/
|
|
980
|
+
async function resolveTokenDomain(chainId, asset, extra) {
|
|
981
|
+
if (extra?.name && extra?.version)
|
|
982
|
+
return { name: String(extra.name), version: String(extra.version) };
|
|
983
|
+
const known = knownTokenDomain(chainId, asset);
|
|
984
|
+
if (known)
|
|
985
|
+
return known;
|
|
986
|
+
let name = '', version = '';
|
|
987
|
+
try {
|
|
988
|
+
name = decodeAbiString(await ethCallHex(chainId, asset, '0x06fdde03')); // name()
|
|
989
|
+
version = decodeAbiString(await ethCallHex(chainId, asset, '0x54fd4d50')); // version()
|
|
990
|
+
}
|
|
991
|
+
catch { /* refused below */ }
|
|
992
|
+
if (!name || !version) {
|
|
993
|
+
throw new Error(`x402: cannot determine the EIP-712 domain for token ${asset} on chain ${chainId}: the endpoint sent no ` +
|
|
994
|
+
`extra.name/extra.version and the contract did not answer name()/version(). Refused rather than signed with a guessed domain.`);
|
|
995
|
+
}
|
|
996
|
+
return { name, version };
|
|
997
|
+
}
|
|
998
|
+
/** Sign the authorization with whichever custody mode is active (local key, or the hosted signer). */
|
|
999
|
+
async function signAuthorization(walletId, chainId, asset, domain, auth) {
|
|
1000
|
+
const r = (await api(`/wallets/${walletId}/x402/authorize`, 'POST', {
|
|
1001
|
+
chain_id: chainId, asset, name: domain.name, version: domain.version,
|
|
1002
|
+
to: auth.to, value: auth.value, valid_after: auth.validAfter, valid_before: auth.validBefore, nonce: auth.nonce,
|
|
1003
|
+
}, { 'X-AGW-SKIP-X402': 'true' }));
|
|
1004
|
+
const sig = String(r?.signature || '');
|
|
1005
|
+
if (!/^0x[0-9a-fA-F]{130}$/.test(sig)) {
|
|
1006
|
+
throw new Error(`x402: the wallet did not return a valid authorization signature${r?.error ? ` (${r.error})` : ''}.`);
|
|
1007
|
+
}
|
|
1008
|
+
return sig;
|
|
1009
|
+
}
|
|
939
1010
|
// ─── Tool: pay_x402 ─────────────────────────────────────────────
|
|
940
1011
|
server.tool('pay_x402', 'Handle an x402 payment flow. Fetches a URL, and if the server returns HTTP 402 Payment Required, ' +
|
|
941
|
-
'parses the payment requirements
|
|
942
|
-
'
|
|
943
|
-
'
|
|
1012
|
+
'parses the payment requirements (v1 body or v2 PAYMENT-REQUIRED header), signs an EIP-3009 ' +
|
|
1013
|
+
'TransferWithAuthorization for the "exact" scheme (no gas, nothing broadcast by the payer), and retries ' +
|
|
1014
|
+
'the request with the payment header. AgentWallet paywalls are paid by on-chain transfer instead. ' +
|
|
1015
|
+
'The "upto" scheme is refused, never approximated. Returns the final response and the settlement ' +
|
|
1016
|
+
'receipt. Supports the x402 open payment standard (https://x402.org). Set max_payment to cap spend.', {
|
|
944
1017
|
url: z.string().url().describe('The URL to access (will handle 402 payment if required)'),
|
|
945
1018
|
wallet_id: z.number().int().describe('Wallet ID to pay from'),
|
|
946
1019
|
method: z.string().default('GET').describe('HTTP method (GET, POST, PUT, DELETE)'),
|
|
@@ -1000,125 +1073,114 @@ server.tool('pay_x402', 'Handle an x402 payment flow. Fetches a URL, and if the
|
|
|
1000
1073
|
response: parsed,
|
|
1001
1074
|
});
|
|
1002
1075
|
}
|
|
1003
|
-
// Step 2:
|
|
1004
|
-
|
|
1076
|
+
// Step 2: Requirements come from the PAYMENT-REQUIRED header (v2) or the JSON body (v1).
|
|
1077
|
+
const initialText = await initialRes.text();
|
|
1078
|
+
let initialBody = null;
|
|
1005
1079
|
try {
|
|
1006
|
-
|
|
1080
|
+
initialBody = JSON.parse(initialText);
|
|
1007
1081
|
}
|
|
1008
1082
|
catch {
|
|
1009
|
-
|
|
1010
|
-
}
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1083
|
+
initialBody = null;
|
|
1084
|
+
}
|
|
1085
|
+
const paymentInfo = parsePaymentRequired(n => initialRes.headers.get(n), initialBody);
|
|
1086
|
+
if (!paymentInfo || !Array.isArray(paymentInfo.accepts) || paymentInfo.accepts.length === 0) {
|
|
1087
|
+
throw new Error('402 response carried no x402 payment requirements (no PAYMENT-REQUIRED header and no "accepts" array in the body). ' +
|
|
1088
|
+
'This server may not support x402.');
|
|
1089
|
+
}
|
|
1090
|
+
const x402Version = Number(paymentInfo.x402Version) || 1;
|
|
1091
|
+
const accepts = paymentInfo.accepts;
|
|
1092
|
+
// Step 3: Pick an option this client can sign. "exact" only; "upto" is refused, never approximated (issue #6).
|
|
1093
|
+
const picked = pickOption(accepts, resolveChainId, prefer_chain);
|
|
1094
|
+
if (!picked.option || !picked.chainId) {
|
|
1095
|
+
return jsonResponse({
|
|
1096
|
+
status: 402,
|
|
1097
|
+
payment_required: true,
|
|
1098
|
+
payment_made: false,
|
|
1099
|
+
error: picked.reason,
|
|
1100
|
+
offered: accepts.map(a => ({ scheme: a.scheme, network: a.network, amount: requiredAmount(a), asset: a.asset ?? a.extra?.token ?? null })),
|
|
1101
|
+
});
|
|
1021
1102
|
}
|
|
1022
|
-
|
|
1023
|
-
const chainId =
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1103
|
+
const option = picked.option;
|
|
1104
|
+
const chainId = picked.chainId;
|
|
1105
|
+
const rawAmount = requiredAmount(option);
|
|
1106
|
+
if (!/^\d+$/.test(rawAmount)) {
|
|
1107
|
+
throw new Error(`x402: invalid amount "${rawAmount}" in the payment requirements (expected integer base units).`);
|
|
1027
1108
|
}
|
|
1028
1109
|
// Decimals come from the registry or the token contract, NEVER from the 402
|
|
1029
|
-
// body. The endpoint controls
|
|
1030
|
-
// hostile server inflate the cap
|
|
1031
|
-
|
|
1032
|
-
const
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
//
|
|
1041
|
-
//
|
|
1042
|
-
// mirroring the internal auto-pay path (deriveX402Payment). Without this, an
|
|
1043
|
-
// untrusted or compromised x402 endpoint could return an arbitrarily large
|
|
1044
|
-
// maxAmountRequired and drain the wallet. The cap is never silently skipped.
|
|
1110
|
+
// body. The endpoint controls what it declares, so trusting it would let a
|
|
1111
|
+
// hostile server inflate the cap and misreport the amount shown to the agent.
|
|
1112
|
+
const tokenAddress = option.asset || option.extra?.token || '';
|
|
1113
|
+
const trustedDecimals = tokenAddress
|
|
1114
|
+
? await resolveTrustedDecimals(chainId, tokenAddress)
|
|
1115
|
+
: nativeDecimals(chainId); // 18 on EVM (wei), 9 on Solana (lamports)
|
|
1116
|
+
if (typeof option.requiredDecimals === 'number')
|
|
1117
|
+
assertDeclaredDecimals(option.requiredDecimals, trustedDecimals);
|
|
1118
|
+
const amount = formatUnits(rawAmount, trustedDecimals);
|
|
1119
|
+
const tokenLabel = option.extra?.name || (tokenAddress ? 'tokens' : 'native');
|
|
1120
|
+
// Step 4: Hard per-payment cap, ALWAYS applied. If the caller omits max_payment
|
|
1121
|
+
// we fall back to AGENTWALLET_MAX_AUTOPAY (default "1"), so an untrusted 402
|
|
1122
|
+
// endpoint can never authorize an unbounded payment. Never silently skipped.
|
|
1045
1123
|
const capSource = max_payment ? 'max_payment' : 'AGENTWALLET_MAX_AUTOPAY';
|
|
1046
1124
|
const effectiveMax = max_payment || process.env.AGENTWALLET_MAX_AUTOPAY || '1';
|
|
1047
|
-
if (!isWithinCap(
|
|
1125
|
+
if (!isWithinCap(rawAmount, trustedDecimals, effectiveMax)) {
|
|
1048
1126
|
return jsonResponse({
|
|
1049
1127
|
status: 402,
|
|
1050
1128
|
payment_required: true,
|
|
1051
1129
|
payment_made: false,
|
|
1052
|
-
error: `Payment of ${amount} ${
|
|
1053
|
-
`${effectiveMax} cap (from ${capSource}).` +
|
|
1130
|
+
error: `Payment of ${amount} ${tokenLabel} exceeds the ${effectiveMax} cap (from ${capSource}).` +
|
|
1054
1131
|
(max_payment ? '' : ' No max_payment was provided, so the default cap was applied. ' +
|
|
1055
1132
|
'Pass max_payment (or raise AGENTWALLET_MAX_AUTOPAY) to allow a larger payment.'),
|
|
1056
1133
|
required_amount: amount,
|
|
1057
1134
|
max_allowed: effectiveMax,
|
|
1058
1135
|
cap_source: capSource,
|
|
1059
|
-
token:
|
|
1136
|
+
token: tokenLabel,
|
|
1060
1137
|
network: option.network,
|
|
1061
1138
|
chain_id: chainId,
|
|
1062
1139
|
pay_to: option.payTo,
|
|
1063
1140
|
description: option.description,
|
|
1064
1141
|
});
|
|
1065
1142
|
}
|
|
1066
|
-
// Standard x402
|
|
1067
|
-
//
|
|
1068
|
-
|
|
1069
|
-
//
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1143
|
+
// Step 5: Pay. Standard x402 "exact" on EVM is an EIP-3009 authorization the
|
|
1144
|
+
// facilitator settles; the payer broadcasts nothing and pays no gas. AgentWallet's
|
|
1145
|
+
// own paywalls (verified by receipt), native-asset requests and Solana still use
|
|
1146
|
+
// a broadcast transfer proved by hash.
|
|
1147
|
+
const standardExact = !isSolanaChain(chainId) && Boolean(tokenAddress) && !isLegacyAgentWalletAccept(option);
|
|
1148
|
+
let headerName = 'X-PAYMENT';
|
|
1149
|
+
let paymentHeader = '';
|
|
1150
|
+
let txHash = null;
|
|
1151
|
+
let authorization = null;
|
|
1152
|
+
let payer = null;
|
|
1153
|
+
if (standardExact) {
|
|
1154
|
+
payer = await payerAddress(wallet_id);
|
|
1155
|
+
const domain = await resolveTokenDomain(chainId, tokenAddress, option.extra);
|
|
1156
|
+
authorization = buildAuthorization(payer, option);
|
|
1157
|
+
const signature = await signAuthorization(wallet_id, chainId, tokenAddress, domain, authorization);
|
|
1158
|
+
const payload = buildPaymentPayload(x402Version, option, authorization, signature, paymentInfo.resource, paymentInfo.extensions);
|
|
1159
|
+
const h = paymentHeaderFor(x402Version, payload);
|
|
1160
|
+
headerName = h.name;
|
|
1161
|
+
paymentHeader = h.value;
|
|
1162
|
+
}
|
|
1163
|
+
else {
|
|
1164
|
+
let txResult;
|
|
1165
|
+
if (isSolanaChain(chainId)) {
|
|
1166
|
+
txResult = (await api(`/wallets/${wallet_id}/send`, 'POST', tokenAddress
|
|
1167
|
+
? { to: option.payTo, value: rawAmount, token_mint: tokenAddress, token_decimals: trustedDecimals, chain_id: chainId }
|
|
1168
|
+
: { to: option.payTo, value: rawAmount, chain_id: chainId }));
|
|
1169
|
+
}
|
|
1170
|
+
else if (tokenAddress) {
|
|
1171
|
+
const calldata = '0xa9059cbb' + padAddress(option.payTo) + encodeUint256(rawAmount);
|
|
1172
|
+
txResult = (await api(`/wallets/${wallet_id}/send`, 'POST', { to: tokenAddress, value: '0', data: calldata, chain_id: chainId }));
|
|
1082
1173
|
}
|
|
1083
1174
|
else {
|
|
1084
|
-
|
|
1085
|
-
txResult = (await api(`/wallets/${wallet_id}/send`, 'POST', {
|
|
1086
|
-
to: option.payTo,
|
|
1087
|
-
value: option.maxAmountRequired,
|
|
1088
|
-
chain_id: chainId,
|
|
1089
|
-
}));
|
|
1175
|
+
txResult = (await api(`/wallets/${wallet_id}/send`, 'POST', { to: option.payTo, value: rawAmount, data: '', chain_id: chainId }));
|
|
1090
1176
|
}
|
|
1177
|
+
txHash = String(txResult.tx_hash || txResult.signature || '');
|
|
1178
|
+
if (!txHash)
|
|
1179
|
+
throw new Error('x402: the payment transaction returned no hash.');
|
|
1180
|
+
paymentHeader = Buffer.from(JSON.stringify({ x402Version, scheme: option.scheme, network: option.network, payload: { txHash } })).toString('base64');
|
|
1091
1181
|
}
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
const calldata = '0xa9059cbb' + padAddress(option.payTo) + encodeUint256(option.maxAmountRequired);
|
|
1095
|
-
txResult = (await api(`/wallets/${wallet_id}/send`, 'POST', {
|
|
1096
|
-
to: tokenAddress,
|
|
1097
|
-
value: '0',
|
|
1098
|
-
data: calldata,
|
|
1099
|
-
chain_id: chainId,
|
|
1100
|
-
}));
|
|
1101
|
-
}
|
|
1102
|
-
else {
|
|
1103
|
-
// EVM native token payment (ETH, etc.)
|
|
1104
|
-
txResult = (await api(`/wallets/${wallet_id}/send`, 'POST', {
|
|
1105
|
-
to: option.payTo,
|
|
1106
|
-
value: option.maxAmountRequired,
|
|
1107
|
-
data: '',
|
|
1108
|
-
chain_id: chainId,
|
|
1109
|
-
}));
|
|
1110
|
-
}
|
|
1111
|
-
// Solana returns 'signature', EVM returns 'tx_hash'
|
|
1112
|
-
const txHash = (txResult.tx_hash || txResult.signature);
|
|
1113
|
-
// Step 6: Build x402 payment proof and retry
|
|
1114
|
-
const paymentProof = {
|
|
1115
|
-
x402Version: paymentInfo.x402Version || 1,
|
|
1116
|
-
scheme: option.scheme,
|
|
1117
|
-
network: option.network,
|
|
1118
|
-
payload: { txHash },
|
|
1119
|
-
};
|
|
1120
|
-
const paymentHeader = Buffer.from(JSON.stringify(paymentProof)).toString('base64');
|
|
1121
|
-
const retryHeaders = { ...reqHeaders, 'X-PAYMENT': paymentHeader };
|
|
1182
|
+
// Step 6: Retry with the payment header and read the settlement receipt.
|
|
1183
|
+
const retryHeaders = { ...reqHeaders, [headerName]: paymentHeader };
|
|
1122
1184
|
const retryOptions = {
|
|
1123
1185
|
method,
|
|
1124
1186
|
headers: retryHeaders,
|
|
@@ -1136,17 +1198,31 @@ server.tool('pay_x402', 'Handle an x402 payment flow. Fetches a URL, and if the
|
|
|
1136
1198
|
catch {
|
|
1137
1199
|
retryParsed = retryText;
|
|
1138
1200
|
}
|
|
1201
|
+
const settlement = parseSettlement(n => retryRes.headers.get(n));
|
|
1202
|
+
// A second 402 means the facilitator declined (unfunded payer, expired window, bad domain...). v2 servers
|
|
1203
|
+
// put the reason in a fresh PAYMENT-REQUIRED header, v1 servers in the body; surface it instead of {}.
|
|
1204
|
+
let retryError = null;
|
|
1205
|
+
if (retryRes.status === 402) {
|
|
1206
|
+
const again = parsePaymentRequired(n => retryRes.headers.get(n), retryParsed);
|
|
1207
|
+
retryError = String(again?.error || retryParsed?.error || 'Payment was not accepted (no reason given).');
|
|
1208
|
+
}
|
|
1139
1209
|
return jsonResponse({
|
|
1140
1210
|
status: retryRes.status,
|
|
1141
1211
|
payment_required: true,
|
|
1142
|
-
payment_made:
|
|
1212
|
+
payment_made: retryRes.status !== 402,
|
|
1213
|
+
retry_error: retryError,
|
|
1214
|
+
payment_method: standardExact ? 'eip3009-authorization' : 'onchain-transfer',
|
|
1215
|
+
x402_version: x402Version,
|
|
1143
1216
|
amount,
|
|
1144
|
-
token:
|
|
1145
|
-
token_address:
|
|
1217
|
+
token: tokenLabel,
|
|
1218
|
+
token_address: tokenAddress || null,
|
|
1146
1219
|
network: option.network,
|
|
1147
1220
|
chain_id: chainId,
|
|
1148
1221
|
pay_to: option.payTo,
|
|
1149
|
-
|
|
1222
|
+
payer,
|
|
1223
|
+
tx_hash: txHash ?? settlement?.transaction ?? null,
|
|
1224
|
+
settlement,
|
|
1225
|
+
authorization: authorization ? { nonce: authorization.nonce, valid_before: authorization.validBefore } : null,
|
|
1150
1226
|
description: option.description,
|
|
1151
1227
|
response: retryParsed,
|
|
1152
1228
|
});
|
package/build/local-wallet.d.ts
CHANGED
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
* on the custodial path and say so explicitly rather than silently downgrading.
|
|
16
16
|
*/
|
|
17
17
|
import { type Address, type Hex } from 'viem';
|
|
18
|
+
import { type Eip3009Authorization } from './x402-eip3009.js';
|
|
18
19
|
/** True when the server is running non-custodially. */
|
|
19
20
|
export declare function isLocalMode(): boolean;
|
|
20
21
|
export declare function getLocalAccount(): {
|
|
@@ -88,3 +89,21 @@ export declare function localWalletRecord(): {
|
|
|
88
89
|
custody: string;
|
|
89
90
|
note: string;
|
|
90
91
|
};
|
|
92
|
+
/**
|
|
93
|
+
* Sign a TransferWithAuthorization for an x402 payment. Nothing is broadcast;
|
|
94
|
+
* the resource server's facilitator settles it on-chain. The amount is put
|
|
95
|
+
* through the same AGENTWALLET_MAX_TX_TOKEN guard as a transfer, because that
|
|
96
|
+
* is exactly what the authorization lets someone else execute.
|
|
97
|
+
*/
|
|
98
|
+
export declare function localSignAuthorization(chainId: number, asset: Address, domain: {
|
|
99
|
+
name: string;
|
|
100
|
+
version: string;
|
|
101
|
+
}, auth: Eip3009Authorization): Promise<{
|
|
102
|
+
signature: `0x${string}`;
|
|
103
|
+
from: Address;
|
|
104
|
+
mode: 'local';
|
|
105
|
+
}>;
|
|
106
|
+
/** Read-only eth_call against the local RPC, same shape as the hosted /eth-call route. */
|
|
107
|
+
export declare function localEthCall(chainId: number, to: Address, data: Hex): Promise<{
|
|
108
|
+
result: string;
|
|
109
|
+
}>;
|
package/build/local-wallet.js
CHANGED
|
@@ -18,6 +18,7 @@ import { readFileSync } from 'node:fs';
|
|
|
18
18
|
import { createPublicClient, createWalletClient, http, encodeFunctionData, parseAbi, formatUnits, } from 'viem';
|
|
19
19
|
import { privateKeyToAccount } from 'viem/accounts';
|
|
20
20
|
import { lookupTrustedDecimals } from './x402-payment.js';
|
|
21
|
+
import { typedDataFor } from './x402-eip3009.js';
|
|
21
22
|
/* ── Key loading ─────────────────────────────────────────────────── */
|
|
22
23
|
/**
|
|
23
24
|
* Read the key once at startup. Kept module-private: nothing exports the key
|
|
@@ -306,3 +307,31 @@ export function localWalletRecord() {
|
|
|
306
307
|
note: 'Signed in-process. The private key is never sent to AgentWallet servers.',
|
|
307
308
|
};
|
|
308
309
|
}
|
|
310
|
+
/* ── x402 "exact": EIP-3009 authorization signed in-process ──────── */
|
|
311
|
+
/**
|
|
312
|
+
* Sign a TransferWithAuthorization for an x402 payment. Nothing is broadcast;
|
|
313
|
+
* the resource server's facilitator settles it on-chain. The amount is put
|
|
314
|
+
* through the same AGENTWALLET_MAX_TX_TOKEN guard as a transfer, because that
|
|
315
|
+
* is exactly what the authorization lets someone else execute.
|
|
316
|
+
*/
|
|
317
|
+
export async function localSignAuthorization(chainId, asset, domain, auth) {
|
|
318
|
+
if (!/^0x[a-fA-F0-9]{40}$/.test(asset))
|
|
319
|
+
throw new Error(`Local mode needs an EVM token address for x402, got "${asset}".`);
|
|
320
|
+
if (!/^0x[a-fA-F0-9]{40}$/.test(auth.to))
|
|
321
|
+
throw new Error(`Local mode needs an EVM payTo address for x402, got "${auth.to}".`);
|
|
322
|
+
if (!/^\d+$/.test(auth.value))
|
|
323
|
+
throw new Error(`x402 authorization value must be integer base units, got "${auth.value}".`);
|
|
324
|
+
const calldata = ('0xa9059cbb'
|
|
325
|
+
+ auth.to.slice(2).toLowerCase().padStart(64, '0')
|
|
326
|
+
+ BigInt(auth.value).toString(16).padStart(64, '0'));
|
|
327
|
+
await assertWithinTokenCap(chainId, asset, calldata);
|
|
328
|
+
const from = getLocalAddress();
|
|
329
|
+
const signature = await getLocalAccount().signTypedData(typedDataFor(chainId, asset, domain.name, domain.version, { ...auth, from }));
|
|
330
|
+
return { signature, from, mode: 'local' };
|
|
331
|
+
}
|
|
332
|
+
/** Read-only eth_call against the local RPC, same shape as the hosted /eth-call route. */
|
|
333
|
+
export async function localEthCall(chainId, to, data) {
|
|
334
|
+
const client = createPublicClient({ transport: http(resolveRpcUrl(chainId)) });
|
|
335
|
+
const r = await client.call({ to, data });
|
|
336
|
+
return { result: r.data ?? '0x' };
|
|
337
|
+
}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
export declare const TRANSFER_WITH_AUTHORIZATION_TYPES: {
|
|
2
|
+
readonly TransferWithAuthorization: readonly [{
|
|
3
|
+
readonly name: "from";
|
|
4
|
+
readonly type: "address";
|
|
5
|
+
}, {
|
|
6
|
+
readonly name: "to";
|
|
7
|
+
readonly type: "address";
|
|
8
|
+
}, {
|
|
9
|
+
readonly name: "value";
|
|
10
|
+
readonly type: "uint256";
|
|
11
|
+
}, {
|
|
12
|
+
readonly name: "validAfter";
|
|
13
|
+
readonly type: "uint256";
|
|
14
|
+
}, {
|
|
15
|
+
readonly name: "validBefore";
|
|
16
|
+
readonly type: "uint256";
|
|
17
|
+
}, {
|
|
18
|
+
readonly name: "nonce";
|
|
19
|
+
readonly type: "bytes32";
|
|
20
|
+
}];
|
|
21
|
+
};
|
|
22
|
+
/** One entry of a 402 "accepts" array, v1 or v2 (and AgentWallet's own legacy shape). */
|
|
23
|
+
export interface X402Requirement {
|
|
24
|
+
scheme: string;
|
|
25
|
+
network: string;
|
|
26
|
+
payTo: string;
|
|
27
|
+
asset?: string;
|
|
28
|
+
amount?: string;
|
|
29
|
+
maxAmountRequired?: string;
|
|
30
|
+
maxTimeoutSeconds?: number;
|
|
31
|
+
resource?: string;
|
|
32
|
+
description?: string;
|
|
33
|
+
mimeType?: string;
|
|
34
|
+
outputSchema?: Record<string, unknown>;
|
|
35
|
+
requiredDecimals?: number;
|
|
36
|
+
extra?: {
|
|
37
|
+
name?: string;
|
|
38
|
+
version?: string;
|
|
39
|
+
token?: string;
|
|
40
|
+
[k: string]: unknown;
|
|
41
|
+
};
|
|
42
|
+
[k: string]: unknown;
|
|
43
|
+
}
|
|
44
|
+
export interface X402PaymentRequired {
|
|
45
|
+
x402Version: number;
|
|
46
|
+
error?: string;
|
|
47
|
+
resource?: {
|
|
48
|
+
url: string;
|
|
49
|
+
description?: string;
|
|
50
|
+
mimeType?: string;
|
|
51
|
+
};
|
|
52
|
+
accepts: X402Requirement[];
|
|
53
|
+
extensions?: Record<string, unknown>;
|
|
54
|
+
}
|
|
55
|
+
export interface Eip3009Authorization {
|
|
56
|
+
from: `0x${string}`;
|
|
57
|
+
to: `0x${string}`;
|
|
58
|
+
value: string;
|
|
59
|
+
validAfter: string;
|
|
60
|
+
validBefore: string;
|
|
61
|
+
nonce: `0x${string}`;
|
|
62
|
+
}
|
|
63
|
+
export interface X402Settlement {
|
|
64
|
+
success?: boolean;
|
|
65
|
+
transaction?: string;
|
|
66
|
+
network?: string;
|
|
67
|
+
payer?: string;
|
|
68
|
+
amount?: string;
|
|
69
|
+
errorReason?: string;
|
|
70
|
+
errorMessage?: string;
|
|
71
|
+
[k: string]: unknown;
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* AgentWallet's own paywalls (hifriendbot.com /x402/access/{id}) predate the
|
|
75
|
+
* spec's field names: no `asset`, token under `extra.token`, `requiredDecimals`
|
|
76
|
+
* present, and they verify a broadcast transfer by receipt. Anything with an
|
|
77
|
+
* `asset` is treated as standard x402.
|
|
78
|
+
*/
|
|
79
|
+
export declare function isLegacyAgentWalletAccept(a: X402Requirement): boolean;
|
|
80
|
+
/** Required amount in base units, whichever spelling the server used. */
|
|
81
|
+
export declare function requiredAmount(a: X402Requirement): string;
|
|
82
|
+
export declare function createNonce(): `0x${string}`;
|
|
83
|
+
/** Authorization window mirrors the reference client: valid from 10 minutes ago until maxTimeoutSeconds from now. */
|
|
84
|
+
export declare function buildAuthorization(from: string, req: X402Requirement, nowSeconds?: number, nonce?: `0x${string}`): Eip3009Authorization;
|
|
85
|
+
/** EIP-712 typed data for the authorization. `name`/`version` are the token's own domain values. */
|
|
86
|
+
export declare function typedDataFor(chainId: number, asset: string, name: string, version: string, auth: Eip3009Authorization): {
|
|
87
|
+
domain: {
|
|
88
|
+
name: string;
|
|
89
|
+
version: string;
|
|
90
|
+
chainId: number;
|
|
91
|
+
verifyingContract: `0x${string}`;
|
|
92
|
+
};
|
|
93
|
+
types: {
|
|
94
|
+
readonly TransferWithAuthorization: readonly [{
|
|
95
|
+
readonly name: "from";
|
|
96
|
+
readonly type: "address";
|
|
97
|
+
}, {
|
|
98
|
+
readonly name: "to";
|
|
99
|
+
readonly type: "address";
|
|
100
|
+
}, {
|
|
101
|
+
readonly name: "value";
|
|
102
|
+
readonly type: "uint256";
|
|
103
|
+
}, {
|
|
104
|
+
readonly name: "validAfter";
|
|
105
|
+
readonly type: "uint256";
|
|
106
|
+
}, {
|
|
107
|
+
readonly name: "validBefore";
|
|
108
|
+
readonly type: "uint256";
|
|
109
|
+
}, {
|
|
110
|
+
readonly name: "nonce";
|
|
111
|
+
readonly type: "bytes32";
|
|
112
|
+
}];
|
|
113
|
+
};
|
|
114
|
+
primaryType: "TransferWithAuthorization";
|
|
115
|
+
message: {
|
|
116
|
+
from: `0x${string}`;
|
|
117
|
+
to: `0x${string}`;
|
|
118
|
+
value: bigint;
|
|
119
|
+
validAfter: bigint;
|
|
120
|
+
validBefore: bigint;
|
|
121
|
+
nonce: `0x${string}`;
|
|
122
|
+
};
|
|
123
|
+
};
|
|
124
|
+
/**
|
|
125
|
+
* Token EIP-712 domains we know without asking the chain. USDC's domain name
|
|
126
|
+
* differs by deployment ("USD Coin" on most mainnets, "USDC" on Base Sepolia),
|
|
127
|
+
* which is exactly the kind of thing that silently produces an unrecoverable
|
|
128
|
+
* signature, so only well-known deployments are listed; anything else is read
|
|
129
|
+
* from the contract's name()/version().
|
|
130
|
+
*/
|
|
131
|
+
export declare const KNOWN_TOKEN_DOMAINS: Record<string, {
|
|
132
|
+
name: string;
|
|
133
|
+
version: string;
|
|
134
|
+
}>;
|
|
135
|
+
export declare function knownTokenDomain(chainId: number, asset: string): {
|
|
136
|
+
name: string;
|
|
137
|
+
version: string;
|
|
138
|
+
} | null;
|
|
139
|
+
/** Decode a single ABI-encoded string return value (name()/version()). */
|
|
140
|
+
export declare function decodeAbiString(hex: string): string;
|
|
141
|
+
/** Build the payment payload the resource server expects for this x402 version. */
|
|
142
|
+
export declare function buildPaymentPayload(x402Version: number, req: X402Requirement, auth: Eip3009Authorization, signature: string, resource?: X402PaymentRequired['resource'], extensions?: Record<string, unknown>): Record<string, unknown>;
|
|
143
|
+
/** 16-128 chars of [A-Za-z0-9_-], the shape the payment-identifier extension accepts. */
|
|
144
|
+
export declare function generatePaymentId(): string;
|
|
145
|
+
/**
|
|
146
|
+
* x402 v2 "payment-identifier" extension: the server may ask for a client-chosen
|
|
147
|
+
* id so it can de-duplicate retries. The 402's extensions are echoed back with
|
|
148
|
+
* info.id filled in. Other extensions pass through untouched.
|
|
149
|
+
*/
|
|
150
|
+
export declare function withPaymentIdentifier(extensions?: Record<string, unknown>, id?: string): Record<string, unknown> | null;
|
|
151
|
+
export declare function paymentHeaderFor(x402Version: number, payload: Record<string, unknown>): {
|
|
152
|
+
name: string;
|
|
153
|
+
value: string;
|
|
154
|
+
};
|
|
155
|
+
/** v2 servers put the requirements in PAYMENT-REQUIRED; v1 servers put them in the JSON body. */
|
|
156
|
+
export declare function parsePaymentRequired(getHeader: (name: string) => string | null | undefined, body: unknown): X402PaymentRequired | null;
|
|
157
|
+
export declare function parseSettlement(getHeader: (name: string) => string | null | undefined): X402Settlement | null;
|
|
158
|
+
/**
|
|
159
|
+
* Choose which accepts[] entry to pay. `exact` is the only scheme this client
|
|
160
|
+
* can sign today; `upto` needs Permit2 (a max authorization the seller settles
|
|
161
|
+
* below) and is refused rather than approximated with a full upfront transfer,
|
|
162
|
+
* which is what issue #6 caught. Returns the reason when nothing is payable.
|
|
163
|
+
*/
|
|
164
|
+
export declare function pickOption(accepts: X402Requirement[], resolveChainId: (network: string) => number | null, preferChain?: number): {
|
|
165
|
+
option: X402Requirement | null;
|
|
166
|
+
chainId: number | null;
|
|
167
|
+
reason?: string;
|
|
168
|
+
};
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* x402 "exact" scheme on EVM, done the way the spec says: an EIP-3009
|
|
3
|
+
* TransferWithAuthorization signed off-chain and handed to the resource server
|
|
4
|
+
* in the payment header. The facilitator settles it; the payer broadcasts
|
|
5
|
+
* nothing and pays no gas.
|
|
6
|
+
*
|
|
7
|
+
* Before v1.11.0 pay_x402 broadcast a plain ERC-20 transfer() and put the tx
|
|
8
|
+
* hash in the header. That only ever worked against AgentWallet's own paywalls
|
|
9
|
+
* (which verify by receipt); every standard x402 server rejected it (issue #7).
|
|
10
|
+
* The receipt-style flow is kept for those paywalls and detected by their
|
|
11
|
+
* shape, see isLegacyAgentWalletAccept().
|
|
12
|
+
*
|
|
13
|
+
* Wire formats, both supported (issue #7 endpoint speaks v1, newer servers v2):
|
|
14
|
+
* v1: 402 body {x402Version:1, accepts:[{maxAmountRequired, asset, payTo, network:"base", ...}]}
|
|
15
|
+
* request header X-PAYMENT: base64({x402Version:1, scheme, network, payload:{signature, authorization}})
|
|
16
|
+
* response header X-PAYMENT-RESPONSE
|
|
17
|
+
* v2: 402 header PAYMENT-REQUIRED: base64({x402Version:2, resource, accepts:[{amount, asset, payTo, network:"eip155:8453", ...}]})
|
|
18
|
+
* request header PAYMENT-SIGNATURE: base64({x402Version:2, resource, accepted, payload:{signature, authorization}})
|
|
19
|
+
* response header PAYMENT-RESPONSE
|
|
20
|
+
*/
|
|
21
|
+
import { randomBytes } from 'node:crypto';
|
|
22
|
+
export const TRANSFER_WITH_AUTHORIZATION_TYPES = {
|
|
23
|
+
TransferWithAuthorization: [
|
|
24
|
+
{ name: 'from', type: 'address' },
|
|
25
|
+
{ name: 'to', type: 'address' },
|
|
26
|
+
{ name: 'value', type: 'uint256' },
|
|
27
|
+
{ name: 'validAfter', type: 'uint256' },
|
|
28
|
+
{ name: 'validBefore', type: 'uint256' },
|
|
29
|
+
{ name: 'nonce', type: 'bytes32' },
|
|
30
|
+
],
|
|
31
|
+
};
|
|
32
|
+
/**
|
|
33
|
+
* AgentWallet's own paywalls (hifriendbot.com /x402/access/{id}) predate the
|
|
34
|
+
* spec's field names: no `asset`, token under `extra.token`, `requiredDecimals`
|
|
35
|
+
* present, and they verify a broadcast transfer by receipt. Anything with an
|
|
36
|
+
* `asset` is treated as standard x402.
|
|
37
|
+
*/
|
|
38
|
+
export function isLegacyAgentWalletAccept(a) {
|
|
39
|
+
if (a.asset)
|
|
40
|
+
return false;
|
|
41
|
+
return Boolean(a.extra?.token) || typeof a.requiredDecimals === 'number';
|
|
42
|
+
}
|
|
43
|
+
/** Required amount in base units, whichever spelling the server used. */
|
|
44
|
+
export function requiredAmount(a) {
|
|
45
|
+
const v = a.amount ?? a.maxAmountRequired ?? '';
|
|
46
|
+
return String(v);
|
|
47
|
+
}
|
|
48
|
+
export function createNonce() {
|
|
49
|
+
return ('0x' + randomBytes(32).toString('hex'));
|
|
50
|
+
}
|
|
51
|
+
const DEFAULT_TIMEOUT_SECONDS = 300;
|
|
52
|
+
/** Authorization window mirrors the reference client: valid from 10 minutes ago until maxTimeoutSeconds from now. */
|
|
53
|
+
export function buildAuthorization(from, req, nowSeconds = Math.floor(Date.now() / 1000), nonce = createNonce()) {
|
|
54
|
+
const value = requiredAmount(req);
|
|
55
|
+
if (!/^\d+$/.test(value))
|
|
56
|
+
throw new Error(`x402: invalid required amount "${value}" (expected integer base units).`);
|
|
57
|
+
if (!/^0x[0-9a-fA-F]{40}$/.test(req.payTo))
|
|
58
|
+
throw new Error(`x402: payTo is not an EVM address: "${req.payTo}".`);
|
|
59
|
+
const timeout = Number.isFinite(req.maxTimeoutSeconds) && req.maxTimeoutSeconds > 0
|
|
60
|
+
? Math.floor(req.maxTimeoutSeconds) : DEFAULT_TIMEOUT_SECONDS;
|
|
61
|
+
return {
|
|
62
|
+
from: from,
|
|
63
|
+
to: req.payTo,
|
|
64
|
+
value,
|
|
65
|
+
validAfter: String(nowSeconds - 600),
|
|
66
|
+
validBefore: String(nowSeconds + timeout),
|
|
67
|
+
nonce,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
/** EIP-712 typed data for the authorization. `name`/`version` are the token's own domain values. */
|
|
71
|
+
export function typedDataFor(chainId, asset, name, version, auth) {
|
|
72
|
+
return {
|
|
73
|
+
domain: { name, version, chainId, verifyingContract: asset },
|
|
74
|
+
types: TRANSFER_WITH_AUTHORIZATION_TYPES,
|
|
75
|
+
primaryType: 'TransferWithAuthorization',
|
|
76
|
+
message: {
|
|
77
|
+
from: auth.from,
|
|
78
|
+
to: auth.to,
|
|
79
|
+
value: BigInt(auth.value),
|
|
80
|
+
validAfter: BigInt(auth.validAfter),
|
|
81
|
+
validBefore: BigInt(auth.validBefore),
|
|
82
|
+
nonce: auth.nonce,
|
|
83
|
+
},
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Token EIP-712 domains we know without asking the chain. USDC's domain name
|
|
88
|
+
* differs by deployment ("USD Coin" on most mainnets, "USDC" on Base Sepolia),
|
|
89
|
+
* which is exactly the kind of thing that silently produces an unrecoverable
|
|
90
|
+
* signature, so only well-known deployments are listed; anything else is read
|
|
91
|
+
* from the contract's name()/version().
|
|
92
|
+
*/
|
|
93
|
+
export const KNOWN_TOKEN_DOMAINS = {
|
|
94
|
+
'8453:0x833589fcd6edb6e08f4c7c32d4f71b54bda02913': { name: 'USD Coin', version: '2' }, // USDC Base
|
|
95
|
+
'84532:0x036cbd53842c5426634e7929541ec2318f3dcf7e': { name: 'USDC', version: '2' }, // USDC Base Sepolia
|
|
96
|
+
'1:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48': { name: 'USD Coin', version: '2' }, // USDC Ethereum
|
|
97
|
+
'137:0x3c499c542cef5e3811e1192ce70d8cc03d5c3359': { name: 'USD Coin', version: '2' }, // USDC Polygon (native)
|
|
98
|
+
'42161:0xaf88d065e77c8cc2239327c5edb3a432268e5831': { name: 'USD Coin', version: '2' }, // USDC Arbitrum
|
|
99
|
+
'10:0x0b2c639c533813f4aa9d7837caf62653d097ff85': { name: 'USD Coin', version: '2' }, // USDC Optimism
|
|
100
|
+
'43114:0xb97ef9ef8734c71904d8002f8b6bc66dd9c48a6e': { name: 'USD Coin', version: '2' }, // USDC Avalanche
|
|
101
|
+
};
|
|
102
|
+
export function knownTokenDomain(chainId, asset) {
|
|
103
|
+
return KNOWN_TOKEN_DOMAINS[`${chainId}:${asset.toLowerCase()}`] ?? null;
|
|
104
|
+
}
|
|
105
|
+
/** Decode a single ABI-encoded string return value (name()/version()). */
|
|
106
|
+
export function decodeAbiString(hex) {
|
|
107
|
+
const h = (hex || '').replace(/^0x/, '');
|
|
108
|
+
if (h.length < 128)
|
|
109
|
+
return '';
|
|
110
|
+
const len = parseInt(h.slice(64, 128), 16);
|
|
111
|
+
if (!Number.isFinite(len) || len <= 0 || len > 256)
|
|
112
|
+
return '';
|
|
113
|
+
const bytes = Buffer.from(h.slice(128, 128 + len * 2), 'hex');
|
|
114
|
+
return bytes.toString('utf8');
|
|
115
|
+
}
|
|
116
|
+
/** Build the payment payload the resource server expects for this x402 version. */
|
|
117
|
+
export function buildPaymentPayload(x402Version, req, auth, signature, resource, extensions) {
|
|
118
|
+
const payload = { signature, authorization: auth };
|
|
119
|
+
if (x402Version >= 2) {
|
|
120
|
+
const out = { x402Version, accepted: req, payload };
|
|
121
|
+
if (resource)
|
|
122
|
+
out.resource = resource;
|
|
123
|
+
const ext = withPaymentIdentifier(extensions);
|
|
124
|
+
if (ext)
|
|
125
|
+
out.extensions = ext;
|
|
126
|
+
return out;
|
|
127
|
+
}
|
|
128
|
+
return { x402Version: 1, scheme: req.scheme, network: req.network, payload };
|
|
129
|
+
}
|
|
130
|
+
/** 16-128 chars of [A-Za-z0-9_-], the shape the payment-identifier extension accepts. */
|
|
131
|
+
export function generatePaymentId() {
|
|
132
|
+
return 'agw_' + randomBytes(16).toString('hex');
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* x402 v2 "payment-identifier" extension: the server may ask for a client-chosen
|
|
136
|
+
* id so it can de-duplicate retries. The 402's extensions are echoed back with
|
|
137
|
+
* info.id filled in. Other extensions pass through untouched.
|
|
138
|
+
*/
|
|
139
|
+
export function withPaymentIdentifier(extensions, id) {
|
|
140
|
+
if (!extensions || typeof extensions !== 'object')
|
|
141
|
+
return null;
|
|
142
|
+
const out = { ...extensions };
|
|
143
|
+
const pid = out['payment-identifier'];
|
|
144
|
+
if (pid && typeof pid === 'object' && pid.info && typeof pid.info === 'object') {
|
|
145
|
+
out['payment-identifier'] = { ...pid, info: { ...pid.info, id: pid.info.id || id || generatePaymentId() } };
|
|
146
|
+
}
|
|
147
|
+
return out;
|
|
148
|
+
}
|
|
149
|
+
export function paymentHeaderFor(x402Version, payload) {
|
|
150
|
+
const value = Buffer.from(JSON.stringify(payload), 'utf8').toString('base64');
|
|
151
|
+
return { name: x402Version >= 2 ? 'PAYMENT-SIGNATURE' : 'X-PAYMENT', value };
|
|
152
|
+
}
|
|
153
|
+
function decodeBase64Json(v) {
|
|
154
|
+
if (!v)
|
|
155
|
+
return null;
|
|
156
|
+
try {
|
|
157
|
+
const parsed = JSON.parse(Buffer.from(v.trim(), 'base64').toString('utf8'));
|
|
158
|
+
return parsed && typeof parsed === 'object' ? parsed : null;
|
|
159
|
+
}
|
|
160
|
+
catch {
|
|
161
|
+
return null;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
/** v2 servers put the requirements in PAYMENT-REQUIRED; v1 servers put them in the JSON body. */
|
|
165
|
+
export function parsePaymentRequired(getHeader, body) {
|
|
166
|
+
const fromHeader = decodeBase64Json(getHeader('PAYMENT-REQUIRED') ?? getHeader('payment-required'));
|
|
167
|
+
if (fromHeader && Array.isArray(fromHeader.accepts))
|
|
168
|
+
return fromHeader;
|
|
169
|
+
if (body && typeof body === 'object' && Array.isArray(body.accepts)) {
|
|
170
|
+
return body;
|
|
171
|
+
}
|
|
172
|
+
return null;
|
|
173
|
+
}
|
|
174
|
+
export function parseSettlement(getHeader) {
|
|
175
|
+
return (decodeBase64Json(getHeader('PAYMENT-RESPONSE') ?? getHeader('payment-response'))
|
|
176
|
+
?? decodeBase64Json(getHeader('X-PAYMENT-RESPONSE') ?? getHeader('x-payment-response')));
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* Choose which accepts[] entry to pay. `exact` is the only scheme this client
|
|
180
|
+
* can sign today; `upto` needs Permit2 (a max authorization the seller settles
|
|
181
|
+
* below) and is refused rather than approximated with a full upfront transfer,
|
|
182
|
+
* which is what issue #6 caught. Returns the reason when nothing is payable.
|
|
183
|
+
*/
|
|
184
|
+
export function pickOption(accepts, resolveChainId, preferChain) {
|
|
185
|
+
const payable = accepts.filter(a => a.scheme === 'exact' && resolveChainId(a.network) !== null);
|
|
186
|
+
if (payable.length === 0) {
|
|
187
|
+
const schemes = Array.from(new Set(accepts.map(a => a.scheme))).join(', ') || 'none';
|
|
188
|
+
const reason = accepts.some(a => a.scheme === 'upto')
|
|
189
|
+
? 'This endpoint only offers the x402 "upto" scheme (Permit2 max-authorization settled by usage). ' +
|
|
190
|
+
'AgentWallet does not sign upto authorizations yet and will not approximate one with an upfront transfer. ' +
|
|
191
|
+
'Ask the endpoint for an "exact" option, or wait for Permit2 support.'
|
|
192
|
+
: `No payable option: schemes offered were ${schemes}.`;
|
|
193
|
+
return { option: null, chainId: null, reason };
|
|
194
|
+
}
|
|
195
|
+
let option = payable[0];
|
|
196
|
+
if (preferChain)
|
|
197
|
+
option = payable.find(a => resolveChainId(a.network) === preferChain) ?? option;
|
|
198
|
+
return { option, chainId: resolveChainId(option.network) };
|
|
199
|
+
}
|
package/build/x402-payment.d.ts
CHANGED
|
@@ -81,4 +81,15 @@ export declare function deriveX402Payment(accept: X402AcceptLike, maxAutopayHuma
|
|
|
81
81
|
* @param trustedDecimals token decimals from a trusted source, NOT the 402 body
|
|
82
82
|
* @param capHuman cap in human-readable units of the asset (e.g. "1")
|
|
83
83
|
*/
|
|
84
|
+
/**
|
|
85
|
+
* Chain IDs this server treats as Solana (mainnet, devnet, testnet).
|
|
86
|
+
*/
|
|
87
|
+
export declare const SOLANA_CHAIN_IDS: ReadonlySet<number>;
|
|
88
|
+
/**
|
|
89
|
+
* Decimals of a chain's NATIVE asset. EVM natives are 18 (wei); native SOL is
|
|
90
|
+
* 9 (lamports). The x402 cap and the human-readable amount must use this when
|
|
91
|
+
* a requirement names no token, otherwise a native-SOL requirement is measured
|
|
92
|
+
* against a cap inflated by 10**9 (AW-002, reported 2026-09-22).
|
|
93
|
+
*/
|
|
94
|
+
export declare function nativeDecimals(chainId: number): number;
|
|
84
95
|
export declare function isWithinCap(rawAmountRequired: string, trustedDecimals: number, capHuman: string): boolean;
|
package/build/x402-payment.js
CHANGED
|
@@ -146,6 +146,19 @@ export function deriveX402Payment(accept, maxAutopayHuman, trustedDecimals) {
|
|
|
146
146
|
* @param trustedDecimals token decimals from a trusted source, NOT the 402 body
|
|
147
147
|
* @param capHuman cap in human-readable units of the asset (e.g. "1")
|
|
148
148
|
*/
|
|
149
|
+
/**
|
|
150
|
+
* Chain IDs this server treats as Solana (mainnet, devnet, testnet).
|
|
151
|
+
*/
|
|
152
|
+
export const SOLANA_CHAIN_IDS = new Set([900, 901, 902]);
|
|
153
|
+
/**
|
|
154
|
+
* Decimals of a chain's NATIVE asset. EVM natives are 18 (wei); native SOL is
|
|
155
|
+
* 9 (lamports). The x402 cap and the human-readable amount must use this when
|
|
156
|
+
* a requirement names no token, otherwise a native-SOL requirement is measured
|
|
157
|
+
* against a cap inflated by 10**9 (AW-002, reported 2026-09-22).
|
|
158
|
+
*/
|
|
159
|
+
export function nativeDecimals(chainId) {
|
|
160
|
+
return SOLANA_CHAIN_IDS.has(chainId) ? 9 : 18;
|
|
161
|
+
}
|
|
149
162
|
export function isWithinCap(rawAmountRequired, trustedDecimals, capHuman) {
|
|
150
163
|
if (!/^\d+$/.test(rawAmountRequired)) {
|
|
151
164
|
throw new Error(`Invalid maxAmountRequired "${rawAmountRequired}" (expected an integer base-unit amount).`);
|
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.
|
|
4
|
+
"version": "1.11.0",
|
|
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/token-cap.test.mjs && node test/spl-cap.test.mjs && node test/unknown-decimals-cap.test.mjs && node test/local-mode.test.mjs && node test/ssrf-guard.test.mjs",
|
|
19
|
+
"test": "node test/x402-payment.test.mjs && node test/eip3009.test.mjs && node test/token-cap.test.mjs && node test/spl-cap.test.mjs && node test/unknown-decimals-cap.test.mjs && node test/local-mode.test.mjs && node test/ssrf-guard.test.mjs",
|
|
20
20
|
"prepublishOnly": "npm run build"
|
|
21
21
|
},
|
|
22
22
|
"keywords": [
|