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