diff --git a/key-wallet-manager/src/event_tests.rs b/key-wallet-manager/src/event_tests.rs index 286896921..05d89c2f0 100644 --- a/key-wallet-manager/src/event_tests.rs +++ b/key-wallet-manager/src/event_tests.rs @@ -1283,3 +1283,59 @@ async fn test_block_processed_chainlocked_flag_matches_record_context() { assert!(matches!(inserted[0].context, TransactionContext::InBlock(_))); } } + +// --------------------------------------------------------------------------- +// In-block position stamping +// --------------------------------------------------------------------------- + +/// Records inserted by block processing must carry their `block.txdata` +/// index in `BlockInfo::position`, so consumers can replay Core's +/// same-block apply order (e.g. multiple provider special transactions +/// for one masternode in a single block resolve latest-wins by this +/// position in `RebuildListFromBlock`). +#[tokio::test] +async fn test_block_processing_stamps_in_block_position() { + let (mut manager, wallet_id, addr) = setup_manager_with_wallet(); + let mut rx = manager.subscribe_events(); + + let tx_first = create_tx_paying_to(&addr, 0xb1); + let tx_second = create_tx_paying_to(&addr, 0xb2); + let block = make_block(vec![tx_first.clone(), tx_second.clone()], 0xb1, 1500); + let wallets = BTreeSet::from([wallet_id]); + manager.process_block_for_wallets(&block, block.block_hash(), 300, &wallets).await; + + let events = drain_events(&mut rx); + let bp = events + .iter() + .find(|e| matches!(e, WalletEvent::BlockProcessed { .. })) + .expect("BlockProcessed expected"); + let WalletEvent::BlockProcessed { + inserted, + .. + } = bp + else { + unreachable!() + }; + assert_eq!(inserted.len(), 2, "both txs pay the wallet, got {inserted:?}"); + + let position_of = |txid| { + inserted + .iter() + .find(|r| r.txid == txid) + .expect("record for txid") + .context + .block_info() + .expect("confirmed record must carry block info") + .position() + }; + assert_eq!( + position_of(tx_first.txid()), + Some(0), + "first tx in block.txdata must be stamped position 0" + ); + assert_eq!( + position_of(tx_second.txid()), + Some(1), + "second tx in block.txdata must be stamped position 1" + ); +} diff --git a/key-wallet-manager/src/process_block.rs b/key-wallet-manager/src/process_block.rs index 1e2d511a7..11e59d74c 100644 --- a/key-wallet-manager/src/process_block.rs +++ b/key-wallet-manager/src/process_block.rs @@ -47,20 +47,22 @@ impl WalletInterface for WalletM && wallets.iter().filter_map(|wid| self.wallet_infos.get(wid)).all(|info| { info.last_applied_chain_lock().is_some_and(|cl| height <= cl.block_height) }); - let block_context = if all_chainlocked { - TransactionContext::InChainLockedBlock(info) - } else { - TransactionContext::InBlock(info) - }; - let mut per_wallet_inserted: BTreeMap> = BTreeMap::new(); let mut per_wallet_updated: BTreeMap> = BTreeMap::new(); let mut per_wallet_derived: BTreeMap> = BTreeMap::new(); - for tx in &block.txdata { - let check_result = self - .check_transaction_in_wallets(tx, block_context.clone(), wallets, true, false) - .await; + for (position, tx) in block.txdata.iter().enumerate() { + // Stamp each record with its `block.vtx` index so consumers + // can replay Core's same-block apply order (e.g. multiple + // provider updates for one masternode in a single block). + let tx_info = info.with_position(position as u32); + let block_context = if all_chainlocked { + TransactionContext::InChainLockedBlock(tx_info) + } else { + TransactionContext::InBlock(tx_info) + }; + let check_result = + self.check_transaction_in_wallets(tx, block_context, wallets, true, false).await; if !check_result.affected_wallets.is_empty() { if check_result.is_new_transaction { diff --git a/key-wallet/src/transaction_checking/transaction_context.rs b/key-wallet/src/transaction_checking/transaction_context.rs index 2377ecf19..b621532c3 100644 --- a/key-wallet/src/transaction_checking/transaction_context.rs +++ b/key-wallet/src/transaction_checking/transaction_context.rs @@ -11,6 +11,15 @@ pub struct BlockInfo { pub(crate) height: CoreBlockHeight, pub(crate) block_hash: BlockHash, pub(crate) timestamp: u32, + /// The transaction's index within `block.txdata` (`block.vtx` order), + /// when known. Same-block ordering matters for consumers that must + /// replay Core's apply-order semantics — e.g. multiple provider + /// special transactions for one masternode in a single block resolve + /// latest-wins by this position in `RebuildListFromBlock`. `None` + /// when the context was built without access to the containing block + /// (FFI-supplied contexts, legacy persisted records). + #[cfg_attr(feature = "serde", serde(default))] + pub(crate) position: Option, } impl BlockInfo { @@ -19,9 +28,19 @@ impl BlockInfo { height, block_hash, timestamp, + position: None, } } + /// Attach the transaction's in-block position (its index within + /// `block.txdata`). Chainlock promotion copies the whole `BlockInfo`, + /// so a position set at block-processing time survives context + /// transitions. + pub fn with_position(mut self, position: u32) -> Self { + self.position = Some(position); + self + } + pub fn height(&self) -> CoreBlockHeight { self.height } @@ -33,6 +52,12 @@ impl BlockInfo { pub fn timestamp(&self) -> u32 { self.timestamp } + + /// The transaction's index within its block (`block.vtx` order), when + /// it was recorded at block-processing time. + pub fn position(&self) -> Option { + self.position + } } /// Context for transaction processing