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;