From 18a86464d5904bff370c5402a9a3b9aefd36e1b2 Mon Sep 17 00:00:00 2001 From: David Case Date: Thu, 13 Nov 2025 15:58:00 -0500 Subject: [PATCH 1/6] add Header type --- block/header.go | 119 +++++++++++++++++++++++++++++++++++++++++++ block/header_test.go | 112 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 231 insertions(+) create mode 100644 block/header.go create mode 100644 block/header_test.go diff --git a/block/header.go b/block/header.go new file mode 100644 index 00000000..8ec72516 --- /dev/null +++ b/block/header.go @@ -0,0 +1,119 @@ +package block + +import ( + "bytes" + "encoding/binary" + "encoding/hex" + "fmt" + "io" + + "github.com/bsv-blockchain/go-sdk/chainhash" +) + +const ( + // HeaderSize is the size of a Bitcoin block header in bytes (80 bytes) + HeaderSize = 80 +) + +// Header represents a Bitcoin block header (80 bytes) +type Header struct { + Version int32 // 4 bytes - Block version + PrevBlock chainhash.Hash // 32 bytes - Previous block hash + MerkleRoot chainhash.Hash // 32 bytes - Merkle root hash + Timestamp uint32 // 4 bytes - Block timestamp (Unix time) + Bits uint32 // 4 bytes - Difficulty target + Nonce uint32 // 4 bytes - Nonce +} + +// NewHeaderFromBytes creates a BlockHeader from an 80-byte slice +func NewHeaderFromBytes(data []byte) (*Header, error) { + if len(data) != HeaderSize { + return nil, fmt.Errorf("invalid header size: expected %d bytes, got %d", HeaderSize, len(data)) + } + + h := &Header{} + r := bytes.NewReader(data) + + // Read version (4 bytes, little-endian) + if err := binary.Read(r, binary.LittleEndian, &h.Version); err != nil { + return nil, fmt.Errorf("failed to read version: %w", err) + } + + // Read previous block hash (32 bytes) + if _, err := io.ReadFull(r, h.PrevBlock[:]); err != nil { + return nil, fmt.Errorf("failed to read prev block hash: %w", err) + } + + // Read merkle root (32 bytes) + if _, err := io.ReadFull(r, h.MerkleRoot[:]); err != nil { + return nil, fmt.Errorf("failed to read merkle root: %w", err) + } + + // Read timestamp (4 bytes, little-endian) + if err := binary.Read(r, binary.LittleEndian, &h.Timestamp); err != nil { + return nil, fmt.Errorf("failed to read timestamp: %w", err) + } + + // Read bits (4 bytes, little-endian) + if err := binary.Read(r, binary.LittleEndian, &h.Bits); err != nil { + return nil, fmt.Errorf("failed to read bits: %w", err) + } + + // Read nonce (4 bytes, little-endian) + if err := binary.Read(r, binary.LittleEndian, &h.Nonce); err != nil { + return nil, fmt.Errorf("failed to read nonce: %w", err) + } + + return h, nil +} + +// NewHeaderFromHex creates a BlockHeader from a hex string (160 characters) +func NewHeaderFromHex(hexStr string) (*Header, error) { + data, err := hex.DecodeString(hexStr) + if err != nil { + return nil, fmt.Errorf("failed to decode hex: %w", err) + } + return NewHeaderFromBytes(data) +} + +// Bytes serializes the block header to an 80-byte slice +func (h *Header) Bytes() []byte { + buf := new(bytes.Buffer) + buf.Grow(HeaderSize) + + // Write version (4 bytes, little-endian) + binary.Write(buf, binary.LittleEndian, h.Version) + + // Write previous block hash (32 bytes) + buf.Write(h.PrevBlock[:]) + + // Write merkle root (32 bytes) + buf.Write(h.MerkleRoot[:]) + + // Write timestamp (4 bytes, little-endian) + binary.Write(buf, binary.LittleEndian, h.Timestamp) + + // Write bits (4 bytes, little-endian) + binary.Write(buf, binary.LittleEndian, h.Bits) + + // Write nonce (4 bytes, little-endian) + binary.Write(buf, binary.LittleEndian, h.Nonce) + + return buf.Bytes() +} + +// Hex returns the block header as a hex string +func (h *Header) Hex() string { + return hex.EncodeToString(h.Bytes()) +} + +// Hash calculates the block hash (double SHA-256 of the header) +func (h *Header) Hash() chainhash.Hash { + return chainhash.DoubleHashH(h.Bytes()) +} + +// String returns a string representation of the header +func (h *Header) String() string { + return fmt.Sprintf("Header{Hash: %s, PrevBlock: %s, Height: ?, Bits: %d}", + h.Hash().String(), h.PrevBlock.String(), h.Bits) +} diff --git a/block/header_test.go b/block/header_test.go new file mode 100644 index 00000000..792d2647 --- /dev/null +++ b/block/header_test.go @@ -0,0 +1,112 @@ +package block + +import ( + "encoding/hex" + "testing" +) + +func TestNewHeaderFromBytes(t *testing.T) { + // Genesis block mainnet header + genesisHex := "0100000000000000000000000000000000000000000000000000000000000000000000003ba3edfd7a7b12b27ac72c3e67768f617fc81bc3888a51323a9fb8aa4b1e5e4a29ab5f49ffff001d1dac2b7c" + genesisBytes, err := hex.DecodeString(genesisHex) + if err != nil { + t.Fatalf("Failed to decode genesis hex: %v", err) + } + + header, err := NewHeaderFromBytes(genesisBytes) + if err != nil { + t.Fatalf("NewHeaderFromBytes() error = %v", err) + } + + if header.Version != 1 { + t.Errorf("Version = %d, want 1", header.Version) + } + + if header.Timestamp != 1231006505 { + t.Errorf("Timestamp = %d, want 1231006505", header.Timestamp) + } + + if header.Bits != 0x1d00ffff { + t.Errorf("Bits = %x, want 0x1d00ffff", header.Bits) + } + + if header.Nonce != 2083236893 { + t.Errorf("Nonce = %d, want 2083236893", header.Nonce) + } +} + +func TestHeaderBytes(t *testing.T) { + genesisHex := "0100000000000000000000000000000000000000000000000000000000000000000000003ba3edfd7a7b12b27ac72c3e67768f617fc81bc3888a51323a9fb8aa4b1e5e4a29ab5f49ffff001d1dac2b7c" + genesisBytes, _ := hex.DecodeString(genesisHex) + + header, err := NewHeaderFromBytes(genesisBytes) + if err != nil { + t.Fatalf("NewHeaderFromBytes() error = %v", err) + } + + serialized := header.Bytes() + + if len(serialized) != HeaderSize { + t.Errorf("Bytes() returned %d bytes, want %d", len(serialized), HeaderSize) + } + + if hex.EncodeToString(serialized) != genesisHex { + t.Errorf("Bytes() = %s, want %s", hex.EncodeToString(serialized), genesisHex) + } +} + +func TestHeaderHex(t *testing.T) { + genesisHex := "0100000000000000000000000000000000000000000000000000000000000000000000003ba3edfd7a7b12b27ac72c3e67768f617fc81bc3888a51323a9fb8aa4b1e5e4a29ab5f49ffff001d1dac2b7c" + + header, err := NewHeaderFromHex(genesisHex) + if err != nil { + t.Fatalf("NewHeaderFromHex() error = %v", err) + } + + if header.Hex() != genesisHex { + t.Errorf("Hex() = %s, want %s", header.Hex(), genesisHex) + } +} + +func TestHeaderHash(t *testing.T) { + // Genesis block mainnet + genesisHex := "0100000000000000000000000000000000000000000000000000000000000000000000003ba3edfd7a7b12b27ac72c3e67768f617fc81bc3888a51323a9fb8aa4b1e5e4a29ab5f49ffff001d1dac2b7c" + expectedHash := "000000000019d6689c085ae165831e934ff763ae46a2a6c172b3f1b60a8ce26f" + + header, err := NewHeaderFromHex(genesisHex) + if err != nil { + t.Fatalf("NewHeaderFromHex() error = %v", err) + } + + hash := header.Hash() + hashStr := hash.String() + + if hashStr != expectedHash { + t.Errorf("Hash() = %s, want %s", hashStr, expectedHash) + } +} + +func TestNewHeaderFromBytesInvalidSize(t *testing.T) { + invalidData := []byte{0x01, 0x02, 0x03} + + _, err := NewHeaderFromBytes(invalidData) + if err == nil { + t.Error("NewHeaderFromBytes() with invalid size should return error") + } +} + +func TestHeaderPrevBlockAndMerkleRoot(t *testing.T) { + // Block 1 mainnet header + block1Hex := "010000006fe28c0ab6f1b372c1a6a246ae63f74f931e8365e15a089c68d6190000000000982051fd1e4ba744bbbe680e1fee14677ba1a3c3540bf7b1cdb606e857233e0e61bc6649ffff001d01e36299" + expectedPrevBlock := "000000000019d6689c085ae165831e934ff763ae46a2a6c172b3f1b60a8ce26f" + + header, err := NewHeaderFromHex(block1Hex) + if err != nil { + t.Fatalf("NewHeaderFromHex() error = %v", err) + } + + prevBlockStr := header.PrevBlock.String() + if prevBlockStr != expectedPrevBlock { + t.Errorf("PrevBlock = %s, want %s", prevBlockStr, expectedPrevBlock) + } +} From b56b04569fb392cfe2060066b92c8b5f1fecad2a Mon Sep 17 00:00:00 2001 From: David Case Date: Wed, 19 Nov 2025 14:45:03 +0000 Subject: [PATCH 2/6] json tags and lint fix --- block/header.go | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/block/header.go b/block/header.go index 8ec72516..f3705459 100644 --- a/block/header.go +++ b/block/header.go @@ -17,12 +17,12 @@ const ( // Header represents a Bitcoin block header (80 bytes) type Header struct { - Version int32 // 4 bytes - Block version - PrevBlock chainhash.Hash // 32 bytes - Previous block hash - MerkleRoot chainhash.Hash // 32 bytes - Merkle root hash - Timestamp uint32 // 4 bytes - Block timestamp (Unix time) - Bits uint32 // 4 bytes - Difficulty target - Nonce uint32 // 4 bytes - Nonce + Version int32 `json:"version"` // 4 bytes - Block version + PrevBlock chainhash.Hash `json:"prevBlock"` // 32 bytes - Previous block hash + MerkleRoot chainhash.Hash `json:"merkleRoot"` // 32 bytes - Merkle root hash + Timestamp uint32 `json:"timestamp"` // 4 bytes - Block timestamp (Unix time) + Bits uint32 `json:"bits"` // 4 bytes - Difficulty target + Nonce uint32 `json:"nonce"` // 4 bytes - Nonce } // NewHeaderFromBytes creates a BlockHeader from an 80-byte slice @@ -82,7 +82,7 @@ func (h *Header) Bytes() []byte { buf.Grow(HeaderSize) // Write version (4 bytes, little-endian) - binary.Write(buf, binary.LittleEndian, h.Version) + _ = binary.Write(buf, binary.LittleEndian, h.Version) // Write previous block hash (32 bytes) buf.Write(h.PrevBlock[:]) @@ -91,13 +91,13 @@ func (h *Header) Bytes() []byte { buf.Write(h.MerkleRoot[:]) // Write timestamp (4 bytes, little-endian) - binary.Write(buf, binary.LittleEndian, h.Timestamp) + _ = binary.Write(buf, binary.LittleEndian, h.Timestamp) // Write bits (4 bytes, little-endian) - binary.Write(buf, binary.LittleEndian, h.Bits) + _ = binary.Write(buf, binary.LittleEndian, h.Bits) // Write nonce (4 bytes, little-endian) - binary.Write(buf, binary.LittleEndian, h.Nonce) + _ = binary.Write(buf, binary.LittleEndian, h.Nonce) return buf.Bytes() } From d6554738622be3e91c3355d9306378adb6d0c7d2 Mon Sep 17 00:00:00 2001 From: David Case Date: Wed, 19 Nov 2025 13:10:29 -0500 Subject: [PATCH 3/6] update block header fields --- block/header.go | 18 +++++++++--------- block/header_test.go | 2 +- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/block/header.go b/block/header.go index f3705459..1700880e 100644 --- a/block/header.go +++ b/block/header.go @@ -17,12 +17,12 @@ const ( // Header represents a Bitcoin block header (80 bytes) type Header struct { - Version int32 `json:"version"` // 4 bytes - Block version - PrevBlock chainhash.Hash `json:"prevBlock"` // 32 bytes - Previous block hash - MerkleRoot chainhash.Hash `json:"merkleRoot"` // 32 bytes - Merkle root hash - Timestamp uint32 `json:"timestamp"` // 4 bytes - Block timestamp (Unix time) - Bits uint32 `json:"bits"` // 4 bytes - Difficulty target - Nonce uint32 `json:"nonce"` // 4 bytes - Nonce + Version int32 `json:"version"` // 4 bytes - Block version + PrevHash chainhash.Hash `json:"previousHash"` // 32 bytes - Previous block hash + MerkleRoot chainhash.Hash `json:"merkleRoot"` // 32 bytes - Merkle root hash + Timestamp uint32 `json:"time"` // 4 bytes - Block timestamp (Unix time) + Bits uint32 `json:"bits"` // 4 bytes - Difficulty target + Nonce uint32 `json:"nonce"` // 4 bytes - Nonce } // NewHeaderFromBytes creates a BlockHeader from an 80-byte slice @@ -40,7 +40,7 @@ func NewHeaderFromBytes(data []byte) (*Header, error) { } // Read previous block hash (32 bytes) - if _, err := io.ReadFull(r, h.PrevBlock[:]); err != nil { + if _, err := io.ReadFull(r, h.PrevHash[:]); err != nil { return nil, fmt.Errorf("failed to read prev block hash: %w", err) } @@ -85,7 +85,7 @@ func (h *Header) Bytes() []byte { _ = binary.Write(buf, binary.LittleEndian, h.Version) // Write previous block hash (32 bytes) - buf.Write(h.PrevBlock[:]) + buf.Write(h.PrevHash[:]) // Write merkle root (32 bytes) buf.Write(h.MerkleRoot[:]) @@ -115,5 +115,5 @@ func (h *Header) Hash() chainhash.Hash { // String returns a string representation of the header func (h *Header) String() string { return fmt.Sprintf("Header{Hash: %s, PrevBlock: %s, Height: ?, Bits: %d}", - h.Hash().String(), h.PrevBlock.String(), h.Bits) + h.Hash().String(), h.PrevHash.String(), h.Bits) } diff --git a/block/header_test.go b/block/header_test.go index 792d2647..edd03fad 100644 --- a/block/header_test.go +++ b/block/header_test.go @@ -105,7 +105,7 @@ func TestHeaderPrevBlockAndMerkleRoot(t *testing.T) { t.Fatalf("NewHeaderFromHex() error = %v", err) } - prevBlockStr := header.PrevBlock.String() + prevBlockStr := header.PrevHash.String() if prevBlockStr != expectedPrevBlock { t.Errorf("PrevBlock = %s, want %s", prevBlockStr, expectedPrevBlock) } From e69417a9da7ddf186dcfe09230cd0879b966f134 Mon Sep 17 00:00:00 2001 From: David Case Date: Tue, 2 Dec 2025 18:24:17 +0000 Subject: [PATCH 4/6] implement Clone methods for Beef and MerklePath types --- transaction/beef.go | 58 +++++++++++++++++++++++++++++++++++++-- transaction/merklepath.go | 9 ++++++ 2 files changed, 64 insertions(+), 3 deletions(-) diff --git a/transaction/beef.go b/transaction/beef.go index ba5c6d2b..e91be7d5 100644 --- a/transaction/beef.go +++ b/transaction/beef.go @@ -1138,15 +1138,67 @@ txLoop: return log } +// Clone creates a deep copy of the Beef object. +// All nested structures are copied, so modifications to the clone +// will not affect the original. func (b *Beef) Clone() *Beef { c := &Beef{ Version: b.Version, - BUMPs: append([]*MerklePath(nil), b.BUMPs...), + BUMPs: make([]*MerklePath, len(b.BUMPs)), Transactions: make(map[chainhash.Hash]*BeefTx, len(b.Transactions)), } - for k, v := range b.Transactions { - c.Transactions[k] = v + + // Deep clone BUMPs + for i, mp := range b.BUMPs { + c.BUMPs[i] = mp.Clone() } + + // First pass: ShallowClone all Transactions + for txid, beefTx := range b.Transactions { + cloned := &BeefTx{ + DataFormat: beefTx.DataFormat, + BumpIndex: beefTx.BumpIndex, + } + + if beefTx.KnownTxID != nil { + id := *beefTx.KnownTxID + cloned.KnownTxID = &id + } + + if beefTx.InputTxids != nil { + cloned.InputTxids = make([]*chainhash.Hash, len(beefTx.InputTxids)) + for i, inputTxid := range beefTx.InputTxids { + if inputTxid != nil { + id := *inputTxid + cloned.InputTxids[i] = &id + } + } + } + + if beefTx.Transaction != nil { + cloned.Transaction = beefTx.Transaction.ShallowClone() + // Link to cloned BUMP + if beefTx.DataFormat == RawTxAndBumpIndex && beefTx.BumpIndex >= 0 && beefTx.BumpIndex < len(c.BUMPs) { + cloned.Transaction.MerklePath = c.BUMPs[beefTx.BumpIndex] + } + } + + c.Transactions[txid] = cloned + } + + // Second pass: wire up SourceTransaction references + for _, beefTx := range c.Transactions { + if beefTx.Transaction != nil { + for _, input := range beefTx.Transaction.Inputs { + if input.SourceTXID != nil { + if sourceBeefTx, ok := c.Transactions[*input.SourceTXID]; ok && sourceBeefTx.Transaction != nil { + input.SourceTransaction = sourceBeefTx.Transaction + } + } + } + } + } + return c } diff --git a/transaction/merklepath.go b/transaction/merklepath.go index 6efad50a..b1ee2c63 100644 --- a/transaction/merklepath.go +++ b/transaction/merklepath.go @@ -55,6 +55,15 @@ func (ip IndexedPath) GetOffsetLeaf(layer int, offset uint64) *PathElement { return nil } +// Clone creates a deep copy of the MerklePath by serializing and deserializing. +func (mp *MerklePath) Clone() *MerklePath { + if mp == nil { + return nil + } + clone, _ := NewMerklePathFromBinary(mp.Bytes()) + return clone +} + // NewMerklePath creates a new MerklePath with the given block height and path func NewMerklePath(blockHeight uint32, path [][]*PathElement) *MerklePath { return &MerklePath{ From 1b170d7f5106ea56811420a06845a20a42756886 Mon Sep 17 00:00:00 2001 From: David Case Date: Fri, 5 Dec 2025 13:20:37 -0500 Subject: [PATCH 5/6] add tests --- block/header_test.go | 27 +++++++++++++++++++++++++++ transaction/beef_test.go | 20 ++++++++++++++++++++ transaction/merklepath_test.go | 22 ++++++++++++++++++++++ 3 files changed, 69 insertions(+) diff --git a/block/header_test.go b/block/header_test.go index edd03fad..33c85b94 100644 --- a/block/header_test.go +++ b/block/header_test.go @@ -2,6 +2,7 @@ package block import ( "encoding/hex" + "strings" "testing" ) @@ -110,3 +111,29 @@ func TestHeaderPrevBlockAndMerkleRoot(t *testing.T) { t.Errorf("PrevBlock = %s, want %s", prevBlockStr, expectedPrevBlock) } } + +func TestHeaderString(t *testing.T) { + genesisHex := "0100000000000000000000000000000000000000000000000000000000000000000000003ba3edfd7a7b12b27ac72c3e67768f617fc81bc3888a51323a9fb8aa4b1e5e4a29ab5f49ffff001d1dac2b7c" + + header, err := NewHeaderFromHex(genesisHex) + if err != nil { + t.Fatalf("NewHeaderFromHex() error = %v", err) + } + + str := header.String() + if str == "" { + t.Error("String() returned empty string") + } + + expectedHash := "000000000019d6689c085ae165831e934ff763ae46a2a6c172b3f1b60a8ce26f" + if !strings.Contains(str, expectedHash) { + t.Errorf("String() should contain hash %s, got %s", expectedHash, str) + } +} + +func TestNewHeaderFromHexInvalid(t *testing.T) { + _, err := NewHeaderFromHex("invalid hex") + if err == nil { + t.Error("NewHeaderFromHex() with invalid hex should return error") + } +} diff --git a/transaction/beef_test.go b/transaction/beef_test.go index 999798ab..96264e2f 100644 --- a/transaction/beef_test.go +++ b/transaction/beef_test.go @@ -267,6 +267,26 @@ func TestBeefClone(t *testing.T) { original, err := NewBeefFromBytes(beefBytes) require.NoError(t, err) + // Test cloning with nil fields by adding a BeefTx with minimal data + nilFieldsTxID := chainhash.HashH([]byte("test-nil-fields")) + original.Transactions[nilFieldsTxID] = &BeefTx{ + DataFormat: TxIDOnly, + KnownTxID: nil, + InputTxids: nil, + Transaction: nil, + } + + // Test cloning with InputTxids populated + inputTxidsTxID := chainhash.HashH([]byte("test-input-txids")) + knownID := chainhash.HashH([]byte("known-id")) + inputID1 := chainhash.HashH([]byte("input-1")) + original.Transactions[inputTxidsTxID] = &BeefTx{ + DataFormat: TxIDOnly, + KnownTxID: &knownID, + InputTxids: []*chainhash.Hash{&inputID1, nil}, + Transaction: nil, + } + // Clone the object clone := original.Clone() diff --git a/transaction/merklepath_test.go b/transaction/merklepath_test.go index 5d5a71ad..8a2fb827 100644 --- a/transaction/merklepath_test.go +++ b/transaction/merklepath_test.go @@ -296,3 +296,25 @@ func TestMerklePathCombine(t *testing.T) { } }) } + +func TestMerklePathClone(t *testing.T) { + t.Run("clones nil merkle path", func(t *testing.T) { + var mp *MerklePath + clone := mp.Clone() + require.Nil(t, clone) + }) + + t.Run("clones valid merkle path", func(t *testing.T) { + original, err := NewMerklePathFromHex(BRC74Hex) + require.NoError(t, err) + + clone := original.Clone() + require.NotNil(t, clone) + require.Equal(t, original.BlockHeight, clone.BlockHeight) + require.Equal(t, len(original.Path), len(clone.Path)) + + // Verify modifying clone doesn't affect original + clone.BlockHeight = 999999 + require.NotEqual(t, original.BlockHeight, clone.BlockHeight) + }) +} From 2b5c465711b8efb577edd51f58582d91839d6e47 Mon Sep 17 00:00:00 2001 From: David Case Date: Fri, 5 Dec 2025 13:28:54 -0500 Subject: [PATCH 6/6] update changelog --- CHANGELOG.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1db12699..1192abf2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ All notable changes to this project will be documented in this file. The format ## Table of Contents +- [1.2.13 - 2025-12-05](#1213---2025-12-05) - [1.2.12 - 2025-11-12](#1212---2025-11-12) - [1.2.11 - 2025-10-27](#1211---2025-10-27) - [1.2.10 - 2025-09-16](#1210---2025-09-16) @@ -47,6 +48,15 @@ All notable changes to this project will be documented in this file. The format - [1.1.0 - 2024-08-19](#110---2024-08-19) - [1.0.0 - 2024-06-06](#100---2024-06-06) +## [1.2.13] - 2025-12-05 + +### Added +- `Header` type in block package for 80-byte Bitcoin block header parsing +- `MerklePath.Clone()` method for deep copying merkle paths + +### Fixed +- `Beef.Clone()` now performs deep copy of all nested structures (BUMPs, transactions, input references) + ## [1.2.12] - 2025-11-12 ### Added