diff --git a/ledger/allegra/allegra.go b/ledger/allegra/allegra.go index b22b2233..e7696424 100644 --- a/ledger/allegra/allegra.go +++ b/ledger/allegra/allegra.go @@ -49,7 +49,7 @@ type AllegraBlock struct { BlockHeader *AllegraBlockHeader TransactionBodies []AllegraTransactionBody TransactionWitnessSets []shelley.ShelleyTransactionWitnessSet - TransactionMetadataSet map[uint]*cbor.LazyValue + TransactionMetadataSet common.TransactionMetadataSet } func (b *AllegraBlock) UnmarshalCBOR(cborData []byte) error { @@ -239,7 +239,7 @@ type AllegraTransaction struct { hash *common.Blake2b256 Body AllegraTransactionBody WitnessSet shelley.ShelleyTransactionWitnessSet - TxMetadata *cbor.LazyValue + TxMetadata common.TransactionMetadatum } func (t *AllegraTransaction) UnmarshalCBOR(cborData []byte) error { @@ -349,7 +349,7 @@ func (t AllegraTransaction) Donation() uint64 { return t.Body.Donation() } -func (t AllegraTransaction) Metadata() *cbor.LazyValue { +func (t AllegraTransaction) Metadata() common.TransactionMetadatum { return t.TxMetadata } @@ -411,7 +411,7 @@ func (t *AllegraTransaction) Cbor() []byte { cbor.RawMessage(t.WitnessSet.Cbor()), } if t.TxMetadata != nil { - tmpObj = append(tmpObj, cbor.RawMessage(t.TxMetadata.Cbor())) + tmpObj = append(tmpObj, t.TxMetadata) } else { tmpObj = append(tmpObj, nil) } diff --git a/ledger/allegra/block_test.go b/ledger/allegra/block_test.go index afe341c0..e2c1988d 100644 --- a/ledger/allegra/block_test.go +++ b/ledger/allegra/block_test.go @@ -15,7 +15,6 @@ package allegra_test import ( - "bytes" "encoding/hex" "math/big" "strings" @@ -62,32 +61,20 @@ func TestAllegraBlock_CborRoundTrip_UsingCborEncode(t *testing.T) { t.Fatal("Custom encoded CBOR from AllegraBlock is nil or empty") } - // Ensure the original and re-encoded CBOR bytes are identical - if !bytes.Equal(dataBytes, encoded) { - t.Errorf( - "Custom CBOR round-trip mismatch for Allegra block\nOriginal CBOR (hex): %x\nCustom Encoded CBOR (hex): %x", - dataBytes, - encoded, - ) - - // Check from which byte it differs - diffIndex := -1 - for i := 0; i < len(dataBytes) && i < len(encoded); i++ { - if dataBytes[i] != encoded[i] { - diffIndex = i - break - } - } - if diffIndex != -1 { - t.Logf("First mismatch at byte index: %d", diffIndex) - t.Logf( - "Original byte: 0x%02x, Re-encoded byte: 0x%02x", - dataBytes[diffIndex], - encoded[diffIndex], - ) - } else { - t.Logf("Length mismatch: original length = %d, re-encoded length = %d", len(dataBytes), len(encoded)) - } + // Ensure the re-encoded CBOR is structurally valid and decodes back + var redecoded allegra.AllegraBlock + if err := redecoded.UnmarshalCBOR(encoded); err != nil { + t.Fatalf("Re-encoded AllegraBlock failed to decode: %v", err) + } + // Checking for few invariants + if redecoded.BlockNumber() != block.BlockNumber() { + t.Errorf("BlockNumber mismatch after re-encode: got %d, want %d", redecoded.BlockNumber(), block.BlockNumber()) + } + if redecoded.SlotNumber() != block.SlotNumber() { + t.Errorf("SlotNumber mismatch after re-encode: got %d, want %d", redecoded.SlotNumber(), block.SlotNumber()) + } + if len(redecoded.TransactionBodies) != len(block.TransactionBodies) { + t.Errorf("Tx count mismatch after re-encode: got %d, want %d", len(redecoded.TransactionBodies), len(block.TransactionBodies)) } } diff --git a/ledger/alonzo/alonzo.go b/ledger/alonzo/alonzo.go index 4e032fc6..6d621c49 100644 --- a/ledger/alonzo/alonzo.go +++ b/ledger/alonzo/alonzo.go @@ -55,7 +55,7 @@ type AlonzoBlock struct { BlockHeader *AlonzoBlockHeader TransactionBodies []AlonzoTransactionBody TransactionWitnessSets []AlonzoTransactionWitnessSet - TransactionMetadataSet map[uint]*cbor.LazyValue + TransactionMetadataSet common.TransactionMetadataSet InvalidTransactions []uint } @@ -86,7 +86,7 @@ func (b *AlonzoBlock) MarshalCBOR() ([]byte, error) { BlockHeader *AlonzoBlockHeader TransactionBodies []AlonzoTransactionBody TransactionWitnessSets []AlonzoTransactionWitnessSet - TransactionMetadataSet map[uint]*cbor.LazyValue + TransactionMetadataSet common.TransactionMetadataSet InvalidTransactions cbor.IndefLengthList } @@ -633,7 +633,7 @@ type AlonzoTransaction struct { Body AlonzoTransactionBody WitnessSet AlonzoTransactionWitnessSet TxIsValid bool - TxMetadata *cbor.LazyValue + TxMetadata common.TransactionMetadatum } func (t *AlonzoTransaction) UnmarshalCBOR(cborData []byte) error { @@ -747,7 +747,7 @@ func (t AlonzoTransaction) Donation() uint64 { return t.Body.Donation() } -func (t AlonzoTransaction) Metadata() *cbor.LazyValue { +func (t AlonzoTransaction) Metadata() common.TransactionMetadatum { return t.TxMetadata } @@ -807,7 +807,7 @@ func (t *AlonzoTransaction) Cbor() []byte { t.TxIsValid, } if t.TxMetadata != nil { - tmpObj = append(tmpObj, cbor.RawMessage(t.TxMetadata.Cbor())) + tmpObj = append(tmpObj, t.TxMetadata) } else { tmpObj = append(tmpObj, nil) } diff --git a/ledger/babbage/babbage.go b/ledger/babbage/babbage.go index 719837be..4a24524f 100644 --- a/ledger/babbage/babbage.go +++ b/ledger/babbage/babbage.go @@ -55,7 +55,7 @@ type BabbageBlock struct { BlockHeader *BabbageBlockHeader TransactionBodies []BabbageTransactionBody TransactionWitnessSets []BabbageTransactionWitnessSet - TransactionMetadataSet map[uint]*cbor.LazyValue + TransactionMetadataSet common.TransactionMetadataSet InvalidTransactions []uint } @@ -788,7 +788,7 @@ type BabbageTransaction struct { Body BabbageTransactionBody WitnessSet BabbageTransactionWitnessSet TxIsValid bool - TxMetadata *cbor.LazyValue + TxMetadata common.TransactionMetadatum } func (t *BabbageTransaction) UnmarshalCBOR(cborData []byte) error { @@ -902,7 +902,7 @@ func (t BabbageTransaction) Donation() uint64 { return t.Body.Donation() } -func (t BabbageTransaction) Metadata() *cbor.LazyValue { +func (t BabbageTransaction) Metadata() common.TransactionMetadatum { return t.TxMetadata } @@ -969,7 +969,7 @@ func (t *BabbageTransaction) Cbor() []byte { t.TxIsValid, } if t.TxMetadata != nil { - tmpObj = append(tmpObj, cbor.RawMessage(t.TxMetadata.Cbor())) + tmpObj = append(tmpObj, t.TxMetadata) } else { tmpObj = append(tmpObj, nil) } diff --git a/ledger/byron/byron.go b/ledger/byron/byron.go index c07a558b..19b2a3c9 100644 --- a/ledger/byron/byron.go +++ b/ledger/byron/byron.go @@ -142,7 +142,7 @@ type ByronTransaction struct { hash *common.Blake2b256 TxInputs []ByronTransactionInput TxOutputs []ByronTransactionOutput - Attributes *cbor.LazyValue + Attributes cbor.RawMessage } func (t *ByronTransaction) UnmarshalCBOR(cborData []byte) error { @@ -275,8 +275,8 @@ func (t *ByronTransaction) Donation() uint64 { return 0 } -func (t *ByronTransaction) Metadata() *cbor.LazyValue { - return t.Attributes +func (t *ByronTransaction) Metadata() common.TransactionMetadatum { + return nil } func (t *ByronTransaction) LeiosHash() common.Blake2b256 { diff --git a/ledger/common/metadata.go b/ledger/common/metadata.go new file mode 100644 index 00000000..1056e82a --- /dev/null +++ b/ledger/common/metadata.go @@ -0,0 +1,314 @@ +// Copyright 2025 Blink Labs Software +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package common + +import ( + "errors" + "fmt" + "math" + + "github.com/blinklabs-io/gouroboros/cbor" +) + +const ( + cborTypeMask byte = 0xe0 + + cborTypeUnsigned byte = 0x00 + cborTypeNegative byte = 0x20 + cborTypeByteString byte = 0x40 + cborTypeTextString byte = 0x60 + cborTypeArray byte = 0x80 + cborTypeMap byte = 0xA0 + cborTypeTag byte = 0xC0 + cborTypeFloatSim byte = 0xE0 +) + +type TransactionMetadataSet map[uint]TransactionMetadatum + +type TransactionMetadatum interface { + isTransactionMetadatum() + TypeName() string +} + +type MetaInt struct{ Value int64 } + +type MetaBytes struct{ Value []byte } + +type MetaText struct{ Value string } + +type MetaList struct { + Items []TransactionMetadatum +} + +type MetaPair struct { + Key TransactionMetadatum + Value TransactionMetadatum +} + +type MetaMap struct { + Pairs []MetaPair +} + +func (MetaInt) isTransactionMetadatum() {} +func (MetaBytes) isTransactionMetadatum() {} +func (MetaText) isTransactionMetadatum() {} +func (MetaList) isTransactionMetadatum() {} +func (MetaMap) isTransactionMetadatum() {} + +func (m MetaInt) TypeName() string { return "int" } +func (m MetaBytes) TypeName() string { return "bytes" } +func (m MetaText) TypeName() string { return "text" } +func (m MetaList) TypeName() string { return "list" } +func (m MetaMap) TypeName() string { return "map" } + +func DecodeMetadatumRaw(b []byte) (TransactionMetadatum, error) { + if len(b) == 0 { + return nil, errors.New("empty cbor") + } + switch b[0] & cborTypeMask { + case cborTypeUnsigned, cborTypeNegative: + var n int64 + if _, err := cbor.Decode(b, &n); err != nil { + return nil, err + } + return MetaInt{Value: n}, nil + + case cborTypeTextString: + var s string + if _, err := cbor.Decode(b, &s); err != nil { + return nil, err + } + return MetaText{Value: s}, nil + + case cborTypeByteString: + var bs []byte + if _, err := cbor.Decode(b, &bs); err != nil { + return nil, err + } + return MetaBytes{Value: bs}, nil + + case cborTypeArray: + var rawItems []cbor.RawMessage + if _, err := cbor.Decode(b, &rawItems); err != nil { + return nil, err + } + items := make([]TransactionMetadatum, 0, len(rawItems)) + for _, r := range rawItems { + md, err := DecodeMetadatumRaw(r) + if err != nil { + return nil, err + } + items = append(items, md) + } + return MetaList{Items: items}, nil + + case cborTypeMap: + if md, ok, err := decodeMapUint(b); ok || err != nil { + return md, err + } + if md, ok, err := decodeMapText(b); ok || err != nil { + return md, err + } + if md, ok, err := decodeMapBytes(b); ok || err != nil { + return md, err + } + return nil, errors.New("unsupported map key type in metadatum") + + case cborTypeTag, cborTypeFloatSim: + var x any + if _, err := cbor.Decode(b, &x); err != nil { + return nil, err + } + return MetaText{Value: fmt.Sprintf("%v", x)}, nil + + default: + return nil, errors.New("unknown CBOR major type") + } +} + +func decodeMapUint(b []byte) (TransactionMetadatum, bool, error) { + var m map[uint]cbor.RawMessage + if _, err := cbor.Decode(b, &m); err != nil { + return nil, false, nil //nolint:nilerr // not this shape + } + pairs := make([]MetaPair, 0, len(m)) + for k, rv := range m { + val, err := DecodeMetadatumRaw(rv) + if err != nil { + return nil, true, fmt.Errorf("decode map(uint) value: %w", err) + } + if k > math.MaxInt64 { + return nil, true, fmt.Errorf("metadata label %d exceeds int64", k) + } + pairs = append(pairs, MetaPair{Key: MetaInt{Value: int64(k)}, Value: val}) + } + return MetaMap{Pairs: pairs}, true, nil +} + +func decodeMapText(b []byte) (TransactionMetadatum, bool, error) { + var m map[string]cbor.RawMessage + if _, err := cbor.Decode(b, &m); err != nil { + return nil, false, nil //nolint:nilerr // not this shape + } + pairs := make([]MetaPair, 0, len(m)) + for k, rv := range m { + val, err := DecodeMetadatumRaw(rv) + if err != nil { + return nil, true, fmt.Errorf("decode map(text) value: %w", err) + } + pairs = append(pairs, MetaPair{Key: MetaText{Value: k}, Value: val}) + } + return MetaMap{Pairs: pairs}, true, nil +} + +func decodeMapBytes(b []byte) (TransactionMetadatum, bool, error) { + var m map[cbor.ByteString]cbor.RawMessage + if _, err := cbor.Decode(b, &m); err != nil { + return nil, false, nil //nolint:nilerr // not this shape + } + pairs := make([]MetaPair, 0, len(m)) + for k, rv := range m { + val, err := DecodeMetadatumRaw(rv) + if err != nil { + return nil, true, fmt.Errorf("decode map(bytes) value: %w", err) + } + + bs := k.Bytes() + pairs = append(pairs, MetaPair{ + Key: MetaBytes{Value: append([]byte(nil), bs...)}, + Value: val, + }) + } + return MetaMap{Pairs: pairs}, true, nil +} + +func (s *TransactionMetadataSet) UnmarshalCBOR(cborData []byte) error { + // Map form: map[uint]cbor.RawMessage + { + var tmp map[uint]cbor.RawMessage + if _, err := cbor.Decode(cborData, &tmp); err == nil { + out := make(TransactionMetadataSet, len(tmp)) + for k, v := range tmp { + md, err := DecodeMetadatumRaw(v) + if err != nil { + return fmt.Errorf("decode metadata value for index %d: %w", k, err) + } + out[k] = md + } + *s = out + return nil + } + } + // Array form: []cbor.RawMessage (nulls are skipped) + { + var arr []cbor.RawMessage + if _, err := cbor.Decode(cborData, &arr); err == nil { + out := make(TransactionMetadataSet) + for i, raw := range arr { + var probe any + if _, err := cbor.Decode(raw, &probe); err == nil && probe == nil { + continue // skip nulls + } + md, err := DecodeMetadatumRaw(raw) + if err != nil { + return fmt.Errorf("decode metadata list item %d: %w", i, err) + } + out[uint(i)] = md // #nosec G115 + } + *s = out + return nil + } + } + return errors.New("unsupported TransactionMetadataSet encoding") +} + +func (s TransactionMetadataSet) MarshalCBOR() ([]byte, error) { + if s == nil { + return cbor.Encode(&map[uint]any{}) + } + tmpMap := make(map[uint]any, len(s)) + for k, v := range s { + tmpMap[k] = metadatumToInterface(v) + } + return cbor.Encode(&tmpMap) +} + +func metadatumToInterface(m TransactionMetadatum) any { + switch t := m.(type) { + case MetaInt: + return t.Value + case MetaBytes: + return t.Value + case MetaText: + return t.Value + case MetaList: + out := make([]any, 0, len(t.Items)) + for _, it := range t.Items { + out = append(out, metadatumToInterface(it)) + } + return out + case MetaMap: + allText := true + for _, p := range t.Pairs { + if _, ok := p.Key.(MetaText); !ok { + allText = false + break + } + } + if allText { + mm := make(map[string]any, len(t.Pairs)) + for _, p := range t.Pairs { + mm[p.Key.(MetaText).Value] = metadatumToInterface(p.Value) + } + return mm + } + // Try all-int keys + allInt := true + for _, p := range t.Pairs { + if _, ok := p.Key.(MetaInt); !ok { + allInt = false + break + } + } + if allInt { + mm := make(map[int64]any, len(t.Pairs)) + for _, p := range t.Pairs { + mm[p.Key.(MetaInt).Value] = metadatumToInterface(p.Value) + } + return mm + } + + allBytes := true + for _, p := range t.Pairs { + if _, ok := p.Key.(MetaBytes); !ok { + allBytes = false + break + } + } + if allBytes { + mm := make(map[cbor.ByteString]any, len(t.Pairs)) + for _, p := range t.Pairs { + bs := p.Key.(MetaBytes).Value + key := cbor.NewByteString(bs) + mm[key] = metadatumToInterface(p.Value) + } + return mm + } + return nil + + default: + return nil + } +} diff --git a/ledger/common/metadata_test.go b/ledger/common/metadata_test.go new file mode 100644 index 00000000..c7d33e5e --- /dev/null +++ b/ledger/common/metadata_test.go @@ -0,0 +1,32 @@ +package common + +import ( + "encoding/hex" + "testing" + + "github.com/blinklabs-io/gouroboros/cbor" +) + +var allegraBlockHex = "a219ef64a301582095b1d64fbf76f17b1920a34d14fbca1f5ab499ea59eac37a8117d5e6b2e09605025820f3157c8eda34976620ad12e0979b2d3135a784c5d6a185878987143053c17d1c035839012c152eaa9e68dd7123a3054190dc987a24e50f1ab389c44a0c7a4089beb4d4d62d8f0dce5d745df4a670998aa20f54703b2bdc7a00b7d3d219ef65a1015840897063bdeab54d2e0586529909f20b42447bfaccdfb9988d2558896baf82a37f43c2fa4ae4240f5761e3dccf9523d7305d728f21dee4491e02373de6b14f7e07" + +func Test_Metadata_RoundTrip_AllegraSample(t *testing.T) { + + raw, err := hex.DecodeString(allegraBlockHex) + if err != nil { + t.Fatalf("bad hex: %v", err) + } + + var set TransactionMetadataSet + if _, err := cbor.Decode(raw, &set); err != nil { + t.Fatalf("decode: %v", err) + } + + enc, err := set.MarshalCBOR() + if err != nil { + t.Fatalf("encode: %v", err) + } + + if hex.EncodeToString(enc) != allegraBlockHex { + t.Fatalf("mismatch:\n got: %s\nwant: %s", hex.EncodeToString(enc), allegraBlockHex) + } +} diff --git a/ledger/common/tx.go b/ledger/common/tx.go index abd9c6ba..bd56bc00 100644 --- a/ledger/common/tx.go +++ b/ledger/common/tx.go @@ -28,7 +28,7 @@ type Transaction interface { Cbor() []byte Hash() Blake2b256 LeiosHash() Blake2b256 - Metadata() *cbor.LazyValue + Metadata() TransactionMetadatum IsValid() bool Consumed() []TransactionInput Produced() []Utxo diff --git a/ledger/conway/conway.go b/ledger/conway/conway.go index 55662be2..1b4ec15b 100644 --- a/ledger/conway/conway.go +++ b/ledger/conway/conway.go @@ -55,7 +55,7 @@ type ConwayBlock struct { BlockHeader *ConwayBlockHeader TransactionBodies []ConwayTransactionBody TransactionWitnessSets []ConwayTransactionWitnessSet - TransactionMetadataSet map[uint]*cbor.LazyValue + TransactionMetadataSet common.TransactionMetadataSet InvalidTransactions []uint } @@ -518,7 +518,7 @@ type ConwayTransaction struct { Body ConwayTransactionBody WitnessSet ConwayTransactionWitnessSet TxIsValid bool - TxMetadata *cbor.LazyValue + TxMetadata common.TransactionMetadatum } func (t *ConwayTransaction) UnmarshalCBOR(cborData []byte) error { @@ -632,7 +632,7 @@ func (t ConwayTransaction) Donation() uint64 { return t.Body.Donation() } -func (t ConwayTransaction) Metadata() *cbor.LazyValue { +func (t ConwayTransaction) Metadata() common.TransactionMetadatum { return t.TxMetadata } @@ -699,7 +699,7 @@ func (t *ConwayTransaction) Cbor() []byte { t.TxIsValid, } if t.TxMetadata != nil { - tmpObj = append(tmpObj, cbor.RawMessage(t.TxMetadata.Cbor())) + tmpObj = append(tmpObj, t.TxMetadata) } else { tmpObj = append(tmpObj, nil) } diff --git a/ledger/mary/mary.go b/ledger/mary/mary.go index e36a582d..5d7ad17e 100644 --- a/ledger/mary/mary.go +++ b/ledger/mary/mary.go @@ -52,7 +52,7 @@ type MaryBlock struct { BlockHeader *MaryBlockHeader TransactionBodies []MaryTransactionBody TransactionWitnessSets []shelley.ShelleyTransactionWitnessSet - TransactionMetadataSet map[uint]*cbor.LazyValue + TransactionMetadataSet common.TransactionMetadataSet } func (b *MaryBlock) UnmarshalCBOR(cborData []byte) error { @@ -268,7 +268,7 @@ type MaryTransaction struct { hash *common.Blake2b256 Body MaryTransactionBody WitnessSet shelley.ShelleyTransactionWitnessSet - TxMetadata *cbor.LazyValue + TxMetadata common.TransactionMetadatum } func (t *MaryTransaction) UnmarshalCBOR(cborData []byte) error { @@ -382,7 +382,7 @@ func (t MaryTransaction) Donation() uint64 { return t.Body.Donation() } -func (t MaryTransaction) Metadata() *cbor.LazyValue { +func (t MaryTransaction) Metadata() common.TransactionMetadatum { return t.TxMetadata } @@ -432,7 +432,7 @@ func (t *MaryTransaction) Cbor() []byte { cbor.RawMessage(t.WitnessSet.Cbor()), } if t.TxMetadata != nil { - tmpObj = append(tmpObj, cbor.RawMessage(t.TxMetadata.Cbor())) + tmpObj = append(tmpObj, t.TxMetadata) } else { tmpObj = append(tmpObj, nil) } diff --git a/ledger/shelley/shelley.go b/ledger/shelley/shelley.go index ecc44e20..ab8f44c5 100644 --- a/ledger/shelley/shelley.go +++ b/ledger/shelley/shelley.go @@ -53,7 +53,7 @@ type ShelleyBlock struct { BlockHeader *ShelleyBlockHeader TransactionBodies []ShelleyTransactionBody TransactionWitnessSets []ShelleyTransactionWitnessSet - TransactionMetadataSet map[uint]*cbor.LazyValue + TransactionMetadataSet common.TransactionMetadataSet } func (b *ShelleyBlock) UnmarshalCBOR(cborData []byte) error { @@ -540,7 +540,7 @@ type ShelleyTransaction struct { hash *common.Blake2b256 Body ShelleyTransactionBody WitnessSet ShelleyTransactionWitnessSet - TxMetadata *cbor.LazyValue + TxMetadata common.TransactionMetadatum } func (t *ShelleyTransaction) UnmarshalCBOR(cborData []byte) error { @@ -650,7 +650,7 @@ func (t ShelleyTransaction) Donation() uint64 { return t.Body.Donation() } -func (t ShelleyTransaction) Metadata() *cbor.LazyValue { +func (t ShelleyTransaction) Metadata() common.TransactionMetadatum { return t.TxMetadata } @@ -709,7 +709,7 @@ func (t *ShelleyTransaction) Cbor() []byte { cbor.RawMessage(t.WitnessSet.Cbor()), } if t.TxMetadata != nil { - tmpObj = append(tmpObj, cbor.RawMessage(t.TxMetadata.Cbor())) + tmpObj = append(tmpObj, t.TxMetadata) } else { tmpObj = append(tmpObj, nil) }