Skip to main content

thruput_json_rpc_client/native/
receipt.rs

1//! Read-only native receipt waiting. Commitment is sticky within the caller's
2//! verified chain instance. A reset invalidates that context even if its network
3//! hash is unchanged; the caller must cancel the wait and discard old evidence.
4
5use std::{collections::hash_map::RandomState, future::Future, hash::BuildHasher, time::Duration};
6
7use jsonrpsee::core::client::{ClientT, Error as TransportError};
8use thruput_json_rpc_types::{
9    BlockHeight, NetworkHash,
10    error::native::{DecodedError, ErrorData},
11    requests::TransactionQuery,
12    responses::{DecodedResponse, ReceiptResult, TransactionReceipt, TransactionStatus},
13    transaction::TxHash,
14};
15use tokio::time::Instant;
16
17use super::{NativeClient, NativeClientError};
18
19/// Only the two read operations needed by the waiter. No submission operation
20/// is available, so timeout, cancellation and retries cannot repeat a write.
21pub trait ReceiptSource {
22    /// Read the committed receipt, including failed execution, or current absence.
23    fn transaction_receipt(
24        &self,
25        hash: TxHash,
26    ) -> impl Future<Output = Result<DecodedResponse<ReceiptResult>, NativeClientError>> + Send;
27
28    /// Read an advisory status. Errors do not erase prior observations.
29    fn transaction_status(
30        &self,
31        hash: TxHash,
32    ) -> impl Future<Output = Result<DecodedResponse<TransactionStatus>, NativeClientError>> + Send;
33}
34
35impl<C: ClientT + Sync> ReceiptSource for NativeClient<C> {
36    async fn transaction_receipt(
37        &self,
38        hash: TxHash,
39    ) -> Result<DecodedResponse<ReceiptResult>, NativeClientError> {
40        self.get_transaction_receipt(TransactionQuery {
41            transaction_hash: hash,
42        })
43        .await
44    }
45
46    async fn transaction_status(
47        &self,
48        hash: TxHash,
49    ) -> Result<DecodedResponse<TransactionStatus>, NativeClientError> {
50        self.get_transaction_status(TransactionQuery {
51            transaction_hash: hash,
52        })
53        .await
54    }
55}
56
57/// Commitment supplied or observed in one caller-verified chain instance.
58/// A network hash identifies the network, not its reset generation.
59#[derive(Debug, Clone, PartialEq, Eq)]
60pub struct KnownCommitment {
61    pub network_hash: NetworkHash,
62    pub transaction_hash: TxHash,
63    pub block_height: BlockHeight,
64}
65
66/// Receipt wait timing. The timeout includes requests, backoff and sleeps.
67#[derive(Debug, Clone, Copy)]
68pub struct ReceiptPollConfig {
69    pub timeout: Duration,
70    pub interval: Duration,
71}
72
73impl Default for ReceiptPollConfig {
74    fn default() -> Self {
75        Self {
76            timeout: Duration::from_secs(30),
77            interval: Duration::from_millis(250),
78        }
79    }
80}
81
82/// Strongest evidence retained when waiting stops without a supported receipt.
83#[derive(Debug)]
84pub struct ReceiptEvidence {
85    pub network_hash: NetworkHash,
86    pub transaction_hash: TxHash,
87    pub commitment: Option<KnownCommitment>,
88    /// Last meaningful status. Unknown and failed reads do not erase it;
89    /// a commitment supersedes all scheduler diagnostics.
90    pub last_status: Option<TransactionStatus>,
91    /// Original most recent failed read, also retained on timeout/cancellation.
92    pub last_lookup_error: Option<NativeClientError>,
93}
94
95/// Waiting stopped; the evidence never grants permission to submit again.
96#[derive(Debug, thiserror::Error)]
97#[error("{cause}")]
98pub struct ReceiptPollError {
99    pub evidence: Box<ReceiptEvidence>,
100    #[source]
101    pub cause: ReceiptPollCause,
102}
103
104/// Reason a native receipt wait stopped.
105#[derive(Debug, thiserror::Error)]
106#[non_exhaustive]
107pub enum ReceiptPollCause {
108    #[error("timed out waiting for transaction receipt")]
109    Timeout,
110    #[error("transaction receipt wait cancelled")]
111    Cancelled,
112    #[error("receipt lookup failed: {0}")]
113    Lookup(#[source] NativeClientError),
114    #[error("unsupported {method} result")]
115    Unsupported {
116        method: &'static str,
117        raw: serde_json::Value,
118    },
119    #[error("invalid receipt wait data: {message}")]
120    Invalid {
121        message: &'static str,
122        /// Preserve a conflicting/invalid result for diagnosis when available.
123        raw: Option<serde_json::Value>,
124    },
125}
126
127impl ReceiptEvidence {
128    fn new(
129        network_hash: NetworkHash,
130        transaction_hash: TxHash,
131        known_commitment: Option<KnownCommitment>,
132    ) -> Result<Self, ReceiptPollError> {
133        let mut evidence = Self {
134            network_hash,
135            transaction_hash,
136            commitment: None,
137            last_status: None,
138            last_lookup_error: None,
139        };
140        if let Some(known) = known_commitment {
141            let checked = if known.network_hash == network_hash {
142                evidence.commit(known.transaction_hash, &known.block_height)
143            } else {
144                Err("commitment belongs to a different network")
145            };
146            if let Err(message) = checked {
147                return Err(evidence.fail(ReceiptPollCause::Invalid { message, raw: None }));
148            }
149        }
150        Ok(evidence)
151    }
152
153    fn fail(self, cause: ReceiptPollCause) -> ReceiptPollError {
154        ReceiptPollError {
155            evidence: Box::new(self),
156            cause,
157        }
158    }
159
160    fn commit(&mut self, hash: TxHash, height: &BlockHeight) -> Result<(), &'static str> {
161        if hash != self.transaction_hash {
162            return Err("transaction hash does not match the requested hash");
163        }
164        if **height < 0 {
165            return Err("commitment height must be nonnegative");
166        }
167        if self
168            .commitment
169            .as_ref()
170            .is_some_and(|known| known.block_height != *height)
171        {
172            return Err("commitment height conflicts with prior evidence");
173        }
174        self.commitment = Some(KnownCommitment {
175            network_hash: self.network_hash,
176            transaction_hash: hash,
177            block_height: height.clone(),
178        });
179        self.last_status = Some(TransactionStatus::Committed {
180            block_height: height.clone(),
181        });
182        Ok(())
183    }
184}
185
186/// Wait for a supported committed receipt, including failed execution. This
187/// helper never signs or submits. Pass `std::future::pending()` when cancellation
188/// is unnecessary. Dropping the future also cancels reads but cannot return its
189/// evidence; use `cancelled` when the caller needs an evidence-bearing error.
190///
191/// `network_hash` and the source must belong to the same verified chain instance.
192/// Supplied commitment must match that context and the original transaction hash.
193/// Retry/Dropped statuses never end a wait; even without a prior commitment they
194/// do not prove nonexecution. Transient receipt failures use bounded jittered
195/// backoff. Transient status failures retain evidence and continue to the next
196/// receipt read. Unsupported or malformed status representations (including an
197/// unfamiliar optional attempt diagnostic) stop with the original response
198/// evidence; they never permit transaction resubmission.
199#[tracing::instrument(skip_all)]
200pub async fn poll_receipt<S, F>(
201    source: &S,
202    network_hash: NetworkHash,
203    transaction_hash: TxHash,
204    known_commitment: Option<KnownCommitment>,
205    config: ReceiptPollConfig,
206    cancelled: F,
207) -> Result<TransactionReceipt, ReceiptPollError>
208where
209    S: ReceiptSource + Sync + ?Sized,
210    F: Future<Output = ()>,
211{
212    let mut evidence = ReceiptEvidence::new(network_hash, transaction_hash, known_commitment)?;
213    let Some(deadline) = Instant::now().checked_add(config.timeout) else {
214        return Err(evidence.fail(ReceiptPollCause::Invalid {
215            message: "timeout exceeds the clock range",
216            raw: None,
217        }));
218    };
219    if config.interval.is_zero() {
220        return Err(evidence.fail(ReceiptPollCause::Invalid {
221            message: "poll interval must be positive",
222            raw: None,
223        }));
224    }
225    tokio::pin!(cancelled);
226    let mut failures = 0u32;
227    loop {
228        let receipt = tokio::select! {
229            biased;
230            () = &mut cancelled => return Err(evidence.fail(ReceiptPollCause::Cancelled)),
231            () = tokio::time::sleep_until(deadline) => return Err(evidence.fail(ReceiptPollCause::Timeout)),
232            result = source.transaction_receipt(transaction_hash) => result,
233        };
234        match receipt {
235            Ok(
236                result @ (DecodedResponse::Known(Some(_)) | DecodedResponse::Unsupported { .. }),
237            ) => {
238                return finish_receipt(&mut evidence, result).map_err(|cause| evidence.fail(cause));
239            }
240            Ok(DecodedResponse::Known(None)) => {
241                let status = tokio::select! {
242                    biased;
243                    () = &mut cancelled => return Err(evidence.fail(ReceiptPollCause::Cancelled)),
244                    () = tokio::time::sleep_until(deadline) => return Err(evidence.fail(ReceiptPollCause::Timeout)),
245                    result = source.transaction_status(transaction_hash) => result,
246                };
247                match status {
248                    Ok(status) => {
249                        if let Err(cause) = observe_status(&mut evidence, status) {
250                            return Err(evidence.fail(cause));
251                        }
252                        failures = 0;
253                    }
254                    Err(error) => {
255                        evidence.last_lookup_error = Some(error);
256                        failures = failures.saturating_add(1);
257                    }
258                }
259            }
260            Ok(_) => {
261                return Err(evidence.fail(ReceiptPollCause::Invalid {
262                    message: "unsupported response classification",
263                    raw: None,
264                }));
265            }
266            Err(error) => {
267                if let Some((hash, height)) = receipt_observation(&error)
268                    && let Err(message) = evidence.commit(hash, height)
269                {
270                    evidence.last_lookup_error = Some(error);
271                    return Err(evidence.fail(ReceiptPollCause::Invalid { message, raw: None }));
272                }
273                if !transient(&error) {
274                    return Err(evidence.fail(ReceiptPollCause::Lookup(error)));
275                }
276                evidence.last_lookup_error = Some(error);
277                failures = failures.saturating_add(1);
278            }
279        }
280        let pause = retry_pause(config.interval, failures)
281            .min(deadline.saturating_duration_since(Instant::now()));
282        tokio::select! {
283            biased;
284            () = &mut cancelled => return Err(evidence.fail(ReceiptPollCause::Cancelled)),
285            () = tokio::time::sleep_until(deadline) => return Err(evidence.fail(ReceiptPollCause::Timeout)),
286            () = tokio::time::sleep(pause) => {}
287        }
288    }
289}
290
291fn finish_receipt(
292    evidence: &mut ReceiptEvidence,
293    result: DecodedResponse<ReceiptResult>,
294) -> Result<TransactionReceipt, ReceiptPollCause> {
295    match result {
296        DecodedResponse::Known(Some(receipt)) => {
297            evidence
298                .commit(receipt.transaction_hash, &receipt.block_height)
299                .map_err(|message| ReceiptPollCause::Invalid {
300                    message,
301                    raw: serde_json::to_value(&receipt).ok(),
302                })?;
303            Ok(receipt)
304        }
305        DecodedResponse::Unsupported { raw } => {
306            // The transport validated known fields before declaring support.
307            // Re-read those fields to retain commitment with an unknown outcome.
308            let checked = match serde_json::from_value::<TransactionReceipt>(raw.clone()) {
309                Ok(receipt) => evidence.commit(receipt.transaction_hash, &receipt.block_height),
310                Err(_) => Err("unsupported receipt has invalid identity or shape"),
311            };
312            if let Err(message) = checked {
313                return Err(ReceiptPollCause::Invalid {
314                    message,
315                    raw: Some(raw),
316                });
317            }
318            Err(ReceiptPollCause::Unsupported {
319                method: "getTransactionReceipt",
320                raw,
321            })
322        }
323        _ => Err(ReceiptPollCause::Invalid {
324            message: "expected a present receipt",
325            raw: None,
326        }),
327    }
328}
329
330fn observe_status(
331    evidence: &mut ReceiptEvidence,
332    result: DecodedResponse<TransactionStatus>,
333) -> Result<(), ReceiptPollCause> {
334    match result {
335        DecodedResponse::Known(status) => {
336            if let TransactionStatus::Committed { block_height } = &status {
337                evidence
338                    .commit(evidence.transaction_hash, block_height)
339                    .map_err(|message| ReceiptPollCause::Invalid {
340                        message,
341                        raw: serde_json::to_value(&status).ok(),
342                    })?;
343            } else if evidence.commitment.is_none()
344                && !matches!(status, TransactionStatus::Unknown { .. })
345            {
346                evidence.last_status = Some(status);
347            }
348            Ok(())
349        }
350        DecodedResponse::Unsupported { raw } => Err(ReceiptPollCause::Unsupported {
351            method: "getTransactionStatus",
352            raw,
353        }),
354        _ => Err(ReceiptPollCause::Invalid {
355            message: "unsupported response classification",
356            raw: None,
357        }),
358    }
359}
360
361fn receipt_observation(error: &NativeClientError) -> Option<(TxHash, &BlockHeight)> {
362    if let NativeClientError::Application { data, .. } = error
363        && let DecodedError::Known { data, .. } = data.as_ref()
364        && let ErrorData::BackendDataInvalid {
365            observation: Some(observation),
366            ..
367        } = data.as_ref()
368    {
369        Some((observation.transaction_hash, &observation.block_height))
370    } else {
371        None
372    }
373}
374
375fn transient(error: &NativeClientError) -> bool {
376    match error {
377        NativeClientError::Application { data, .. } => matches!(data.as_ref(),
378            DecodedError::Known { data, .. } if matches!(data.as_ref(),
379                ErrorData::BackendUnavailable { .. } | ErrorData::BackendTimeout { .. } | ErrorData::Overloaded { .. })),
380        NativeClientError::Transport(
381            TransportError::Transport(_) | TransportError::RequestTimeout,
382        ) => true,
383        _ => false,
384    }
385}
386
387fn retry_pause(interval: Duration, failures: u32) -> Duration {
388    if failures == 0 {
389        return interval;
390    }
391    let cap = Duration::from_secs(2).max(interval);
392    let base = interval.saturating_mul(1 << failures.min(8)).min(cap);
393    // Standard-library randomized hashing supplies jitter without a new RNG
394    // dependency. Timing is bounded to 50..100% of the capped backoff.
395    let percent = u32::try_from(RandomState::new().hash_one(failures) % 51).unwrap_or(0) + 50;
396    (base / 100).saturating_mul(percent).max(interval)
397}
398
399#[cfg(test)]
400mod tests;