diff --git a/cmd/addblock/import.go b/cmd/addblock/import.go index 527bccda..da3f557b 100644 --- a/cmd/addblock/import.go +++ b/cmd/addblock/import.go @@ -13,9 +13,9 @@ import ( "github.com/blinklabs-io/handshake-node/blockchain" "github.com/blinklabs-io/handshake-node/blockchain/indexers" - "github.com/blinklabs-io/handshake-node/hnsutil" "github.com/blinklabs-io/handshake-node/chaincfg/chainhash" "github.com/blinklabs-io/handshake-node/database" + "github.com/blinklabs-io/handshake-node/hnsutil" "github.com/blinklabs-io/handshake-node/wire" ) @@ -127,10 +127,9 @@ func (bi *blockImporter) processBlock(serializedBlock []byte) (bool, error) { } } - // Ensure the blocks follows all of the chain rules and match up to the + // Ensure the block follows all of the chain rules and matches up to the // known checkpoints. - isMainChain, isOrphan, err := bi.chain.ProcessBlock(block, - blockchain.BFFastAdd) + isMainChain, isOrphan, err := bi.chain.ProcessBlock(block, blockchain.BFNone) if err != nil { return false, err } diff --git a/cmd/addblock/import_test.go b/cmd/addblock/import_test.go new file mode 100644 index 00000000..f7c71201 --- /dev/null +++ b/cmd/addblock/import_test.go @@ -0,0 +1,127 @@ +// Copyright (c) 2026 Blink Labs Software +// Use of this source code is governed by an ISC +// license that can be found in the LICENSE file. + +package main + +import ( + "errors" + "math" + "path/filepath" + "testing" + "time" + + "github.com/blinklabs-io/handshake-node/blockchain" + "github.com/blinklabs-io/handshake-node/chaincfg" + "github.com/blinklabs-io/handshake-node/chaincfg/chainhash" + "github.com/blinklabs-io/handshake-node/database" + _ "github.com/blinklabs-io/handshake-node/database/ffldb" + "github.com/blinklabs-io/handshake-node/hnsutil" + "github.com/blinklabs-io/handshake-node/txscript" + "github.com/blinklabs-io/handshake-node/wire" +) + +func solveImportTestBlock(t *testing.T, header *wire.BlockHeader, + params *chaincfg.Params) { + + t.Helper() + 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 + } + } + t.Fatal("failed to solve import test block") +} + +func invalidImportCoinbaseBlock(t *testing.T, + params *chaincfg.Params) *hnsutil.Block { + + t.Helper() + height := int32(1) + coinbase := wire.NewMsgTx(wire.TxVersion) + coinbase.LockTime = uint32(height + 1) + heightScript, err := txscript.NewScriptBuilder(). + AddInt64(int64(height)). + AddOp(txscript.OP_0). + Script() + if err != nil { + t.Fatalf("coinbase height script: %v", err) + } + coinbase.AddTxIn(&wire.TxIn{ + PreviousOutPoint: wire.OutPoint{ + Hash: chainhash.Hash{}, + Index: wire.MaxPrevOutIndex, + }, + Sequence: wire.MaxTxInSequenceNum, + Witness: wire.TxWitness{heightScript}, + }) + coinbase.AddTxOut(&wire.TxOut{ + Value: blockchain.CalcBlockSubsidy(height, params), + Address: wire.Address{ + Version: 0, + Hash: make([]byte, 20), + }, + }) + + txns := []*hnsutil.Tx{hnsutil.NewTx(coinbase)} + header := wire.BlockHeader{ + Version: 2, + PrevBlock: *params.GenesisHash, + MerkleRoot: blockchain.CalcMerkleRoot(txns, false), + WitnessRoot: blockchain.CalcMerkleRoot(txns, true), + Timestamp: params.GenesisBlock.Header.Timestamp.Add(time.Minute), + Bits: params.PowLimitBits, + } + solveImportTestBlock(t, &header, params) + + return hnsutil.NewBlock(&wire.MsgBlock{ + Header: header, + Transactions: []*wire.MsgTx{coinbase}, + }) +} + +func TestProcessBlockFullyValidatesImports(t *testing.T) { + params := chaincfg.RegressionNetParams + params.Checkpoints = nil + dbPath := filepath.Join(t.TempDir(), "ffldb") + db, err := database.Create("ffldb", dbPath, params.Net) + if err != nil { + t.Fatalf("database.Create: %v", err) + } + t.Cleanup(func() { + if err := db.Close(); err != nil { + t.Errorf("database close: %v", err) + } + }) + + chain, err := blockchain.New(&blockchain.Config{ + DB: db, + ChainParams: ¶ms, + TimeSource: blockchain.NewMedianTime(), + UtxoCacheMaxSize: 16 * 1024 * 1024, + }) + if err != nil { + t.Fatalf("blockchain.New: %v", err) + } + block := invalidImportCoinbaseBlock(t, ¶ms) + serialized, err := block.Bytes() + if err != nil { + t.Fatalf("serialize block: %v", err) + } + + imported, err := (&blockImporter{chain: chain}).processBlock(serialized) + if imported { + t.Fatal("consensus-invalid block was imported") + } + var ruleErr blockchain.RuleError + if !errors.As(err, &ruleErr) { + t.Fatalf("processBlock error = %v, want blockchain.RuleError", err) + } + if ruleErr.ErrorCode != blockchain.ErrBadCoinbaseHeight { + t.Fatalf("processBlock error code = %v, want %v", + ruleErr.ErrorCode, blockchain.ErrBadCoinbaseHeight) + } +} diff --git a/netsync/manager.go b/netsync/manager.go index 94568117..c615ab09 100644 --- a/netsync/manager.go +++ b/netsync/manager.go @@ -869,6 +869,17 @@ func (sm *SyncManager) checkHeadersList(blockHash *chainhash.Hash) ( return false, blockchain.BFNone } + // A checkpoint can only prove the validity of blocks on its ancestor path. + // Since IsValidHeader requires a header to be in the linear best-header + // view, requiring both the block and checkpoint to be in that view proves + // the block is an ancestor of the checkpoint. Do not fast-add while the + // future checkpoint is still unknown, since a peer could otherwise end a + // valid-PoW header chain before the checkpoint and supply invalid block + // bodies that bypass full validation. + if !sm.chain.IsValidHeader(checkpoint.Hash) { + return false, blockchain.BFNone + } + behaviorFlags |= blockchain.BFFastAdd if blockHash.IsEqual(checkpoint.Hash) { isCheckpointBlock = true diff --git a/netsync/manager_test.go b/netsync/manager_test.go index 6ff06b54..e898ea48 100644 --- a/netsync/manager_test.go +++ b/netsync/manager_test.go @@ -424,8 +424,9 @@ func TestCheckHeadersList(t *testing.T) { sm, tearDown := makeMockSyncManager(t, ¶ms) defer tearDown() - // Setup SyncManager with headers processed. - for _, block := range blocks[:checkpointHeight] { + // Process headers up to, but not including, the checkpoint. A future + // checkpoint that is not known yet must not authorize fast-add. + for _, block := range blocks[:checkpointHeight-1] { isMainChain, err := sm.chain.ProcessBlockHeader( &block.MsgBlock().Header, blockchain.BFNone, false) if err != nil { @@ -437,6 +438,22 @@ func TestCheckHeadersList(t *testing.T) { block.Hash()) } } + sm.ibdMode = true + isCheckpoint, gotFlags := sm.checkHeadersList( + blocks[checkpointHeight-2].Hash(), + ) + require.False(t, isCheckpoint) + require.Equal(t, blockchain.BFNone, gotFlags) + + // Once the checkpoint itself is known on the same best-header path, it + // anchors all earlier headers on that path and fast-add is safe. + for _, block := range blocks[checkpointHeight-1:] { + isMainChain, err := sm.chain.ProcessBlockHeader( + &block.MsgBlock().Header, blockchain.BFNone, false) + require.NoError(t, err) + require.True(t, isMainChain, + "expected block header %v to be in the main chain", block.Hash()) + } tests := []struct { hash *chainhash.Hash