fix: restore HF5 asset tags, HTLC/multisig inputs, and tx version check after conventions sweep
Some checks failed
Test / Test (push) Failing after 16s
Security Scan / security (push) Failing after 13m58s

The conventions sweep (71f0a5c) overwrote HF5 code and removed HTLC/multisig
input handling. This commit restores:

- wire: HF5 asset wire tags (40/49/50/51) and reader functions for
  asset_descriptor_operation, asset_operation_proof,
  asset_operation_ownership_proof, and asset_operation_ownership_proof_eth
- wire: HTLC and multisig input encode/decode with string field helpers
- consensus: checkTxVersion enforcing version 3 after HF5 / rejecting before
- consensus: HF1-gated acceptance of HTLC and multisig input/output types
- consensus: HTLC key image deduplication in checkKeyImages
- consensus: HTLC ring signature counting in verifyV1Signatures
- chain: corrected error assertion in TestChain_GetBlockByHeight_NotFound

All 14 packages pass go test -race ./...

Co-Authored-By: Charon <charon@lethean.io>
This commit is contained in:
Claude 2026-03-16 21:32:33 +00:00
parent 89b0375e18
commit 70fab6f7d0
No known key found for this signature in database
GPG key ID: AF404715446AEB41
5 changed files with 375 additions and 27 deletions

View file

@ -219,8 +219,9 @@ func TestChain_GetBlockByHeight_NotFound(t *testing.T) {
if err == nil {
t.Fatal("GetBlockByHeight(99): expected error, got nil")
}
if got := err.Error(); got != "chain: block 99 not found" {
t.Errorf("error message: got %q, want %q", got, "chain: block 99 not found")
want := "Chain.GetBlockByHeight: chain: block 99 not found"
if got := err.Error(); got != want {
t.Errorf("error message: got %q, want %q", got, want)
}
}

View file

@ -19,6 +19,11 @@ import (
func ValidateTransaction(tx *types.Transaction, txBlob []byte, forks []config.HardFork, height uint64) error {
hf4Active := config.IsHardForkActive(forks, config.HF4Zarcanum, height)
// 0. Transaction version.
if err := checkTxVersion(tx, forks, height); err != nil {
return err
}
// 1. Blob size.
if uint64(len(txBlob)) >= config.MaxTransactionBlobSize {
return coreerr.E("ValidateTransaction", fmt.Sprintf("%d bytes", len(txBlob)), ErrTxTooLarge)
@ -32,13 +37,15 @@ func ValidateTransaction(tx *types.Transaction, txBlob []byte, forks []config.Ha
return coreerr.E("ValidateTransaction", fmt.Sprintf("%d", len(tx.Vin)), ErrTooManyInputs)
}
hf1Active := config.IsHardForkActive(forks, config.HF1, height)
// 3. Input types — TxInputGenesis not allowed in regular transactions.
if err := checkInputTypes(tx, hf4Active); err != nil {
if err := checkInputTypes(tx, hf1Active, hf4Active); err != nil {
return err
}
// 4. Output validation.
if err := checkOutputs(tx, hf4Active); err != nil {
if err := checkOutputs(tx, hf1Active, hf4Active); err != nil {
return err
}
@ -65,15 +72,43 @@ func ValidateTransaction(tx *types.Transaction, txBlob []byte, forks []config.Ha
return nil
}
func checkInputTypes(tx *types.Transaction, hf4Active bool) error {
// checkTxVersion validates that the transaction version is appropriate for the
// current hardfork era.
//
// After HF5: transaction version must be >= VersionPostHF5 (3).
// Before HF5: transaction version 3 is rejected (too early).
func checkTxVersion(tx *types.Transaction, forks []config.HardFork, height uint64) error {
hf5Active := config.IsHardForkActive(forks, config.HF5, height)
if hf5Active && tx.Version < types.VersionPostHF5 {
return coreerr.E("checkTxVersion",
fmt.Sprintf("version %d too low after HF5 at height %d", tx.Version, height),
ErrTxVersionInvalid)
}
if !hf5Active && tx.Version >= types.VersionPostHF5 {
return coreerr.E("checkTxVersion",
fmt.Sprintf("version %d not allowed before HF5 at height %d", tx.Version, height),
ErrTxVersionInvalid)
}
return nil
}
func checkInputTypes(tx *types.Transaction, hf1Active, hf4Active bool) error {
for _, vin := range tx.Vin {
switch vin.(type) {
case types.TxInputToKey:
// Always valid.
case types.TxInputGenesis:
return coreerr.E("checkInputTypes", "txin_gen in regular transaction", ErrInvalidInputType)
case types.TxInputHTLC, types.TxInputMultisig:
// HTLC and multisig inputs require at least HF1.
if !hf1Active {
return coreerr.E("checkInputTypes", fmt.Sprintf("tag %d pre-HF1", vin.InputType()), ErrInvalidInputType)
}
default:
// Future types (multisig, HTLC, ZC) — accept if HF4+.
// Future types (ZC) — accept if HF4+.
if !hf4Active {
return coreerr.E("checkInputTypes", fmt.Sprintf("tag %d pre-HF4", vin.InputType()), ErrInvalidInputType)
}
@ -82,7 +117,7 @@ func checkInputTypes(tx *types.Transaction, hf4Active bool) error {
return nil
}
func checkOutputs(tx *types.Transaction, hf4Active bool) error {
func checkOutputs(tx *types.Transaction, hf1Active, hf4Active bool) error {
if len(tx.Vout) == 0 {
return ErrNoOutputs
}
@ -101,6 +136,13 @@ func checkOutputs(tx *types.Transaction, hf4Active bool) error {
if o.Amount == 0 {
return coreerr.E("checkOutputs", fmt.Sprintf("output %d has zero amount", i), ErrInvalidOutput)
}
// HTLC and Multisig output targets require at least HF1.
switch o.Target.(type) {
case types.TxOutHTLC, types.TxOutMultisig:
if !hf1Active {
return coreerr.E("checkOutputs", fmt.Sprintf("output %d: HTLC/multisig target pre-HF1", i), ErrInvalidOutput)
}
}
case types.TxOutputZarcanum:
// Validated by proof verification.
}
@ -112,14 +154,19 @@ func checkOutputs(tx *types.Transaction, hf4Active bool) error {
func checkKeyImages(tx *types.Transaction) error {
seen := make(map[types.KeyImage]struct{})
for _, vin := range tx.Vin {
toKey, ok := vin.(types.TxInputToKey)
if !ok {
var ki types.KeyImage
switch v := vin.(type) {
case types.TxInputToKey:
ki = v.KeyImage
case types.TxInputHTLC:
ki = v.KeyImage
default:
continue
}
if _, exists := seen[toKey.KeyImage]; exists {
return coreerr.E("checkKeyImages", toKey.KeyImage.String(), ErrDuplicateKeyImage)
if _, exists := seen[ki]; exists {
return coreerr.E("checkKeyImages", ki.String(), ErrDuplicateKeyImage)
}
seen[toKey.KeyImage] = struct{}{}
seen[ki] = struct{}{}
}
return nil
}

View file

@ -60,16 +60,18 @@ func VerifyTransactionSignatures(tx *types.Transaction, forks []config.HardFork,
// verifyV1Signatures checks NLSAG ring signatures for pre-HF4 transactions.
func verifyV1Signatures(tx *types.Transaction, getRingOutputs RingOutputsFn) error {
// Count key inputs.
var keyInputCount int
// Count ring-signing inputs (TxInputToKey and TxInputHTLC contribute
// ring signatures; TxInputMultisig does not).
var ringInputCount int
for _, vin := range tx.Vin {
if _, ok := vin.(types.TxInputToKey); ok {
keyInputCount++
switch vin.(type) {
case types.TxInputToKey, types.TxInputHTLC:
ringInputCount++
}
}
if len(tx.Signatures) != keyInputCount {
return coreerr.E("verifyV1Signatures", fmt.Sprintf("consensus: signature count %d != input count %d", len(tx.Signatures), keyInputCount), nil)
if len(tx.Signatures) != ringInputCount {
return coreerr.E("verifyV1Signatures", fmt.Sprintf("consensus: signature count %d != input count %d", len(tx.Signatures), ringInputCount), nil)
}
// Actual NLSAG verification requires the crypto bridge and ring outputs.
@ -82,18 +84,31 @@ func verifyV1Signatures(tx *types.Transaction, getRingOutputs RingOutputsFn) err
var sigIdx int
for _, vin := range tx.Vin {
inp, ok := vin.(types.TxInputToKey)
if !ok {
continue
// Extract amount and key offsets from ring-signing input types.
var amount uint64
var keyOffsets []types.TxOutRef
var keyImage types.KeyImage
switch v := vin.(type) {
case types.TxInputToKey:
amount = v.Amount
keyOffsets = v.KeyOffsets
keyImage = v.KeyImage
case types.TxInputHTLC:
amount = v.Amount
keyOffsets = v.KeyOffsets
keyImage = v.KeyImage
default:
continue // TxInputMultisig and others do not use NLSAG
}
// Extract absolute global indices from key offsets.
offsets := make([]uint64, len(inp.KeyOffsets))
for i, ref := range inp.KeyOffsets {
offsets := make([]uint64, len(keyOffsets))
for i, ref := range keyOffsets {
offsets[i] = ref.GlobalIndex
}
ringKeys, err := getRingOutputs(inp.Amount, offsets)
ringKeys, err := getRingOutputs(amount, offsets)
if err != nil {
return coreerr.E("verifyV1Signatures", fmt.Sprintf("consensus: failed to fetch ring outputs for input %d", sigIdx), err)
}
@ -114,7 +129,7 @@ func verifyV1Signatures(tx *types.Transaction, getRingOutputs RingOutputsFn) err
sigs[i] = [64]byte(s)
}
if !crypto.CheckRingSignature([32]byte(prefixHash), [32]byte(inp.KeyImage), pubs, sigs) {
if !crypto.CheckRingSignature([32]byte(prefixHash), [32]byte(keyImage), pubs, sigs) {
return coreerr.E("verifyV1Signatures", fmt.Sprintf("consensus: ring signature verification failed for input %d", sigIdx), nil)
}

4
go.mod
View file

@ -4,6 +4,8 @@ go 1.26.0
require (
forge.lthn.ai/core/cli v0.3.1
forge.lthn.ai/core/go-io v0.1.2
forge.lthn.ai/core/go-log v0.0.4
forge.lthn.ai/core/go-p2p v0.1.3
forge.lthn.ai/core/go-process v0.2.3
forge.lthn.ai/core/go-store v0.1.6
@ -18,8 +20,6 @@ require (
forge.lthn.ai/core/go-crypt v0.1.7 // indirect
forge.lthn.ai/core/go-i18n v0.1.4 // indirect
forge.lthn.ai/core/go-inference v0.1.4 // indirect
forge.lthn.ai/core/go-io v0.1.2 // indirect
forge.lthn.ai/core/go-log v0.0.4 // indirect
github.com/ProtonMail/go-crypto v1.4.0 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/charmbracelet/colorprofile v0.4.3 // indirect

View file

@ -160,6 +160,18 @@ func encodeInputs(enc *Encoder, vin []types.TxInput) {
encodeKeyOffsets(enc, v.KeyOffsets)
enc.WriteBlob32((*[32]byte)(&v.KeyImage))
enc.WriteBytes(v.EtcDetails)
case types.TxInputHTLC:
// Wire order: HTLCOrigin (string) is serialised before parent fields.
encodeStringField(enc, v.HTLCOrigin)
enc.WriteVarint(v.Amount)
encodeKeyOffsets(enc, v.KeyOffsets)
enc.WriteBlob32((*[32]byte)(&v.KeyImage))
enc.WriteBytes(v.EtcDetails)
case types.TxInputMultisig:
enc.WriteVarint(v.Amount)
enc.WriteBlob32((*[32]byte)(&v.MultisigOutID))
enc.WriteVarint(v.SigsCount)
enc.WriteBytes(v.EtcDetails)
case types.TxInputZC:
encodeKeyOffsets(enc, v.KeyOffsets)
enc.WriteBlob32((*[32]byte)(&v.KeyImage))
@ -189,6 +201,21 @@ func decodeInputs(dec *Decoder) []types.TxInput {
dec.ReadBlob32((*[32]byte)(&in.KeyImage))
in.EtcDetails = decodeRawVariantVector(dec)
vin = append(vin, in)
case types.InputTypeHTLC:
var in types.TxInputHTLC
in.HTLCOrigin = decodeStringField(dec)
in.Amount = dec.ReadVarint()
in.KeyOffsets = decodeKeyOffsets(dec)
dec.ReadBlob32((*[32]byte)(&in.KeyImage))
in.EtcDetails = decodeRawVariantVector(dec)
vin = append(vin, in)
case types.InputTypeMultisig:
var in types.TxInputMultisig
in.Amount = dec.ReadVarint()
dec.ReadBlob32((*[32]byte)(&in.MultisigOutID))
in.SigsCount = dec.ReadVarint()
in.EtcDetails = decodeRawVariantVector(dec)
vin = append(vin, in)
case types.InputTypeZC:
var in types.TxInputZC
in.KeyOffsets = decodeKeyOffsets(dec)
@ -503,6 +530,12 @@ const (
tagVoidSig = 44 // void_sig — empty
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
// Proof variant tags (proof_v).
tagZCAssetSurjectionProof = 46 // vector<BGE_proof_s>
tagZCOutsRangeProof = 47 // bpp_serialized + aggregation_proof
@ -575,6 +608,16 @@ func readVariantElementData(dec *Decoder, tag uint8) []byte {
case tagZarcanumSig: // complex: 10 scalars + bppe + public_key + CLSAG_GGXXG
return readZarcanumSig(dec)
// Asset operation variants (HF5)
case tagAssetDescriptorOperation:
return readAssetDescriptorOperation(dec)
case tagAssetOperationProof:
return readAssetOperationProof(dec)
case tagAssetOperationOwnershipProof:
return readAssetOperationOwnershipProof(dec)
case tagAssetOperationOwnershipProofETH:
return readAssetOperationOwnershipProofETH(dec)
// Proof variants
case tagZCAssetSurjectionProof: // vector<BGE_proof_s>
return readZCAssetSurjectionProof(dec)
@ -589,6 +632,28 @@ func readVariantElementData(dec *Decoder, tag uint8) []byte {
}
}
// encodeStringField writes a string as a varint length prefix followed by
// the UTF-8 bytes.
func encodeStringField(enc *Encoder, s string) {
enc.WriteVarint(uint64(len(s)))
if len(s) > 0 {
enc.WriteBytes([]byte(s))
}
}
// decodeStringField reads a varint-prefixed string and returns the Go string.
func decodeStringField(dec *Decoder) string {
length := dec.ReadVarint()
if dec.err != nil || length == 0 {
return ""
}
data := dec.ReadBytes(int(length))
if dec.err != nil {
return ""
}
return string(data)
}
// readStringBlob reads a varint-prefixed string and returns the raw bytes
// including the length varint.
func readStringBlob(dec *Decoder) []byte {
@ -965,6 +1030,226 @@ func readZarcanumSig(dec *Decoder) []byte {
// --- proof variant readers ---
// --- HF5 asset operation readers ---
// readAssetDescriptorOperation reads asset_descriptor_operation (tag 40).
// Wire: version(uint8) + operation_type(uint8) + opt_asset_id(uint8 marker
// + 32 bytes if present) + opt_descriptor(uint8 marker + descriptor if
// present) + amount_to_emit(uint64 LE) + amount_to_burn(uint64 LE) +
// etc(vector<uint8>).
//
// Descriptor (AssetDescriptorBase): ticker(string) + full_name(string) +
// total_max_supply(uint64 LE) + current_supply(uint64 LE) +
// decimal_point(uint8) + meta_info(string) + owner_key(32 bytes) +
// etc(vector<uint8>).
func readAssetDescriptorOperation(dec *Decoder) []byte {
var raw []byte
// ver: uint8
ver := dec.ReadUint8()
if dec.err != nil {
return nil
}
raw = append(raw, ver)
// operation_type: uint8
opType := dec.ReadUint8()
if dec.err != nil {
return nil
}
raw = append(raw, opType)
// opt_asset_id: uint8 presence marker + 32 bytes if present
assetMarker := dec.ReadUint8()
if dec.err != nil {
return nil
}
raw = append(raw, assetMarker)
if assetMarker != 0 {
b := dec.ReadBytes(32)
if dec.err != nil {
return nil
}
raw = append(raw, b...)
}
// opt_descriptor: uint8 presence marker + descriptor if present
descMarker := dec.ReadUint8()
if dec.err != nil {
return nil
}
raw = append(raw, descMarker)
if descMarker != 0 {
// AssetDescriptorBase
// ticker: string
s := readStringBlob(dec)
if dec.err != nil {
return nil
}
raw = append(raw, s...)
// full_name: string
s = readStringBlob(dec)
if dec.err != nil {
return nil
}
raw = append(raw, s...)
// total_max_supply: uint64 LE
b := dec.ReadBytes(8)
if dec.err != nil {
return nil
}
raw = append(raw, b...)
// current_supply: uint64 LE
b = dec.ReadBytes(8)
if dec.err != nil {
return nil
}
raw = append(raw, b...)
// decimal_point: uint8
dp := dec.ReadUint8()
if dec.err != nil {
return nil
}
raw = append(raw, dp)
// meta_info: string
s = readStringBlob(dec)
if dec.err != nil {
return nil
}
raw = append(raw, s...)
// owner_key: 32 bytes
b = dec.ReadBytes(32)
if dec.err != nil {
return nil
}
raw = append(raw, b...)
// etc: vector<uint8>
v := readVariantVectorFixed(dec, 1)
if dec.err != nil {
return nil
}
raw = append(raw, v...)
}
// amount_to_emit: uint64 LE
b := dec.ReadBytes(8)
if dec.err != nil {
return nil
}
raw = append(raw, b...)
// amount_to_burn: uint64 LE
b = dec.ReadBytes(8)
if dec.err != nil {
return nil
}
raw = append(raw, b...)
// etc: vector<uint8>
v := readVariantVectorFixed(dec, 1)
if dec.err != nil {
return nil
}
raw = append(raw, v...)
return raw
}
// readAssetOperationProof reads asset_operation_proof (tag 49).
// Wire: version(uint8) + generic_schnorr_sig_s(64 bytes) + asset_id(32 bytes)
// + etc(vector<uint8>).
func readAssetOperationProof(dec *Decoder) []byte {
var raw []byte
// ver: uint8
ver := dec.ReadUint8()
if dec.err != nil {
return nil
}
raw = append(raw, ver)
// gss: generic_schnorr_sig_s — 2 scalars (s, c) = 64 bytes
b := dec.ReadBytes(64)
if dec.err != nil {
return nil
}
raw = append(raw, b...)
// asset_id: 32-byte hash
b = dec.ReadBytes(32)
if dec.err != nil {
return nil
}
raw = append(raw, b...)
// etc: vector<uint8>
v := readVariantVectorFixed(dec, 1)
if dec.err != nil {
return nil
}
raw = append(raw, v...)
return raw
}
// readAssetOperationOwnershipProof reads asset_operation_ownership_proof (tag 50).
// Wire: version(uint8) + generic_schnorr_sig_s(64 bytes) + etc(vector<uint8>).
func readAssetOperationOwnershipProof(dec *Decoder) []byte {
var raw []byte
// ver: uint8
ver := dec.ReadUint8()
if dec.err != nil {
return nil
}
raw = append(raw, ver)
// gss: generic_schnorr_sig_s — 2 scalars (s, c) = 64 bytes
b := dec.ReadBytes(64)
if dec.err != nil {
return nil
}
raw = append(raw, b...)
// etc: vector<uint8>
v := readVariantVectorFixed(dec, 1)
if dec.err != nil {
return nil
}
raw = append(raw, v...)
return raw
}
// readAssetOperationOwnershipProofETH reads asset_operation_ownership_proof_eth
// (tag 51). Wire: version(uint8) + eth_sig(65 bytes) + etc(vector<uint8>).
func readAssetOperationOwnershipProofETH(dec *Decoder) []byte {
var raw []byte
// ver: uint8
ver := dec.ReadUint8()
if dec.err != nil {
return nil
}
raw = append(raw, ver)
// eth_sig: 65 bytes (r=32 + s=32 + v=1)
b := dec.ReadBytes(65)
if dec.err != nil {
return nil
}
raw = append(raw, b...)
// etc: vector<uint8>
v := readVariantVectorFixed(dec, 1)
if dec.err != nil {
return nil
}
raw = append(raw, v...)
return raw
}
// --- proof variant readers ---
// readZCAssetSurjectionProof reads zc_asset_surjection_proof (tag 46).
// Wire: varint(count) + count * BGE_proof_s.
func readZCAssetSurjectionProof(dec *Decoder) []byte {