/** * Transaction utilities for EVM chains * * Provides helpers for transaction validation, timeout handling, * nonce management, and gas estimation. */ import { JsonRpcProvider } from 'ethers'; /** * Transaction amount validation options */ export interface AmountValidationOptions { /** Maximum amount allowed */ maxAmount?: bigint; /** Minimum amount (default: 1 to prevent dust) */ minAmount?: bigint; /** Require positive amount (default: true) */ requirePositive?: boolean; /** Allow transferring full balance (default: false) */ allowFullBalance?: boolean; } /** * Validate transfer amount * * @param amount - Amount to transfer * @param balance - Available balance * @param options - Validation options * @throws Error if amount is invalid * * @example * ```typescript * const balance = await provider.getBalance(address); * validateTransferAmount(parseEther('1.0'), balance, { * allowFullBalance: true, * minAmount: parseEther('0.001') * }); * ``` */ export function validateTransferAmount( amount: bigint, balance: bigint, options?: AmountValidationOptions ): void { const { maxAmount, minAmount = 1n, requirePositive = true, allowFullBalance = false } = options || {}; // Check positive if (requirePositive && amount <= 0n) { throw new Error(`Amount must be positive, got: ${amount}`); } // Check minimum (prevent dust) if (amount < minAmount) { throw new Error( `Amount below minimum: ${amount} < ${minAmount}` ); } // Check maximum if (maxAmount && amount > maxAmount) { throw new Error( `Amount exceeds maximum: ${amount} > ${maxAmount}` ); } // Check balance if (amount > balance) { throw new Error( `Insufficient balance: ${amount} > ${balance}` ); } // Check full balance transfer if (!allowFullBalance && amount === balance) { throw new Error( 'Transferring entire balance requires explicit confirmation. ' + 'Set allowFullBalance: true to proceed, or leave some balance for gas fees.' ); } } /** * Wait for transaction confirmation with timeout * * @param txResponse - Transaction response from ethers.js * @param confirmations - Number of confirmations to wait for * @param timeoutMs - Timeout in milliseconds * @returns Transaction receipt * @throws Error if timeout or confirmation fails * * @example * ```typescript * const txResponse = await wallet.sendTransaction({...}); * const receipt = await waitForTransaction(txResponse, 1, 60000); * ``` */ export async function waitForTransaction( txResponse: any, confirmations: number = 1, timeoutMs: number = 60000 ): Promise { const txHash = txResponse.hash; const timeoutPromise = new Promise((_, reject) => setTimeout( () => reject(new Error(`Transaction confirmation timeout after ${timeoutMs}ms`)), timeoutMs ) ); const confirmationPromise = txResponse.wait(confirmations); try { const receipt = await Promise.race([ confirmationPromise, timeoutPromise ]); return receipt; } catch (error: any) { if (error.message.includes('timeout')) { // Transaction may still be pending throw new Error( `Transaction not confirmed after ${timeoutMs}ms. ` + `Hash: ${txHash}. Check block explorer for status.` ); } throw error; } } /** * Nonce manager for handling concurrent transactions */ export class NonceManager { private pendingNonces = new Map(); private provider: JsonRpcProvider; constructor(provider: JsonRpcProvider) { this.provider = provider; } /** * Get next available nonce for address * * @param address - Ethereum address * @param forceRefresh - Force refresh from chain * @returns Next nonce to use */ async getNextNonce(address: string, forceRefresh: boolean = false): Promise { if (forceRefresh) { this.pendingNonces.delete(address); } // Get latest nonce from chain const chainNonce = await this.provider.getTransactionCount(address, 'latest'); // Get pending nonce from our tracking const pendingNonce = this.pendingNonces.get(address); // Use whichever is higher const nextNonce = pendingNonce !== undefined ? Math.max(chainNonce, pendingNonce) : chainNonce; // Reserve this nonce this.pendingNonces.set(address, nextNonce + 1); return nextNonce; } /** * Release a nonce (call if transaction fails) * * @param address - Ethereum address * @param nonce - Nonce to release */ releaseNonce(address: string, nonce: number): void { const pending = this.pendingNonces.get(address); if (pending === nonce + 1) { this.pendingNonces.set(address, nonce); } } /** * Clear nonces for address (e.g., after detecting nonce mismatch) * * @param address - Ethereum address */ clearNonces(address: string): void { this.pendingNonces.delete(address); } /** * Clear all nonces */ clearAll(): void { this.pendingNonces.clear(); } } /** * Estimate gas with safety margin * * @param provider - JSON RPC provider * @param tx - Transaction parameters * @param marginPercent - Safety margin percentage (default: 20%) * @returns Gas limit with safety margin * * @example * ```typescript * const gasLimit = await estimateGasWithMargin(provider, { * to: '0x...', * value: parseEther('1.0'), * data: '0x...' * }, 20); // 20% margin * ``` */ export async function estimateGasWithMargin( provider: JsonRpcProvider, tx: any, marginPercent: number = 20 ): Promise { try { const estimate = await provider.estimateGas(tx); // Add safety margin const margin = (estimate * BigInt(marginPercent)) / 100n; const withMargin = estimate + margin; // Cap at block gas limit (if available) try { const block = await provider.getBlock('latest'); if (block && block.gasLimit) { const blockGasLimit = BigInt(block.gasLimit.toString()); return withMargin > blockGasLimit ? blockGasLimit : withMargin; } } catch (error) { // If we can't get block, just return withMargin } return withMargin; } catch (error: any) { // If estimation fails, return a conservative default console.warn('Gas estimation failed, using conservative default:', error.message); // Default gas limits for different transaction types if (tx.data && tx.data !== '0x' && tx.data.length > 2) { return 500000n; // Contract interaction } return 21000n; // Simple transfer } } /** * Calculate maximum transaction amount accounting for gas fees * * @param balance - Available balance * @param gasLimit - Estimated gas limit * @param gasPrice - Gas price (for legacy) or maxFeePerGas (for EIP-1559) * @returns Maximum transferable amount * * @example * ```typescript * const balance = await provider.getBalance(address); * const gasLimit = 21000n; * const gasPrice = (await provider.getFeeData()).gasPrice; * * const maxAmount = calculateMaxTransferAmount(balance, gasLimit, gasPrice); * ``` */ export function calculateMaxTransferAmount( balance: bigint, gasLimit: bigint, gasPrice: bigint ): bigint { const gasCost = gasLimit * gasPrice; if (balance <= gasCost) { return 0n; } return balance - gasCost; } /** * Check if transaction has sufficient gas * * @param balance - Available balance * @param amount - Amount to transfer * @param gasLimit - Gas limit * @param gasPrice - Gas price * @returns true if sufficient, throws error otherwise * @throws Error if insufficient gas */ export function checkSufficientGas( balance: bigint, amount: bigint, gasLimit: bigint, gasPrice: bigint ): boolean { const gasCost = gasLimit * gasPrice; const totalCost = amount + gasCost; if (totalCost > balance) { const shortfall = totalCost - balance; throw new Error( `Insufficient balance for transaction + gas. ` + `Need: ${totalCost}, Have: ${balance}, Short: ${shortfall}` ); } return true; } /** * Transaction builder with validation */ export class TransactionBuilder { private tx: any = {}; constructor(private provider: JsonRpcProvider) {} to(address: string): this { this.tx.to = address; return this; } value(amount: bigint): this { this.tx.value = amount; return this; } data(data: string): this { this.tx.data = data; return this; } gasLimit(limit: bigint): this { this.tx.gasLimit = limit; return this; } gasPrice(price: bigint): this { this.tx.gasPrice = price; return this; } maxFeePerGas(fee: bigint): this { this.tx.maxFeePerGas = fee; return this; } maxPriorityFeePerGas(fee: bigint): this { this.tx.maxPriorityFeePerGas = fee; return this; } nonce(nonce: number): this { this.tx.nonce = nonce; return this; } /** * Build transaction with validation */ async build(options?: { addGasMargin?: boolean; marginPercent?: number; }): Promise { // Validate required fields if (!this.tx.to) { throw new Error('Transaction must have a "to" address'); } // Add gas limit if not provided if (!this.tx.gasLimit && options?.addGasMargin) { this.tx.gasLimit = await estimateGasWithMargin( this.provider, this.tx, options.marginPercent ); } return { ...this.tx }; } }