Skip to content

Commit c1fbd05

Browse files
MajorTalclaude
andcommitted
Fix x402 paid fetch selecting unfunded network
The shared setupPaidFetch() registered both Base mainnet and Base Sepolia with the x402 client, but the default selector picked mainnet first — where faucet-funded wallets have zero USDC. This broke tier set and other paid commands for testnet wallets. Now checks on-chain USDC balances at setup time and registers an x402 policy that filters to funded networks. Fails fast with actionable error when no network has funds. Same logic applied to MCP server paid-fetch. Also removes the duplicate setupPaidFetch() from cli/lib/functions.mjs (was hardcoded to sepolia-only as a workaround) and fixes the integration test that was masking the failure by catching "Payment required" as OK. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 1238cad commit c1fbd05

4 files changed

Lines changed: 109 additions & 26 deletions

File tree

cli-integration.test.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -164,9 +164,10 @@ describe("CLI integration (live API, no mocks)", { timeout: 180_000 }, () => {
164164
try {
165165
await run("set", ["prototype"]);
166166
} catch (err: unknown) {
167-
// If tier is already active, the CLI exits with 402 "already active" — that's fine
167+
// If tier is already active, the server returns 402 for renewal — the x402 wrapper
168+
// should auto-pay. Only "already active" after successful renewal is acceptable.
168169
const msg = (err as Error).message || "";
169-
if (msg.includes("already active") || msg.includes("Payment required")) {
170+
if (msg.includes("already active")) {
170171
captureStop();
171172
assert.ok(true, "tier already active (expected for pre-funded wallet)");
172173
return;
@@ -550,7 +551,7 @@ describe("CLI integration (live API, no mocks)", { timeout: 180_000 }, () => {
550551
await run("set", ["prototype"]);
551552
} catch (err: unknown) {
552553
const msg = (err as Error).message || "";
553-
if (msg.includes("already active") || msg.includes("Payment required") || msg.includes("renew")) {
554+
if (msg.includes("already active") || msg.includes("renew")) {
554555
captureStop();
555556
assert.ok(true, "tier already active or renewed (expected for pre-funded wallet)");
556557
return;

cli/lib/functions.mjs

Lines changed: 3 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
1-
import { readFileSync, existsSync } from "fs";
2-
import { findProject, readAllowance, API, ALLOWANCE_FILE } from "./config.mjs";
1+
import { readFileSync } from "fs";
2+
import { findProject, API } from "./config.mjs";
3+
import { setupPaidFetch } from "./paid-fetch.mjs";
34

45
const HELP = `run402 functions — Manage serverless functions
56
@@ -27,26 +28,6 @@ Notes:
2728
- Deploy may require payment if the project lease has expired
2829
`;
2930

30-
async function setupPaidFetch() {
31-
if (!existsSync(ALLOWANCE_FILE)) {
32-
console.error(JSON.stringify({ status: "error", message: "No agent allowance found. Run: run402 allowance create && run402 allowance fund" }));
33-
process.exit(1);
34-
}
35-
const allowance = readAllowance();
36-
const { privateKeyToAccount } = await import("viem/accounts");
37-
const { createPublicClient, http } = await import("viem");
38-
const { baseSepolia } = await import("viem/chains");
39-
const { x402Client, wrapFetchWithPayment } = await import("@x402/fetch");
40-
const { ExactEvmScheme } = await import("@x402/evm/exact/client");
41-
const { toClientEvmSigner } = await import("@x402/evm");
42-
const account = privateKeyToAccount(allowance.privateKey);
43-
const publicClient = createPublicClient({ chain: baseSepolia, transport: http() });
44-
const signer = toClientEvmSigner(account, publicClient);
45-
const client = new x402Client();
46-
client.register("eip155:84532", new ExactEvmScheme(signer));
47-
return wrapFetchWithPayment(fetch, client);
48-
}
49-
5031
async function deploy(projectId, name, args) {
5132
const p = findProject(projectId);
5233
const opts = { file: null, timeout: undefined, memory: undefined, deps: undefined };

cli/lib/paid-fetch.mjs

Lines changed: 65 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,33 @@
33
* Branches on allowance rail:
44
* - "mpp": uses mppx.fetch (Tempo pathUSD)
55
* - "x402" (default): uses @x402/fetch (Base USDC)
6+
*
7+
* Checks on-chain balances at setup time and selects funded networks.
68
*/
79

810
import { readAllowance, ALLOWANCE_FILE } from "./config.mjs";
911
import { existsSync } from "fs";
1012

13+
const USDC_ABI = [{ name: "balanceOf", type: "function", stateMutability: "view", inputs: [{ name: "account", type: "address" }], outputs: [{ name: "", type: "uint256" }] }];
14+
const USDC_MAINNET = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913";
15+
const USDC_SEPOLIA = "0x036CbD53842c5426634e7929541eC2318f3dCF7e";
16+
const PATH_USD = "0x20c0000000000000000000000000000000000000";
17+
const TEMPO_RPC = "https://rpc.moderato.tempo.xyz/";
18+
19+
async function checkBalance(publicClient, tokenAddress, walletAddress) {
20+
try {
21+
const raw = await publicClient.readContract({
22+
address: tokenAddress,
23+
abi: USDC_ABI,
24+
functionName: "balanceOf",
25+
args: [walletAddress],
26+
});
27+
return Number(raw);
28+
} catch {
29+
return 0;
30+
}
31+
}
32+
1133
export async function setupPaidFetch() {
1234
if (!existsSync(ALLOWANCE_FILE)) {
1335
console.error(JSON.stringify({ status: "error", message: "No agent allowance found. Run: run402 allowance create && run402 allowance fund" }));
@@ -18,6 +40,23 @@ export async function setupPaidFetch() {
1840
const account = privateKeyToAccount(allowance.privateKey);
1941

2042
if (allowance.rail === "mpp") {
43+
const { createPublicClient, http, defineChain } = await import("viem");
44+
const tempoModerato = defineChain({
45+
id: 42431,
46+
name: "Tempo Moderato",
47+
nativeCurrency: { name: "pathUSD", symbol: "pathUSD", decimals: 6 },
48+
rpcUrls: { default: { http: [TEMPO_RPC] } },
49+
});
50+
const tempoClient = createPublicClient({ chain: tempoModerato, transport: http() });
51+
const balance = await checkBalance(tempoClient, PATH_USD, allowance.address);
52+
if (balance === 0) {
53+
console.error(JSON.stringify({
54+
status: "error",
55+
message: `No pathUSD balance on Tempo Moderato (0). Fund your wallet: run402 allowance fund`,
56+
}));
57+
process.exit(1);
58+
}
59+
2160
const { Mppx, tempo } = await import("mppx/client");
2261
const mppx = Mppx.create({
2362
polyfill: false,
@@ -26,7 +65,7 @@ export async function setupPaidFetch() {
2665
return mppx.fetch;
2766
}
2867

29-
// Default: x402 (existing behavior)
68+
// Default: x402
3069
const { createPublicClient, http } = await import("viem");
3170
const { base, baseSepolia } = await import("viem/chains");
3271
const { x402Client, wrapFetchWithPayment } = await import("@x402/fetch");
@@ -36,8 +75,33 @@ export async function setupPaidFetch() {
3675
const mainnetClient = createPublicClient({ chain: base, transport: http() });
3776
const sepoliaClient = createPublicClient({ chain: baseSepolia, transport: http() });
3877

78+
// Check balances in parallel
79+
const [mainnetBalance, sepoliaBalance] = await Promise.all([
80+
checkBalance(mainnetClient, USDC_MAINNET, allowance.address),
81+
checkBalance(sepoliaClient, USDC_SEPOLIA, allowance.address),
82+
]);
83+
84+
if (mainnetBalance === 0 && sepoliaBalance === 0) {
85+
console.error(JSON.stringify({
86+
status: "error",
87+
message: `No USDC balance on any supported network (Base: $${(mainnetBalance / 1e6).toFixed(2)}, Base Sepolia: $${(sepoliaBalance / 1e6).toFixed(2)}). Fund your wallet or run: run402 allowance fund`,
88+
}));
89+
process.exit(1);
90+
}
91+
3992
const client = new x402Client();
4093
client.register("eip155:8453", new ExactEvmScheme(toClientEvmSigner(account, mainnetClient)));
4194
client.register("eip155:84532", new ExactEvmScheme(toClientEvmSigner(account, sepoliaClient)));
95+
96+
// Policy: only allow networks where the wallet has funds
97+
client.registerPolicy((_version, reqs) => {
98+
const funded = reqs.filter((r) => {
99+
if (r.network === "eip155:8453") return mainnetBalance > 0;
100+
if (r.network === "eip155:84532") return sepoliaBalance > 0;
101+
return false;
102+
});
103+
return funded.length > 0 ? funded : reqs;
104+
});
105+
42106
return wrapFetchWithPayment(fetch, client);
43107
}

src/paid-fetch.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
* (x402 vs mpp), returns a wrapped fetch that intercepts 402 responses,
44
* signs payment, and retries automatically.
55
*
6+
* Checks on-chain balances at setup time and selects funded networks.
67
* Returns null when no allowance is configured or payment libraries are
78
* unavailable (graceful degradation).
89
*/
@@ -13,6 +14,24 @@ import type { ApiResponse, ApiRequestOptions } from "./client.js";
1314

1415
type FetchFn = typeof globalThis.fetch;
1516

17+
const USDC_ABI = [{ name: "balanceOf", type: "function", stateMutability: "view", inputs: [{ name: "account", type: "address" }], outputs: [{ name: "", type: "uint256" }] }] as const;
18+
const USDC_MAINNET = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913";
19+
const USDC_SEPOLIA = "0x036CbD53842c5426634e7929541eC2318f3dCF7e";
20+
21+
async function checkBalance(publicClient: any, tokenAddress: string, walletAddress: string): Promise<number> {
22+
try {
23+
const raw = await publicClient.readContract({
24+
address: tokenAddress,
25+
abi: USDC_ABI,
26+
functionName: "balanceOf",
27+
args: [walletAddress],
28+
});
29+
return Number(raw);
30+
} catch {
31+
return 0;
32+
}
33+
}
34+
1635
/**
1736
* Create a payment-wrapping fetch function from the local allowance.
1837
* Returns null if no allowance exists or payment libraries fail to load.
@@ -47,10 +66,28 @@ export async function setupPaidFetch(): Promise<FetchFn | null> {
4766
const mainnetClient = createPublicClient({ chain: base, transport: http() });
4867
const sepoliaClient = createPublicClient({ chain: baseSepolia, transport: http() });
4968

69+
// Check balances in parallel
70+
const [mainnetBalance, sepoliaBalance] = await Promise.all([
71+
checkBalance(mainnetClient, USDC_MAINNET, allowance.address),
72+
checkBalance(sepoliaClient, USDC_SEPOLIA, allowance.address),
73+
]);
74+
5075
const client = new x402Client();
5176
client.register("eip155:8453", new ExactEvmScheme(toClientEvmSigner(account, mainnetClient)));
5277
client.register("eip155:84532", new ExactEvmScheme(toClientEvmSigner(account, sepoliaClient)));
5378

79+
// Policy: only allow networks where the wallet has funds
80+
if (mainnetBalance > 0 || sepoliaBalance > 0) {
81+
client.registerPolicy((_version: number, reqs: any[]) => {
82+
const funded = reqs.filter((r: any) => {
83+
if (r.network === "eip155:8453") return mainnetBalance > 0;
84+
if (r.network === "eip155:84532") return sepoliaBalance > 0;
85+
return false;
86+
});
87+
return funded.length > 0 ? funded : reqs;
88+
});
89+
}
90+
5491
return wrapFetchWithPayment(fetch, client) as FetchFn;
5592
} catch {
5693
// Payment libraries not available — degrade gracefully

0 commit comments

Comments
 (0)