Skip to main content

decdn_incentive/
tx.rs

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