/** * SVM (Solana) Savings Manager * * Manages savings pockets on Solana blockchain * Uses BIP-44 coin type 501 (different addresses from EVM) */ import { SavingsManager, Pocket } from "./savings-manager"; import { Connection, PublicKey, Keypair, Transaction, sendAndConfirmTransaction } from "@solana/web3.js"; import { SVMDeriveChildPrivateKey, mnemonicToSeed } from "../walletBip32"; import { Balance, TransactionResult } from "../types"; import { getSvmNativeBalance, getTokenBalance as getSvmTokenBalance, signAndSendTransaction, getTransferNativeTransaction, getTransferTokenTransaction } from "../svm"; import { SavingsValidation } from "./validation"; import BN from "bn.js"; /** * Solana Savings Manager * * Provides savings pocket functionality for Solana blockchain. * Uses BIP-44 derivation path: m/44'/501'/{pocketIndex}'/0/{walletIndex} * * @example * ```typescript * const manager = new SVMSavingsManager( * mnemonic, * 'https://api.mainnet-beta.solana.com', * 0 // wallet index * ); * * // Get pocket * const pocket = manager.getPocket(0); * console.log(pocket.address.toBase58()); // Solana address * * // Get balances * const balances = await manager.getPocketBalance(0, [usdcMint]); * ``` */ export class SVMSavingsManager extends SavingsManager { coinType = 501; derivationPathBase = "m/44'/501'/"; // Base path for account derivation private rpcUrl: string; private _client?: Connection; /** * Create a new SVMSavingsManager * * @param mnemonic - BIP-39 mnemonic phrase * @param rpcUrl - Solana RPC endpoint URL * @param walletIndex - Wallet index in derivation path (default: 0) */ constructor( mnemonic: string, rpcUrl: string, walletIndex: number = 0 ) { super(mnemonic, walletIndex); if (!rpcUrl || typeof rpcUrl !== 'string') { throw new Error('RPC URL must be a non-empty string'); } this.rpcUrl = rpcUrl; } /** * Get or create the RPC client on-demand * * Lazy initialization allows the client to be garbage collected between operations. */ get client(): Connection { if (!this._client) { this._client = this.createClient(this.rpcUrl); } return this._client; } /** * Create a Connection to Solana * * @param rpcUrl - RPC endpoint URL * @returns Connection instance */ createClient(rpcUrl: string): Connection { return new Connection(rpcUrl, 'confirmed'); } /** * Clear the cached RPC client */ clearClient(): void { this._client = undefined; } private toSafeNumberAmount(amount: bigint, label: string): number { if (amount > BigInt(Number.MAX_SAFE_INTEGER)) { throw new Error(`${label} exceeds Number.MAX_SAFE_INTEGER and cannot be represented safely: ${amount}`); } return Number(amount); } /** * Derive a savings pocket at the specified account index * * @param accountIndex - Account index (0-based) * @returns Pocket object with privateKey (Keypair), address (PublicKey), derivationPath, and index */ derivePocket(accountIndex: number): Pocket { this.checkNotDisposed(); SavingsValidation.validateAccountIndex(accountIndex); // Add 1 to preserve index 0 for main wallet const pocketIndex = accountIndex + 1; const derivationPathBase = `${this.derivationPathBase}${pocketIndex}'/0/`; const derivationPath = `${derivationPathBase}${this.walletIndex}'`; const seed = mnemonicToSeed(this.mnemonic); const keypair = SVMDeriveChildPrivateKey(seed, this.walletIndex, derivationPathBase); const pocket: Pocket = { privateKey: keypair, address: keypair.publicKey, derivationPath, index: pocketIndex }; this.pockets.set(accountIndex, pocket); return pocket; } /** * Get the main wallet credentials * * @returns Main wallet object with privateKey (Keypair), address (PublicKey), and derivationPath */ getMainWallet() { this.checkNotDisposed(); const derivationPathBase = `${this.derivationPathBase}0'/0/`; const derivationPath = `${derivationPathBase}${this.walletIndex}'`; const seed = mnemonicToSeed(this.mnemonic); const keypair = SVMDeriveChildPrivateKey(seed, this.walletIndex, derivationPathBase); return { privateKey: keypair, address: keypair.publicKey, derivationPath }; } /** * Get the main wallet address * * @returns Main wallet PublicKey */ getMainWalletAddress(): PublicKey { return this.getMainWallet().address; } /** * Get token balances for a specific pocket * * @param pocketIndex - Pocket index to check * @param tokens - Array of SPL token mint addresses * @returns Array of balance objects */ async getPocketBalance(pocketIndex: number, tokens: string[]): Promise<{ address: PublicKey | 'native'; balance: Balance; }[]> { SavingsValidation.validateAccountIndex(pocketIndex); if (!Array.isArray(tokens)) { throw new Error('Tokens must be an array'); } const pocket = this.getPocket(pocketIndex); const balances: { address: PublicKey | 'native'; balance: Balance }[] = []; // Get native SOL balance const nativeBalance = await getSvmNativeBalance(pocket.address, this.client); balances.push({ address: 'native', balance: nativeBalance }); // Get SPL token balances await Promise.all(tokens.map(async (token) => { try { const tokenPubkey = new PublicKey(token); const tokenBalanceData = await getSvmTokenBalance(pocket.address, tokenPubkey, this.client); // Handle the case where getTokenBalance returns 0 or TokenAmount if (tokenBalanceData === 0) { balances.push({ address: tokenPubkey, balance: { balance: new BN(0), formatted: 0, decimal: 0 } }); } else { const balance: Balance = { balance: new BN(tokenBalanceData.amount), formatted: tokenBalanceData.uiAmount || 0, decimal: tokenBalanceData.decimals }; balances.push({ address: tokenPubkey, balance }); } } catch (error) { // Token account might not exist, push zero balance balances.push({ address: new PublicKey(token), balance: { balance: new BN(0), formatted: 0, decimal: 0 } }); } })); return balances; } /** * Get balances for multiple pockets * * @param tokens - Array of token mint addresses * @param pockets - Array of pocket indices * @returns Array of balance arrays per pocket */ async getTotalTokenBalanceOfAllPockets( tokens: string[], pockets: number[] ): Promise> { if (!Array.isArray(tokens) || tokens.length === 0) { throw new Error('Tokens array must be non-empty'); } if (!Array.isArray(pockets) || pockets.length === 0) { throw new Error('Pockets array must be non-empty'); } // Validate all pocket indices pockets.forEach((pocket) => { SavingsValidation.validateAccountIndex(pocket); }); // Fetch balances for all pockets in parallel const allBalances = await Promise.all( pockets.map((p: number) => this.getPocketBalance(p, tokens)) ); return allBalances; } /** * Transfer SOL from main wallet to a pocket * * @param mainWallet - Keypair for the main wallet * @param pocketIndex - Destination pocket index * @param amount - Amount to transfer in lamports * @returns Transaction result */ async transferToPocket( mainWallet: Keypair, pocketIndex: number, amount: bigint ): Promise { SavingsValidation.validateAccountIndex(pocketIndex); if (typeof amount !== 'bigint' || amount <= 0n) { throw new Error(`Amount must be a positive bigint, got: ${amount}`); } const pocket = this.getPocket(pocketIndex); const tx = await getTransferNativeTransaction( mainWallet, pocket.address, this.toSafeNumberAmount(amount, 'Native transfer amount'), this.client ); const hash = await signAndSendTransaction(tx, this.client, mainWallet); return { success: true, hash }; } /** * Transfer SPL tokens from main wallet to a pocket * * @param mainWallet - Keypair for the main wallet * @param tokenInfo - SPL token information (address, decimals, etc.) * @param pocketIndex - Destination pocket index * @param amount - Amount to transfer (in token base units) * @returns Transaction result */ async transferTokenToPocket( mainWallet: Keypair, tokenInfo: { address: string; decimals: number }, pocketIndex: number, amount: bigint ): Promise { SavingsValidation.validateAccountIndex(pocketIndex); if (typeof amount !== 'bigint' || amount <= 0n) { throw new Error(`Amount must be a positive bigint, got: ${amount}`); } const pocket = this.getPocket(pocketIndex); const tx = await getTransferTokenTransaction( mainWallet, pocket.address, tokenInfo as any, // TokenInfo type this.toSafeNumberAmount(amount, 'Token transfer amount'), this.client ); const hash = await signAndSendTransaction(tx, this.client, mainWallet); return { success: true, hash }; } /** * Get Keypair from pocket (for signing transactions) * * @param pocketIndex - Pocket index * @returns Keypair instance */ accountFromPocketId(pocketIndex: number): Keypair { const pocket = this.getPocket(pocketIndex); return pocket.privateKey; } /** * Verify that a stored pocket address matches the derived address * * @param accountIndex - Pocket index * @param storedAddress - Address to verify (base58 string) * @returns true if addresses match */ verifyPocketAddress(accountIndex: number, storedAddress: string): boolean { SavingsValidation.validateAccountIndex(accountIndex); if (!storedAddress || typeof storedAddress !== 'string') { throw new Error('Stored address must be a non-empty string'); } try { const pocket = this.getPocket(accountIndex); const storedPubkey = new PublicKey(storedAddress); return pocket.address.equals(storedPubkey); } catch (error) { return false; } } /** * Send tokens from a pocket back to the main wallet * * @param pocketIndex - Source pocket index * @param amount - Amount to send (in base units) * @param token - Token info object or "native" for SOL * @returns Transaction result */ async sendToMainWallet( pocketIndex: number, amount: bigint, token: { address: string; decimals: number } | "native" ): Promise { SavingsValidation.validateAccountIndex(pocketIndex); if (typeof amount !== 'bigint' || amount <= 0n) { throw new Error(`Amount must be a positive bigint, got: ${amount}`); } const pocket = this.getPocket(pocketIndex); const mainWalletAddress = this.getMainWalletAddress(); if (token === "native") { const tx = await getTransferNativeTransaction( pocket.privateKey, mainWalletAddress, this.toSafeNumberAmount(amount, 'Native withdrawal amount'), this.client ); const hash = await signAndSendTransaction(tx, this.client, pocket.privateKey); return { success: true, hash }; } const tx = await getTransferTokenTransaction( pocket.privateKey, mainWalletAddress, token as any, // TokenInfo type this.toSafeNumberAmount(amount, 'Token withdrawal amount'), this.client ); const hash = await signAndSendTransaction(tx, this.client, pocket.privateKey); return { success: true, hash }; } /** * Dispose and clear all resources */ dispose(): void { super.dispose(); this.clearClient(); } }