agentwallet-mcp 1.10.9 → 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 +176 -99
- 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/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
|
@@ -15,6 +15,8 @@ import { z } from 'zod';
|
|
|
15
15
|
import { assertPublicUrl, safeFetch } from './ssrf-guard.js';
|
|
16
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);
|
|
@@ -423,7 +445,7 @@ function resolveChainId(network) {
|
|
|
423
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');
|
|
424
446
|
const server = new McpServer({
|
|
425
447
|
name: 'agentwallet',
|
|
426
|
-
version: '1.
|
|
448
|
+
version: '1.11.0',
|
|
427
449
|
}, {
|
|
428
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.
|
|
429
451
|
|
|
@@ -935,11 +957,63 @@ server.tool('get_token_info', 'Get the name, symbol, and decimals of any ERC-20
|
|
|
935
957
|
decimals,
|
|
936
958
|
});
|
|
937
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
|
+
}
|
|
938
1010
|
// ─── Tool: pay_x402 ─────────────────────────────────────────────
|
|
939
1011
|
server.tool('pay_x402', 'Handle an x402 payment flow. Fetches a URL, and if the server returns HTTP 402 Payment Required, ' +
|
|
940
|
-
'parses the payment requirements
|
|
941
|
-
'
|
|
942
|
-
'
|
|
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.', {
|
|
943
1017
|
url: z.string().url().describe('The URL to access (will handle 402 payment if required)'),
|
|
944
1018
|
wallet_id: z.number().int().describe('Wallet ID to pay from'),
|
|
945
1019
|
method: z.string().default('GET').describe('HTTP method (GET, POST, PUT, DELETE)'),
|
|
@@ -999,125 +1073,114 @@ server.tool('pay_x402', 'Handle an x402 payment flow. Fetches a URL, and if the
|
|
|
999
1073
|
response: parsed,
|
|
1000
1074
|
});
|
|
1001
1075
|
}
|
|
1002
|
-
// Step 2:
|
|
1003
|
-
|
|
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;
|
|
1004
1079
|
try {
|
|
1005
|
-
|
|
1080
|
+
initialBody = JSON.parse(initialText);
|
|
1006
1081
|
}
|
|
1007
1082
|
catch {
|
|
1008
|
-
|
|
1009
|
-
}
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
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
|
+
});
|
|
1020
1102
|
}
|
|
1021
|
-
|
|
1022
|
-
const chainId =
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
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).`);
|
|
1026
1108
|
}
|
|
1027
1109
|
// Decimals come from the registry or the token contract, NEVER from the 402
|
|
1028
|
-
// body. The endpoint controls
|
|
1029
|
-
// hostile server inflate the cap
|
|
1030
|
-
|
|
1031
|
-
const
|
|
1032
|
-
|
|
1033
|
-
? await resolveTrustedDecimals(chainId, assetForDecimals)
|
|
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)
|
|
1034
1115
|
: nativeDecimals(chainId); // 18 on EVM (wei), 9 on Solana (lamports)
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
const
|
|
1039
|
-
// Step 4:
|
|
1040
|
-
//
|
|
1041
|
-
//
|
|
1042
|
-
// untrusted or compromised x402 endpoint could return an arbitrarily large
|
|
1043
|
-
// maxAmountRequired and drain the wallet. The cap is never silently skipped.
|
|
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.
|
|
1044
1123
|
const capSource = max_payment ? 'max_payment' : 'AGENTWALLET_MAX_AUTOPAY';
|
|
1045
1124
|
const effectiveMax = max_payment || process.env.AGENTWALLET_MAX_AUTOPAY || '1';
|
|
1046
|
-
if (!isWithinCap(
|
|
1125
|
+
if (!isWithinCap(rawAmount, trustedDecimals, effectiveMax)) {
|
|
1047
1126
|
return jsonResponse({
|
|
1048
1127
|
status: 402,
|
|
1049
1128
|
payment_required: true,
|
|
1050
1129
|
payment_made: false,
|
|
1051
|
-
error: `Payment of ${amount} ${
|
|
1052
|
-
`${effectiveMax} cap (from ${capSource}).` +
|
|
1130
|
+
error: `Payment of ${amount} ${tokenLabel} exceeds the ${effectiveMax} cap (from ${capSource}).` +
|
|
1053
1131
|
(max_payment ? '' : ' No max_payment was provided, so the default cap was applied. ' +
|
|
1054
1132
|
'Pass max_payment (or raise AGENTWALLET_MAX_AUTOPAY) to allow a larger payment.'),
|
|
1055
1133
|
required_amount: amount,
|
|
1056
1134
|
max_allowed: effectiveMax,
|
|
1057
1135
|
cap_source: capSource,
|
|
1058
|
-
token:
|
|
1136
|
+
token: tokenLabel,
|
|
1059
1137
|
network: option.network,
|
|
1060
1138
|
chain_id: chainId,
|
|
1061
1139
|
pay_to: option.payTo,
|
|
1062
1140
|
description: option.description,
|
|
1063
1141
|
});
|
|
1064
1142
|
}
|
|
1065
|
-
// Standard x402
|
|
1066
|
-
//
|
|
1067
|
-
|
|
1068
|
-
//
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
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 }));
|
|
1081
1173
|
}
|
|
1082
1174
|
else {
|
|
1083
|
-
|
|
1084
|
-
txResult = (await api(`/wallets/${wallet_id}/send`, 'POST', {
|
|
1085
|
-
to: option.payTo,
|
|
1086
|
-
value: option.maxAmountRequired,
|
|
1087
|
-
chain_id: chainId,
|
|
1088
|
-
}));
|
|
1175
|
+
txResult = (await api(`/wallets/${wallet_id}/send`, 'POST', { to: option.payTo, value: rawAmount, data: '', chain_id: chainId }));
|
|
1089
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');
|
|
1090
1181
|
}
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
const calldata = '0xa9059cbb' + padAddress(option.payTo) + encodeUint256(option.maxAmountRequired);
|
|
1094
|
-
txResult = (await api(`/wallets/${wallet_id}/send`, 'POST', {
|
|
1095
|
-
to: tokenAddress,
|
|
1096
|
-
value: '0',
|
|
1097
|
-
data: calldata,
|
|
1098
|
-
chain_id: chainId,
|
|
1099
|
-
}));
|
|
1100
|
-
}
|
|
1101
|
-
else {
|
|
1102
|
-
// EVM native token payment (ETH, etc.)
|
|
1103
|
-
txResult = (await api(`/wallets/${wallet_id}/send`, 'POST', {
|
|
1104
|
-
to: option.payTo,
|
|
1105
|
-
value: option.maxAmountRequired,
|
|
1106
|
-
data: '',
|
|
1107
|
-
chain_id: chainId,
|
|
1108
|
-
}));
|
|
1109
|
-
}
|
|
1110
|
-
// Solana returns 'signature', EVM returns 'tx_hash'
|
|
1111
|
-
const txHash = (txResult.tx_hash || txResult.signature);
|
|
1112
|
-
// Step 6: Build x402 payment proof and retry
|
|
1113
|
-
const paymentProof = {
|
|
1114
|
-
x402Version: paymentInfo.x402Version || 1,
|
|
1115
|
-
scheme: option.scheme,
|
|
1116
|
-
network: option.network,
|
|
1117
|
-
payload: { txHash },
|
|
1118
|
-
};
|
|
1119
|
-
const paymentHeader = Buffer.from(JSON.stringify(paymentProof)).toString('base64');
|
|
1120
|
-
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 };
|
|
1121
1184
|
const retryOptions = {
|
|
1122
1185
|
method,
|
|
1123
1186
|
headers: retryHeaders,
|
|
@@ -1135,17 +1198,31 @@ server.tool('pay_x402', 'Handle an x402 payment flow. Fetches a URL, and if the
|
|
|
1135
1198
|
catch {
|
|
1136
1199
|
retryParsed = retryText;
|
|
1137
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
|
+
}
|
|
1138
1209
|
return jsonResponse({
|
|
1139
1210
|
status: retryRes.status,
|
|
1140
1211
|
payment_required: true,
|
|
1141
|
-
payment_made:
|
|
1212
|
+
payment_made: retryRes.status !== 402,
|
|
1213
|
+
retry_error: retryError,
|
|
1214
|
+
payment_method: standardExact ? 'eip3009-authorization' : 'onchain-transfer',
|
|
1215
|
+
x402_version: x402Version,
|
|
1142
1216
|
amount,
|
|
1143
|
-
token:
|
|
1144
|
-
token_address:
|
|
1217
|
+
token: tokenLabel,
|
|
1218
|
+
token_address: tokenAddress || null,
|
|
1145
1219
|
network: option.network,
|
|
1146
1220
|
chain_id: chainId,
|
|
1147
1221
|
pay_to: option.payTo,
|
|
1148
|
-
|
|
1222
|
+
payer,
|
|
1223
|
+
tx_hash: txHash ?? settlement?.transaction ?? null,
|
|
1224
|
+
settlement,
|
|
1225
|
+
authorization: authorization ? { nonce: authorization.nonce, valid_before: authorization.validBefore } : null,
|
|
1149
1226
|
description: option.description,
|
|
1150
1227
|
response: retryParsed,
|
|
1151
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/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": [
|