miden-client 0.16.1

Client library that facilitates interaction with the Miden network
Documentation
use alloc::boxed::Box;
use alloc::string::{String, ToString};
use core::error::Error;
use core::fmt;
use core::num::TryFromIntError;

use miden_protocol::account::AccountId;
use miden_protocol::crypto::merkle::MerkleError;
use miden_protocol::errors::NoteError;
use miden_protocol::note::NoteId;
use miden_protocol::utils::serde::DeserializationError;
use thiserror::Error;

use super::RpcEndpoint;

pub mod node;
pub use node::{AddTransactionError, EndpointError};

// RPC ERROR
// ================================================================================================

#[derive(Debug, Error)]
pub enum RpcError {
    #[error("accept header validation failed")]
    AcceptHeaderError(#[from] AcceptHeaderError),
    #[error(
        "unexpected update received for private account {0}; private account state should not be sent by the node"
    )]
    AccountUpdateForPrivateAccountReceived(AccountId),
    #[error("failed to connect to the Miden node")]
    ConnectionError(#[source] Box<dyn Error + Send + Sync + 'static>),
    #[error("failed to deserialize response from the Miden node: {0}")]
    DeserializationError(String),
    #[error("Miden node response is missing expected field '{0}'")]
    ExpectedDataMissing(String),
    #[error("rpc pagination error: {0}")]
    PaginationError(String),
    #[error("received an invalid response from the Miden node: {0}")]
    InvalidResponse(String),
    #[error("grpc request failed for {endpoint}: {error_kind}{}",
        endpoint_error.as_ref().map_or(String::new(), |e| format!(" ({e})")))]
    RequestError {
        endpoint: RpcEndpoint,
        error_kind: GrpcError,
        endpoint_error: Option<EndpointError>,
        #[source]
        source: Option<Box<dyn Error + Send + Sync + 'static>>,
    },
    #[error("note {0} was not found on the Miden node")]
    NoteNotFound(NoteId),
    #[error("failed to seal transaction inputs for submission: {0}")]
    TransactionInputsSealingFailed(String),
    #[error("the transaction encryption key served by the node was rejected: {0}")]
    TransactionEncryptionKeyRejected(String),
    #[error("invalid Miden node endpoint '{0}'; expected format: https://host:port")]
    InvalidNodeEndpoint(String),
}

impl RpcError {
    /// Returns the typed endpoint error if this is a request error, or `None` otherwise.
    pub fn endpoint_error(&self) -> Option<&EndpointError> {
        match self {
            Self::RequestError { endpoint_error, .. } => endpoint_error.as_ref(),
            _ => None,
        }
    }

    /// Returns whether this is a submission rejected because the transaction inputs were sealed
    /// against an encryption key the validator does not hold.
    pub fn is_stale_transaction_encryption_key(&self) -> bool {
        matches!(
            self,
            Self::RequestError {
                endpoint: RpcEndpoint::SubmitProvenTx | RpcEndpoint::SubmitProvenBatch,
                error_kind: GrpcError::FailedPrecondition,
                ..
            }
        )
    }

    /// Returns whether this is a submission that came back without a definite outcome, so the
    /// node may or may not have accepted the transaction.
    ///
    /// In practice a lost submission arrives as `Unavailable`, `Unknown` or `Cancelled`. The match
    /// lists the codes the node issues deliberately instead, so a code this client does not
    /// recognize stays on the "may have landed" side.
    pub fn is_indeterminate_submission(&self) -> bool {
        let Self::RequestError {
            endpoint: RpcEndpoint::SubmitProvenTx | RpcEndpoint::SubmitProvenBatch,
            error_kind,
            ..
        } = self
        else {
            return false;
        };

        !matches!(
            error_kind,
            // The node processed the request and rejected it
            GrpcError::InvalidArgument
                | GrpcError::FailedPrecondition
                | GrpcError::NotFound
                | GrpcError::AlreadyExists
                | GrpcError::OutOfRange
                | GrpcError::ResourceExhausted
                | GrpcError::Unauthenticated
                | GrpcError::PermissionDenied
                | GrpcError::Unimplemented
        )
    }
}

impl From<DeserializationError> for RpcError {
    fn from(err: DeserializationError) -> Self {
        Self::DeserializationError(err.to_string())
    }
}

impl From<NoteError> for RpcError {
    fn from(err: NoteError) -> Self {
        Self::DeserializationError(err.to_string())
    }
}

impl From<RpcConversionError> for RpcError {
    fn from(err: RpcConversionError) -> Self {
        Self::DeserializationError(err.to_string())
    }
}

// RPC CONVERSION ERROR
// ================================================================================================

#[derive(Debug, Error)]
pub enum RpcConversionError {
    #[error("failed to deserialize")]
    DeserializationError(#[from] DeserializationError),
    #[error(
        "invalid field element: value is outside the valid range (0..modulus, where modulus = 2^64 - 2^32 + 1)"
    )]
    NotAValidFelt,
    #[error("invalid note type in node response")]
    NoteTypeError(#[from] NoteError),
    #[error("merkle proof error in node response")]
    MerkleError(#[from] MerkleError),
    #[error("invalid field in node response: {0}")]
    InvalidField(String),
    #[error("integer conversion failed in node response")]
    InvalidInt(#[from] TryFromIntError),
    #[error("field `{field_name}` expected to be present in protobuf representation of {entity}")]
    MissingFieldInProtobufRepresentation {
        entity: &'static str,
        field_name: &'static str,
    },
}

// GRPC ERROR KIND
// ================================================================================================

/// Categorizes gRPC errors based on their status codes and common patterns
#[derive(Debug, Error)]
pub enum GrpcError {
    #[error("resource not found")]
    NotFound,
    #[error("invalid request parameters")]
    InvalidArgument,
    #[error("permission denied")]
    PermissionDenied,
    #[error("resource already exists")]
    AlreadyExists,
    #[error("request was rate-limited or the node's resources are exhausted; retry after a delay")]
    ResourceExhausted,
    #[error("precondition failed")]
    FailedPrecondition,
    #[error("operation was cancelled")]
    Cancelled,
    #[error("request to Miden node timed out; the node may be under heavy load")]
    DeadlineExceeded,
    #[error("Miden node is unavailable; check that the node is running and reachable")]
    Unavailable,
    #[error("Miden node returned an internal error; this is likely a node-side issue")]
    Internal,
    #[error("the requested method is not implemented by this version of the Miden node")]
    Unimplemented,
    #[error(
        "request was rejected as unauthenticated; check your credentials and connection settings"
    )]
    Unauthenticated,
    #[error("operation was aborted")]
    Aborted,
    #[error("operation was attempted past the valid range")]
    OutOfRange,
    #[error("unrecoverable data loss or corruption")]
    DataLoss,
    #[error("unknown error: {0}")]
    Unknown(String),
}

impl GrpcError {
    /// Creates a `GrpcError` from a gRPC status code following the official specification
    /// <https://github.com/grpc/grpc/blob/master/doc/statuscodes.md#status-codes-and-their-use-in-grpc>
    pub fn from_code(code: i32, message: Option<String>) -> Self {
        match code {
            1 => Self::Cancelled,
            2 => Self::Unknown(message.unwrap_or_default()),
            3 => Self::InvalidArgument,
            4 => Self::DeadlineExceeded,
            5 => Self::NotFound,
            6 => Self::AlreadyExists,
            7 => Self::PermissionDenied,
            8 => Self::ResourceExhausted,
            9 => Self::FailedPrecondition,
            10 => Self::Aborted,
            11 => Self::OutOfRange,
            12 => Self::Unimplemented,
            13 => Self::Internal,
            14 => Self::Unavailable,
            15 => Self::DataLoss,
            16 => Self::Unauthenticated,
            _ => Self::Unknown(
                message.unwrap_or_else(|| format!("Unknown gRPC status code: {code}")),
            ),
        }
    }
}

// ACCEPT HEADER ERROR
// ================================================================================================

// TODO: Accept header errors are still parsed from message strings, which is fragile.
// Ideally the node would return structured error codes for these too. See #1129.

/// Errors that can occur during accept header validation.
#[derive(Debug, Error)]
pub enum AcceptHeaderError {
    #[error("server rejected request - please check your version and network settings ({0})")]
    NoSupportedMediaRange(AcceptHeaderContext),
    #[error("server rejected request - parsing error: {0}")]
    ParsingError(String),
}

/// Extra context attached to Accept header negotiation failures.
#[derive(Debug, Clone)]
pub struct AcceptHeaderContext {
    pub client_version: String,
    pub genesis_commitment: String,
}

impl AcceptHeaderContext {
    pub fn unknown() -> Self {
        Self {
            client_version: "unknown".to_string(),
            genesis_commitment: "unknown".to_string(),
        }
    }
}

impl fmt::Display for AcceptHeaderContext {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(
            f,
            "client version: {}, genesis commitment: {}",
            self.client_version, self.genesis_commitment
        )
    }
}

impl AcceptHeaderError {
    /// Try to parse an accept header error from a message string, adding context.
    pub fn try_from_message_with_context(
        message: &str,
        context: AcceptHeaderContext,
    ) -> Option<Self> {
        // Check for the main compatibility error message
        if message.contains(
            "server does not support any of the specified application/vnd.miden content types",
        ) {
            return Some(Self::NoSupportedMediaRange(context));
        }
        if message.contains("genesis value failed to parse")
            || message.contains("version value failed to parse")
        {
            return Some(Self::ParsingError(message.to_string()));
        }
        None
    }
}

// TESTS
// ================================================================================================

#[cfg(test)]
mod tests {
    use super::{GrpcError, RpcEndpoint, RpcError};

    fn submission_failure(error_kind: GrpcError) -> RpcError {
        RpcError::RequestError {
            endpoint: RpcEndpoint::SubmitProvenTx,
            error_kind,
            endpoint_error: None,
            source: None,
        }
    }

    /// None of these carry evidence about whether the node processed the request, so a submission
    /// that fails with any of them may still be in the mempool.
    #[test]
    fn transport_failures_are_indeterminate() {
        for error_kind in [
            GrpcError::Unavailable,
            GrpcError::Unknown("transport error".into()),
            GrpcError::Cancelled,
            GrpcError::DeadlineExceeded,
            GrpcError::Internal,
            GrpcError::Aborted,
        ] {
            let label = format!("{error_kind:?}");
            assert!(
                submission_failure(error_kind).is_indeterminate_submission(),
                "{label} must be treated as indeterminate"
            );
        }
    }

    /// Codes the node issues deliberately are an answer, so the transaction did not land.
    #[test]
    fn deliberate_rejections_are_definite() {
        for error_kind in [
            GrpcError::InvalidArgument,
            GrpcError::FailedPrecondition,
            GrpcError::ResourceExhausted,
            GrpcError::NotFound,
            GrpcError::AlreadyExists,
            GrpcError::OutOfRange,
            GrpcError::Unauthenticated,
            GrpcError::PermissionDenied,
            GrpcError::Unimplemented,
        ] {
            let label = format!("{error_kind:?}");
            assert!(
                !submission_failure(error_kind).is_indeterminate_submission(),
                "{label} is a rejection, not an unknown outcome"
            );
        }
    }

    /// A read that fails leaves nothing behind to recover, so it never qualifies.
    #[test]
    fn reads_are_never_indeterminate_submissions() {
        let err = RpcError::RequestError {
            endpoint: RpcEndpoint::GetBlockHeaderByNumber,
            error_kind: GrpcError::Unavailable,
            endpoint_error: None,
            source: None,
        };

        assert!(!err.is_indeterminate_submission());
    }

    /// A connection that was never opened is not a submission failure: nothing was sent.
    #[test]
    fn connection_errors_are_not_indeterminate() {
        let err = RpcError::ConnectionError("no route to host".into());

        assert!(!err.is_indeterminate_submission());
    }
}