agentwallet-mcp 1.11.0 → 1.12.1
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 +18 -2
- package/build/index.js +157 -30
- package/build/local-wallet.d.ts +7 -0
- package/build/local-wallet.js +14 -0
- package/build/token-risk.d.ts +19 -0
- package/build/token-risk.js +127 -0
- package/build/x402-eip3009.d.ts +7 -4
- package/build/x402-eip3009.js +17 -9
- package/build/x402-permit2.d.ts +124 -0
- package/build/x402-permit2.js +93 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -172,7 +172,10 @@ Add to your settings:
|
|
|
172
172
|
| `get_allowance` | Check ERC-20 token allowance |
|
|
173
173
|
| `wrap_eth` | Wrap native tokens to WETH/WAVAX/etc. |
|
|
174
174
|
| `unwrap_eth` | Unwrap WETH back to native tokens |
|
|
175
|
-
| `pay_x402` | Pay x402 invoices
|
|
175
|
+
| `pay_x402` | Pay x402 invoices (exact via EIP-3009, upto via Permit2), with approvals above the cap |
|
|
176
|
+
| `check_approval` | Status of a human approval created by an over-cap `pay_x402` |
|
|
177
|
+
| `approve_permit2` | One-time token approval to Permit2 for `upto` endpoints |
|
|
178
|
+
| `check_token_risk` | Honeypot, tax, owner-power and holder-concentration flags for a token |
|
|
176
179
|
| `create_paywall` | Create an x402 paywall to charge for a resource |
|
|
177
180
|
| `list_paywalls` | List all your x402 paywalls |
|
|
178
181
|
| `get_paywall` | Get paywall details by ID |
|
|
@@ -239,7 +242,20 @@ pay_x402(
|
|
|
239
242
|
|
|
240
243
|
`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
244
|
|
|
242
|
-
**Schemes.** `exact` is
|
|
245
|
+
**Schemes.** `exact` (EIP-3009, gasless for the payer) is preferred and works on every EVM chain AgentWallet knows. `upto` (a Permit2 maximum the seller settles at actual usage) is signed as a `PermitWitnessTransferFrom` bound to the endpoint's facilitator; it needs a one-time `approve_permit2` per token (or `AGENTWALLET_PERMIT2_AUTO_APPROVE=1`). An `upto` option without `extra.facilitatorAddress` is refused, never approximated with an upfront transfer. 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.
|
|
246
|
+
|
|
247
|
+
**Approvals above the cap.** On a hosted wallet, a payment above the cap does not have to fail. With `request_approval` (default on; `AGENTWALLET_APPROVALS=0` turns it off) the wallet owner gets an email with approve and deny links, `pay_x402` returns an `approval_id`, and the agent retries with it once `check_approval` says approved. An approval is bound to one wallet, chain, asset, recipient and maximum, expires in 24 hours, and is consumed by one signature. Local mode has no approval channel: the caps in your environment are the policy.
|
|
248
|
+
|
|
249
|
+
**Asset risk.** `check_token_risk` (and every `approve_token` result) reports honeypot, tax, owner-power, verified-source, holder-concentration and liquidity flags from GoPlus Security, with an on-chain fallback. A warning for the caller to weigh, never a block.
|
|
250
|
+
|
|
251
|
+
**Proof.** Real settlements are listed in [PAYMENTS.md](PAYMENTS.md); the first is a 0.02 USDC `exact` payment on Base with the facilitator paying the gas.
|
|
252
|
+
|
|
253
|
+
## Pay any x402 API from Claude in three steps
|
|
254
|
+
|
|
255
|
+
1. Add the server (one of the snippets under Quick Start). Self-custody: set `AGENTWALLET_PRIVATE_KEY` to a key that holds USDC on Base. Hosted: `AGENTWALLET_USER` and `AGENTWALLET_PASS` from hifriendbot.com/wallet.
|
|
256
|
+
2. Ask: *"Use pay_x402 to POST https://api.ozdreamtools.de/api/holidays with body {"year":2026,"state":"BY"} and a max_payment of 0.05."*
|
|
257
|
+
3. Read the result: `payment_made: true`, the amount, `payment_method`, and `settlement.transaction`, the on-chain hash. No ETH needed for `exact`: the endpoint's facilitator pays the gas.
|
|
258
|
+
|
|
243
259
|
|
|
244
260
|
**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.
|
|
245
261
|
|
package/build/index.js
CHANGED
|
@@ -15,8 +15,10 @@ 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 {
|
|
18
|
+
import { localSignAuthorization, localEthCall, localSignPermit2Upto } from './local-wallet.js';
|
|
19
|
+
import { buildUptoAuthorization, uptoPayload, permit2AllowanceCalldata, permit2ApproveCalldata, decodeUint, PERMIT2_ADDRESS, } from './x402-permit2.js';
|
|
20
|
+
import { assessTokenRisk } from './token-risk.js';
|
|
21
|
+
import { isLegacyAgentWalletAccept, requiredAmount, buildAuthorization, buildPaymentPayload, buildPaymentPayloadRaw, paymentHeaderFor, parsePaymentRequired, parseSettlement, pickOption, knownTokenDomain, } from './x402-eip3009.js';
|
|
20
22
|
import { isSolanaLocalMode, getSolanaAddress, solanaWalletRecord, localSolBalance, localSplBalance, localSolTransfer, localSplTransfer, resolveSolanaRpc, } from './local-solana.js';
|
|
21
23
|
/** True when either chain family is running non-custodially. */
|
|
22
24
|
function anyLocalMode() {
|
|
@@ -106,6 +108,20 @@ async function routeLocally(path, method, body) {
|
|
|
106
108
|
nonce: String(body?.nonce || ''),
|
|
107
109
|
});
|
|
108
110
|
}
|
|
111
|
+
// POST /wallets/{id}/x402/permit2 (x402 "upto": Permit2 max-authorization, signed here)
|
|
112
|
+
if (method === 'POST' && /^\/wallets\/[^/]*\/x402\/permit2$/.test(rawPath)) {
|
|
113
|
+
if (!isLocalMode())
|
|
114
|
+
refuseMissingLocalKey('evm');
|
|
115
|
+
const chainId = localChainId(body, qs);
|
|
116
|
+
return localSignPermit2Upto(chainId, {
|
|
117
|
+
from: getLocalAddress(),
|
|
118
|
+
permitted: { token: String(body?.asset || ''), amount: String(body?.amount ?? '0') },
|
|
119
|
+
spender: String(body?.spender || ''),
|
|
120
|
+
nonce: String(body?.nonce ?? '0'),
|
|
121
|
+
deadline: String(body?.deadline ?? '0'),
|
|
122
|
+
witness: { to: String(body?.to || ''), facilitator: String(body?.facilitator || ''), validAfter: String(body?.valid_after ?? '0') },
|
|
123
|
+
});
|
|
124
|
+
}
|
|
109
125
|
// POST /eth-call (read-only; answered from the local RPC so pure self-custody needs no hosted credentials)
|
|
110
126
|
if (method === 'POST' && rawPath === '/eth-call') {
|
|
111
127
|
if (!isLocalMode())
|
|
@@ -445,7 +461,7 @@ function resolveChainId(network) {
|
|
|
445
461
|
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');
|
|
446
462
|
const server = new McpServer({
|
|
447
463
|
name: 'agentwallet',
|
|
448
|
-
version: '1.
|
|
464
|
+
version: '1.12.1',
|
|
449
465
|
}, {
|
|
450
466
|
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.
|
|
451
467
|
|
|
@@ -787,6 +803,17 @@ server.tool('approve_token', 'Approve a spender contract to transfer ERC-20 toke
|
|
|
787
803
|
rawAmount = parseUnits(amount, decimals);
|
|
788
804
|
}
|
|
789
805
|
const calldata = '0x095ea7b3' + padAddress(spender) + encodeUint256(rawAmount);
|
|
806
|
+
// Asset risk rides along with the approval (issue #13). It never blocks; it is the
|
|
807
|
+
// caller's call. Skipped for Permit2, which only moves what a signature allows.
|
|
808
|
+
let risk = null;
|
|
809
|
+
if (spender.toLowerCase() !== PERMIT2_ADDRESS.toLowerCase()) {
|
|
810
|
+
try {
|
|
811
|
+
risk = await assessTokenRisk(chain_id, token, (to, data) => ethCallHex(chain_id, to, data));
|
|
812
|
+
}
|
|
813
|
+
catch {
|
|
814
|
+
risk = null;
|
|
815
|
+
}
|
|
816
|
+
}
|
|
790
817
|
const result = await api(`/wallets/${wallet_id}/send`, 'POST', {
|
|
791
818
|
to: token,
|
|
792
819
|
value: '0',
|
|
@@ -798,6 +825,7 @@ server.tool('approve_token', 'Approve a spender contract to transfer ERC-20 toke
|
|
|
798
825
|
token,
|
|
799
826
|
spender,
|
|
800
827
|
amount: amount.toLowerCase() === 'max' ? 'unlimited' : amount,
|
|
828
|
+
risk,
|
|
801
829
|
});
|
|
802
830
|
});
|
|
803
831
|
// ─── Tool: get_allowance ────────────────────────────────────────
|
|
@@ -996,10 +1024,11 @@ async function resolveTokenDomain(chainId, asset, extra) {
|
|
|
996
1024
|
return { name, version };
|
|
997
1025
|
}
|
|
998
1026
|
/** Sign the authorization with whichever custody mode is active (local key, or the hosted signer). */
|
|
999
|
-
async function signAuthorization(walletId, chainId, asset, domain, auth) {
|
|
1027
|
+
async function signAuthorization(walletId, chainId, asset, domain, auth, approvalId) {
|
|
1000
1028
|
const r = (await api(`/wallets/${walletId}/x402/authorize`, 'POST', {
|
|
1001
1029
|
chain_id: chainId, asset, name: domain.name, version: domain.version,
|
|
1002
1030
|
to: auth.to, value: auth.value, valid_after: auth.validAfter, valid_before: auth.validBefore, nonce: auth.nonce,
|
|
1031
|
+
...(approvalId ? { approval_id: approvalId } : {}),
|
|
1003
1032
|
}, { 'X-AGW-SKIP-X402': 'true' }));
|
|
1004
1033
|
const sig = String(r?.signature || '');
|
|
1005
1034
|
if (!/^0x[0-9a-fA-F]{130}$/.test(sig)) {
|
|
@@ -1007,6 +1036,18 @@ async function signAuthorization(walletId, chainId, asset, domain, auth) {
|
|
|
1007
1036
|
}
|
|
1008
1037
|
return sig;
|
|
1009
1038
|
}
|
|
1039
|
+
/** Sign a Permit2 upto authorization with whichever custody mode is active. */
|
|
1040
|
+
async function signPermit2(walletId, chainId, auth, approvalId) {
|
|
1041
|
+
const r = (await api(`/wallets/${walletId}/x402/permit2`, 'POST', {
|
|
1042
|
+
chain_id: chainId, asset: auth.permitted.token, amount: auth.permitted.amount, spender: auth.spender, permit2: PERMIT2_ADDRESS,
|
|
1043
|
+
nonce: auth.nonce, deadline: auth.deadline, to: auth.witness.to, facilitator: auth.witness.facilitator, valid_after: auth.witness.validAfter,
|
|
1044
|
+
...(approvalId ? { approval_id: approvalId } : {}),
|
|
1045
|
+
}, { 'X-AGW-SKIP-X402': 'true' }));
|
|
1046
|
+
const sig = String(r?.signature || '');
|
|
1047
|
+
if (!/^0x[0-9a-fA-F]{130}$/.test(sig))
|
|
1048
|
+
throw new Error(`x402 upto: the wallet did not return a valid Permit2 signature${r?.error ? ` (${r.error})` : ''}.`);
|
|
1049
|
+
return sig;
|
|
1050
|
+
}
|
|
1010
1051
|
// ─── Tool: pay_x402 ─────────────────────────────────────────────
|
|
1011
1052
|
server.tool('pay_x402', 'Handle an x402 payment flow. Fetches a URL, and if the server returns HTTP 402 Payment Required, ' +
|
|
1012
1053
|
'parses the payment requirements (v1 body or v2 PAYMENT-REQUIRED header), signs an EIP-3009 ' +
|
|
@@ -1028,7 +1069,10 @@ server.tool('pay_x402', 'Handle an x402 payment flow. Fetches a URL, and if the
|
|
|
1028
1069
|
'to allow a larger single payment.'),
|
|
1029
1070
|
prefer_chain: z.number().int().optional().describe('Preferred chain ID if the server accepts payment on multiple chains ' +
|
|
1030
1071
|
'(e.g. 8453 for Base, 1 for Ethereum)'),
|
|
1031
|
-
|
|
1072
|
+
request_approval: z.boolean().optional().describe('Hosted wallets only. When the payment exceeds the cap, email the wallet owner an approve/deny link ' +
|
|
1073
|
+
'instead of refusing, and return an approval_id to retry with. Default true unless AGENTWALLET_APPROVALS=0.'),
|
|
1074
|
+
approval_id: z.string().optional().describe('An approval id from an earlier over-cap attempt. Once the owner has approved it, pass it here to make that one payment.'),
|
|
1075
|
+
}, async ({ url, wallet_id, method, headers: headersJson, body: reqBody, max_payment, prefer_chain, request_approval, approval_id }) => {
|
|
1032
1076
|
// Build request headers
|
|
1033
1077
|
const reqHeaders = { Accept: 'application/json' };
|
|
1034
1078
|
if (headersJson) {
|
|
@@ -1089,7 +1133,7 @@ server.tool('pay_x402', 'Handle an x402 payment flow. Fetches a URL, and if the
|
|
|
1089
1133
|
}
|
|
1090
1134
|
const x402Version = Number(paymentInfo.x402Version) || 1;
|
|
1091
1135
|
const accepts = paymentInfo.accepts;
|
|
1092
|
-
// Step 3: Pick an option this client can sign
|
|
1136
|
+
// Step 3: Pick an option this client can sign: exact (EIP-3009) first, then a signable upto (Permit2).
|
|
1093
1137
|
const picked = pickOption(accepts, resolveChainId, prefer_chain);
|
|
1094
1138
|
if (!picked.option || !picked.chainId) {
|
|
1095
1139
|
return jsonResponse({
|
|
@@ -1102,6 +1146,7 @@ server.tool('pay_x402', 'Handle an x402 payment flow. Fetches a URL, and if the
|
|
|
1102
1146
|
}
|
|
1103
1147
|
const option = picked.option;
|
|
1104
1148
|
const chainId = picked.chainId;
|
|
1149
|
+
const isUpto = option.scheme === 'upto';
|
|
1105
1150
|
const rawAmount = requiredAmount(option);
|
|
1106
1151
|
if (!/^\d+$/.test(rawAmount)) {
|
|
1107
1152
|
throw new Error(`x402: invalid amount "${rawAmount}" in the payment requirements (expected integer base units).`);
|
|
@@ -1117,44 +1162,100 @@ server.tool('pay_x402', 'Handle an x402 payment flow. Fetches a URL, and if the
|
|
|
1117
1162
|
assertDeclaredDecimals(option.requiredDecimals, trustedDecimals);
|
|
1118
1163
|
const amount = formatUnits(rawAmount, trustedDecimals);
|
|
1119
1164
|
const tokenLabel = option.extra?.name || (tokenAddress ? 'tokens' : 'native');
|
|
1165
|
+
const skip = { 'X-AGW-SKIP-X402': 'true' };
|
|
1120
1166
|
// Step 4: Hard per-payment cap, ALWAYS applied. If the caller omits max_payment
|
|
1121
|
-
// we fall back to AGENTWALLET_MAX_AUTOPAY (default "1")
|
|
1122
|
-
//
|
|
1167
|
+
// we fall back to AGENTWALLET_MAX_AUTOPAY (default "1"). Above the cap a hosted
|
|
1168
|
+
// wallet can ask its owner instead of refusing: an approval is created, the
|
|
1169
|
+
// owner gets approve/deny links by email, and the agent retries with the id.
|
|
1170
|
+
// For upto the cap applies to the MAXIMUM the seller may settle.
|
|
1123
1171
|
const capSource = max_payment ? 'max_payment' : 'AGENTWALLET_MAX_AUTOPAY';
|
|
1124
1172
|
const effectiveMax = max_payment || process.env.AGENTWALLET_MAX_AUTOPAY || '1';
|
|
1173
|
+
let approvalUsed = null;
|
|
1125
1174
|
if (!isWithinCap(rawAmount, trustedDecimals, effectiveMax)) {
|
|
1126
|
-
|
|
1127
|
-
status: 402,
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
}
|
|
1175
|
+
const overCap = {
|
|
1176
|
+
status: 402, payment_required: true, payment_made: false,
|
|
1177
|
+
required_amount: amount, max_allowed: effectiveMax, cap_source: capSource, token: tokenLabel,
|
|
1178
|
+
network: option.network, chain_id: chainId, pay_to: option.payTo, description: option.description,
|
|
1179
|
+
};
|
|
1180
|
+
const wantApproval = request_approval ?? (process.env.AGENTWALLET_APPROVALS !== '0');
|
|
1181
|
+
if (approval_id) {
|
|
1182
|
+
const a = (await api(`/approvals/${encodeURIComponent(approval_id)}`, 'GET', undefined, skip));
|
|
1183
|
+
if (a?.status !== 'approved' || Number(a.used) === 1) {
|
|
1184
|
+
return jsonResponse({ ...overCap, approval_id, approval_status: a?.status ?? null, error: `Approval ${approval_id} is ${a?.status ?? 'unknown'}, not approved.${a?.error ? ' ' + a.error : ''}` });
|
|
1185
|
+
}
|
|
1186
|
+
if (String(a.pay_to || '').toLowerCase() !== option.payTo.toLowerCase() || Number(a.chain_id) !== chainId || BigInt(String(a.value || '0')) < BigInt(rawAmount)) {
|
|
1187
|
+
return jsonResponse({ ...overCap, approval_id, error: 'Approval does not cover this payment (recipient, chain or amount differ).' });
|
|
1188
|
+
}
|
|
1189
|
+
approvalUsed = String(approval_id);
|
|
1190
|
+
}
|
|
1191
|
+
else if (wantApproval && !anyLocalMode()) {
|
|
1192
|
+
const created = (await api('/approvals', 'POST', {
|
|
1193
|
+
wallet_id, chain_id: chainId, scheme: option.scheme, asset: tokenAddress, pay_to: option.payTo, value: rawAmount,
|
|
1194
|
+
amount_human: amount, token_name: tokenLabel, url,
|
|
1195
|
+
}, skip));
|
|
1196
|
+
if (!created?.id) {
|
|
1197
|
+
return jsonResponse({ ...overCap, error: `Payment of ${amount} ${tokenLabel} exceeds the ${effectiveMax} cap and the approval request failed: ${created?.error || 'unknown error'}.` });
|
|
1198
|
+
}
|
|
1199
|
+
return jsonResponse({
|
|
1200
|
+
...overCap, approval_pending: true, approval_id: String(created.id), expires_at: created.expires_at ?? null,
|
|
1201
|
+
error: `Payment of ${amount} ${tokenLabel} exceeds the ${effectiveMax} cap (from ${capSource}). The wallet owner has been emailed approve/deny links ` +
|
|
1202
|
+
`(approval ${created.id}, valid 24 hours). Check it with check_approval, then call pay_x402 again with approval_id "${created.id}".`,
|
|
1203
|
+
});
|
|
1204
|
+
}
|
|
1205
|
+
else {
|
|
1206
|
+
return jsonResponse({
|
|
1207
|
+
...overCap,
|
|
1208
|
+
error: `Payment of ${amount} ${tokenLabel} exceeds the ${effectiveMax} cap (from ${capSource}).` +
|
|
1209
|
+
(max_payment ? '' : ' No max_payment was provided, so the default cap was applied.') +
|
|
1210
|
+
(anyLocalMode() ? ' Local mode has no approval channel: raise max_payment, AGENTWALLET_MAX_AUTOPAY or AGENTWALLET_MAX_TX_TOKEN yourself.'
|
|
1211
|
+
: ' Pass request_approval=true to email the wallet owner for a one-time approval, or raise the cap.'),
|
|
1212
|
+
});
|
|
1213
|
+
}
|
|
1142
1214
|
}
|
|
1143
1215
|
// Step 5: Pay. Standard x402 "exact" on EVM is an EIP-3009 authorization the
|
|
1144
|
-
// facilitator settles;
|
|
1216
|
+
// facilitator settles; "upto" is a Permit2 max-authorization settled at actual
|
|
1217
|
+
// usage; the payer broadcasts nothing and pays no gas for either. AgentWallet's
|
|
1145
1218
|
// own paywalls (verified by receipt), native-asset requests and Solana still use
|
|
1146
1219
|
// a broadcast transfer proved by hash.
|
|
1147
|
-
const standardExact = !isSolanaChain(chainId) && Boolean(tokenAddress) && !isLegacyAgentWalletAccept(option);
|
|
1220
|
+
const standardExact = !isUpto && !isSolanaChain(chainId) && Boolean(tokenAddress) && !isLegacyAgentWalletAccept(option);
|
|
1148
1221
|
let headerName = 'X-PAYMENT';
|
|
1149
1222
|
let paymentHeader = '';
|
|
1150
1223
|
let txHash = null;
|
|
1151
1224
|
let authorization = null;
|
|
1225
|
+
let uptoAuth = null;
|
|
1152
1226
|
let payer = null;
|
|
1153
|
-
|
|
1227
|
+
let permit2ApprovalTx = null;
|
|
1228
|
+
if (isUpto) {
|
|
1229
|
+
payer = await payerAddress(wallet_id);
|
|
1230
|
+
const allowance = decodeUint(await ethCallHex(chainId, tokenAddress, permit2AllowanceCalldata(payer)));
|
|
1231
|
+
if (allowance < BigInt(rawAmount)) {
|
|
1232
|
+
if (process.env.AGENTWALLET_PERMIT2_AUTO_APPROVE === '1') {
|
|
1233
|
+
const r = (await api(`/wallets/${wallet_id}/send`, 'POST', { to: tokenAddress, value: '0', data: permit2ApproveCalldata(), chain_id: chainId }, skip));
|
|
1234
|
+
permit2ApprovalTx = String(r?.tx_hash || '');
|
|
1235
|
+
await new Promise(res => setTimeout(res, 4000));
|
|
1236
|
+
}
|
|
1237
|
+
else {
|
|
1238
|
+
return jsonResponse({
|
|
1239
|
+
status: 402, payment_required: true, payment_made: false, permit2_approval_needed: true,
|
|
1240
|
+
token: tokenLabel, token_address: tokenAddress, chain_id: chainId, payer, permit2: PERMIT2_ADDRESS,
|
|
1241
|
+
error: `This endpoint uses the x402 "upto" scheme, which settles through Permit2. Wallet ${payer} has not approved ` +
|
|
1242
|
+
`${tokenLabel} to Permit2 on chain ${chainId}. Call approve_permit2 once for this token (a normal transaction that needs gas), ` +
|
|
1243
|
+
`or set AGENTWALLET_PERMIT2_AUTO_APPROVE=1, then call pay_x402 again.`,
|
|
1244
|
+
});
|
|
1245
|
+
}
|
|
1246
|
+
}
|
|
1247
|
+
uptoAuth = buildUptoAuthorization(payer, option);
|
|
1248
|
+
const signature = await signPermit2(wallet_id, chainId, uptoAuth, approvalUsed);
|
|
1249
|
+
const payload = buildPaymentPayloadRaw(x402Version, option, uptoPayload(uptoAuth, signature), paymentInfo.resource, paymentInfo.extensions);
|
|
1250
|
+
const h = paymentHeaderFor(x402Version, payload);
|
|
1251
|
+
headerName = h.name;
|
|
1252
|
+
paymentHeader = h.value;
|
|
1253
|
+
}
|
|
1254
|
+
else if (standardExact) {
|
|
1154
1255
|
payer = await payerAddress(wallet_id);
|
|
1155
1256
|
const domain = await resolveTokenDomain(chainId, tokenAddress, option.extra);
|
|
1156
1257
|
authorization = buildAuthorization(payer, option);
|
|
1157
|
-
const signature = await signAuthorization(wallet_id, chainId, tokenAddress, domain, authorization);
|
|
1258
|
+
const signature = await signAuthorization(wallet_id, chainId, tokenAddress, domain, authorization, approvalUsed);
|
|
1158
1259
|
const payload = buildPaymentPayload(x402Version, option, authorization, signature, paymentInfo.resource, paymentInfo.extensions);
|
|
1159
1260
|
const h = paymentHeaderFor(x402Version, payload);
|
|
1160
1261
|
headerName = h.name;
|
|
@@ -1211,7 +1312,9 @@ server.tool('pay_x402', 'Handle an x402 payment flow. Fetches a URL, and if the
|
|
|
1211
1312
|
payment_required: true,
|
|
1212
1313
|
payment_made: retryRes.status !== 402,
|
|
1213
1314
|
retry_error: retryError,
|
|
1214
|
-
payment_method: standardExact ? 'eip3009-authorization' : 'onchain-transfer',
|
|
1315
|
+
payment_method: isUpto ? 'permit2-upto-authorization' : (standardExact ? 'eip3009-authorization' : 'onchain-transfer'),
|
|
1316
|
+
approval_id: approvalUsed,
|
|
1317
|
+
permit2_approval_tx: permit2ApprovalTx,
|
|
1215
1318
|
x402_version: x402Version,
|
|
1216
1319
|
amount,
|
|
1217
1320
|
token: tokenLabel,
|
|
@@ -1222,11 +1325,35 @@ server.tool('pay_x402', 'Handle an x402 payment flow. Fetches a URL, and if the
|
|
|
1222
1325
|
payer,
|
|
1223
1326
|
tx_hash: txHash ?? settlement?.transaction ?? null,
|
|
1224
1327
|
settlement,
|
|
1225
|
-
authorization: authorization ? { nonce: authorization.nonce, valid_before: authorization.validBefore }
|
|
1328
|
+
authorization: authorization ? { nonce: authorization.nonce, valid_before: authorization.validBefore }
|
|
1329
|
+
: (uptoAuth ? { scheme: 'upto', max_amount: amount, nonce: uptoAuth.nonce, deadline: uptoAuth.deadline, facilitator: uptoAuth.witness.facilitator } : null),
|
|
1226
1330
|
description: option.description,
|
|
1227
1331
|
response: retryParsed,
|
|
1228
1332
|
});
|
|
1229
1333
|
});
|
|
1334
|
+
// ─── Tool: check_approval ───────────────────────────────────────
|
|
1335
|
+
server.tool('check_approval', 'Status of a payment approval created when pay_x402 exceeded the cap on a hosted wallet. ' +
|
|
1336
|
+
'Returns pending, approved, denied or expired. Once approved, call pay_x402 again with approval_id.', { approval_id: z.string().describe('Approval id returned by pay_x402') }, async ({ approval_id }) => jsonResponse(await api(`/approvals/${encodeURIComponent(approval_id)}`, 'GET', undefined, { 'X-AGW-SKIP-X402': 'true' })));
|
|
1337
|
+
// ─── Tool: approve_permit2 ──────────────────────────────────────
|
|
1338
|
+
server.tool('approve_permit2', 'One-time ERC-20 approval of a token to the Permit2 contract, needed before paying x402 "upto" endpoints with that token. ' +
|
|
1339
|
+
'A normal on-chain transaction (needs gas). Approves the maximum so it never has to be repeated; Permit2 itself only moves ' +
|
|
1340
|
+
'what each signed authorization allows.', {
|
|
1341
|
+
wallet_id: z.number().int().describe('Wallet ID'),
|
|
1342
|
+
token: z.string().regex(/^0x[a-fA-F0-9]{40}$/).describe('ERC-20 token address (e.g. USDC on Base)'),
|
|
1343
|
+
chain_id: z.number().int().describe('Chain ID'),
|
|
1344
|
+
}, async ({ wallet_id, token, chain_id }) => {
|
|
1345
|
+
if (isSolanaChain(chain_id))
|
|
1346
|
+
throw new Error('Permit2 is an EVM contract; there is nothing to approve on Solana.');
|
|
1347
|
+
const result = (await api(`/wallets/${wallet_id}/send`, 'POST', { to: token, value: '0', data: permit2ApproveCalldata(), chain_id }));
|
|
1348
|
+
return jsonResponse({ ...result, token, spender: PERMIT2_ADDRESS, amount: 'unlimited', note: 'Permit2 only transfers what a signed authorization allows; the allowance itself moves nothing.' });
|
|
1349
|
+
});
|
|
1350
|
+
// ─── Tool: check_token_risk ─────────────────────────────────────
|
|
1351
|
+
server.tool('check_token_risk', 'Assess an ERC-20 token before approving or swapping it: honeypot, taxes, owner powers, verified source, ' +
|
|
1352
|
+
'holder concentration, DEX liquidity. Uses GoPlus Security (free, no key) with an on-chain fallback. ' +
|
|
1353
|
+
'A warning for the caller to weigh, never a block.', {
|
|
1354
|
+
token: z.string().regex(/^0x[a-fA-F0-9]{40}$/).describe('ERC-20 token contract address'),
|
|
1355
|
+
chain_id: z.number().int().describe('Chain ID'),
|
|
1356
|
+
}, async ({ token, chain_id }) => jsonResponse({ token, chain_id, risk: await assessTokenRisk(chain_id, token, (to, data) => ethCallHex(chain_id, to, data)) }));
|
|
1230
1357
|
// ─── Tool: get_usage ─────────────────────────────────────────────
|
|
1231
1358
|
server.tool('get_usage', 'Get the current month\'s usage statistics. ' +
|
|
1232
1359
|
'Returns operations count, tier info, remaining quota, and fees.', {}, async () => {
|
package/build/local-wallet.d.ts
CHANGED
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
*/
|
|
17
17
|
import { type Address, type Hex } from 'viem';
|
|
18
18
|
import { type Eip3009Authorization } from './x402-eip3009.js';
|
|
19
|
+
import { type UptoPermit2Authorization } from './x402-permit2.js';
|
|
19
20
|
/** True when the server is running non-custodially. */
|
|
20
21
|
export declare function isLocalMode(): boolean;
|
|
21
22
|
export declare function getLocalAccount(): {
|
|
@@ -107,3 +108,9 @@ export declare function localSignAuthorization(chainId: number, asset: Address,
|
|
|
107
108
|
export declare function localEthCall(chainId: number, to: Address, data: Hex): Promise<{
|
|
108
109
|
result: string;
|
|
109
110
|
}>;
|
|
111
|
+
/** x402 "upto": sign a Permit2 max-authorization in-process. The maximum goes through the token cap like a transfer would. */
|
|
112
|
+
export declare function localSignPermit2Upto(chainId: number, auth: UptoPermit2Authorization): Promise<{
|
|
113
|
+
signature: `0x${string}`;
|
|
114
|
+
from: Address;
|
|
115
|
+
mode: 'local';
|
|
116
|
+
}>;
|
package/build/local-wallet.js
CHANGED
|
@@ -19,6 +19,7 @@ import { createPublicClient, createWalletClient, http, encodeFunctionData, parse
|
|
|
19
19
|
import { privateKeyToAccount } from 'viem/accounts';
|
|
20
20
|
import { lookupTrustedDecimals } from './x402-payment.js';
|
|
21
21
|
import { typedDataFor } from './x402-eip3009.js';
|
|
22
|
+
import { uptoTypedData } from './x402-permit2.js';
|
|
22
23
|
/* ── Key loading ─────────────────────────────────────────────────── */
|
|
23
24
|
/**
|
|
24
25
|
* Read the key once at startup. Kept module-private: nothing exports the key
|
|
@@ -335,3 +336,16 @@ export async function localEthCall(chainId, to, data) {
|
|
|
335
336
|
const r = await client.call({ to, data });
|
|
336
337
|
return { result: r.data ?? '0x' };
|
|
337
338
|
}
|
|
339
|
+
/** x402 "upto": sign a Permit2 max-authorization in-process. The maximum goes through the token cap like a transfer would. */
|
|
340
|
+
export async function localSignPermit2Upto(chainId, auth) {
|
|
341
|
+
const token = auth.permitted.token;
|
|
342
|
+
if (!/^0x[a-fA-F0-9]{40}$/.test(token))
|
|
343
|
+
throw new Error(`Local mode needs an EVM token address for x402 upto, got "${token}".`);
|
|
344
|
+
if (!/^\d+$/.test(auth.permitted.amount))
|
|
345
|
+
throw new Error(`x402 upto amount must be integer base units, got "${auth.permitted.amount}".`);
|
|
346
|
+
const calldata = ('0xa9059cbb' + auth.witness.to.slice(2).toLowerCase().padStart(64, '0') + BigInt(auth.permitted.amount).toString(16).padStart(64, '0'));
|
|
347
|
+
await assertWithinTokenCap(chainId, token, calldata);
|
|
348
|
+
const from = getLocalAddress();
|
|
349
|
+
const signature = await getLocalAccount().signTypedData(uptoTypedData(chainId, { ...auth, from }));
|
|
350
|
+
return { signature, from, mode: 'local' };
|
|
351
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export type RiskLevel = 'low' | 'medium' | 'high' | 'unknown';
|
|
2
|
+
export interface TokenRisk {
|
|
3
|
+
level: RiskLevel;
|
|
4
|
+
score: number;
|
|
5
|
+
flags: string[];
|
|
6
|
+
notes: string[];
|
|
7
|
+
source: 'goplus' | 'onchain' | 'none';
|
|
8
|
+
holder_count?: number;
|
|
9
|
+
top_holder_percent?: number;
|
|
10
|
+
buy_tax?: number;
|
|
11
|
+
sell_tax?: number;
|
|
12
|
+
}
|
|
13
|
+
/** Turn a GoPlus token_security record into a verdict. Exported so it can be unit-tested offline. */
|
|
14
|
+
export declare function assessGoPlus(r: Record<string, unknown>): TokenRisk;
|
|
15
|
+
/**
|
|
16
|
+
* Assess a token. `ethCall` runs a read-only call and returns the hex result
|
|
17
|
+
* (used for the on-chain fallback: does the contract answer name() and decimals()?).
|
|
18
|
+
*/
|
|
19
|
+
export declare function assessTokenRisk(chainId: number, token: string, ethCall?: (to: string, data: string) => Promise<string>): Promise<TokenRisk>;
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Asset risk before an approval or a swap. The wallet layer knows the amount;
|
|
3
|
+
* it did not know the asset. This asks GoPlus Security's free token_security
|
|
4
|
+
* endpoint (no key) and, when that is unavailable, falls back to what the
|
|
5
|
+
* chain itself can tell us. Always a WARNING, never a block: the caller
|
|
6
|
+
* decides. Requested in agentwallet-mcp issue #13.
|
|
7
|
+
*/
|
|
8
|
+
import { safeFetch } from './ssrf-guard.js';
|
|
9
|
+
const GOPLUS_CHAINS = new Set([1, 56, 137, 8453, 42161, 10, 43114, 324, 59144, 534352, 25, 250, 100, 1101, 5000, 81457, 204]);
|
|
10
|
+
function num(v) {
|
|
11
|
+
if (v === undefined || v === null || v === '')
|
|
12
|
+
return null;
|
|
13
|
+
const n = Number(v);
|
|
14
|
+
return Number.isFinite(n) ? n : null;
|
|
15
|
+
}
|
|
16
|
+
function flag(v) { return v === '1' || v === 1 || v === true; }
|
|
17
|
+
/** Turn a GoPlus token_security record into a verdict. Exported so it can be unit-tested offline. */
|
|
18
|
+
export function assessGoPlus(r) {
|
|
19
|
+
const flags = [];
|
|
20
|
+
const notes = [];
|
|
21
|
+
let score = 0;
|
|
22
|
+
const add = (cond, name, pts, note) => { if (cond) {
|
|
23
|
+
flags.push(name);
|
|
24
|
+
score += pts;
|
|
25
|
+
if (note)
|
|
26
|
+
notes.push(note);
|
|
27
|
+
} };
|
|
28
|
+
add(flag(r.is_honeypot), 'honeypot', 100, 'GoPlus marks this token as a honeypot: it can be bought but not sold.');
|
|
29
|
+
add(flag(r.fake_token), 'fake_token', 100, 'Flagged as an imitation of a known token.');
|
|
30
|
+
add(flag(r.is_airdrop_scam), 'airdrop_scam', 80);
|
|
31
|
+
add(flag(r.cannot_sell_all), 'cannot_sell_all', 60);
|
|
32
|
+
add(flag(r.transfer_pausable), 'transfer_pausable', 30, 'The owner can pause transfers.');
|
|
33
|
+
add(flag(r.is_blacklisted), 'blacklist', 30, 'The contract has a blacklist function.');
|
|
34
|
+
add(flag(r.owner_change_balance), 'owner_change_balance', 80, 'The owner can change any balance.');
|
|
35
|
+
add(flag(r.hidden_owner), 'hidden_owner', 40);
|
|
36
|
+
add(flag(r.selfdestruct), 'selfdestruct', 40);
|
|
37
|
+
add(flag(r.is_mintable), 'mintable', 15);
|
|
38
|
+
add(flag(r.slippage_modifiable), 'tax_modifiable', 25, 'Buy/sell tax can be changed by the owner.');
|
|
39
|
+
add(flag(r.personal_slippage_modifiable), 'personal_tax', 40, 'The owner can set a different tax for specific addresses.');
|
|
40
|
+
add(flag(r.trading_cooldown), 'trading_cooldown', 10);
|
|
41
|
+
add(flag(r.external_call), 'external_call', 15);
|
|
42
|
+
add(r.is_open_source === '0', 'closed_source', 35, 'Contract source is not verified.');
|
|
43
|
+
add(flag(r.is_proxy), 'proxy', 10, 'Upgradeable proxy: the logic can change.');
|
|
44
|
+
const buy = num(r.buy_tax), sell = num(r.sell_tax);
|
|
45
|
+
if (buy !== null && buy > 0.1) {
|
|
46
|
+
flags.push('buy_tax_high');
|
|
47
|
+
score += 30;
|
|
48
|
+
}
|
|
49
|
+
if (sell !== null && sell > 0.1) {
|
|
50
|
+
flags.push('sell_tax_high');
|
|
51
|
+
score += 40;
|
|
52
|
+
}
|
|
53
|
+
const holders = num(r.holder_count);
|
|
54
|
+
if (holders !== null && holders < 50) {
|
|
55
|
+
flags.push('few_holders');
|
|
56
|
+
score += 25;
|
|
57
|
+
}
|
|
58
|
+
let top = 0;
|
|
59
|
+
const list = Array.isArray(r.holders) ? r.holders : [];
|
|
60
|
+
for (const h of list) {
|
|
61
|
+
const p = num(h.percent);
|
|
62
|
+
if (p !== null && !flag(h.is_contract) && !flag(h.is_locked) && p > top)
|
|
63
|
+
top = p;
|
|
64
|
+
}
|
|
65
|
+
if (top > 0.5) {
|
|
66
|
+
flags.push('top_holder_over_50pct');
|
|
67
|
+
score += 40;
|
|
68
|
+
}
|
|
69
|
+
else if (top > 0.2) {
|
|
70
|
+
flags.push('top_holder_over_20pct');
|
|
71
|
+
score += 15;
|
|
72
|
+
}
|
|
73
|
+
if (r.is_in_dex === '0') {
|
|
74
|
+
flags.push('no_dex_liquidity');
|
|
75
|
+
score += 20;
|
|
76
|
+
}
|
|
77
|
+
if (flag(r.trust_list)) {
|
|
78
|
+
notes.push('On GoPlus trust list (well-known token).');
|
|
79
|
+
score = Math.min(score, 5);
|
|
80
|
+
}
|
|
81
|
+
score = Math.min(100, score);
|
|
82
|
+
const level = score >= 60 ? 'high' : score >= 25 ? 'medium' : 'low';
|
|
83
|
+
const out = { level, score, flags, notes, source: 'goplus' };
|
|
84
|
+
if (holders !== null)
|
|
85
|
+
out.holder_count = holders;
|
|
86
|
+
if (top)
|
|
87
|
+
out.top_holder_percent = Math.round(top * 10000) / 100;
|
|
88
|
+
if (buy !== null)
|
|
89
|
+
out.buy_tax = buy;
|
|
90
|
+
if (sell !== null)
|
|
91
|
+
out.sell_tax = sell;
|
|
92
|
+
return out;
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Assess a token. `ethCall` runs a read-only call and returns the hex result
|
|
96
|
+
* (used for the on-chain fallback: does the contract answer name() and decimals()?).
|
|
97
|
+
*/
|
|
98
|
+
export async function assessTokenRisk(chainId, token, ethCall) {
|
|
99
|
+
const addr = token.toLowerCase();
|
|
100
|
+
if (GOPLUS_CHAINS.has(chainId)) {
|
|
101
|
+
try {
|
|
102
|
+
const res = await safeFetch(`https://api.gopluslabs.io/api/v1/token_security/${chainId}?contract_addresses=${addr}`, {
|
|
103
|
+
method: 'GET', headers: { Accept: 'application/json' }, signal: AbortSignal.timeout(12_000),
|
|
104
|
+
});
|
|
105
|
+
const j = (await res.json());
|
|
106
|
+
const rec = j?.result?.[addr];
|
|
107
|
+
if (rec && Object.keys(rec).length > 0)
|
|
108
|
+
return assessGoPlus(rec);
|
|
109
|
+
}
|
|
110
|
+
catch { /* fall through to on-chain */ }
|
|
111
|
+
}
|
|
112
|
+
if (ethCall) {
|
|
113
|
+
try {
|
|
114
|
+
const name = await ethCall(token, '0x06fdde03');
|
|
115
|
+
const decimals = await ethCall(token, '0x313ce567');
|
|
116
|
+
const answers = Boolean(name && name !== '0x') && Boolean(decimals && decimals !== '0x');
|
|
117
|
+
return {
|
|
118
|
+
level: answers ? 'unknown' : 'high', score: answers ? 50 : 90,
|
|
119
|
+
flags: answers ? ['no_security_data'] : ['not_an_erc20'],
|
|
120
|
+
notes: [answers ? 'No security data available for this chain or token; only basic ERC-20 shape was confirmed.' : 'Contract did not answer name() and decimals(); it may not be an ERC-20 token at all.'],
|
|
121
|
+
source: 'onchain',
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
catch { /* fall through */ }
|
|
125
|
+
}
|
|
126
|
+
return { level: 'unknown', score: 50, flags: ['no_security_data'], notes: ['No risk data could be fetched.'], source: 'none' };
|
|
127
|
+
}
|
package/build/x402-eip3009.d.ts
CHANGED
|
@@ -140,6 +140,8 @@ export declare function knownTokenDomain(chainId: number, asset: string): {
|
|
|
140
140
|
export declare function decodeAbiString(hex: string): string;
|
|
141
141
|
/** Build the payment payload the resource server expects for this x402 version. */
|
|
142
142
|
export declare function buildPaymentPayload(x402Version: number, req: X402Requirement, auth: Eip3009Authorization, signature: string, resource?: X402PaymentRequired['resource'], extensions?: Record<string, unknown>): Record<string, unknown>;
|
|
143
|
+
/** Same envelope for any scheme payload (EIP-3009 authorization, Permit2 authorization, tx hash). */
|
|
144
|
+
export declare function buildPaymentPayloadRaw(x402Version: number, req: X402Requirement, payload: Record<string, unknown>, resource?: X402PaymentRequired['resource'], extensions?: Record<string, unknown>): Record<string, unknown>;
|
|
143
145
|
/** 16-128 chars of [A-Za-z0-9_-], the shape the payment-identifier extension accepts. */
|
|
144
146
|
export declare function generatePaymentId(): string;
|
|
145
147
|
/**
|
|
@@ -156,10 +158,11 @@ export declare function paymentHeaderFor(x402Version: number, payload: Record<st
|
|
|
156
158
|
export declare function parsePaymentRequired(getHeader: (name: string) => string | null | undefined, body: unknown): X402PaymentRequired | null;
|
|
157
159
|
export declare function parseSettlement(getHeader: (name: string) => string | null | undefined): X402Settlement | null;
|
|
158
160
|
/**
|
|
159
|
-
* Choose which accepts[] entry to pay. `exact`
|
|
160
|
-
*
|
|
161
|
-
*
|
|
162
|
-
*
|
|
161
|
+
* Choose which accepts[] entry to pay. `exact` (EIP-3009, gasless for the
|
|
162
|
+
* payer) is preferred. `upto` is accepted when it carries the facilitator
|
|
163
|
+
* address the Permit2 witness must be bound to; an upto option without it is
|
|
164
|
+
* refused rather than approximated with an upfront transfer (issue #6).
|
|
165
|
+
* Returns the reason when nothing is payable.
|
|
163
166
|
*/
|
|
164
167
|
export declare function pickOption(accepts: X402Requirement[], resolveChainId: (network: string) => number | null, preferChain?: number): {
|
|
165
168
|
option: X402Requirement | null;
|
package/build/x402-eip3009.js
CHANGED
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
* response header PAYMENT-RESPONSE
|
|
20
20
|
*/
|
|
21
21
|
import { randomBytes } from 'node:crypto';
|
|
22
|
+
import { isUptoPayable } from './x402-permit2.js';
|
|
22
23
|
export const TRANSFER_WITH_AUTHORIZATION_TYPES = {
|
|
23
24
|
TransferWithAuthorization: [
|
|
24
25
|
{ name: 'from', type: 'address' },
|
|
@@ -115,7 +116,10 @@ export function decodeAbiString(hex) {
|
|
|
115
116
|
}
|
|
116
117
|
/** Build the payment payload the resource server expects for this x402 version. */
|
|
117
118
|
export function buildPaymentPayload(x402Version, req, auth, signature, resource, extensions) {
|
|
118
|
-
|
|
119
|
+
return buildPaymentPayloadRaw(x402Version, req, { signature, authorization: auth }, resource, extensions);
|
|
120
|
+
}
|
|
121
|
+
/** Same envelope for any scheme payload (EIP-3009 authorization, Permit2 authorization, tx hash). */
|
|
122
|
+
export function buildPaymentPayloadRaw(x402Version, req, payload, resource, extensions) {
|
|
119
123
|
if (x402Version >= 2) {
|
|
120
124
|
const out = { x402Version, accepted: req, payload };
|
|
121
125
|
if (resource)
|
|
@@ -176,19 +180,23 @@ export function parseSettlement(getHeader) {
|
|
|
176
180
|
?? decodeBase64Json(getHeader('X-PAYMENT-RESPONSE') ?? getHeader('x-payment-response')));
|
|
177
181
|
}
|
|
178
182
|
/**
|
|
179
|
-
* Choose which accepts[] entry to pay. `exact`
|
|
180
|
-
*
|
|
181
|
-
*
|
|
182
|
-
*
|
|
183
|
+
* Choose which accepts[] entry to pay. `exact` (EIP-3009, gasless for the
|
|
184
|
+
* payer) is preferred. `upto` is accepted when it carries the facilitator
|
|
185
|
+
* address the Permit2 witness must be bound to; an upto option without it is
|
|
186
|
+
* refused rather than approximated with an upfront transfer (issue #6).
|
|
187
|
+
* Returns the reason when nothing is payable.
|
|
183
188
|
*/
|
|
184
189
|
export function pickOption(accepts, resolveChainId, preferChain) {
|
|
185
|
-
const
|
|
190
|
+
const onChain = accepts.filter(a => resolveChainId(a.network) !== null);
|
|
191
|
+
const exact = onChain.filter(a => a.scheme === 'exact');
|
|
192
|
+
const upto = onChain.filter(a => isUptoPayable(a));
|
|
193
|
+
const payable = [...exact, ...upto];
|
|
186
194
|
if (payable.length === 0) {
|
|
187
195
|
const schemes = Array.from(new Set(accepts.map(a => a.scheme))).join(', ') || 'none';
|
|
188
196
|
const reason = accepts.some(a => a.scheme === 'upto')
|
|
189
|
-
? 'This endpoint
|
|
190
|
-
'
|
|
191
|
-
'Ask the endpoint
|
|
197
|
+
? 'This endpoint offers the x402 "upto" scheme without an extra.facilitatorAddress (or without an asset), so the ' +
|
|
198
|
+
'Permit2 authorization cannot be bound to a facilitator. AgentWallet will not approximate it with an upfront transfer. ' +
|
|
199
|
+
'Ask the endpoint operator to publish facilitatorAddress, or an "exact" option.'
|
|
192
200
|
: `No payable option: schemes offered were ${schemes}.`;
|
|
193
201
|
return { option: null, chainId: null, reason };
|
|
194
202
|
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import type { X402Requirement } from './x402-eip3009.js';
|
|
2
|
+
export declare const PERMIT2_ADDRESS: "0x000000000022D473030F116dDEE9F6B43aC78BA3";
|
|
3
|
+
export declare const X402_UPTO_PERMIT2_PROXY: "0x4020A4f3b7b90ccA423B9fabCc0CE57C6C240002";
|
|
4
|
+
/** EIP-712 types. Referenced types must follow the primary type in alphabetical order (TokenPermissions, Witness). */
|
|
5
|
+
export declare const UPTO_PERMIT2_WITNESS_TYPES: {
|
|
6
|
+
readonly PermitWitnessTransferFrom: readonly [{
|
|
7
|
+
readonly name: "permitted";
|
|
8
|
+
readonly type: "TokenPermissions";
|
|
9
|
+
}, {
|
|
10
|
+
readonly name: "spender";
|
|
11
|
+
readonly type: "address";
|
|
12
|
+
}, {
|
|
13
|
+
readonly name: "nonce";
|
|
14
|
+
readonly type: "uint256";
|
|
15
|
+
}, {
|
|
16
|
+
readonly name: "deadline";
|
|
17
|
+
readonly type: "uint256";
|
|
18
|
+
}, {
|
|
19
|
+
readonly name: "witness";
|
|
20
|
+
readonly type: "Witness";
|
|
21
|
+
}];
|
|
22
|
+
readonly TokenPermissions: readonly [{
|
|
23
|
+
readonly name: "token";
|
|
24
|
+
readonly type: "address";
|
|
25
|
+
}, {
|
|
26
|
+
readonly name: "amount";
|
|
27
|
+
readonly type: "uint256";
|
|
28
|
+
}];
|
|
29
|
+
readonly Witness: readonly [{
|
|
30
|
+
readonly name: "to";
|
|
31
|
+
readonly type: "address";
|
|
32
|
+
}, {
|
|
33
|
+
readonly name: "facilitator";
|
|
34
|
+
readonly type: "address";
|
|
35
|
+
}, {
|
|
36
|
+
readonly name: "validAfter";
|
|
37
|
+
readonly type: "uint256";
|
|
38
|
+
}];
|
|
39
|
+
};
|
|
40
|
+
export interface UptoPermit2Authorization {
|
|
41
|
+
from: `0x${string}`;
|
|
42
|
+
permitted: {
|
|
43
|
+
token: `0x${string}`;
|
|
44
|
+
amount: string;
|
|
45
|
+
};
|
|
46
|
+
spender: `0x${string}`;
|
|
47
|
+
nonce: string;
|
|
48
|
+
deadline: string;
|
|
49
|
+
witness: {
|
|
50
|
+
to: `0x${string}`;
|
|
51
|
+
facilitator: `0x${string}`;
|
|
52
|
+
validAfter: string;
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
/** Permit2 nonces are uint256; 32 random bytes as a decimal string. */
|
|
56
|
+
export declare function createPermit2Nonce(): string;
|
|
57
|
+
export declare function facilitatorAddressOf(req: X402Requirement): `0x${string}` | null;
|
|
58
|
+
/** True when this upto option carries everything the client needs to sign it. */
|
|
59
|
+
export declare function isUptoPayable(req: X402Requirement): boolean;
|
|
60
|
+
export declare function buildUptoAuthorization(from: string, req: X402Requirement, nowSeconds?: number, nonce?: string): UptoPermit2Authorization;
|
|
61
|
+
export declare function uptoTypedData(chainId: number, auth: UptoPermit2Authorization): {
|
|
62
|
+
domain: {
|
|
63
|
+
name: string;
|
|
64
|
+
chainId: number;
|
|
65
|
+
verifyingContract: "0x000000000022D473030F116dDEE9F6B43aC78BA3";
|
|
66
|
+
};
|
|
67
|
+
types: {
|
|
68
|
+
readonly PermitWitnessTransferFrom: readonly [{
|
|
69
|
+
readonly name: "permitted";
|
|
70
|
+
readonly type: "TokenPermissions";
|
|
71
|
+
}, {
|
|
72
|
+
readonly name: "spender";
|
|
73
|
+
readonly type: "address";
|
|
74
|
+
}, {
|
|
75
|
+
readonly name: "nonce";
|
|
76
|
+
readonly type: "uint256";
|
|
77
|
+
}, {
|
|
78
|
+
readonly name: "deadline";
|
|
79
|
+
readonly type: "uint256";
|
|
80
|
+
}, {
|
|
81
|
+
readonly name: "witness";
|
|
82
|
+
readonly type: "Witness";
|
|
83
|
+
}];
|
|
84
|
+
readonly TokenPermissions: readonly [{
|
|
85
|
+
readonly name: "token";
|
|
86
|
+
readonly type: "address";
|
|
87
|
+
}, {
|
|
88
|
+
readonly name: "amount";
|
|
89
|
+
readonly type: "uint256";
|
|
90
|
+
}];
|
|
91
|
+
readonly Witness: readonly [{
|
|
92
|
+
readonly name: "to";
|
|
93
|
+
readonly type: "address";
|
|
94
|
+
}, {
|
|
95
|
+
readonly name: "facilitator";
|
|
96
|
+
readonly type: "address";
|
|
97
|
+
}, {
|
|
98
|
+
readonly name: "validAfter";
|
|
99
|
+
readonly type: "uint256";
|
|
100
|
+
}];
|
|
101
|
+
};
|
|
102
|
+
primaryType: "PermitWitnessTransferFrom";
|
|
103
|
+
message: {
|
|
104
|
+
permitted: {
|
|
105
|
+
token: `0x${string}`;
|
|
106
|
+
amount: bigint;
|
|
107
|
+
};
|
|
108
|
+
spender: `0x${string}`;
|
|
109
|
+
nonce: bigint;
|
|
110
|
+
deadline: bigint;
|
|
111
|
+
witness: {
|
|
112
|
+
to: `0x${string}`;
|
|
113
|
+
facilitator: `0x${string}`;
|
|
114
|
+
validAfter: bigint;
|
|
115
|
+
};
|
|
116
|
+
};
|
|
117
|
+
};
|
|
118
|
+
/** The payload object that goes under `payload` in the x402 payment. */
|
|
119
|
+
export declare function uptoPayload(auth: UptoPermit2Authorization, signature: string): Record<string, unknown>;
|
|
120
|
+
/** allowance(owner, Permit2) calldata. */
|
|
121
|
+
export declare function permit2AllowanceCalldata(owner: string): `0x${string}`;
|
|
122
|
+
/** approve(Permit2, max) calldata: the one-time step upto needs. */
|
|
123
|
+
export declare function permit2ApproveCalldata(): `0x${string}`;
|
|
124
|
+
export declare function decodeUint(hex: string): bigint;
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* x402 "upto" scheme on EVM: a Permit2 PermitWitnessTransferFrom that authorizes
|
|
3
|
+
* a MAXIMUM, which the seller settles at or below actual usage through the
|
|
4
|
+
* x402 upto proxy. Only the facilitator named in the witness can settle it.
|
|
5
|
+
*
|
|
6
|
+
* Needs a one-time ERC-20 approval of the token to the Permit2 contract from
|
|
7
|
+
* the payer (costs gas once per token per chain); see permit2AllowanceCalldata
|
|
8
|
+
* and permit2ApproveCalldata. Reference: coinbase/x402 mechanisms/evm/upto.
|
|
9
|
+
*/
|
|
10
|
+
import { randomBytes } from 'node:crypto';
|
|
11
|
+
export const PERMIT2_ADDRESS = '0x000000000022D473030F116dDEE9F6B43aC78BA3';
|
|
12
|
+
export const X402_UPTO_PERMIT2_PROXY = '0x4020A4f3b7b90ccA423B9fabCc0CE57C6C240002';
|
|
13
|
+
/** EIP-712 types. Referenced types must follow the primary type in alphabetical order (TokenPermissions, Witness). */
|
|
14
|
+
export const UPTO_PERMIT2_WITNESS_TYPES = {
|
|
15
|
+
PermitWitnessTransferFrom: [
|
|
16
|
+
{ name: 'permitted', type: 'TokenPermissions' },
|
|
17
|
+
{ name: 'spender', type: 'address' },
|
|
18
|
+
{ name: 'nonce', type: 'uint256' },
|
|
19
|
+
{ name: 'deadline', type: 'uint256' },
|
|
20
|
+
{ name: 'witness', type: 'Witness' },
|
|
21
|
+
],
|
|
22
|
+
TokenPermissions: [
|
|
23
|
+
{ name: 'token', type: 'address' },
|
|
24
|
+
{ name: 'amount', type: 'uint256' },
|
|
25
|
+
],
|
|
26
|
+
Witness: [
|
|
27
|
+
{ name: 'to', type: 'address' },
|
|
28
|
+
{ name: 'facilitator', type: 'address' },
|
|
29
|
+
{ name: 'validAfter', type: 'uint256' },
|
|
30
|
+
],
|
|
31
|
+
};
|
|
32
|
+
/** Permit2 nonces are uint256; 32 random bytes as a decimal string. */
|
|
33
|
+
export function createPermit2Nonce() {
|
|
34
|
+
return BigInt('0x' + randomBytes(32).toString('hex')).toString();
|
|
35
|
+
}
|
|
36
|
+
export function facilitatorAddressOf(req) {
|
|
37
|
+
const f = req.extra?.facilitatorAddress;
|
|
38
|
+
return typeof f === 'string' && /^0x[0-9a-fA-F]{40}$/.test(f) ? f : null;
|
|
39
|
+
}
|
|
40
|
+
/** True when this upto option carries everything the client needs to sign it. */
|
|
41
|
+
export function isUptoPayable(req) {
|
|
42
|
+
return req.scheme === 'upto' && Boolean(facilitatorAddressOf(req)) && /^0x[0-9a-fA-F]{40}$/.test(String(req.asset || ''));
|
|
43
|
+
}
|
|
44
|
+
export function buildUptoAuthorization(from, req, nowSeconds = Math.floor(Date.now() / 1000), nonce = createPermit2Nonce()) {
|
|
45
|
+
const facilitator = facilitatorAddressOf(req);
|
|
46
|
+
if (!facilitator)
|
|
47
|
+
throw new Error('x402 upto: the payment requirements carry no extra.facilitatorAddress, so the authorization cannot be bound to a facilitator.');
|
|
48
|
+
const amount = String(req.amount ?? req.maxAmountRequired ?? '');
|
|
49
|
+
if (!/^\d+$/.test(amount))
|
|
50
|
+
throw new Error(`x402 upto: invalid amount "${amount}" (expected integer base units).`);
|
|
51
|
+
if (!/^0x[0-9a-fA-F]{40}$/.test(req.payTo))
|
|
52
|
+
throw new Error(`x402 upto: payTo is not an EVM address: "${req.payTo}".`);
|
|
53
|
+
const timeout = Number.isFinite(req.maxTimeoutSeconds) && req.maxTimeoutSeconds > 0 ? Math.floor(req.maxTimeoutSeconds) : 300;
|
|
54
|
+
return {
|
|
55
|
+
from: from,
|
|
56
|
+
permitted: { token: req.asset, amount },
|
|
57
|
+
spender: X402_UPTO_PERMIT2_PROXY,
|
|
58
|
+
nonce,
|
|
59
|
+
deadline: String(nowSeconds + timeout),
|
|
60
|
+
witness: { to: req.payTo, facilitator, validAfter: String(nowSeconds - 600) },
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
export function uptoTypedData(chainId, auth) {
|
|
64
|
+
return {
|
|
65
|
+
domain: { name: 'Permit2', chainId, verifyingContract: PERMIT2_ADDRESS },
|
|
66
|
+
types: UPTO_PERMIT2_WITNESS_TYPES,
|
|
67
|
+
primaryType: 'PermitWitnessTransferFrom',
|
|
68
|
+
message: {
|
|
69
|
+
permitted: { token: auth.permitted.token, amount: BigInt(auth.permitted.amount) },
|
|
70
|
+
spender: auth.spender,
|
|
71
|
+
nonce: BigInt(auth.nonce),
|
|
72
|
+
deadline: BigInt(auth.deadline),
|
|
73
|
+
witness: { to: auth.witness.to, facilitator: auth.witness.facilitator, validAfter: BigInt(auth.witness.validAfter) },
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
/** The payload object that goes under `payload` in the x402 payment. */
|
|
78
|
+
export function uptoPayload(auth, signature) {
|
|
79
|
+
return { signature, permit2Authorization: auth };
|
|
80
|
+
}
|
|
81
|
+
function pad(addr) { return addr.replace(/^0x/, '').toLowerCase().padStart(64, '0'); }
|
|
82
|
+
/** allowance(owner, Permit2) calldata. */
|
|
83
|
+
export function permit2AllowanceCalldata(owner) {
|
|
84
|
+
return ('0xdd62ed3e' + pad(owner) + pad(PERMIT2_ADDRESS));
|
|
85
|
+
}
|
|
86
|
+
/** approve(Permit2, max) calldata: the one-time step upto needs. */
|
|
87
|
+
export function permit2ApproveCalldata() {
|
|
88
|
+
return ('0x095ea7b3' + pad(PERMIT2_ADDRESS) + 'f'.repeat(64));
|
|
89
|
+
}
|
|
90
|
+
export function decodeUint(hex) {
|
|
91
|
+
const h = (hex || '').replace(/^0x/, '');
|
|
92
|
+
return h && /^[0-9a-fA-F]+$/.test(h) ? BigInt('0x' + h) : 0n;
|
|
93
|
+
}
|
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.12.1",
|
|
5
5
|
"description": "MCP server for AgentWallet: self-custody local signing on EVM and Solana, or hosted wallets. Sign, broadcast, pay x402 invoices, and accept x402 payments.",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"main": "build/index.js",
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
"scripts": {
|
|
17
17
|
"build": "tsc",
|
|
18
18
|
"start": "node build/index.js",
|
|
19
|
-
"test": "node test/x402-payment.test.mjs && node test/eip3009.test.mjs && node test/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/permit2-risk.test.mjs && node test/token-cap.test.mjs && node test/spl-cap.test.mjs && node test/unknown-decimals-cap.test.mjs && node test/local-mode.test.mjs && node test/ssrf-guard.test.mjs",
|
|
20
20
|
"prepublishOnly": "npm run build"
|
|
21
21
|
},
|
|
22
22
|
"keywords": [
|