package netsync import ( "bufio" "bytes" "encoding/hex" "fmt" "math" "os" "path/filepath" "testing" "time" "github.com/btcsuite/btcd/blockchain" "github.com/btcsuite/btcd/btcutil/v2" "github.com/btcsuite/btcd/chaincfg/v2" "github.com/btcsuite/btcd/chainhash/v2" "github.com/btcsuite/btcd/database" _ "github.com/btcsuite/btcd/database/ffldb" "github.com/btcsuite/btcd/mempool" "github.com/btcsuite/btcd/peer" "github.com/btcsuite/btcd/txscript/v2" "github.com/btcsuite/btcd/wire/v2" "github.com/stretchr/testify/require" ) // The package-level log variable is nil by default. Set it to the // disabled logger so that log calls in the sync manager don't panic. func init() { DisableLog() } // noopPeerNotifier is a no-op implementation of PeerNotifier for tests. type noopPeerNotifier struct{} func (noopPeerNotifier) AnnounceNewTransactions([]*mempool.TxDesc) {} func (noopPeerNotifier) UpdatePeerHeights(*chainhash.Hash, int32, *peer.Peer) {} func (noopPeerNotifier) RelayInventory(*wire.InvVect, interface{}) {} func (noopPeerNotifier) TransactionConfirmed(*btcutil.Tx) {} // dbSetup is used to create a new db with the genesis block already inserted. // It returns a teardown function the caller should invoke when done testing to // clean up. The database is stored under t.TempDir() which is automatically // removed when the test finishes. func dbSetup(t *testing.T, params *chaincfg.Params) (database.DB, func(), error) { dbPath := filepath.Join(t.TempDir(), "ffldb") db, err := database.Create("ffldb", dbPath, params.Net) if err != nil { return nil, nil, fmt.Errorf("error creating db: %v", err) } teardown := func() { db.Close() } return db, teardown, nil } // chainSetup is used to create a new db and chain instance with the genesis // block already inserted. In addition to the new chain instance, it returns // a teardown function the caller should invoke when done testing to clean up. func chainSetup(t *testing.T, params *chaincfg.Params) ( *blockchain.BlockChain, func(), error) { db, teardown, err := dbSetup(t, params) if err != nil { return nil, nil, err } // Copy the chain params to ensure any modifications the tests do to // the chain parameters do not affect the global instance. paramsCopy := *params // Deep-copy deployment starters/enders so that parallel tests don't // race on the shared blockClock field written by SynchronizeClock. for i := range paramsCopy.Deployments { d := ¶msCopy.Deployments[i] if s, ok := d.DeploymentStarter.(*chaincfg.MedianTimeDeploymentStarter); ok { d.DeploymentStarter = chaincfg.NewMedianTimeDeploymentStarter( s.StartTime()) } if e, ok := d.DeploymentEnder.(*chaincfg.MedianTimeDeploymentEnder); ok { d.DeploymentEnder = chaincfg.NewMedianTimeDeploymentEnder( e.EndTime()) } } // Create the main chain instance. chain, err := blockchain.New(&blockchain.Config{ DB: db, Checkpoints: paramsCopy.Checkpoints, ChainParams: ¶msCopy, TimeSource: blockchain.NewMedianTime(), SigCache: txscript.NewSigCache(1000), }) if err != nil { teardown() err := fmt.Errorf("failed to create chain instance: %v", err) return nil, nil, err } return chain, teardown, nil } // loadHeaders loads headers from mainnet from 1 to 11. func loadHeaders(t *testing.T) []*wire.BlockHeader { testFile := "blockheaders-mainnet-1-11.txt" filename := filepath.Join("testdata/", testFile) file, err := os.Open(filename) if err != nil { t.Fatal(err) } headers := make([]*wire.BlockHeader, 0, 10) scanner := bufio.NewScanner(file) for scanner.Scan() { line := scanner.Text() b, err := hex.DecodeString(line) if err != nil { t.Fatalf("failed to read block headers from file %v", testFile) } r := bytes.NewReader(b) header := new(wire.BlockHeader) header.Deserialize(r) headers = append(headers, header) } return headers } func makeMockSyncManager(t *testing.T, params *chaincfg.Params) (*SyncManager, func()) { t.Helper() chain, tearDown, err := chainSetup(t, params) require.NoError(t, err) sm, err := New(&Config{ PeerNotifier: noopPeerNotifier{}, Chain: chain, ChainParams: params, }) require.NoError(t, err) return sm, tearDown } func TestCheckHeadersList(t *testing.T) { // Set params to mainnet with a checkpoint at block 11. params := chaincfg.MainNetParams checkpointHeight := int32(11) checkpointHash, err := chainhash.NewHashFromStr( "0000000097be56d606cdd9c54b04d4747e957d3608abe69198c661f2add73073") if err != nil { t.Fatal(err) } params.Checkpoints = []chaincfg.Checkpoint{ { Height: checkpointHeight, Hash: checkpointHash, }, } // Create mock SyncManager. sm, tearDown := makeMockSyncManager(t, ¶ms) defer tearDown() // Setup SyncManager with headers processed. headers := loadHeaders(t) for _, header := range headers { isMainChain, err := sm.chain.ProcessBlockHeader( header, blockchain.BFNone, false) if err != nil { t.Fatal(err) } if !isMainChain { t.Fatalf("expected block header %v to be in the main chain", header.BlockHash()) } } tests := []struct { hash string isCheckpointBlock bool behaviorFlags blockchain.BehaviorFlags }{ { hash: chaincfg.MainNetParams.GenesisHash.String(), isCheckpointBlock: false, behaviorFlags: blockchain.BFFastAdd, }, { // Block 10. hash: "000000002c05cc2e78923c34df87fd108b22221ac6076c18f3ade378a4d915e9", isCheckpointBlock: false, behaviorFlags: blockchain.BFFastAdd, }, { // Block 11. hash: "0000000097be56d606cdd9c54b04d4747e957d3608abe69198c661f2add73073", isCheckpointBlock: true, behaviorFlags: blockchain.BFFastAdd, }, { // Block 12. hash: "0000000027c2488e2510d1acf4369787784fa20ee084c258b58d9fbd43802b5e", isCheckpointBlock: false, behaviorFlags: blockchain.BFNone, }, } for _, test := range tests { hash, err := chainhash.NewHashFromStr(test.hash) if err != nil { t.Errorf("NewHashFromStr: %v", err) continue } // Make sure that when the ibd mode is off, we always get // false and BFNone. sm.ibdMode = false isCheckpoint, gotFlags := sm.checkHeadersList(hash) require.Equal(t, false, isCheckpoint) require.Equal(t, blockchain.BFNone, gotFlags) // Now check that the test values are correct. sm.ibdMode = true isCheckpoint, gotFlags = sm.checkHeadersList(hash) require.Equal(t, test.isCheckpointBlock, isCheckpoint) require.Equal(t, test.behaviorFlags, gotFlags) } } func TestFetchHigherPeers(t *testing.T) { // Create mock SyncManager. sm, tearDown := makeMockSyncManager(t, &chaincfg.MainNetParams) defer tearDown() tests := []struct { peerHeights []int32 peerSyncCandidate []bool height int32 expectedCnt int }{ { peerHeights: []int32{9, 10, 10, 10}, peerSyncCandidate: []bool{true, true, true, true}, height: 5, expectedCnt: 4, }, { peerHeights: []int32{9, 10, 10, 10}, peerSyncCandidate: []bool{false, false, true, true}, height: 5, expectedCnt: 2, }, { peerHeights: []int32{1, 100, 100, 100, 100}, peerSyncCandidate: []bool{true, false, true, true, false}, height: 100, expectedCnt: 0, }, } for _, test := range tests { // Setup peers. sm.peerStates = make(map[*peer.Peer]*peerSyncState) for i, height := range test.peerHeights { peer := peer.NewInboundPeer(&peer.Config{}) peer.UpdateLastBlockHeight(height) sm.peerStates[peer] = &peerSyncState{ syncCandidate: test.peerSyncCandidate[i], requestedTxns: make(map[chainhash.Hash]struct{}), requestedBlocks: make(map[chainhash.Hash]struct{}), } } // Fetch higher peers and assert. peers := sm.fetchHigherPeers(test.height) require.Equal(t, test.expectedCnt, len(peers)) for _, peer := range peers { state, found := sm.peerStates[peer] require.True(t, found) require.True(t, state.syncCandidate) } } } // mockTimeSource is used to trick the BlockChain instance to think that we're // in the past. This is so that we can force it to return true for isCurrent(). type mockTimeSource struct { adjustedTime time.Time } // AdjustedTime returns the internal adjustedTime. // // Part of the MedianTimeSource interface implementation. func (m *mockTimeSource) AdjustedTime() time.Time { return m.adjustedTime } // AddTimeSample isn't relevant so we just leave it as emtpy. // // Part of the MedianTimeSource interface implementation. func (m *mockTimeSource) AddTimeSample(id string, timeVal time.Time) { // purposely left empty } // Offset isn't relevant so we just return 0. // // Part of the MedianTimeSource interface implementation. func (m *mockTimeSource) Offset() time.Duration { return 0 } // TestBuildBlockRequestSkipsInflightBlocks verifies that buildBlockRequest // does not re-request blocks that are already in sm.requestedBlocks. When // the pipeline refill threshold triggers fetchHeaderBlocks while blocks are // still in-flight, re-requesting them causes the peer to send duplicates. // The first copy gets processed (removing the hash from requestedBlocks), // and the second copy then arrives as "unrequested", disconnecting the peer. func TestBuildBlockRequestSkipsInflightBlocks(t *testing.T) { tests := []struct { name string // inflightHeights are the block heights already in // requestedBlocks before calling buildBlockRequest. inflightHeights []int32 // wantRequestedHeights are the block heights that should // appear in the returned getdata message. wantRequestedHeights []int32 }{ { name: "no blocks in-flight requests all", inflightHeights: nil, wantRequestedHeights: []int32{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11}, }, { name: "all blocks in-flight requests none", inflightHeights: []int32{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11}, wantRequestedHeights: nil, }, { name: "first 5 in-flight requests remaining 6", inflightHeights: []int32{1, 2, 3, 4, 5}, wantRequestedHeights: []int32{6, 7, 8, 9, 10, 11}, }, { name: "last 6 in-flight requests first 5", inflightHeights: []int32{6, 7, 8, 9, 10, 11}, wantRequestedHeights: []int32{1, 2, 3, 4, 5}, }, { name: "scattered in-flight requests gaps", inflightHeights: []int32{2, 4, 6, 8, 10}, wantRequestedHeights: []int32{1, 3, 5, 7, 9, 11}, }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { params := chaincfg.MainNetParams params.Checkpoints = nil sm, tearDown := makeMockSyncManager(t, ¶ms) defer tearDown() // Process headers 1-11 so the header chain is // ahead of the block chain. headers := loadHeaders(t) for _, header := range headers { _, err := sm.chain.ProcessBlockHeader( header, blockchain.BFNone, false) require.NoError(t, err) } // Set up a disconnected peer as syncPeer. syncPeer := peer.NewInboundPeer(&peer.Config{}) sm.syncPeer = syncPeer syncPeerState := &peerSyncState{ requestedTxns: make(map[chainhash.Hash]struct{}), requestedBlocks: make(map[chainhash.Hash]struct{}), } sm.peerStates[syncPeer] = syncPeerState // Pre-populate in-flight blocks. for _, h := range tc.inflightHeights { hash, err := sm.chain.HeaderHashByHeight(h) require.NoError(t, err) sm.requestedBlocks[*hash] = struct{}{} syncPeerState.requestedBlocks[*hash] = struct{}{} } gdmsg := sm.buildBlockRequest(syncPeer) // Collect the hashes from the getdata message. got := make(map[chainhash.Hash]struct{}, len(gdmsg.InvList)) for _, iv := range gdmsg.InvList { got[iv.Hash] = struct{}{} } require.Equal(t, len(tc.wantRequestedHeights), len(gdmsg.InvList)) for _, h := range tc.wantRequestedHeights { hash, err := sm.chain.HeaderHashByHeight(h) require.NoError(t, err) require.Contains(t, got, *hash, "block at height %d should be requested", h) } for _, h := range tc.inflightHeights { hash, err := sm.chain.HeaderHashByHeight(h) require.NoError(t, err) require.NotContains(t, got, *hash, "in-flight block at height %d should not be re-requested", h) } }) } } func TestIsInIBDMode(t *testing.T) { tests := []struct { peerState map[*peer.Peer]*peerSyncState params *chaincfg.Params timesource *mockTimeSource isIBDMode bool }{ // Is not current, higher peers. { params: &chaincfg.MainNetParams, peerState: func() map[*peer.Peer]*peerSyncState { ps := make(map[*peer.Peer]*peerSyncState) peer := peer.NewInboundPeer(&peer.Config{}) peer.UpdateLastBlockHeight(900_000) ps[peer] = &peerSyncState{ syncCandidate: true, requestedTxns: make(map[chainhash.Hash]struct{}), requestedBlocks: make(map[chainhash.Hash]struct{}), } return ps }(), timesource: nil, isIBDMode: true, }, // Is not current, no higher peers. { params: &chaincfg.MainNetParams, peerState: func() map[*peer.Peer]*peerSyncState { ps := make(map[*peer.Peer]*peerSyncState) peer := peer.NewInboundPeer(&peer.Config{}) peer.UpdateLastBlockHeight(0) ps[peer] = &peerSyncState{ syncCandidate: true, requestedTxns: make(map[chainhash.Hash]struct{}), requestedBlocks: make(map[chainhash.Hash]struct{}), } return ps }(), timesource: nil, isIBDMode: true, }, // Is current, higher peers. { params: func() *chaincfg.Params { params := chaincfg.MainNetParams params.Checkpoints = nil return ¶ms }(), peerState: func() map[*peer.Peer]*peerSyncState { ps := make(map[*peer.Peer]*peerSyncState) peer := peer.NewInboundPeer(&peer.Config{}) peer.UpdateLastBlockHeight(900_000) ps[peer] = &peerSyncState{ syncCandidate: true, requestedTxns: make(map[chainhash.Hash]struct{}), requestedBlocks: make(map[chainhash.Hash]struct{}), } return ps }(), timesource: &mockTimeSource{ chaincfg.MainNetParams.GenesisBlock.Header.Timestamp, }, isIBDMode: true, }, // Is current, no higher peers. { params: func() *chaincfg.Params { params := chaincfg.MainNetParams params.Checkpoints = nil return ¶ms }(), peerState: func() map[*peer.Peer]*peerSyncState { ps := make(map[*peer.Peer]*peerSyncState) peer := peer.NewInboundPeer(&peer.Config{}) peer.UpdateLastBlockHeight(0) ps[peer] = &peerSyncState{ syncCandidate: true, requestedTxns: make(map[chainhash.Hash]struct{}), requestedBlocks: make(map[chainhash.Hash]struct{}), } return ps }(), timesource: &mockTimeSource{ chaincfg.MainNetParams.GenesisBlock.Header.Timestamp, }, isIBDMode: false, }, } for _, test := range tests { db, tearDown, err := dbSetup(t, test.params) if err != nil { tearDown() t.Fatal(err) } timesource := blockchain.NewMedianTime() if test.timesource != nil { timesource = test.timesource } // Create the main chain instance. chain, err := blockchain.New(&blockchain.Config{ DB: db, Checkpoints: test.params.Checkpoints, ChainParams: test.params, TimeSource: timesource, SigCache: txscript.NewSigCache(1000), }) if err != nil { tearDown() t.Fatal(err) } sm, err := New(&Config{ Chain: chain, ChainParams: test.params, }) if err != nil { tearDown() t.Fatal(err) } // Run test and assert. sm.peerStates = test.peerState got := sm.isInIBDMode() require.Equal(t, test.isIBDMode, got) tearDown() } } // createTestCoinbase creates a minimal coinbase transaction for the given // block height. The signature script encodes the height to ensure unique // transaction hashes across blocks. func createTestCoinbase(height int32, params *chaincfg.Params) *wire.MsgTx { tx := wire.NewMsgTx(wire.TxVersion) // Encode the height as a minimally encoded script integer and add a trailing // OP_0 so the script also satisfies the generic coinbase-length checks. sigScript, err := txscript.NewScriptBuilder(). AddInt64(int64(height)). AddInt64(0). Script() if err != nil { panic(fmt.Sprintf("unable to encode coinbase height %d: %v", height, err)) } tx.AddTxIn(&wire.TxIn{ PreviousOutPoint: wire.OutPoint{ Hash: chainhash.Hash{}, Index: wire.MaxPrevOutIndex, }, SignatureScript: sigScript, Sequence: wire.MaxTxInSequenceNum, }) tx.AddTxOut(&wire.TxOut{ Value: blockchain.CalcBlockSubsidy(height, params), PkScript: []byte{txscript.OP_TRUE}, }) return tx } // solveTestBlock finds a nonce that satisfies the proof of work for the given // header. With regression test parameters the difficulty is minimal and a // solution is found almost immediately. func solveTestBlock(header *wire.BlockHeader, params *chaincfg.Params) bool { target := blockchain.CompactToBig(params.PowLimitBits) for nonce := uint32(0); nonce < math.MaxUint32; nonce++ { header.Nonce = nonce hash := header.BlockHash() if blockchain.HashToBig(&hash).Cmp(target) <= 0 { return true } } return false } // generateTestBlocks creates count valid blocks chaining from the genesis // block of the given params. Each block contains only a coinbase transaction. func generateTestBlocks( t *testing.T, params *chaincfg.Params, count int) []*btcutil.Block { t.Helper() blocks := make([]*btcutil.Block, 0, count) prevHash := params.GenesisHash prevTime := params.GenesisBlock.Header.Timestamp for h := int32(1); h <= int32(count); h++ { cb := createTestCoinbase(h, params) merkleRoot := cb.TxHash() header := wire.BlockHeader{ // Regtest enforces the BIP34/65/66 version floor from height 1. Version: 4, PrevBlock: *prevHash, MerkleRoot: merkleRoot, Timestamp: prevTime.Add(time.Minute), Bits: params.PowLimitBits, } require.True(t, solveTestBlock(&header, params), "failed to solve block at height %d", h) msgBlock := &wire.MsgBlock{ Header: header, Transactions: []*wire.MsgTx{cb}, } block := btcutil.NewBlock(msgBlock) blocks = append(blocks, block) bh := block.Hash() prevHash = bh prevTime = header.Timestamp } return blocks } // TestSyncStateMachine exercises the end-to-end IBD sync flow: // // ┌→ startSync // │ ↓ // │ fetchHeaders // │ ↓ // │ handleHeadersMsg // │ ↓ // │ fetchHeaderBlocks ←┐ // │ ↓ │ (refill) // │ handleBlockMsg ────┘──→ IBD complete // │ // │ (stall detected at any phase above) // │ ↓ // │ handleStallSample // │ ↓ // └── handleDonePeerMsg // // It verifies that header processing transitions to block download, that the // pipeline refill path in handleBlockMsg is exercised, and that IBD mode is // properly cleared once the chain catches up to the best header. // // The "fresh ibd" case tests a complete sync from genesis: headers are fetched // and then blocks are downloaded. // // The "stall before any headers" and "stall mid header download" cases test // recovery when the sync peer stalls during header download. A replacement // peer delivers the remaining (or all) headers and then all blocks. // // The "headers complete peer stalls on blocks" case tests recovery when the // sync peer delivers all headers but stalls before sending any blocks; a // replacement peer downloads all blocks. // // The "stalled sync peer recovery" case tests recovery mid-block-download: a // sync peer stops responding after some blocks, handleStallSample detects the // inactivity, the stalled peer is disconnected, and a replacement peer // finishes IBD. // // The "stall mid headers then stall on blocks" case combines both failure // modes: one peer stalls during headers (peer 2 takes over and finishes // headers), then peer 2 stalls during block download (peer 3 finishes blocks). // This exercises recovery across three distinct peers. func TestSyncStateMachine(t *testing.T) { t.Parallel() const testTotalBlocks = 2 * minInFlightBlocks tests := []struct { name string totalBlocks int // stallHeadersAfter, when >= 0, triggers a stall during // header download: deliver this many headers, then stall // the sync peer and verify a replacement finishes header // download. Set to -1 for no header stall. stallHeadersAfter int // stallAfter, when >= 0, triggers a stall during block // download: deliver all headers, then process this many // blocks before stalling. Set to -1 for no block stall. stallAfter int }{ { name: "fresh ibd", totalBlocks: testTotalBlocks, stallHeadersAfter: -1, stallAfter: -1, }, { name: "stall before any headers", totalBlocks: testTotalBlocks, stallHeadersAfter: 0, stallAfter: -1, }, { name: "stall mid header download", totalBlocks: testTotalBlocks, stallHeadersAfter: testTotalBlocks / 2, stallAfter: -1, }, { name: "headers complete peer stalls on blocks", totalBlocks: testTotalBlocks, stallHeadersAfter: -1, stallAfter: 0, }, { name: "stalled sync peer recovery", totalBlocks: testTotalBlocks, stallHeadersAfter: -1, stallAfter: 5, }, { name: "stall mid headers then stall on blocks", totalBlocks: testTotalBlocks, stallHeadersAfter: testTotalBlocks / 2, stallAfter: 5, }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { t.Parallel() params := chaincfg.RegressionNetParams params.Checkpoints = nil sm, tearDown := makeMockSyncManager(t, ¶ms) defer tearDown() blocks := generateTestBlocks(t, ¶ms, tc.totalBlocks) // Register a sync candidate and call startSync, // which activates IBD mode and sends getheaders. peer1 := startIBD(t, sm, tc.totalBlocks) if tc.stallHeadersAfter >= 0 { // Stall during header download; // replacement sends remaining headers. peer2 := newSyncCandidate(t, sm, int32(tc.totalBlocks)) syncStalledHeaderRecovery( t, sm, peer1, peer2, blocks, tc.stallHeadersAfter, tc.totalBlocks, ) if tc.stallAfter >= 0 { peer3 := newSyncCandidate(t, sm, int32(tc.totalBlocks)) syncStalledPeerRecovery( t, sm, peer2, peer3, blocks, tc.stallAfter, tc.totalBlocks, ) } else { syncProcessBlocks(t, sm, peer2, blocks, tc.totalBlocks) } } else { syncSendHeaders(t, sm, peer1, blocks, tc.totalBlocks) if tc.stallAfter >= 0 { peer2 := newSyncCandidate(t, sm, int32(tc.totalBlocks)) syncStalledPeerRecovery( t, sm, peer1, peer2, blocks, tc.stallAfter, tc.totalBlocks, ) } else { syncProcessBlocks(t, sm, peer1, blocks, tc.totalBlocks) } } }) } } // newSyncCandidate creates and registers a sync-candidate peer at the // given height without triggering startSync. func newSyncCandidate(t *testing.T, sm *SyncManager, height int32) *peer.Peer { t.Helper() p := peer.NewInboundPeer(&peer.Config{ ChainParams: sm.chainParams, }) p.UpdateLastBlockHeight(height) sm.peerStates[p] = &peerSyncState{ syncCandidate: true, requestedTxns: make(map[chainhash.Hash]struct{}), requestedBlocks: make(map[chainhash.Hash]struct{}), } return p } // assertIBDComplete verifies that IBD finished: chain height matches // totalBlocks, ibdMode is off, and no blocks remain in-flight. func assertIBDComplete(t *testing.T, sm *SyncManager, peerState *peerSyncState, totalBlocks int) { t.Helper() best := sm.chain.BestSnapshot() require.Equal(t, int32(totalBlocks), best.Height) require.False(t, sm.ibdMode, "ibdMode should be off after catching up") require.Empty(t, sm.requestedBlocks, "all requested blocks should be fulfilled") require.Empty(t, peerState.requestedBlocks, "peer should have no outstanding block requests") } // startIBD registers a sync peer and calls startSync, verifying that IBD // mode is activated and the peer is selected. func startIBD(t *testing.T, sm *SyncManager, peerHeight int) *peer.Peer { t.Helper() syncPeer := newSyncCandidate(t, sm, int32(peerHeight)) sm.startSync() require.True(t, sm.syncPeer == syncPeer, "syncPeer should be set after startSync") require.True(t, sm.ibdMode, "ibdMode should be on") require.False(t, sm.lastProgressTime.IsZero(), "lastProgressTime should be set") return syncPeer } // syncSendHeaders delivers block headers to the sync manager and verifies // that block requests are generated. func syncSendHeaders(t *testing.T, sm *SyncManager, syncPeer *peer.Peer, blocks []*btcutil.Block, totalBlocks int) { t.Helper() // Reset the progress time to a zero sentinel so the assertion below // verifies that handleHeadersMsg writes it without depending on the // system clock advancing between calls to time.Now. sm.lastProgressTime = time.Time{} headers := wire.NewMsgHeaders() for _, block := range blocks { err := headers.AddBlockHeader(&block.MsgBlock().Header) require.NoError(t, err) } sm.handleHeadersMsg(&headersMsg{ headers: headers, peer: syncPeer, }) _, bestHeaderHeight := sm.chain.BestHeader() require.Equal(t, int32(totalBlocks), bestHeaderHeight) require.False(t, sm.lastProgressTime.IsZero(), "handleHeadersMsg should update lastProgressTime") wantRequested := make(map[chainhash.Hash]struct{}, len(blocks)) for _, block := range blocks { wantRequested[*block.Hash()] = struct{}{} } require.Equal(t, wantRequested, sm.requestedBlocks) require.Equal(t, wantRequested, sm.peerStates[syncPeer].requestedBlocks) } // syncProcessBlocks feeds all blocks to handleBlockMsg and verifies that IBD // mode remains active until the final block, at which point IBD completes. func syncProcessBlocks(t *testing.T, sm *SyncManager, syncPeer *peer.Peer, blocks []*btcutil.Block, totalBlocks int) { t.Helper() peerState := sm.peerStates[syncPeer] for i, block := range blocks { sm.handleBlockMsg(&blockMsg{ block: block, peer: syncPeer, reply: make(chan struct{}, 1), }) if i < len(blocks)-1 { require.True(t, sm.ibdMode, "ibdMode should still be on at height %d", i+1) } } assertIBDComplete(t, sm, peerState, totalBlocks) } // syncStalledPeerRecovery processes stallAfter blocks from stalledPeer, // triggers stall detection, verifies that stalledPeer is removed and // replacementPeer takes over, then feeds remaining blocks and verifies // IBD completes. func syncStalledPeerRecovery(t *testing.T, sm *SyncManager, stalledPeer, replacementPeer *peer.Peer, blocks []*btcutil.Block, stallAfter, totalBlocks int) { t.Helper() // Process the first stallAfter blocks from the stalled peer. for _, block := range blocks[:stallAfter] { sm.handleBlockMsg(&blockMsg{ block: block, peer: stalledPeer, reply: make(chan struct{}, 1), }) } best := sm.chain.BestSnapshot() require.Equal(t, int32(stallAfter), best.Height) require.True(t, sm.ibdMode) // Trigger stall detection. sm.lastProgressTime = time.Now().Add( -(maxStallDuration + time.Minute)) sm.handleStallSample() // Verify that handleStallSample called Disconnect() on the // stalled peer (which closes p.quit, making WaitForDisconnect // return immediately). disconnected := make(chan struct{}) go func() { stalledPeer.WaitForDisconnect() close(disconnected) }() select { case <-disconnected: case <-time.After(time.Second): t.Fatal("Disconnect() was not called on stalled peer") } // Snapshot the stalled peer's outstanding requested blocks before // disconnection so we can verify they are cleaned up. stalledState := sm.peerStates[stalledPeer] stalledRequested := make([]chainhash.Hash, 0, len(stalledState.requestedBlocks)) for hash := range stalledState.requestedBlocks { stalledRequested = append(stalledRequested, hash) } require.NotEmpty(t, stalledRequested, "stalled peer should have outstanding requested blocks") // In production, Disconnect() triggers handleDonePeerMsg // asynchronously via the peer goroutine. Call it directly to // complete the removal. Note: handleDonePeerMsg first clears the // stalled peer's requested blocks from the global map via // clearRequestedState, then updateSyncPeer → startSync immediately // re-requests them for the replacement peer. sm.handleDonePeerMsg(stalledPeer) _, stalledTracked := sm.peerStates[stalledPeer] require.False(t, stalledTracked, "stalled peer should be removed") require.True(t, sm.syncPeer == replacementPeer, "replacement peer should take over as sync peer") require.True(t, sm.ibdMode) // Verify that the replacement peer re-requested the exact same // blocks that were outstanding from the stalled peer. replacementState := sm.peerStates[replacementPeer] require.Equal(t, len(stalledRequested), len(replacementState.requestedBlocks), "replacement peer should request same number of blocks") for _, hash := range stalledRequested { _, exists := replacementState.requestedBlocks[hash] require.True(t, exists, "block %v should be requested from replacement peer", hash) } // Feed remaining blocks from the replacement peer. for _, block := range blocks[stallAfter:] { sm.handleBlockMsg(&blockMsg{ block: block, peer: replacementPeer, reply: make(chan struct{}, 1), }) } assertIBDComplete(t, sm, replacementState, totalBlocks) } // syncStalledHeaderRecovery simulates a stall during header download. // It optionally delivers headersSent headers from stalledPeer, triggers stall // detection, verifies that stalledPeer is removed and replacementPeer takes // over, then delivers remaining headers and verifies block requests are // generated. The caller is responsible for the block-download phase. func syncStalledHeaderRecovery(t *testing.T, sm *SyncManager, stalledPeer, replacementPeer *peer.Peer, blocks []*btcutil.Block, headersSent, totalBlocks int) { t.Helper() // Deliver partial headers from the stalled peer. When // headersSent is 0, this is a no-op (peer stalls immediately). if headersSent > 0 { headers := wire.NewMsgHeaders() for _, block := range blocks[:headersSent] { err := headers.AddBlockHeader( &block.MsgBlock().Header) require.NoError(t, err) } sm.handleHeadersMsg(&headersMsg{ headers: headers, peer: stalledPeer, }) _, bestHeaderHeight := sm.chain.BestHeader() require.Equal(t, int32(headersSent), bestHeaderHeight) } // No blocks should have been requested during header download // since the headers haven't caught up to the peer's height yet. require.Empty(t, sm.requestedBlocks, "no blocks should be requested during header download") // Trigger stall detection. sm.lastProgressTime = time.Now().Add( -(maxStallDuration + time.Minute)) sm.handleStallSample() // Verify that handleStallSample called Disconnect() on the // stalled peer. disconnected := make(chan struct{}) go func() { stalledPeer.WaitForDisconnect() close(disconnected) }() select { case <-disconnected: case <-time.After(time.Second): t.Fatal("Disconnect() was not called on stalled peer") } // Complete peer removal. handleDonePeerMsg clears state and // triggers startSync which selects the replacement peer. sm.handleDonePeerMsg(stalledPeer) _, stalledTracked := sm.peerStates[stalledPeer] require.False(t, stalledTracked, "stalled peer should be removed") require.True(t, sm.syncPeer == replacementPeer, "replacement peer should take over as sync peer") require.True(t, sm.ibdMode) // Deliver remaining headers from the replacement peer. When // headersSent is 0, this is all headers. remainingHeaders := wire.NewMsgHeaders() for _, block := range blocks[headersSent:] { err := remainingHeaders.AddBlockHeader( &block.MsgBlock().Header) require.NoError(t, err) } sm.handleHeadersMsg(&headersMsg{ headers: remainingHeaders, peer: replacementPeer, }) _, bestHeaderHeight := sm.chain.BestHeader() require.Equal(t, int32(totalBlocks), bestHeaderHeight) // Verify all blocks were requested from the replacement. wantRequested := make(map[chainhash.Hash]struct{}, len(blocks)) for _, block := range blocks { wantRequested[*block.Hash()] = struct{}{} } require.Equal(t, wantRequested, sm.requestedBlocks) replacementState := sm.peerStates[replacementPeer] require.Equal(t, wantRequested, replacementState.requestedBlocks) } // TestStartSyncBlockFallback verifies the startSync fallback path where // headers are already caught up but the block chain lags behind. In this // case startSync should skip header download and directly request blocks. func TestStartSyncBlockFallback(t *testing.T) { t.Parallel() params := chaincfg.RegressionNetParams params.Checkpoints = nil sm, tearDown := makeMockSyncManager(t, ¶ms) defer tearDown() // Process headers so the header chain is at numBlocks while the // block chain stays at genesis. const numBlocks = 11 blocks := generateTestBlocks(t, ¶ms, numBlocks) for _, block := range blocks { _, err := sm.chain.ProcessBlockHeader( &block.MsgBlock().Header, blockchain.BFNone, false) require.NoError(t, err) } // Add a peer whose height equals the header height. // fetchHigherPeers(bestHeaderHeight) returns nothing because // the peer is not strictly higher than our headers. // fetchHigherPeers(bestBlockHeight=0) returns the peer. syncPeer := peer.NewInboundPeer(&peer.Config{}) syncPeer.UpdateLastBlockHeight(int32(numBlocks)) sm.peerStates[syncPeer] = &peerSyncState{ syncCandidate: true, requestedTxns: make(map[chainhash.Hash]struct{}), requestedBlocks: make(map[chainhash.Hash]struct{}), } sm.startSync() require.NotNil(t, sm.syncPeer, "sync peer should be set for block download") require.NotEmpty(t, sm.requestedBlocks, "blocks should be requested via fetchHeaderBlocks") } // TestStallNoDisconnectAtSameHeight verifies that handleStallSample does // not disconnect a sync peer whose advertised height equals our own. func TestStallNoDisconnectAtSameHeight(t *testing.T) { t.Parallel() params := chaincfg.RegressionNetParams params.Checkpoints = nil sm, tearDown := makeMockSyncManager(t, ¶ms) defer tearDown() p := peer.NewInboundPeer(&peer.Config{}) p.UpdateLastBlockHeight(0) // Same height as our genesis chain. sm.peerStates[p] = &peerSyncState{} sm.syncPeer = p sm.ibdMode = true sm.lastProgressTime = time.Now().Add( -(maxStallDuration + time.Minute)) sm.handleStallSample() _, tracked := sm.peerStates[p] require.True(t, tracked, "peer at same height should not be disconnected") require.Nil(t, sm.syncPeer, "we should have nil syncPeer after handleStallSample") } // TestStartSyncChainCurrent verifies that startSync does not set syncPeer // or ibdMode when the chain is current and no peer is strictly higher. // isInIBDMode sees IsCurrent()==true with no higher peers, returns false, // and startSync exits immediately. func TestStartSyncChainCurrent(t *testing.T) { t.Parallel() params := chaincfg.RegressionNetParams params.Checkpoints = nil sm, tearDown := makeMockSyncManager(t, ¶ms) defer tearDown() // Mine a single block with a recent timestamp so // IsCurrent() returns true. cb := createTestCoinbase(1, ¶ms) header := wire.BlockHeader{ // Regtest enforces the BIP34/65/66 version floor from height 1. Version: 4, PrevBlock: *params.GenesisHash, MerkleRoot: cb.TxHash(), Timestamp: time.Now().Truncate(time.Second), Bits: params.PowLimitBits, } require.True(t, solveTestBlock(&header, ¶ms)) block := btcutil.NewBlock(&wire.MsgBlock{ Header: header, Transactions: []*wire.MsgTx{cb}, }) _, _, err := sm.chain.ProcessBlock(block, blockchain.BFNone) require.NoError(t, err) require.True(t, sm.chain.IsCurrent()) // Peer at our height — not higher. newSyncCandidate(t, sm, 1) sm.startSync() require.Nil(t, sm.syncPeer, "syncPeer should not be set when chain is already current") require.False(t, sm.ibdMode, "ibdMode should not be activated when chain is already current") } // TestIsSyncCandidateRegtest verifies that isSyncCandidate accepts peers // on regtest and simnet based on their service flags. func TestIsSyncCandidateRegtest(t *testing.T) { t.Parallel() params := chaincfg.RegressionNetParams sm, tearDown := makeMockSyncManager(t, ¶ms) defer tearDown() tests := []struct { name string flags wire.ServiceFlag lastBlock int32 want bool }{ { name: "just node network", flags: wire.SFNodeNetwork, want: true, }, { name: "just limited network", flags: wire.SFNodeNetworkLimited, want: true, }, { name: "limited network with block ahead", flags: wire.SFNodeNetworkLimited, lastBlock: wire.NodeNetworkLimitedBlockThreshold + 1, want: false, }, { name: "node network and limited node network", flags: wire.SFNodeNetwork | wire.SFNodeNetworkLimited, want: true, }, { name: "no flags", flags: 0, want: false, }, { name: "different flag", flags: wire.SFNodeBloom, want: false, }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { p := peer.NewInboundPeer(&peer.Config{ ChainParams: sm.chainParams, Services: tc.flags, }) p.UpdateLastBlockHeight(tc.lastBlock) got := sm.isSyncCandidate(p) require.Equal(t, tc.want, got) }) } }