refactor(ax): centralise asset validation
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 22:09:17 +00:00
parent 8802b94ee5
commit 95edac1d15
3 changed files with 172 additions and 71 deletions

View file

@ -7,7 +7,6 @@ package consensus
import (
"fmt"
"unicode/utf8"
coreerr "dappco.re/go/core/log"
@ -222,79 +221,10 @@ func checkAssetOperations(extra []byte, hf5Active bool) error {
if err != nil {
return coreerr.E("checkAssetOperations", fmt.Sprintf("extra[%d]: decode asset descriptor operation", i), ErrInvalidExtra)
}
if err := validateAssetDescriptorOperation(op); err != nil {
if err := op.Validate(); 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

@ -5,6 +5,11 @@
package types
import (
"fmt"
"unicode/utf8"
)
// AssetDescriptorOperationTag is the wire tag for asset_descriptor_operation
// extra variants.
const AssetDescriptorOperationTag uint8 = 40
@ -31,6 +36,30 @@ type AssetDescriptorBase struct {
Etc []byte
}
// Validate checks that the base asset metadata is structurally valid.
func (base AssetDescriptorBase) Validate() error {
tickerLen := utf8.RuneCountInString(base.Ticker)
fullNameLen := utf8.RuneCountInString(base.FullName)
if base.TotalMaxSupply == 0 {
return fmt.Errorf("types: total max supply must be non-zero")
}
if base.CurrentSupply > base.TotalMaxSupply {
return fmt.Errorf("types: current supply %d exceeds max supply %d", base.CurrentSupply, base.TotalMaxSupply)
}
if tickerLen == 0 || tickerLen > 6 {
return fmt.Errorf("types: ticker length %d out of range", tickerLen)
}
if fullNameLen == 0 || fullNameLen > 64 {
return fmt.Errorf("types: full name length %d out of range", fullNameLen)
}
if base.OwnerKey.IsZero() {
return fmt.Errorf("types: owner key must be non-zero")
}
return nil
}
// AssetDescriptorOperation represents a deploy/emit/update/burn operation.
// The wire format is parsed in wire/ as an opaque blob for round-tripping.
type AssetDescriptorOperation struct {
@ -42,3 +71,65 @@ type AssetDescriptorOperation struct {
AmountToBurn uint64
Etc []byte
}
// Validate checks that the operation is structurally valid for HF5 parsing.
func (op AssetDescriptorOperation) Validate() error {
switch op.Version {
case 0, 1:
default:
return fmt.Errorf("types: unsupported version %d", op.Version)
}
switch op.OperationType {
case AssetOpRegister:
if !op.AssetID.IsZero() {
return fmt.Errorf("types: register operation must not carry asset id")
}
if op.Descriptor == nil {
return fmt.Errorf("types: register operation missing descriptor")
}
if err := op.Descriptor.Validate(); err != nil {
return err
}
if op.AmountToEmit != 0 || op.AmountToBurn != 0 {
return fmt.Errorf("types: register operation must not include emission or burn amounts")
}
case AssetOpEmit:
if op.AssetID.IsZero() {
return fmt.Errorf("types: emit operation must carry asset id")
}
if op.AmountToEmit == 0 {
return fmt.Errorf("types: emit operation has zero amount")
}
if op.Descriptor != nil {
return fmt.Errorf("types: emit operation must not carry descriptor")
}
case AssetOpUpdate:
if op.AssetID.IsZero() {
return fmt.Errorf("types: update operation must carry asset id")
}
if op.Descriptor == nil {
return fmt.Errorf("types: update operation missing descriptor")
}
if err := op.Descriptor.Validate(); err != nil {
return err
}
if op.AmountToEmit != 0 || op.AmountToBurn != 0 {
return fmt.Errorf("types: update operation must not include emission or burn amounts")
}
case AssetOpBurn, AssetOpPublicBurn:
if op.AssetID.IsZero() {
return fmt.Errorf("types: burn operation must carry asset id")
}
if op.AmountToBurn == 0 {
return fmt.Errorf("types: burn operation has zero amount")
}
if op.Descriptor != nil {
return fmt.Errorf("types: burn operation must not carry descriptor")
}
default:
return fmt.Errorf("types: unsupported operation type %d", op.OperationType)
}
return nil
}

View file

@ -58,3 +58,83 @@ func TestAssetDescriptorTypes_Good(t *testing.T) {
t.Fatalf("unexpected operation: %+v", op)
}
}
func TestAssetDescriptorBaseValidate_Good(t *testing.T) {
base := AssetDescriptorBase{
Ticker: "LTHN",
FullName: "Lethean",
TotalMaxSupply: 1000000,
CurrentSupply: 0,
DecimalPoint: 12,
MetaInfo: "{}",
OwnerKey: PublicKey{1},
}
if err := base.Validate(); err != nil {
t.Fatalf("Validate() error = %v", err)
}
}
func TestAssetDescriptorBaseValidate_Bad(t *testing.T) {
tests := []struct {
name string
base AssetDescriptorBase
}{
{"zero_supply", AssetDescriptorBase{Ticker: "LTHN", FullName: "Lethean", OwnerKey: PublicKey{1}}},
{"too_many_current", AssetDescriptorBase{Ticker: "LTHN", FullName: "Lethean", TotalMaxSupply: 10, CurrentSupply: 11, OwnerKey: PublicKey{1}}},
{"empty_ticker", AssetDescriptorBase{FullName: "Lethean", TotalMaxSupply: 10, OwnerKey: PublicKey{1}}},
{"empty_name", AssetDescriptorBase{Ticker: "LTHN", TotalMaxSupply: 10, OwnerKey: PublicKey{1}}},
{"zero_owner", AssetDescriptorBase{Ticker: "LTHN", FullName: "Lethean", TotalMaxSupply: 10}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if err := tt.base.Validate(); err == nil {
t.Fatal("Validate() error = nil, want error")
}
})
}
}
func TestAssetDescriptorOperationValidate_Good(t *testing.T) {
base := AssetDescriptorBase{
Ticker: "LTHN",
FullName: "Lethean",
TotalMaxSupply: 1000000,
CurrentSupply: 0,
DecimalPoint: 12,
MetaInfo: "{}",
OwnerKey: PublicKey{1},
}
op := AssetDescriptorOperation{
Version: 1,
OperationType: AssetOpRegister,
Descriptor: &base,
}
if err := op.Validate(); err != nil {
t.Fatalf("Validate() error = %v", err)
}
}
func TestAssetDescriptorOperationValidate_Bad(t *testing.T) {
tests := []struct {
name string
op AssetDescriptorOperation
}{
{"unsupported_version", AssetDescriptorOperation{Version: 2, OperationType: AssetOpRegister}},
{"register_missing_descriptor", AssetDescriptorOperation{Version: 1, OperationType: AssetOpRegister}},
{"emit_zero_amount", AssetDescriptorOperation{Version: 1, OperationType: AssetOpEmit, AssetID: Hash{1}}},
{"update_missing_descriptor", AssetDescriptorOperation{Version: 1, OperationType: AssetOpUpdate, AssetID: Hash{1}}},
{"burn_zero_amount", AssetDescriptorOperation{Version: 1, OperationType: AssetOpBurn, AssetID: Hash{1}}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if err := tt.op.Validate(); err == nil {
t.Fatal("Validate() error = nil, want error")
}
})
}
}