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;