newton-chainio 0.5.2

newton prover chainio
use alloy::{
    contract::Error as ContractError,
    primitives::{Address, Bytes, B256},
    providers::PendingTransactionError as AlloyError,
    signers::local::LocalSignerError,
    transports::{RpcError, TransportErrorKind},
};
use eigensdk::client_avsregistry::error::AvsRegistryError;
use newton_core::{config::error::ConfigError, keys::error::KeyError};
use thiserror::Error;

/// Per-item failure classification within a batch, decoded from the 4-byte revert selector.
/// Used by the batch submitter to decide per-item handling without string matching.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum BatchItemError {
    /// Task already exists on-chain — idempotent, treat as success
    TaskAlreadyExists,
    /// Task already responded — idempotent, treat as success
    TaskAlreadyResponded,
    /// Per-item inner call ran out of gas. Either signaled explicitly by the
    /// contract's `ItemLikelyOutOfGas` selector (newer contracts) or inferred
    /// from a 0-byte revert `reason` (older contracts whose `try/catch` swallowed
    /// OOG without a selector fingerprint). Transient — safe to retry.
    LikelyOutOfGas {
        /// Gas cap the contract forwarded to the inner call, decoded from the
        /// explicit error. `None` for legacy empty-reason reverts.
        gas_forwarded: Option<u64>,
    },
    /// Batch contract's per-item pre-check rejected the item because `gasleft()`
    /// was below the minimum safe forwarding threshold. Means the item never
    /// ran — retry in a fresh batch where it gets a full gas budget.
    InsufficientGasForItem {
        /// `gasleft()` at the time of the pre-check, from the explicit error.
        gas_left: Option<u64>,
    },
    /// Contract reverted with a known non-recoverable error
    ContractRevert {
        /// 4-byte selector as hex string
        selector: String,
        /// Human-readable error name
        name: String,
    },
    /// Revert data too short or unrecognized selector
    Unknown {
        /// Raw revert bytes
        raw: Vec<u8>,
    },
}

impl BatchItemError {
    /// Returns true if this is an idempotent success (task already processed).
    pub fn is_idempotent(&self) -> bool {
        matches!(self, Self::TaskAlreadyExists | Self::TaskAlreadyResponded)
    }

    /// Returns true if this revert is transient and the item should be re-queued.
    /// OOG and insufficient-gas pre-check rejections are non-deterministic failures
    /// that depend on the batch context; a fresh batch gives the item a new shot.
    pub fn is_retryable(&self) -> bool {
        matches!(self, Self::LikelyOutOfGas { .. } | Self::InsufficientGasForItem { .. })
    }
}
/// Error returned by chainio
#[derive(Debug, Error)]
pub enum ChainIoError {
    /// Create new task call failed
    #[error("Create new task call failed: {reason}")]
    CreateNewTaskCallFail {
        /// The reason for the create new task call failure
        reason: String,
    },
    /// Submit identity data call failed
    #[error("Submit identity data call failed: {reason}")]
    SubmitIdentityDataCallFail {
        /// The reason for the submit identity data call failure
        reason: String,
    },
    /// `commitStateRoot` call failed.
    #[error("Commit state root call failed: {reason}")]
    CommitStateRootCallFail {
        /// The reason for the commit state root call failure
        reason: String,
    },
    /// Bls response conversion error
    #[error("Bls response conversion error: {reason}")]
    BlsResponseConversionError {
        /// The reason for the bls response conversion error
        reason: String,
    },
    /// Alloy contract error
    #[error("Alloy provider error: {0:?}")]
    AlloyProviderError(#[from] AlloyError),
    /// No logs generated in Create new task function
    #[error("No logs generated in Create new task function")]
    CreateNewTaskNoEventFound,
    /// Config error
    #[error("Config error {0:?}")]
    ConfigParseError(#[from] ConfigError),
    /// Avs registry error in eigensdk-rs
    #[error("AvsRegistry error in eigensdk-rs {0:?}")]
    SdkAvsRegistryChainError(#[from] AvsRegistryError),
    /// Alloy Rpc Error
    #[error("Alloy Rpc Error {0:?}")]
    RpcError(#[from] RpcError<TransportErrorKind>),
    /// Alloy Signer Error
    #[error("Alloy Signer Error {0:?}")]
    SignerError(#[from] eyre::Error),
    /// Key error
    #[error("Key error {0:?}")]
    KeyError(#[from] KeyError),
    /// Alloy Contract Error
    #[error("Alloy Contract Error {0:?}")]
    ContractError(#[from] ContractError),
    /// Alloy Contract Error with transaction context
    #[error("Alloy Contract Error {source:?} (tx from={from}, to={to}, value={value}, data={data})")]
    ContractErrorWithTx {
        /// Transaction sender
        from: Address,
        /// Transaction target
        to: Address,
        /// Transaction value (hex, 0x-prefixed)
        value: String,
        /// Transaction calldata (hex, 0x-prefixed)
        data: String,
        /// Underlying alloy contract error
        #[source]
        source: ContractError,
    },
    /// Send aggregated response error
    #[error("Send aggregated response error")]
    SendAggregatedResponseError,
    /// Policy deployed event not found
    #[error("Policy deployed event not found")]
    PolicyDeployedEventNotFound,
    /// Policy data deployed event not found
    #[error("Policy data deployed event not found")]
    PolicyDataDeployedEventNotFound,
    /// Identity data submitted event not found
    #[error("Identity data submitted event not found")]
    IdentityDataSubmittedEventNotFound,
    /// Batch transaction partially failed — some items reverted.
    /// Contains per-item failure details parsed from `BatchPartialFailure` revert data.
    #[error("Batch partial failure: {count} items failed", count = failures.len())]
    BatchPartialFailure {
        /// Failed items with index, task ID, and ABI-encoded revert reason
        failures: Vec<crate::avs::writer::BatchFailedItem>,
    },
    /// Task already exists on-chain (idempotent — not a real error).
    #[error("Task {task_id} already exists on-chain")]
    TaskAlreadyExists {
        /// The task ID that already exists
        task_id: B256,
    },
    /// Task already responded to on-chain (idempotent — not a real error).
    #[error("Task {task_id} already responded")]
    TaskAlreadyResponded {
        /// The task ID that was already responded to
        task_id: B256,
    },
    /// Contract reverted with a known error selector.
    #[error("Contract reverted: {name} (0x{selector})")]
    ContractRevert {
        /// 4-byte selector as hex string
        selector: String,
        /// Human-readable error name
        name: String,
        /// Full revert data for downstream decoding
        raw_data: Vec<u8>,
    },
    /// Transaction was mined but reverted on-chain
    #[error("Transaction {0} reverted on-chain")]
    TransactionReverted(B256),
    /// Transaction submission timed out
    #[error("Transaction submission timed out after {timeout_secs} seconds")]
    TransactionTimeout {
        /// Timeout duration in seconds
        timeout_secs: u64,
    },
    /// Transaction submission retries exhausted
    #[error("Transaction submission failed after {attempts} attempts: {last_error}")]
    RetriesExhausted {
        /// Number of attempts made
        attempts: usize,
        /// Last error message
        last_error: String,
    },
    /// A broadcast failed at the `send` step *after* its nonce was reserved and
    /// set explicitly — so the transaction may already have reached the mempool.
    /// Unlike a pre-send (`fill`/gas-estimate) failure, the reserved nonce cannot
    /// be safely released: a propagated tx could still mine, and reusing the
    /// nonce would collide with it. The reserved nonce is carried so the caller
    /// drives a same-nonce cancel (confirm-or-cancel) to resolve the slot on-chain
    /// rather than orphaning it. Only produced when an owned nonce allocator is
    /// active.
    #[error("broadcast send failed at nonce {nonce}: {reason}")]
    BroadcastSendFailed {
        /// The reserved nonce whose slot must be resolved on-chain.
        nonce: u64,
        /// `max_fee_per_gas` the failed tx was signed with. Carried so the
        /// same-nonce cancel that resolves the slot can be seeded at (or above)
        /// the original price — a cancel priced below a propagated original is
        /// rejected "replacement underpriced" and burns escalation cycles.
        max_fee_per_gas: u128,
        /// `max_priority_fee_per_gas` the failed tx was signed with (see above).
        max_priority_fee_per_gas: u128,
        /// Underlying send error, formatted.
        reason: String,
    },
}

impl ChainIoError {
    /// Returns true if this error is transient and the operation may succeed on retry.
    /// Transient errors are network/RPC issues and receipt timeouts, not contract
    /// reverts or config errors.
    pub fn is_transient(&self) -> bool {
        matches!(
            self,
            ChainIoError::RpcError(_) | ChainIoError::AlloyProviderError(_) | ChainIoError::TransactionTimeout { .. }
        )
    }

    /// Returns true if this error represents an idempotent success (task already on-chain).
    pub fn is_idempotent(&self) -> bool {
        matches!(
            self,
            ChainIoError::TaskAlreadyExists { .. } | ChainIoError::TaskAlreadyResponded { .. }
        )
    }

    /// Returns true if the node rejected the broadcast because the signer can't
    /// afford `gas_limit × max_fee_per_gas (+ value)` — geth/erigon surface this
    /// as `"insufficient funds for gas * price + value"` (JSON-RPC code -32000).
    ///
    /// Critical for the cancel/replacement retry loops: an underpriced rejection
    /// means "bid higher", but an insufficient-funds rejection means the bid is
    /// ALREADY too high for the balance — escalating the fee makes the next
    /// attempt even less affordable. Callers must hold the fee flat (and wait for
    /// funding) on this class, never climb. Matched on the message because alloy
    /// collapses these into an opaque `TransportError(ErrorResp(..))`.
    pub fn is_insufficient_funds(&self) -> bool {
        let s = self.to_string().to_lowercase();
        s.contains("insufficient funds")
    }

    /// Returns true if the node rejected a broadcast because the nonce is below
    /// the account's current nonce — geth/erigon phrase this `"nonce too low"`.
    /// This is a definitive signal that the slot is ALREADY resolved on-chain:
    /// some transaction at this nonce (the original, a gas-bump replacement, or
    /// an earlier cancel) has mined, so the slot can never be replaced again.
    ///
    /// The bump/cancel pursuit loops use this to STOP — re-broadcasting at a
    /// consumed nonce can only ever fail, and escalating its fee is pointless.
    /// The payload is re-queued (re-submission is idempotent on-chain) rather
    /// than chasing a ghost. Note: this is distinct from "replacement
    /// underpriced" (slot still open, bid higher) and "already known" (our tx is
    /// already pending — keep waiting, don't treat as resolved).
    pub fn is_nonce_consumed(&self) -> bool {
        let s = self.to_string().to_lowercase();
        s.contains("nonce too low")
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn is_insufficient_funds_matches_geth_message() {
        // Verbatim message from the runaway-bump incident: a 21k-gas cancel
        // priced at ~786k gwei (16.5 ETH) against a 0.13 ETH balance. alloy
        // collapses this into a Debug-formatted ContractError whose string
        // contains the node's message; matching on the substring is what lets
        // the cancel loop stop escalating an already-unaffordable tx.
        let err = ChainIoError::CreateNewTaskCallFail {
            reason: "Alloy Contract Error TransportError(ErrorResp(ErrorPayload { code: -32000, \
                     message: \"insufficient funds for gas * price + value: balance 130453576665547003, \
                     tx cost 16524416509720140000, overshot 16393962933054592997\", data: None }))"
                .to_string(),
        };
        assert!(err.is_insufficient_funds());
    }

    #[test]
    fn is_insufficient_funds_case_insensitive() {
        let err = ChainIoError::CreateNewTaskCallFail {
            reason: "INSUFFICIENT FUNDS for gas".to_string(),
        };
        assert!(err.is_insufficient_funds());
    }

    #[test]
    fn is_insufficient_funds_ignores_underpriced() {
        // An underpriced rejection is the OPPOSITE class — the caller SHOULD
        // climb the fee, so this must not be misclassified as unaffordable.
        let err = ChainIoError::CreateNewTaskCallFail {
            reason: "replacement transaction underpriced".to_string(),
        };
        assert!(!err.is_insufficient_funds());
    }

    #[test]
    fn is_nonce_consumed_matches_nonce_too_low() {
        let err = ChainIoError::CreateNewTaskCallFail {
            reason: "nonce too low: next nonce 42, tx nonce 41".to_string(),
        };
        assert!(err.is_nonce_consumed());
    }

    #[test]
    fn is_nonce_consumed_ignores_unrelated_and_sibling_classes() {
        // "replacement underpriced" (slot still open, bid higher) and
        // "insufficient funds" (can't pay) must NOT be read as a consumed slot —
        // those keep their own handling.
        for reason in [
            "replacement transaction underpriced",
            "insufficient funds for gas * price + value",
            "connection refused",
        ] {
            let err = ChainIoError::CreateNewTaskCallFail {
                reason: reason.to_string(),
            };
            assert!(!err.is_nonce_consumed(), "must not flag nonce-consumed: {reason}");
        }
    }
}