thruput_json_rpc_client/native/
receipt.rs1use 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
19pub trait ReceiptSource {
22 fn transaction_receipt(
24 &self,
25 hash: TxHash,
26 ) -> impl Future<Output = Result<DecodedResponse<ReceiptResult>, NativeClientError>> + Send;
27
28 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#[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#[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#[derive(Debug)]
84pub struct ReceiptEvidence {
85 pub network_hash: NetworkHash,
86 pub transaction_hash: TxHash,
87 pub commitment: Option<KnownCommitment>,
88 pub last_status: Option<TransactionStatus>,
91 pub last_lookup_error: Option<NativeClientError>,
93}
94
95#[derive(Debug, thiserror::Error)]
97#[error("{cause}")]
98pub struct ReceiptPollError {
99 pub evidence: Box<ReceiptEvidence>,
100 #[source]
101 pub cause: ReceiptPollCause,
102}
103
104#[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 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#[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 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 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;