diff --git a/consensus/errors.go b/consensus/errors.go index 93b788d..181feb4 100644 --- a/consensus/errors.go +++ b/consensus/errors.go @@ -20,6 +20,8 @@ var ( ErrInvalidOutput = errors.New("consensus: invalid output") ErrDuplicateKeyImage = errors.New("consensus: duplicate key image in transaction") ErrInvalidExtra = errors.New("consensus: invalid extra field") + ErrTxVersionInvalid = errors.New("consensus: invalid transaction version for current hardfork") + ErrPreHardforkFreeze = errors.New("consensus: non-coinbase transaction rejected during pre-hardfork freeze") // Transaction economic errors. ErrInputOverflow = errors.New("consensus: input amount overflow") diff --git a/consensus/tx.go b/consensus/tx.go index 7b6e7f7..e9b462a 100644 --- a/consensus/tx.go +++ b/consensus/tx.go @@ -18,6 +18,11 @@ func ValidateTransaction(tx *types.Transaction, txBlob []byte, forks []config.Ha hf1Active := config.IsHardForkActive(forks, config.HF1, height) hf4Active := config.IsHardForkActive(forks, config.HF4Zarcanum, height) + // 0. Transaction version for current hardfork. + if err := checkTxVersion(tx, forks, height); err != nil { + return err + } + // 1. Blob size. if uint64(len(txBlob)) >= config.MaxTransactionBlobSize { return fmt.Errorf("%w: %d bytes", ErrTxTooLarge, len(txBlob)) @@ -122,6 +127,29 @@ func checkOutputs(tx *types.Transaction, hf1Active, hf4Active bool) error { return nil } +// checkTxVersion validates that the transaction version is correct for the +// current hardfork era. After HF5, version must be 3. Before HF5, version 3 +// is rejected. +func checkTxVersion(tx *types.Transaction, forks []config.HardFork, height uint64) error { + hf5Active := config.IsHardForkActive(forks, config.HF5, height) + + if hf5Active { + // After HF5: must be version 3. + if tx.Version != types.VersionPostHF5 { + return fmt.Errorf("%w: got version %d, require %d after HF5", + ErrTxVersionInvalid, tx.Version, types.VersionPostHF5) + } + } else { + // Before HF5: version 3 is not allowed. + if tx.Version >= types.VersionPostHF5 { + return fmt.Errorf("%w: version %d not allowed before HF5", + ErrTxVersionInvalid, tx.Version) + } + } + + return nil +} + func checkKeyImages(tx *types.Transaction) error { seen := make(map[types.KeyImage]struct{}) for _, vin := range tx.Vin { diff --git a/consensus/tx_version_test.go b/consensus/tx_version_test.go new file mode 100644 index 0000000..d8ecbe7 --- /dev/null +++ b/consensus/tx_version_test.go @@ -0,0 +1,112 @@ +// 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 + +//go:build !integration + +package consensus + +import ( + "testing" + + "forge.lthn.ai/core/go-blockchain/config" + "forge.lthn.ai/core/go-blockchain/types" +) + +// validV2Tx returns a minimal valid v2 (Zarcanum) transaction for testing. +func validV2Tx() *types.Transaction { + return &types.Transaction{ + Version: types.VersionPostHF4, + Vin: []types.TxInput{ + types.TxInputZC{ + KeyImage: types.KeyImage{1}, + }, + }, + Vout: []types.TxOutput{ + types.TxOutputZarcanum{StealthAddress: types.PublicKey{1}}, + types.TxOutputZarcanum{StealthAddress: types.PublicKey{2}}, + }, + } +} + +// validV3Tx returns a minimal valid v3 (HF5) transaction for testing. +func validV3Tx() *types.Transaction { + return &types.Transaction{ + Version: types.VersionPostHF5, + HardforkID: 5, + Vin: []types.TxInput{ + types.TxInputZC{ + KeyImage: types.KeyImage{1}, + }, + }, + Vout: []types.TxOutput{ + types.TxOutputZarcanum{StealthAddress: types.PublicKey{1}}, + types.TxOutputZarcanum{StealthAddress: types.PublicKey{2}}, + }, + } +} + +func TestCheckTxVersion_Good(t *testing.T) { + tests := []struct { + name string + tx *types.Transaction + forks []config.HardFork + height uint64 + }{ + // v1 transaction before HF4 — valid. + {"v1_before_hf4", validV1Tx(), config.MainnetForks, 5000}, + // v2 transaction after HF4, before HF5 — valid. + {"v2_after_hf4_before_hf5", validV2Tx(), config.TestnetForks, 150}, + // v3 transaction after HF5 — valid. + {"v3_after_hf5", validV3Tx(), config.TestnetForks, 250}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := checkTxVersion(tt.tx, tt.forks, tt.height) + if err != nil { + t.Errorf("checkTxVersion returned unexpected error: %v", err) + } + }) + } +} + +func TestCheckTxVersion_Bad(t *testing.T) { + tests := []struct { + name string + tx *types.Transaction + forks []config.HardFork + height uint64 + }{ + // v2 transaction after HF5 — must be v3. + {"v2_after_hf5", validV2Tx(), config.TestnetForks, 250}, + // v3 transaction before HF5 — too early. + {"v3_before_hf5", validV3Tx(), config.TestnetForks, 150}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := checkTxVersion(tt.tx, tt.forks, tt.height) + if err == nil { + t.Error("expected ErrTxVersionInvalid, got nil") + } + }) + } +} + +func TestCheckTxVersion_Ugly(t *testing.T) { + // v3 at exact HF5 activation boundary (height 201 on testnet, HF5.Height=200). + tx := validV3Tx() + err := checkTxVersion(tx, config.TestnetForks, 201) + if err != nil { + t.Errorf("v3 at HF5 activation boundary should be valid: %v", err) + } + + // v2 at exact HF5 activation boundary — should be rejected. + tx2 := validV2Tx() + err = checkTxVersion(tx2, config.TestnetForks, 201) + if err == nil { + t.Error("v2 at HF5 activation boundary should be rejected") + } +}