newton-chainio 0.5.2

newton prover chainio
use std::{future::Future, time::Duration};

use alloy::{contract::Error as ContractError, primitives::U256, providers::Provider, rpc::types::TransactionReceipt};
use eigensdk::common::get_provider;
use tokio::time::sleep;
use tracing::{error, info, warn};

use crate::error::ChainIoError;

const MAX_RETRY_ATTEMPTS: usize = 2;
const RETRY_DELAY_MS: u64 = 300;
/// Timeout for a single transaction submission attempt (send + wait for receipt)
const TX_SUBMISSION_TIMEOUT_SECS: u64 = 60;
/// Fee multiplier increase for "replacement underpriced" errors (25% instead of 10%)
const REPLACEMENT_UNDERPRICED_FEE_INCREASE_PCT: u64 = 125;

/// EIP-1559 fee structure for transactions
#[derive(Debug, Clone, Copy)]
pub struct Eip1559Fees {
    /// Maximum fee per gas unit (includes base fee + priority fee)
    pub max_fee_per_gas: u128,
    /// Maximum priority fee per gas unit (tip to miners)
    pub max_priority_fee_per_gas: u128,
}

/// Generic retry logic for any Ethereum contract transaction
///
/// # Arguments
/// * `rpc_url` - RPC URL for getting gas fees
/// * `transaction_fn` - Async function that attempts to send a transaction and get receipt
///
/// # Returns
/// * `Result<TransactionReceipt, ChainIoError>` - Transaction receipt on success
pub async fn send_with_retries<F, Fut>(
    rpc_url: String,
    chain_id: u64,
    mut transaction_fn: F,
) -> Result<TransactionReceipt, ChainIoError>
where
    F: FnMut(Option<Eip1559Fees>) -> Fut,
    Fut: Future<Output = Result<TransactionReceipt, ChainIoError>>,
{
    let mut fee_multiplier = U256::from(100); // Start with 100% (no increase)
    let mut last_error_msg = String::new();

    for attempt in 1..=MAX_RETRY_ATTEMPTS {
        // Calculate adjusted EIP-1559 fees if needed
        let fee_override = if fee_multiplier > U256::from(100) {
            let provider = get_provider(&rpc_url);

            // Try to get EIP-1559 fees first, fallback to legacy gas price
            if let Ok(estimate) = provider.estimate_eip1559_fees().await {
                let base_max_fee = U256::from(estimate.max_fee_per_gas);
                let base_priority_fee = U256::from(estimate.max_priority_fee_per_gas);

                let adjusted_max_fee = base_max_fee * fee_multiplier / U256::from(100);
                let adjusted_priority_fee = base_priority_fee * fee_multiplier / U256::from(100);

                let fees = Eip1559Fees {
                    max_fee_per_gas: adjusted_max_fee.to::<u128>(),
                    max_priority_fee_per_gas: adjusted_priority_fee.to::<u128>(),
                };

                info!(
                    "Retry attempt {} with increased EIP-1559 fees: max_fee {} -> {}, priority_fee {} -> {} (multiplier: {}%)",
                    attempt,
                    estimate.max_fee_per_gas,
                    fees.max_fee_per_gas,
                    estimate.max_priority_fee_per_gas,
                    fees.max_priority_fee_per_gas,
                    fee_multiplier
                );

                Some(fees)
            } else if let Ok(current_gas_price) = provider.get_gas_price().await {
                // Fallback to legacy gas price calculation
                let current_gas_price_u256 = U256::from(current_gas_price);
                let adjusted_gas_price = current_gas_price_u256 * fee_multiplier / U256::from(100);
                let adjusted_u128 = adjusted_gas_price.to::<u128>();

                let fees = Eip1559Fees {
                    max_fee_per_gas: adjusted_u128,
                    max_priority_fee_per_gas: adjusted_u128 / 10, // 10% of max fee as priority
                };

                info!(
                    "Retry attempt {} with legacy gas price converted to EIP-1559: {} -> max_fee {}, priority_fee {} (multiplier: {}%)",
                    attempt, current_gas_price, fees.max_fee_per_gas, fees.max_priority_fee_per_gas, fee_multiplier
                );

                Some(fees)
            } else {
                None
            }
        } else {
            None
        };

        // Try to execute the transaction with timeout
        let timeout_duration = Duration::from_secs(TX_SUBMISSION_TIMEOUT_SECS);
        let tx_result = tokio::time::timeout(timeout_duration, transaction_fn(fee_override)).await;

        match tx_result {
            Ok(Ok(receipt)) => {
                info!(
                    "Successfully sent transaction on attempt {}, tx hash: {}",
                    attempt, receipt.transaction_hash
                );
                newton_metric::record_tx_submission_attempts(chain_id, attempt as u64);
                return Ok(receipt);
            }
            Ok(Err(error)) => {
                error!("Transaction failed on attempt {}: {:?}", attempt, error);
                last_error_msg = format!("{:?}", error);

                // Extract the contract error if it's wrapped in ChainIoError
                // Handle both ContractError and ContractErrorWithTx variants
                let contract_error = match &error {
                    ChainIoError::ContractError(e) => Some(e),
                    ChainIoError::ContractErrorWithTx { source, .. } => Some(source),
                    _ => None,
                };

                // Check if this is a retryable error
                if let Some(contract_err) = contract_error {
                    if !is_retryable_error(contract_err) {
                        error!("Non-retryable contract error encountered: {:?}", error);
                        newton_metric::inc_tx_submission_exhausted(chain_id, "non_retryable");
                        newton_metric::record_tx_submission_attempts(chain_id, attempt as u64);
                        return Err(error);
                    }

                    // Check if this is a "replacement underpriced" error
                    if is_replacement_underpriced_error(contract_err) {
                        // Increase fees by 25% for replacement underpriced (more aggressive than 10%)
                        fee_multiplier =
                            fee_multiplier * U256::from(REPLACEMENT_UNDERPRICED_FEE_INCREASE_PCT) / U256::from(100);
                        newton_metric::inc_tx_submission_retry(chain_id, "replacement_underpriced");
                        warn!(
                            "Replacement underpriced error detected, increasing fees to {}% for next attempt",
                            fee_multiplier
                        );
                    } else {
                        newton_metric::inc_tx_submission_retry(chain_id, "other_retryable");
                    }
                } else {
                    // For non-ContractError types (network errors, etc.), treat as potentially retryable
                    newton_metric::inc_tx_submission_retry(chain_id, "network_error");
                    warn!("Non-contract error, retrying: {:?}", error);
                }

                // If this is the last attempt, return the error with enhanced context
                if attempt == MAX_RETRY_ATTEMPTS {
                    newton_metric::inc_tx_submission_exhausted(chain_id, "retries_exhausted");
                    newton_metric::record_tx_submission_attempts(chain_id, attempt as u64);
                    error!(
                        "Transaction submission failed after {} attempts. Last error: {}",
                        MAX_RETRY_ATTEMPTS, last_error_msg
                    );
                    return Err(ChainIoError::RetriesExhausted {
                        attempts: MAX_RETRY_ATTEMPTS,
                        last_error: last_error_msg,
                    });
                }

                // Wait before retrying
                warn!(
                    "Retrying in {}ms due to error (attempt {}/{})",
                    RETRY_DELAY_MS, attempt, MAX_RETRY_ATTEMPTS
                );
                sleep(Duration::from_millis(RETRY_DELAY_MS)).await;
            }
            Err(_elapsed) => {
                // Timeout occurred
                error!(
                    "Transaction attempt {} timed out after {} seconds",
                    attempt, TX_SUBMISSION_TIMEOUT_SECS
                );
                newton_metric::inc_tx_submission_timeout(chain_id);
                last_error_msg = format!("timeout after {} seconds", TX_SUBMISSION_TIMEOUT_SECS);

                if attempt == MAX_RETRY_ATTEMPTS {
                    newton_metric::inc_tx_submission_exhausted(chain_id, "timeout");
                    newton_metric::record_tx_submission_attempts(chain_id, attempt as u64);
                    error!("Transaction submission timed out after {} attempts", MAX_RETRY_ATTEMPTS);
                    return Err(ChainIoError::TransactionTimeout {
                        timeout_secs: TX_SUBMISSION_TIMEOUT_SECS,
                    });
                }

                // Wait before retrying after timeout
                warn!(
                    "Retrying after timeout in {}ms (attempt {}/{})",
                    RETRY_DELAY_MS, attempt, MAX_RETRY_ATTEMPTS
                );
                sleep(Duration::from_millis(RETRY_DELAY_MS)).await;
            }
        }
    }

    // This should never be reached due to the return statements above, but just in case
    newton_metric::inc_tx_submission_exhausted(chain_id, "unexpected");
    Err(ChainIoError::RetriesExhausted {
        attempts: MAX_RETRY_ATTEMPTS,
        last_error: last_error_msg,
    })
}

/// Check if an error is retryable
fn is_retryable_error(error: &ContractError) -> bool {
    if !matches!(
        error,
        ContractError::TransportError(_) | ContractError::PendingTransactionError(_)
    ) {
        return false;
    }

    let error_str = error.to_string().to_lowercase();
    error_str.contains("timeout")
        || error_str.contains("network")
        || error_str.contains("connection")
        || error_str.contains("nonce too low")
        || error_str.contains("replacement transaction underpriced")
        || error_str.contains("server error")
        || error_str.contains("internal error")
}

/// Check if an error is "replacement transaction underpriced"
fn is_replacement_underpriced_error(error: &ContractError) -> bool {
    let error_str = error.to_string().to_lowercase();
    error_str.contains("replacement transaction underpriced")
}