From f7ee451fc47365d42e83f6fc1743b2cc818e73ce Mon Sep 17 00:00:00 2001 From: Virgil Date: Sat, 4 Apr 2026 18:30:32 +0000 Subject: [PATCH] fix(blockchain): enforce HF5 freeze and peer build gate Co-Authored-By: Virgil --- chain/sync.go | 4 +-- chain/sync_test.go | 78 +++++++++++++++++++++++++++++++++++++++++++++ p2p/version.go | 78 +++++++++++++++++++++++++++++++++++++++++++++ p2p/version_test.go | 71 +++++++++++++++++++++++++++++++++++++++++ sync_loop.go | 9 ++++++ 5 files changed, 238 insertions(+), 2 deletions(-) create mode 100644 p2p/version.go create mode 100644 p2p/version_test.go diff --git a/chain/sync.go b/chain/sync.go index 27d6154..e81d2e1 100644 --- a/chain/sync.go +++ b/chain/sync.go @@ -217,8 +217,8 @@ func (c *Chain) processBlockBlobs(blockBlob []byte, txBlobs [][]byte, txHash := wire.TransactionHash(&tx) - // Validate transaction semantics. - if err := consensus.ValidateTransaction(&tx, txBlobData, opts.Forks, height); err != nil { + // Validate transaction semantics, including the HF5 freeze window. + if err := consensus.ValidateTransactionInBlock(&tx, txBlobData, opts.Forks, height); err != nil { return coreerr.E("Chain.processBlockBlobs", fmt.Sprintf("validate tx %s", txHash), err) } diff --git a/chain/sync_test.go b/chain/sync_test.go index 645cedd..5f37d2d 100644 --- a/chain/sync_test.go +++ b/chain/sync_test.go @@ -12,6 +12,7 @@ import ( "encoding/json" "net/http" "net/http/httptest" + "strings" "testing" "github.com/stretchr/testify/assert" @@ -735,6 +736,83 @@ func TestSync_Bad_InvalidBlockBlob(t *testing.T) { } } +func TestSync_Bad_PreHardforkFreeze(t *testing.T) { + genesisBlob, genesisHash := makeGenesisBlockBlob() + + regularTx := types.Transaction{ + Version: 1, + Vin: []types.TxInput{ + types.TxInputToKey{ + Amount: 1000000000000, + KeyOffsets: []types.TxOutRef{{ + Tag: types.RefTypeGlobalIndex, + GlobalIndex: 0, + }}, + KeyImage: types.KeyImage{0xaa, 0xbb, 0xcc}, + EtcDetails: wire.EncodeVarint(0), + }, + }, + Vout: []types.TxOutput{ + types.TxOutputBare{ + Amount: 900000000000, + Target: types.TxOutToKey{Key: types.PublicKey{0x02}}, + }, + }, + Extra: wire.EncodeVarint(0), + Attachment: wire.EncodeVarint(0), + } + + var txBuf bytes.Buffer + txEnc := wire.NewEncoder(&txBuf) + wire.EncodeTransaction(txEnc, ®ularTx) + regularTxBlob := txBuf.Bytes() + regularTxHash := wire.TransactionHash(®ularTx) + + minerTx1 := testCoinbaseTx(1) + block1 := types.Block{ + BlockHeader: types.BlockHeader{ + MajorVersion: 1, + Nonce: 42, + PrevID: genesisHash, + Timestamp: 1770897720, + }, + MinerTx: minerTx1, + TxHashes: []types.Hash{regularTxHash}, + } + + var blk1Buf bytes.Buffer + blk1Enc := wire.NewEncoder(&blk1Buf) + wire.EncodeBlock(blk1Enc, &block1) + block1Blob := blk1Buf.Bytes() + + orig := GenesisHash + GenesisHash = genesisHash.String() + t.Cleanup(func() { GenesisHash = orig }) + + s, _ := store.New(":memory:") + defer s.Close() + c := New(s) + + opts := SyncOptions{ + Forks: []config.HardFork{ + {Version: config.HF0Initial, Height: 0, Mandatory: true}, + {Version: config.HF5, Height: 2, Mandatory: true}, + }, + } + + if err := c.processBlockBlobs(genesisBlob, nil, 0, 1, opts); err != nil { + t.Fatalf("process genesis: %v", err) + } + + err := c.processBlockBlobs(block1Blob, [][]byte{regularTxBlob}, 1, 100, opts) + if err == nil { + t.Fatal("expected freeze rejection, got nil") + } + if !strings.Contains(err.Error(), "freeze") { + t.Fatalf("expected freeze error, got %v", err) + } +} + // testCoinbaseTxV2 creates a v2 (post-HF4) coinbase transaction with Zarcanum outputs. func testCoinbaseTxV2(height uint64) types.Transaction { return types.Transaction{ diff --git a/p2p/version.go b/p2p/version.go new file mode 100644 index 0000000..5620261 --- /dev/null +++ b/p2p/version.go @@ -0,0 +1,78 @@ +// Copyright (c) 2017-2026 Lethean (https://lt.hn) +// +// Licensed under the European Union Public Licence (EUPL) version 1.2. +// SPDX-License-Identifier: EUPL-1.2 + +package p2p + +import ( + "strconv" + "strings" +) + +const ( + // MinimumRequiredBuildVersionMainnet matches the C++ daemon's mainnet gate. + MinimumRequiredBuildVersionMainnet uint64 = 601 + + // MinimumRequiredBuildVersionTestnet matches the C++ daemon's testnet gate. + MinimumRequiredBuildVersionTestnet uint64 = 2 +) + +// MinimumRequiredBuildVersion returns the minimum accepted peer version gate +// for the given network. +func MinimumRequiredBuildVersion(isTestnet bool) uint64 { + if isTestnet { + return MinimumRequiredBuildVersionTestnet + } + return MinimumRequiredBuildVersionMainnet +} + +// PeerBuildVersion extracts the numeric major.minor.revision component from a +// daemon client version string. +// +// The daemon formats its version as "major.minor.revision.build[extra]". +// The minimum build gate compares the first three components, so +// "6.0.1.2[go-blockchain]" becomes 601. +func PeerBuildVersion(clientVersion string) (uint64, bool) { + parts := strings.SplitN(clientVersion, ".", 4) + if len(parts) < 3 { + return 0, false + } + + major, err := strconv.ParseUint(parts[0], 10, 64) + if err != nil { + return 0, false + } + minor, err := strconv.ParseUint(parts[1], 10, 64) + if err != nil { + return 0, false + } + + revPart := parts[2] + for i := 0; i < len(revPart); i++ { + if revPart[i] < '0' || revPart[i] > '9' { + revPart = revPart[:i] + break + } + } + if revPart == "" { + return 0, false + } + + revision, err := strconv.ParseUint(revPart, 10, 64) + if err != nil { + return 0, false + } + + return major*100 + minor*10 + revision, true +} + +// MeetsMinimumBuildVersion reports whether the peer's version is acceptable +// for the given network. +func MeetsMinimumBuildVersion(clientVersion string, isTestnet bool) bool { + buildVersion, ok := PeerBuildVersion(clientVersion) + if !ok { + return false + } + return buildVersion >= MinimumRequiredBuildVersion(isTestnet) +} diff --git a/p2p/version_test.go b/p2p/version_test.go new file mode 100644 index 0000000..7e97f6c --- /dev/null +++ b/p2p/version_test.go @@ -0,0 +1,71 @@ +// Copyright (c) 2017-2026 Lethean (https://lt.hn) +// +// Licensed under the European Union Public Licence (EUPL) version 1.2. +// SPDX-License-Identifier: EUPL-1.2 + +package p2p + +import "testing" + +func TestPeerBuildVersion_Good(t *testing.T) { + tests := []struct { + name string + input string + want uint64 + wantOK bool + }{ + {"release", "6.0.1.2[go-blockchain]", 601, true}, + {"two_digits", "12.3.4.5", 1234, true}, + {"suffix", "6.0.1-beta.2", 601, true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, ok := PeerBuildVersion(tt.input) + if ok != tt.wantOK { + t.Fatalf("PeerBuildVersion(%q) ok = %v, want %v", tt.input, ok, tt.wantOK) + } + if got != tt.want { + t.Fatalf("PeerBuildVersion(%q) = %d, want %d", tt.input, got, tt.want) + } + }) + } +} + +func TestPeerBuildVersion_Bad(t *testing.T) { + tests := []string{ + "", + "6", + "6.0", + "abc.def.ghi", + } + + for _, input := range tests { + t.Run(input, func(t *testing.T) { + if got, ok := PeerBuildVersion(input); ok || got != 0 { + t.Fatalf("PeerBuildVersion(%q) = (%d, %v), want (0, false)", input, got, ok) + } + }) + } +} + +func TestMeetsMinimumBuildVersion_Good(t *testing.T) { + if !MeetsMinimumBuildVersion("6.0.1.2[go-blockchain]", false) { + t.Fatal("expected mainnet build version to satisfy minimum") + } + if !MeetsMinimumBuildVersion("6.0.1.2[go-blockchain]", true) { + t.Fatal("expected testnet build version to satisfy minimum") + } +} + +func TestMeetsMinimumBuildVersion_Bad(t *testing.T) { + if MeetsMinimumBuildVersion("0.0.1.0", false) { + t.Fatal("expected low mainnet build version to fail") + } + if MeetsMinimumBuildVersion("0.0.1.0", true) { + t.Fatal("expected low testnet build version to fail") + } + if MeetsMinimumBuildVersion("bogus", false) { + t.Fatal("expected malformed version to fail") + } +} diff --git a/sync_loop.go b/sync_loop.go index 7125ee9..524a68c 100644 --- a/sync_loop.go +++ b/sync_loop.go @@ -102,6 +102,15 @@ func runChainSyncOnce(ctx context.Context, blockchain *chain.Chain, chainConfig return coreerr.E("runChainSyncOnce", "decode handshake", err) } + if !p2p.MeetsMinimumBuildVersion(handshakeResp.PayloadData.ClientVersion, chainConfig.IsTestnet) { + minBuild := p2p.MinimumRequiredBuildVersion(chainConfig.IsTestnet) + return coreerr.E( + "runChainSyncOnce", + fmt.Sprintf("peer build %q below minimum %d", handshakeResp.PayloadData.ClientVersion, minBuild), + nil, + ) + } + localSync := p2p.CoreSyncData{ CurrentHeight: localHeight, ClientVersion: config.ClientVersion,