feat(consensus): validate HF5 asset operations
Some checks are pending
Security Scan / security (push) Waiting to run
Test / Test (push) Waiting to run

Co-Authored-By: Charon <charon@lethean.io>
This commit is contained in:
Virgil 2026-04-04 20:14:54 +00:00
parent c1b68523c6
commit 050d530b29
6 changed files with 346 additions and 9 deletions

View file

@ -7,17 +7,20 @@ package consensus
import (
"fmt"
"unicode/utf8"
coreerr "dappco.re/go/core/log"
"dappco.re/go/core/blockchain/config"
"dappco.re/go/core/blockchain/types"
"dappco.re/go/core/blockchain/wire"
)
// 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)
hf5Active := config.IsHardForkActive(forks, config.HF5, height)
// 0. Transaction version.
if err := checkTxVersion(tx, forks, height); err != nil {
@ -49,6 +52,11 @@ func ValidateTransaction(tx *types.Transaction, txBlob []byte, forks []config.Ha
return err
}
// 4a. HF5 asset operation validation inside extra.
if err := checkAssetOperations(tx.Extra, hf5Active); err != nil {
return err
}
// 5. Money overflow.
if _, err := sumInputs(tx); err != nil {
return err
@ -191,3 +199,102 @@ func checkKeyImages(tx *types.Transaction) error {
}
return nil
}
func checkAssetOperations(extra []byte, hf5Active bool) error {
if len(extra) == 0 {
return nil
}
elements, err := wire.DecodeVariantVector(extra)
if err != nil {
return coreerr.E("checkAssetOperations", "parse extra", ErrInvalidExtra)
}
for i, elem := range elements {
if elem.Tag != types.AssetDescriptorOperationTag {
continue
}
if !hf5Active {
return coreerr.E("checkAssetOperations", fmt.Sprintf("extra[%d]: asset descriptor operation pre-HF5", i), ErrInvalidExtra)
}
op, err := wire.DecodeAssetDescriptorOperation(elem.Data)
if err != nil {
return coreerr.E("checkAssetOperations", fmt.Sprintf("extra[%d]: decode asset descriptor operation", i), ErrInvalidExtra)
}
if err := validateAssetDescriptorOperation(op); err != nil {
return coreerr.E("checkAssetOperations", fmt.Sprintf("extra[%d]", i), err)
}
}
return nil
}
func validateAssetDescriptorOperation(op types.AssetDescriptorOperation) error {
switch op.Version {
case 0, 1:
default:
return coreerr.E("validateAssetDescriptorOperation", fmt.Sprintf("unsupported version %d", op.Version), ErrInvalidExtra)
}
switch op.OperationType {
case types.AssetOpRegister:
if !op.AssetID.IsZero() {
return coreerr.E("validateAssetDescriptorOperation", "register operation must not carry asset id", ErrInvalidExtra)
}
if op.Descriptor == nil {
return coreerr.E("validateAssetDescriptorOperation", "register operation missing descriptor", ErrInvalidExtra)
}
if err := validateAssetDescriptorBase(*op.Descriptor); err != nil {
return err
}
if op.AmountToEmit != 0 || op.AmountToBurn != 0 {
return coreerr.E("validateAssetDescriptorOperation", "register operation must not include emission or burn amounts", ErrInvalidExtra)
}
case types.AssetOpEmit, types.AssetOpUpdate, types.AssetOpBurn, types.AssetOpPublicBurn:
if op.AssetID.IsZero() {
return coreerr.E("validateAssetDescriptorOperation", "operation must carry asset id", ErrInvalidExtra)
}
if op.OperationType == types.AssetOpUpdate && op.Descriptor == nil {
return coreerr.E("validateAssetDescriptorOperation", "update operation missing descriptor", ErrInvalidExtra)
}
if op.OperationType == types.AssetOpEmit && op.AmountToEmit == 0 {
return coreerr.E("validateAssetDescriptorOperation", "emit operation has zero amount", ErrInvalidExtra)
}
if (op.OperationType == types.AssetOpBurn || op.OperationType == types.AssetOpPublicBurn) && op.AmountToBurn == 0 {
return coreerr.E("validateAssetDescriptorOperation", "burn operation has zero amount", ErrInvalidExtra)
}
if op.OperationType == types.AssetOpUpdate && op.Descriptor != nil {
if err := validateAssetDescriptorBase(*op.Descriptor); err != nil {
return err
}
}
default:
return coreerr.E("validateAssetDescriptorOperation", fmt.Sprintf("unsupported operation type %d", op.OperationType), ErrInvalidExtra)
}
return nil
}
func validateAssetDescriptorBase(base types.AssetDescriptorBase) error {
tickerLen := utf8.RuneCountInString(base.Ticker)
fullNameLen := utf8.RuneCountInString(base.FullName)
if base.TotalMaxSupply == 0 {
return coreerr.E("validateAssetDescriptorBase", "total max supply must be non-zero", ErrInvalidExtra)
}
if base.CurrentSupply > base.TotalMaxSupply {
return coreerr.E("validateAssetDescriptorBase", fmt.Sprintf("current supply %d exceeds max supply %d", base.CurrentSupply, base.TotalMaxSupply), ErrInvalidExtra)
}
if tickerLen == 0 || tickerLen > 6 {
return coreerr.E("validateAssetDescriptorBase", fmt.Sprintf("ticker length %d out of range", tickerLen), ErrInvalidExtra)
}
if fullNameLen == 0 || fullNameLen > 64 {
return coreerr.E("validateAssetDescriptorBase", fmt.Sprintf("full name length %d out of range", fullNameLen), ErrInvalidExtra)
}
if base.OwnerKey.IsZero() {
return coreerr.E("validateAssetDescriptorBase", "owner key must be non-zero", ErrInvalidExtra)
}
return nil
}

View file

@ -8,10 +8,12 @@
package consensus
import (
"bytes"
"testing"
"dappco.re/go/core/blockchain/config"
"dappco.re/go/core/blockchain/types"
"dappco.re/go/core/blockchain/wire"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@ -272,6 +274,101 @@ func TestCheckOutputs_UnsupportedTarget_Bad(t *testing.T) {
assert.ErrorIs(t, err, ErrInvalidOutput)
}
func assetDescriptorExtraBlob(ticker string, ownerZero bool) []byte {
var buf bytes.Buffer
enc := wire.NewEncoder(&buf)
enc.WriteVarint(1)
enc.WriteUint8(types.AssetDescriptorOperationTag)
assetOp := bytes.Buffer{}
opEnc := wire.NewEncoder(&assetOp)
opEnc.WriteUint8(1) // version
opEnc.WriteUint8(types.AssetOpRegister)
opEnc.WriteUint8(0) // no asset id
opEnc.WriteUint8(1) // descriptor present
opEnc.WriteVarint(uint64(len(ticker)))
opEnc.WriteBytes([]byte(ticker))
opEnc.WriteVarint(7)
opEnc.WriteBytes([]byte("Lethean"))
opEnc.WriteUint64LE(1000000)
opEnc.WriteUint64LE(0)
opEnc.WriteUint8(12)
opEnc.WriteVarint(0)
if ownerZero {
opEnc.WriteBytes(make([]byte, 32))
} else {
opEnc.WriteBytes(bytes.Repeat([]byte{0xAA}, 32))
}
opEnc.WriteVarint(0)
opEnc.WriteUint64LE(0)
opEnc.WriteUint64LE(0)
opEnc.WriteVarint(0)
enc.WriteBytes(assetOp.Bytes())
return buf.Bytes()
}
func TestValidateTransaction_AssetDescriptorOperation_Good(t *testing.T) {
tx := &types.Transaction{
Version: types.VersionPostHF5,
Vin: []types.TxInput{
types.TxInputZC{
KeyImage: types.KeyImage{1},
},
},
Vout: []types.TxOutput{
types.TxOutputBare{
Amount: 90,
Target: types.TxOutToKey{Key: types.PublicKey{1}},
},
types.TxOutputBare{
Amount: 1,
Target: types.TxOutToKey{Key: types.PublicKey{2}},
},
},
Extra: assetDescriptorExtraBlob("LTHN", false),
}
blob := make([]byte, 100)
err := ValidateTransaction(tx, blob, config.TestnetForks, 250)
require.NoError(t, err)
}
func TestValidateTransaction_AssetDescriptorOperationPreHF5_Bad(t *testing.T) {
tx := &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}}},
},
Extra: assetDescriptorExtraBlob("LTHN", false),
}
blob := make([]byte, 100)
err := ValidateTransaction(tx, blob, config.MainnetForks, 5000)
assert.ErrorIs(t, err, ErrInvalidExtra)
}
func TestValidateTransaction_AssetDescriptorOperationInvalid_Bad(t *testing.T) {
tx := &types.Transaction{
Version: types.VersionPostHF5,
Vin: []types.TxInput{
types.TxInputZC{
KeyImage: types.KeyImage{1},
},
},
Vout: []types.TxOutput{
types.TxOutputBare{Amount: 90, Target: types.TxOutToKey{Key: types.PublicKey{1}}},
types.TxOutputBare{Amount: 1, Target: types.TxOutToKey{Key: types.PublicKey{2}}},
},
Extra: assetDescriptorExtraBlob("TOO-LONG", true),
}
blob := make([]byte, 100)
err := ValidateTransaction(tx, blob, config.TestnetForks, 250)
assert.ErrorIs(t, err, ErrInvalidExtra)
}
// --- Key image tests for HTLC (Task 8) ---
func TestCheckKeyImages_HTLCDuplicate_Bad(t *testing.T) {

View file

@ -5,6 +5,10 @@
package types
// AssetDescriptorOperationTag is the wire tag for asset_descriptor_operation
// extra variants.
const AssetDescriptorOperationTag uint8 = 40
// Asset operation types used by the HF5 asset_descriptor_operation variant.
const (
AssetOpRegister uint8 = 0 // deploy new asset

View file

@ -540,10 +540,10 @@ const (
tagZarcanumSig = 45 // zarcanum_sig — complex
// Asset operation tags (HF5 confidential assets).
tagAssetDescriptorOperation = 40 // asset_descriptor_operation
tagAssetOperationProof = 49 // asset_operation_proof
tagAssetOperationOwnershipProof = 50 // asset_operation_ownership_proof
tagAssetOperationOwnershipProofETH = 51 // asset_operation_ownership_proof_eth
tagAssetDescriptorOperation = types.AssetDescriptorOperationTag // asset_descriptor_operation
tagAssetOperationProof = 49 // asset_operation_proof
tagAssetOperationOwnershipProof = 50 // asset_operation_ownership_proof
tagAssetOperationOwnershipProofETH = 51 // asset_operation_ownership_proof_eth
// Proof variant tags (proof_v).
tagZCAssetSurjectionProof = 46 // vector<BGE_proof_s>

View file

@ -64,6 +64,23 @@ func TestReadAssetDescriptorOperation_Good(t *testing.T) {
if !bytes.Equal(got, blob) {
t.Fatalf("round-trip mismatch: got %d bytes, want %d bytes", len(got), len(blob))
}
op, err := DecodeAssetDescriptorOperation(blob)
if err != nil {
t.Fatalf("DecodeAssetDescriptorOperation failed: %v", err)
}
if op.Version != 1 || op.OperationType != 0 {
t.Fatalf("unexpected operation header: %+v", op)
}
if op.Descriptor == nil {
t.Fatal("expected descriptor to be present")
}
if op.Descriptor.Ticker != "LTHN" || op.Descriptor.FullName != "Lethean" {
t.Fatalf("unexpected descriptor contents: %+v", op.Descriptor)
}
if op.Descriptor.TotalMaxSupply != 1000000 || op.Descriptor.DecimalPoint != 12 {
t.Fatalf("unexpected descriptor values: %+v", op.Descriptor)
}
}
func TestReadAssetDescriptorOperation_Bad(t *testing.T) {
@ -136,6 +153,17 @@ func TestVariantVectorWithTag40_Good(t *testing.T) {
if !bytes.Equal(got, raw) {
t.Fatalf("round-trip mismatch: got %d bytes, want %d bytes", len(got), len(raw))
}
elements, err := DecodeVariantVector(raw)
if err != nil {
t.Fatalf("DecodeVariantVector failed: %v", err)
}
if len(elements) != 1 || elements[0].Tag != tagAssetDescriptorOperation {
t.Fatalf("unexpected elements: %+v", elements)
}
if !bytes.Equal(elements[0].Data, innerBlob) {
t.Fatalf("unexpected element payload length: got %d, want %d", len(elements[0].Data), len(innerBlob))
}
}
func buildAssetOperationProofBlob() []byte {
@ -273,9 +301,9 @@ func TestV3TransactionRoundTrip_Good(t *testing.T) {
// version = 3
enc.WriteVarint(3)
// vin: 1 coinbase input
enc.WriteVarint(1) // input count
enc.WriteVarint(1) // input count
enc.WriteVariantTag(0) // txin_gen tag
enc.WriteVarint(201) // height
enc.WriteVarint(201) // height
// extra: variant vector with 2 elements (public_key + zarcanum_tx_data_v1)
enc.WriteVarint(2)
@ -289,13 +317,13 @@ func TestV3TransactionRoundTrip_Good(t *testing.T) {
// vout: 2 Zarcanum outputs
enc.WriteVarint(2)
for range 2 {
enc.WriteVariantTag(38) // OutputTypeZarcanum
enc.WriteVariantTag(38) // OutputTypeZarcanum
enc.WriteBytes(make([]byte, 32)) // stealth_address
enc.WriteBytes(make([]byte, 32)) // concealing_point
enc.WriteBytes(make([]byte, 32)) // amount_commitment
enc.WriteBytes(make([]byte, 32)) // blinded_asset_id
enc.WriteUint64LE(0) // encrypted_amount
enc.WriteUint8(0) // mix_attr
enc.WriteUint64LE(0) // encrypted_amount
enc.WriteUint8(0) // mix_attr
}
// hardfork_id = 5

101
wire/variant.go Normal file
View file

@ -0,0 +1,101 @@
// 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 wire
import (
"bytes"
"fmt"
coreerr "dappco.re/go/core/log"
"dappco.re/go/core/blockchain/types"
)
// VariantElement is one tagged element from a raw variant vector.
// Data contains the raw wire bytes for the element payload, without the tag.
type VariantElement struct {
Tag uint8
Data []byte
}
// DecodeVariantVector decodes a raw variant vector into tagged raw elements.
// It is useful for higher-level validation of raw transaction fields such as
// extra, attachment, signatures, and proofs.
func DecodeVariantVector(raw []byte) ([]VariantElement, error) {
dec := NewDecoder(bytes.NewReader(raw))
count := dec.ReadVarint()
if dec.Err() != nil {
return nil, dec.Err()
}
elems := make([]VariantElement, 0, int(count))
for i := uint64(0); i < count; i++ {
tag := dec.ReadUint8()
if dec.Err() != nil {
return nil, coreerr.E("DecodeVariantVector", fmt.Sprintf("read tag %d", i), dec.Err())
}
data := readVariantElementData(dec, tag)
if dec.Err() != nil {
return nil, coreerr.E("DecodeVariantVector", fmt.Sprintf("read element %d", i), dec.Err())
}
elems = append(elems, VariantElement{Tag: tag, Data: data})
}
return elems, nil
}
// DecodeAssetDescriptorOperation decodes a raw asset_descriptor_operation
// payload into its typed representation.
func DecodeAssetDescriptorOperation(raw []byte) (types.AssetDescriptorOperation, error) {
dec := NewDecoder(bytes.NewReader(raw))
var op types.AssetDescriptorOperation
op.Version = dec.ReadUint8()
op.OperationType = dec.ReadUint8()
if dec.ReadUint8() != 0 {
assetID := dec.ReadBytes(32)
if dec.Err() != nil {
return types.AssetDescriptorOperation{}, coreerr.E("DecodeAssetDescriptorOperation", "read asset id", dec.Err())
}
copy(op.AssetID[:], assetID)
}
if dec.ReadUint8() != 0 {
desc := &types.AssetDescriptorBase{}
desc.Ticker = decodeStringField(dec)
desc.FullName = decodeStringField(dec)
desc.TotalMaxSupply = dec.ReadUint64LE()
desc.CurrentSupply = dec.ReadUint64LE()
desc.DecimalPoint = dec.ReadUint8()
desc.MetaInfo = decodeStringField(dec)
ownerKey := dec.ReadBytes(32)
if dec.Err() != nil {
return types.AssetDescriptorOperation{}, coreerr.E("DecodeAssetDescriptorOperation", "read owner key", dec.Err())
}
copy(desc.OwnerKey[:], ownerKey)
desc.Etc = readVariantVectorFixed(dec, 1)
if dec.Err() != nil {
return types.AssetDescriptorOperation{}, coreerr.E("DecodeAssetDescriptorOperation", "read descriptor etc", dec.Err())
}
op.Descriptor = desc
}
op.AmountToEmit = dec.ReadUint64LE()
op.AmountToBurn = dec.ReadUint64LE()
op.Etc = readVariantVectorFixed(dec, 1)
if dec.Err() != nil {
return types.AssetDescriptorOperation{}, coreerr.E("DecodeAssetDescriptorOperation", "read trailing etc", dec.Err())
}
if dec.Err() != nil {
return types.AssetDescriptorOperation{}, coreerr.E("DecodeAssetDescriptorOperation", "decode asset descriptor operation", dec.Err())
}
return op, nil
}