Skip to main content

thruput_sdk/call/
raw_call_error.rs

1//! The raw, untyped error from a contract call.
2
3use crate::exn::{
4    CallingError, FaultInfo, FaultString, RawException,
5    client_library_throw as library_throw, syscall_exn,
6};
7use crate::sys::{SysErr, SyscallNo};
8
9/// The raw, untyped error from a contract call.
10///
11/// Generic over the exception representation `X`, which
12/// [`try_call_raw_with`](super::try_call_raw_with)'s `on_exn` closure chooses:
13/// the default [`RawException`] (one copy, via `to_owned`), a typed error
14/// enum, or `Infallible` if the closure re-throws unconditionally.
15///
16/// The variants divide by how far the call got.
17/// [`Invalid`](Self::Invalid), [`Reentrance`](Self::Reentrance),
18/// [`DepthExceeded`](Self::DepthExceeded) and
19/// [`InvokeRejected`](Self::InvokeRejected) all mean the invoke syscall was
20/// refused before any child frame ran, so the call was a complete no-op.
21/// [`Exception`](Self::Exception) and [`Fault`](Self::Fault) mean a child
22/// frame ran and then aborted or died. The rest are encode/decode failures
23/// in this contract, on either side of a call that otherwise completed.
24#[derive(Debug, PartialEq)]
25pub enum RawCallError<X = RawException> {
26    /// The invoke syscall rejected the call as malformed: no contract at the
27    /// target address, invalid invoke flags, or an auth token id that is
28    /// malformed, duplicated, or not owned by this frame.
29    Invalid,
30
31    /// The target address already has a frame open on the invocation stack,
32    /// so the runtime refused the call as reentrant.
33    ///
34    /// The call can never succeed anywhere in this call chain — unlike
35    /// [`DepthExceeded`](Self::DepthExceeded), retrying from a shallower
36    /// frame does not help while this address is still on the stack.
37    Reentrance,
38
39    /// The transaction already holds the network's maximum number of open
40    /// invocation frames, so admitting another would exceed the cross-invoke
41    /// depth bound.
42    ///
43    /// A resource limit rather than a malformed call: the same invocation
44    /// succeeds when made from a shallower frame.
45    DepthExceeded,
46
47    /// The invoke syscall was refused with a code this SDK does not model.
48    ///
49    /// Carries the raw code rather than collapsing an unrecognized rejection
50    /// into a neighbouring variant's meaning.
51    InvokeRejected(SysErr),
52
53    /// The called contract threw an exception with a selector-tagged error.
54    Exception(X),
55
56    /// A child frame ran and terminated without producing a result: a panic,
57    /// a trap, or exhausting its gas.
58    ///
59    /// The message is *host-side state recorded beside the call*, never
60    /// something the callee sent: the runtime reports a faulted child as a
61    /// bare frame status with neither a return nor an abort buffer, so no
62    /// message crosses the call boundary and none ever will. That splits the
63    /// two cases:
64    ///
65    /// - **A frame the native harness ran itself** — the root frame of an
66    ///   `env.transact`, reaching here through `into_root_call_result`. The
67    ///   harness caught that panic, so the message is the panicking body's
68    ///   own. A frame that died with no ordinary panic to catch leaves it
69    ///   empty: running out of gas unwinds an `InvokeError`, which carries
70    ///   no message.
71    /// - **A `raw_Invoke`ed callee** — caught inside the runtime's own
72    ///   frame, where there is nothing to attribute a message against, so
73    ///   the payload is [`FaultInfo::empty`] and `as_str()` reads
74    ///   `"<fault>"`. Reporting anything else here would mean reporting some
75    ///   other frame's message.
76    ///
77    /// See [`FaultInfo`] for what the payload holds and where.
78    ///
79    /// A callee that wants its failure to be legible to its caller — on
80    /// chain, not just in a host test — must abort with an exception rather
81    /// than fault.
82    Fault(FaultInfo),
83
84    /// Decode error: the called contract returned some unparseable return result
85    UnparseableReturn(FaultString),
86    /// Decode error: the called contract aborted with some unparseable result
87    UnparseableAbort(FaultString),
88    /// Decode error: the called contract aborted with some unparseable result
89    UnparseableAuthHandles(FaultString),
90
91    /// Encode error: the arguments supplied failed to encode
92    EncodingArgs(FaultString),
93}
94
95impl<X> RawCallError<X> {
96    /// Whether the invoke syscall was refused before any child frame ran.
97    ///
98    /// True for [`Invalid`](Self::Invalid), [`Reentrance`](Self::Reentrance),
99    /// [`DepthExceeded`](Self::DepthExceeded) and
100    /// [`InvokeRejected`](Self::InvokeRejected). When this holds the call was
101    /// a complete no-op: the callee did not run, observed nothing, and wrote
102    /// nothing.
103    pub fn is_rejected_before_call(&self) -> bool {
104        matches!(
105            self,
106            RawCallError::Invalid
107                | RawCallError::Reentrance
108                | RawCallError::DepthExceeded
109                | RawCallError::InvokeRejected(_)
110        )
111    }
112}
113
114/// Classify the `Err` from the Invoke syscall, which the runtime returns only
115/// when it refused the call before running a child frame.
116///
117/// The codes are those `Invoke` documents in the syscall spec; anything else
118/// keeps its raw code in [`RawCallError::InvokeRejected`] rather than being
119/// folded into one of the modelled meanings.
120pub(crate) fn classify_invoke_rejection<X>(syserr: SysErr) -> RawCallError<X> {
121    use crate::sys::SyscallErrorCode as Code;
122
123    if syserr.is_invalid_input() {
124        RawCallError::Invalid
125    } else if syserr.is_code(Code::kSyscallErrorCodeReentrance) {
126        RawCallError::Reentrance
127    } else if syserr.is_code(Code::kSyscallErrorCodeInvokeDepthExceeded) {
128        RawCallError::DepthExceeded
129    } else {
130        RawCallError::InvokeRejected(syserr)
131    }
132}
133
134/// The error type of ordinary generated `try_*` client methods: a call failure
135/// that may carry a caught exception, kept untyped.
136///
137/// Exceptions stay as [`RawException`] rather than being pre-decoded into a
138/// per-client enum, because the wire form already identifies the type by
139/// selector, independently of what any interface declared it `throws`. Match
140/// one with [`match_exn!`](crate::match_exn); everything else here is
141/// ordinary `match` material.
142pub type CallError = RawCallError;
143
144/// A call failure with no exception payload: the call was refused before the
145/// target ran (see [`is_rejected_before_call`](RawCallError::is_rejected_before_call)),
146/// the target faulted, or the host's returned
147/// data failed validation. The `Exception` variant is statically
148/// uninhabited ([`Infallible`](core::convert::Infallible)) — on these
149/// paths an abort is not an error; it surfaces as
150/// [`CallOutcome::Abort`](crate::call::CallOutcome::Abort) with the
151/// payload in the caller's buffer. (Crate-internal code widens into a
152/// `RawCallError<X>` with `widen_uninhabited_exn`.)
153pub type CallFault = RawCallError<core::convert::Infallible>;
154
155#[cfg(not(target_arch = "riscv32"))]
156impl<X: core::fmt::Debug> core::fmt::Display for RawCallError<X> {
157    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
158        match self {
159            RawCallError::Invalid => {
160                write!(f, "contract not found (or invalid auth tokens)")
161            }
162            RawCallError::Reentrance => {
163                write!(f, "call refused: target already has an open frame")
164            }
165            RawCallError::DepthExceeded => {
166                write!(f, "call refused: cross-invoke depth bound reached")
167            }
168            RawCallError::InvokeRejected(code) => {
169                write!(f, "call refused: {code:?}")
170            }
171            RawCallError::Exception(exn) => write!(f, "exception: {exn:?}"),
172            RawCallError::Fault(fault) => write!(f, "fault: {fault}"),
173            RawCallError::UnparseableReturn(error) => {
174                write!(f, "unparseable return: {error}")
175            }
176            RawCallError::UnparseableAbort(error) => {
177                write!(f, "unparseable abort: {error}")
178            }
179            RawCallError::UnparseableAuthHandles(error) => {
180                write!(f, "unparseable auth handles: {error}")
181            }
182            RawCallError::EncodingArgs(error) => {
183                write!(f, "encoding args: {error}")
184            }
185        }
186    }
187}
188
189impl<X: crate::re_throw::ReThrow> crate::re_throw::ReThrow
190    for RawCallError<X>
191{
192    #[cfg_attr(not(target_arch = "riscv32"), track_caller)]
193    fn re_throw(self) -> ! {
194        match self {
195            RawCallError::Invalid => library_throw(CallingError::Invalid),
196            RawCallError::Reentrance => {
197                library_throw(CallingError::Reentrance)
198            }
199            RawCallError::DepthExceeded => {
200                library_throw(CallingError::DepthExceeded)
201            }
202            // An unmodelled rejection keeps its raw code by riding the
203            // existing syscall exception rather than earning a `CallingError`
204            // variant of its own: there is no meaning to name yet, and the
205            // code is what a caller would want to see.
206            RawCallError::InvokeRejected(code) => {
207                library_throw(syscall_exn(SyscallNo::kInvoke, code))
208            }
209            RawCallError::Exception(exn) => {
210                crate::re_throw::ReThrow::re_throw(exn)
211            }
212            // Renders whatever host-side description was recorded beside
213            // the call, or `"<fault>"` when none was. A guest has no message
214            // to render and no way to surface one: its `#[panic_handler]`
215            // ignores `PanicInfo` and issues `ebreak`.
216            #[cfg(not(target_arch = "riscv32"))]
217            RawCallError::Fault(f) => panic!("{f}"),
218            #[cfg(target_arch = "riscv32")]
219            RawCallError::Fault(_) => panic!(),
220            RawCallError::UnparseableReturn(error) => {
221                library_throw(CallingError::DecodingSuccess(error))
222            }
223            RawCallError::UnparseableAbort(error) => {
224                library_throw(CallingError::DecodingException(error))
225            }
226            RawCallError::UnparseableAuthHandles(error) => {
227                library_throw(CallingError::DecodingSuccess(error))
228            }
229            RawCallError::EncodingArgs(error) => {
230                library_throw(CallingError::EncodingArgs(error))
231            }
232        }
233    }
234}
235
236#[cfg(test)]
237mod tests {
238    use super::*;
239    use crate::re_throw::OrReThrow;
240
241    #[test]
242    fn or_re_throw_ok_returns_value() {
243        let result: Result<i32, RawCallError> = Ok(42);
244        assert_eq!(result.or_re_throw(), 42);
245    }
246
247    /// Every rejection `Invoke` documents keeps its own identity. Reentrance
248    /// and the depth bound used to land in `Fault`, which told the caller its
249    /// callee had misbehaved when in fact the callee never ran — and on rv32,
250    /// where the old `Fault` payload compiled out, nothing distinguished them
251    /// at all.
252    #[test]
253    fn documented_rejections_map_to_their_own_variants() {
254        use crate::sys::SyscallErrorCode as Code;
255
256        let cases = [
257            (
258                Code::kSyscallErrorCodeInvalidSyscallInput,
259                RawCallError::Invalid,
260            ),
261            (Code::kSyscallErrorCodeReentrance, RawCallError::Reentrance),
262            (
263                Code::kSyscallErrorCodeInvokeDepthExceeded,
264                RawCallError::DepthExceeded,
265            ),
266        ];
267
268        for (code, expected) in cases {
269            let got: RawCallError = classify_invoke_rejection(code.into());
270            assert_eq!(got, expected, "rejection code {code:?}");
271        }
272    }
273
274    /// A rejection this SDK does not model keeps its raw code instead of
275    /// being folded into a neighbouring variant's meaning.
276    #[test]
277    fn unmodelled_rejection_keeps_its_code() {
278        let unknown = SysErr(0x7fff_0000);
279        let got: RawCallError = classify_invoke_rejection(unknown);
280        assert_eq!(got, RawCallError::InvokeRejected(unknown));
281    }
282
283    /// `Fault` means a child frame ran and died; every rejection means it
284    /// never ran at all. Contracts that must not retry a call whose side
285    /// effects may have landed depend on that split.
286    #[test]
287    fn rejections_are_distinguished_from_a_ran_and_faulted_callee() {
288        let rejected: [RawCallError; 4] = [
289            RawCallError::Invalid,
290            RawCallError::Reentrance,
291            RawCallError::DepthExceeded,
292            RawCallError::InvokeRejected(SysErr(1234)),
293        ];
294        for e in &rejected {
295            assert!(e.is_rejected_before_call(), "{e:?}");
296            assert_ne!(*e, RawCallError::Fault(FaultInfo::empty()));
297        }
298
299        let ran: RawCallError = RawCallError::Fault(FaultInfo::empty());
300        assert!(!ran.is_rejected_before_call());
301    }
302}