From b6dbc399156d06bd627049ac3e11fb5441327bad Mon Sep 17 00:00:00 2001 From: 1bcMax Date: Tue, 3 Mar 2026 20:07:02 -0500 Subject: [PATCH] fix: use SLIP-10 Ed25519 for Solana wallet derivation (Phantom-compatible) The old derivation used secp256k1 BIP-32 (HDKey) for Solana keys at m/44'/501'/0'/0'. All Solana wallets (Phantom, Solflare, Backpack) use SLIP-10 Ed25519 instead. Same mnemonic + same path + different curve = completely different address, making mnemonic recovery impossible. Changes: - wallet.ts: SLIP-10 Ed25519 derivation via @noble/hashes HMAC-SHA512 - wallet.ts: old function preserved as deriveSolanaKeyBytesLegacy() for sweep - auth.ts: migration detection on startup, logs old/new addresses - solana-sweep.ts: new file to transfer USDC from legacy to new wallet - index.ts: /wallet migrate-solana command + exports - wallet.test.ts: known test vectors, Phantom address verification --- package.json | 2 +- src/auth.ts | 58 +++++++++- src/cli.ts | 15 +-- src/index.ts | 107 ++++++++++++++++- src/solana-sweep.ts | 273 ++++++++++++++++++++++++++++++++++++++++++++ src/wallet.test.ts | 72 +++++++++++- src/wallet.ts | 56 ++++++++- 7 files changed, 565 insertions(+), 18 deletions(-) create mode 100644 src/solana-sweep.ts diff --git a/package.json b/package.json index 21767f5..3f58bca 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@blockrun/clawrouter", - "version": "0.11.14", + "version": "0.12.0", "description": "Smart LLM router — save 92% on inference costs. 41+ models, one wallet, x402 micropayments.", "type": "module", "main": "dist/index.js", diff --git a/src/auth.ts b/src/auth.ts index 227f9d2..f97ff67 100644 --- a/src/auth.ts +++ b/src/auth.ts @@ -33,6 +33,7 @@ import { generateWalletMnemonic, isValidMnemonic, deriveSolanaKeyBytes, + deriveSolanaKeyBytesLegacy, deriveAllKeys, } from "./wallet.js"; @@ -193,6 +194,39 @@ async function generateAndSaveWallet(): Promise<{ }; } +/** + * Log migration warning when legacy and new Solana addresses differ. + * Checks old wallet USDC balance and prints instructions. + */ +async function logMigrationWarning( + legacyKeyBytes: Uint8Array, + newKeyBytes: Uint8Array, +): Promise { + try { + const { createKeyPairSignerFromPrivateKeyBytes } = await import("@solana/kit"); + const [oldSigner, newSigner] = await Promise.all([ + createKeyPairSignerFromPrivateKeyBytes(legacyKeyBytes), + createKeyPairSignerFromPrivateKeyBytes(newKeyBytes), + ]); + + console.log(`[ClawRouter]`); + console.log(`[ClawRouter] ⚠ SOLANA WALLET MIGRATION DETECTED`); + console.log(`[ClawRouter] ════════════════════════════════════════════════`); + console.log(`[ClawRouter] Old address (secp256k1): ${oldSigner.address}`); + console.log(`[ClawRouter] New address (SLIP-10): ${newSigner.address}`); + console.log(`[ClawRouter]`); + console.log(`[ClawRouter] Your Solana wallet derivation has been fixed to use`); + console.log(`[ClawRouter] SLIP-10 Ed25519 (Phantom/Solflare compatible).`); + console.log(`[ClawRouter]`); + console.log(`[ClawRouter] If you had funds in the old wallet, run:`); + console.log(`[ClawRouter] /wallet migrate-solana`); + console.log(`[ClawRouter] ════════════════════════════════════════════════`); + console.log(`[ClawRouter]`); + } catch { + // Non-fatal — don't block startup if signer creation fails + } +} + /** * Resolve wallet key: load saved → env var → auto-generate. * Also loads mnemonic if available for Solana key derivation. @@ -204,6 +238,8 @@ export type WalletResolution = { source: "saved" | "env" | "generated"; mnemonic?: string; solanaPrivateKeyBytes?: Uint8Array; + /** Legacy (secp256k1) Solana key bytes, present when migration is needed. */ + legacySolanaKeyBytes?: Uint8Array; }; export async function resolveOrGenerateWalletKey(): Promise { @@ -216,13 +252,22 @@ export async function resolveOrGenerateWalletKey(): Promise { const mnemonic = await loadMnemonic(); if (mnemonic) { const solanaKeyBytes = deriveSolanaKeyBytes(mnemonic); - return { + const result: WalletResolution = { key: saved, address: account.address, source: "saved", mnemonic, solanaPrivateKeyBytes: solanaKeyBytes, }; + + // Migration detection: compare legacy (secp256k1) vs new (SLIP-10) Solana keys + const legacyKeyBytes = deriveSolanaKeyBytesLegacy(mnemonic); + if (Buffer.from(legacyKeyBytes).toString("hex") !== Buffer.from(solanaKeyBytes).toString("hex")) { + result.legacySolanaKeyBytes = legacyKeyBytes; + await logMigrationWarning(legacyKeyBytes, solanaKeyBytes); + } + + return result; } return { key: saved, address: account.address, source: "saved" }; @@ -237,13 +282,22 @@ export async function resolveOrGenerateWalletKey(): Promise { const mnemonic = await loadMnemonic(); if (mnemonic) { const solanaKeyBytes = deriveSolanaKeyBytes(mnemonic); - return { + const result: WalletResolution = { key: envKey, address: account.address, source: "env", mnemonic, solanaPrivateKeyBytes: solanaKeyBytes, }; + + // Migration detection + const legacyKeyBytes = deriveSolanaKeyBytesLegacy(mnemonic); + if (Buffer.from(legacyKeyBytes).toString("hex") !== Buffer.from(solanaKeyBytes).toString("hex")) { + result.legacySolanaKeyBytes = legacyKeyBytes; + await logMigrationWarning(legacyKeyBytes, solanaKeyBytes); + } + + return result; } return { key: envKey, address: account.address, source: "env" }; diff --git a/src/cli.ts b/src/cli.ts index 361bebc..a05c669 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -14,8 +14,7 @@ */ import { startProxy, getProxyPort } from "./proxy.js"; -import { resolveOrGenerateWalletKey } from "./auth.js"; -import { BalanceMonitor } from "./balance.js"; +import { resolveOrGenerateWalletKey, resolvePaymentChain } from "./auth.js"; import { generateReport } from "./report.js"; import { VERSION } from "./version.js"; import { runDoctor } from "./doctor.js"; @@ -246,20 +245,22 @@ async function main(): Promise { }, }); - // Check balance - const monitor = new BalanceMonitor(wallet.address); + // Check balance on the active payment chain + const paymentChain = await resolvePaymentChain(); + const displayAddress = + paymentChain === "solana" && proxy.solanaAddress ? proxy.solanaAddress : wallet.address; try { - const balance = await monitor.checkBalance(); + const balance = await proxy.balanceMonitor.checkBalance(); if (balance.isEmpty) { console.log(`[ClawRouter] Wallet balance: $0.00 (using FREE model)`); - console.log(`[ClawRouter] Fund wallet for premium models: ${wallet.address}`); + console.log(`[ClawRouter] Fund wallet for premium models: ${displayAddress}`); } else if (balance.isLow) { console.log(`[ClawRouter] Wallet balance: ${balance.balanceUSD} (low)`); } else { console.log(`[ClawRouter] Wallet balance: ${balance.balanceUSD}`); } } catch { - console.log(`[ClawRouter] Wallet: ${wallet.address} (balance check pending)`); + console.log(`[ClawRouter] Wallet: ${displayAddress} (balance check pending)`); } console.log(`[ClawRouter] Ready - Ctrl+C to stop`); diff --git a/src/index.ts b/src/index.ts index e7bc971..d19c88a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -476,8 +476,10 @@ async function startProxyInBackground(api: OpenClawPluginApi): Promise { api.logger.info(`Pricing: Simple ~$0.001 | Code ~$0.01 | Complex ~$0.05 | Free: $0`); // Non-blocking balance check AFTER proxy is ready (won't hang startup) - // Uses the proxy's chain-aware balance monitor (Solana or EVM) - const displayAddress = proxy.solanaAddress ?? wallet.address; + // Uses the proxy's chain-aware balance monitor and matching active-chain address. + const currentChain = await resolvePaymentChain(); + const displayAddress = + currentChain === "solana" && proxy.solanaAddress ? proxy.solanaAddress : wallet.address; proxy.balanceMonitor .checkBalance() .then((balance) => { @@ -679,6 +681,103 @@ async function createWalletCommand(): Promise { } } + if (subcommand === "migrate-solana") { + // Sweep USDC from legacy (secp256k1) wallet to new (SLIP-10) wallet + try { + if (!existsSync(MNEMONIC_FILE)) { + return { + text: "No mnemonic file found. Solana wallet not set up — nothing to migrate.", + isError: true, + }; + } + + const mnemonic = readTextFileSync(MNEMONIC_FILE).trim(); + if (!mnemonic) { + return { text: "Mnemonic file is empty.", isError: true }; + } + + const { deriveSolanaKeyBytes, deriveSolanaKeyBytesLegacy } = await import("./wallet.js"); + const { createKeyPairSignerFromPrivateKeyBytes } = await import("@solana/kit"); + + const legacyKeyBytes = deriveSolanaKeyBytesLegacy(mnemonic); + const newKeyBytes = deriveSolanaKeyBytes(mnemonic); + + const [oldSigner, newSigner] = await Promise.all([ + createKeyPairSignerFromPrivateKeyBytes(legacyKeyBytes), + createKeyPairSignerFromPrivateKeyBytes(newKeyBytes), + ]); + + if (oldSigner.address === newSigner.address) { + return { text: "Legacy and new Solana addresses are the same. No migration needed." }; + } + + // Check old wallet balance before attempting sweep + let oldUsdcText = "unknown"; + try { + const { SolanaBalanceMonitor } = await import("./solana-balance.js"); + const monitor = new SolanaBalanceMonitor(oldSigner.address); + const balance = await monitor.checkBalance(); + oldUsdcText = balance.balanceUSD; + + if (balance.isEmpty) { + return { + text: [ + "**Solana Migration Status**", + "", + `Old wallet (secp256k1): \`${oldSigner.address}\``, + ` USDC: $0.00`, + "", + `New wallet (SLIP-10): \`${newSigner.address}\``, + "", + "No USDC in old wallet. Nothing to sweep.", + "Your new SLIP-10 address is Phantom/Solflare compatible.", + ].join("\n"), + }; + } + } catch { + // Continue — sweep function will also check balance + } + + // Attempt sweep + const { sweepSolanaWallet } = await import("./solana-sweep.js"); + const result = await sweepSolanaWallet(legacyKeyBytes, newSigner.address); + + if ("error" in result) { + return { + text: [ + "**Solana Migration Failed**", + "", + `Old wallet: \`${result.oldAddress}\` (USDC: ${oldUsdcText})`, + `New wallet: \`${result.newAddress || newSigner.address}\``, + "", + `Error: ${result.error}`, + ].join("\n"), + isError: true, + }; + } + + return { + text: [ + "**Solana Migration Complete**", + "", + `Swept **${result.transferred}** USDC from old to new wallet.`, + "", + `Old wallet: \`${result.oldAddress}\``, + `New wallet: \`${result.newAddress}\``, + `TX: https://solscan.io/tx/${result.txSignature}`, + "", + "Your new SLIP-10 address is Phantom/Solflare compatible.", + "You can recover it from your 24-word mnemonic in any standard wallet.", + ].join("\n"), + }; + } catch (err) { + return { + text: `Migration failed: ${err instanceof Error ? err.message : String(err)}`, + isError: true, + }; + } + } + if (subcommand === "base") { // Switch back to Base (EVM) payment chain try { @@ -762,6 +861,7 @@ async function createWalletCommand(): Promise { !solanaSection ? "• `/wallet solana` - Enable Solana payments" : "", solanaSection ? "• `/wallet base` - Switch to Base (EVM)" : "", solanaSection ? "• `/wallet solana` - Switch to Solana" : "", + solanaSection ? "• `/wallet migrate-solana` - Sweep funds from old Solana wallet" : "", ] .filter(Boolean) .join("\n"), @@ -1026,9 +1126,12 @@ export { isValidMnemonic, deriveEvmKey, deriveSolanaKeyBytes, + deriveSolanaKeyBytesLegacy, deriveAllKeys, } from "./wallet.js"; export type { DerivedKeys } from "./wallet.js"; +export { sweepSolanaWallet } from "./solana-sweep.js"; +export type { SweepResult, SweepError } from "./solana-sweep.js"; export { setupSolana, savePaymentChain, loadPaymentChain, resolvePaymentChain } from "./auth.js"; export { InsufficientFundsError, diff --git a/src/solana-sweep.ts b/src/solana-sweep.ts new file mode 100644 index 0000000..39dcbf5 --- /dev/null +++ b/src/solana-sweep.ts @@ -0,0 +1,273 @@ +/** + * Solana Wallet Sweep — migrate USDC from legacy (secp256k1) to new (SLIP-10) wallet. + * + * Used when upgrading from the old BIP-32 secp256k1 derivation to correct + * SLIP-10 Ed25519 derivation. Transfers all USDC from the old address to the new one. + * + * Uses raw instruction encoding to avoid @solana-program/token dependency. + */ + +import { + address as solAddress, + createSolanaRpc, + createSolanaRpcSubscriptions, + createKeyPairSignerFromPrivateKeyBytes, + pipe, + createTransactionMessage, + setTransactionMessageFeePayer, + setTransactionMessageLifetimeUsingBlockhash, + appendTransactionMessageInstructions, + signTransactionMessageWithSigners, + getSignatureFromTransaction, + sendAndConfirmTransactionFactory, + getProgramDerivedAddress, + getAddressEncoder, + type Address, +} from "@solana/kit"; + +const SOLANA_USDC_MINT = "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v"; +const SOLANA_DEFAULT_RPC = "https://api.mainnet-beta.solana.com"; +const TOKEN_PROGRAM = "TokenkegQfeN4jV6bme4LphiJbfPe2VopRsimuVSoZ5K" as Address; +const ASSOCIATED_TOKEN_PROGRAM = "ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL" as Address; +const SYSTEM_PROGRAM = "11111111111111111111111111111111" as Address; + +export type SweepResult = { + transferred: string; // e.g. "$1.23" + transferredMicros: bigint; + txSignature: string; + oldAddress: string; + newAddress: string; +}; + +export type SweepError = { + error: string; + oldAddress: string; + newAddress?: string; + solBalance?: bigint; + usdcBalance?: bigint; +}; + +/** + * Derive the Associated Token Account (ATA) address for an owner + mint. + */ +async function getAssociatedTokenAddress(owner: Address, mint: Address): Promise
{ + const encoder = getAddressEncoder(); + const [ata] = await getProgramDerivedAddress({ + programAddress: ASSOCIATED_TOKEN_PROGRAM, + seeds: [encoder.encode(owner), encoder.encode(TOKEN_PROGRAM), encoder.encode(mint)], + }); + return ata; +} + +/** + * Build a "create associated token account idempotent" instruction. + * Instruction index 1 of the Associated Token Account program. + */ +function buildCreateAtaIdempotentInstruction( + payer: Address, + ata: Address, + owner: Address, + mint: Address, +) { + return { + programAddress: ASSOCIATED_TOKEN_PROGRAM, + accounts: [ + { address: payer, role: 3 /* writable signer */ }, + { address: ata, role: 1 /* writable */ }, + { address: owner, role: 0 /* readonly */ }, + { address: mint, role: 0 /* readonly */ }, + { address: SYSTEM_PROGRAM, role: 0 /* readonly */ }, + { address: TOKEN_PROGRAM, role: 0 /* readonly */ }, + ], + data: new Uint8Array([1]), // instruction index 1 = CreateIdempotent + } as const; +} + +/** + * Build a SPL Token "Transfer" instruction (instruction index 3). + * Encodes amount as little-endian u64. + */ +function buildTokenTransferInstruction( + source: Address, + destination: Address, + authority: Address, + amount: bigint, +) { + // SPL Token Transfer: 1 byte instruction (3) + 8 bytes LE u64 amount + const data = new Uint8Array(9); + data[0] = 3; // Transfer instruction index + // Write amount as little-endian u64 + const view = new DataView(data.buffer, data.byteOffset); + view.setBigUint64(1, amount, true); + + return { + programAddress: TOKEN_PROGRAM, + accounts: [ + { address: source, role: 1 /* writable */ }, + { address: destination, role: 1 /* writable */ }, + { address: authority, role: 2 /* signer */ }, + ], + data, + } as const; +} + +/** + * Sweep all USDC from old (legacy secp256k1) wallet to new (SLIP-10) wallet. + * + * @param oldKeyBytes - 32-byte private key from legacy derivation + * @param newAddress - Solana address of the new (correct) wallet + * @param rpcUrl - Optional RPC URL override + * @returns SweepResult on success, SweepError on failure + */ +export async function sweepSolanaWallet( + oldKeyBytes: Uint8Array, + newAddress: string, + rpcUrl?: string, +): Promise { + const url = rpcUrl || process["env"].CLAWROUTER_SOLANA_RPC_URL || SOLANA_DEFAULT_RPC; + const rpc = createSolanaRpc(url); + + // 1. Create signer from old key bytes + const oldSigner = await createKeyPairSignerFromPrivateKeyBytes(oldKeyBytes); + const oldAddress = oldSigner.address; + + const mint = solAddress(SOLANA_USDC_MINT); + const newOwner = solAddress(newAddress); + + // 2. Check old wallet SOL balance (for gas) + let solBalance: bigint; + try { + const solResp = await rpc.getBalance(solAddress(oldAddress)).send(); + solBalance = solResp.value; + } catch (err) { + return { + error: `Failed to check SOL balance: ${err instanceof Error ? err.message : String(err)}`, + oldAddress, + newAddress, + }; + } + + // 3. Check old wallet USDC balance + let usdcBalance = 0n; + let oldTokenAccount: string | undefined; + try { + const response = await rpc + .getTokenAccountsByOwner( + solAddress(oldAddress), + { mint }, + { encoding: "jsonParsed" }, + ) + .send(); + + if (response.value.length > 0) { + for (const account of response.value) { + const parsed = account.account.data as { + parsed: { info: { tokenAmount: { amount: string } } }; + }; + const amount = BigInt(parsed.parsed.info.tokenAmount.amount); + if (amount > 0n) { + usdcBalance += amount; + oldTokenAccount = account.pubkey; + } + } + } + } catch (err) { + return { + error: `Failed to check USDC balance: ${err instanceof Error ? err.message : String(err)}`, + oldAddress, + newAddress, + }; + } + + if (usdcBalance === 0n) { + return { + error: "No USDC found in old wallet. Nothing to sweep.", + oldAddress, + newAddress, + solBalance, + usdcBalance: 0n, + }; + } + + // 4. Check if enough SOL for gas (~0.005 SOL = 5_000_000 lamports) + const MIN_SOL_FOR_GAS = 5_000_000n; + if (solBalance < MIN_SOL_FOR_GAS) { + const needed = Number(MIN_SOL_FOR_GAS - solBalance) / 1e9; + return { + error: + `Insufficient SOL for transaction fees. ` + + `Send ~${needed.toFixed(4)} SOL to ${oldAddress} to cover gas. ` + + `Current SOL balance: ${(Number(solBalance) / 1e9).toFixed(6)} SOL`, + oldAddress, + newAddress, + solBalance, + usdcBalance, + }; + } + + if (!oldTokenAccount) { + return { + error: "Could not find USDC token account in old wallet.", + oldAddress, + newAddress, + }; + } + + // 5. Build and send SPL token transfer + try { + // Derive ATA for new wallet + const newAta = await getAssociatedTokenAddress(newOwner, mint); + + // Get recent blockhash + const { value: latestBlockhash } = await rpc.getLatestBlockhash().send(); + + // Build instructions: create ATA (idempotent) + transfer USDC + const createAtaIx = buildCreateAtaIdempotentInstruction( + oldSigner.address, + newAta, + newOwner, + mint, + ); + + const transferIx = buildTokenTransferInstruction( + solAddress(oldTokenAccount), + newAta, + oldSigner.address, + usdcBalance, + ); + + const txMessage = pipe( + createTransactionMessage({ version: 0 }), + (msg) => setTransactionMessageFeePayer(oldSigner.address, msg), + (msg) => setTransactionMessageLifetimeUsingBlockhash(latestBlockhash, msg), + (msg) => appendTransactionMessageInstructions([createAtaIx, transferIx], msg), + ); + + const signedTx = await signTransactionMessageWithSigners(txMessage); + const txSignature = getSignatureFromTransaction(signedTx); + + // Send transaction and poll for confirmation + const wsUrl = url.replace("https://", "wss://").replace("http://", "ws://"); + const rpcSubscriptions = createSolanaRpcSubscriptions(wsUrl); + const sendAndConfirm = sendAndConfirmTransactionFactory({ rpc, rpcSubscriptions }); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await sendAndConfirm(signedTx as any, { commitment: "confirmed" }); + + const dollars = Number(usdcBalance) / 1_000_000; + return { + transferred: `$${dollars.toFixed(2)}`, + transferredMicros: usdcBalance, + txSignature, + oldAddress, + newAddress, + }; + } catch (err) { + return { + error: `Transaction failed: ${err instanceof Error ? err.message : String(err)}`, + oldAddress, + newAddress, + solBalance, + usdcBalance, + }; + } +} diff --git a/src/wallet.test.ts b/src/wallet.test.ts index a3ef1b0..c472c20 100644 --- a/src/wallet.test.ts +++ b/src/wallet.test.ts @@ -1,5 +1,6 @@ /** * Wallet derivation tests — BIP-39 mnemonic + BIP-44 key derivation. + * Tests SLIP-10 Ed25519 (Phantom-compatible) and legacy secp256k1 derivation. */ import { describe, it, expect } from "vitest"; @@ -8,6 +9,7 @@ import { isValidMnemonic, deriveEvmKey, deriveSolanaKeyBytes, + deriveSolanaKeyBytesLegacy, deriveAllKeys, } from "./wallet.js"; @@ -15,6 +17,15 @@ describe("wallet key derivation", () => { const TEST_MNEMONIC = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon art"; + // Known SLIP-10 Ed25519 derivation result for the test mnemonic at m/44'/501'/0'/0' + const EXPECTED_SLIP10_KEY_HEX = + "7c139e1a603ca04f5f7cff194e1bb6f6d1b9098470ea90695ab628488a9f921b"; + const EXPECTED_SLIP10_ADDRESS = "3Cy3YNTFywCmxoxt8n7UH6hg6dLo5uACowX3CFceaSnx"; + + // Known legacy (secp256k1) derivation result — different from SLIP-10 + const EXPECTED_LEGACY_KEY_HEX = + "39193f920d8cefc6f5ad9b5371d2331744d6b4a406764bbccbb1e9ac72f84b6f"; + describe("generateWalletMnemonic", () => { it("generates a valid 24-word mnemonic", () => { const mnemonic = generateWalletMnemonic(); @@ -62,7 +73,7 @@ describe("wallet key derivation", () => { }); }); - describe("deriveSolanaKeyBytes", () => { + describe("deriveSolanaKeyBytes (SLIP-10 Ed25519)", () => { it("returns a 32-byte Uint8Array", () => { const bytes = deriveSolanaKeyBytes(TEST_MNEMONIC); expect(bytes).toBeInstanceOf(Uint8Array); @@ -80,6 +91,58 @@ describe("wallet key derivation", () => { const b = deriveSolanaKeyBytes(generateWalletMnemonic()); expect(Buffer.from(a).toString("hex")).not.toBe(Buffer.from(b).toString("hex")); }); + + it("produces known SLIP-10 key for test mnemonic", () => { + const bytes = deriveSolanaKeyBytes(TEST_MNEMONIC); + expect(Buffer.from(bytes).toString("hex")).toBe(EXPECTED_SLIP10_KEY_HEX); + }); + + it("produces Phantom-compatible Solana address", async () => { + const { createKeyPairSignerFromPrivateKeyBytes } = await import("@solana/kit"); + const bytes = deriveSolanaKeyBytes(TEST_MNEMONIC); + const signer = await createKeyPairSignerFromPrivateKeyBytes(bytes); + expect(signer.address).toBe(EXPECTED_SLIP10_ADDRESS); + }); + }); + + describe("deriveSolanaKeyBytesLegacy (secp256k1)", () => { + it("returns a 32-byte Uint8Array", () => { + const bytes = deriveSolanaKeyBytesLegacy(TEST_MNEMONIC); + expect(bytes).toBeInstanceOf(Uint8Array); + expect(bytes.length).toBe(32); + }); + + it("produces known legacy key for test mnemonic", () => { + const bytes = deriveSolanaKeyBytesLegacy(TEST_MNEMONIC); + expect(Buffer.from(bytes).toString("hex")).toBe(EXPECTED_LEGACY_KEY_HEX); + }); + + it("is deterministic", () => { + const a = deriveSolanaKeyBytesLegacy(TEST_MNEMONIC); + const b = deriveSolanaKeyBytesLegacy(TEST_MNEMONIC); + expect(Buffer.from(a).toString("hex")).toBe(Buffer.from(b).toString("hex")); + }); + }); + + describe("SLIP-10 vs legacy derivation", () => { + it("produces different keys from same mnemonic", () => { + const slip10 = deriveSolanaKeyBytes(TEST_MNEMONIC); + const legacy = deriveSolanaKeyBytesLegacy(TEST_MNEMONIC); + expect(Buffer.from(slip10).toString("hex")).not.toBe( + Buffer.from(legacy).toString("hex"), + ); + }); + + it("produces different Solana addresses from same mnemonic", async () => { + const { createKeyPairSignerFromPrivateKeyBytes } = await import("@solana/kit"); + const slip10Signer = await createKeyPairSignerFromPrivateKeyBytes( + deriveSolanaKeyBytes(TEST_MNEMONIC), + ); + const legacySigner = await createKeyPairSignerFromPrivateKeyBytes( + deriveSolanaKeyBytesLegacy(TEST_MNEMONIC), + ); + expect(slip10Signer.address).not.toBe(legacySigner.address); + }); }); describe("deriveAllKeys", () => { @@ -92,6 +155,13 @@ describe("wallet key derivation", () => { expect(keys.solanaPrivateKeyBytes.length).toBe(32); }); + it("uses SLIP-10 derivation for Solana key", () => { + const keys = deriveAllKeys(TEST_MNEMONIC); + expect(Buffer.from(keys.solanaPrivateKeyBytes).toString("hex")).toBe( + EXPECTED_SLIP10_KEY_HEX, + ); + }); + it("EVM and Solana keys are different", () => { const keys = deriveAllKeys(TEST_MNEMONIC); const evmHex = keys.evmPrivateKey.slice(2); diff --git a/src/wallet.ts b/src/wallet.ts index 7133881..9281567 100644 --- a/src/wallet.ts +++ b/src/wallet.ts @@ -3,15 +3,25 @@ * * BIP-39 mnemonic generation + BIP-44 HD key derivation for EVM and Solana. * Absorbed from @blockrun/clawwallet. No file I/O here - auth.ts handles persistence. + * + * Solana uses SLIP-10 Ed25519 derivation (Phantom/Solflare/Backpack compatible). + * EVM uses standard BIP-32 secp256k1 derivation. */ import { HDKey } from "@scure/bip32"; import { generateMnemonic, mnemonicToSeedSync, validateMnemonic } from "@scure/bip39"; import { wordlist as english } from "@scure/bip39/wordlists/english"; +import { hmac } from "@noble/hashes/hmac"; +import { sha512 } from "@noble/hashes/sha512"; import { privateKeyToAccount } from "viem/accounts"; const ETH_DERIVATION_PATH = "m/44'/60'/0'/0/0"; -const SOLANA_DERIVATION_PATH = "m/44'/501'/0'/0'"; +const SOLANA_HARDENED_INDICES = [ + 44 + 0x80000000, + 501 + 0x80000000, + 0 + 0x80000000, + 0 + 0x80000000, +]; // m/44'/501'/0'/0' export interface DerivedKeys { mnemonic: string; @@ -49,14 +59,50 @@ export function deriveEvmKey(mnemonic: string): { privateKey: `0x${string}`; add } /** - * Derive 32-byte Solana private key from a BIP-39 mnemonic. - * Path: m/44'/501'/0'/0' (standard Solana derivation) + * Derive 32-byte Solana private key using SLIP-10 Ed25519 derivation. + * Path: m/44'/501'/0'/0' (Phantom / Solflare / Backpack compatible) + * + * Algorithm (SLIP-0010 for Ed25519): + * 1. Master: HMAC-SHA512(key="ed25519 seed", data=bip39_seed) → IL=key, IR=chainCode + * 2. For each hardened child index: + * HMAC-SHA512(key=chainCode, data=0x00 || key || ser32(index)) → split again + * 3. Final IL (32 bytes) = Ed25519 private key seed */ export function deriveSolanaKeyBytes(mnemonic: string): Uint8Array { const seed = mnemonicToSeedSync(mnemonic); + + // Master key from SLIP-10 + let I = hmac(sha512, "ed25519 seed", seed); + let key = I.slice(0, 32); + let chainCode = I.slice(32); + + // Derive each hardened child: m/44'/501'/0'/0' + for (const index of SOLANA_HARDENED_INDICES) { + const data = new Uint8Array(37); + data[0] = 0x00; + data.set(key, 1); + // ser32 big-endian + data[33] = (index >>> 24) & 0xff; + data[34] = (index >>> 16) & 0xff; + data[35] = (index >>> 8) & 0xff; + data[36] = index & 0xff; + I = hmac(sha512, chainCode, data); + key = I.slice(0, 32); + chainCode = I.slice(32); + } + + return new Uint8Array(key); +} + +/** + * Legacy Solana key derivation using secp256k1 BIP-32 (incorrect for Solana). + * Kept for migration: sweeping funds from wallets derived with the old method. + */ +export function deriveSolanaKeyBytesLegacy(mnemonic: string): Uint8Array { + const seed = mnemonicToSeedSync(mnemonic); const hdKey = HDKey.fromMasterSeed(seed); - const derived = hdKey.derive(SOLANA_DERIVATION_PATH); - if (!derived.privateKey) throw new Error("Failed to derive Solana private key"); + const derived = hdKey.derive("m/44'/501'/0'/0'"); + if (!derived.privateKey) throw new Error("Failed to derive legacy Solana private key"); return new Uint8Array(derived.privateKey); }