cow-sdk-core 0.1.0-alpha.10

Shared CoW Protocol core types and validation primitives
Documentation
use thiserror::Error;

use crate::{cancellation::Cancelled, config::CowEnv, redaction::Redacted};

/// Validation failures for typed user input and configuration values.
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq, Error)]
pub enum ValidationError {
    /// A required string or collection field was empty after validation.
    #[error("{field} must not be empty")]
    EmptyField {
        /// Identifies the invalid field.
        field: &'static str,
    },
    /// A value could not be serialized into a valid HTTP header.
    #[error("{field} must be a valid HTTP header value")]
    InvalidHttpHeaderValue {
        /// Identifies the invalid field.
        field: &'static str,
    },
    /// A hexadecimal value did not include the required `0x` prefix.
    #[error("{field} must be 0x-prefixed hexadecimal data")]
    InvalidHexPrefix {
        /// Identifies the invalid field.
        field: &'static str,
    },
    /// A fixed-length hexadecimal value had the wrong number of hex characters.
    #[error("{field} must contain exactly {expected} hex characters")]
    InvalidHexLength {
        /// Identifies the invalid field.
        field: &'static str,
        /// Required number of hex characters excluding the `0x` prefix.
        expected: usize,
    },
    /// A hexadecimal value contained non-hex characters.
    #[error("{field} contains non-hex characters")]
    InvalidHexCharacters {
        /// Identifies the invalid field.
        field: &'static str,
    },
    /// A decimal or hexadecimal numeric value could not be parsed.
    #[error("{field} must be a non-negative integer quantity")]
    InvalidNumeric {
        /// Identifies the invalid field.
        field: &'static str,
    },
    /// A parsed numeric value exceeded the supported `uint256` range.
    #[error("{field} exceeds uint256 bounds")]
    NumericOverflow {
        /// Identifies the invalid field.
        field: &'static str,
    },
    /// A chain id was not part of the supported `CoW` Protocol network set.
    #[error("unsupported chain id {chain_id}")]
    UnsupportedChain {
        /// Unsupported numeric chain id supplied by the caller.
        chain_id: u64,
    },
    /// A `valid_to` timestamp exceeded the protocol-fixed `u32` epoch ceiling.
    #[error("valid_to {actual_seconds} exceeds the protocol u32 epoch ceiling")]
    ValidToOutOfRange {
        /// Requested absolute timestamp in seconds.
        actual_seconds: u64,
    },
    /// A decimals scale passed to [`Amount::parse_units`] was above the
    /// maximum representable value.
    ///
    /// The maximum is `77` because `10^77 < 2^256 - 1 < 10^78`, so any
    /// `decimals` value above `77` would make `10^decimals` overflow the
    /// inner `uint256` that backs [`Amount`]. Every ERC-20 token across the
    /// supported chains ships `decimals <= 18`, so the bound is structurally
    /// satisfied in practice; the explicit error fails closed at
    /// construction time instead of saturating or panicking.
    ///
    /// [`Amount`]: crate::Amount
    /// [`Amount::parse_units`]: crate::Amount::parse_units
    #[error("decimals scale {actual} exceeds the maximum representable value {max}")]
    DecimalsOutOfRange {
        /// The decimals scale that was rejected.
        actual: u8,
        /// The maximum representable decimals scale.
        max: u8,
    },
}

/// Top-level core crate error.
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq, Error)]
pub enum CoreError {
    /// Validation failed for a typed user input or configuration value.
    #[error("validation error: {0}")]
    Validation(#[from] ValidationError),
    /// The selected chain/environment pair did not resolve to a base URL.
    #[error(
        "missing API base URL for chain id {chain_id} in {env} environment (partner_api={partner_api})"
    )]
    MissingBaseUrl {
        /// Numeric chain id that could not be resolved.
        chain_id: u64,
        /// Environment name used during resolution.
        env: CowEnv,
        /// Whether partner API URLs were being requested.
        partner_api: bool,
    },
    /// A JSON or ABI-adjacent serialization step failed.
    ///
    /// Retained as a redaction-conformance witness: it is constructed only by
    /// the error-redaction and classification test suites to prove this arm's
    /// `Debug` redacts, and no production path emits it.
    #[error("serialization error: {0}")]
    Serialization(Redacted<String>),
    /// A downstream transport implementation violated the core contract.
    ///
    /// Retained as a redaction-conformance witness: it is constructed only by
    /// the error-redaction and classification test suites to prove this arm's
    /// `Debug` redacts, and no production path emits it.
    #[error("transport contract violation: {0}")]
    TransportContract(Redacted<String>),
    /// A long-running operation was cancelled through a cooperative cancellation token.
    #[error("operation was cancelled")]
    Cancelled,
}

impl From<Cancelled> for CoreError {
    fn from(_: Cancelled) -> Self {
        Self::Cancelled
    }
}

/// Coarse-grained failure classification shared across the workspace error
/// family.
///
/// Every public error type that the `cow-sdk` facade aggregates exposes a
/// `class(&self) -> ErrorClass` accessor that resolves to one of these
/// buckets, so downstream telemetry and retry layers can partition failures
/// without pattern-matching every nested variant by hand.
/// [`ErrorClass::Transport`] is the retryable class: the failure happened
/// before a complete response arrived, so resending is safe. For
/// [`ErrorClass::Remote`] the class alone is insufficient — a structured
/// 4xx rejection is permanent while a 5xx outage is transient — so consult
/// the concrete error's `is_retryable()` / `backoff_hint()` accessors
/// before retrying. The remaining classes signal caller-side or
/// protocol-level conditions that benefit from different recovery paths.
#[non_exhaustive]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum ErrorClass {
    /// Caller-side input failed a client-side validation boundary.
    Validation,
    /// A transport-layer failure occurred before a complete response was received.
    Transport,
    /// The remote endpoint returned a structured error response.
    Remote,
    /// The remote endpoint signalled rate limiting (HTTP 429) and the
    /// transport layer's retry budget was exhausted before it cleared.
    ///
    /// Transport retries already honor `Retry-After`, so reaching this class
    /// means the throttle outlived the retry policy rather than a transient
    /// spike the client absorbed.
    RateLimited,
    /// A signing, provider, or cryptographic helper surfaced an error.
    Signing,
    /// A long-running operation was cancelled through a cooperative token.
    Cancelled,
    /// An internal invariant or helper contract was violated.
    Internal,
}

impl ErrorClass {
    /// Returns the stable lowercase telemetry label for this class.
    ///
    /// The label is a stability contract for telemetry partitioning, mirroring
    /// [`TransportErrorClass::as_str`](crate::TransportErrorClass::as_str) and
    /// the adapter class enums. Prefer it over `{:?}`, whose `Debug` output is
    /// not contractual.
    #[must_use]
    pub const fn as_str(self) -> &'static str {
        match self {
            Self::Validation => "validation",
            Self::Transport => "transport",
            Self::Remote => "remote",
            Self::RateLimited => "rate-limited",
            Self::Signing => "signing",
            Self::Cancelled => "cancelled",
            Self::Internal => "internal",
        }
    }
}

impl core::fmt::Display for ErrorClass {
    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
        f.write_str(self.as_str())
    }
}

/// Maps a `serde_json` failure to its stable category tag.
///
/// Returns `"io"`, `"syntax"`, `"data"`, or `"eof"` — the label names the failure
/// class without echoing any decoded bytes, so it is safe to surface on a redacted
/// error (ADR 0025). The orderbook, contracts, and app-data crates share this one
/// classifier on their `From<serde_json::Error>` conversions.
#[must_use]
pub fn serialization_error_category(error: &serde_json::Error) -> &'static str {
    match error.classify() {
        serde_json::error::Category::Io => "io",
        serde_json::error::Category::Syntax => "syntax",
        serde_json::error::Category::Data => "data",
        serde_json::error::Category::Eof => "eof",
    }
}

impl CoreError {
    /// Returns the coarse-grained [`ErrorClass`] for this error.
    #[must_use]
    pub const fn class(&self) -> ErrorClass {
        match self {
            Self::Validation(_) | Self::MissingBaseUrl { .. } => ErrorClass::Validation,
            Self::Cancelled => ErrorClass::Cancelled,
            // Serialization and transport-contract failures plus any future
            // additive variants signal invariant violations.
            _ => ErrorClass::Internal,
        }
    }
}

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

    #[test]
    fn error_class_labels_are_stable_and_display_matches_as_str() {
        for (class, label) in [
            (ErrorClass::Validation, "validation"),
            (ErrorClass::Transport, "transport"),
            (ErrorClass::Remote, "remote"),
            (ErrorClass::RateLimited, "rate-limited"),
            (ErrorClass::Signing, "signing"),
            (ErrorClass::Cancelled, "cancelled"),
            (ErrorClass::Internal, "internal"),
        ] {
            assert_eq!(class.as_str(), label);
            assert_eq!(class.to_string(), label);
        }
    }
}