From e2068338a5339a2ef4e8d2d35fc1a5ed2220e14f Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 21 Feb 2026 00:47:43 +0000 Subject: [PATCH] feat(consensus): transaction semantic validation Eight checks matching C++ validate_tx_semantic(): blob size, input count, input types, output validation, overflow, key image uniqueness, and pre-HF4 balance. Hardfork-aware for HF4+ Zarcanum types. Co-Authored-By: Charon --- consensus/tx.go | 123 +++++++++++++++++++++++++++++++++++++++ consensus/tx_test.go | 135 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 258 insertions(+) create mode 100644 consensus/tx.go create mode 100644 consensus/tx_test.go diff --git a/consensus/tx.go b/consensus/tx.go new file mode 100644 index 0000000..6488954 --- /dev/null +++ b/consensus/tx.go @@ -0,0 +1,123 @@ +// 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 consensus + +import ( + "fmt" + + "forge.lthn.ai/core/go-blockchain/config" + "forge.lthn.ai/core/go-blockchain/types" +) + +// ValidateTransaction performs semantic validation on a regular (non-coinbase) +// transaction. Checks are ordered to match the C++ validate_tx_semantic(). +func ValidateTransaction(tx *types.Transaction, txBlob []byte, forks []config.HardFork, height uint64) error { + hf4Active := config.IsHardForkActive(forks, config.HF4Zarcanum, height) + + // 1. Blob size. + if uint64(len(txBlob)) >= config.MaxTransactionBlobSize { + return fmt.Errorf("%w: %d bytes", ErrTxTooLarge, len(txBlob)) + } + + // 2. Input count. + if len(tx.Vin) == 0 { + return ErrNoInputs + } + if uint64(len(tx.Vin)) > config.TxMaxAllowedInputs { + return fmt.Errorf("%w: %d", ErrTooManyInputs, len(tx.Vin)) + } + + // 3. Input types — TxInputGenesis not allowed in regular transactions. + if err := checkInputTypes(tx, hf4Active); err != nil { + return err + } + + // 4. Output validation. + if err := checkOutputs(tx, hf4Active); err != nil { + return err + } + + // 5. Money overflow. + if _, err := sumInputs(tx); err != nil { + return err + } + if _, err := sumOutputs(tx); err != nil { + return err + } + + // 6. Key image uniqueness. + if err := checkKeyImages(tx); err != nil { + return err + } + + // 7. Balance check (pre-HF4 only — post-HF4 uses commitment proofs). + if !hf4Active { + if _, err := TxFee(tx); err != nil { + return err + } + } + + return nil +} + +func checkInputTypes(tx *types.Transaction, hf4Active bool) error { + for _, vin := range tx.Vin { + switch vin.(type) { + case types.TxInputToKey: + // Always valid. + case types.TxInputGenesis: + return fmt.Errorf("%w: txin_gen in regular transaction", ErrInvalidInputType) + default: + // Future types (multisig, HTLC, ZC) — accept if HF4+. + if !hf4Active { + return fmt.Errorf("%w: tag %d pre-HF4", ErrInvalidInputType, vin.InputType()) + } + } + } + return nil +} + +func checkOutputs(tx *types.Transaction, hf4Active bool) error { + if len(tx.Vout) == 0 { + return ErrNoOutputs + } + + if hf4Active && uint64(len(tx.Vout)) < config.TxMinAllowedOutputs { + return fmt.Errorf("%w: %d (min %d)", ErrTooFewOutputs, len(tx.Vout), config.TxMinAllowedOutputs) + } + + if uint64(len(tx.Vout)) > config.TxMaxAllowedOutputs { + return fmt.Errorf("%w: %d", ErrTooManyOutputs, len(tx.Vout)) + } + + for i, vout := range tx.Vout { + switch o := vout.(type) { + case types.TxOutputBare: + if o.Amount == 0 { + return fmt.Errorf("%w: output %d has zero amount", ErrInvalidOutput, i) + } + case types.TxOutputZarcanum: + // Validated by proof verification. + } + } + + return nil +} + +func checkKeyImages(tx *types.Transaction) error { + seen := make(map[types.KeyImage]struct{}) + for _, vin := range tx.Vin { + toKey, ok := vin.(types.TxInputToKey) + if !ok { + continue + } + if _, exists := seen[toKey.KeyImage]; exists { + return fmt.Errorf("%w: %s", ErrDuplicateKeyImage, toKey.KeyImage) + } + seen[toKey.KeyImage] = struct{}{} + } + return nil +} diff --git a/consensus/tx_test.go b/consensus/tx_test.go new file mode 100644 index 0000000..826ee92 --- /dev/null +++ b/consensus/tx_test.go @@ -0,0 +1,135 @@ +// 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" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// validV1Tx returns a minimal valid v1 transaction for testing. +func validV1Tx() *types.Transaction { + return &types.Transaction{ + Version: types.VersionPreHF4, + Vin: []types.TxInput{ + types.TxInputToKey{ + Amount: 100, + KeyImage: types.KeyImage{1}, + }, + }, + Vout: []types.TxOutput{ + types.TxOutputBare{Amount: 90, Target: types.TxOutToKey{Key: types.PublicKey{1}}}, + }, + } +} + +func TestValidateTransaction_Good(t *testing.T) { + tx := validV1Tx() + blob := make([]byte, 100) // small blob + err := ValidateTransaction(tx, blob, config.MainnetForks, 5000) + require.NoError(t, err) +} + +func TestValidateTransaction_BlobTooLarge(t *testing.T) { + tx := validV1Tx() + blob := make([]byte, config.MaxTransactionBlobSize+1) + err := ValidateTransaction(tx, blob, config.MainnetForks, 5000) + assert.ErrorIs(t, err, ErrTxTooLarge) +} + +func TestValidateTransaction_NoInputs(t *testing.T) { + tx := validV1Tx() + tx.Vin = nil + blob := make([]byte, 100) + err := ValidateTransaction(tx, blob, config.MainnetForks, 5000) + assert.ErrorIs(t, err, ErrNoInputs) +} + +func TestValidateTransaction_TooManyInputs(t *testing.T) { + tx := validV1Tx() + tx.Vin = make([]types.TxInput, config.TxMaxAllowedInputs+1) + for i := range tx.Vin { + tx.Vin[i] = types.TxInputToKey{Amount: 1, KeyImage: types.KeyImage{byte(i)}} + } + blob := make([]byte, 100) + err := ValidateTransaction(tx, blob, config.MainnetForks, 5000) + assert.ErrorIs(t, err, ErrTooManyInputs) +} + +func TestValidateTransaction_InvalidInputType(t *testing.T) { + tx := validV1Tx() + tx.Vin = []types.TxInput{types.TxInputGenesis{Height: 1}} // genesis not allowed + blob := make([]byte, 100) + err := ValidateTransaction(tx, blob, config.MainnetForks, 5000) + assert.ErrorIs(t, err, ErrInvalidInputType) +} + +func TestValidateTransaction_NoOutputs(t *testing.T) { + tx := validV1Tx() + tx.Vout = nil + blob := make([]byte, 100) + err := ValidateTransaction(tx, blob, config.MainnetForks, 5000) + assert.ErrorIs(t, err, ErrNoOutputs) +} + +func TestValidateTransaction_TooManyOutputs(t *testing.T) { + tx := validV1Tx() + tx.Vout = make([]types.TxOutput, config.TxMaxAllowedOutputs+1) + for i := range tx.Vout { + tx.Vout[i] = types.TxOutputBare{Amount: 1, Target: types.TxOutToKey{Key: types.PublicKey{byte(i)}}} + } + blob := make([]byte, 100) + err := ValidateTransaction(tx, blob, config.MainnetForks, 5000) + assert.ErrorIs(t, err, ErrTooManyOutputs) +} + +func TestValidateTransaction_ZeroOutputAmount(t *testing.T) { + tx := validV1Tx() + tx.Vout = []types.TxOutput{ + types.TxOutputBare{Amount: 0, Target: types.TxOutToKey{Key: types.PublicKey{1}}}, + } + blob := make([]byte, 100) + err := ValidateTransaction(tx, blob, config.MainnetForks, 5000) + assert.ErrorIs(t, err, ErrInvalidOutput) +} + +func TestValidateTransaction_DuplicateKeyImage(t *testing.T) { + ki := types.KeyImage{42} + tx := &types.Transaction{ + Version: types.VersionPreHF4, + Vin: []types.TxInput{ + types.TxInputToKey{Amount: 100, KeyImage: ki}, + types.TxInputToKey{Amount: 50, KeyImage: ki}, // duplicate + }, + Vout: []types.TxOutput{ + types.TxOutputBare{Amount: 140, Target: types.TxOutToKey{Key: types.PublicKey{1}}}, + }, + } + blob := make([]byte, 100) + err := ValidateTransaction(tx, blob, config.MainnetForks, 5000) + assert.ErrorIs(t, err, ErrDuplicateKeyImage) +} + +func TestValidateTransaction_NegativeFee(t *testing.T) { + tx := &types.Transaction{ + Version: types.VersionPreHF4, + Vin: []types.TxInput{ + types.TxInputToKey{Amount: 10, KeyImage: types.KeyImage{1}}, + }, + Vout: []types.TxOutput{ + types.TxOutputBare{Amount: 100, Target: types.TxOutToKey{Key: types.PublicKey{1}}}, + }, + } + blob := make([]byte, 100) + err := ValidateTransaction(tx, blob, config.MainnetForks, 5000) + assert.ErrorIs(t, err, ErrNegativeFee) +}