From f04042732a78bf3dc98d1aee7bf5b032e398010c Mon Sep 17 00:00:00 2001 From: SW van Heerden Date: Fri, 20 Aug 2021 20:00:32 +0200 Subject: [PATCH] fix: chain error caused by zero-conf transactions and reorgs (#3223) ## Description If a zero-conf transaction was in a block and the block is rewound. The block_chain backend will try to delete in input which was never marked as unspent as it was immediately spent. When we rewound this block we should check this and not try and delete an output that was never an unspent output on the chain. ## Motivation and Context ## How Has This Been Tested? ## Checklist: * [x] I'm merging against the `development` branch. * [x] I have squashed my commits into a single commit. --- .../core/src/chain_storage/lmdb_db/lmdb_db.rs | 71 +++++++++++-------- integration_tests/features/Reorgs.feature | 43 +++++++++++ 2 files changed, 85 insertions(+), 29 deletions(-) diff --git a/base_layer/core/src/chain_storage/lmdb_db/lmdb_db.rs b/base_layer/core/src/chain_storage/lmdb_db/lmdb_db.rs index 0e47058b03..7bb5908911 100644 --- a/base_layer/core/src/chain_storage/lmdb_db/lmdb_db.rs +++ b/base_layer/core/src/chain_storage/lmdb_db/lmdb_db.rs @@ -744,29 +744,63 @@ impl LMDBDatabase { "block_accumulated_data_db", )?; - let output_rows = - lmdb_delete_keys_starting_with::(&write_txn, &self.utxos_db, &hash_hex)?; + self.delete_block_inputs_outputs(write_txn, &hash_hex)?; + self.delete_block_kernels(write_txn, &hash_hex)?; + Ok(()) + } + + fn delete_block_inputs_outputs(&self, txn: &WriteTransaction<'_>, hash: &str) -> Result<(), ChainStorageError> { + let output_rows = lmdb_delete_keys_starting_with::(txn, &self.utxos_db, hash)?; debug!(target: LOG_TARGET, "Deleted {} outputs...", output_rows.len()); + let inputs = lmdb_delete_keys_starting_with::(txn, &self.inputs_db, hash)?; + debug!(target: LOG_TARGET, "Deleted {} input(s)...", inputs.len()); + for utxo in &output_rows { trace!(target: LOG_TARGET, "Deleting UTXO `{}`", to_hex(&utxo.hash)); lmdb_delete( - &write_txn, + txn, &self.txos_hash_to_index_db, utxo.hash.as_slice(), "txos_hash_to_index_db", )?; if let Some(ref output) = utxo.output { + let output_hash = output.hash(); + // if an output was already spent in the block, it was never created as unspent, so dont delete it as it + // does not exist here + if inputs.iter().any(|r| r.input.output_hash() == output_hash) { + continue; + } lmdb_delete( - &write_txn, + txn, &*self.utxo_commitment_index, output.commitment.as_bytes(), "utxo_commitment_index", )?; } } - let kernels = - lmdb_delete_keys_starting_with::(&write_txn, &self.kernels_db, &hash_hex)?; + // Move inputs in this block back into the unspent set, any outputs spent within this block they will be removed + // by deleting all the block's outputs below + for row in inputs { + // If input spends an output in this block, don't add it to the utxo set + let output_hash = row.input.output_hash(); + if output_rows.iter().any(|r| r.hash == output_hash) { + continue; + } + trace!(target: LOG_TARGET, "Input moved to UTXO set: {}", row.input); + lmdb_insert( + txn, + &*self.utxo_commitment_index, + row.input.commitment.as_bytes(), + &row.input.output_hash(), + "utxo_commitment_index", + )?; + } + Ok(()) + } + + fn delete_block_kernels(&self, txn: &WriteTransaction<'_>, hash: &str) -> Result<(), ChainStorageError> { + let kernels = lmdb_delete_keys_starting_with::(txn, &self.kernels_db, hash)?; debug!(target: LOG_TARGET, "Deleted {} kernels...", kernels.len()); for kernel in kernels { trace!( @@ -775,7 +809,7 @@ impl LMDBDatabase { kernel.kernel.excess.to_hex() ); lmdb_delete( - &write_txn, + txn, &self.kernel_excess_index, kernel.kernel.excess.as_bytes(), "kernel_excess_index", @@ -789,33 +823,12 @@ impl LMDBDatabase { to_hex(&excess_sig_key) ); lmdb_delete( - &write_txn, + txn, &self.kernel_excess_sig_index, excess_sig_key.as_slice(), "kernel_excess_sig_index", )?; } - - let inputs = lmdb_delete_keys_starting_with::(&write_txn, &self.inputs_db, &hash_hex)?; - debug!(target: LOG_TARGET, "Deleted {} input(s)...", inputs.len()); - // Move inputs in this block back into the unspent set, any outputs spent within this block they will be removed - // by deleting all the block's outputs below - for row in inputs { - // If input spends an output in this block, don't add it to the utxo set - let output_hash = row.input.output_hash(); - if output_rows.iter().any(|r| r.hash == output_hash) { - continue; - } - trace!(target: LOG_TARGET, "Input moved to UTXO set: {}", row.input); - lmdb_insert( - &write_txn, - &*self.utxo_commitment_index, - row.input.commitment.as_bytes(), - &row.input.output_hash(), - "utxo_commitment_index", - )?; - } - Ok(()) } diff --git a/integration_tests/features/Reorgs.feature b/integration_tests/features/Reorgs.feature index 218201b2a0..008df39e8e 100644 --- a/integration_tests/features/Reorgs.feature +++ b/integration_tests/features/Reorgs.feature @@ -94,6 +94,49 @@ Feature: Reorgs When I submit transaction TX2 to PNODE1 Then PNODE1 has TX2 in MEMPOOL state + @critical @reorg + Scenario: Zero-conf reorg with spending + Given I have a base node NODE1 connected to all seed nodes + Given I have a base node NODE2 connected to node NODE1 + When I mine 14 blocks on NODE1 + When I mine a block on NODE1 with coinbase CB1 + When I mine 4 blocks on NODE1 + When I create a custom fee transaction TX1 spending CB1 to UTX1 with fee 100 + When I create a custom fee transaction TX11 spending UTX1 to UTX11 with fee 100 + When I submit transaction TX1 to NODE1 + When I submit transaction TX11 to NODE1 + When I mine 1 blocks on NODE1 + Then NODE1 has TX1 in MINED state + And NODE1 has TX11 in MINED state + And all nodes are at height 20 + And I stop node NODE1 + And node NODE2 is at height 20 + When I mine a block on NODE2 with coinbase CB2 + When I mine 3 blocks on NODE2 + When I create a custom fee transaction TX2 spending CB2 to UTX2 with fee 100 + When I create a custom fee transaction TX21 spending UTX2 to UTX21 with fee 100 + When I submit transaction TX2 to NODE2 + When I submit transaction TX21 to NODE2 + When I mine 1 blocks on NODE2 + Then node NODE2 is at height 25 + And NODE2 has TX2 in MINED state + And NODE2 has TX21 in MINED state + And I stop node NODE2 + When I start base node NODE1 + And node NODE1 is at height 20 + When I mine a block on NODE1 with coinbase CB3 + When I mine 3 blocks on NODE1 + When I create a custom fee transaction TX3 spending CB3 to UTX3 with fee 100 + When I create a custom fee transaction TX31 spending UTX3 to UTX31 with fee 100 + When I submit transaction TX3 to NODE1 + When I submit transaction TX31 to NODE1 + When I mine 1 blocks on NODE1 + Then NODE1 has TX3 in MINED state + And NODE1 has TX31 in MINED state + And node NODE1 is at height 25 + When I start base node NODE2 + Then all nodes are on the same chain tip + Scenario Outline: Massive multiple reorg # # Chain 1a: