diff --git a/libs/types-internal/src/event.rs b/libs/types-internal/src/event.rs index 5fb1eee6..b24c0261 100644 --- a/libs/types-internal/src/event.rs +++ b/libs/types-internal/src/event.rs @@ -105,6 +105,13 @@ pub enum EventType { /// The signature of the failed Solana transaction. signature: Signature, }, + /// A previously submitted Solana transaction has an expired blockhash + /// and a null on-chain status, meaning it will never be executed. + /// The transaction has been marked for resubmission. + ExpiredTransaction { + /// The signature of the expired Solana transaction. + signature: Signature, + }, } /// The purpose of a submitted Solana transaction. diff --git a/minter/cksol_minter.did b/minter/cksol_minter.did index 8a7fb080..1f9c876c 100644 --- a/minter/cksol_minter.did +++ b/minter/cksol_minter.did @@ -347,6 +347,13 @@ type EventType = variant { // The signature of the failed Solana transaction. signature: Signature; }; + // A previously submitted Solana transaction has an expired blockhash + // and a null on-chain status, meaning it will never be executed. + // The transaction has been marked for resubmission. + ExpiredTransaction : record { + // The signature of the expired Solana transaction. + signature: Signature; + }; }; // A single transaction can deposit to multiple accounts, so the signature alone diff --git a/minter/src/main.rs b/minter/src/main.rs index 5019a41a..c91bffcd 100644 --- a/minter/src/main.rs +++ b/minter/src/main.rs @@ -184,6 +184,9 @@ fn get_events( EventType::FailedTransaction { signature } => event::EventType::FailedTransaction { signature: signature.into(), }, + EventType::ExpiredTransaction { signature } => event::EventType::ExpiredTransaction { + signature: signature.into(), + }, } } diff --git a/minter/src/monitor/mod.rs b/minter/src/monitor/mod.rs index bf525bb8..413b1565 100644 --- a/minter/src/monitor/mod.rs +++ b/minter/src/monitor/mod.rs @@ -112,8 +112,18 @@ pub async fn monitor_submitted_transactions(runtime: R) { return; } + for signature in expired_signatures { + mutate_state(|state| { + // Skip if the transaction was finalized concurrently. + if state.submitted_transactions().contains_key(&signature) { + process_event(state, EventType::ExpiredTransaction { signature }, &runtime); + } + }); + } + let to_resubmit: Vec<_> = read_state(|state| { - expired_signatures + state + .transactions_to_resubmit() .iter() .filter_map(|sig| { state diff --git a/minter/src/monitor/tests.rs b/minter/src/monitor/tests.rs index d0561e51..cf170a12 100644 --- a/minter/src/monitor/tests.rs +++ b/minter/src/monitor/tests.rs @@ -260,11 +260,15 @@ mod resubmission { monitor_submitted_transactions(runtime).await; - EventsAssert::from_recorded().expect_contains_event_eq(EventType::ResubmittedTransaction { - old_signature, - new_signature, - new_slot: RESUBMISSION_SLOT, - }); + EventsAssert::from_recorded() + .expect_contains_event_eq(EventType::ExpiredTransaction { + signature: old_signature, + }) + .expect_contains_event_eq(EventType::ResubmittedTransaction { + old_signature, + new_signature, + new_slot: RESUBMISSION_SLOT, + }); read_state(|s| { assert_eq!(s.submitted_transactions().len(), 1); @@ -316,11 +320,15 @@ mod resubmission { monitor_submitted_transactions(runtime).await; - EventsAssert::from_recorded().expect_contains_event_eq(EventType::ResubmittedTransaction { - old_signature, - new_signature, - new_slot: RESUBMISSION_SLOT, - }); + EventsAssert::from_recorded() + .expect_contains_event_eq(EventType::ExpiredTransaction { + signature: old_signature, + }) + .expect_contains_event_eq(EventType::ResubmittedTransaction { + old_signature, + new_signature, + new_slot: RESUBMISSION_SLOT, + }); } #[tokio::test] @@ -355,7 +363,6 @@ mod resubmission { .add_signature([0xA0 + i as u8; 64]); } - // Round 2: get_recent_slot_and_blockhash for resubmission (getSlot + getBlock) runtime = runtime .add_stub_response(SlotResult::Consistent(Ok(RESUBMISSION_SLOT))) .add_stub_response(BlockResult::Consistent(Ok(confirmed_block()))); diff --git a/minter/src/state/audit.rs b/minter/src/state/audit.rs index 6fdeaaec..faa6bcd1 100644 --- a/minter/src/state/audit.rs +++ b/minter/src/state/audit.rs @@ -63,6 +63,9 @@ fn apply_state_transition(state: &mut State, payload: &EventType, timestamp: u64 EventType::FailedTransaction { signature } => { state.process_transaction_failed(signature); } + EventType::ExpiredTransaction { signature } => { + state.process_transaction_expired(signature); + } } } diff --git a/minter/src/state/event.rs b/minter/src/state/event.rs index 9a915fff..6de289ee 100644 --- a/minter/src/state/event.rs +++ b/minter/src/state/event.rs @@ -120,6 +120,15 @@ pub enum EventType { #[cbor(n(0), with = "cbor::signature")] signature: Signature, }, + /// A previously submitted Solana transaction has an expired blockhash + /// and a null on-chain status, meaning it will never be executed. + /// The transaction has been marked for resubmission. + #[n(10)] + ExpiredTransaction { + /// The signature of the expired Solana transaction. + #[cbor(n(0), with = "cbor::signature")] + signature: Signature, + }, } /// Payload of the `AcceptedWithdrawalRequest` event. diff --git a/minter/src/state/mod.rs b/minter/src/state/mod.rs index fa00a32f..3841bbf5 100644 --- a/minter/src/state/mod.rs +++ b/minter/src/state/mod.rs @@ -102,6 +102,7 @@ pub struct State { failed_withdrawal_requests: BTreeMap, deposits_to_consolidate: BTreeMap, submitted_transactions: BTreeMap, + transactions_to_resubmit: BTreeSet, succeeded_transactions: BTreeSet, failed_transactions: BTreeMap, consolidation_transactions: BTreeMap, @@ -201,6 +202,29 @@ impl State { &self.submitted_transactions } + pub fn transactions_to_resubmit(&self) -> &BTreeSet { + &self.transactions_to_resubmit + } + + pub fn process_transaction_expired(&mut self, signature: &Signature) { + assert!( + !self.succeeded_transactions.contains(signature), + "BUG: cannot mark already succeeded transaction {signature} for resubmission" + ); + assert!( + !self.failed_transactions.contains_key(signature), + "BUG: cannot mark already failed transaction {signature} for resubmission" + ); + assert!( + self.submitted_transactions.contains_key(signature), + "BUG: cannot mark non-submitted transaction {signature} for resubmission" + ); + assert!( + self.transactions_to_resubmit.insert(*signature), + "BUG: transaction {signature} is already queued for resubmission" + ); + } + pub fn succeeded_transactions(&self) -> &BTreeSet { &self.succeeded_transactions } @@ -591,6 +615,10 @@ impl State { None, "Attempted to resubmit transaction with signature {new_signature:?} that already exists" ); + assert!( + self.transactions_to_resubmit.remove(old_signature), + "BUG: transaction {old_signature} is not queued for resubmission" + ); if let Some(info) = self.consolidation_transactions.remove(old_signature) { self.consolidation_transactions.insert(*new_signature, info); } @@ -622,6 +650,10 @@ impl State { .checked_sub(tx_fee) .expect("BUG: consolidation amount is less than transaction fee"); } + assert!( + !self.transactions_to_resubmit.contains(signature), + "BUG: transaction {signature} is queued for resubmission but is being marked as succeeded" + ); assert!( self.succeeded_transactions.insert(*signature), "Attempted to mark transaction {signature:?} as succeeded twice" @@ -644,6 +676,10 @@ impl State { .unwrap_or_else(|| { panic!("Attempted to mark unknown transaction {signature:?} as failed") }); + assert!( + !self.transactions_to_resubmit.contains(signature), + "BUG: transaction {signature} is queued for resubmission but is being marked as failed" + ); assert_eq!( self.failed_transactions.insert(*signature, transaction), None, @@ -711,6 +747,7 @@ impl TryFrom for State { failed_withdrawal_requests: BTreeMap::new(), deposits_to_consolidate: BTreeMap::new(), submitted_transactions: BTreeMap::new(), + transactions_to_resubmit: BTreeSet::new(), succeeded_transactions: BTreeSet::new(), failed_transactions: BTreeMap::new(), consolidation_transactions: BTreeMap::new(), diff --git a/minter/src/state/tests.rs b/minter/src/state/tests.rs index 2752a96b..aceaa42e 100644 --- a/minter/src/state/tests.rs +++ b/minter/src/state/tests.rs @@ -8,8 +8,9 @@ use crate::{ arb::arb_event, deposit_id, events::{ - accept_deposit, accept_withdrawal, accept_withdrawal_at, fail_transaction, - mint_deposit, resubmit_transaction, submit_withdrawal, succeed_transaction, + accept_deposit, accept_withdrawal, accept_withdrawal_at, expire_transaction, + fail_transaction, mint_deposit, resubmit_transaction, submit_withdrawal, + succeed_transaction, }, init_balance, init_state, ledger_canister_id, runtime::TestCanisterRuntime, @@ -64,6 +65,7 @@ mod state_from_init_args { failed_withdrawal_requests: BTreeMap::new(), deposits_to_consolidate: BTreeMap::new(), submitted_transactions: BTreeMap::new(), + transactions_to_resubmit: BTreeSet::new(), succeeded_transactions: BTreeSet::new(), failed_transactions: BTreeMap::new(), consolidation_transactions: BTreeMap::new(), @@ -617,7 +619,8 @@ mod oldest_incomplete_withdrawal_created_at { Some(1_000_000_000) ); - // Resubmit the transaction with a new signature + // Expire then resubmit the transaction with a new signature + expire_transaction(signature(0xAA)); resubmit_transaction(signature(0xAA), signature(0xBB), 42); // created_at timestamps should be unchanged diff --git a/minter/src/test_fixtures/mod.rs b/minter/src/test_fixtures/mod.rs index e679c58b..47072fa2 100644 --- a/minter/src/test_fixtures/mod.rs +++ b/minter/src/test_fixtures/mod.rs @@ -294,6 +294,16 @@ pub mod events { }); } + pub fn expire_transaction(signature: Signature) { + mutate_state(|state| { + process_event( + state, + EventType::ExpiredTransaction { signature }, + &runtime(), + ) + }); + } + pub fn resubmit_transaction( old_signature: Signature, new_signature: Signature, @@ -558,6 +568,7 @@ pub mod arb { ), arb_signature().prop_map(|signature| EventType::SucceededTransaction { signature }), arb_signature().prop_map(|signature| EventType::FailedTransaction { signature }), + arb_signature().prop_map(|signature| EventType::ExpiredTransaction { signature }), ] }