import { EVMDeriveChildPrivateKey, mnemonicToSeed } from "../walletBip32"; import { ethers } from "ethers"; import { WalletClient, PublicClient, Hex, Chain, createWalletClient, createPublicClient, http } from "viem"; import { } from "../utils"; import { Balance, ChainWalletConfig, TransactionResult } from "../types"; import { fromChainToViemChain, getNativeBalance, getTokenBalance, sendERC20Token, sendNativeToken } from "../evm"; import { fetchPrices } from "../price"; import { Account, privateKeyToAccount } from "viem/accounts"; import { SavingsValidation } from "./validation"; /** * Base class for managing multi-pocket savings accounts for EVM wallets * * Provides core functionality for creating and managing savings pockets derived from a master mnemonic. * Each pocket is a separate wallet address derived using BIP-44 paths, allowing for isolated savings accounts. * * @remarks * - Pockets are derived using BIP-44 path: `m/44'/60'/{pocketIndex}'/0/{walletIndex}` * - Pocket index 0 is reserved for the main wallet * - All pocket addresses are deterministically derived from the mnemonic * - Supports optional master address for scenarios where the savings manager is created from a derived key * * @example * ```typescript * const manager = new BaseSavingsManager( * 'your twelve word mnemonic phrase here...', * 0, // wallet index * chainConfig, * '0x...' // optional master address * ); * * // Get a pocket * const pocket = manager.getPocket(1); * console.log(pocket.address); // Derived pocket address * ``` */ export class BaseSavingsManager { private mnemonic: string; private walletIndex: number; chain: ChainWalletConfig; private _client?: PublicClient; private pockets: Map = new Map(); masterAddress: Hex | undefined /** * Creates a new BaseSavingsManager instance * * @param mnemonic - BIP-39 mnemonic phrase used to derive all wallet addresses * @param walletIndex - Wallet index in the derivation path (default: 0) * @param chain - Chain configuration containing RPC URL and chain details * @param masterAddress - Optional master wallet address (used when manager is created from derived key) * @throws Error if validation fails * * @remarks * RPC client is created on-demand to support browser extension scenarios * where background scripts need to sleep */ constructor(mnemonic: string, walletIndex: number = 0, chain: ChainWalletConfig, masterAddress?: Hex) { // Validate inputs SavingsValidation.validateMnemonic(mnemonic); SavingsValidation.validateWalletIndex(walletIndex); SavingsValidation.validateChainId(chain.chainId); if (masterAddress) { SavingsValidation.validateAddress(masterAddress, 'Master address'); } this.mnemonic = mnemonic; this.chain = chain this.walletIndex = walletIndex; if (masterAddress) { this.masterAddress = masterAddress } // Client is created on-demand via getter } /** * Gets or creates the RPC client on-demand * * Creates a new PublicClient if one doesn't exist. This allows the client * to be garbage collected between operations, which is important for * browser extensions where background scripts need to sleep. * * @returns PublicClient instance for making RPC calls * * @remarks * The client is lazily initialized and cached until disposed. * Call `dispose()` or `clearClient()` to release the client. */ get client(): PublicClient { if (!this._client) { this._client = createPublicClient({ chain: fromChainToViemChain(this.chain), transport: http(this.chain.rpcUrl) }); } return this._client; } /** * Clears the cached RPC client * * Releases the RPC client so it can be garbage collected. * Useful for browser extensions where background scripts need to sleep. * * @remarks * The client will be recreated on next access if needed. * * @example * ```typescript * // After completing operations * manager.clearClient(); * ``` */ clearClient(): void { this._client = undefined; } /** * Derives a savings pocket at the specified account index * * Creates a deterministic wallet address for a savings pocket using BIP-44 derivation. * The actual derivation uses `accountIndex + 1` to preserve index 0 for the main wallet. * * @param accountIndex - The account index for the pocket (0-based, gets incremented internally) * @returns Pocket object containing privateKey, address, derivationPath, and index * * @remarks * - Uses derivation path: `m/44'/60'/{accountIndex + 1}'/0/{walletIndex}` * - Cached in memory after first derivation * - Private method, use `getPocket()` for public access * * @throws Error if validation fails * @private */ private derivePocket(accountIndex: number) { // Validate account index SavingsValidation.validateAccountIndex(accountIndex); //? for the sake of derivation we will add one to the index of the pocket that was passed so as to preserve the index 0 as the main wallet index const pocketIndex = accountIndex + 1 const derivationPathBase = `m/44'/60'/${pocketIndex}'/0/`; const derivationPath = `${derivationPathBase}${this.walletIndex}'`; const { privateKey } = EVMDeriveChildPrivateKey(mnemonicToSeed(this.mnemonic), this.walletIndex, derivationPathBase); const wallet = new ethers.Wallet(privateKey); const pocket = { privateKey, address: wallet.address, derivationPath, index: pocketIndex }; this.pockets.set(accountIndex, pocket); return pocket; } /** * Gets the main wallet credentials * * Derives the main wallet using BIP-44 path at account index 0. * * @returns Object containing privateKey, address, and derivationPath of the main wallet * * @example * ```typescript * const mainWallet = manager.getMainWallet(); * console.log(mainWallet.address); // Main wallet address * console.log(mainWallet.derivationPath); // m/44'/60'/0'/0/0 * ``` */ getMainWallet() { const mainWalletDerivationPathBase = `m/44'/60'/0'/0/`; const mainWalletDerivationPath = `${mainWalletDerivationPathBase}${this.walletIndex}'`; const { privateKey } = EVMDeriveChildPrivateKey( mnemonicToSeed(this.mnemonic), this.walletIndex, mainWalletDerivationPathBase ); const wallet = new ethers.Wallet(privateKey); return { privateKey, address: wallet.address, derivationPath: mainWalletDerivationPath }; } /** * Gets the main wallet address * * Returns the master address if provided during construction, otherwise derives it from the mnemonic. * * @returns The main wallet address as a Hex string * * @example * ```typescript * const address = manager.getMainWalletAddress(); * console.log(address); // 0x... * ``` */ getMainWalletAddress(): Hex { if (this.masterAddress) return this.masterAddress; return this.getMainWallet().address as Hex } /** * Gets or creates a savings pocket at the specified index * * Retrieves a cached pocket if it exists, otherwise derives a new one. * This is the primary method for accessing savings pockets. * * @param accountIndex - The pocket index (0-based) * @returns Pocket object containing privateKey, address, derivationPath, and index * * @throws Error if validation fails * * @example * ```typescript * // Get first savings pocket * const pocket1 = manager.getPocket(0); * console.log(pocket1.address); // Pocket address * * // Get second savings pocket * const pocket2 = manager.getPocket(1); * console.log(pocket2.derivationPath); // m/44'/60'/2'/0/0 * ``` */ getPocket(accountIndex: number) { // Validation is done in derivePocket if (!this.pockets.has(accountIndex)) { return this.derivePocket(accountIndex); } return this.pockets.get(accountIndex)!; } /** * Transfers native tokens from the main wallet to a savings pocket * * Sends the native blockchain token (e.g., ETH, MATIC, BNB) to the specified pocket address. * * @param mainWallet - Viem WalletClient instance for the main wallet * @param pocketIndex - Index of the destination pocket * @param amount - Amount to transfer as a string * @returns Transaction result containing hash and success status * * @throws Error if validation fails * * @example * ```typescript * const result = await manager.transferToPocket( * walletClient, * 0, // First savings pocket * '0.1' // 0.1 ETH * ); * console.log(result.hash); // Transaction hash * ``` */ async transferToPocket( mainWallet: WalletClient, pocketIndex: number, amount: string ): Promise { // Validate inputs SavingsValidation.validateAccountIndex(pocketIndex); SavingsValidation.validateAmountString(amount, 'Transfer amount'); const pocket = this.getPocket(pocketIndex); return await sendNativeToken(mainWallet, this.client, pocket.address as Hex, amount, 5); } /** * Transfers ERC-20 tokens from the main wallet to a savings pocket * * Sends ERC-20 tokens to the specified pocket address. * * @param mainWallet - Viem WalletClient instance for the main wallet * @param tokenAddress - Contract address of the ERC-20 token * @param pocketIndex - Index of the destination pocket * @param amount - Amount to transfer in token's base units (e.g., wei for 18 decimal tokens) * @returns Transaction result containing hash and success status * * @throws Error if validation fails * * @example * ```typescript * const result = await manager.transferTokenToPocket( * walletClient, * '0x...', // USDC contract address * 0, // First savings pocket * 1000000n // 1 USDC (6 decimals) * ); * console.log(result.hash); * ``` */ async transferTokenToPocket( mainWallet: WalletClient, tokenAddress: string, pocketIndex: number, amount: bigint ): Promise { // Validate inputs SavingsValidation.validateAddress(tokenAddress, 'Token address'); SavingsValidation.validateAccountIndex(pocketIndex); SavingsValidation.validateAmount(amount, 'Transfer amount'); const pocket = this.getPocket(pocketIndex); return await sendERC20Token(mainWallet, this.client, tokenAddress as Hex, pocket.address as Hex, amount, 5); } /** * Verifies that a stored pocket address matches the derived address * * Security check to ensure the stored address hasn't been tampered with. * Always derives the address fresh from the mnemonic for comparison. * * @param accountIndex - Index of the pocket to verify * @param storedAddress - The address to verify against * @returns true if addresses match (case-insensitive), false otherwise * * @throws Error if validation fails * * @example * ```typescript * const isValid = manager.verifyPocketAddress(0, '0x...'); * if (!isValid) { * console.error('Address mismatch! Possible tampering detected.'); * } * ``` */ verifyPocketAddress(accountIndex: number, storedAddress: string) { // Validate inputs SavingsValidation.validateAccountIndex(accountIndex); SavingsValidation.validateAddress(storedAddress, 'Stored address'); const pocket = this.getPocket(accountIndex); return pocket.address.toLowerCase() === storedAddress.toLowerCase(); } /** * Creates a Viem Account instance from a pocket's private key * * Converts the pocket's private key into a Viem Account object for use with Viem WalletClients. * * @param p - Pocket index * @returns Viem Account instance for the pocket * * @throws Error if validation fails * * @example * ```typescript * const pocketAccount = manager.accountFromPocketId(0); * const walletClient = createWalletClient({ * account: pocketAccount, * chain: mainnet, * transport: http() * }); * ``` */ accountFromPocketId(p: number): Account { // Validation is done in getPocket return privateKeyToAccount(`0x${this.getPocket(p).privateKey}`) } /** * Clears a specific pocket's cached private key from memory * * Zeros out the private key string for security. The pocket can be re-derived if needed later. * * @param accountIndex - Index of the pocket to clear * * @remarks * This provides defense-in-depth by clearing sensitive data when no longer needed. * However, JavaScript strings are immutable, so we can only clear our reference. * The actual memory may persist until garbage collection. * * @example * ```typescript * // Use a pocket * const pocket = manager.getPocket(0); * // ... use the pocket ... * * // Clear it when done * manager.clearPocket(0); * ``` */ clearPocket(accountIndex: number): void { SavingsValidation.validateAccountIndex(accountIndex); if (this.pockets.has(accountIndex)) { const pocket = this.pockets.get(accountIndex)!; // Attempt to clear the private key string // Note: JavaScript strings are immutable, so this only clears our reference // The actual memory will be cleared by garbage collection (pocket as any).privateKey = ''; // Remove from cache this.pockets.delete(accountIndex); } } /** * Clears all cached pocket private keys from memory * * Removes all cached pockets and attempts to zero out their private keys. * Pockets can be re-derived if needed later. * * @remarks * Call this method when: * - User locks the wallet * - Application goes to background (mobile) * - Extension popup closes (browser extension) * - Session ends * * @example * ```typescript * // Clear all pockets when user locks wallet * manager.clearAllPockets(); * ``` */ clearAllPockets(): void { for (const [index, pocket] of this.pockets.entries()) { // Attempt to clear the private key string (pocket as any).privateKey = ''; } // Clear the map this.pockets.clear(); } /** * Clears all sensitive data from memory * * Attempts to clear mnemonic and all cached private keys from memory. * Also releases the RPC client. * After calling this method, the manager instance should not be used. * * @remarks * IMPORTANT: JavaScript strings are immutable, so this method can only clear * references. The actual memory will be cleared by garbage collection. * * For maximum security: * 1. Call this method when done with the manager * 2. Remove all references to the manager instance * 3. Allow garbage collection to occur * * Call this method when: * - User logs out * - Session ends permanently * - Application closes * * @example * ```typescript * // When user logs out * manager.dispose(); * manager = null; // Remove reference * ``` */ dispose(): void { // Clear all cached pockets this.clearAllPockets(); // Clear RPC client this.clearClient(); // Attempt to clear mnemonic // Note: JavaScript strings are immutable, so this only clears our reference (this as any).mnemonic = ''; // Clear master address if present if (this.masterAddress) { (this as any).masterAddress = undefined; } } } /** * Extended savings manager with balance querying capabilities * * Extends BaseSavingsManager with additional methods for querying balances across * multiple pockets and transferring funds back to the main wallet. * * @example * ```typescript * const savingsManager = new SavingsManager( * 'your twelve word mnemonic phrase here...', * chainConfig, * 0 // wallet index * ); * * // Get balances for a pocket * const balances = await savingsManager.getPocketTokenBalance( * ['0xUSDC...', '0xDAI...'], * 0 // pocket index * ); * ``` */ export class SavingsManager extends BaseSavingsManager { /** * Creates a new SavingsManager instance * * @param mnemonic - BIP-39 mnemonic phrase used to derive all wallet addresses * @param chain - Chain configuration containing RPC URL and chain details * @param walletIndex - Wallet index in the derivation path (default: 0) */ constructor(mnemonic: string, chain: ChainWalletConfig, walletIndex: number = 0,) { super(mnemonic, walletIndex, chain) } /** * Gets total token balances across all specified pockets * * @param tokens - Array of token contract addresses to query * @param pockets - Array of pocket indices to check * @returns Promise resolving to array of balance objects * * @throws Error if validation fails * * @example * ```typescript * const balances = await manager.getTotalTokenBalanceOfAllPockets( * ['0xUSDC...', '0xDAI...'], * [0, 1, 2] // Check first three pockets * ); * ``` */ async getTotalTokenBalanceOfAllPockets( tokens: string[], pockets: number[] ): Promise> { // Validate inputs 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 token addresses tokens.forEach((token, index) => { SavingsValidation.validateAddress(token, `Token at index ${index}`); }); // Validate all pocket indices pockets.forEach((pocket, index) => { SavingsValidation.validateAccountIndex(pocket); }); // Fetch balances for all pockets const allBalances = await Promise.all( pockets.map((p: number) => this.getPocketTokenBalance(tokens, p)) ); return allBalances; } /** * Gets token balances for a specific savings pocket * * Queries the native token balance and all specified ERC-20 token balances for a pocket. * * @param tokens - Array of ERC-20 token contract addresses to query * @param pocket - Pocket index to check balances for * @returns Promise resolving to array of balance objects containing address and balance info * * @throws Error if validation fails * * @example * ```typescript * const balances = await manager.getPocketTokenBalance( * ['0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48'], // USDC * 0 // First pocket * ); * * console.log(balances); * // [ * // { address: 'native', balance: { balance: BN, formatted: 1.5, decimal: 18 } }, * // { address: '0xA0b8...', balance: { balance: BN, formatted: 100, decimal: 6 } } * // ] * ``` */ async getPocketTokenBalance(tokens: string[], pocket: number): Promise<{ address: Hex | 'native'; balance: Balance; }[]> { // Validate inputs if (!Array.isArray(tokens)) { throw new Error('Tokens must be an array'); } SavingsValidation.validateAccountIndex(pocket); // Validate all token addresses tokens.forEach((token, index) => { SavingsValidation.validateAddress(token, `Token at index ${index}`); }); const account = this.accountFromPocketId(pocket) const nativeBalance = await getNativeBalance(account.address, this.client) const balancesList: { address: Hex | 'native'; balance: Balance; }[] = [{ address: 'native', balance: nativeBalance }] await Promise.all(tokens.map(async (t: string) => { const ercBalance = await getTokenBalance(t as Hex, account.address, this.client) balancesList.push({ balance: ercBalance, address: t as Hex }) })) return balancesList } /** * Sends tokens from a savings pocket back to the main wallet * * Withdraws either native tokens or ERC-20 tokens from a pocket to the main wallet. * * @param pocketIndex - Index of the pocket to withdraw from * @param amount - Amount to send in base units * @param token - Token address or "native" for native blockchain token * @returns Transaction result containing hash and success status * * @throws Error if validation fails * * @remarks * @example * ```typescript * // Send native token from pocket to main wallet * const result = await manager.sendToMainWallet( * 0, // From pocket 0 * 1000000000000000000n, // 1 ETH in wei * 'native' * ); * * // Send ERC-20 token * const result2 = await manager.sendToMainWallet( * 0, * 1000000n, // 1 USDC (6 decimals) * '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48' * ); * ``` */ async sendToMainWallet(pocketIndex: number, amount: bigint, token: Hex | "native"): Promise { // Validate inputs SavingsValidation.validateAccountIndex(pocketIndex); SavingsValidation.validateAmount(amount, 'Transfer amount'); if (token !== 'native') { SavingsValidation.validateAddress(token, 'Token address'); } const account = this.accountFromPocketId(pocketIndex) const mainWalletAddress = this.getMainWalletAddress() const walletClient = createWalletClient( { account, transport: http(this.client.chain?.rpcUrls.default.http[0]), chain: this.client.chain } ) if (token === "native") { return await sendNativeToken(walletClient, this.client, mainWalletAddress, amount) } const res = await sendERC20Token(walletClient, this.client, token, mainWalletAddress as Hex, amount) return res } }