/** * Stateless Savings Operations * * Provides stateless operations for savings management where credentials * are passed per-operation rather than stored in memory. Designed for * secure usage in browser extensions and mobile wallets. * * @remarks * Security Model: * - No mnemonic or private key storage * - Implementer passes credentials per-operation * - Keys exist in memory only during operation * - Automatic cleanup after use * * @example * ```typescript * // Browser extension with encrypted storage * const operations = new SavingsOperations(); * * // Unlock wallet (implementer handles encryption/biometric) * const mnemonic = await secureStorage.getMnemonic(); * * // Perform operation - mnemonic not stored * const result = await operations.transferFromPocket( * mnemonic, * { walletIndex: 0, accountIndex: 1, to: '0x...', amount: 100n }, * provider, * chain * ); * * // Mnemonic automatically cleared from memory after operation * ``` */ import { ethers, JsonRpcProvider, Wallet, parseUnits, formatUnits, Interface } from "ethers"; import { Hex } from "viem"; import { SavingsValidation } from "./validation"; import { ChainWalletConfig, TransactionResult } from "../types"; import { EVMDeriveChildPrivateKey, mnemonicToSeed } from "../walletBip32"; /** * Options for transferring from a savings pocket */ export interface TransferFromPocketOptions { /** Wallet index in derivation path (default: 0) */ walletIndex?: number; /** Pocket index to transfer from (0-based) */ accountIndex: number; /** Destination address */ to: string; /** Amount to transfer in base units (wei for ETH) */ amount: bigint; /** Optional gas limit */ gasLimit?: bigint; /** Optional gas price (for legacy transactions) */ gasPrice?: bigint; /** Optional max fee per gas (for EIP-1559) */ maxFeePerGas?: bigint; /** Optional max priority fee per gas (for EIP-1559) */ maxPriorityFeePerGas?: bigint; } /** * Options for transferring tokens from a savings pocket */ export interface TransferTokenFromPocketOptions extends Omit { /** Token contract address */ tokenAddress: string; /** Amount to transfer in token's smallest unit */ amount: bigint; } /** * Options for getting pocket balance */ export interface GetPocketBalanceOptions { /** Wallet index in derivation path (default: 0) */ walletIndex?: number; /** Pocket index (0-based) */ accountIndex: number; } /** * Options for getting pocket token balance */ export interface GetPocketTokenBalanceOptions extends GetPocketBalanceOptions { /** Token contract address */ tokenAddress: string; } /** * Token balance information */ export interface TokenBalance { /** Token contract address */ address: string; /** Balance in token's smallest unit */ balance: bigint; /** Token decimals */ decimals: number; /** Token symbol */ symbol: string; /** Token name */ name: string; /** Formatted balance string */ formatted: string; } /** * Result of a pocket derivation */ interface PocketDerivationResult { /** Derived wallet instance */ wallet: Wallet; /** Pocket address */ address: string; /** Cleanup function to zero out sensitive data */ cleanup: () => void; } /** * Stateless savings operations that don't store sensitive data * * All methods accept mnemonic as parameter and derive keys on-demand. * Keys are automatically cleaned up after each operation. */ export class SavingsOperations { /** * Derives a savings pocket wallet from mnemonic * * @param mnemonic - BIP-39 mnemonic phrase * @param accountIndex - Pocket index (0-based) * @param walletIndex - Wallet index (default: 0) * @param provider - RPC provider to connect wallet to * @returns Derived wallet, address, and cleanup function * * @remarks * IMPORTANT: Always call cleanup() when done with the wallet to zero out private key * * Derivation path: m/44'/60'/{accountIndex + 1}'/0/{walletIndex}' * * @throws Error if validation fails * * @internal */ private derivePocketWallet( mnemonic: string, accountIndex: number, walletIndex: number = 0, provider: JsonRpcProvider ): PocketDerivationResult { // Validate inputs SavingsValidation.validateMnemonic(mnemonic); SavingsValidation.validateAccountIndex(accountIndex); SavingsValidation.validateWalletIndex(walletIndex); // Derive pocket private key (account 0 is main wallet, so pockets start at +1) const pocketIndex = accountIndex + 1; const seed = mnemonicToSeed(mnemonic); const { privateKey } = EVMDeriveChildPrivateKey(seed, walletIndex, `m/44'/60'/${pocketIndex}'/0/`); // Create wallet const wallet = new Wallet(privateKey, provider); const address = wallet.address; // Cleanup function to zero out sensitive data const cleanup = () => { // Note: JavaScript strings are immutable, so we can't directly zero them // The seed and privateKey will be garbage collected when they go out of scope }; return { wallet, address, cleanup }; } /** * Get native token balance of a savings pocket * * @param mnemonic - BIP-39 mnemonic phrase * @param options - Balance query options * @param provider - RPC provider * @returns Balance in wei * * @throws Error if validation fails or balance check fails * * @example * ```typescript * const balance = await operations.getPocketBalance( * mnemonic, * { accountIndex: 1, walletIndex: 0 }, * provider * ); * console.log(`Pocket 1 balance: ${formatUnits(balance, 18)} ETH`); * ``` */ async getPocketBalance( mnemonic: string, options: GetPocketBalanceOptions, provider: JsonRpcProvider ): Promise { const { accountIndex, walletIndex = 0 } = options; const { wallet, cleanup } = this.derivePocketWallet( mnemonic, accountIndex, walletIndex, provider ); try { const balance = await provider.getBalance(wallet.address); return BigInt(balance.toString()); } finally { cleanup(); } } /** * Get token balance of a savings pocket * * @param mnemonic - BIP-39 mnemonic phrase * @param options - Token balance query options * @param provider - RPC provider * @returns Token balance and metadata * * @throws Error if validation fails or balance check fails * * @example * ```typescript * const usdcBalance = await operations.getPocketTokenBalance( * mnemonic, * { * accountIndex: 1, * walletIndex: 0, * tokenAddress: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48' * }, * provider * ); * ``` */ async getPocketTokenBalance( mnemonic: string, options: GetPocketTokenBalanceOptions, provider: JsonRpcProvider ): Promise { const { accountIndex, walletIndex = 0, tokenAddress } = options; // Validate token address SavingsValidation.validateAddress(tokenAddress, 'Token address'); const { wallet, address, cleanup } = this.derivePocketWallet( mnemonic, accountIndex, walletIndex, provider ); try { // ERC20 interface const erc20Interface = new Interface([ "function balanceOf(address) view returns (uint256)", "function decimals() view returns (uint8)", "function symbol() view returns (string)", "function name() view returns (string)" ]); const tokenContract = new ethers.Contract(tokenAddress, erc20Interface, provider); // Fetch token info and balance in parallel const [balance, decimals, symbol, name] = await Promise.all([ tokenContract.balanceOf(address), tokenContract.decimals(), tokenContract.symbol(), tokenContract.name() ]); const balanceBigInt = BigInt(balance.toString()); return { address: tokenAddress, balance: balanceBigInt, decimals: Number(decimals), symbol: symbol, name: name, formatted: formatUnits(balanceBigInt, decimals) }; } finally { cleanup(); } } /** * Transfer native tokens from a savings pocket * * @param mnemonic - BIP-39 mnemonic phrase * @param options - Transfer options * @param provider - RPC provider * @param chain - Chain configuration * @returns Transaction result * * @throws Error if validation fails or transaction fails * * @example * ```typescript * // Transfer 0.1 ETH from pocket 1 to another address * const result = await operations.transferFromPocket( * mnemonic, * { * accountIndex: 1, * walletIndex: 0, * to: '0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb', * amount: parseUnits('0.1', 18) * }, * provider, * chain * ); * ``` */ async transferFromPocket( mnemonic: string, options: TransferFromPocketOptions, provider: JsonRpcProvider, chain: ChainWalletConfig ): Promise { const { accountIndex, walletIndex = 0, to, amount, gasLimit, gasPrice, maxFeePerGas, maxPriorityFeePerGas } = options; // Validate inputs SavingsValidation.validateAddress(to, 'Destination address'); SavingsValidation.validateAmount(amount, 'Transfer amount'); const { wallet, cleanup } = this.derivePocketWallet( mnemonic, accountIndex, walletIndex, provider ); try { // Build transaction const tx: any = { to, value: amount, chainId: chain.chainId }; // Add gas parameters if provided if (gasLimit) tx.gasLimit = gasLimit; if (gasPrice) tx.gasPrice = gasPrice; if (maxFeePerGas) tx.maxFeePerGas = maxFeePerGas; if (maxPriorityFeePerGas) tx.maxPriorityFeePerGas = maxPriorityFeePerGas; // Send transaction const txResponse = await wallet.sendTransaction(tx); // Wait for confirmation const receipt = await txResponse.wait(); if (!receipt) { throw new Error('Transaction receipt is null'); } return { hash: receipt.hash as Hex, success: receipt.status === 1 }; } finally { cleanup(); } } /** * Transfer ERC20 tokens from a savings pocket * * @param mnemonic - BIP-39 mnemonic phrase * @param options - Transfer options including token address * @param provider - RPC provider * @param chain - Chain configuration * @returns Transaction result * * @throws Error if validation fails or transaction fails * * @example * ```typescript * // Transfer 100 USDC from pocket 1 * const result = await operations.transferTokenFromPocket( * mnemonic, * { * accountIndex: 1, * walletIndex: 0, * tokenAddress: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', * to: '0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb', * amount: parseUnits('100', 6) // USDC has 6 decimals * }, * provider, * chain * ); * ``` */ async transferTokenFromPocket( mnemonic: string, options: TransferTokenFromPocketOptions, provider: JsonRpcProvider, chain: ChainWalletConfig ): Promise { const { accountIndex, walletIndex = 0, tokenAddress, to, amount, gasLimit, maxFeePerGas, maxPriorityFeePerGas } = options; // Validate inputs SavingsValidation.validateAddress(tokenAddress, 'Token address'); SavingsValidation.validateAddress(to, 'Destination address'); SavingsValidation.validateAmount(amount, 'Transfer amount'); const { wallet, cleanup } = this.derivePocketWallet( mnemonic, accountIndex, walletIndex, provider ); try { // ERC20 transfer interface const erc20Interface = new Interface([ "function transfer(address to, uint256 amount) returns (bool)" ]); const tokenContract = new ethers.Contract(tokenAddress, erc20Interface, wallet); // Build transaction const tx: any = { chainId: chain.chainId }; // Add gas parameters if provided if (gasLimit) tx.gasLimit = gasLimit; if (maxFeePerGas) tx.maxFeePerGas = maxFeePerGas; if (maxPriorityFeePerGas) tx.maxPriorityFeePerGas = maxPriorityFeePerGas; // Send token transfer transaction const txResponse = await tokenContract.transfer(to, amount, tx); // Wait for confirmation const receipt = await txResponse.wait(); if (!receipt) { throw new Error('Transaction receipt is null'); } return { hash: receipt.hash as Hex, success: receipt.status === 1 }; } finally { cleanup(); } } /** * Get the address of a savings pocket without storing credentials * * @param mnemonic - BIP-39 mnemonic phrase * @param accountIndex - Pocket index (0-based) * @param walletIndex - Wallet index (default: 0) * @returns Pocket address * * @throws Error if validation fails * * @example * ```typescript * const pocketAddress = operations.getPocketAddress(mnemonic, 1, 0); * console.log(`Pocket 1 address: ${pocketAddress}`); * ``` */ getPocketAddress( mnemonic: string, accountIndex: number, walletIndex: number = 0 ): string { // Validate inputs SavingsValidation.validateMnemonic(mnemonic); SavingsValidation.validateAccountIndex(accountIndex); SavingsValidation.validateWalletIndex(walletIndex); // Derive address (account 0 is main wallet, so pockets start at +1) const pocketIndex = accountIndex + 1; const seed = mnemonicToSeed(mnemonic); const { privateKey } = EVMDeriveChildPrivateKey(seed, walletIndex, `m/44'/60'/${pocketIndex}'/0/`); const wallet = new Wallet(privateKey); const address = wallet.address; // Note: JavaScript strings are immutable, so seed will be garbage collected // when it goes out of scope return address; } }