sui_jsonrpc/
error.rs

1use std::sync::LazyLock;
2
3use af_sui_types::ObjectId;
4use extension_traits::extension;
5use jsonrpsee::core::ClientError;
6use jsonrpsee::types::{ErrorCode, ErrorObject, ErrorObjectOwned};
7
8pub type JsonRpcClientResult<T = ()> = Result<T, JsonRpcClientError>;
9
10pub type JsonRpcClientError = ClientError;
11
12static OBJECT_UNAVAILABLE_FOR_CONSUMPTION_REGEX: LazyLock<regex::Regex> = LazyLock::new(|| {
13    regex::RegexBuilder::new("Object .* not available for consumption, current version:")
14        .case_insensitive(true)
15        .build()
16        .expect("Tested below for panics")
17});
18
19/// Breakdown:
20/// - "object_id: ([[:alnum:]]+)" captures the object id
21/// - "version: (?:...|None)" matches either the first pattern or "None", but doesn't capture its
22///   content
23/// - "Some\(SequenceNumber\((\d+)\)\)": captures the object version
24static OBJECT_NOT_FOUND_REGEX: LazyLock<regex::Regex> = LazyLock::new(|| {
25    regex::RegexBuilder::new(r"Error checking transaction input objects: ObjectNotFound \{ object_id: ([[:alnum:]]+), version: (?:Some\(SequenceNumber\((\d+)\)\)|None) \}")
26        .case_insensitive(true)
27        .build()
28        .expect("Tested below for panics")
29});
30
31static RETRIED_TRANSACTION_SUCCESS_REGEX: LazyLock<regex::Regex> = LazyLock::new(|| {
32    regex::RegexBuilder::new(r"Failed to sign transaction by a quorum of validators because one or more of its objects is reserved for another transaction. Retried transaction [[:word:]]+ \(succeeded\) because it was able to gather the necessary votes.")
33        .case_insensitive(true)
34        .build()
35        .expect("Tested below for panics")
36});
37
38/// Helpers to inspect the error type from the client implementation.
39#[extension(pub trait JsonRpcClientErrorExt)]
40impl JsonRpcClientError {
41    /// If this is a JSON-RPC [error object], return a reference to it.
42    ///
43    /// [error object]: https://www.jsonrpc.org/specification#response_object
44    fn as_error_object(&self) -> Option<&ErrorObjectOwned> {
45        match &self {
46            Self::Call(err_obj) => Some(err_obj),
47            _ => None,
48        }
49    }
50}
51
52/// Helpers to inspect the error type from JSON-RPC calls.
53///
54/// JSON-RPC [error object] codes taken from the [original implementation].
55///
56/// See the [source] for codes and messages in the quorum driver FN API.
57///
58/// [error object]: https://www.jsonrpc.org/specification#response_object
59/// [original implementation]: https://github.com/MystenLabs/sui/blob/main/crates/sui-json-rpc-api/src/lib.rs
60/// [source]: https://github.com/MystenLabs/sui/blob/testnet-v1.35.1/crates/sui-json-rpc/src/error.rs
61#[extension(pub trait ErrorObjectExt)]
62impl<'a> ErrorObject<'a> {
63    const TRANSIENT_ERROR_CODE: i32 = -32050;
64    const TRANSACTION_EXECUTION_CLIENT_ERROR_CODE: i32 = -32002;
65
66    /// Transient error, suggesting it may be possible to retry.
67    ///
68    /// # Example error messages
69    ///
70    /// - Transaction timed out before reaching finality
71    /// - Transaction failed to reach finality with transient error after X attempts
72    /// - Transaction is not processed because [...] of validators by stake are overloaded with
73    ///   certificates pending execution.
74    fn is_transient_error(&self) -> bool {
75        self.code() == Self::TRANSIENT_ERROR_CODE
76    }
77
78    /// Error in transaction execution (pre-consensus)
79    ///
80    /// # Example error messages
81    ///
82    /// - Invalid user signature
83    /// - Failed to sign transaction by a quorum of validators because one or more of its objects
84    ///   is {reason}. {retried_info} Other transactions locking these objects: [...]
85    ///   - reason:
86    ///     - equivocated until the next epoch
87    ///     - reserved for another transaction
88    ///   - retried_info: Retried transaction [...] (success/failure) because it was able to
89    ///     gather the necessary votes
90    /// - Transaction validator signing failed due to issues with transaction inputs, please review
91    ///   the errors and try again: {reason}
92    ///   - reason:
93    ///     - Balance of gas object [...] is lower than the needed amount
94    ///     - Object [...] not available for consumption, current version: [...]
95    ///     - Could not find the referenced object [...] at version [...]
96    fn is_execution_error(&self) -> bool {
97        self.code() == Self::TRANSACTION_EXECUTION_CLIENT_ERROR_CODE
98    }
99
100    /// Error with message "Object [...] not available for consumption, current version: [...]".
101    ///
102    /// TLDR: usually happens when the client's state sync is lagging too much.
103    ///
104    /// Note that this may not be the single reason why the transaction failed. Other errors
105    /// related to the transaction inputs may be present.
106    ///
107    /// May be due to state sync lag. For example, the client submits two transactions in quick
108    /// succession but doesn't sync owned objects quick enough for the second transaction, therefore
109    /// it uses the same owned object reference twice.
110    fn is_object_unavailable_for_consumption(&self) -> bool {
111        OBJECT_UNAVAILABLE_FOR_CONSUMPTION_REGEX.is_match(self.message())
112    }
113
114    /// Like [`Self::as_object_not_found`], but doesn't extract the object id and version.
115    fn is_object_not_found(&self) -> bool {
116        if self.code() != ErrorCode::InvalidParams.code() {
117            return false;
118        }
119        OBJECT_NOT_FOUND_REGEX.is_match(self.message())
120    }
121
122    /// Whether the transaction didn't fail because the RPC retried the submission and it
123    /// succeeded.
124    ///
125    /// The message for such an error looks like:
126    /// ```text
127    /// Failed to sign transaction by a quorum of validators because one or more of its objects is reserved for another transaction. Retried transaction [...] (succeeded) because it was able to gather the necessary votes
128    /// ```
129    fn is_transaction_retried_success(&self) -> bool {
130        self.is_execution_error() && RETRIED_TRANSACTION_SUCCESS_REGEX.is_match(self.message())
131    }
132
133    /// Error with [`InvalidParams`] code and message "Error checking transaction input objects:
134    /// ObjectNotFound { ... }"
135    ///
136    /// TLDR: usually happens when the client's state sync is faster than the full node's.
137    ///
138    /// Seen in practice when dry-running a transaction and the full node hasn't synchronized yet
139    /// with the effects of a previous one.
140    ///
141    /// [`InvalidParams`]: ErrorCode::InvalidParams
142    fn as_object_not_found(&self) -> Option<(ObjectId, Option<u64>)> {
143        if self.code() != ErrorCode::InvalidParams.code() {
144            return None;
145        }
146        let captures = OBJECT_NOT_FOUND_REGEX.captures(self.message())?;
147        // Version may be None so we don't use `?` after `.get()`
148        let version = captures.get(2).and_then(|c| c.as_str().parse().ok());
149        Some((captures.get(1)?.as_str().parse().ok()?, version))
150    }
151}
152
153#[cfg(test)]
154mod tests {
155    use super::*;
156
157    #[test]
158    fn object_unavailable_for_consumption_regex_builds() {
159        let _ = &*OBJECT_UNAVAILABLE_FOR_CONSUMPTION_REGEX;
160    }
161
162    #[test]
163    fn object_not_found_regex_builds() {
164        let _ = &*OBJECT_NOT_FOUND_REGEX;
165    }
166
167    /// Toy example
168    #[test]
169    fn object_unavailable_for_consumption_match1() {
170        let expect = "Transaction validator signing failed due to issues with transaction inputs, \
171            please review the errors and try again:\n\
172            - Balance of gas object 10 is lower than the needed amount: 100\n\
173            - Object ID 0x0000000000000000000000000000000000000000000000000000000000000000 \
174              Version 0x0 \
175              Digest 11111111111111111111111111111111 \
176              is not available for consumption, current version: 0xa";
177        assert!(OBJECT_UNAVAILABLE_FOR_CONSUMPTION_REGEX.is_match(expect));
178    }
179
180    /// Real-world example
181    #[test]
182    fn object_unavailable_for_consumption_match2() {
183        let expect = "Transaction validator signing failed due to issues with transaction inputs, \
184            please review the errors and try again:\n\
185            - Object ID 0xa3b25765e4f7f4524367fa792b608483157bfef919108f0d998c6980493fc7bc \
186              Version 0xb0cb7f7 \
187              Digest FKkELfAR3vP19MrjwEwTapH3JDbdZuxqcC25CoALNUsN \
188              is not available for consumption, current version: 0xb0cb7f8";
189        assert!(OBJECT_UNAVAILABLE_FOR_CONSUMPTION_REGEX.is_match(expect));
190    }
191
192    /// Real-world example
193    #[test]
194    fn object_unavailable_for_consumption_not_match1() {
195        let expect = "Transaction validator signing failed due to issues with transaction inputs, \
196            please review the errors and try again:\n\
197            - Transaction was not signed by the correct sender: \
198              Object 0x2d13a698a9ef878372210f6d96e2315f368794e1c9c842f6fddacb3815ff749d is owned \
199              by account address \
200              0x162602a3f40fcab9b513a3fefad1c046ae242bb6bb83334b3aa8cd639e018b28, but given \
201              owner/signer address is \
202              0x76f9ca7f89994d4039b739859a41b39123d6f695a1b33f7431cee3c6b40a45c2";
203        assert!(!OBJECT_UNAVAILABLE_FOR_CONSUMPTION_REGEX.is_match(expect));
204    }
205
206    /// Real-world example
207    ///
208    /// ErrorObject {
209    ///     code: InvalidParams,
210    ///     message: "Error checking transaction input objects: ObjectNotFound { object_id: 0x38826d19eb0338509eedf78c4f3a1de6479163e1a0a2fb447aa3fe947ef4cc2a, version: Some(SequenceNumber(247815626)) }",
211    ///     data: None
212    /// }
213    #[test]
214    fn object_not_found_match1() {
215        let expect = "Error checking transaction input objects: ObjectNotFound { object_id: 0x38826d19eb0338509eedf78c4f3a1de6479163e1a0a2fb447aa3fe947ef4cc2a, version: Some(SequenceNumber(247815626)) }";
216        assert!(OBJECT_NOT_FOUND_REGEX.is_match(expect));
217        let matches = OBJECT_NOT_FOUND_REGEX
218            .captures(expect)
219            .expect("object_id and version present");
220        assert_eq!(
221            matches.get(1).expect("object_id").as_str(),
222            "0x38826d19eb0338509eedf78c4f3a1de6479163e1a0a2fb447aa3fe947ef4cc2a"
223        );
224        assert_eq!(matches.get(2).expect("version").as_str(), "247815626");
225    }
226
227    /// Hypothetical
228    ///
229    /// ErrorObject {
230    ///     code: InvalidParams,
231    ///     message: "Error checking transaction input objects: ObjectNotFound { object_id: 0x38826d19eb0338509eedf78c4f3a1de6479163e1a0a2fb447aa3fe947ef4cc2a, version: None }",
232    ///     data: None
233    /// }
234    #[test]
235    fn object_not_found_match2() {
236        let expect = "Error checking transaction input objects: ObjectNotFound { object_id: 0x38826d19eb0338509eedf78c4f3a1de6479163e1a0a2fb447aa3fe947ef4cc2a, version: None }";
237        assert!(OBJECT_NOT_FOUND_REGEX.is_match(expect));
238        let matches = OBJECT_NOT_FOUND_REGEX
239            .captures(expect)
240            .expect("object_id present");
241        dbg!(&matches);
242        assert_eq!(
243            matches.get(1).expect("object_id").as_str(),
244            "0x38826d19eb0338509eedf78c4f3a1de6479163e1a0a2fb447aa3fe947ef4cc2a"
245        );
246        assert_eq!(matches.get(2), None);
247    }
248
249    #[test]
250    fn object_not_found1() {
251        let error = ErrorObject::owned::<()>(
252            ErrorCode::InvalidParams.code(),
253            "Error checking transaction input objects: ObjectNotFound { object_id: 0x38826d19eb0338509eedf78c4f3a1de6479163e1a0a2fb447aa3fe947ef4cc2a, version: Some(SequenceNumber(247815626)) }",
254            None,
255        );
256        assert!(error.is_object_not_found());
257        assert_eq!(
258            error.as_object_not_found(),
259            Some((
260                "0x38826d19eb0338509eedf78c4f3a1de6479163e1a0a2fb447aa3fe947ef4cc2a"
261                    .parse()
262                    .unwrap(),
263                Some(247815626)
264            ))
265        )
266    }
267
268    #[test]
269    fn object_not_found2() {
270        let error = ErrorObject::owned::<()>(
271            ErrorCode::InvalidParams.code(),
272            "Error checking transaction input objects: ObjectNotFound { object_id: 0x38826d19eb0338509eedf78c4f3a1de6479163e1a0a2fb447aa3fe947ef4cc2a, version: None }",
273            None,
274        );
275        assert!(error.is_object_not_found());
276        assert_eq!(
277            error.as_object_not_found(),
278            Some((
279                "0x38826d19eb0338509eedf78c4f3a1de6479163e1a0a2fb447aa3fe947ef4cc2a"
280                    .parse()
281                    .unwrap(),
282                None
283            ))
284        )
285    }
286
287    #[test]
288    fn retried_transaction_regex_matches() {
289        let message = r#"Failed to sign transaction by a quorum of validators because one or more of its objects is reserved for another transaction. Retried transaction EENuLfRygexZrE1ycfsUHRFFenAgPLReFxYTFKQg9Jpu (succeeded) because it was able to gather the necessary votes. Other transactions locking these objects:\n- EENuLfRygexZrE1ycfsUHRFFenAgPLReFxYTFKQg9Jpu (stake 90.17)"#;
290        assert!(RETRIED_TRANSACTION_SUCCESS_REGEX.is_match(message));
291    }
292}