/** * EVM Savings Manager * * Manages savings pockets across EVM-compatible chains (Ethereum, Polygon, BSC, Arbitrum, etc.) * All EVM chains use the same addresses (coin type 60 in BIP-44) */ import { SavingsManager, Pocket } from "./savings-manager"; import { Hex, PublicClient, WalletClient, createPublicClient, createWalletClient, http } from "viem"; import { EVMDeriveChildPrivateKey, mnemonicToSeed } from "../walletBip32"; import { ChainWalletConfig, Balance, TransactionResult } from "../types"; import { ethers } from "ethers"; import { fromChainToViemChain, getNativeBalance, getTokenBalance, sendNativeToken, sendERC20Token } from "../evm"; import { SavingsValidation } from "./validation"; import { privateKeyToAccount } from "viem/accounts"; /** * EVM Savings Manager * * Provides savings pocket functionality for EVM-compatible chains. * Uses BIP-44 derivation path: m/44'/60'/{pocketIndex}'/0/{walletIndex} * * @example * ```typescript * const manager = new EVMSavingsManager( * mnemonic, * { chainId: 1, name: 'ethereum', rpcUrl: 'https://...' }, * 0 // wallet index * ); * * // Get pocket * const pocket = manager.getPocket(0); * console.log(pocket.address); // 0x... * * // Get balances * const balances = await manager.getPocketBalance(0, [usdcAddress]); * ``` */ export class EVMSavingsManager extends SavingsManager { coinType = 60; derivationPathBase = "m/44'/60'/"; private chain: ChainWalletConfig; private _client?: PublicClient; private masterAddress?: Hex; /** * Create a new EVMSavingsManager * * @param mnemonic - BIP-39 mnemonic phrase * @param chain - Chain configuration with RPC URL and chain details * @param walletIndex - Wallet index in derivation path (default: 0) * @param masterAddress - Optional master wallet address */ constructor( mnemonic: string, chain: ChainWalletConfig, walletIndex: number = 0, masterAddress?: Hex ) { super(mnemonic, walletIndex); SavingsValidation.validateChainId(chain.chainId); if (masterAddress) { SavingsValidation.validateAddress(masterAddress, 'Master address'); } this.chain = chain; this.masterAddress = masterAddress; } /** * Get or create the RPC client on-demand * * Lazy initialization allows the client to be garbage collected between operations. */ get client(): PublicClient { if (!this._client) { this._client = this.createClient(this.chain.rpcUrl); } return this._client; } /** * Create an RPC client for this chain * * @param rpcUrl - RPC endpoint URL * @returns PublicClient instance */ createClient(rpcUrl: string): PublicClient { return createPublicClient({ chain: fromChainToViemChain(this.chain), transport: http(rpcUrl) }); } /** * Clear the cached RPC client */ clearClient(): void { this._client = undefined; } /** * Derive a savings pocket at the specified account index * * @param accountIndex - Account index (0-based) * @returns Pocket object with privateKey, address, 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 { privateKey } = EVMDeriveChildPrivateKey(seed, this.walletIndex, derivationPathBase); const wallet = new ethers.Wallet(privateKey); const pocket: Pocket = { privateKey, address: wallet.address as Hex, derivationPath, index: pocketIndex }; this.pockets.set(accountIndex, pocket); return pocket; } /** * Get the main wallet credentials * * @returns Main wallet object with privateKey, address, and derivationPath */ getMainWallet() { this.checkNotDisposed(); const derivationPathBase = `${this.derivationPathBase}0'/0/`; const derivationPath = `${derivationPathBase}${this.walletIndex}'`; const seed = mnemonicToSeed(this.mnemonic); const { privateKey } = EVMDeriveChildPrivateKey(seed, this.walletIndex, derivationPathBase); const wallet = new ethers.Wallet(privateKey); return { privateKey, address: wallet.address as Hex, derivationPath }; } /** * Get the main wallet address * * @returns Main wallet address as Hex */ getMainWalletAddress(): Hex { if (this.masterAddress) return this.masterAddress; return this.getMainWallet().address; } /** * Get token balances for a specific pocket * * @param pocketIndex - Pocket index to check * @param tokens - Array of ERC-20 token addresses to query * @returns Array of balance objects */ async getPocketBalance(pocketIndex: number, tokens: string[]): Promise<{ address: Hex | '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: Hex | 'native'; balance: Balance }[] = []; // Get native balance const nativeBalance = await getNativeBalance(pocket.address, this.client); balances.push({ address: 'native', balance: nativeBalance }); // Get token balances await Promise.all(tokens.map(async (token) => { SavingsValidation.validateAddress(token, 'Token address'); const tokenBalance = await getTokenBalance(token as Hex, pocket.address, this.client); balances.push({ address: token as Hex, balance: tokenBalance }); })); return balances; } /** * Get balances for multiple pockets * * @param pocketIndices - Array of pocket indices * @param tokens - Array of token addresses to query * @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 inputs tokens.forEach((token, index) => { SavingsValidation.validateAddress(token, `Token at index ${index}`); }); 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 native tokens from main wallet to a pocket * * @param mainWallet - WalletClient for the main wallet * @param pocketIndex - Destination pocket index * @param amount - Amount to transfer as string (in ether units) * @returns Transaction result */ async transferToPocket( mainWallet: WalletClient, pocketIndex: number, amount: string ): Promise { SavingsValidation.validateAccountIndex(pocketIndex); SavingsValidation.validateAmountString(amount, 'Transfer amount'); const pocket = this.getPocket(pocketIndex); return await sendNativeToken(mainWallet, this.client, pocket.address, amount, 5); } /** * Transfer ERC-20 tokens from main wallet to a pocket * * @param mainWallet - WalletClient for the main wallet * @param tokenAddress - ERC-20 token contract address * @param pocketIndex - Destination pocket index * @param amount - Amount to transfer (in token base units) * @returns Transaction result */ async transferTokenToPocket( mainWallet: WalletClient, tokenAddress: string, pocketIndex: number, amount: bigint ): Promise { 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, amount, 5); } /** * Create a Viem Account from a pocket's private key * * @param pocketIndex - Pocket index * @returns Viem Account instance */ accountFromPocketId(pocketIndex: number) { const pocket = this.getPocket(pocketIndex); return privateKeyToAccount(`0x${pocket.privateKey}`); } /** * Verify that a stored pocket address matches the derived address * * @param accountIndex - Pocket index * @param storedAddress - Address to verify * @returns true if addresses match */ verifyPocketAddress(accountIndex: number, storedAddress: string): boolean { SavingsValidation.validateAccountIndex(accountIndex); SavingsValidation.validateAddress(storedAddress, 'Stored address'); const pocket = this.getPocket(accountIndex); return pocket.address.toLowerCase() === storedAddress.toLowerCase(); } /** * 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 address or "native" * @returns Transaction result */ async sendToMainWallet( pocketIndex: number, amount: bigint, token: Hex | "native" ): Promise { SavingsValidation.validateAccountIndex(pocketIndex); if (typeof amount !== 'bigint' || amount <= 0n) { throw new Error(`Amount must be a positive bigint, got: ${amount}`); } if (token !== 'native') { SavingsValidation.validateAddress(token, 'Token address'); } const pocket = this.getPocket(pocketIndex); const account = this.accountFromPocketId(pocketIndex); const mainWalletAddress = this.getMainWalletAddress(); const walletClient = createWalletClient({ account, transport: http(this.chain.rpcUrl), chain: fromChainToViemChain(this.chain) }); if (token === "native") { return await sendNativeToken(walletClient, this.client, mainWalletAddress, amount); } return await sendERC20Token(walletClient, this.client, token, mainWalletAddress, amount); } /** * Dispose and clear all resources */ dispose(): void { super.dispose(); this.clearClient(); if (this.masterAddress) { (this as any).masterAddress = undefined; } } }