From de4a903837eb783c9cd11fc120e92fc1676cde9d Mon Sep 17 00:00:00 2001 From: Shaun Wackerly Date: Fri, 5 Jun 2026 19:09:09 -0500 Subject: [PATCH 1/2] fix: immediately set Canceled status when cancelling a Sent/Submitted tx Previously, cancel_transaction on a Sent or Submitted EVM transaction only set is_canceled=true and queued a noop resubmit job, leaving the DB status as Sent or Submitted. The transaction would only transition once the noop mined on-chain (and then to Confirmed, not Canceled), so polling ?status=Sent continued returning the transaction indefinitely. Add status=Canceled to the TransactionUpdateRequest built from prepare_noop_update_request so the status persists immediately when the cancel API is called. The noop replacement is still submitted on-chain to unblock the nonce, but the relayer treats the transaction as terminal from this point on. is_final_state already includes Canceled and handle_status_impl already routes Canceled to handle_final_state, so no further changes are needed to prevent a stale status-check job from overriding the terminal state. Co-Authored-By: Claude Sonnet 4.6 --- src/domain/transaction/evm/evm_transaction.rs | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/domain/transaction/evm/evm_transaction.rs b/src/domain/transaction/evm/evm_transaction.rs index 380faba0b..4f269bec7 100644 --- a/src/domain/transaction/evm/evm_transaction.rs +++ b/src/domain/transaction/evm/evm_transaction.rs @@ -1260,13 +1260,19 @@ where .await; } - let update = self + let mut update = self .prepare_noop_update_request( &tx, true, Some("Transaction canceled by user, replacing with NOOP".to_string()), ) .await?; + // Immediately mark the transaction as Canceled so it no longer appears in + // active-status queries. The noop replacement is still submitted on-chain to + // prevent the original transaction from mining, but the relayer treats this + // as a terminal state from this point on. + update.status = Some(TransactionStatus::Canceled); + let updated_tx = self .transaction_repository() .partial_update(tx.id.clone(), update) @@ -2231,7 +2237,8 @@ mod tests { // Verify the cancellation transaction was properly created assert_eq!(cancelled_tx.id, "test-tx-id"); - assert_eq!(cancelled_tx.status, TransactionStatus::Submitted); + // Status is immediately set to Canceled; the noop is submitted asynchronously. + assert_eq!(cancelled_tx.status, TransactionStatus::Canceled); // Verify the network data was properly updated if let NetworkTransactionData::Evm(evm_data) = &cancelled_tx.network_data { From 60dad7c822de678a81cd0e4d47e00aa70ed45eeb Mon Sep 17 00:00:00 2001 From: Dylan Kilkenny Date: Mon, 29 Jun 2026 10:24:13 +0100 Subject: [PATCH 2/2] fix: Keep cancelled tx tracked until NOOP mines MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous approach set status=Canceled in cancel_transaction before enqueueing the resubmit job. Because the resubmit handler reloads the tx by id and resubmit_transaction only proceeds for Sent/Submitted, the cancellation NOOP was never broadcast — the nonce stayed blocked and the original transaction could still mine. Marking the row terminal also disabled all follow-up tracking (nonce recovery, retries on ReplacementUnderpriced/NonceTooHigh, dropped-NOOP resubmission), since handle_status_impl short-circuits on final states. Instead, leave the transaction in its active status with is_canceled=true so the normal status machinery keeps resubmitting and recovering the NOOP until it actually mines. When a confirmed transaction is a cancellation NOOP (is_canceled && is_noop), check_transaction_status now returns Canceled instead of Confirmed, so it terminates in the user-meaningful state once the nonce is genuinely consumed. Excluding canceled-in-progress transactions from active-status API views is handled separately at the listing endpoint (PR #792); it cannot live in the shared find_by_status repository primitive, which internal nonce and cleanup logic relies on to see the still-active NOOP. Signed-off-by: Dylan Kilkenny --- src/domain/transaction/evm/evm_transaction.rs | 19 +++-- src/domain/transaction/evm/status.rs | 85 +++++++++++++++++++ 2 files changed, 96 insertions(+), 8 deletions(-) diff --git a/src/domain/transaction/evm/evm_transaction.rs b/src/domain/transaction/evm/evm_transaction.rs index 4f269bec7..dd75f660d 100644 --- a/src/domain/transaction/evm/evm_transaction.rs +++ b/src/domain/transaction/evm/evm_transaction.rs @@ -1260,18 +1260,18 @@ where .await; } - let mut update = self + // Build the NOOP replacement. The transaction keeps its active status + // (Sent/Submitted) and is marked is_canceled=true, so it is still tracked, + // resubmitted, and recovered by the normal status machinery until the NOOP + // actually mines — at which point it transitions to Canceled (see status.rs). + // The is_canceled flag excludes it from active-status API views immediately. + let update = self .prepare_noop_update_request( &tx, true, Some("Transaction canceled by user, replacing with NOOP".to_string()), ) .await?; - // Immediately mark the transaction as Canceled so it no longer appears in - // active-status queries. The noop replacement is still submitted on-chain to - // prevent the original transaction from mining, but the relayer treats this - // as a terminal state from this point on. - update.status = Some(TransactionStatus::Canceled); let updated_tx = self .transaction_repository() @@ -2168,6 +2168,7 @@ mod tests { updated_tx.status = update.status.unwrap_or(updated_tx.status); updated_tx.network_data = update.network_data.unwrap_or(updated_tx.network_data); + updated_tx.is_canceled = update.is_canceled.or(updated_tx.is_canceled); if let Some(hashes) = update.hashes { updated_tx.hashes = hashes; } @@ -2237,8 +2238,10 @@ mod tests { // Verify the cancellation transaction was properly created assert_eq!(cancelled_tx.id, "test-tx-id"); - // Status is immediately set to Canceled; the noop is submitted asynchronously. - assert_eq!(cancelled_tx.status, TransactionStatus::Canceled); + // The tx keeps its active status until the NOOP mines; is_canceled excludes + // it from active-status API views in the meantime. + assert_eq!(cancelled_tx.status, TransactionStatus::Submitted); + assert_eq!(cancelled_tx.is_canceled, Some(true)); // Verify the network data was properly updated if let NetworkTransactionData::Evm(evm_data) = &cancelled_tx.network_data { diff --git a/src/domain/transaction/evm/status.rs b/src/domain/transaction/evm/status.rs index 6ae90bb6f..c424b7207 100644 --- a/src/domain/transaction/evm/status.rs +++ b/src/domain/transaction/evm/status.rs @@ -115,6 +115,19 @@ where ); return Ok(TransactionStatus::Mined); } + // A confirmed NOOP that replaced a user-cancelled transaction is terminal + // as Canceled, not Confirmed: the relayer kept tracking and resubmitting it + // until it mined (consuming the nonce so the original could never execute), + // but from the user's perspective the transaction was cancelled. + if tx.is_canceled == Some(true) && is_noop(&evm_data) { + debug!( + tx_id = %tx.id, + relayer_id = %tx.relayer_id, + tx_hash = %tx_hash, + "cancellation NOOP confirmed on-chain; marking transaction as Canceled" + ); + return Ok(TransactionStatus::Canceled); + } Ok(TransactionStatus::Confirmed) } else { debug!( @@ -1619,6 +1632,78 @@ mod tests { assert_eq!(status, TransactionStatus::Confirmed); } + /// A confirmed NOOP that replaced a user-cancelled transaction must terminate + /// as Canceled rather than Confirmed, so the user sees the cancellation reflected + /// once the nonce-consuming NOOP actually mines. + #[tokio::test] + async fn test_cancellation_noop_confirmed_becomes_canceled() { + let mut mocks = default_test_mocks(); + let relayer = create_test_relayer(); + let mut tx = make_test_transaction(TransactionStatus::Submitted); + tx.is_canceled = Some(true); + + // Shape the network data as a cancellation NOOP (value 0, data "0x", to == from). + if let NetworkTransactionData::Evm(ref mut evm_data) = tx.network_data { + evm_data.hash = Some("0xNoopHash".to_string()); + evm_data.value = U256::from(0); + evm_data.data = Some("0x".to_string()); + evm_data.to = Some(evm_data.from.clone()); + } + + mocks + .provider + .expect_get_transaction_receipt() + .returning(|_| Box::pin(async { Ok(Some(make_mock_receipt(true, Some(100)))) })); + mocks + .provider + .expect_get_block_number() + .return_once(|| Box::pin(async { Ok(113) })); + mocks + .network_repo + .expect_get_by_chain_id() + .returning(|_, _| Ok(Some(create_test_network_model()))); + + let evm_transaction = make_test_evm_relayer_transaction(relayer, mocks); + + let status = evm_transaction.check_transaction_status(&tx).await.unwrap(); + assert_eq!(status, TransactionStatus::Canceled); + } + + /// A confirmed NOOP that is NOT a user cancellation (is_canceled=false) — e.g. a + /// nonce-clearing NOOP from timeout handling — must still confirm normally. + #[tokio::test] + async fn test_non_cancellation_noop_confirmed_stays_confirmed() { + let mut mocks = default_test_mocks(); + let relayer = create_test_relayer(); + let mut tx = make_test_transaction(TransactionStatus::Submitted); + // is_canceled stays Some(false) from the helper. + + if let NetworkTransactionData::Evm(ref mut evm_data) = tx.network_data { + evm_data.hash = Some("0xNoopHash".to_string()); + evm_data.value = U256::from(0); + evm_data.data = Some("0x".to_string()); + evm_data.to = Some(evm_data.from.clone()); + } + + mocks + .provider + .expect_get_transaction_receipt() + .returning(|_| Box::pin(async { Ok(Some(make_mock_receipt(true, Some(100)))) })); + mocks + .provider + .expect_get_block_number() + .return_once(|| Box::pin(async { Ok(113) })); + mocks + .network_repo + .expect_get_by_chain_id() + .returning(|_, _| Ok(Some(create_test_network_model()))); + + let evm_transaction = make_test_evm_relayer_transaction(relayer, mocks); + + let status = evm_transaction.check_transaction_status(&tx).await.unwrap(); + assert_eq!(status, TransactionStatus::Confirmed); + } + #[tokio::test] async fn test_failed() { let mut mocks = default_test_mocks();