Skip to main content

decdn_incentive/
tx.rs

1//! One place to submit a transaction and resolve its outcome (#1355).
2//!
3//! Every production on-chain write needs the same three steps — send, await
4//! the receipt, fail on a revert — and the tx hash must be read before
5//! `get_receipt()` consumes the pending handle. A receipt that cannot be fetched
6//! leaves the transaction's fate unknown: it may still mine, and re-sending it
7//! is what causes duplicate spends. That is the one failure mode where the
8//! operator most needs the hash, so the error must name it.
9//!
10//! Lives in `decdn-incentive` rather than the CLI so every on-chain write in
11//! this crate shares one send/receipt path.
12//!
13//! `cli::commands::pool`'s `close_and_forget` / `reclaim_and_forget` hand-roll
14//! the pair so they can word a revert as a likely race: a concurrent close by
15//! the same owner, or a concurrent permissionless reclaim. Like this module,
16//! they read the tx hash before the receipt wait, so a receipt-read error names
17//! the transaction. In production code, a send/receipt pair that reads the hash
18//! after the receipt wait is a bug. The `decdn-e2e` fixtures are exempt: a
19//! failed fixture tx fails the test, and no operator reconciles it.
20
21use alloy::contract::{CallBuilder, CallDecoder, Error as ContractError};
22use alloy::primitives::B256;
23use alloy::providers::Provider;
24use anyhow::Context;
25
26/// What a [`send_for_receipt`] call did on-chain, recorded through its out-param
27/// independently of the `Result` it returns.
28///
29/// `send_for_receipt` still fails on every non-success path; this says *which*
30/// non-success, and carries the tx hash on every arm that captured one — so a
31/// machine-readable receipt need not scrape the hash out of the human error
32/// text. That gap is the bug behind #1550 (a confirmed revert lost its hash) and
33/// #1577 (a lost send response was reported as a definite "nothing happened").
34///
35/// The six states are mutually exclusive and cover every path:
36///
37/// - [`NotSent`](Self::NotSent) — the initial value; also what a dry run leaves.
38/// - [`Rejected`](Self::Rejected) — `send()` failed with a JSON-RPC error
39///   response (a pre-flight gas-estimate revert, a stale nonce, an under-funded
40///   account). The node answered, and nothing entered the mempool.
41/// - [`MaybeBroadcast`](Self::MaybeBroadcast) — `send()` failed with a
42///   transport-class error. The request may have reached the node and broadcast
43///   the transaction, but no hash was captured. The outcome is UNKNOWN.
44/// - [`InFlight`](Self::InFlight) — broadcast, but the receipt could not be
45///   fetched. In flight, may still confirm. Carries the hash.
46/// - [`Reverted`](Self::Reverted) — broadcast and mined, but reverted: it burned
47///   gas and had no effect. Carries the hash as a permanent on-chain handle.
48/// - [`Confirmed`](Self::Confirmed) — confirmed success. Carries the hash.
49#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
50pub enum SendOutcome {
51    /// No send has resolved into this slot — the initial value, and the value a
52    /// dry run leaves.
53    #[default]
54    NotSent,
55    /// The node rejected the call before broadcast (a JSON-RPC error response).
56    /// Nothing entered the mempool.
57    Rejected,
58    /// `send()` failed with a transport-class error (timeout, connection reset, a
59    /// 5xx from an RPC load balancer). The transaction may have been broadcast;
60    /// no hash was captured, so the outcome is genuinely unknown and re-sending
61    /// can double-submit.
62    MaybeBroadcast,
63    /// Broadcast; the receipt could not be read. May still confirm.
64    InFlight(B256),
65    /// Broadcast and mined, but reverted. No effect; the hash is a handle for
66    /// reconciling the gas spend.
67    Reverted(B256),
68    /// Confirmed success.
69    Confirmed(B256),
70}
71
72impl SendOutcome {
73    /// The tx hash for any arm that captured one — [`Confirmed`](Self::Confirmed),
74    /// [`Reverted`](Self::Reverted), or [`InFlight`](Self::InFlight).
75    ///
76    /// [`MaybeBroadcast`](Self::MaybeBroadcast) has none by construction: a
77    /// transport error surfaces before `send()` returns a handle, so there is no
78    /// hash to give even though a transaction may exist.
79    #[must_use]
80    pub const fn tx(self) -> Option<B256> {
81        match self {
82            Self::Confirmed(h) | Self::Reverted(h) | Self::InFlight(h) => Some(h),
83            Self::NotSent | Self::Rejected | Self::MaybeBroadcast => None,
84        }
85    }
86
87    /// Whether the transaction may have taken effect on-chain.
88    ///
89    /// True for a confirmed success, an in-flight broadcast, and a transport
90    /// failure whose broadcast is unknown; false for a definitive rejection and a
91    /// confirmed revert. Asserting "nothing was transferred" when this is true is
92    /// how an operator gets told to re-run a transaction that is still pending.
93    #[must_use]
94    pub const fn maybe_effected(self) -> bool {
95        matches!(
96            self,
97            Self::Confirmed(_) | Self::InFlight(_) | Self::MaybeBroadcast
98        )
99    }
100}
101
102/// Whether a `send()` failure leaves the transaction's fate genuinely unknown.
103///
104/// The question is whether the request could have reached the node and broadcast
105/// the transaction before its response was lost. Two kinds of failure are
106/// definite rejections that broadcast nothing:
107///
108/// - a JSON-RPC error *response* — the node processed the request and rejected
109///   it (a pre-flight gas-estimate revert, the modal `createNamespace` /
110///   `addOrigin` failure; a stale nonce; an under-funded account);
111/// - a client-side fault raised before the request left the process (an
112///   unsupported feature, a local-usage error, a body that failed to serialize),
113///   or an HTTP 4xx where the gateway refused the request outright (a bad path,
114///   an expired key) without forwarding it to the node.
115///
116/// Everything else is genuinely uncertain: a timeout, a connection reset, an HTTP
117/// 5xx from an RPC load balancer, or a response body that could not be
118/// deserialized. There the transaction may already be in the mempool, and
119/// `eth_sendRawTransaction` is not idempotent from the client's view, so the
120/// caller must not report "nothing happened".
121///
122/// The `as_error_resp().is_none()` shorthand is deliberately NOT used: it would
123/// fold the local faults above into the uncertain bucket, warning about a
124/// possibly-broadcast transaction on a bug that never touched the wire.
125///
126/// `const` because the workspace `missing_const_for_fn` clippy lint requires it —
127/// the body is a pure classification with no non-const calls.
128#[must_use]
129pub const fn send_broadcast_unknown(err: &ContractError) -> bool {
130    use alloy::transports::{RpcError, TransportErrorKind};
131
132    let ContractError::TransportError(rpc) = err else {
133        // ABI, pending-transaction, and other contract-level faults are local —
134        // nothing reached the wire.
135        return false;
136    };
137    match rpc {
138        // The node answered, or the client failed before sending: a definite
139        // rejection that broadcast nothing.
140        RpcError::ErrorResp(_)
141        | RpcError::UnsupportedFeature(_)
142        | RpcError::LocalUsageError(_)
143        | RpcError::SerError(_) => false,
144        // An HTTP 4xx is the gateway refusing the request (bad path, bad key)
145        // before it reaches the node; a 5xx may have broadcast before failing.
146        RpcError::Transport(TransportErrorKind::HttpError(h)) => h.status >= 500,
147        // Timeouts, resets, a null response, an undeserializable body: the
148        // request may have been processed before the answer was lost.
149        _ => true,
150    }
151}
152
153/// Whether a send failed because another transaction from the same account took
154/// its nonce: the node answered with a JSON-RPC rejection (`nonce too low`, or a
155/// same-nonce `replacement transaction underpriced`). Nothing was broadcast, so
156/// re-sending is safe, and a provider that reads the pending nonce on every send
157/// picks the next free nonce on the retry. A node's seller and buyer services
158/// sign with one operator key, so their transactions can collide this way.
159#[must_use]
160pub fn is_nonce_collision(err: &ContractError) -> bool {
161    use alloy::transports::RpcError;
162
163    let ContractError::TransportError(RpcError::ErrorResp(resp)) = err else {
164        return false;
165    };
166    let message = resp.message.to_ascii_lowercase();
167    message.contains("nonce too low") || message.contains("replacement transaction underpriced")
168}
169
170/// Send one call, await its receipt, and fail on a revert.
171///
172/// `landed` is the caller's record of whether this step may have taken effect,
173/// and is the reason an out-param exists rather than just a return value:
174///
175/// - rejected send → `None` (nothing was broadcast)
176/// - transport-class send failure → `None`, but the error text warns that the tx
177///   MAY have been broadcast — the outcome is unknown ([`SendOutcome`] carries
178///   this as [`MaybeBroadcast`](SendOutcome::MaybeBroadcast) for callers that
179///   render structured receipts)
180/// - broadcast, receipt unreadable → `Some(hash)` **and** `Err` — the outcome is
181///   UNKNOWN, and the caller must treat it as possibly-applied
182/// - confirmed revert → `None` and `Err` (definitively no effect)
183/// - confirmed success → `Some(hash)` and `Ok`
184///
185/// So `Some` means "may have taken effect", never merely "was broadcast", and an
186/// `Err` from here does **not** imply nothing landed. Callers that report what
187/// happened must read it that way; asserting "nothing was transferred" on an
188/// `Err` is how an operator gets told to re-run a transaction that is still
189/// pending. Callers that need the full outcome — the revert hash, the
190/// broadcast-unknown case — call [`send_for_receipt`] with a [`SendOutcome`]
191/// out-param instead of this `Option<B256>` collapse.
192///
193/// `hint` is a per-step semantic diagnostic, attached to **both** the send and
194/// revert arms deliberately. A wallet provider built with alloy's recommended
195/// fillers runs `eth_estimateGas` inside `send()`, so a call that would revert
196/// usually fails *there*, before it is ever broadcast, surfacing as a bare
197/// `execution reverted` selector. A hint attached only to the receipt arm would
198/// therefore almost never print.
199///
200/// # Errors
201///
202/// Fails if the call cannot be sent, if the receipt cannot be fetched, or if the
203/// transaction reverted.
204pub async fn send<C: CallDecoder, P: Provider>(
205    call: CallBuilder<P, C>,
206    label: &str,
207    hint: Option<&str>,
208    landed: &mut Option<B256>,
209) -> anyhow::Result<B256> {
210    let mut outcome = SendOutcome::NotSent;
211    let result = send_for_receipt(call, label, hint, &mut outcome).await;
212    // Collapse the rich outcome to the historical `Option<B256>` contract:
213    // `Some(hash)` iff the transaction may have taken effect. A revert clears it
214    // (definitively no effect); a transport-class failure has no hash to give.
215    *landed = outcome.tx().filter(|_| outcome.maybe_effected());
216    result.map(|r| r.transaction_hash)
217}
218
219/// [`send`], recording the full [`SendOutcome`] and returning the whole receipt
220/// for callers that must decode event logs from it (a contract's return value is
221/// not in a receipt, so an emitted event is the only way to read one back).
222///
223/// `outcome` is set on every path, so a caller can render a machine-readable
224/// receipt from it after the `Result` propagates: the revert hash it needs for
225/// #1550, and the broadcast-unknown case it needs for #1577, are both here even
226/// though the `Result` cannot carry them.
227///
228/// # Errors
229///
230/// Fails if the call cannot be sent, if the receipt cannot be fetched, or if the
231/// transaction reverted.
232pub async fn send_for_receipt<C: CallDecoder, P: Provider>(
233    call: CallBuilder<P, C>,
234    label: &str,
235    hint: Option<&str>,
236    outcome: &mut SendOutcome,
237) -> anyhow::Result<alloy::rpc::types::TransactionReceipt> {
238    let suffix = hint.map_or_else(String::new, |h| format!("; {h}"));
239    let pending = match call.send().await {
240        Ok(pending) => pending,
241        Err(err) => {
242            // A rejected send never reached the mempool; a transport-class
243            // failure may have broadcast the tx before its response was lost.
244            // The latter must not be reported as a clean "nothing happened",
245            // since re-sending is not idempotent (#1577).
246            let broadcast_unknown = send_broadcast_unknown(&err);
247            *outcome = if broadcast_unknown {
248                SendOutcome::MaybeBroadcast
249            } else {
250                SendOutcome::Rejected
251            };
252            let uncertainty = if broadcast_unknown {
253                " This is a transport error, not a rejection: the request may have reached the \
254                 node and broadcast the transaction before the response was lost. Check this \
255                 signer's pending and mined transactions before re-running — re-sending is not \
256                 idempotent and can double-submit."
257            } else {
258                ""
259            };
260            return Err(anyhow::Error::new(err).context(format!(
261                "{label} transaction failed to send. A pre-flight gas estimate rejecting the \
262                 call surfaces here rather than as a revert, as do transport and nonce errors — \
263                 see the cause below{suffix}.{uncertainty}"
264            )));
265        }
266    };
267    let hash = *pending.tx_hash();
268    // Recorded BEFORE the receipt is awaited, which is the whole point of the
269    // out-param. From here the transaction is broadcast and may take effect; if
270    // the receipt cannot be read `outcome` stays `InFlight`, so the caller can
271    // still see that a tx is outstanding.
272    *outcome = SendOutcome::InFlight(hash);
273    let receipt = pending.get_receipt().await.with_context(|| {
274        format!("{label} sent (tx {hash:#x}) but the receipt could not be fetched")
275    })?;
276    // `status()` coerces a pre-Byzantium `PostState` receipt to success. Not
277    // reachable on the L2s this targets; noted so the check isn't read as
278    // exhaustive over every receipt shape.
279    if !receipt.status() {
280        // A confirmed revert had no effect — but it DID mine, so keep the hash:
281        // `Reverted` is what makes a gas-burning revert distinguishable from a
282        // send that never broadcast (#1550). [`SendOutcome::maybe_effected`]
283        // still reports `false` for it, so the `Option<B256>` collapse in
284        // [`send`] clears the slot exactly as before.
285        *outcome = SendOutcome::Reverted(hash);
286        anyhow::bail!("{label} reverted (tx {hash:#x}){suffix}");
287    }
288    *outcome = SendOutcome::Confirmed(hash);
289    Ok(receipt)
290}
291
292/// [`send`] for callers with no outcome to record — a step whose hash they do
293/// not report. The in-flight hash still reaches the operator through the error
294/// message; this only drops the structured record.
295///
296/// # Errors
297///
298/// As [`send`].
299pub async fn send_unrecorded<C: CallDecoder, P: Provider>(
300    call: CallBuilder<P, C>,
301    label: &str,
302    hint: Option<&str>,
303) -> anyhow::Result<B256> {
304    let mut ignored = None;
305    send(call, label, hint, &mut ignored).await
306}
307
308#[cfg(test)]
309#[allow(clippy::unwrap_used, clippy::expect_used)]
310mod tests;