diff --git a/consensus/tx.go b/consensus/tx.go index f7af120..a4c8587 100644 --- a/consensus/tx.go +++ b/consensus/tx.go @@ -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 +} diff --git a/consensus/tx_test.go b/consensus/tx_test.go index fc6595e..d336a55 100644 --- a/consensus/tx_test.go +++ b/consensus/tx_test.go @@ -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) { diff --git a/types/asset.go b/types/asset.go index db814a6..dbaf6e2 100644 --- a/types/asset.go +++ b/types/asset.go @@ -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 diff --git a/wire/transaction.go b/wire/transaction.go index 82d3a3f..3014b57 100644 --- a/wire/transaction.go +++ b/wire/transaction.go @@ -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 diff --git a/wire/transaction_v3_test.go b/wire/transaction_v3_test.go index 3eef6b0..6d54956 100644 --- a/wire/transaction_v3_test.go +++ b/wire/transaction_v3_test.go @@ -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 diff --git a/wire/variant.go b/wire/variant.go new file mode 100644 index 0000000..0e75a10 --- /dev/null +++ b/wire/variant.go @@ -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 +}