Skip to content

Commit 893e1f8

Browse files
committed
fix(svm): resolve Solana RPC 429 rate-limit failures on concurrent payments
Replace upstream ExactSvmScheme with OptimizedSvmScheme: - Shared RPC client with @solana/kit request coalescing - Hardcoded USDC mint metadata (skip fetchMint RPC call) - Failover transport across 2 public mainnet RPCs on 429 - Custom RPC URL tried first with public RPCs as fallback Stress tested: 30 concurrent Solana payments through single MCP server process with zero failures.
1 parent 27ae22e commit 893e1f8

7 files changed

Lines changed: 347 additions & 9 deletions

File tree

.claude/CLAUDE.md

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,3 +102,82 @@ act -n --workflows .github/workflows/publish.yml \
102102
```
103103

104104
Always dry-run with act after modifying workflow files to catch issues before pushing.
105+
106+
## Testing & Debugging
107+
108+
All commands run from the repo root. Always `pnpm build` first.
109+
110+
### CLI smoke test
111+
```bash
112+
# Direct HTTP request (single process, no concurrency concerns)
113+
node packages/x402-proxy/dist/bin/cli.js https://twitter.surf.cascade.fyi/users/cascade_fyi --network solana
114+
115+
# Wallet info
116+
node packages/x402-proxy/dist/bin/cli.js wallet info
117+
```
118+
119+
### MCP Inspector - interactive UI
120+
```bash
121+
# Start inspector (opens browser at localhost:6274)
122+
npx @modelcontextprotocol/inspector \
123+
node packages/x402-proxy/dist/bin/cli.js mcp --network solana https://surf.cascade.fyi/mcp
124+
```
125+
Use the Tools tab to call tools manually (e.g. `surf_twitter_user` with `ref=cascade_fyi`).
126+
127+
### MCP Inspector - CLI mode
128+
```bash
129+
# List tools (no payment)
130+
npx @modelcontextprotocol/inspector --cli \
131+
node packages/x402-proxy/dist/bin/cli.js mcp https://surf.cascade.fyi/mcp \
132+
--method tools/list
133+
134+
# Call a tool
135+
npx @modelcontextprotocol/inspector --cli \
136+
node packages/x402-proxy/dist/bin/cli.js mcp --network solana https://surf.cascade.fyi/mcp \
137+
--method tools/call --tool-name surf_twitter_user --tool-arg ref=cascade_fyi
138+
```
139+
140+
### MCP concurrency stress test via Inspector proxy
141+
142+
The Inspector proxy at `localhost:6277` exposes a Streamable HTTP endpoint. This lets you
143+
fire concurrent requests through a **single MCP server process** - critical for testing
144+
RPC coalescing and failover (which only work within one process, not across separate CLIs).
145+
146+
Auth header: `x-mcp-proxy-auth: Bearer <TOKEN>` (token printed at inspector startup).
147+
148+
```bash
149+
TOKEN="<token from inspector startup>"
150+
151+
# 1. Create a session (spawns a new stdio MCP server behind the proxy)
152+
curl -s -X POST "http://localhost:6277/mcp?transportType=stdio&command=node&args=dist/bin/cli.js%20mcp%20--network%20solana%20https://surf.cascade.fyi/mcp" \
153+
-H "Content-Type: application/json" \
154+
-H "x-mcp-proxy-auth: Bearer $TOKEN" \
155+
-H "Accept: application/json, text/event-stream" \
156+
-D /tmp/mcp-headers.txt \
157+
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"stress-test","version":"1.0.0"}}}'
158+
159+
# Extract session ID from response headers
160+
SESSION=$(grep -i 'mcp-session-id' /tmp/mcp-headers.txt | tr -d '\r' | awk '{print $2}')
161+
162+
# 2. Fire N concurrent tool calls through the single server process
163+
call() {
164+
local id=$1
165+
curl -s --max-time 120 -X POST "http://localhost:6277/mcp" \
166+
-H "Content-Type: application/json" \
167+
-H "x-mcp-proxy-auth: Bearer $TOKEN" \
168+
-H "mcp-session-id: $SESSION" \
169+
-H "Accept: application/json, text/event-stream" \
170+
-d "{\"jsonrpc\":\"2.0\",\"id\":$id,\"method\":\"tools/call\",\"params\":{\"name\":\"surf_twitter_user\",\"arguments\":{\"ref\":\"cascade_fyi\"}}}"
171+
}
172+
173+
# Fire 30 concurrent calls
174+
for i in $(seq 1 30); do call $i & done
175+
wait
176+
```
177+
178+
**Key details:**
179+
- Each CLI invocation is a separate process with its own RPC client - no coalescing across processes
180+
- Coalescing + failover only help in single-process modes: MCP server, OpenClaw plugin
181+
- The Inspector proxy routes all concurrent HTTP requests through one stdio MCP server process
182+
- 429 errors from Solana RPC show as bare `"HTTP error (429)"` in MCP mode (no "Failed to create payment" wrapper)
183+
- Use `surf.cascade.fyi/mcp` as the test endpoint (Solana payments)

packages/x402-proxy/CHANGELOG.md

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

88
## [Unreleased]
99

10+
## [0.9.3] - 2026-03-26
11+
12+
### Fixed
13+
- Solana RPC 429 rate-limit failures on concurrent payments - replaced upstream `ExactSvmScheme` (creates new RPC client per call) with `OptimizedSvmScheme` that shares a single RPC client with `@solana/kit` request coalescing (identical calls in the same microtask merge into one network request)
14+
- Hardcoded USDC mint metadata (Token Program address, 6 decimals) to skip `fetchMint` RPC call entirely for USDC payments
15+
- Added RPC failover transport: on 429 from one endpoint, immediately tries the next instead of failing. Two public mainnet RPCs: `api.mainnet.solana.com` (Solana Labs, 100 req/10s) and `public.rpc.solanavibestation.com` (community, 5 RPS)
16+
- Custom RPC URL (via config or OpenClaw plugin) is tried first, with public RPCs as fallback
17+
18+
### Changed
19+
- Added `@solana-program/compute-budget`, `@solana-program/token`, `@solana-program/token-2022`, `@x402/core` as direct dependencies (previously transitive via `@x402/svm`)
20+
1021
## [0.9.2] - 2026-03-26
1122

1223
### Changed
@@ -284,7 +295,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
284295
- `appendHistory` / `readHistory` / `calcSpend` - JSONL transaction history
285296
- Re-exports from `@x402/fetch`, `@x402/svm`, `@x402/evm`
286297

287-
[Unreleased]: https://github.com/cascade-protocol/x402-proxy/compare/v0.9.2...HEAD
298+
[Unreleased]: https://github.com/cascade-protocol/x402-proxy/compare/v0.9.3...HEAD
299+
[0.9.3]: https://github.com/cascade-protocol/x402-proxy/compare/v0.9.2...v0.9.3
288300
[0.9.2]: https://github.com/cascade-protocol/x402-proxy/compare/v0.9.1...v0.9.2
289301
[0.9.1]: https://github.com/cascade-protocol/x402-proxy/compare/v0.9.0...v0.9.1
290302
[0.9.0]: https://github.com/cascade-protocol/x402-proxy/compare/v0.8.6...v0.9.0

packages/x402-proxy/package.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "x402-proxy",
3-
"version": "0.9.2",
3+
"version": "0.9.3",
44
"description": "curl for x402 paid APIs. Auto-pays any endpoint on Base, Solana, and Tempo. Also works as an OpenClaw plugin.",
55
"type": "module",
66
"sideEffects": false,
@@ -43,8 +43,12 @@
4343
"@scure/bip32": "^2.0.1",
4444
"@scure/bip39": "^2.0.1",
4545
"@sinclair/typebox": "^0.34.48",
46+
"@solana-program/compute-budget": "^0.15.0",
47+
"@solana-program/token": "^0.12.0",
48+
"@solana-program/token-2022": "^0.9.0",
4649
"@solana/kit": "^6.5.0",
4750
"@stricli/core": "^1.2.6",
51+
"@x402/core": "^2.8.0",
4852
"@x402/evm": "^2.8.0",
4953
"@x402/fetch": "^2.8.0",
5054
"@x402/mcp": "^2.8.0",
Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
import {
2+
type Address,
3+
appendTransactionMessageInstructions,
4+
createDefaultRpcTransport,
5+
createSolanaRpcFromTransport,
6+
createTransactionMessage,
7+
getBase64EncodedWireTransaction,
8+
isSolanaError,
9+
mainnet,
10+
partiallySignTransactionMessageWithSigners,
11+
pipe,
12+
prependTransactionMessageInstruction,
13+
SOLANA_ERROR__RPC__TRANSPORT_HTTP_ERROR,
14+
setTransactionMessageFeePayer,
15+
setTransactionMessageLifetimeUsingBlockhash,
16+
type TransactionSigner,
17+
} from "@solana/kit";
18+
import {
19+
getSetComputeUnitLimitInstruction,
20+
setTransactionMessageComputeUnitPrice,
21+
} from "@solana-program/compute-budget";
22+
import { TOKEN_PROGRAM_ADDRESS } from "@solana-program/token";
23+
import {
24+
fetchMint,
25+
findAssociatedTokenPda,
26+
getTransferCheckedInstruction,
27+
TOKEN_2022_PROGRAM_ADDRESS,
28+
} from "@solana-program/token-2022";
29+
import type { PaymentRequirements, SchemeNetworkClient } from "@x402/core/types";
30+
31+
const MEMO_PROGRAM_ADDRESS: Address = "MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr" as Address;
32+
const COMPUTE_UNIT_LIMIT = 20_000;
33+
const COMPUTE_UNIT_PRICE_MICROLAMPORTS = 1;
34+
35+
const USDC_MINT: Address = "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v" as Address;
36+
const USDC_DECIMALS = 6;
37+
38+
// Public no-auth Solana mainnet RPCs used as failover chain.
39+
// On 429 from one endpoint the transport immediately tries the next.
40+
//
41+
// api.mainnet.solana.com (Solana Labs load-balanced cluster):
42+
// 100 req/10s per IP, 40 req/10s per method, 100 MB/30s bandwidth cap.
43+
// (api.mainnet-beta.solana.com is an alias for the same cluster -
44+
// requests to either count against the same rate limit.)
45+
//
46+
// public.rpc.solanavibestation.com (community, self-hosted in Atlanta):
47+
// 5 RPS general limit. 100% uptime last 90 days per status page.
48+
const MAINNET_RPC_URLS = [
49+
"https://api.mainnet.solana.com",
50+
"https://public.rpc.solanavibestation.com",
51+
];
52+
53+
type Transport = ReturnType<typeof createDefaultRpcTransport>;
54+
55+
/**
56+
* Create a failover transport that tries each RPC in order.
57+
* On 429 from one endpoint, immediately tries the next instead of waiting.
58+
* Each transport gets its own coalescing via createDefaultRpcTransport.
59+
*/
60+
function createFailoverTransport(urls: string[]) {
61+
const transports = urls.map((url) => createDefaultRpcTransport({ url }));
62+
const failover: Transport = (async (config) => {
63+
let lastError: unknown;
64+
for (const transport of transports) {
65+
try {
66+
return await transport(config);
67+
} catch (e) {
68+
lastError = e;
69+
if (
70+
isSolanaError(e, SOLANA_ERROR__RPC__TRANSPORT_HTTP_ERROR) &&
71+
e.context.statusCode === 429
72+
) {
73+
continue;
74+
}
75+
throw e;
76+
}
77+
}
78+
throw lastError;
79+
}) as Transport;
80+
return failover;
81+
}
82+
83+
function createRpcClient(customRpcUrl?: string) {
84+
const urls = customRpcUrl ? [customRpcUrl, ...MAINNET_RPC_URLS] : MAINNET_RPC_URLS;
85+
return createSolanaRpcFromTransport(createFailoverTransport(urls.map((u) => mainnet(u))));
86+
}
87+
88+
/**
89+
* Optimized ExactSvmScheme that replaces upstream @x402/svm to prevent
90+
* RPC rate-limit failures on parallel payments.
91+
*
92+
* Two optimizations over upstream:
93+
* 1. Shared RPC client - @solana/kit's built-in request coalescing
94+
* merges identical getLatestBlockhash calls in the same tick into 1.
95+
* 2. Hardcoded USDC - skips fetchMint RPC call for USDC (immutable data).
96+
*/
97+
export class OptimizedSvmScheme implements SchemeNetworkClient {
98+
readonly scheme = "exact";
99+
private readonly rpc: ReturnType<typeof createRpcClient>;
100+
101+
constructor(
102+
private readonly signer: TransactionSigner,
103+
config?: { rpcUrl?: string },
104+
) {
105+
this.rpc = createRpcClient(config?.rpcUrl);
106+
}
107+
108+
async createPaymentPayload(x402Version: number, paymentRequirements: PaymentRequirements) {
109+
const rpc = this.rpc;
110+
111+
const asset = paymentRequirements.asset as Address;
112+
113+
let tokenProgramAddress: Address;
114+
let decimals: number;
115+
if (asset === USDC_MINT) {
116+
tokenProgramAddress = TOKEN_PROGRAM_ADDRESS;
117+
decimals = USDC_DECIMALS;
118+
} else {
119+
const tokenMint = await fetchMint(rpc, asset);
120+
tokenProgramAddress = tokenMint.programAddress;
121+
if (
122+
tokenProgramAddress !== TOKEN_PROGRAM_ADDRESS &&
123+
tokenProgramAddress !== TOKEN_2022_PROGRAM_ADDRESS
124+
) {
125+
throw new Error("Asset was not created by a known token program");
126+
}
127+
decimals = tokenMint.data.decimals;
128+
}
129+
130+
const [sourceATA] = await findAssociatedTokenPda({
131+
mint: asset,
132+
owner: this.signer.address,
133+
tokenProgram: tokenProgramAddress,
134+
});
135+
136+
const [destinationATA] = await findAssociatedTokenPda({
137+
mint: asset,
138+
owner: paymentRequirements.payTo as Address,
139+
tokenProgram: tokenProgramAddress,
140+
});
141+
142+
const transferIx = getTransferCheckedInstruction(
143+
{
144+
source: sourceATA,
145+
mint: asset,
146+
destination: destinationATA,
147+
authority: this.signer,
148+
amount: BigInt(paymentRequirements.amount),
149+
decimals,
150+
},
151+
{ programAddress: tokenProgramAddress },
152+
);
153+
154+
const feePayer = paymentRequirements.extra?.feePayer as Address;
155+
if (!feePayer) {
156+
throw new Error("feePayer is required in paymentRequirements.extra for SVM transactions");
157+
}
158+
159+
const { value: latestBlockhash } = await rpc.getLatestBlockhash().send();
160+
161+
const nonce = crypto.getRandomValues(new Uint8Array(16));
162+
const memoIx = {
163+
programAddress: MEMO_PROGRAM_ADDRESS,
164+
accounts: [] as const,
165+
data: new TextEncoder().encode(
166+
Array.from(nonce)
167+
.map((b) => b.toString(16).padStart(2, "0"))
168+
.join(""),
169+
),
170+
};
171+
172+
const tx = pipe(
173+
createTransactionMessage({ version: 0 }),
174+
(tx) => setTransactionMessageComputeUnitPrice(COMPUTE_UNIT_PRICE_MICROLAMPORTS, tx),
175+
(tx) => setTransactionMessageFeePayer(feePayer, tx),
176+
(tx) =>
177+
prependTransactionMessageInstruction(
178+
getSetComputeUnitLimitInstruction({ units: COMPUTE_UNIT_LIMIT }),
179+
tx,
180+
),
181+
(tx) => appendTransactionMessageInstructions([transferIx, memoIx], tx),
182+
(tx) => setTransactionMessageLifetimeUsingBlockhash(latestBlockhash, tx),
183+
);
184+
185+
const signedTransaction = await partiallySignTransactionMessageWithSigners(tx);
186+
187+
return {
188+
x402Version,
189+
payload: { transaction: getBase64EncodedWireTransaction(signedTransaction) },
190+
};
191+
}
192+
}

packages/x402-proxy/src/lib/resolve-wallet.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,13 @@ import { base58 } from "@scure/base";
33
import { toClientEvmSigner } from "@x402/evm";
44
import { registerExactEvmScheme } from "@x402/evm/exact/client";
55
import { type PaymentPolicy, type SelectPaymentRequirements, x402Client } from "@x402/fetch";
6-
import { registerExactSvmScheme } from "@x402/svm/exact/client";
76
import { createPublicClient, http } from "viem";
87
import { privateKeyToAccount } from "viem/accounts";
98
import { base } from "viem/chains";
109
import { calcSpend, displayNetwork, formatUsdcValue, readHistory } from "../history.js";
1110
import { getHistoryPath, loadWalletFile } from "./config.js";
1211
import { deriveEvmKeypair, deriveSolanaKeypair } from "./derive.js";
12+
import { OptimizedSvmScheme } from "./optimized-svm-scheme.js";
1313

1414
export type WalletSource = "flag" | "env" | "mnemonic-env" | "wallet-file" | "none";
1515

@@ -203,7 +203,7 @@ export async function buildX402Client(
203203
if (wallet.solanaKey) {
204204
const { createKeyPairSignerFromBytes } = await import("@solana/kit");
205205
const signer = await createKeyPairSignerFromBytes(wallet.solanaKey);
206-
registerExactSvmScheme(client, { signer });
206+
client.register("solana:*", new OptimizedSvmScheme(signer));
207207
}
208208

209209
client.registerPolicy(createAddressValidationPolicy());

packages/x402-proxy/src/openclaw/plugin.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,9 @@ import { homedir } from "node:os";
22
import { join } from "node:path";
33
import { createKeyPairSignerFromBytes, type KeyPairSigner } from "@solana/kit";
44
import { x402Client } from "@x402/fetch";
5-
import { ExactSvmScheme } from "@x402/svm/exact/client";
65
import { createX402ProxyHandler, type X402ProxyHandler } from "../handler.js";
76
import { getHistoryPath } from "../lib/config.js";
7+
import { OptimizedSvmScheme } from "../lib/optimized-svm-scheme.js";
88
import { resolveWallet } from "../lib/resolve-wallet.js";
99
import { loadSvmWallet } from "../wallet.js";
1010
import { createWalletCommand } from "./commands.js";
@@ -147,7 +147,7 @@ export function register(api: OpenClawPluginApi): void {
147147
}
148148

149149
const client = new x402Client();
150-
client.register(SOL_MAINNET, new ExactSvmScheme(signer, { rpcUrl }));
150+
client.register(SOL_MAINNET, new OptimizedSvmScheme(signer, { rpcUrl }));
151151
proxyRef = createX402ProxyHandler({ client });
152152

153153
const upstreamOrigin = upstreamOrigins[0];

0 commit comments

Comments
 (0)