Skip to main content

nexus_sdk/nexus/
error.rs

1//! Common error types for Nexus-related functionality.
2
3use {crate::sui, std::time::Duration, thiserror::Error};
4
5/// Identity of the SDK build performing compatibility checks.
6pub const SDK_BUILD_IDENTITY: &str = env!("CARGO_PKG_VERSION");
7
8/// A live object uses a witness or inner layout unknown to this SDK build.
9#[derive(Clone, Debug, Eq, Error, PartialEq)]
10#[error(
11    "object '{object_id}' uses witness '{witness_type}' and inner type '{inner}', which SDK build \
12     '{sdk_build}' does not support",
13    inner = inner_type
14        .as_ref()
15        .map(ToString::to_string)
16        .unwrap_or_else(|| "<unavailable>".to_owned())
17)]
18pub struct ClientUpgradeRequired {
19    /// Stable identity of the object that cannot be interpreted.
20    pub object_id: sui::types::Address,
21    /// Exact package witness observed on the object.
22    pub witness_type: Box<sui::types::StructTag>,
23    /// Exact inner layout when its metadata was available.
24    pub inner_type: Option<Box<sui::types::StructTag>>,
25    /// SDK build that performed the compatibility check.
26    pub sdk_build: &'static str,
27}
28
29impl ClientUpgradeRequired {
30    /// Creates a compatibility error for one observed object state.
31    pub fn new(
32        object_id: sui::types::Address,
33        witness_type: sui::types::StructTag,
34        inner_type: Option<sui::types::StructTag>,
35    ) -> Self {
36        Self {
37            object_id,
38            witness_type: Box::new(witness_type),
39            inner_type: inner_type.map(Box::new),
40            sdk_build: SDK_BUILD_IDENTITY,
41        }
42    }
43}
44
45/// Durable transaction state known when execution does not complete normally.
46#[derive(Clone, Copy, Debug, Eq, PartialEq)]
47pub enum TransactionErrorState {
48    /// The network rejected the transaction before execution.
49    SubmissionRejected,
50    /// The client cannot prove whether the transaction reached the network.
51    SubmissionUnknown,
52    /// The transaction executed, but checkpoint confirmation is unknown.
53    ConfirmationUnknown,
54    /// The transaction was confirmed with failed effects.
55    ExecutionFailed,
56}
57
58/// A transaction failure that preserves every fact needed for recovery.
59#[derive(Debug, Error)]
60#[non_exhaustive]
61pub enum TransactionError {
62    /// The transaction was rejected before execution.
63    #[error("transaction {digest} was rejected before execution: {source}")]
64    SubmissionRejected {
65        digest: crate::sui::types::Digest,
66        #[source]
67        source: tonic::Status,
68    },
69    /// The transaction submission result is unknown.
70    #[error("transaction {digest} submission state is unknown: {source}")]
71    SubmissionUnknown {
72        digest: crate::sui::types::Digest,
73        #[source]
74        source: tonic::Status,
75    },
76    /// The transaction executed, but did not reach a checkpoint in time.
77    #[error(
78        "transaction {digest} was executed but checkpoint confirmation is unknown after {timeout:?}"
79    )]
80    ConfirmationTimedOut {
81        digest: crate::sui::types::Digest,
82        timeout: Duration,
83        response: Box<crate::sui::grpc::ExecuteTransactionResponse>,
84    },
85    /// The transaction executed, but checkpoint observation failed.
86    #[error("transaction {digest} was executed but checkpoint confirmation failed: {source}")]
87    ConfirmationFailed {
88        digest: crate::sui::types::Digest,
89        response: Box<crate::sui::grpc::ExecuteTransactionResponse>,
90        #[source]
91        source: tonic::Status,
92    },
93    /// The confirmation response omitted required transaction data.
94    #[error("transaction {digest} confirmation response is invalid: {message}")]
95    ConfirmationResponseInvalid {
96        digest: crate::sui::types::Digest,
97        response: Box<crate::sui::grpc::ExecuteTransactionResponse>,
98        message: &'static str,
99    },
100    /// The transaction was confirmed with failed effects.
101    #[error(
102        "transaction {digest} failed at checkpoint {checkpoint} in command {command:?}: {error:?}"
103    )]
104    ExecutionFailed {
105        digest: crate::sui::types::Digest,
106        checkpoint: u64,
107        error: crate::sui::types::ExecutionError,
108        command: Option<u64>,
109    },
110}
111
112impl TransactionError {
113    /// Creates a [`TransactionError::SubmissionRejected`] error.
114    pub fn submission_rejected(digest: crate::sui::types::Digest, source: tonic::Status) -> Self {
115        Self::SubmissionRejected { digest, source }
116    }
117
118    /// Creates a [`TransactionError::SubmissionUnknown`] error.
119    pub fn submission_unknown(digest: crate::sui::types::Digest, source: tonic::Status) -> Self {
120        Self::SubmissionUnknown { digest, source }
121    }
122
123    /// Creates a [`TransactionError::ConfirmationTimedOut`] error.
124    pub fn confirmation_timed_out(
125        digest: crate::sui::types::Digest,
126        timeout: Duration,
127        response: crate::sui::grpc::ExecuteTransactionResponse,
128    ) -> Self {
129        Self::ConfirmationTimedOut {
130            digest,
131            timeout,
132            response: Box::new(response),
133        }
134    }
135
136    /// Creates a [`TransactionError::ConfirmationFailed`] error.
137    pub fn confirmation_failed(
138        digest: crate::sui::types::Digest,
139        response: crate::sui::grpc::ExecuteTransactionResponse,
140        source: tonic::Status,
141    ) -> Self {
142        Self::ConfirmationFailed {
143            digest,
144            response: Box::new(response),
145            source,
146        }
147    }
148
149    /// Creates a [`TransactionError::ConfirmationResponseInvalid`] error.
150    pub fn confirmation_response_invalid(
151        digest: crate::sui::types::Digest,
152        response: crate::sui::grpc::ExecuteTransactionResponse,
153        message: &'static str,
154    ) -> Self {
155        Self::ConfirmationResponseInvalid {
156            digest,
157            response: Box::new(response),
158            message,
159        }
160    }
161
162    /// Creates a [`TransactionError::ExecutionFailed`] error.
163    pub fn execution_failed(
164        digest: crate::sui::types::Digest,
165        checkpoint: u64,
166        error: crate::sui::types::ExecutionError,
167        command: Option<u64>,
168    ) -> Self {
169        Self::ExecutionFailed {
170            digest,
171            checkpoint,
172            error,
173            command,
174        }
175    }
176
177    /// Returns the transaction digest.
178    pub fn digest(&self) -> &crate::sui::types::Digest {
179        match self {
180            Self::SubmissionRejected { digest, .. }
181            | Self::SubmissionUnknown { digest, .. }
182            | Self::ConfirmationTimedOut { digest, .. }
183            | Self::ConfirmationFailed { digest, .. }
184            | Self::ConfirmationResponseInvalid { digest, .. }
185            | Self::ExecutionFailed { digest, .. } => digest,
186        }
187    }
188
189    /// Returns the known transaction state.
190    pub fn state(&self) -> TransactionErrorState {
191        match self {
192            Self::SubmissionRejected { .. } => TransactionErrorState::SubmissionRejected,
193            Self::SubmissionUnknown { .. } => TransactionErrorState::SubmissionUnknown,
194            Self::ConfirmationTimedOut { .. }
195            | Self::ConfirmationFailed { .. }
196            | Self::ConfirmationResponseInvalid { .. } => {
197                TransactionErrorState::ConfirmationUnknown
198            }
199            Self::ExecutionFailed { .. } => TransactionErrorState::ExecutionFailed,
200        }
201    }
202
203    /// Returns the retained execution response when the transaction executed.
204    pub fn response(&self) -> Option<&crate::sui::grpc::ExecuteTransactionResponse> {
205        match self {
206            Self::ConfirmationTimedOut { response, .. }
207            | Self::ConfirmationFailed { response, .. }
208            | Self::ConfirmationResponseInvalid { response, .. } => Some(response),
209            Self::SubmissionRejected { .. }
210            | Self::SubmissionUnknown { .. }
211            | Self::ExecutionFailed { .. } => None,
212        }
213    }
214
215    /// Returns the network status retained by
216    /// [`TransactionError::SubmissionRejected`].
217    pub fn submission_rejection(&self) -> Option<&tonic::Status> {
218        match self {
219            Self::SubmissionRejected { source, .. } => Some(source),
220            Self::SubmissionUnknown { .. }
221            | Self::ConfirmationTimedOut { .. }
222            | Self::ConfirmationFailed { .. }
223            | Self::ConfirmationResponseInvalid { .. }
224            | Self::ExecutionFailed { .. } => None,
225        }
226    }
227}
228
229#[derive(Debug, Error)]
230pub enum NexusError {
231    #[error(transparent)]
232    Transaction(Box<TransactionError>),
233    #[error("a private key is required for this operation")]
234    MissingPrivateKey,
235    #[error("Sui wallet error: {0}")]
236    Wallet(anyhow::Error),
237    #[error("Client configuration error: {0}")]
238    Configuration(String),
239    #[error("a gas source is already configured")]
240    GasSourceAlreadyConfigured,
241    #[error("Transaction building error: {0}")]
242    TransactionBuilding(anyhow::Error),
243    #[error("RPC error: {0}")]
244    Rpc(anyhow::Error),
245    #[error("Parsing error: {0}")]
246    Parsing(anyhow::Error),
247    #[error("Timeout error: {0}")]
248    Timeout(anyhow::Error),
249    #[error("Channel error: {0}")]
250    Channel(anyhow::Error),
251    #[error("Storage error: {0}")]
252    Storage(anyhow::Error),
253    /// The connected Sui chain does not match [`crate::types::NexusObjects`].
254    #[error("Connected chain '{actual}' does not match configured chain '{expected}'")]
255    ChainMismatch { expected: String, actual: String },
256    /// A live object violates the required anchor field structure.
257    #[error("Object state for '{object}' is invalid: {reason}")]
258    InvalidObjectState {
259        object: sui::types::Address,
260        reason: String,
261    },
262    /// A transaction response contains inconsistent output identity or state.
263    #[error("Transaction '{transaction}' outputs are invalid: {reason}")]
264    InvalidTransactionOutput {
265        transaction: sui::types::Digest,
266        reason: String,
267    },
268    /// A package is structurally valid but incompatible with an operation graph.
269    #[error("Package '{package}' is incompatible: {reason}")]
270    IncompatiblePackage {
271        package: sui::types::Address,
272        reason: String,
273    },
274    /// The node answered definitively that the object does not exist.
275    #[error("Object '{object}' does not exist")]
276    ObjectNotFound { object: sui::types::Address },
277    /// The SDK does not contain an adapter for the observed object state.
278    #[error(transparent)]
279    ClientUpgradeRequired(#[from] ClientUpgradeRequired),
280}
281
282impl NexusError {
283    /// Classifies an RPC-layer failure, keeping a definitive object absence
284    /// distinguishable from a failure to reach the node.
285    pub fn from_rpc(error: anyhow::Error) -> Self {
286        match error.downcast_ref::<Self>() {
287            Some(Self::ObjectNotFound { object }) => Self::ObjectNotFound { object: *object },
288            _ => Self::Rpc(error),
289        }
290    }
291}
292
293impl From<TransactionError> for NexusError {
294    fn from(error: TransactionError) -> Self {
295        Self::Transaction(Box::new(error))
296    }
297}
298
299#[cfg(test)]
300mod tests {
301    use {
302        super::{TransactionError, TransactionErrorState},
303        crate::sui,
304        std::time::Duration,
305    };
306
307    #[test]
308    fn submission_unknown_retains_transaction_identity() {
309        let digest = sui::types::Digest::new([7; 32]);
310        let error = TransactionError::submission_unknown(
311            digest,
312            tonic::Status::unavailable("connection closed"),
313        );
314
315        assert_eq!(error.digest(), &digest);
316        assert_eq!(error.state(), TransactionErrorState::SubmissionUnknown);
317        assert!(error.response().is_none());
318        assert!(!error.to_string().contains("retry"));
319    }
320
321    #[test]
322    fn submission_rejection_is_known_and_retains_transaction_identity() {
323        let digest = sui::types::Digest::new([7; 32]);
324        let error = TransactionError::submission_rejected(
325            digest,
326            tonic::Status::invalid_argument("invalid gas reservation"),
327        );
328
329        assert_eq!(error.digest(), &digest);
330        assert_eq!(error.state(), TransactionErrorState::SubmissionRejected);
331        assert!(error.response().is_none());
332        assert_eq!(
333            error.submission_rejection().map(tonic::Status::message),
334            Some("invalid gas reservation")
335        );
336        assert!(!error.to_string().contains("unknown"));
337    }
338
339    #[test]
340    fn confirmation_unknown_retains_transaction_identity_and_response() {
341        let digest = sui::types::Digest::new([7; 32]);
342        let error = TransactionError::confirmation_timed_out(
343            digest,
344            Duration::from_secs(30),
345            sui::grpc::ExecuteTransactionResponse::default(),
346        );
347
348        assert_eq!(error.digest(), &digest);
349        assert_eq!(error.state(), TransactionErrorState::ConfirmationUnknown);
350        assert!(error.response().is_some());
351        assert!(!error.to_string().contains("retry"));
352    }
353
354    #[test]
355    fn invalid_confirmation_response_retains_transaction_identity_and_response() {
356        let digest = sui::types::Digest::new([7; 32]);
357        let response = sui::grpc::ExecuteTransactionResponse::default();
358        let error = TransactionError::confirmation_response_invalid(
359            digest,
360            response.clone(),
361            "transaction is missing",
362        );
363
364        assert_eq!(error.digest(), &digest);
365        assert_eq!(error.state(), TransactionErrorState::ConfirmationUnknown);
366        assert_eq!(error.response(), Some(&response));
367        assert!(!error.to_string().contains("retry"));
368    }
369
370    #[test]
371    fn execution_failure_retains_transaction_identity() {
372        let digest = sui::types::Digest::new([7; 32]);
373        let error = TransactionError::execution_failed(
374            digest,
375            42,
376            sui::types::ExecutionError::InsufficientGas,
377            Some(3),
378        );
379
380        assert_eq!(error.digest(), &digest);
381        assert_eq!(error.state(), TransactionErrorState::ExecutionFailed);
382        assert!(error.response().is_none());
383        assert!(!error.to_string().contains("retry"));
384    }
385}