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;