Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions src/domain/transaction/evm/evm_transaction.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1260,13 +1260,19 @@ 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,
true,
Some("Transaction canceled by user, replacing with NOOP".to_string()),
)
.await?;

let updated_tx = self
.transaction_repository()
.partial_update(tx.id.clone(), update)
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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 {
Expand Down
85 changes: 85 additions & 0 deletions src/domain/transaction/evm/status.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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!(
Expand Down Expand Up @@ -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();
Expand Down
Loading