Skip to content

Commit 203ff9f

Browse files
committed
relay: typed AlreadyProcessed error to skip retry on duplicate submits
process_deposit and process_refund now return Result<(), ProcessError>. The Linera bridge contract reverts with "deposit already processed" or "refund already processed" on a duplicate key; before this, the relayer treated both as ordinary failures, burned retry budget, and emitted refund_failed metrics when check_refund_completion hadn't yet caught up. Classify the error chain in serve_loop and short-circuit in the pending processors — mark the entry as completed instead of retrying.
1 parent ef40392 commit 203ff9f

3 files changed

Lines changed: 119 additions & 9 deletions

File tree

linera-bridge/src/monitor/evm.rs

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,11 @@ use tokio::sync::{Notify, RwLock};
1313
use super::{MonitorState, PendingDeposit, PendingRefund};
1414
use crate::{
1515
proof::{parse_burn_blocked_event, parse_deposit_event, DepositKey, ReceiptLog, RefundKey},
16-
relay::{self, evm::EvmClient, linera::LineraClient},
16+
relay::{
17+
self,
18+
evm::EvmClient,
19+
linera::{LineraClient, ProcessError},
20+
},
1721
};
1822

1923
/// Background task that polls EVM for `DepositInitiated` events and checks
@@ -115,7 +119,16 @@ pub(crate) async fn process_pending_deposits<E: linera_core::environment::Enviro
115119
tracing::info!(%tx_hash, "Deposit processed successfully");
116120
relay::update_linera_balance_metric(linera_client).await;
117121
}
118-
Err(e) => {
122+
Err(ProcessError::AlreadyProcessed) => {
123+
// The chain already recorded this key (e.g. another
124+
// relayer beat us to it). Skip the retry budget and
125+
// mark the deposit completed instead of waiting for
126+
// the periodic completion check.
127+
tracing::info!(%tx_hash, "Deposit already processed on-chain");
128+
monitor.write().await.complete_deposit(&pending.key).await;
129+
continue;
130+
}
131+
Err(ProcessError::Other(e)) => {
119132
tracing::warn!(%tx_hash, "Deposit processing failed: {e}");
120133
}
121134
}
@@ -356,7 +369,16 @@ pub(crate) async fn process_pending_refunds<E: linera_core::environment::Environ
356369
tracing::info!(%tx_hash, "Refund processed successfully");
357370
relay::update_linera_balance_metric(linera_client).await;
358371
}
359-
Err(e) => {
372+
Err(ProcessError::AlreadyProcessed) => {
373+
// Same short-circuit as the deposit path: the chain
374+
// already recorded this refund key, so the duplicate
375+
// submission isn't worth retrying — mark it completed
376+
// and skip the retry-counter bump.
377+
tracing::info!(%tx_hash, "Refund already processed on-chain");
378+
monitor.write().await.complete_refund(&pending.key).await;
379+
continue;
380+
}
381+
Err(ProcessError::Other(e)) => {
360382
tracing::warn!(%tx_hash, "Refund processing failed: {e}");
361383
}
362384
},

linera-bridge/src/relay/linera.rs

Lines changed: 86 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,41 @@ use tokio::sync::{mpsc, oneshot};
1717

1818
use crate::proof::{DepositKey, RefundKey};
1919

20+
/// Outcome of a `process_deposit` / `process_refund` submission.
21+
///
22+
/// The bridge contract reverts with `"deposit already processed"` or
23+
/// `"refund already processed"` when the chain has already recorded the key.
24+
/// In that case the on-chain state is already what the relayer wanted, so the
25+
/// duplicate submission is not a retry-worthy failure and the caller should
26+
/// short-circuit instead of burning its retry budget.
27+
#[derive(Debug, thiserror::Error)]
28+
pub enum ProcessError {
29+
/// The chain already recorded this deposit/refund key. A duplicate
30+
/// submission isn't a retry-worthy failure — the on-chain state is
31+
/// already what we wanted.
32+
#[error("already processed on-chain")]
33+
AlreadyProcessed,
34+
#[error(transparent)]
35+
Other(#[from] anyhow::Error),
36+
}
37+
38+
impl ProcessError {
39+
/// Walks the error chain looking for the bridge contract's
40+
/// `"already processed"` panic substring. Matches both
41+
/// `"deposit already processed"` and `"refund already processed"` panics,
42+
/// regardless of how many layers of context wrap the underlying error.
43+
pub fn classify(err: anyhow::Error) -> Self {
44+
if err
45+
.chain()
46+
.any(|c| c.to_string().contains("already processed"))
47+
{
48+
ProcessError::AlreadyProcessed
49+
} else {
50+
ProcessError::Other(err)
51+
}
52+
}
53+
}
54+
2055
/// A write operation to be executed on the bridge chain.
2156
/// Sent to the main loop which serializes all chain mutations.
2257
#[derive(Debug)]
@@ -27,11 +62,11 @@ pub(crate) enum ChainOperation {
2762
},
2863
ProcessDeposit {
2964
proof: crate::proof::gen::DepositProof,
30-
response: oneshot::Sender<Result<()>>,
65+
response: oneshot::Sender<Result<(), ProcessError>>,
3166
},
3267
ProcessRefund {
3368
proof: crate::proof::gen::BurnBlockedProof,
34-
response: oneshot::Sender<Result<()>>,
69+
response: oneshot::Sender<Result<(), ProcessError>>,
3570
},
3671
}
3772

@@ -125,7 +160,10 @@ impl<E: linera_core::environment::Environment> LineraClient<E> {
125160

126161
// ── Write operations (sent to main loop via channel) ──
127162

128-
pub async fn process_deposit(&self, proof: crate::proof::gen::DepositProof) -> Result<()> {
163+
pub async fn process_deposit(
164+
&self,
165+
proof: crate::proof::gen::DepositProof,
166+
) -> Result<(), ProcessError> {
129167
let (resp_tx, resp_rx) = oneshot::channel();
130168
self.op_tx
131169
.send(ChainOperation::ProcessDeposit {
@@ -137,7 +175,10 @@ impl<E: linera_core::environment::Environment> LineraClient<E> {
137175
resp_rx.await.with_context(|| "Response channel closed")?
138176
}
139177

140-
pub async fn process_refund(&self, proof: crate::proof::gen::BurnBlockedProof) -> Result<()> {
178+
pub async fn process_refund(
179+
&self,
180+
proof: crate::proof::gen::BurnBlockedProof,
181+
) -> Result<(), ProcessError> {
141182
let (resp_tx, resp_rx) = oneshot::channel();
142183
self.op_tx
143184
.send(ChainOperation::ProcessRefund {
@@ -196,3 +237,44 @@ pub(crate) fn find_burn_events(
196237
}
197238
result
198239
}
240+
241+
#[cfg(test)]
242+
mod tests {
243+
use anyhow::anyhow;
244+
245+
use super::ProcessError;
246+
247+
#[test]
248+
fn classify_direct_already_processed_is_already_processed() {
249+
let err = anyhow!("deposit already processed");
250+
assert!(matches!(
251+
ProcessError::classify(err),
252+
ProcessError::AlreadyProcessed
253+
));
254+
}
255+
256+
/// The bridge panic string sits underneath a wrapper that comes from the
257+
/// chain-client execution path. Classification must walk the error chain,
258+
/// not only inspect the top-level `Display`.
259+
#[test]
260+
fn classify_wrapped_already_processed_is_already_processed() {
261+
let err = anyhow!(
262+
"worker operation failed: Execution error: Failed to execute Wasm module: \
263+
RuntimeError: unreachable during Operation(0)"
264+
)
265+
.context("refund already processed");
266+
assert!(matches!(
267+
ProcessError::classify(err),
268+
ProcessError::AlreadyProcessed
269+
));
270+
}
271+
272+
#[test]
273+
fn classify_unrelated_error_is_other() {
274+
let err = anyhow!("something else");
275+
assert!(matches!(
276+
ProcessError::classify(err),
277+
ProcessError::Other(_)
278+
));
279+
}
280+
}

linera-bridge/src/relay/mod.rs

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -516,7 +516,11 @@ async fn serve_loop<E: linera_core::environment::Environment + 'static>(
516516
}
517517
}
518518
linera::ChainOperation::ProcessDeposit { proof, response } => {
519-
let result = async {
519+
// Inner block returns anyhow::Error; the outer match
520+
// classifies it into ProcessError so the caller can
521+
// skip retrying when the chain already recorded this
522+
// deposit key.
523+
let inner: Result<()> = async {
520524
let operations: Vec<_> = proof.log_indices.iter().map(|&log_index| {
521525
let op = crate::abi::BridgeOperation::ProcessDeposit {
522526
block_header_rlp: proof.block_header_rlp.clone(),
@@ -557,13 +561,14 @@ async fn serve_loop<E: linera_core::environment::Environment + 'static>(
557561
};
558562
Ok(())
559563
}.await;
564+
let result = inner.map_err(linera::ProcessError::classify);
560565
if response.send(result).is_err() {
561566
tracing::debug!("ProcessDeposit response receiver dropped");
562567
}
563568
update_balance_metrics(&evm_client, &linera_client).await;
564569
}
565570
linera::ChainOperation::ProcessRefund { proof, response } => {
566-
let result = async {
571+
let inner: Result<()> = async {
567572
let op = crate::abi::BridgeOperation::RefundBurn {
568573
block_header_rlp: proof.block_header_rlp,
569574
receipt_rlp: proof.receipt_rlp,
@@ -595,6 +600,7 @@ async fn serve_loop<E: linera_core::environment::Environment + 'static>(
595600
other => anyhow::bail!("RefundBurn not committed: {other:?}"),
596601
}
597602
}.await;
603+
let result = inner.map_err(linera::ProcessError::classify);
598604
if response.send(result).is_err() {
599605
tracing::debug!("ProcessRefund response receiver dropped");
600606
}

0 commit comments

Comments
 (0)