diff --git a/src/domain/transaction/evm/evm_transaction.rs b/src/domain/transaction/evm/evm_transaction.rs index 380faba0b..dd75f660d 100644 --- a/src/domain/transaction/evm/evm_transaction.rs +++ b/src/domain/transaction/evm/evm_transaction.rs @@ -1260,6 +1260,11 @@ where .await; } + // 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, @@ -1267,6 +1272,7 @@ where Some("Transaction canceled by user, replacing with NOOP".to_string()), ) .await?; + let updated_tx = self .transaction_repository() .partial_update(tx.id.clone(), update) @@ -2162,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; } @@ -2231,7 +2238,10 @@ mod tests { // Verify the cancellation transaction was properly created assert_eq!(cancelled_tx.id, "test-tx-id"); + // 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();