Skip to content

feat(solana): add Solana x402 payment support + SDK migration#54

Open
tenequm wants to merge 13 commits intoBlockRunAI:mainfrom
tenequm:feat/solana-support
Open

feat(solana): add Solana x402 payment support + SDK migration#54
tenequm wants to merge 13 commits intoBlockRunAI:mainfrom
tenequm:feat/solana-support

Conversation

@tenequm
Copy link

@tenequm tenequm commented Feb 26, 2026

Summary

Add Solana x402 payment support alongside existing EVM (Base) payments, and migrate from hand-rolled x402 implementation to official @x402/fetch, @x402/evm, and @x402/svm SDKs.

Key changes:

  • BIP-39/BIP-44 HD wallet derivation - single mnemonic derives both EVM and Solana keys
  • Dual-chain x402 payment signing via registerExactEvmScheme + registerExactSvmScheme
  • Solana USDC balance monitoring with configurable RPC (CLAWROUTER_SOLANA_RPC_URL)
  • Payment chain logging via x402.onAfterPaymentCreation() hook
  • /wallet export now includes mnemonic + Solana address for full backup
  • /wallet setup-solana command for existing EVM-only users
  • Fund-loss protection: refuses to overwrite mnemonic if wallet.key is deleted
  • ProxyOptions refactored to accept wallet: WalletConfig object (prevents callers from forgetting to forward Solana keys)

Files added:

  • src/wallet.ts - BIP-39 mnemonic + BIP-44 key derivation
  • src/solana-balance.ts - Solana USDC balance checker
  • src/spend-control.ts - Time-windowed spending limits
  • src/wallet.test.ts (14 tests), src/x402-sdk.test.ts (7 tests), src/spend-control.test.ts (23 tests)
  • test/test-solana-e2e.ts - E2E test against real blockrun.ai with dual-chain proxy

Files removed (replaced by SDK):

  • src/x402.ts, src/payment-cache.ts, src/x402.test.ts

Three wallet scenarios supported:

  1. wallet.key exists, no mnemonic - EVM-only mode
  2. wallet.key + mnemonic exist - dual-chain mode
  3. Nothing exists - generates BIP-39 mnemonic, derives both chains

Test plan

  • TypeScript typecheck passes (npx tsc --noEmit)
  • Build succeeds (npm run build)
  • 256 unit/integration tests pass (npm test)
  • Solana E2E test passes against real blockrun.ai (npx tsx test/test-solana-e2e.ts)
  • Payment chain logging verified in E2E output: [ClawRouter] Payment signed on Base (EVM) (eip155:8453)
  • Wallet migration scenarios traced through code for all 3 paths + edge cases

- Add Solana USDC payment signing via @x402/svm ExactSvmScheme
- Replace hand-rolled x402.ts with official @x402/fetch + @x402/evm SDK
- Add BIP-39 mnemonic generation + BIP-44 key derivation (EVM + Solana)
- Add SolanaBalanceMonitor for USDC balance checking on Solana
- Add SpendControl with per-request, hourly, daily, session limits
- Add /wallet setup-solana command for existing EVM-only users
- Dual-chain display in wallet status (EVM + Solana balances)
- Remove payment-cache.ts and pre-auth optimization (SDK handles retries)
- Dynamic Solana module loading (only when Solana key is present)
- Remove unused generatePrivateKey import from auth.ts
- Replace manual x402.register() with registerExactSvmScheme() helper
  which registers solana:* wildcard + V1 compat names
- Remove unused solanaRpcUrl from ProxyOptions
- Remove stale "Payment cache" comment from proxy.ts header
- Update cost buffer comment (was referencing removed pre-auth)
- Convert SolanaBalanceMonitor to dynamic import in index.ts
  so CLI users don't load @solana/kit at startup
- wallet.test.ts (14 tests): BIP-39 generation, BIP-44 derivation for
  both EVM and Solana, determinism, Solana signer creation
- x402-sdk.test.ts (7 tests): client creation, EVM + Solana scheme
  registration, wrapFetchWithPayment pass-through, streaming, 402 flow
- spend-control.test.ts (23 tests): per-request/hourly/daily/session
  limits, window expiry, persistence, validation, formatDuration
If a user deletes wallet.key but mnemonic file exists (from setup-solana),
generateAndSaveWallet() would silently overwrite the mnemonic, destroying
the Solana wallet derived from it. Now refuses to generate and tells the
user to restore their EVM key instead.
CLI entry point was not destructuring or forwarding Solana key bytes
from resolveOrGenerateWalletKey(), so standalone CLI users would never
get Solana payments registered even with a valid mnemonic file.
ProxyOptions.wallet now accepts either a plain key string (EVM-only,
backward compat for tests) or the full WalletResolution object from
resolveOrGenerateWalletKey(). This prevents callers from accidentally
forgetting to forward solanaPrivateKeyBytes.

- Export WalletResolution type from auth.ts
- Add WalletConfig union type to proxy.ts
- Update cli.ts, index.ts, test/integration/setup.ts to pass wallet object
- Solana now auto-registers in integration tests (previously missing)
…letKey:

22 occurrences across 12 test files updated to match the new
ProxyOptions.wallet interface.
… v0.11.0

- Add x402 onAfterPaymentCreation hook to log which chain (Base/Solana)
  is used for each payment
- Update /wallet export to include mnemonic and Solana address when
  available, with restore instructions for both chains
- Add test/test-solana-e2e.ts validating dual-chain proxy startup,
  wallet derivation, and 402 flow against real blockrun.ai
- Update README with Solana badge, dual-chain payment docs, setup-solana
  command, and CLAWROUTER_SOLANA_RPC_URL config
- Bump version to 0.11.0 for Solana support release
blockrun.ai/api only offers EVM payment options. Solana payments
require sol.blockrun.ai/api. Default to Solana endpoint when
Solana keys are present.
Tests all wallet scenarios on real disk with temp HOME:
- Fresh install: generates mnemonic + both keys, verifies 0o600 permissions
- Existing wallet.key only: EVM-only path, no Solana
- wallet.key + setup-solana: creates mnemonic, leaves EVM untouched
- Edge cases: protective error on missing wallet.key, duplicate setup-solana
@1bcMax
Copy link
Member

1bcMax commented Mar 2, 2026

Code review

Found 2 issues:

  1. src/spend-control.ts uses fs.readFileSync() directly instead of readTextFileSync from ./fs-read.js. PR Create helper function to handle reads #42 established and enforced a pattern that all production disk reads must go through the readTextFile/readTextFileSync helpers to avoid false-positive "potential exfiltration" warnings from the OpenClaw security scanner. All other production files follow this pattern. The fix is to replace fs.readFileSync(this.spendingFile, "utf-8") with readTextFileSync(this.spendingFile).

import * as fs from "node:fs";
import * as path from "node:path";

try {
if (fs.existsSync(this.spendingFile)) {
const data = JSON.parse(fs.readFileSync(this.spendingFile, "utf-8"));
const rawLimits = data.limits ?? {};

  1. loadMnemonic() silently returns undefined when the mnemonic file exists but contains invalid/corrupt data. The existing loadSavedWallet() in the same file has an explicit safety pattern: "do NOT silently fall through to generate a new wallet — this would silently replace a funded wallet with an empty one" (line 57). The new loadMnemonic violates this principle — a corrupt mnemonic file causes the Solana wallet to be silently abandoned without any error or warning, potentially losing Solana funds at the derived address. It should throw an error (matching the loadSavedWallet pattern) when the file exists but fails validation.

ClawRouter/src/auth.ts

Lines 57 to 67 in 6071556

// File exists but content is wrong — do NOT silently fall through to generate a new wallet.
// This would silently replace a funded wallet with an empty one.
console.error(`[ClawRouter] ✗ CRITICAL: Wallet file exists but has invalid format!`);
console.error(`[ClawRouter] File: ${WALLET_FILE}`);
console.error(`[ClawRouter] Expected: 0x followed by 64 hex characters (66 chars total)`);
console.error(`[ClawRouter] To fix: restore your backup key or set BLOCKRUN_WALLET_KEY env var`);
throw new Error(
`Wallet file at ${WALLET_FILE} is corrupted or has wrong format. ` +
`Refusing to auto-generate new wallet to protect existing funds. ` +
`Restore your backup key or set BLOCKRUN_WALLET_KEY environment variable.`,
);

ClawRouter/src/auth.ts

Lines 88 to 100 in 6071556

/**
* Load mnemonic from disk if it exists.
*/
async function loadMnemonic(): Promise<string | undefined> {
try {
const mnemonic = (await readTextFile(MNEMONIC_FILE)).trim();
if (mnemonic && isValidMnemonic(mnemonic)) {
return mnemonic;
}
} catch {
// No mnemonic file or invalid - that's fine
}
return undefined;

🤖 Generated with Claude Code

- If this code review was useful, please react with 👍. Otherwise, react with 👎.

1bcMax added a commit that referenced this pull request Mar 2, 2026
- doctor.ts: remove unused imports (homedir, join, stat, readdir)
- spend-control.test.ts: let → const for non-reassigned variable
- x402-sdk.test.ts: remove unused generateWalletMnemonic import
1bcMax added a commit that referenced this pull request Mar 2, 2026
- doctor.ts: remove unused imports (homedir, join, stat, readdir)
- spend-control.test.ts: let → const for non-reassigned variable
- x402-sdk.test.ts: remove unused generateWalletMnemonic import
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants