Skip to content
Merged
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
56 changes: 56 additions & 0 deletions key-wallet-manager/src/event_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"
);
}
22 changes: 12 additions & 10 deletions key-wallet-manager/src/process_block.rs
Original file line number Diff line number Diff line change
Expand Up @@ -47,20 +47,22 @@ impl<T: WalletInfoInterface + Send + Sync + 'static> 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<WalletId, Vec<TransactionRecord>> = BTreeMap::new();
let mut per_wallet_updated: BTreeMap<WalletId, Vec<TransactionRecord>> = BTreeMap::new();
let mut per_wallet_derived: BTreeMap<WalletId, Vec<DerivedAddressInfo>> = 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 {
Expand Down
25 changes: 25 additions & 0 deletions key-wallet/src/transaction_checking/transaction_context.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<u32>,
}

impl BlockInfo {
Expand All @@ -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
}
Expand All @@ -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<u32> {
self.position
}
}

/// Context for transaction processing
Expand Down
Loading