Skip to main content

decdn_node/
onchain_tx.rs

1//! Shared driver for the node's on-chain `PaymentPool` write ladders.
2//!
3//! Every state-changing call the node makes against `PaymentPool` (today
4//! only the redeemer's `redeemMany`) follows the same micro-sequence: issue the transaction with `.send()`, await its receipt
5//! (optionally under a timeout), and inspect `receipt.status()` to tell a
6//! mined-and-succeeded transaction from a mined-and-reverted one. A receipt
7//! wait that fails or lapses is not an outcome yet: the transaction was issued
8//! and often mines, and a load-balanced RPC can fail the wait on a backend that
9//! has not seen the block. The driver then fetches the receipt by hash a few
10//! times before it settles on an unconfirmed outcome. What each
11//! caller then *does* with that result — which metric it ticks, whether it
12//! re-reads the pool to resolve a revert, what it returns — is domain state
13//! that stays at the call site.
14//!
15//! `send_and_await_receipt` folds only that common plumbing into one place and
16//! hands back a `TxOutcome`. It deliberately does **not** take the call builder
17//! (each `redeem`/`redeemMany`/… builder is a distinct type, which would force a
18//! generic or a closure hook); it takes the already issued `.send()` result, so
19//! the concrete method call stays a visible call at every site.
20//!
21//! `send_and_await_receipt` is fixed to the `Ethereum` network — the whole
22//! crate is Ethereum-only — which keeps its signature free of a `Provider` type
23//! parameter and its `where` clause. The private by-hash helpers stay generic
24//! over `Provider` so tests can drive them against a mocked client.
25
26use std::time::Duration;
27
28use alloy::contract::Error as ContractError;
29use alloy::network::Ethereum;
30use alloy::primitives::TxHash;
31use alloy::providers::{PendingTransactionBuilder, PendingTransactionError, Provider};
32use alloy::rpc::types::TransactionReceipt;
33use decdn_common::redact::sanitize_error_sources;
34use tracing::{debug, info};
35
36use crate::metrics::Metrics;
37
38/// By-hash receipt fetches after a failed or lapsed receipt wait, before the
39/// outcome counts as unconfirmed.
40const RESOLVE_ATTEMPTS: u32 = 3;
41
42/// Pause before the first by-hash fetch. It doubles before each later fetch
43/// (1 s, 2 s, 4 s across the [`RESOLVE_ATTEMPTS`] fetches), so a lagging RPC
44/// backend has time to see the block.
45const RESOLVE_FIRST_BACKOFF: Duration = Duration::from_secs(1);
46
47/// Bound on one by-hash fetch, so a hung RPC cannot hold the caller's tick.
48/// With the backoff, the by-hash fallback adds at most 1 + 2 + 4 + 3 × 10 = 37 s
49/// after a failed or lapsed wait.
50const RESOLVE_CALL_TIMEOUT: Duration = Duration::from_secs(10);
51
52/// Terminal outcome of one on-chain transaction: the `send` → `get_receipt` →
53/// `status` sequence folded to a single value. Every arm past `.send()` carries
54/// the transaction hash (inside the receipt, or beside the error) so callers can
55/// log it: a transaction that is still unconfirmed may yet mine, and its hash is
56/// the only way to find it. The two error arms keep their distinct concrete
57/// types rather than being flattened.
58pub(crate) enum TxOutcome {
59    /// Mined and succeeded (`receipt.status() == true`).
60    Landed(TransactionReceipt),
61    /// Mined and reverted (`receipt.status() == false`).
62    Reverted(TransactionReceipt),
63    /// `.send()` failed (RPC / mempool rejection) — no transaction was issued.
64    SendErr(ContractError),
65    /// The transaction was issued, awaiting its receipt failed, and no by-hash
66    /// fetch found a receipt either. The transaction is unconfirmed and may
67    /// still mine.
68    ReceiptErr {
69        /// The receipt-wait failure.
70        error: PendingTransactionError,
71        /// Hash of the issued transaction.
72        tx_hash: TxHash,
73        /// Why the last by-hash fetch found nothing: no receipt yet, or the
74        /// sanitized RPC error, or a fetch timeout. "No receipt" points at a
75        /// dropped or replaced transaction; an error points at the RPC.
76        last_lookup: String,
77    },
78    /// The receipt wait exceeded the caller-supplied timeout, and no by-hash
79    /// fetch found a receipt either. Only reachable when `receipt_timeout` is
80    /// `Some`; the transaction is unconfirmed and may still mine.
81    Timeout {
82        /// Hash of the issued transaction.
83        tx_hash: TxHash,
84        /// Why the last by-hash fetch found nothing (see
85        /// [`TxOutcome::ReceiptErr::last_lookup`]).
86        last_lookup: String,
87    },
88}
89
90/// Drive an already-issued `.send()` to a classified [`TxOutcome`].
91///
92/// `sent` is the result of `contract.<method>(..).send().await`. When
93/// `receipt_timeout` is `Some`, the receipt wait is bounded and a lapse yields
94/// [`TxOutcome::Timeout`]; when `None`, the wait is unbounded. A wait that
95/// fails or lapses falls back to fetching the receipt by hash, and a receipt
96/// found there classifies as `Landed` / `Reverted` like one the wait returned.
97/// Every outcome counts once into one of the five outcome counters
98/// (`decdn_onchain_tx_{landed,reverted,send_failed,receipt_failed,timeout}_total`);
99/// a wait the by-hash fetch resolved also counts into
100/// `decdn_onchain_tx_receipt_recovered_total`, which sits beside that family.
101pub(crate) async fn send_and_await_receipt(
102    sent: Result<PendingTransactionBuilder<Ethereum>, ContractError>,
103    receipt_timeout: Option<Duration>,
104    metrics: &Metrics,
105) -> TxOutcome {
106    let (outcome, recovered) = await_receipt(sent, receipt_timeout).await;
107    count_outcome(&outcome, recovered, metrics);
108    outcome
109}
110
111/// Count one classified outcome into its outcome counter, plus the recovered
112/// counter when a by-hash fetch resolved it.
113fn count_outcome(outcome: &TxOutcome, recovered: bool, metrics: &Metrics) {
114    if recovered {
115        metrics.onchain_tx_receipt_recovered();
116    }
117    match outcome {
118        TxOutcome::Landed(_) => metrics.onchain_tx_landed(),
119        TxOutcome::Reverted(_) => metrics.onchain_tx_reverted(),
120        TxOutcome::SendErr(_) => metrics.onchain_tx_send_failed(),
121        TxOutcome::ReceiptErr { .. } => metrics.onchain_tx_receipt_failed(),
122        TxOutcome::Timeout { .. } => metrics.onchain_tx_timeout(),
123    }
124}
125
126/// Why a receipt wait ended without a receipt.
127enum WaitFailure {
128    /// The wait returned an error.
129    Err(PendingTransactionError),
130    /// The wait exceeded the caller-supplied timeout.
131    Lapsed,
132}
133
134/// The classification behind [`send_and_await_receipt`], before it is counted.
135/// The `bool` is `true` when a by-hash fetch resolved a failed or lapsed wait.
136async fn await_receipt(
137    sent: Result<PendingTransactionBuilder<Ethereum>, ContractError>,
138    receipt_timeout: Option<Duration>,
139) -> (TxOutcome, bool) {
140    let pending = match sent {
141        Ok(pending) => pending,
142        Err(err) => return (TxOutcome::SendErr(err), false),
143    };
144    let tx_hash = *pending.tx_hash();
145    let provider = pending.provider().clone();
146    let waited = match receipt_timeout {
147        Some(timeout) => match tokio::time::timeout(timeout, pending.get_receipt()).await {
148            Ok(result) => result.map_err(WaitFailure::Err),
149            Err(_elapsed) => Err(WaitFailure::Lapsed),
150        },
151        None => pending.get_receipt().await.map_err(WaitFailure::Err),
152    };
153    classify_wait(waited, &provider, tx_hash).await
154}
155
156/// Classify a finished receipt wait. A failed or lapsed wait first fetches the
157/// receipt by hash ([`receipt_by_hash`]); only when that finds nothing does the
158/// outcome stay `ReceiptErr` / `Timeout`.
159async fn classify_wait<P: Provider>(
160    waited: Result<TransactionReceipt, WaitFailure>,
161    provider: &P,
162    tx_hash: TxHash,
163) -> (TxOutcome, bool) {
164    let failure = match waited {
165        Ok(receipt) => return (by_status(receipt), false),
166        Err(failure) => failure,
167    };
168    let receipt = match receipt_by_hash(provider, tx_hash).await {
169        Ok(receipt) => receipt,
170        Err(last_lookup) => {
171            let outcome = match failure {
172                WaitFailure::Err(error) => TxOutcome::ReceiptErr {
173                    error,
174                    tx_hash,
175                    last_lookup,
176                },
177                WaitFailure::Lapsed => TxOutcome::Timeout {
178                    tx_hash,
179                    last_lookup,
180                },
181            };
182            return (outcome, false);
183        }
184    };
185    let wait = match &failure {
186        WaitFailure::Err(error) => sanitize_error_sources(error),
187        WaitFailure::Lapsed => "timed out".to_owned(),
188    };
189    info!(tx = %tx_hash, %wait, "receipt wait failed; found the receipt by hash");
190    (by_status(receipt), true)
191}
192
193/// `Landed` or `Reverted` by the receipt's status.
194const fn by_status(receipt: TransactionReceipt) -> TxOutcome {
195    if receipt.status() {
196        TxOutcome::Landed(receipt)
197    } else {
198        TxOutcome::Reverted(receipt)
199    }
200}
201
202/// Fetch a transaction's receipt by hash, up to [`RESOLVE_ATTEMPTS`] times with
203/// a doubling pause before each fetch. A fetch that errors, lapses or returns no
204/// receipt moves on to the next attempt. When every attempt misses, the `Err`
205/// carries the last miss's reason, so the caller's warning can tell a
206/// transaction the chain does not know from an RPC that is failing.
207async fn receipt_by_hash<P: Provider>(
208    provider: &P,
209    tx_hash: TxHash,
210) -> Result<TransactionReceipt, String> {
211    let mut backoff = RESOLVE_FIRST_BACKOFF;
212    let mut last_miss = String::new();
213    for attempt in 1..=RESOLVE_ATTEMPTS {
214        tokio::time::sleep(backoff).await;
215        backoff = backoff.saturating_mul(2);
216        let fetch = provider.get_transaction_receipt(tx_hash);
217        let miss = match tokio::time::timeout(RESOLVE_CALL_TIMEOUT, fetch).await {
218            Ok(Ok(Some(receipt))) => return Ok(receipt),
219            Ok(Ok(None)) => "no receipt yet".to_owned(),
220            Ok(Err(error)) => sanitize_error_sources(&error),
221            Err(_elapsed) => "fetch timed out".to_owned(),
222        };
223        debug!(tx = %tx_hash, attempt, %miss, "receipt fetch by hash found nothing");
224        last_miss = miss;
225    }
226    Err(last_miss)
227}
228
229#[cfg(test)]
230#[allow(clippy::expect_used)]
231mod tests;