Skip to content

Commit 960f60a

Browse files
committed
feat(cli): add --network flag, human-readable network names, and audit fixes
- Add --network flag (fetch + mcp) for hard network filtering with clear error when unavailable ("Available: Solana, Base") - Display "Base"/"Solana" instead of raw CAIP-2 IDs in payment output - Use wildcard scheme registration (eip155:*, solana:*) via SDK helpers - Derive solanaAddress for --solana-key flag and env var sources - Port balance auto-detection to MCP command - Fix MCP payment history: add amount, to, correct network access - Remove debug prefix stripping from payment amounts - Show USDC balances with 4 decimal places
1 parent 2f6d09a commit 960f60a

10 files changed

Lines changed: 241 additions & 45 deletions

File tree

packages/x402-proxy/CHANGELOG.md

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
## [0.4.0] - 2026-03-13
11+
12+
### Added
13+
- `--network` flag for `fetch` and `mcp` commands - hard filter that requires a specific network (base, solana, or CAIP-2 ID), fails with clear error if unavailable
14+
- Human-readable network names in payment output ("Base", "Solana" instead of "eip155:8453")
15+
- `displayNetwork()` exported from library for mapping CAIP-2 IDs to display names
16+
17+
### Fixed
18+
- Wildcard scheme registration (`eip155:*`, `solana:*`) via SDK helpers - payment signing now works for any EVM chain a server requests, not just Base
19+
- Solana address derivation for `--solana-key` flag and `X402_PROXY_WALLET_SOLANA_KEY` env var - balance detection, wallet display, and history recording were broken without it
20+
- MCP command now auto-detects preferred network based on USDC balance (same fix previously applied to `fetch`)
21+
- MCP payment history records now include `amount`, `to`, and correct `network` (removed fragile type cast)
22+
- Removed debug prefix stripping from payment amounts in handler
23+
- USDC balance display now shows 4 decimal places (was 2)
24+
1025
## [0.3.2] - 2026-03-13
1126

1227
### Added
@@ -95,7 +110,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
95110
- `appendHistory` / `readHistory` / `calcSpend` - JSONL transaction history
96111
- Re-exports from `@x402/fetch`, `@x402/svm`, `@x402/evm`
97112

98-
[Unreleased]: https://github.com/cascade-protocol/x402-proxy/compare/v0.3.2...HEAD
113+
[Unreleased]: https://github.com/cascade-protocol/x402-proxy/compare/v0.4.0...HEAD
114+
[0.4.0]: https://github.com/cascade-protocol/x402-proxy/compare/v0.3.2...v0.4.0
99115
[0.3.2]: https://github.com/cascade-protocol/x402-proxy/compare/v0.3.1...v0.3.2
100116
[0.3.1]: https://github.com/cascade-protocol/x402-proxy/compare/v0.3.0...v0.3.1
101117
[0.3.0]: https://github.com/cascade-protocol/x402-proxy/compare/v0.2.1...v0.3.0

packages/x402-proxy/README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,9 @@ $ npx x402-proxy --method POST \
4646
--body '{"url":"https://x402.org"}' \
4747
https://web.surf.cascade.fyi/v1/crawl
4848

49+
# Force a specific network
50+
$ npx x402-proxy --network base https://api.example.com/data
51+
4952
# Pipe-safe
5053
$ npx x402-proxy https://api.example.com/data | jq '.results'
5154
```

packages/x402-proxy/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "x402-proxy",
3-
"version": "0.3.2",
3+
"version": "0.4.0",
44
"description": "curl for x402 paid APIs. Auto-pays any endpoint on Base and Solana.",
55
"type": "module",
66
"sideEffects": false,

packages/x402-proxy/src/commands/fetch.ts

Lines changed: 119 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { buildCommand, type CommandContext } from "@stricli/core";
22
import type { PaymentRequired } from "@x402/fetch";
33
import pc from "picocolors";
44
import { createX402ProxyHandler, extractTxSignature } from "../handler.js";
5-
import { appendHistory, type TxRecord } from "../history.js";
5+
import { appendHistory, displayNetwork, type TxRecord } from "../history.js";
66
import { ensureConfigDir, getHistoryPath, isConfigured, loadConfig } from "../lib/config.js";
77
import { dim, error, info, isTTY } from "../lib/output.js";
88
import { buildX402Client, resolveWallet } from "../lib/resolve-wallet.js";
@@ -13,6 +13,7 @@ type FetchFlags = {
1313
header: string[] | undefined;
1414
evmKey: string | undefined;
1515
solanaKey: string | undefined;
16+
network: string | undefined;
1617
json: boolean;
1718
};
1819

@@ -59,6 +60,12 @@ Examples:
5960
parse: String,
6061
optional: true,
6162
},
63+
network: {
64+
kind: "parsed",
65+
brief: "Require specific network (base, solana)",
66+
parse: String,
67+
optional: true,
68+
},
6269
json: {
6370
kind: "boolean",
6471
brief: "Force JSON output",
@@ -158,8 +165,27 @@ Examples:
158165
}
159166

160167
const config = loadConfig();
168+
169+
// Auto-detect preferred network based on balance when not configured
170+
let preferredNetwork = config?.defaultNetwork;
171+
if (!preferredNetwork && wallet.evmAddress && wallet.solanaAddress) {
172+
const { fetchEvmBalances, fetchSolanaBalances } = await import("./wallet.js");
173+
const [evmBal, solBal] = await Promise.allSettled([
174+
fetchEvmBalances(wallet.evmAddress),
175+
fetchSolanaBalances(wallet.solanaAddress),
176+
]);
177+
const evmUsdc = evmBal.status === "fulfilled" ? Number(evmBal.value?.usdc ?? 0) : 0;
178+
const solUsdc = solBal.status === "fulfilled" ? Number(solBal.value?.usdc ?? 0) : 0;
179+
if (evmUsdc > solUsdc) {
180+
preferredNetwork = "base";
181+
} else if (solUsdc > evmUsdc) {
182+
preferredNetwork = "solana";
183+
}
184+
}
185+
161186
const client = await buildX402Client(wallet, {
162-
preferredNetwork: config?.defaultNetwork,
187+
preferredNetwork,
188+
network: flags.network,
163189
spendLimitDaily: config?.spendLimitDaily,
164190
spendLimitPerTx: config?.spendLimitPerTx,
165191
});
@@ -196,7 +222,7 @@ Examples:
196222
const payment = shiftPayment();
197223
const txSig = extractTxSignature(response);
198224

199-
// Payment failed - show funding instructions from the endpoint's actual requirements
225+
// Payment failed - check balances and show appropriate message
200226
if (response.status === 402 && isTTY()) {
201227
const prHeader =
202228
response.headers.get("PAYMENT-REQUIRED") ?? response.headers.get("X-PAYMENT-REQUIRED");
@@ -210,14 +236,14 @@ Examples:
210236
}
211237
}
212238

239+
let costNum = 0;
240+
let costStr = "?";
213241
if (accepts.length > 0) {
214242
const cheapest = accepts.reduce((min, a) =>
215243
Number(a.amount) < Number(min.amount) ? a : min,
216244
);
217-
const cost = (Number(cheapest.amount) / 1_000_000).toFixed(4);
218-
error(`Payment required: ${cost} USDC`);
219-
} else {
220-
error("Payment required");
245+
costNum = Number(cheapest.amount) / 1_000_000;
246+
costStr = costNum.toFixed(4);
221247
}
222248

223249
const hasEvm = accepts.some((a) => a.network.startsWith("eip155:"));
@@ -226,37 +252,104 @@ Examples:
226252
(a) => !a.network.startsWith("eip155:") && !a.network.startsWith("solana:"),
227253
);
228254

229-
if (hasEvm || hasSolana) {
255+
// Check on-chain balances to give actionable feedback
256+
const { fetchEvmBalances, fetchSolanaBalances } = await import("./wallet.js");
257+
let evmUsdc = 0;
258+
let solUsdc = 0;
259+
if (hasEvm && wallet.evmAddress) {
260+
try {
261+
const bal = await fetchEvmBalances(wallet.evmAddress);
262+
evmUsdc = Number(bal.usdc);
263+
} catch {
264+
// Network error - fall through with 0
265+
}
266+
}
267+
if (hasSolana && wallet.solanaAddress) {
268+
try {
269+
const bal = await fetchSolanaBalances(wallet.solanaAddress);
270+
solUsdc = Number(bal.usdc);
271+
} catch {
272+
// Network error - fall through with 0
273+
}
274+
}
275+
276+
const hasSufficientBalance =
277+
(hasEvm && evmUsdc >= costNum) || (hasSolana && solUsdc >= costNum);
278+
279+
if (hasSufficientBalance) {
280+
// Balance is sufficient but payment failed - read server error
281+
let serverReason: string | undefined;
282+
try {
283+
const body = await response.text();
284+
if (body) {
285+
const parsed = JSON.parse(body) as { error?: string; message?: string };
286+
serverReason = parsed.error || parsed.message;
287+
}
288+
} catch {
289+
// Not JSON or no body
290+
}
291+
292+
error(`Payment failed: ${costStr} USDC`);
230293
console.error();
231-
dim(" Fund your wallet with USDC:");
232-
if (hasEvm && wallet.evmAddress) {
233-
console.error(` Base: ${pc.cyan(wallet.evmAddress)}`);
294+
if (payment) {
295+
dim(" Payment was signed and sent but rejected by the server.");
296+
} else {
297+
dim(" Payment was not attempted despite sufficient balance.");
234298
}
235-
if (hasSolana && wallet.solanaAddress) {
236-
console.error(` Solana: ${pc.cyan(wallet.solanaAddress)}`);
299+
if (serverReason) {
300+
dim(` Reason: ${serverReason}`);
237301
}
238-
if (hasEvm && !wallet.evmAddress) {
239-
dim(" Base: endpoint accepts EVM but no EVM wallet configured");
302+
if (hasEvm && wallet.evmAddress && evmUsdc > 0) {
303+
console.error(
304+
` Base: ${pc.cyan(wallet.evmAddress)} ${pc.dim(`(${evmUsdc.toFixed(4)} USDC)`)}`,
305+
);
240306
}
241-
if (hasSolana && !wallet.solanaAddress) {
242-
dim(" Solana: endpoint accepts Solana but no Solana wallet configured");
307+
if (hasSolana && wallet.solanaAddress && solUsdc > 0) {
308+
console.error(
309+
` Solana: ${pc.cyan(wallet.solanaAddress)} ${pc.dim(`(${solUsdc.toFixed(4)} USDC)`)}`,
310+
);
243311
}
244-
} else if (hasOther) {
245-
const networks = [...new Set(accepts.map((a) => a.network))].join(", ");
246312
console.error();
247-
error(`This endpoint only accepts payment on unsupported networks: ${networks}`);
248-
}
313+
dim(" This may be a temporary server-side issue. Try again in a moment.");
314+
console.error();
315+
} else {
316+
// Insufficient balance
317+
error(`Payment required: ${costStr} USDC`);
318+
319+
if (hasEvm || hasSolana) {
320+
console.error();
321+
dim(" Fund your wallet with USDC:");
322+
if (hasEvm && wallet.evmAddress) {
323+
const balHint = evmUsdc > 0 ? pc.dim(` (${evmUsdc.toFixed(4)} USDC)`) : "";
324+
console.error(` Base: ${pc.cyan(wallet.evmAddress)}${balHint}`);
325+
}
326+
if (hasSolana && wallet.solanaAddress) {
327+
const balHint = solUsdc > 0 ? pc.dim(` (${solUsdc.toFixed(4)} USDC)`) : "";
328+
console.error(` Solana: ${pc.cyan(wallet.solanaAddress)}${balHint}`);
329+
}
330+
if (hasEvm && !wallet.evmAddress) {
331+
dim(" Base: endpoint accepts EVM but no EVM wallet configured");
332+
}
333+
if (hasSolana && !wallet.solanaAddress) {
334+
dim(" Solana: endpoint accepts Solana but no Solana wallet configured");
335+
}
336+
} else if (hasOther) {
337+
const networks = [...new Set(accepts.map((a) => a.network))].join(", ");
338+
console.error();
339+
error(`This endpoint only accepts payment on unsupported networks: ${networks}`);
340+
}
249341

250-
console.error();
251-
dim(" Then re-run:");
252-
console.error(` ${pc.cyan(`$ npx x402-proxy ${url}`)}`);
253-
console.error();
342+
console.error();
343+
dim(" Then re-run:");
344+
console.error(` ${pc.cyan(`$ npx x402-proxy ${url}`)}`);
345+
console.error();
346+
}
254347
return;
255348
}
256349

257350
if (payment && isTTY()) {
258351
info(
259-
` Payment: ${payment.amount ? (Number(payment.amount) / 1_000_000).toFixed(4) : "?"} USDC (${payment.network ?? "unknown"})`,
352+
` Payment: ${payment.amount ? (Number(payment.amount) / 1_000_000).toFixed(4) : "?"} USDC (${displayNetwork(payment.network ?? "unknown")})`,
260353
);
261354
if (txSig) dim(` Tx: ${txSig}`);
262355
}

packages/x402-proxy/src/commands/mcp.ts

Lines changed: 36 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,15 @@
11
declare const __VERSION__: string;
22

33
import { buildCommand, type CommandContext } from "@stricli/core";
4-
import { appendHistory, type TxRecord } from "../history.js";
4+
import { appendHistory, displayNetwork, type TxRecord } from "../history.js";
55
import { ensureConfigDir, getHistoryPath, loadConfig } from "../lib/config.js";
66
import { dim, error, warn } from "../lib/output.js";
77
import { buildX402Client, resolveWallet } from "../lib/resolve-wallet.js";
88

99
type McpFlags = {
1010
evmKey: string | undefined;
1111
solanaKey: string | undefined;
12+
network: string | undefined;
1213
};
1314

1415
export const mcpCommand = buildCommand<McpFlags, [remoteUrl: string], CommandContext>({
@@ -35,6 +36,12 @@ Add to your MCP client config (Claude, Cursor, etc.):
3536
parse: String,
3637
optional: true,
3738
},
39+
network: {
40+
kind: "parsed",
41+
brief: "Require specific network (base, solana)",
42+
parse: String,
43+
optional: true,
44+
},
3845
},
3946
positional: {
4047
kind: "tuple",
@@ -66,8 +73,27 @@ Add to your MCP client config (Claude, Cursor, etc.):
6673
if (wallet.solanaAddress) dim(` Solana: ${wallet.solanaAddress}`);
6774

6875
const config = loadConfig();
76+
77+
// Auto-detect preferred network based on balance when not configured
78+
let preferredNetwork = config?.defaultNetwork;
79+
if (!preferredNetwork && wallet.evmAddress && wallet.solanaAddress) {
80+
const { fetchEvmBalances, fetchSolanaBalances } = await import("./wallet.js");
81+
const [evmBal, solBal] = await Promise.allSettled([
82+
fetchEvmBalances(wallet.evmAddress),
83+
fetchSolanaBalances(wallet.solanaAddress),
84+
]);
85+
const evmUsdc = evmBal.status === "fulfilled" ? Number(evmBal.value?.usdc ?? 0) : 0;
86+
const solUsdc = solBal.status === "fulfilled" ? Number(solBal.value?.usdc ?? 0) : 0;
87+
if (evmUsdc > solUsdc) {
88+
preferredNetwork = "base";
89+
} else if (solUsdc > evmUsdc) {
90+
preferredNetwork = "solana";
91+
}
92+
}
93+
6994
const x402PaymentClient = await buildX402Client(wallet, {
70-
preferredNetwork: config?.defaultNetwork,
95+
preferredNetwork,
96+
network: flags.network,
7197
spendLimitDaily: config?.spendLimitDaily,
7298
spendLimitPerTx: config?.spendLimitPerTx,
7399
});
@@ -89,7 +115,10 @@ Add to your MCP client config (Claude, Cursor, etc.):
89115
onPaymentRequested: (ctx) => {
90116
const accept = ctx.paymentRequired.accepts?.[0];
91117
if (accept) {
92-
warn(` Payment: ${accept.amount} on ${accept.network} for tool "${ctx.toolName}"`);
118+
const amount = accept.amount ? (Number(accept.amount) / 1_000_000).toFixed(4) : "?";
119+
warn(
120+
` Payment: ${amount} USDC on ${displayNetwork(accept.network)} for tool "${ctx.toolName}"`,
121+
);
93122
}
94123
return true;
95124
},
@@ -98,15 +127,17 @@ Add to your MCP client config (Claude, Cursor, etc.):
98127
// Track payments
99128
x402Mcp.onAfterPayment(async (ctx) => {
100129
ensureConfigDir();
130+
const accepted = ctx.paymentPayload.accepted;
101131
const tx = ctx.settleResponse?.transaction;
102-
const accept = ctx.paymentPayload;
103132
const record: TxRecord = {
104133
t: Date.now(),
105134
ok: true,
106135
kind: "x402_payment",
107-
net: (accept as { network?: string }).network ?? "unknown",
136+
net: accepted?.network ?? "unknown",
108137
from: wallet.evmAddress ?? wallet.solanaAddress ?? "unknown",
138+
to: accepted?.payTo,
109139
tx: typeof tx === "string" ? tx : undefined,
140+
amount: accepted?.amount ? Number(accepted.amount) / 1_000_000 : undefined,
110141
token: "USDC",
111142
label: `mcp:${ctx.toolName}`,
112143
};

packages/x402-proxy/src/commands/wallet.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ export async function fetchEvmBalances(address: string): Promise<{ eth: string;
3636
])) as [RpcResult, RpcResult];
3737

3838
const eth = ethRes.result ? (Number(BigInt(ethRes.result)) / 1e18).toFixed(6) : "?";
39-
const usdc = usdcRes.result ? (Number(BigInt(usdcRes.result)) / 1e6).toFixed(2) : "?";
39+
const usdc = usdcRes.result ? (Number(BigInt(usdcRes.result)) / 1e6).toFixed(4) : "?";
4040
return { eth, usdc };
4141
}
4242

@@ -53,8 +53,8 @@ export async function fetchSolanaBalances(address: string): Promise<{ sol: strin
5353
const sol = solRes.result?.value != null ? (solRes.result.value / 1e9).toFixed(6) : "?";
5454
const accounts = usdcRes.result?.value;
5555
const usdc = accounts?.length
56-
? Number(accounts[0].account.data.parsed.info.tokenAmount.uiAmountString).toFixed(2)
57-
: "0.00";
56+
? Number(accounts[0].account.data.parsed.info.tokenAmount.uiAmountString).toFixed(4)
57+
: "0.0000";
5858
return { sol, usdc };
5959
}
6060

@@ -112,8 +112,8 @@ export const walletInfoCommand = buildCommand<{ verbose: boolean }, [], CommandC
112112
}
113113

114114
// Funding hint when both USDC balances are zero
115-
const evmEmpty = !evm || evm.usdc === "0.00";
116-
const solEmpty = !sol || sol.usdc === "0.00";
115+
const evmEmpty = !evm || evm.usdc === "0.0000";
116+
const solEmpty = !sol || sol.usdc === "0.0000";
117117
if (evmEmpty && solEmpty) {
118118
console.log();
119119
dim(" Send USDC to either address above to start using x402 APIs.");

packages/x402-proxy/src/handler.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ export function createX402ProxyHandler(opts: X402ProxyOptions): X402ProxyHandler
5757
paymentQueue.push({
5858
network: hookCtx.selectedRequirements.network,
5959
payTo: hookCtx.selectedRequirements.payTo,
60-
amount: raw?.startsWith("debug.") ? raw.slice(6) : raw,
60+
amount: raw,
6161
asset: hookCtx.selectedRequirements.asset,
6262
});
6363
});

0 commit comments

Comments
 (0)