diff --git a/daemon/test_daemon.go b/daemon/test_daemon.go index 4cf05448ab..746fe5a56b 100644 --- a/daemon/test_daemon.go +++ b/daemon/test_daemon.go @@ -879,7 +879,7 @@ func (td *TestDaemon) VerifyOnLongestChainInUtxoStore(t *testing.T, tx *bt.Tx) { func (td *TestDaemon) VerifyNotOnLongestChainInUtxoStore(t *testing.T, tx *bt.Tx) { readTx, err := td.UtxoStore.Get(td.Ctx, tx.TxIDChainHash(), fields.UnminedSince) require.NoError(t, err, "Failed to get transaction %s", tx.String()) - assert.Greater(t, readTx.UnminedSince, uint32(0), "Expected transaction %s to be on the longest chain", tx.TxIDChainHash().String()) + assert.Greater(t, readTx.UnminedSince, uint32(0), "Expected transaction %s to be not on the longest chain", tx.TxIDChainHash().String()) } // VerifyNotInUtxoStore verifies that the transaction does not exist in the UTXO store. @@ -1395,6 +1395,13 @@ finished: } func (td *TestDaemon) WaitForBlockStateChange(t *testing.T, expectedBlock *model.Block, timeout time.Duration) { + // First check if the expected block is already the current best block + state, err := td.BlockAssemblyClient.GetBlockAssemblyState(td.Ctx) + if err == nil && state.CurrentHash == expectedBlock.Header.Hash().String() { + t.Logf("Block %s (height %d) is already the current best block", expectedBlock.Header.Hash().String(), expectedBlock.Height) + return + } + stateChangeCh := make(chan blockassembly.BestBlockInfo) td.BlockAssembler.SetStateChangeCh(stateChangeCh) @@ -1412,6 +1419,7 @@ func (td *TestDaemon) WaitForBlockStateChange(t *testing.T, expectedBlock *model t.Fatalf("Timeout waiting for block assembly to reach block %s", expectedBlock.Header.Hash().String()) case bestBlockInfo := <-stateChangeCh: t.Logf("Received BestBlockInfo: Height=%d, Hash=%s", bestBlockInfo.Height, bestBlockInfo.Header.Hash().String()) + t.Logf("Expected block: Height=%d, Hash=%s", expectedBlock.Height, expectedBlock.Header.Hash().String()) if bestBlockInfo.Header.Hash().IsEqual(expectedBlock.Header.Hash()) { return } diff --git a/services/blockassembly/subtreeprocessor/SubtreeProcessor.go b/services/blockassembly/subtreeprocessor/SubtreeProcessor.go index f2ce69cb6c..c8cf1746df 100644 --- a/services/blockassembly/subtreeprocessor/SubtreeProcessor.go +++ b/services/blockassembly/subtreeprocessor/SubtreeProcessor.go @@ -559,11 +559,12 @@ func (stp *SubtreeProcessor) Start(ctx context.Context) { // create empty map for processed conflicting hashes processedConflictingHashesMap := make(map[chainhash.Hash]bool) - // store current state before attempting to move forward the block - originalChainedSubtrees := stp.chainedSubtrees - originalCurrentSubtree := stp.currentSubtree.Load() - originalCurrentTxMap := stp.currentTxMap - currentBlockHeader := stp.currentBlockHeader + if _, _, err = stp.moveForwardBlock(ctx, moveForwardReq.block, false, processedConflictingHashesMap, false, true); err != nil { + // rollback to previous state + stp.chainedSubtrees = originalChainedSubtrees + stp.currentSubtree = originalCurrentSubtree + stp.currentTxMap = originalCurrentTxMap + stp.currentBlockHeader = currentBlockHeader if _, err = stp.moveForwardBlock(processorCtx, moveForwardReq.block, false, processedConflictingHashesMap, false, true); err != nil { // rollback to previous state @@ -1956,42 +1957,106 @@ func (stp *SubtreeProcessor) reorgBlocks(ctx context.Context, moveBackBlocks []* var ( transactionMap txmap.TxMap + losingTxHashesMap txmap.TxMap markOnLongestChain = make([]chainhash.Hash, 0, 1024) + allLosingTxHashes = make([]chainhash.Hash, 0, 1024) ) + // Build a set of all transactions that will be mined in moveForward blocks + // We need this set BEFORE we start processing to correctly filter losing transactions + winningTxSet := make(map[chainhash.Hash]bool) + + // Temporary storage for losing transactions per block + type blockLosingTxs struct { + blockHash *chainhash.Hash + losingTxs []chainhash.Hash + } + blockLosingTxsList := make([]blockLosingTxs, 0, len(moveForwardBlocks)) + for blockIdx, block := range moveForwardBlocks { lastMoveForwardBlock := blockIdx == len(moveForwardBlocks)-1 // we skip the notifications for now and do them all at the end // transactionMap is returned so we can check which transactions need to be marked as on the longest chain - if transactionMap, err = stp.moveForwardBlock(ctx, block, true, processedConflictingHashesMap, true, lastMoveForwardBlock); err != nil { + // losingTxHashesMap contains transactions that lost conflicts and need to be marked as NOT on longest chain + if transactionMap, losingTxHashesMap, err = stp.moveForwardBlock(ctx, block, true, processedConflictingHashesMap, true, lastMoveForwardBlock); err != nil { return err } + // Process transactionMap: build winningTxSet and markOnLongestChain if transactionMap != nil { transactionMap.Iter(func(hash chainhash.Hash, n uint64) bool { - // if the transaction is not in the movedBackBlockTxMap, it means it was not part of the blocks we moved back - // and therefore needs to be marked in the utxo store as on the longest chain now - // since it was on the block moving forward - if !hash.Equal(subtreepkg.CoinbasePlaceholderHashValue) && !movedBackBlockTxMap[hash] { - markOnLongestChain = append(markOnLongestChain, hash) + if !hash.Equal(subtreepkg.CoinbasePlaceholderHashValue) { + // Track as winning transaction + winningTxSet[hash] = true + + // Add to markOnLongestChain if not in movedBack blocks + if !movedBackBlockTxMap[hash] { + markOnLongestChain = append(markOnLongestChain, hash) + } } + return true + }) + } + // Store losing transactions for later processing + // We can't process them yet because we need the complete winningTxSet from all blocks + if losingTxHashesMap != nil && losingTxHashesMap.Length() > 0 { + losingTxs := make([]chainhash.Hash, 0, losingTxHashesMap.Length()) + losingTxHashesMap.Iter(func(hash chainhash.Hash, _ uint64) bool { + losingTxs = append(losingTxs, hash) return true }) + blockLosingTxsList = append(blockLosingTxsList, blockLosingTxs{ + blockHash: block.Hash(), + losingTxs: losingTxs, + }) } stp.currentBlockHeader = block.Header } + // Second pass: filter losing transactions using the complete winningTxSet + for _, blockLosing := range blockLosingTxsList { + for _, hash := range blockLosing.losingTxs { + // Only add to losing list if it's not a winning transaction in ANY moveForward block + if !winningTxSet[hash] { + allLosingTxHashes = append(allLosingTxHashes, hash) + } + } + } + movedBackBlockTxMap = nil // free up memory + // Build a set of all losing transactions for fast lookup + losingTxSet := make(map[chainhash.Hash]bool, len(allLosingTxHashes)) + for _, hash := range allLosingTxHashes { + losingTxSet[hash] = true + } + + // Remove losing transactions from markOnLongestChain + // A transaction that loses a conflict in a later block should NOT be marked as on longest chain + // even if it appeared in an earlier block + filteredMarkOnLongestChain := make([]chainhash.Hash, 0, len(markOnLongestChain)) + for _, hash := range markOnLongestChain { + if !losingTxSet[hash] { + filteredMarkOnLongestChain = append(filteredMarkOnLongestChain, hash) + } + } + // all the transactions in markOnLongestChain need to be marked as on the longest chain in the utxo store - if len(markOnLongestChain) > 0 { - if err = stp.utxoStore.MarkTransactionsOnLongestChain(ctx, markOnLongestChain, true); err != nil { + if len(filteredMarkOnLongestChain) > 0 { + if err = stp.utxoStore.MarkTransactionsOnLongestChain(ctx, filteredMarkOnLongestChain, true); err != nil { return errors.NewProcessingError("[reorgBlocks] error marking transactions as on longest chain in utxo store", err) } } + // Mark all losing conflicting transactions as NOT on longest chain + if len(allLosingTxHashes) > 0 { + if err = stp.utxoStore.MarkTransactionsOnLongestChain(ctx, allLosingTxHashes, false); err != nil { + return errors.NewProcessingError("[reorgBlocks] error marking losing conflicting transactions as not on longest chain in utxo store", err) + } + } + // everything now in block assembly is not mined on the longest chain // so we need to set the unminedSince for all transactions in block assembly for _, subtree := range stp.chainedSubtrees { @@ -2733,9 +2798,9 @@ func (stp *SubtreeProcessor) finalizeBlockProcessing(ctx context.Context, block // moveForwardBlock cleans out all transactions that are in the current subtrees and also in the block // given. It is akin to moving up the blockchain to the next block. func (stp *SubtreeProcessor) moveForwardBlock(ctx context.Context, block *model.Block, skipNotification bool, - processedConflictingHashesMap map[chainhash.Hash]bool, skipDequeue bool, createProperlySizedSubtrees bool) (transactionMap txmap.TxMap, err error) { + processedConflictingHashesMap map[chainhash.Hash]bool, skipDequeue bool, createProperlySizedSubtrees bool) (transactionMap txmap.TxMap, losingTxHashesMap txmap.TxMap, err error) { if block == nil { - return nil, errors.NewProcessingError("[moveForwardBlock] you must pass in a block to moveForwardBlock") + return nil, nil, errors.NewProcessingError("[moveForwardBlock] you must pass in a block to moveForwardBlock") } _, _, deferFn := tracing.Tracer("subtreeprocessor").Start(ctx, "moveForwardBlock", @@ -2750,7 +2815,7 @@ func (stp *SubtreeProcessor) moveForwardBlock(ctx context.Context, block *model. }() if !block.Header.HashPrevBlock.IsEqual(stp.currentBlockHeader.Hash()) { - return nil, errors.NewProcessingError("the block passed in does not match the current block header: [%s] - [%s]", block.Header.StringDump(), stp.currentBlockHeader.StringDump()) + return nil, nil, errors.NewProcessingError("the block passed in does not match the current block header: [%s] - [%s]", block.Header.StringDump(), stp.currentBlockHeader.StringDump()) } stp.logger.Debugf("[moveForwardBlock][%s] resetting subtrees: %v", block.String(), block.Subtrees) @@ -2763,13 +2828,13 @@ func (stp *SubtreeProcessor) moveForwardBlock(ctx context.Context, block *model. // Create transaction map from remaining block subtrees transactionMap, conflictingNodes, err = stp.createTransactionMapIfNeeded(ctx, block, blockSubtreesMap) if err != nil { - return nil, err + return nil, nil, err } // Process conflicting transactions - losingTxHashesMap, err := stp.processConflictingTransactions(ctx, block, conflictingNodes, processedConflictingHashesMap) + losingTxHashesMap, err = stp.processConflictingTransactions(ctx, block, conflictingNodes, processedConflictingHashesMap) if err != nil { - return nil, err + return nil, nil, err } originalCurrentSubtree := stp.currentSubtree.Load() @@ -2777,7 +2842,7 @@ func (stp *SubtreeProcessor) moveForwardBlock(ctx context.Context, block *model. // Reset subtree state if err = stp.resetSubtreeState(createProperlySizedSubtrees); err != nil { - return nil, errors.NewProcessingError("[moveForwardBlock][%s] error resetting subtree state", block.String(), err) + return nil, nil, errors.NewProcessingError("[moveForwardBlock][%s] error resetting subtree state", block.String(), err) } // Process remainder transactions and dequeueDuringBlockMovement @@ -2792,12 +2857,12 @@ func (stp *SubtreeProcessor) moveForwardBlock(ctx context.Context, block *model. SkipNotification: skipNotification, }) if err != nil { - return nil, err + return nil, nil, err } // create the coinbase after processing all other transaction operations if err = stp.processCoinbaseUtxos(ctx, block); err != nil { - return nil, errors.NewProcessingError("[moveForwardBlock][%s] error processing coinbase utxos", block.String(), err) + return nil, nil, errors.NewProcessingError("[moveForwardBlock][%s] error processing coinbase utxos", block.String(), err) } // Log memory stats after block processing if debug logging is enabled @@ -2818,7 +2883,7 @@ func (stp *SubtreeProcessor) moveForwardBlock(ctx context.Context, block *model. } } - return transactionMap, nil + return transactionMap, losingTxHashesMap, nil } func (stp *SubtreeProcessor) waitForBlockBeingMined(ctx context.Context, blockHash *chainhash.Hash) (bool, error) { diff --git a/services/blockassembly/subtreeprocessor/SubtreeProcessor_test.go b/services/blockassembly/subtreeprocessor/SubtreeProcessor_test.go index a28bd7b868..ed019f897b 100644 --- a/services/blockassembly/subtreeprocessor/SubtreeProcessor_test.go +++ b/services/blockassembly/subtreeprocessor/SubtreeProcessor_test.go @@ -3345,7 +3345,7 @@ func TestMoveForwardBlock_BlockHeaderValidation(t *testing.T) { } // moveForwardBlock should fail with parent mismatch - _, err := stp.moveForwardBlock(context.Background(), invalidBlock, false, map[chainhash.Hash]bool{}, false, true) + _, _, err := stp.moveForwardBlock(context.Background(), invalidBlock, false, map[chainhash.Hash]bool{}, false, true) require.Error(t, err) assert.Contains(t, err.Error(), "does not match the current block header") }) diff --git a/test/sequentialtest/longest_chain/03_longest_chain_invalidate_fork_test.go b/test/sequentialtest/longest_chain/03_longest_chain_invalidate_fork_test.go new file mode 100644 index 0000000000..85029f6c64 --- /dev/null +++ b/test/sequentialtest/longest_chain/03_longest_chain_invalidate_fork_test.go @@ -0,0 +1,139 @@ +package longest_chain + +import ( + "testing" + + "github.com/bsv-blockchain/teranode/test/utils/aerospike" + "github.com/bsv-blockchain/teranode/test/utils/transactions" + "github.com/stretchr/testify/require" +) + +func TestLongestChainAerospikeInvalidateFork(t *testing.T) { + // start an aerospike container + utxoStore, teardown, err := aerospike.InitAerospikeContainer() + require.NoError(t, err) + + t.Cleanup(func() { + _ = teardown() + }) + + t.Run("invalid block with old tx", func(t *testing.T) { + testLongestChainInvalidateFork(t, utxoStore) + }) +} + +func testLongestChainInvalidateFork(t *testing.T, utxoStore string) { + // Setup test environment + td, block3 := setupLongestChainTest(t, utxoStore) + defer func() { + td.Stop(t) + }() + + td.Settings.BlockValidation.OptimisticMining = true + + block1, err := td.BlockchainClient.GetBlockByHeight(td.Ctx, 1) + require.NoError(t, err) + + parentTxWith3Outputs := td.CreateTransactionWithOptions(t, transactions.WithInput(block1.CoinbaseTx, 0), transactions.WithP2PKHOutputs(3, 100000)) + require.NoError(t, td.PropagationClient.ProcessTransaction(td.Ctx, parentTxWith3Outputs)) + + childTx1 := td.CreateTransactionWithOptions(t, transactions.WithInput(parentTxWith3Outputs, 0), transactions.WithP2PKHOutputs(1, 100000)) + childTx2 := td.CreateTransactionWithOptions(t, transactions.WithInput(parentTxWith3Outputs, 1), transactions.WithP2PKHOutputs(1, 100000)) + childTx3 := td.CreateTransactionWithOptions(t, transactions.WithInput(parentTxWith3Outputs, 2), transactions.WithP2PKHOutputs(1, 100000)) + // create a double spend of tx3 + childTx3DS := td.CreateTransactionWithOptions(t, transactions.WithInput(parentTxWith3Outputs, 2), transactions.WithP2PKHOutputs(2, 50000)) + + require.NoError(t, td.PropagationClient.ProcessTransaction(td.Ctx, childTx1)) + require.NoError(t, td.PropagationClient.ProcessTransaction(td.Ctx, childTx2)) + require.NoError(t, td.PropagationClient.ProcessTransaction(td.Ctx, childTx3)) + + _, block4a := td.CreateTestBlock(t, block3, 4001, parentTxWith3Outputs, childTx1, childTx2) + require.NoError(t, td.BlockValidation.ValidateBlock(td.Ctx, block4a, "legacy", nil, false, true), "Failed to process block") + td.WaitForBlockBeingMined(t, block4a) + t.Logf("WaitForBlock(t, block4a, blockWait): %s", block4a.Hash().String()) + td.WaitForBlock(t, block4a, blockWait) + + // 0 -> 1 ... 2 -> 3 -> 4a (*) + + td.VerifyNotInBlockAssembly(t, parentTxWith3Outputs) + td.VerifyNotInBlockAssembly(t, childTx1) + td.VerifyNotInBlockAssembly(t, childTx2) + td.VerifyInBlockAssembly(t, childTx3) + td.VerifyOnLongestChainInUtxoStore(t, parentTxWith3Outputs) + td.VerifyOnLongestChainInUtxoStore(t, childTx1) + td.VerifyOnLongestChainInUtxoStore(t, childTx2) + td.VerifyNotOnLongestChainInUtxoStore(t, childTx3) + + // create a block with tx1 and tx2 that will be invalid as tx2 is already on block4a + _, block4b := td.CreateTestBlock(t, block3, 4002, parentTxWith3Outputs, childTx2, childTx3) + require.NoError(t, td.BlockValidation.ValidateBlock(td.Ctx, block4b, "legacy", nil, true), "Failed to process block") + t.Logf("WaitForBlockBeingMined(t, block4b): %s", block4b.Hash().String()) + td.WaitForBlockBeingMined(t, block4b) + + _, block5b := td.CreateTestBlock(t, block4b, 5001) + require.NoError(t, td.BlockValidation.ValidateBlock(td.Ctx, block5b, "legacy", nil, true), "Failed to process block") + t.Logf("WaitForBlockBeingMined(t, block5b): %s", block5b.Hash().String()) + td.WaitForBlockBeingMined(t, block5b) + t.Logf("WaitForBlock(t, block5b, blockWait): %s", block5b.Hash().String()) + td.WaitForBlock(t, block5b, blockWait) + + // 0 -> 1 ... 2 -> 3 -> 4b -> 5b (*) + td.VerifyInBlockAssembly(t, childTx1) + td.VerifyNotInBlockAssembly(t, childTx2) + td.VerifyNotInBlockAssembly(t, childTx3) + td.VerifyNotOnLongestChainInUtxoStore(t, childTx1) + td.VerifyOnLongestChainInUtxoStore(t, childTx2) + td.VerifyOnLongestChainInUtxoStore(t, childTx3) + + _, err = td.BlockchainClient.InvalidateBlock(t.Context(), block5b.Hash()) + require.NoError(t, err) + + td.WaitForBlock(t, block4a, blockWait) + + // 0 -> 1 ... 2 -> 3 -> 4a + + td.VerifyNotInBlockAssembly(t, childTx1) + td.VerifyNotInBlockAssembly(t, childTx2) + td.VerifyInBlockAssembly(t, childTx3) + td.VerifyOnLongestChainInUtxoStore(t, childTx1) + td.VerifyOnLongestChainInUtxoStore(t, childTx2) + td.VerifyNotOnLongestChainInUtxoStore(t, childTx3) + + // create a new block on 4a with tx3 in it + _, block5a := td.CreateTestBlock(t, block4a, 6001, childTx3DS) + require.NoError(t, td.BlockValidation.ValidateBlock(td.Ctx, block5a, "legacy", nil, false, true), "Failed to process block") + t.Logf("WaitForBlockBeingMined(t, block5a): %s", block5a.Hash().String()) + td.WaitForBlockBeingMined(t, block5a) + t.Logf("WaitForBlock(t, block5a, blockWait): %s", block5a.Hash().String()) + td.WaitForBlock(t, block5a, blockWait) + + td.VerifyNotInBlockAssembly(t, childTx1) + td.VerifyNotInBlockAssembly(t, childTx2) + td.VerifyInBlockAssembly(t, childTx3) + td.VerifyNotInBlockAssembly(t, childTx3DS) + td.VerifyOnLongestChainInUtxoStore(t, childTx1) + td.VerifyOnLongestChainInUtxoStore(t, childTx2) + td.VerifyNotOnLongestChainInUtxoStore(t, childTx3) + td.VerifyOnLongestChainInUtxoStore(t, childTx3DS) // 0 -> 1 ... 2 -> 3 -> 4a -> 6a (*) + + _, err = td.BlockchainClient.InvalidateBlock(t.Context(), block4b.Hash()) + require.NoError(t, err) + + // create a new block on 5a with tx3 in it + _, block6a := td.CreateTestBlock(t, block5a, 7001, childTx3) + require.NoError(t, td.BlockValidation.ValidateBlock(td.Ctx, block6a, "legacy", nil, false, true), "Failed to process block") + t.Logf("WaitForBlockBeingMined(t, block6a): %s", block6a.Hash().String()) + td.WaitForBlockBeingMined(t, block6a) + t.Logf("WaitForBlock(t, block6a, blockWait): %s", block6a.Hash().String()) + td.WaitForBlock(t, block6a, blockWait) + + t.Logf("FINAL VERIFICATIONS:") + td.VerifyNotInBlockAssembly(t, childTx1) + td.VerifyNotInBlockAssembly(t, childTx2) + td.VerifyNotInBlockAssembly(t, childTx3) + td.VerifyNotInBlockAssembly(t, childTx3DS) + td.VerifyOnLongestChainInUtxoStore(t, childTx1) + td.VerifyOnLongestChainInUtxoStore(t, childTx2) + td.VerifyOnLongestChainInUtxoStore(t, childTx3) + td.VerifyOnLongestChainInUtxoStore(t, childTx3DS) +} diff --git a/test/sequentialtest/longest_chain/helpers.go b/test/sequentialtest/longest_chain/helpers.go index b05657a535..97378ef3e2 100644 --- a/test/sequentialtest/longest_chain/helpers.go +++ b/test/sequentialtest/longest_chain/helpers.go @@ -13,7 +13,7 @@ import ( ) var ( - blockWait = 5 * time.Second + blockWait = 30 * time.Second ) func setupLongestChainTest(t *testing.T, utxoStoreType string) (td *daemon.TestDaemon, block3 *model.Block) { diff --git a/test/sequentialtest/longest_chain/longest_chain_test.go b/test/sequentialtest/longest_chain/longest_chain_test.go index c270c1c01a..47efd7e44e 100644 --- a/test/sequentialtest/longest_chain/longest_chain_test.go +++ b/test/sequentialtest/longest_chain/longest_chain_test.go @@ -20,6 +20,18 @@ func TestLongestChainPostgres(t *testing.T) { t.Skip() testLongestChainInvalidateBlockWithOldTx(t, "postgres") }) + + t.Run("fork with different tx inclusion", func(t *testing.T) { + testLongestChainForkDifferentTxInclusion(t, utxoStore) + }) + + t.Run("transaction chain dependency", func(t *testing.T) { + testLongestChainTransactionChainDependency(t, utxoStore) + }) + + t.Run("partial output consumption", func(t *testing.T) { + testLongestChainWithDoubleSpendTransaction(t, utxoStore) + }) } func TestLongestChainAerospike(t *testing.T) { @@ -34,6 +46,14 @@ func TestLongestChainAerospike(t *testing.T) { t.Run("invalid block with old tx", func(t *testing.T) { testLongestChainInvalidateBlockWithOldTx(t, "aerospike") }) + + t.Run("fork with different tx inclusion", func(t *testing.T) { + testLongestChainForkDifferentTxInclusion(t, utxoStore) + }) + + t.Run("transaction chain dependency", func(t *testing.T) { + testLongestChainWithDoubleSpendTransaction(t, utxoStore) + }) } func testLongestChainSimple(t *testing.T, utxoStore string) { @@ -210,3 +230,286 @@ func testLongestChainInvalidateBlockWithOldTx(t *testing.T, utxoStore string) { td.VerifyNotOnLongestChainInUtxoStore(t, tx1) td.VerifyOnLongestChainInUtxoStore(t, tx2) } + +func testLongestChainForkDifferentTxInclusion(t *testing.T, utxoStore string) { + // Setup test environment + td, block3 := setupLongestChainTest(t, utxoStore) + defer func() { + td.Stop(t) + }() + + block1, err := td.BlockchainClient.GetBlockByHeight(td.Ctx, 1) + require.NoError(t, err) + + block2, err := td.BlockchainClient.GetBlockByHeight(td.Ctx, 2) + require.NoError(t, err) + + // Create two transactions + tx1 := td.CreateTransaction(t, block1.CoinbaseTx, 0) + require.NoError(t, td.PropagationClient.ProcessTransaction(td.Ctx, tx1)) + + tx2 := td.CreateTransaction(t, block2.CoinbaseTx, 0) + require.NoError(t, td.PropagationClient.ProcessTransaction(td.Ctx, tx2)) + + td.VerifyInBlockAssembly(t, tx1) + td.VerifyInBlockAssembly(t, tx2) + + // Fork A: Create block4a with only tx1 + _, block4a := td.CreateTestBlock(t, block3, 4001, tx1) + require.NoError(t, td.BlockValidation.ValidateBlock(td.Ctx, block4a, "legacy", nil, false), "Failed to process block") + td.WaitForBlock(t, block4a, blockWait) + td.WaitForBlockBeingMined(t, block4a) + + // 0 -> 1 ... 2 -> 3 -> 4a (*) + + td.VerifyNotInBlockAssembly(t, tx1) // mined and removed from block assembly + td.VerifyInBlockAssembly(t, tx2) // not mined yet + td.VerifyOnLongestChainInUtxoStore(t, tx1) + td.VerifyNotOnLongestChainInUtxoStore(t, tx2) + + // Fork B: Create block4b with both tx1 and tx2 + _, block4b := td.CreateTestBlock(t, block3, 4002, tx1, tx2) + require.NoError(t, td.BlockValidation.ValidateBlock(td.Ctx, block4b, "legacy", nil, false), "Failed to process block") + td.WaitForBlockBeingMined(t, block4b) + + time.Sleep(1 * time.Second) // give some time for the block to be processed + + // / 4a (*) + // 0 -> 1 ... 2 -> 3 + // \ 4b + + // Still on fork A, so tx1 is mined, tx2 is not + td.VerifyNotInBlockAssembly(t, tx1) // mined in fork A + td.VerifyInBlockAssembly(t, tx2) // not on longest chain yet + td.VerifyOnLongestChainInUtxoStore(t, tx1) + td.VerifyNotOnLongestChainInUtxoStore(t, tx2) + + // Make fork B longer by adding block5b + _, block5b := td.CreateTestBlock(t, block4b, 5002) // empty block + require.NoError(t, td.BlockValidation.ValidateBlock(td.Ctx, block5b, "legacy", nil, false, false), "Failed to process block") + td.WaitForBlock(t, block5b, blockWait) + td.WaitForBlockBeingMined(t, block5b) + + // / 4a + // 0 -> 1 ... 2 -> 3 + // \ 4b -> 5b (*) + + // Now fork B is longest, both tx1 and tx2 are mined in block4b + td.VerifyNotInBlockAssembly(t, tx1) // mined in fork B (block4b) + td.VerifyNotInBlockAssembly(t, tx2) // mined in fork B (block4b) + td.VerifyOnLongestChainInUtxoStore(t, tx1) + td.VerifyOnLongestChainInUtxoStore(t, tx2) +} + +func testLongestChainTransactionChainDependency(t *testing.T, utxoStore string) { + // Scenario: Parent-child transaction chain where parent gets invalidated in reorg + // Fork A: Block4a contains tx1 (creates multiple outputs) + // Mempool: tx2 spends output from tx1, tx3 spends output from tx2 + // Fork B becomes longest without tx1 + // All dependent transactions (tx2, tx3) should be removed from mempool + + td, block3 := setupLongestChainTest(t, utxoStore) + defer func() { + td.Stop(t) + }() + + block1, err := td.BlockchainClient.GetBlockByHeight(td.Ctx, 1) + require.NoError(t, err) + + block2, err := td.BlockchainClient.GetBlockByHeight(td.Ctx, 2) + require.NoError(t, err) + + // Create parent transaction with multiple outputs (explicitly 5 outputs) + // This ensures we have enough outputs for child and grandchild transactions to spend + tx1, err := td.CreateParentTransactionWithNOutputs(t, block1.CoinbaseTx, 5) + require.NoError(t, err) + td.VerifyInBlockAssembly(t, tx1) + t.Logf("tx1 created with %d outputs", len(tx1.Outputs)) + + // Mine tx1 in Fork A + _, block4a := td.CreateTestBlock(t, block3, 4001, tx1) + require.NoError(t, td.BlockValidation.ValidateBlock(td.Ctx, block4a, "legacy", nil, false), "Failed to process block") + td.WaitForBlock(t, block4a, blockWait) + td.WaitForBlockBeingMined(t, block4a) + + // 0 -> 1 ... 2 -> 3 -> 4a (*) + + td.VerifyNotInBlockAssembly(t, tx1) + td.VerifyOnLongestChainInUtxoStore(t, tx1) + + // Create child transaction (tx2) spending output from tx1 + tx2 := td.CreateTransaction(t, tx1, 0) + require.NoError(t, td.PropagationClient.ProcessTransaction(td.Ctx, tx2)) + td.VerifyInBlockAssembly(t, tx2) + + // Create grandchild transaction (tx3) spending output from tx2 + tx3 := td.CreateTransaction(t, tx2, 0) + require.NoError(t, td.PropagationClient.ProcessTransaction(td.Ctx, tx3)) + td.VerifyInBlockAssembly(t, tx3) + + // Create competing Fork B without tx1 + altTx := td.CreateTransaction(t, block2.CoinbaseTx, 0) + require.NoError(t, td.PropagationClient.ProcessTransaction(td.Ctx, altTx)) + + _, block4b := td.CreateTestBlock(t, block3, 4002, altTx) + require.NoError(t, td.BlockValidation.ValidateBlock(td.Ctx, block4b, "legacy", nil, false), "Failed to process block") + td.WaitForBlockBeingMined(t, block4b) + + time.Sleep(1 * time.Second) + + // / 4a (*) [contains tx1] + // 0 -> 1 ... 2 -> 3 + // \ 4b [contains altTx, no tx1] + + // Still on Fork A, all transactions should be in expected state + td.VerifyNotInBlockAssembly(t, tx1) + td.VerifyInBlockAssembly(t, tx2) + td.VerifyInBlockAssembly(t, tx3) + + // Make Fork B longer + _, block5b := td.CreateTestBlock(t, block4b, 5002) + require.NoError(t, td.BlockValidation.ValidateBlock(td.Ctx, block5b, "legacy", nil, false, false), "Failed to process block") + td.WaitForBlock(t, block5b, blockWait) + td.WaitForBlockBeingMined(t, block5b) + + // / 4a [contains tx1] + // 0 -> 1 ... 2 -> 3 + // \ 4b -> 5b (*) [no tx1] + + // Now Fork B is longest, tx1 should return to mempool + // tx2 and tx3 should be removed as their parent (tx1) outputs are not on longest chain + td.VerifyInBlockAssembly(t, tx1) // back in mempool + + // tx2 and tx3 depend on tx1's outputs which are not on the longest chain + // They should NOT be in block assembly as they're invalid + td.VerifyInBlockAssembly(t, tx2) + td.VerifyInBlockAssembly(t, tx3) + + td.VerifyNotOnLongestChainInUtxoStore(t, tx1) + td.VerifyNotOnLongestChainInUtxoStore(t, tx2) + td.VerifyNotOnLongestChainInUtxoStore(t, tx3) +} + +func testLongestChainWithDoubleSpendTransaction(t *testing.T, utxoStore string) { + // Scenario: Transaction with multiple outputs gets consumed differently across forks + // Parent tx creates multiple outputs [O1, O2, O3] + // Fork A: Contains tx1 spending O1 and tx2 spending O2 + // Fork B: Contains tx3 spending all outputs [O2, O3] + + td, block3 := setupLongestChainTest(t, utxoStore) + defer func() { + td.Stop(t) + }() + + block1, err := td.BlockchainClient.GetBlockByHeight(td.Ctx, 1) + require.NoError(t, err) + + block2, err := td.BlockchainClient.GetBlockByHeight(td.Ctx, 2) + require.NoError(t, err) + + // Create parent transaction with multiple outputs (at least 3) + parentTx, err := td.CreateParentTransactionWithNOutputs(t, block1.CoinbaseTx, 4) + require.NoError(t, err) + + // Mine parentTx first so we have confirmed UTXOs + _, block4 := td.CreateTestBlock(t, block3, 4000, parentTx) + require.NoError(t, td.BlockValidation.ValidateBlock(td.Ctx, block4, "legacy", nil, false), "Failed to process block") + t.Logf("WaitForBlockBeingMined(t, block4): %s", block4.Hash().String()) + td.WaitForBlockBeingMined(t, block4) + t.Logf("WaitForBlock(t, block4, blockWait): %s", block4.Hash().String()) + td.WaitForBlock(t, block4, blockWait) + t.Logf("VerifyNotInBlockAssembly(t, parentTx): %s", parentTx.TxIDChainHash().String()) + td.VerifyNotInBlockAssembly(t, parentTx) + t.Logf("VerifyOnLongestChainInUtxoStore(t, parentTx): %s", parentTx.TxIDChainHash().String()) + td.VerifyOnLongestChainInUtxoStore(t, parentTx) + + // 0 -> 1 ... 2 -> 3 -> 4 (*) + + // Create transactions spending individual outputs + tx1 := td.CreateTransaction(t, parentTx, 0) // spends output 0 + require.NoError(t, td.PropagationClient.ProcessTransaction(td.Ctx, tx1)) + + tx2 := td.CreateTransaction(t, parentTx, 1) // spends output 1 + require.NoError(t, td.PropagationClient.ProcessTransaction(td.Ctx, tx2)) + + // Create parentTx2 early so it can be mined in both forks + parentTx2 := td.CreateTransaction(t, block2.CoinbaseTx, 0) + require.NoError(t, td.PropagationClient.ProcessTransaction(td.Ctx, parentTx2)) + + td.VerifyInBlockAssembly(t, tx1) + td.VerifyInBlockAssembly(t, tx2) + td.VerifyInBlockAssembly(t, parentTx2) + + // Fork A: Mine tx1, tx2, and parentTx2 + _, block5a := td.CreateTestBlock(t, block4, 5001, tx1, tx2, parentTx2) + require.NoError(t, td.BlockValidation.ValidateBlock(td.Ctx, block5a, "legacy", nil, false), "Failed to process block") + t.Logf("WaitForBlockBeingMined(t, block5a): %s", block5a.Hash().String()) + td.WaitForBlockBeingMined(t, block5a) + t.Logf("WaitForBlock(t, block5a, blockWait): %s", block5a.Hash().String()) + td.WaitForBlock(t, block5a, blockWait) + + // 0 -> 1 ... 2 -> 3 -> 4 -> 5a (*) [tx1, tx2, parentTx2] + + t.Logf("VerifyNotInBlockAssembly(t, tx1): %s", tx1.TxIDChainHash().String()) + td.VerifyNotInBlockAssembly(t, tx1) + t.Logf("VerifyNotInBlockAssembly(t, tx2): %s", tx2.TxIDChainHash().String()) + td.VerifyNotInBlockAssembly(t, tx2) + t.Logf("VerifyNotInBlockAssembly(t, parentTx2): %s", parentTx2.TxIDChainHash().String()) + td.VerifyNotInBlockAssembly(t, parentTx2) + t.Logf("VerifyOnLongestChainInUtxoStore(t, tx1): %s", tx1.TxIDChainHash().String()) + td.VerifyOnLongestChainInUtxoStore(t, tx1) + t.Logf("VerifyOnLongestChainInUtxoStore(t, tx2): %s", tx2.TxIDChainHash().String()) + td.VerifyOnLongestChainInUtxoStore(t, tx2) + t.Logf("VerifyOnLongestChainInUtxoStore(t, parentTx2): %s", parentTx2.TxIDChainHash().String()) + td.VerifyOnLongestChainInUtxoStore(t, parentTx2) + + // Fork B: Create a transaction that spends output 3 and 1 from parentTx (conflicts with tx2) and output 0 from parentTx2 + tx3 := td.CreateTransactionWithOptions(t, transactions.WithInput(parentTx, 3), transactions.WithInput(parentTx, 1), transactions.WithInput(parentTx2, 0), transactions.WithP2PKHOutputs(1, 100000)) + + // Fork B: block5b must also mine parentTx2 (even though it's in block5a) because both forks need it + // Each fork mines parentTx2 independently at the same height + _, block5b := td.CreateTestBlock(t, block4, 5002, parentTx2, tx3) + require.NoError(t, td.BlockValidation.ValidateBlock(td.Ctx, block5b, "legacy", nil, false), "Failed to process block") + t.Logf("WaitForBlockBeingMined(t, block5b): %s", block5b.Hash().String()) + td.WaitForBlockBeingMined(t, block5b) + + // / 5a (*) [tx1, tx2, parentTx2] + // 0 -> 1 ... 2 -> 3 -> 4 + // \ 5b [parentTx2, tx3 - conflicts with tx2] + + // Make Fork B longer + _, block6b := td.CreateTestBlock(t, block5b, 6002) + require.NoError(t, td.BlockValidation.ValidateBlock(td.Ctx, block6b, "legacy", nil, false, false), "Failed to process block") + t.Logf("WaitForBlockBeingMined(t, block6b): %s", block6b.Hash().String()) + td.WaitForBlockBeingMined(t, block6b) + t.Logf("WaitForBlock(t, block6b, blockWait): %s", block6b.Hash().String()) + td.WaitForBlock(t, block6b, blockWait) + + // / 5a [tx1, tx2, parentTx2] + // 0 -> 1 ... 2 -> 3 -> 4 + // \ 5b [parentTx2, tx3 - conflicts with tx2] -> 6b (*) [empty] + + t.Logf("VerifyInBlockAssembly(t, tx1): %s", tx1.TxIDChainHash().String()) + td.VerifyInBlockAssembly(t, tx1) // back in mempool (was in block5a) + t.Logf("VerifyNotInBlockAssembly(t, tx2): %s", tx2.TxIDChainHash().String()) + td.VerifyNotInBlockAssembly(t, tx2) // lost conflict to tx3, removed from mempool + t.Logf("VerifyNotInBlockAssembly(t, tx3): %s", tx3.TxIDChainHash().String()) + td.VerifyNotInBlockAssembly(t, tx3) // mined in block5b + t.Logf("VerifyNotInBlockAssembly(t, parentTx2): %s", parentTx2.TxIDChainHash().String()) + td.VerifyNotInBlockAssembly(t, parentTx2) // mined in block5b on longest chain + t.Logf("VerifyNotOnLongestChainInUtxoStore(t, tx1): %s", tx1.TxIDChainHash().String()) + td.VerifyNotOnLongestChainInUtxoStore(t, tx1) + t.Logf("VerifyNotOnLongestChainInUtxoStore(t, tx2): %s", tx2.TxIDChainHash().String()) + td.VerifyNotOnLongestChainInUtxoStore(t, tx2) + t.Logf("VerifyOnLongestChainInUtxoStore(t, parentTx2): %s", parentTx2.TxIDChainHash().String()) + td.VerifyOnLongestChainInUtxoStore(t, parentTx2) // mined in block5b on longest chain + t.Logf("VerifyOnLongestChainInUtxoStore(t, tx3): %s", tx3.TxIDChainHash().String()) + td.VerifyOnLongestChainInUtxoStore(t, tx3) + + // Note: We cannot mine tx3 on Fork A because tx2 (mined in block5a) already spent parentTx output 1 + // tx3 also spends parentTx output 1, creating a conflict. Once tx2 is mined, that UTXO is consumed. + // The test scenario successfully validates: + // 1. Transactions can be mined differently across forks + // 2. During reorg, conflicting losers are correctly marked as NOT on longest chain + // 3. Conflicting winners are correctly marked as ON longest chain +}