agentwallet-mcp 1.10.9 → 1.12.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 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 automatically (fetch, pay, retry) |
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 |
@@ -219,15 +222,15 @@ All on-chain. All through MCP. No frontend needed.
219
222
 
220
223
  ## x402 Payments
221
224
 
222
- AgentWallet natively supports the [x402 open payment standard](https://x402.org). When your Ai agent encounters an API that returns HTTP 402 Payment Required, the `pay_x402` tool handles the entire flow automatically:
225
+ 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
226
 
224
- 1. Fetches the URL and detects the 402 response
225
- 2. Parses the payment requirements (amount, token, chain)
226
- 3. Executes the on-chain payment from your wallet
227
- 4. Retries the request with proof of payment
228
- 5. Returns the final response
227
+ 1. Fetches the URL and reads the payment requirements, from the v2 `PAYMENT-REQUIRED` header or the v1 JSON body.
228
+ 2. Picks an `exact` option (you can steer it with `prefer_chain`).
229
+ 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.
230
+ 4. Retries the request with the payment header (`PAYMENT-SIGNATURE` for v2, `X-PAYMENT` for v1).
231
+ 5. Returns the response, the settlement receipt (`PAYMENT-RESPONSE` / `X-PAYMENT-RESPONSE`) and, if the endpoint declined, its stated reason in `retry_error`.
229
232
 
230
- **Set `max_payment` to control spending:**
233
+ 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
234
 
232
235
  ```
233
236
  pay_x402(
@@ -237,9 +240,24 @@ pay_x402(
237
240
  )
238
241
  ```
239
242
 
240
- `max_payment` is enforced as a hard per-payment cap. If you omit it, `pay_x402` falls back to `AGENTWALLET_MAX_AUTOPAY` (default `1`), the same cap the auto-pay path uses, so a malicious or compromised 402 endpoint can never authorize an unbounded payment. Set `max_payment` explicitly (or raise `AGENTWALLET_MAX_AUTOPAY`) to allow a larger single payment.
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
- Supports ERC-20 tokens, SPL tokens, and native tokens on EVM and Solana. Compatible with x402 V1 and V2 (CAIP-2 chain identifiers), and reads the token address from the standard x402 `asset` field (falling back to `extra.token`).
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
+
259
+
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.
243
261
 
244
262
  ## x402 Acceptance
245
263
 
package/build/index.js CHANGED
@@ -15,6 +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, 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';
18
22
  import { isSolanaLocalMode, getSolanaAddress, solanaWalletRecord, localSolBalance, localSplBalance, localSolTransfer, localSplTransfer, resolveSolanaRpc, } from './local-solana.js';
19
23
  /** True when either chain family is running non-custodially. */
20
24
  function anyLocalMode() {
@@ -90,6 +94,40 @@ async function routeLocally(path, method, body) {
90
94
  const hasData = data && data !== '0x';
91
95
  return localSend(chainId, to, value, hasData ? data : undefined);
92
96
  }
97
+ // POST /wallets/{id}/x402/authorize (x402 "exact": EIP-3009 authorization, signed here, broadcast by nobody)
98
+ if (method === 'POST' && /^\/wallets\/[^/]*\/x402\/authorize$/.test(rawPath)) {
99
+ if (!isLocalMode())
100
+ refuseMissingLocalKey('evm');
101
+ const chainId = localChainId(body, qs);
102
+ return localSignAuthorization(chainId, String(body?.asset || ''), { name: String(body?.name || ''), version: String(body?.version || '') }, {
103
+ from: getLocalAddress(),
104
+ to: String(body?.to || ''),
105
+ value: String(body?.value ?? '0'),
106
+ validAfter: String(body?.valid_after ?? '0'),
107
+ validBefore: String(body?.valid_before ?? '0'),
108
+ nonce: String(body?.nonce || ''),
109
+ });
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
+ }
125
+ // POST /eth-call (read-only; answered from the local RPC so pure self-custody needs no hosted credentials)
126
+ if (method === 'POST' && rawPath === '/eth-call') {
127
+ if (!isLocalMode())
128
+ return NOT_HANDLED;
129
+ return localEthCall(localChainId(body, qs), String(body?.to || ''), String(body?.data || '0x'));
130
+ }
93
131
  // GET /wallets/{id}/balance
94
132
  if (method === 'GET' && /^\/wallets\/[^/]*\/balance$/.test(rawPath)) {
95
133
  const chainId = localChainId(body, qs);
@@ -423,7 +461,7 @@ function resolveChainId(network) {
423
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');
424
462
  const server = new McpServer({
425
463
  name: 'agentwallet',
426
- version: '1.10.9',
464
+ version: '1.12.0',
427
465
  }, {
428
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.
429
467
 
@@ -765,6 +803,17 @@ server.tool('approve_token', 'Approve a spender contract to transfer ERC-20 toke
765
803
  rawAmount = parseUnits(amount, decimals);
766
804
  }
767
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
+ }
768
817
  const result = await api(`/wallets/${wallet_id}/send`, 'POST', {
769
818
  to: token,
770
819
  value: '0',
@@ -776,6 +825,7 @@ server.tool('approve_token', 'Approve a spender contract to transfer ERC-20 toke
776
825
  token,
777
826
  spender,
778
827
  amount: amount.toLowerCase() === 'max' ? 'unlimited' : amount,
828
+ risk,
779
829
  });
780
830
  });
781
831
  // ─── Tool: get_allowance ────────────────────────────────────────
@@ -935,11 +985,76 @@ server.tool('get_token_info', 'Get the name, symbol, and decimals of any ERC-20
935
985
  decimals,
936
986
  });
937
987
  });
988
+ // ─── x402 "exact" helpers ────────────────────────────────────────
989
+ /** The paying address: the local key in self-custody mode, else the hosted wallet record. */
990
+ async function payerAddress(walletId) {
991
+ const w = (await api(`/wallets/${walletId}`, 'GET', undefined, { 'X-AGW-SKIP-X402': 'true' }));
992
+ const a = String(w?.address || w?.wallet_address || '');
993
+ if (!/^0x[0-9a-fA-F]{40}$/.test(a))
994
+ throw new Error(`x402: could not resolve the EVM address of wallet ${walletId} (got "${a}").`);
995
+ return a;
996
+ }
997
+ async function ethCallHex(chainId, to, data) {
998
+ const r = (await api('/eth-call', 'POST', { chain_id: chainId, to, data }, { 'X-AGW-SKIP-X402': 'true' }));
999
+ return String(r?.result || '');
1000
+ }
1001
+ /**
1002
+ * The token's EIP-712 domain. Order: what the 402 declared (extra.name/version,
1003
+ * which is what the reference client requires), then a short list of USDC
1004
+ * deployments we know, then the contract's own name()/version(). A wrong
1005
+ * domain produces a signature that recovers to a stranger, so we refuse
1006
+ * rather than guess.
1007
+ */
1008
+ async function resolveTokenDomain(chainId, asset, extra) {
1009
+ if (extra?.name && extra?.version)
1010
+ return { name: String(extra.name), version: String(extra.version) };
1011
+ const known = knownTokenDomain(chainId, asset);
1012
+ if (known)
1013
+ return known;
1014
+ let name = '', version = '';
1015
+ try {
1016
+ name = decodeAbiString(await ethCallHex(chainId, asset, '0x06fdde03')); // name()
1017
+ version = decodeAbiString(await ethCallHex(chainId, asset, '0x54fd4d50')); // version()
1018
+ }
1019
+ catch { /* refused below */ }
1020
+ if (!name || !version) {
1021
+ throw new Error(`x402: cannot determine the EIP-712 domain for token ${asset} on chain ${chainId}: the endpoint sent no ` +
1022
+ `extra.name/extra.version and the contract did not answer name()/version(). Refused rather than signed with a guessed domain.`);
1023
+ }
1024
+ return { name, version };
1025
+ }
1026
+ /** Sign the authorization with whichever custody mode is active (local key, or the hosted signer). */
1027
+ async function signAuthorization(walletId, chainId, asset, domain, auth, approvalId) {
1028
+ const r = (await api(`/wallets/${walletId}/x402/authorize`, 'POST', {
1029
+ chain_id: chainId, asset, name: domain.name, version: domain.version,
1030
+ to: auth.to, value: auth.value, valid_after: auth.validAfter, valid_before: auth.validBefore, nonce: auth.nonce,
1031
+ ...(approvalId ? { approval_id: approvalId } : {}),
1032
+ }, { 'X-AGW-SKIP-X402': 'true' }));
1033
+ const sig = String(r?.signature || '');
1034
+ if (!/^0x[0-9a-fA-F]{130}$/.test(sig)) {
1035
+ throw new Error(`x402: the wallet did not return a valid authorization signature${r?.error ? ` (${r.error})` : ''}.`);
1036
+ }
1037
+ return sig;
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
+ }
938
1051
  // ─── Tool: pay_x402 ─────────────────────────────────────────────
939
1052
  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, executes the on-chain payment, and retries the request with ' +
941
- 'proof of payment. Returns the final response. Supports the x402 open payment standard ' +
942
- '(https://x402.org). Set max_payment to prevent overspending.', {
1053
+ 'parses the payment requirements (v1 body or v2 PAYMENT-REQUIRED header), signs an EIP-3009 ' +
1054
+ 'TransferWithAuthorization for the "exact" scheme (no gas, nothing broadcast by the payer), and retries ' +
1055
+ 'the request with the payment header. AgentWallet paywalls are paid by on-chain transfer instead. ' +
1056
+ 'The "upto" scheme is refused, never approximated. Returns the final response and the settlement ' +
1057
+ 'receipt. Supports the x402 open payment standard (https://x402.org). Set max_payment to cap spend.', {
943
1058
  url: z.string().url().describe('The URL to access (will handle 402 payment if required)'),
944
1059
  wallet_id: z.number().int().describe('Wallet ID to pay from'),
945
1060
  method: z.string().default('GET').describe('HTTP method (GET, POST, PUT, DELETE)'),
@@ -954,7 +1069,10 @@ server.tool('pay_x402', 'Handle an x402 payment flow. Fetches a URL, and if the
954
1069
  'to allow a larger single payment.'),
955
1070
  prefer_chain: z.number().int().optional().describe('Preferred chain ID if the server accepts payment on multiple chains ' +
956
1071
  '(e.g. 8453 for Base, 1 for Ethereum)'),
957
- }, async ({ url, wallet_id, method, headers: headersJson, body: reqBody, max_payment, prefer_chain }) => {
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 }) => {
958
1076
  // Build request headers
959
1077
  const reqHeaders = { Accept: 'application/json' };
960
1078
  if (headersJson) {
@@ -999,125 +1117,171 @@ server.tool('pay_x402', 'Handle an x402 payment flow. Fetches a URL, and if the
999
1117
  response: parsed,
1000
1118
  });
1001
1119
  }
1002
- // Step 2: Parse x402 payment requirements from 402 response
1003
- let paymentInfo;
1120
+ // Step 2: Requirements come from the PAYMENT-REQUIRED header (v2) or the JSON body (v1).
1121
+ const initialText = await initialRes.text();
1122
+ let initialBody = null;
1004
1123
  try {
1005
- paymentInfo = await initialRes.json();
1124
+ initialBody = JSON.parse(initialText);
1006
1125
  }
1007
1126
  catch {
1008
- throw new Error('402 response body is not valid JSON. This server may not support x402.');
1009
- }
1010
- if (!paymentInfo.accepts || paymentInfo.accepts.length === 0) {
1011
- throw new Error('402 response has no payment options in "accepts" array.');
1012
- }
1013
- const options = paymentInfo.accepts;
1014
- let option;
1015
- if (prefer_chain) {
1016
- option = options.find(a => resolveChainId(a.network) === prefer_chain) || options[0];
1017
- }
1018
- else {
1019
- option = options[0];
1020
- }
1021
- // Resolve the chain
1022
- const chainId = resolveChainId(option.network);
1023
- if (!chainId) {
1024
- throw new Error(`Unsupported x402 network: "${option.network}". ` +
1025
- `Supported: ${Object.keys(X402_NETWORKS).join(', ')}, or any CAIP-2 / numeric chain ID.`);
1026
- }
1027
- // Decimals come from the registry or the token contract, NEVER from the 402
1028
- // body. The endpoint controls requiredDecimals, so trusting it lets a
1029
- // hostile server inflate the cap AND misreport the amount shown to the
1030
- // agent. The declared value is only used to detect a mismatch and refuse.
1031
- const assetForDecimals = option.asset || option.extra?.token || '';
1032
- const trustedDecimals = assetForDecimals
1033
- ? await resolveTrustedDecimals(chainId, assetForDecimals)
1034
- : nativeDecimals(chainId); // 18 on EVM (wei), 9 on Solana (lamports)
1035
- assertDeclaredDecimals(option.requiredDecimals, trustedDecimals);
1036
- // Human-readable amount, computed with trusted decimals so what the agent
1037
- // is told matches what will actually leave the wallet.
1038
- const amount = formatUnits(option.maxAmountRequired, trustedDecimals);
1039
- // Step 4: Enforce a hard per-payment cap. ALWAYS applied — if the caller
1040
- // omits max_payment we fall back to AGENTWALLET_MAX_AUTOPAY (default "1"),
1041
- // mirroring the internal auto-pay path (deriveX402Payment). Without this, an
1042
- // untrusted or compromised x402 endpoint could return an arbitrarily large
1043
- // maxAmountRequired and drain the wallet. The cap is never silently skipped.
1044
- const capSource = max_payment ? 'max_payment' : 'AGENTWALLET_MAX_AUTOPAY';
1045
- const effectiveMax = max_payment || process.env.AGENTWALLET_MAX_AUTOPAY || '1';
1046
- if (!isWithinCap(option.maxAmountRequired, trustedDecimals, effectiveMax)) {
1127
+ initialBody = null;
1128
+ }
1129
+ const paymentInfo = parsePaymentRequired(n => initialRes.headers.get(n), initialBody);
1130
+ if (!paymentInfo || !Array.isArray(paymentInfo.accepts) || paymentInfo.accepts.length === 0) {
1131
+ throw new Error('402 response carried no x402 payment requirements (no PAYMENT-REQUIRED header and no "accepts" array in the body). ' +
1132
+ 'This server may not support x402.');
1133
+ }
1134
+ const x402Version = Number(paymentInfo.x402Version) || 1;
1135
+ const accepts = paymentInfo.accepts;
1136
+ // Step 3: Pick an option this client can sign: exact (EIP-3009) first, then a signable upto (Permit2).
1137
+ const picked = pickOption(accepts, resolveChainId, prefer_chain);
1138
+ if (!picked.option || !picked.chainId) {
1047
1139
  return jsonResponse({
1048
1140
  status: 402,
1049
1141
  payment_required: true,
1050
1142
  payment_made: false,
1051
- error: `Payment of ${amount} ${option.extra?.name || 'tokens'} exceeds the ` +
1052
- `${effectiveMax} cap (from ${capSource}).` +
1053
- (max_payment ? '' : ' No max_payment was provided, so the default cap was applied. ' +
1054
- 'Pass max_payment (or raise AGENTWALLET_MAX_AUTOPAY) to allow a larger payment.'),
1055
- required_amount: amount,
1056
- max_allowed: effectiveMax,
1057
- cap_source: capSource,
1058
- token: option.extra?.name || 'native',
1059
- network: option.network,
1060
- chain_id: chainId,
1061
- pay_to: option.payTo,
1062
- description: option.description,
1143
+ error: picked.reason,
1144
+ offered: accepts.map(a => ({ scheme: a.scheme, network: a.network, amount: requiredAmount(a), asset: a.asset ?? a.extra?.token ?? null })),
1063
1145
  });
1064
1146
  }
1065
- // Standard x402 puts the token address in `asset`; AgentWallet's own server
1066
- // currently emits it under extra.token. Prefer the standard field, fall back.
1147
+ const option = picked.option;
1148
+ const chainId = picked.chainId;
1149
+ const isUpto = option.scheme === 'upto';
1150
+ const rawAmount = requiredAmount(option);
1151
+ if (!/^\d+$/.test(rawAmount)) {
1152
+ throw new Error(`x402: invalid amount "${rawAmount}" in the payment requirements (expected integer base units).`);
1153
+ }
1154
+ // Decimals come from the registry or the token contract, NEVER from the 402
1155
+ // body. The endpoint controls what it declares, so trusting it would let a
1156
+ // hostile server inflate the cap and misreport the amount shown to the agent.
1067
1157
  const tokenAddress = option.asset || option.extra?.token || '';
1068
- // Step 5: Execute payment
1069
- let txResult;
1070
- if (isSolanaChain(chainId)) {
1071
- // Solana payment
1072
- if (tokenAddress) {
1073
- // SPL token payment (e.g. USDC on Solana)
1074
- txResult = (await api(`/wallets/${wallet_id}/send`, 'POST', {
1075
- to: option.payTo,
1076
- value: option.maxAmountRequired,
1077
- token_mint: tokenAddress,
1078
- token_decimals: trustedDecimals,
1079
- chain_id: chainId,
1080
- }));
1158
+ const trustedDecimals = tokenAddress
1159
+ ? await resolveTrustedDecimals(chainId, tokenAddress)
1160
+ : nativeDecimals(chainId); // 18 on EVM (wei), 9 on Solana (lamports)
1161
+ if (typeof option.requiredDecimals === 'number')
1162
+ assertDeclaredDecimals(option.requiredDecimals, trustedDecimals);
1163
+ const amount = formatUnits(rawAmount, trustedDecimals);
1164
+ const tokenLabel = option.extra?.name || (tokenAddress ? 'tokens' : 'native');
1165
+ const skip = { 'X-AGW-SKIP-X402': 'true' };
1166
+ // Step 4: Hard per-payment cap, ALWAYS applied. If the caller omits max_payment
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.
1171
+ const capSource = max_payment ? 'max_payment' : 'AGENTWALLET_MAX_AUTOPAY';
1172
+ const effectiveMax = max_payment || process.env.AGENTWALLET_MAX_AUTOPAY || '1';
1173
+ let approvalUsed = null;
1174
+ if (!isWithinCap(rawAmount, trustedDecimals, effectiveMax)) {
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') {
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
+ });
1081
1204
  }
1082
1205
  else {
1083
- // Native SOL payment
1084
- txResult = (await api(`/wallets/${wallet_id}/send`, 'POST', {
1085
- to: option.payTo,
1086
- value: option.maxAmountRequired,
1087
- chain_id: chainId,
1088
- }));
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
+ });
1089
1213
  }
1090
1214
  }
1091
- else if (tokenAddress) {
1092
- // EVM ERC-20 token payment (e.g. USDC)
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
- }));
1215
+ // Step 5: Pay. Standard x402 "exact" on EVM is an EIP-3009 authorization the
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
1218
+ // own paywalls (verified by receipt), native-asset requests and Solana still use
1219
+ // a broadcast transfer proved by hash.
1220
+ const standardExact = !isUpto && !isSolanaChain(chainId) && Boolean(tokenAddress) && !isLegacyAgentWalletAccept(option);
1221
+ let headerName = 'X-PAYMENT';
1222
+ let paymentHeader = '';
1223
+ let txHash = null;
1224
+ let authorization = null;
1225
+ let uptoAuth = null;
1226
+ let payer = null;
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) {
1255
+ payer = await payerAddress(wallet_id);
1256
+ const domain = await resolveTokenDomain(chainId, tokenAddress, option.extra);
1257
+ authorization = buildAuthorization(payer, option);
1258
+ const signature = await signAuthorization(wallet_id, chainId, tokenAddress, domain, authorization, approvalUsed);
1259
+ const payload = buildPaymentPayload(x402Version, option, authorization, signature, paymentInfo.resource, paymentInfo.extensions);
1260
+ const h = paymentHeaderFor(x402Version, payload);
1261
+ headerName = h.name;
1262
+ paymentHeader = h.value;
1100
1263
  }
1101
1264
  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 };
1265
+ let txResult;
1266
+ if (isSolanaChain(chainId)) {
1267
+ txResult = (await api(`/wallets/${wallet_id}/send`, 'POST', tokenAddress
1268
+ ? { to: option.payTo, value: rawAmount, token_mint: tokenAddress, token_decimals: trustedDecimals, chain_id: chainId }
1269
+ : { to: option.payTo, value: rawAmount, chain_id: chainId }));
1270
+ }
1271
+ else if (tokenAddress) {
1272
+ const calldata = '0xa9059cbb' + padAddress(option.payTo) + encodeUint256(rawAmount);
1273
+ txResult = (await api(`/wallets/${wallet_id}/send`, 'POST', { to: tokenAddress, value: '0', data: calldata, chain_id: chainId }));
1274
+ }
1275
+ else {
1276
+ txResult = (await api(`/wallets/${wallet_id}/send`, 'POST', { to: option.payTo, value: rawAmount, data: '', chain_id: chainId }));
1277
+ }
1278
+ txHash = String(txResult.tx_hash || txResult.signature || '');
1279
+ if (!txHash)
1280
+ throw new Error('x402: the payment transaction returned no hash.');
1281
+ paymentHeader = Buffer.from(JSON.stringify({ x402Version, scheme: option.scheme, network: option.network, payload: { txHash } })).toString('base64');
1282
+ }
1283
+ // Step 6: Retry with the payment header and read the settlement receipt.
1284
+ const retryHeaders = { ...reqHeaders, [headerName]: paymentHeader };
1121
1285
  const retryOptions = {
1122
1286
  method,
1123
1287
  headers: retryHeaders,
@@ -1135,21 +1299,61 @@ server.tool('pay_x402', 'Handle an x402 payment flow. Fetches a URL, and if the
1135
1299
  catch {
1136
1300
  retryParsed = retryText;
1137
1301
  }
1302
+ const settlement = parseSettlement(n => retryRes.headers.get(n));
1303
+ // A second 402 means the facilitator declined (unfunded payer, expired window, bad domain...). v2 servers
1304
+ // put the reason in a fresh PAYMENT-REQUIRED header, v1 servers in the body; surface it instead of {}.
1305
+ let retryError = null;
1306
+ if (retryRes.status === 402) {
1307
+ const again = parsePaymentRequired(n => retryRes.headers.get(n), retryParsed);
1308
+ retryError = String(again?.error || retryParsed?.error || 'Payment was not accepted (no reason given).');
1309
+ }
1138
1310
  return jsonResponse({
1139
1311
  status: retryRes.status,
1140
1312
  payment_required: true,
1141
- payment_made: true,
1313
+ payment_made: retryRes.status !== 402,
1314
+ retry_error: retryError,
1315
+ payment_method: isUpto ? 'permit2-upto-authorization' : (standardExact ? 'eip3009-authorization' : 'onchain-transfer'),
1316
+ approval_id: approvalUsed,
1317
+ permit2_approval_tx: permit2ApprovalTx,
1318
+ x402_version: x402Version,
1142
1319
  amount,
1143
- token: option.extra?.name || 'native',
1144
- token_address: option.asset || option.extra?.token || null,
1320
+ token: tokenLabel,
1321
+ token_address: tokenAddress || null,
1145
1322
  network: option.network,
1146
1323
  chain_id: chainId,
1147
1324
  pay_to: option.payTo,
1148
- tx_hash: txHash,
1325
+ payer,
1326
+ tx_hash: txHash ?? settlement?.transaction ?? null,
1327
+ settlement,
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),
1149
1330
  description: option.description,
1150
1331
  response: retryParsed,
1151
1332
  });
1152
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)) }));
1153
1357
  // ─── Tool: get_usage ─────────────────────────────────────────────
1154
1358
  server.tool('get_usage', 'Get the current month\'s usage statistics. ' +
1155
1359
  'Returns operations count, tier info, remaining quota, and fees.', {}, async () => {
@@ -15,6 +15,8 @@
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';
19
+ import { type UptoPermit2Authorization } from './x402-permit2.js';
18
20
  /** True when the server is running non-custodially. */
19
21
  export declare function isLocalMode(): boolean;
20
22
  export declare function getLocalAccount(): {
@@ -88,3 +90,27 @@ export declare function localWalletRecord(): {
88
90
  custody: string;
89
91
  note: string;
90
92
  };
93
+ /**
94
+ * Sign a TransferWithAuthorization for an x402 payment. Nothing is broadcast;
95
+ * the resource server's facilitator settles it on-chain. The amount is put
96
+ * through the same AGENTWALLET_MAX_TX_TOKEN guard as a transfer, because that
97
+ * is exactly what the authorization lets someone else execute.
98
+ */
99
+ export declare function localSignAuthorization(chainId: number, asset: Address, domain: {
100
+ name: string;
101
+ version: string;
102
+ }, auth: Eip3009Authorization): Promise<{
103
+ signature: `0x${string}`;
104
+ from: Address;
105
+ mode: 'local';
106
+ }>;
107
+ /** Read-only eth_call against the local RPC, same shape as the hosted /eth-call route. */
108
+ export declare function localEthCall(chainId: number, to: Address, data: Hex): Promise<{
109
+ result: string;
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
+ }>;