miden-client 0.14.5

Client library that facilitates interaction with the Miden network
Documentation
use alloc::string::{String, ToString};
use alloc::vec::Vec;
use core::fmt;

use miden_protocol::Word;
use miden_protocol::account::AccountId;
use miden_protocol::crypto::merkle::MerkleError;
pub use miden_protocol::errors::{AccountError, AccountIdError, AssetError, NetworkIdError};
use miden_protocol::errors::{
    NoteError,
    PartialBlockchainError,
    TransactionInputError,
    TransactionScriptError,
};
use miden_protocol::note::{NoteId, NoteTag};
use miden_standards::account::interface::AccountInterfaceError;
// RE-EXPORTS
// ================================================================================================
pub use miden_standards::errors::CodeBuilderError;
pub use miden_tx::AuthenticationError;
use miden_tx::utils::HexParseError;
use miden_tx::utils::serde::DeserializationError;
use miden_tx::{NoteCheckerError, TransactionExecutorError, TransactionProverError};
use thiserror::Error;

use crate::note::NoteScreenerError;
use crate::note_transport::NoteTransportError;
use crate::rpc::RpcError;
use crate::store::{NoteRecordError, StoreError};
use crate::transaction::TransactionRequestError;

// ACTIONABLE HINTS
// ================================================================================================

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ErrorHint {
    message: String,
    docs_url: Option<&'static str>,
}

impl ErrorHint {
    pub fn into_help_message(self) -> String {
        self.to_string()
    }
}

impl fmt::Display for ErrorHint {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self.docs_url {
            Some(url) => write!(f, "{} See docs: {}", self.message, url),
            None => f.write_str(self.message.as_str()),
        }
    }
}

// TODO: This is mostly illustrative but we could add a URL with fragemtn identifiers
// for each error
const TROUBLESHOOTING_DOC: &str = "https://0xmiden.github.io/miden-client/cli-troubleshooting.html";

// CLIENT ERROR
// ================================================================================================

/// Errors generated by the client.
#[derive(Debug, Error)]
pub enum ClientError {
    #[error("address {0} is already being tracked")]
    AddressAlreadyTracked(String),
    #[error("account with id {0} is already being tracked")]
    AccountAlreadyTracked(AccountId),
    #[error(
        "address {0} cannot be tracked: its derived note tag {1} is already associated with another tracked address"
    )]
    NoteTagDerivedAddressAlreadyTracked(String, NoteTag),
    #[error("account error")]
    AccountError(#[from] AccountError),
    #[error("account {0} is locked because the local state may be out of date with the network")]
    AccountLocked(AccountId),
    #[error(
        "account import failed: the on-chain account commitment ({0}) does not match the commitment of the account being imported"
    )]
    AccountCommitmentMismatch(Word),
    #[error("account {0} is private and its details cannot be retrieved from the network")]
    AccountIsPrivate(AccountId),
    #[error("account with id {0} not found on the network")]
    AccountNotFoundOnChain(AccountId),
    #[error(
        "cannot import account: the local account nonce is higher than the imported one, meaning the local state is newer"
    )]
    AccountNonceTooLow,
    #[error("asset error")]
    AssetError(#[from] AssetError),
    #[error("account data wasn't found for account id {0}")]
    AccountDataNotFound(AccountId),
    #[error("failed to construct the partial blockchain")]
    PartialBlockchainError(#[from] PartialBlockchainError),
    #[error("failed to deserialize data")]
    DataDeserializationError(#[from] DeserializationError),
    #[error("note with id {0} not found on chain")]
    NoteNotFoundOnChain(NoteId),
    #[error("failed to parse hex string")]
    HexParseError(#[from] HexParseError),
    #[error(
        "the chain Merkle Mountain Range (MMR) forest value exceeds the supported range (must fit in a u32)"
    )]
    InvalidPartialMmrForest,
    #[error(
        "cannot track a new account without its seed; the seed is required to validate the account ID's correctness"
    )]
    AddNewAccountWithoutSeed,
    #[error("merkle proof error")]
    MerkleError(#[from] MerkleError),
    #[error(
        "transaction output mismatch: expected output notes with recipient digests {0:?} were not produced by the transaction"
    )]
    MissingOutputRecipients(Vec<Word>),
    #[error("note error")]
    NoteError(#[from] NoteError),
    #[error("note consumption check failed")]
    NoteCheckerError(#[from] NoteCheckerError),
    #[error("note import error: {0}")]
    NoteImportError(String),
    #[error("failed to convert note record")]
    NoteRecordConversionError(#[from] NoteRecordError),
    #[error("note transport error")]
    NoteTransportError(#[from] NoteTransportError),
    #[error(
        "account {0} has no notes available to consume; sync the client or check that notes targeting this account exist"
    )]
    NoConsumableNoteForAccount(AccountId),
    #[error("RPC error")]
    RpcError(#[from] RpcError),
    #[error(
        "transaction failed a recency check: {0} — the reference block may be too old; try syncing and resubmitting"
    )]
    RecencyConditionError(&'static str),
    #[error("note relevance check failed")]
    NoteScreenerError(#[from] NoteScreenerError),
    #[error("storage error")]
    StoreError(#[from] StoreError),
    #[error("transaction execution failed")]
    TransactionExecutorError(#[from] TransactionExecutorError),
    #[error("invalid transaction input")]
    TransactionInputError(#[source] TransactionInputError),
    #[error("transaction proving failed")]
    TransactionProvingError(#[from] TransactionProverError),
    #[error("invalid transaction request")]
    TransactionRequestError(#[from] TransactionRequestError),
    #[error("failed to build transaction script from account interface")]
    AccountInterfaceError(#[from] AccountInterfaceError),
    #[error("transaction script error")]
    TransactionScriptError(#[source] TransactionScriptError),
    #[error("client initialization error: {0}")]
    ClientInitializationError(String),
    #[error("cannot track more note tags: the maximum of {0} tracked tags has been reached")]
    NoteTagsLimitExceeded(u32),
    #[error("cannot track more accounts: the maximum of {0} tracked accounts has been reached")]
    AccountsLimitExceeded(u32),
    #[error("expected full account data for account {0}, but only partial data is available")]
    AccountRecordNotFull(AccountId),
    #[error("expected partial account data for account {0}, but full data was found")]
    AccountRecordNotPartial(AccountId),
    #[error("failed to register NTX note script with root {script_root:?}")]
    NtxScriptRegistrationFailed {
        script_root: Word,
        #[source]
        source: RpcError,
    },
}

// CONVERSIONS
// ================================================================================================

impl From<ClientError> for String {
    fn from(err: ClientError) -> String {
        err.to_string()
    }
}

impl From<&ClientError> for Option<ErrorHint> {
    fn from(err: &ClientError) -> Self {
        match err {
            ClientError::MissingOutputRecipients(recipients) => {
                Some(missing_recipient_hint(recipients))
            },
            ClientError::TransactionRequestError(inner) => inner.into(),
            ClientError::TransactionExecutorError(inner) => transaction_executor_hint(inner),
            ClientError::NoteNotFoundOnChain(note_id) => Some(ErrorHint {
                message: format!(
                    "Note {note_id} has not been found on chain. Double-check the note ID, ensure it has been committed, and run `miden-client sync` before retrying."
                ),
                docs_url: Some(TROUBLESHOOTING_DOC),
            }),
            ClientError::AccountLocked(account_id) => Some(ErrorHint {
                message: format!(
                    "Account {account_id} is locked because the client may be missing its latest \
                     state. This can happen when the account is shared and another client executed \
                     a transaction. Run `sync` to fetch the latest state from the network."
                ),
                docs_url: Some(TROUBLESHOOTING_DOC),
            }),
            ClientError::AccountNonceTooLow => Some(ErrorHint {
                message: "The account you are trying to import has an older nonce than the version \
                          already tracked locally. Run `sync` to ensure your local state is current, \
                          or re-export the account from a more up-to-date source.".to_string(),
                docs_url: Some(TROUBLESHOOTING_DOC),
            }),
            ClientError::NoConsumableNoteForAccount(account_id) => Some(ErrorHint {
                message: format!(
                    "No notes were found that account {account_id} can consume. \
                     Run `sync` to fetch the latest notes from the network, \
                     and verify that notes targeting this account have been committed on chain."
                ),
                docs_url: Some(TROUBLESHOOTING_DOC),
            }),
            ClientError::RpcError(RpcError::ConnectionError(_)) => Some(ErrorHint {
                message: "Could not reach the Miden node. Check that the node endpoint in your \
                          configuration is correct and that the node is running.".to_string(),
                docs_url: Some(TROUBLESHOOTING_DOC),
            }),
            ClientError::RpcError(RpcError::AcceptHeaderError(_)) => Some(ErrorHint {
                message: "The node rejected the request due to a version mismatch. \
                          Ensure your client version is compatible with the node version.".to_string(),
                docs_url: Some(TROUBLESHOOTING_DOC),
            }),
            ClientError::AddNewAccountWithoutSeed => Some(ErrorHint {
                message: "New accounts require a seed to derive their initial state. \
                          Use `Client::new_account()` which generates the seed automatically, \
                          or provide the seed when importing.".to_string(),
                docs_url: Some(TROUBLESHOOTING_DOC),
            }),
            _ => None,
        }
    }
}

impl ClientError {
    pub fn error_hint(&self) -> Option<ErrorHint> {
        self.into()
    }
}

impl From<&TransactionRequestError> for Option<ErrorHint> {
    fn from(err: &TransactionRequestError) -> Self {
        match err {
            TransactionRequestError::NoInputNotesNorAccountChange => Some(ErrorHint {
                message: "Transactions must consume input notes or mutate tracked account state. Add at least one authenticated/unauthenticated input note or include an explicit account state update in the request.".to_string(),
                docs_url: Some(TROUBLESHOOTING_DOC),
            }),
            TransactionRequestError::StorageSlotNotFound(slot, account_id) => {
                Some(storage_miss_hint(*slot, *account_id))
            },
            TransactionRequestError::InputNoteNotAuthenticated(note_id) => Some(ErrorHint {
                message: format!(
                    "Note {note_id} needs an inclusion proof before it can be consumed as an \
                     authenticated input. Run `sync` to fetch the latest proofs from the network."
                ),
                docs_url: Some(TROUBLESHOOTING_DOC),
            }),
            TransactionRequestError::P2IDNoteWithoutAsset => Some(ErrorHint {
                message: "A pay-to-ID (P2ID) note transfers assets to a target account. \
                          Add at least one fungible or non-fungible asset to the note.".to_string(),
                docs_url: Some(TROUBLESHOOTING_DOC),
            }),
            TransactionRequestError::InvalidSenderAccount(account_id) => Some(ErrorHint {
                message: format!(
                    "Account {account_id} is not tracked by this client. Import or create the \
                     account first, then retry the transaction."
                ),
                docs_url: Some(TROUBLESHOOTING_DOC),
            }),
            _ => None,
        }
    }
}

impl TransactionRequestError {
    pub fn error_hint(&self) -> Option<ErrorHint> {
        self.into()
    }
}

fn missing_recipient_hint(recipients: &[Word]) -> ErrorHint {
    let message = format!(
        "Recipients {recipients:?} were missing from the transaction outputs. Keep `TransactionRequestBuilder::expected_output_recipients(...)` aligned with the MASM program so the declared recipients appear in the outputs."
    );

    ErrorHint {
        message,
        docs_url: Some(TROUBLESHOOTING_DOC),
    }
}

fn storage_miss_hint(slot: u8, account_id: AccountId) -> ErrorHint {
    ErrorHint {
        message: format!(
            "Storage slot {slot} was not found on account {account_id}. Verify the account ABI and component ordering, then adjust the slot index used in the transaction."
        ),
        docs_url: Some(TROUBLESHOOTING_DOC),
    }
}

fn transaction_executor_hint(err: &TransactionExecutorError) -> Option<ErrorHint> {
    match err {
        TransactionExecutorError::ForeignAccountNotAnchoredInReference(account_id) => {
            Some(ErrorHint {
                message: format!(
                    "The foreign account proof for {account_id} was built against a different block. Re-fetch the account proof anchored at the request's reference block before retrying."
                ),
                docs_url: Some(TROUBLESHOOTING_DOC),
            })
        },
        TransactionExecutorError::TransactionProgramExecutionFailed(_) => Some(ErrorHint {
            message: "Re-run the transaction with debug mode enabled, capture VM diagnostics, and inspect the source manager output to understand why execution failed.".to_string(),
            docs_url: Some(TROUBLESHOOTING_DOC),
        }),
        _ => None,
    }
}

// ID PREFIX FETCH ERROR
// ================================================================================================

/// Error when Looking for a specific ID from a partial ID.
#[derive(Debug, Error)]
pub enum IdPrefixFetchError {
    /// No matches were found for the ID prefix.
    #[error("no stored notes matched the provided prefix '{0}'")]
    NoMatch(String),
    /// Multiple entities matched with the ID prefix.
    #[error(
        "multiple {0} entries match the provided prefix; provide a longer prefix to narrow it down"
    )]
    MultipleMatches(String),
}