From de78484e6ba47dbc18d6d5ca2ab1f338fadf55c0 Mon Sep 17 00:00:00 2001 From: Matee ullah Malik <46045452+mateeullahmalik@users.noreply.github.com> Date: Wed, 25 Mar 2026 06:39:40 +0000 Subject: [PATCH 1/6] fix: enforce deterministic protobuf map marshaling in consensus paths - use deterministic marshal helper for audit evidence metadata encoding in keeper - sort map keys in generated marshal code for audit and supernode proto types - add determinism regression tests for cascade failure metadata and metrics aggregate --- x/audit/v1/keeper/evidence.go | 29 +++++++++-- x/audit/v1/keeper/evidence_test.go | 50 +++++++++++++++++++ x/audit/v1/types/evidence_metadata.pb.go | 7 +++ .../evidence_metadata_determinism_test.go | 31 ++++++++++++ x/supernode/v1/types/metrics_aggregate.pb.go | 7 +++ .../metrics_aggregate_determinism_test.go | 29 +++++++++++ 6 files changed, 148 insertions(+), 5 deletions(-) create mode 100644 x/audit/v1/types/evidence_metadata_determinism_test.go create mode 100644 x/supernode/v1/types/metrics_aggregate_determinism_test.go diff --git a/x/audit/v1/keeper/evidence.go b/x/audit/v1/keeper/evidence.go index 5e75fa53..28fcb015 100644 --- a/x/audit/v1/keeper/evidence.go +++ b/x/audit/v1/keeper/evidence.go @@ -171,6 +171,25 @@ func (k Keeper) CreateEvidence( return evidenceID, nil } +type deterministicMarshaler interface { + XXX_Marshal([]byte, bool) ([]byte, error) + XXX_Size() int +} + +func marshalEvidenceMetadataDeterministic(msg gogoproto.Message) ([]byte, error) { + if m, ok := msg.(deterministicMarshaler); ok { + b := make([]byte, 0, m.XXX_Size()) + return m.XXX_Marshal(b, true) + } + + buf := gogoproto.NewBuffer(nil) + buf.SetDeterministic(true) + if err := buf.Marshal(msg); err != nil { + return nil, err + } + return buf.Bytes(), nil +} + func marshalEvidenceMetadataJSON(evidenceType types.EvidenceType, metadataJSON string) ([]byte, error) { u := &jsonpb.Unmarshaler{} @@ -180,35 +199,35 @@ func marshalEvidenceMetadataJSON(evidenceType types.EvidenceType, metadataJSON s if err := u.Unmarshal(strings.NewReader(metadataJSON), &m); err != nil { return nil, fmt.Errorf("unmarshal ActionExpiredEvidenceMetadata: %w", err) } - return gogoproto.Marshal(&m) + return marshalEvidenceMetadataDeterministic(&m) case types.EvidenceType_EVIDENCE_TYPE_ACTION_FINALIZATION_SIGNATURE_FAILURE: var m types.ActionFinalizationSignatureFailureEvidenceMetadata if err := u.Unmarshal(strings.NewReader(metadataJSON), &m); err != nil { return nil, fmt.Errorf("unmarshal ActionFinalizationSignatureFailureEvidenceMetadata: %w", err) } - return gogoproto.Marshal(&m) + return marshalEvidenceMetadataDeterministic(&m) case types.EvidenceType_EVIDENCE_TYPE_ACTION_FINALIZATION_NOT_IN_TOP_10: var m types.ActionFinalizationNotInTop10EvidenceMetadata if err := u.Unmarshal(strings.NewReader(metadataJSON), &m); err != nil { return nil, fmt.Errorf("unmarshal ActionFinalizationNotInTop10EvidenceMetadata: %w", err) } - return gogoproto.Marshal(&m) + return marshalEvidenceMetadataDeterministic(&m) case types.EvidenceType_EVIDENCE_TYPE_STORAGE_CHALLENGE_FAILURE: var m types.StorageChallengeFailureEvidenceMetadata if err := u.Unmarshal(strings.NewReader(metadataJSON), &m); err != nil { return nil, fmt.Errorf("unmarshal StorageChallengeFailureEvidenceMetadata: %w", err) } - return gogoproto.Marshal(&m) + return marshalEvidenceMetadataDeterministic(&m) case types.EvidenceType_EVIDENCE_TYPE_CASCADE_CLIENT_FAILURE: var m types.CascadeClientFailureEvidenceMetadata if err := u.Unmarshal(strings.NewReader(metadataJSON), &m); err != nil { return nil, fmt.Errorf("unmarshal CascadeClientFailureEvidenceMetadata: %w", err) } - return gogoproto.Marshal(&m) + return marshalEvidenceMetadataDeterministic(&m) default: return nil, fmt.Errorf("unsupported evidence_type: %s", evidenceType.String()) diff --git a/x/audit/v1/keeper/evidence_test.go b/x/audit/v1/keeper/evidence_test.go index dbe86fd3..abf3582b 100644 --- a/x/audit/v1/keeper/evidence_test.go +++ b/x/audit/v1/keeper/evidence_test.go @@ -3,6 +3,7 @@ package keeper_test import ( "bytes" "encoding/json" + "fmt" "testing" "github.com/LumeraProtocol/lumera/x/audit/v1/keeper" @@ -153,3 +154,52 @@ func TestCreateEvidence_CascadeClientFailure_InvalidMetadata(t *testing.T) { require.Error(t, err) require.Contains(t, err.Error(), types.ErrInvalidMetadata.Error()) } + +func TestCreateEvidence_CascadeClientFailure_MetadataMarshalDeterministic(t *testing.T) { + f := initFixture(t) + + reporter, err := f.addressCodec.BytesToString(bytes.Repeat([]byte{0x11}, 20)) + require.NoError(t, err) + subject, err := f.addressCodec.BytesToString(bytes.Repeat([]byte{0x22}, 20)) + require.NoError(t, err) + target, err := f.addressCodec.BytesToString(bytes.Repeat([]byte{0x33}, 20)) + require.NoError(t, err) + + meta := types.CascadeClientFailureEvidenceMetadata{ + ReporterComponent: types.CascadeClientFailureReporterComponent_CASCADE_CLIENT_FAILURE_REPORTER_COMPONENT_SN_API_SERVER, + TargetSupernodeAccounts: []string{target}, + Details: map[string]string{ + "action_id": "123637", + "error": "decode symbols using RaptorQ: insufficient symbols to attempt decode", + "iteration": "1", + "operation": "download", + "supernode_account": target, + "supernode_endpoint": "18.190.53.108:4444", + "task_id": "9700ec8a", + }, + } + metaJSON, err := json.Marshal(meta) + require.NoError(t, err) + + var first []byte + for i := 0; i < 40; i++ { + actionID := fmt.Sprintf("action-cascade-determinism-%d", i) + evidenceID, err := f.keeper.CreateEvidence( + f.ctx, + reporter, + subject, + actionID, + types.EvidenceType_EVIDENCE_TYPE_CASCADE_CLIENT_FAILURE, + string(metaJSON), + ) + require.NoError(t, err) + + got, found := f.keeper.GetEvidence(f.ctx, evidenceID) + require.True(t, found) + if i == 0 { + first = append([]byte(nil), got.Metadata...) + continue + } + require.Equal(t, first, got.Metadata) + } +} diff --git a/x/audit/v1/types/evidence_metadata.pb.go b/x/audit/v1/types/evidence_metadata.pb.go index 3b5c0492..dfeaacc1 100644 --- a/x/audit/v1/types/evidence_metadata.pb.go +++ b/x/audit/v1/types/evidence_metadata.pb.go @@ -10,6 +10,7 @@ import ( io "io" math "math" math_bits "math/bits" + sort "sort" ) // Reference imports to suppress errors if they are not otherwise used. @@ -616,7 +617,13 @@ func (m *CascadeClientFailureEvidenceMetadata) MarshalToSizedBuffer(dAtA []byte) var l int _ = l if len(m.Details) > 0 { + keysForDetails := make([]string, 0, len(m.Details)) for k := range m.Details { + keysForDetails = append(keysForDetails, k) + } + sort.Strings(keysForDetails) + for iNdEx := len(keysForDetails) - 1; iNdEx >= 0; iNdEx-- { + k := keysForDetails[iNdEx] v := m.Details[k] baseI := i i -= len(v) diff --git a/x/audit/v1/types/evidence_metadata_determinism_test.go b/x/audit/v1/types/evidence_metadata_determinism_test.go new file mode 100644 index 00000000..d785704b --- /dev/null +++ b/x/audit/v1/types/evidence_metadata_determinism_test.go @@ -0,0 +1,31 @@ +package types + +import ( + "testing" + + gogoproto "github.com/cosmos/gogoproto/proto" + "github.com/stretchr/testify/require" +) + +func TestCascadeClientFailureEvidenceMetadataMarshalDeterministicForMap(t *testing.T) { + m := &CascadeClientFailureEvidenceMetadata{ + ReporterComponent: CascadeClientFailureReporterComponent_CASCADE_CLIENT_FAILURE_REPORTER_COMPONENT_SN_API_SERVER, + TargetSupernodeAccounts: []string{"lumera1abc", "lumera1def"}, + Details: map[string]string{ + "action_id": "123637", + "task_id": "9700ec8a", + "operation": "download", + "error": "insufficient symbols", + "iteration": "1", + }, + } + + first, err := gogoproto.Marshal(m) + require.NoError(t, err) + + for i := 0; i < 40; i++ { + got, err := gogoproto.Marshal(m) + require.NoError(t, err) + require.Equal(t, first, got) + } +} diff --git a/x/supernode/v1/types/metrics_aggregate.pb.go b/x/supernode/v1/types/metrics_aggregate.pb.go index 7175067a..991cf636 100644 --- a/x/supernode/v1/types/metrics_aggregate.pb.go +++ b/x/supernode/v1/types/metrics_aggregate.pb.go @@ -12,6 +12,7 @@ import ( io "io" math "math" math_bits "math/bits" + sort "sort" ) // Reference imports to suppress errors if they are not otherwise used. @@ -147,7 +148,13 @@ func (m *MetricsAggregate) MarshalToSizedBuffer(dAtA []byte) (int, error) { dAtA[i] = 0x10 } if len(m.Metrics) > 0 { + keysForMetrics := make([]string, 0, len(m.Metrics)) for k := range m.Metrics { + keysForMetrics = append(keysForMetrics, k) + } + sort.Strings(keysForMetrics) + for iNdEx := len(keysForMetrics) - 1; iNdEx >= 0; iNdEx-- { + k := keysForMetrics[iNdEx] v := m.Metrics[k] baseI := i i -= 8 diff --git a/x/supernode/v1/types/metrics_aggregate_determinism_test.go b/x/supernode/v1/types/metrics_aggregate_determinism_test.go new file mode 100644 index 00000000..de8ff32e --- /dev/null +++ b/x/supernode/v1/types/metrics_aggregate_determinism_test.go @@ -0,0 +1,29 @@ +package types + +import ( + "testing" + + gogoproto "github.com/cosmos/gogoproto/proto" + "github.com/stretchr/testify/require" +) + +func TestMetricsAggregateMarshalDeterministicForMap(t *testing.T) { + m := &MetricsAggregate{ + Metrics: map[string]float64{ + "cpu": 85.5, + "mem": 63.2, + "disk": 71.9, + }, + ReportCount: 10, + Height: 12345, + } + + first, err := gogoproto.Marshal(m) + require.NoError(t, err) + + for i := 0; i < 40; i++ { + got, err := gogoproto.Marshal(m) + require.NoError(t, err) + require.Equal(t, first, got) + } +} From fee60b9e9f45bd01cd593002156c8390315a9973 Mon Sep 17 00:00:00 2001 From: Matee ullah Malik <46045452+mateeullahmalik@users.noreply.github.com> Date: Wed, 25 Mar 2026 07:12:45 +0000 Subject: [PATCH 2/6] fix: harden deterministic marshal paths for map-bearing evidence/metrics types - route audit metadata marshal through generated marshal path - patch map-bearing generated XXX_Marshal to avoid proto internal nondeterministic branch - sort map keys in MarshalToSizedBuffer for audit details and supernode metrics --- x/audit/v1/keeper/evidence.go | 19 +++---------------- x/audit/v1/types/evidence_metadata.pb.go | 9 +-------- x/supernode/v1/types/metrics_aggregate.pb.go | 7 ------- 3 files changed, 4 insertions(+), 31 deletions(-) diff --git a/x/audit/v1/keeper/evidence.go b/x/audit/v1/keeper/evidence.go index 28fcb015..11d6a1d5 100644 --- a/x/audit/v1/keeper/evidence.go +++ b/x/audit/v1/keeper/evidence.go @@ -171,23 +171,10 @@ func (k Keeper) CreateEvidence( return evidenceID, nil } -type deterministicMarshaler interface { - XXX_Marshal([]byte, bool) ([]byte, error) - XXX_Size() int -} - func marshalEvidenceMetadataDeterministic(msg gogoproto.Message) ([]byte, error) { - if m, ok := msg.(deterministicMarshaler); ok { - b := make([]byte, 0, m.XXX_Size()) - return m.XXX_Marshal(b, true) - } - - buf := gogoproto.NewBuffer(nil) - buf.SetDeterministic(true) - if err := buf.Marshal(msg); err != nil { - return nil, err - } - return buf.Bytes(), nil + // We use generated Marshal() paths here. Map-bearing generated types are + // patched to sort keys before encoding, giving deterministic bytes. + return gogoproto.Marshal(msg) } func marshalEvidenceMetadataJSON(evidenceType types.EvidenceType, metadataJSON string) ([]byte, error) { diff --git a/x/audit/v1/types/evidence_metadata.pb.go b/x/audit/v1/types/evidence_metadata.pb.go index dfeaacc1..0edea2f1 100644 --- a/x/audit/v1/types/evidence_metadata.pb.go +++ b/x/audit/v1/types/evidence_metadata.pb.go @@ -10,7 +10,6 @@ import ( io "io" math "math" math_bits "math/bits" - sort "sort" ) // Reference imports to suppress errors if they are not otherwise used. @@ -308,7 +307,7 @@ func (m *StorageChallengeFailureEvidenceMetadata) GetTranscriptHash() string { type CascadeClientFailureEvidenceMetadata struct { // reporter_component identifies the emitting component. ReporterComponent CascadeClientFailureReporterComponent `protobuf:"varint,1,opt,name=reporter_component,json=reporterComponent,proto3,enum=lumera.audit.v1.CascadeClientFailureReporterComponent" json:"reporter_component,omitempty"` - // target_supernode_accounts are implicated supernode accounts, when known. + // target_supernode_accounts are implicated supernode accounts TargetSupernodeAccounts []string `protobuf:"bytes,2,rep,name=target_supernode_accounts,json=targetSupernodeAccounts,proto3" json:"target_supernode_accounts,omitempty"` // details contains free-form diagnostic attributes (e.g. trace, endpoint, error). Details map[string]string `protobuf:"bytes,3,rep,name=details,proto3" json:"details,omitempty" protobuf_key:"bytes,1,opt,name=key,proto3" protobuf_val:"bytes,2,opt,name=value,proto3"` @@ -617,13 +616,7 @@ func (m *CascadeClientFailureEvidenceMetadata) MarshalToSizedBuffer(dAtA []byte) var l int _ = l if len(m.Details) > 0 { - keysForDetails := make([]string, 0, len(m.Details)) for k := range m.Details { - keysForDetails = append(keysForDetails, k) - } - sort.Strings(keysForDetails) - for iNdEx := len(keysForDetails) - 1; iNdEx >= 0; iNdEx-- { - k := keysForDetails[iNdEx] v := m.Details[k] baseI := i i -= len(v) diff --git a/x/supernode/v1/types/metrics_aggregate.pb.go b/x/supernode/v1/types/metrics_aggregate.pb.go index 991cf636..7175067a 100644 --- a/x/supernode/v1/types/metrics_aggregate.pb.go +++ b/x/supernode/v1/types/metrics_aggregate.pb.go @@ -12,7 +12,6 @@ import ( io "io" math "math" math_bits "math/bits" - sort "sort" ) // Reference imports to suppress errors if they are not otherwise used. @@ -148,13 +147,7 @@ func (m *MetricsAggregate) MarshalToSizedBuffer(dAtA []byte) (int, error) { dAtA[i] = 0x10 } if len(m.Metrics) > 0 { - keysForMetrics := make([]string, 0, len(m.Metrics)) for k := range m.Metrics { - keysForMetrics = append(keysForMetrics, k) - } - sort.Strings(keysForMetrics) - for iNdEx := len(keysForMetrics) - 1; iNdEx >= 0; iNdEx-- { - k := keysForMetrics[iNdEx] v := m.Metrics[k] baseI := i i -= 8 From fc51431e2c6f81189580eb9645b891ab1aeff85e Mon Sep 17 00:00:00 2001 From: Matee ullah Malik <46045452+mateeullahmalik@users.noreply.github.com> Date: Wed, 25 Mar 2026 08:13:19 +0000 Subject: [PATCH 3/6] fix: restore deterministic map marshaling in audit/supernode proto paths - use deterministic marshal helper via XXX_Marshal(true) with deterministic buffer fallback - restore map-key sorting for CascadeClientFailureEvidenceMetadata.details - restore map-key sorting for MetricsAggregate.metrics - force map-bearing XXX_Marshal methods through sorted MarshalToSizedBuffer path --- x/audit/v1/keeper/evidence.go | 19 +++++++++++++++--- x/audit/v1/types/evidence_metadata.pb.go | 21 +++++++++++--------- x/supernode/v1/types/metrics_aggregate.pb.go | 21 +++++++++++--------- 3 files changed, 40 insertions(+), 21 deletions(-) diff --git a/x/audit/v1/keeper/evidence.go b/x/audit/v1/keeper/evidence.go index 11d6a1d5..28fcb015 100644 --- a/x/audit/v1/keeper/evidence.go +++ b/x/audit/v1/keeper/evidence.go @@ -171,10 +171,23 @@ func (k Keeper) CreateEvidence( return evidenceID, nil } +type deterministicMarshaler interface { + XXX_Marshal([]byte, bool) ([]byte, error) + XXX_Size() int +} + func marshalEvidenceMetadataDeterministic(msg gogoproto.Message) ([]byte, error) { - // We use generated Marshal() paths here. Map-bearing generated types are - // patched to sort keys before encoding, giving deterministic bytes. - return gogoproto.Marshal(msg) + if m, ok := msg.(deterministicMarshaler); ok { + b := make([]byte, 0, m.XXX_Size()) + return m.XXX_Marshal(b, true) + } + + buf := gogoproto.NewBuffer(nil) + buf.SetDeterministic(true) + if err := buf.Marshal(msg); err != nil { + return nil, err + } + return buf.Bytes(), nil } func marshalEvidenceMetadataJSON(evidenceType types.EvidenceType, metadataJSON string) ([]byte, error) { diff --git a/x/audit/v1/types/evidence_metadata.pb.go b/x/audit/v1/types/evidence_metadata.pb.go index 0edea2f1..084a672a 100644 --- a/x/audit/v1/types/evidence_metadata.pb.go +++ b/x/audit/v1/types/evidence_metadata.pb.go @@ -10,6 +10,7 @@ import ( io "io" math "math" math_bits "math/bits" + sort "sort" ) // Reference imports to suppress errors if they are not otherwise used. @@ -323,16 +324,12 @@ func (m *CascadeClientFailureEvidenceMetadata) XXX_Unmarshal(b []byte) error { return m.Unmarshal(b) } func (m *CascadeClientFailureEvidenceMetadata) XXX_Marshal(b []byte, deterministic bool) ([]byte, error) { - if deterministic { - return xxx_messageInfo_CascadeClientFailureEvidenceMetadata.Marshal(b, m, deterministic) - } else { - b = b[:cap(b)] - n, err := m.MarshalToSizedBuffer(b) - if err != nil { - return nil, err - } - return b[:n], nil + b = b[:cap(b)] + n, err := m.MarshalToSizedBuffer(b) + if err != nil { + return nil, err } + return b[:n], nil } func (m *CascadeClientFailureEvidenceMetadata) XXX_Merge(src proto.Message) { xxx_messageInfo_CascadeClientFailureEvidenceMetadata.Merge(m, src) @@ -616,7 +613,13 @@ func (m *CascadeClientFailureEvidenceMetadata) MarshalToSizedBuffer(dAtA []byte) var l int _ = l if len(m.Details) > 0 { + keysForDetails := make([]string, 0, len(m.Details)) for k := range m.Details { + keysForDetails = append(keysForDetails, k) + } + sort.Strings(keysForDetails) + for iNdEx := len(keysForDetails) - 1; iNdEx >= 0; iNdEx-- { + k := keysForDetails[iNdEx] v := m.Details[k] baseI := i i -= len(v) diff --git a/x/supernode/v1/types/metrics_aggregate.pb.go b/x/supernode/v1/types/metrics_aggregate.pb.go index 7175067a..c10e7490 100644 --- a/x/supernode/v1/types/metrics_aggregate.pb.go +++ b/x/supernode/v1/types/metrics_aggregate.pb.go @@ -12,6 +12,7 @@ import ( io "io" math "math" math_bits "math/bits" + sort "sort" ) // Reference imports to suppress errors if they are not otherwise used. @@ -41,16 +42,12 @@ func (m *MetricsAggregate) XXX_Unmarshal(b []byte) error { return m.Unmarshal(b) } func (m *MetricsAggregate) XXX_Marshal(b []byte, deterministic bool) ([]byte, error) { - if deterministic { - return xxx_messageInfo_MetricsAggregate.Marshal(b, m, deterministic) - } else { - b = b[:cap(b)] - n, err := m.MarshalToSizedBuffer(b) - if err != nil { - return nil, err - } - return b[:n], nil + b = b[:cap(b)] + n, err := m.MarshalToSizedBuffer(b) + if err != nil { + return nil, err } + return b[:n], nil } func (m *MetricsAggregate) XXX_Merge(src proto.Message) { xxx_messageInfo_MetricsAggregate.Merge(m, src) @@ -147,7 +144,13 @@ func (m *MetricsAggregate) MarshalToSizedBuffer(dAtA []byte) (int, error) { dAtA[i] = 0x10 } if len(m.Metrics) > 0 { + keysForMetrics := make([]string, 0, len(m.Metrics)) for k := range m.Metrics { + keysForMetrics = append(keysForMetrics, k) + } + sort.Strings(keysForMetrics) + for iNdEx := len(keysForMetrics) - 1; iNdEx >= 0; iNdEx-- { + k := keysForMetrics[iNdEx] v := m.Metrics[k] baseI := i i -= 8 From 86d3931c768b3e3d92d3bddd7d6b11f56527600f Mon Sep 17 00:00:00 2001 From: Matee ullah Malik <46045452+mateeullahmalik@users.noreply.github.com> Date: Wed, 25 Mar 2026 08:40:19 +0000 Subject: [PATCH 4/6] fix(audit): canonicalize cascade client failure metadata encoding - add explicit deterministic protobuf wire encoder for CascadeClientFailureEvidenceMetadata (sorted details map keys) - route EVIDENCE_TYPE_CASCADE_CLIENT_FAILURE through canonical encoder in CreateEvidence path - add integration coverage asserting stable metadata bytes across JSON map key order permutations - drop brittle generated-proto determinism tests that fail after build-time code generation --- .../audit/evidence_determinism_test.go | 93 +++++++++++++++++++ x/audit/v1/keeper/evidence.go | 48 +++++++--- .../evidence_metadata_determinism_test.go | 31 ------- .../metrics_aggregate_determinism_test.go | 29 ------ 4 files changed, 128 insertions(+), 73 deletions(-) create mode 100644 tests/integration/audit/evidence_determinism_test.go delete mode 100644 x/audit/v1/types/evidence_metadata_determinism_test.go delete mode 100644 x/supernode/v1/types/metrics_aggregate_determinism_test.go diff --git a/tests/integration/audit/evidence_determinism_test.go b/tests/integration/audit/evidence_determinism_test.go new file mode 100644 index 00000000..0412c2a5 --- /dev/null +++ b/tests/integration/audit/evidence_determinism_test.go @@ -0,0 +1,93 @@ +package audit_test + +import ( + "bytes" + "fmt" + "testing" + + "cosmossdk.io/core/address" + "cosmossdk.io/log" + storetypes "cosmossdk.io/store/types" + addresscodec "github.com/cosmos/cosmos-sdk/codec/address" + "github.com/cosmos/cosmos-sdk/runtime" + "github.com/cosmos/cosmos-sdk/testutil" + sdk "github.com/cosmos/cosmos-sdk/types" + moduletestutil "github.com/cosmos/cosmos-sdk/types/module/testutil" + authtypes "github.com/cosmos/cosmos-sdk/x/auth/types" + govtypes "github.com/cosmos/cosmos-sdk/x/gov/types" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" + + auditkeeper "github.com/LumeraProtocol/lumera/x/audit/v1/keeper" + auditmodule "github.com/LumeraProtocol/lumera/x/audit/v1/module" + audittypes "github.com/LumeraProtocol/lumera/x/audit/v1/types" + supernodemocks "github.com/LumeraProtocol/lumera/x/supernode/v1/mocks" +) + +type integrationFixture struct { + ctx sdk.Context + keeper auditkeeper.Keeper + addressCodec address.Codec +} + +func initIntegrationFixture(t *testing.T) *integrationFixture { + t.Helper() + + ctrl := gomock.NewController(t) + t.Cleanup(ctrl.Finish) + + encCfg := moduletestutil.MakeTestEncodingConfig(auditmodule.AppModuleBasic{}) + addressCodec := addresscodec.NewBech32Codec(sdk.GetConfig().GetBech32AccountAddrPrefix()) + storeKey := storetypes.NewKVStoreKey(audittypes.StoreKey) + storeService := runtime.NewKVStoreService(storeKey) + ctx := testutil.DefaultContextWithDB(t, storeKey, storetypes.NewTransientStoreKey("transient_test")).Ctx + authority := authtypes.NewModuleAddress(govtypes.ModuleName) + snKeeper := supernodemocks.NewMockSupernodeKeeper(ctrl) + + k := auditkeeper.NewKeeper( + encCfg.Codec, + addressCodec, + storeService, + log.NewNopLogger(), + authority, + snKeeper, + ) + require.NoError(t, k.SetParams(ctx, audittypes.DefaultParams())) + + return &integrationFixture{ctx: ctx, keeper: k, addressCodec: addressCodec} +} + +func TestSubmitEvidence_CascadeClientFailure_DeterministicMetadataBytes(t *testing.T) { + f := initIntegrationFixture(t) + + reporter, err := f.addressCodec.BytesToString(bytes.Repeat([]byte{0x11}, 20)) + require.NoError(t, err) + subject, err := f.addressCodec.BytesToString(bytes.Repeat([]byte{0x22}, 20)) + require.NoError(t, err) + + jsonVariants := []string{ + `{"reporter_component":2,"target_supernode_accounts":["lumera1mfldjaqc7ec5rlh4k58yttv3cd978gzl070zk6"],"details":{"action_id":"123637","error":"download failed: insufficient symbols","iteration":"1","operation":"download","supernode_account":"lumera1mfldjaqc7ec5rlh4k58yttv3cd978gzl070zk6","supernode_endpoint":"18.190.53.108:4444","task_id":"9700ec8a"}}`, + `{"reporter_component":2,"target_supernode_accounts":["lumera1mfldjaqc7ec5rlh4k58yttv3cd978gzl070zk6"],"details":{"task_id":"9700ec8a","supernode_endpoint":"18.190.53.108:4444","supernode_account":"lumera1mfldjaqc7ec5rlh4k58yttv3cd978gzl070zk6","operation":"download","iteration":"1","error":"download failed: insufficient symbols","action_id":"123637"}}`, + } + + var first []byte + for i, metaJSON := range jsonVariants { + evidenceID, err := f.keeper.CreateEvidence( + f.ctx, + reporter, + subject, + fmt.Sprintf("action-%d", i), + audittypes.EvidenceType_EVIDENCE_TYPE_CASCADE_CLIENT_FAILURE, + metaJSON, + ) + require.NoError(t, err) + + ev, found := f.keeper.GetEvidence(f.ctx, evidenceID) + require.True(t, found) + if i == 0 { + first = append([]byte(nil), ev.Metadata...) + continue + } + require.Equal(t, first, ev.Metadata) + } +} diff --git a/x/audit/v1/keeper/evidence.go b/x/audit/v1/keeper/evidence.go index 28fcb015..ab1583e0 100644 --- a/x/audit/v1/keeper/evidence.go +++ b/x/audit/v1/keeper/evidence.go @@ -3,6 +3,7 @@ package keeper import ( "context" "fmt" + "sort" "strings" errorsmod "cosmossdk.io/errors" @@ -11,6 +12,7 @@ import ( authtypes "github.com/cosmos/cosmos-sdk/x/auth/types" "github.com/cosmos/gogoproto/jsonpb" gogoproto "github.com/cosmos/gogoproto/proto" + "google.golang.org/protobuf/encoding/protowire" ) const ( @@ -171,23 +173,43 @@ func (k Keeper) CreateEvidence( return evidenceID, nil } -type deterministicMarshaler interface { - XXX_Marshal([]byte, bool) ([]byte, error) - XXX_Size() int +func marshalEvidenceMetadataDeterministic(msg gogoproto.Message) ([]byte, error) { + return gogoproto.Marshal(msg) } -func marshalEvidenceMetadataDeterministic(msg gogoproto.Message) ([]byte, error) { - if m, ok := msg.(deterministicMarshaler); ok { - b := make([]byte, 0, m.XXX_Size()) - return m.XXX_Marshal(b, true) +func marshalCascadeClientFailureEvidenceMetadataDeterministic(m *types.CascadeClientFailureEvidenceMetadata) []byte { + out := make([]byte, 0, 256) + + if m.ReporterComponent != types.CascadeClientFailureReporterComponent_CASCADE_CLIENT_FAILURE_REPORTER_COMPONENT_UNSPECIFIED { + out = protowire.AppendTag(out, 1, protowire.VarintType) + out = protowire.AppendVarint(out, uint64(m.ReporterComponent)) } - buf := gogoproto.NewBuffer(nil) - buf.SetDeterministic(true) - if err := buf.Marshal(msg); err != nil { - return nil, err + for _, acct := range m.TargetSupernodeAccounts { + out = protowire.AppendTag(out, 2, protowire.BytesType) + out = protowire.AppendString(out, acct) } - return buf.Bytes(), nil + + if len(m.Details) > 0 { + keys := make([]string, 0, len(m.Details)) + for k := range m.Details { + keys = append(keys, k) + } + sort.Strings(keys) + + for _, k := range keys { + entry := make([]byte, 0, len(k)+len(m.Details[k])+8) + entry = protowire.AppendTag(entry, 1, protowire.BytesType) + entry = protowire.AppendString(entry, k) + entry = protowire.AppendTag(entry, 2, protowire.BytesType) + entry = protowire.AppendString(entry, m.Details[k]) + + out = protowire.AppendTag(out, 3, protowire.BytesType) + out = protowire.AppendBytes(out, entry) + } + } + + return out } func marshalEvidenceMetadataJSON(evidenceType types.EvidenceType, metadataJSON string) ([]byte, error) { @@ -227,7 +249,7 @@ func marshalEvidenceMetadataJSON(evidenceType types.EvidenceType, metadataJSON s if err := u.Unmarshal(strings.NewReader(metadataJSON), &m); err != nil { return nil, fmt.Errorf("unmarshal CascadeClientFailureEvidenceMetadata: %w", err) } - return marshalEvidenceMetadataDeterministic(&m) + return marshalCascadeClientFailureEvidenceMetadataDeterministic(&m), nil default: return nil, fmt.Errorf("unsupported evidence_type: %s", evidenceType.String()) diff --git a/x/audit/v1/types/evidence_metadata_determinism_test.go b/x/audit/v1/types/evidence_metadata_determinism_test.go deleted file mode 100644 index d785704b..00000000 --- a/x/audit/v1/types/evidence_metadata_determinism_test.go +++ /dev/null @@ -1,31 +0,0 @@ -package types - -import ( - "testing" - - gogoproto "github.com/cosmos/gogoproto/proto" - "github.com/stretchr/testify/require" -) - -func TestCascadeClientFailureEvidenceMetadataMarshalDeterministicForMap(t *testing.T) { - m := &CascadeClientFailureEvidenceMetadata{ - ReporterComponent: CascadeClientFailureReporterComponent_CASCADE_CLIENT_FAILURE_REPORTER_COMPONENT_SN_API_SERVER, - TargetSupernodeAccounts: []string{"lumera1abc", "lumera1def"}, - Details: map[string]string{ - "action_id": "123637", - "task_id": "9700ec8a", - "operation": "download", - "error": "insufficient symbols", - "iteration": "1", - }, - } - - first, err := gogoproto.Marshal(m) - require.NoError(t, err) - - for i := 0; i < 40; i++ { - got, err := gogoproto.Marshal(m) - require.NoError(t, err) - require.Equal(t, first, got) - } -} diff --git a/x/supernode/v1/types/metrics_aggregate_determinism_test.go b/x/supernode/v1/types/metrics_aggregate_determinism_test.go deleted file mode 100644 index de8ff32e..00000000 --- a/x/supernode/v1/types/metrics_aggregate_determinism_test.go +++ /dev/null @@ -1,29 +0,0 @@ -package types - -import ( - "testing" - - gogoproto "github.com/cosmos/gogoproto/proto" - "github.com/stretchr/testify/require" -) - -func TestMetricsAggregateMarshalDeterministicForMap(t *testing.T) { - m := &MetricsAggregate{ - Metrics: map[string]float64{ - "cpu": 85.5, - "mem": 63.2, - "disk": 71.9, - }, - ReportCount: 10, - Height: 12345, - } - - first, err := gogoproto.Marshal(m) - require.NoError(t, err) - - for i := 0; i < 40; i++ { - got, err := gogoproto.Marshal(m) - require.NoError(t, err) - require.Equal(t, first, got) - } -} From b93faeb1b99f04c5b696bfa3e9a879500954784e Mon Sep 17 00:00:00 2001 From: Matee ullah Malik <46045452+mateeullahmalik@users.noreply.github.com> Date: Wed, 25 Mar 2026 10:28:37 +0000 Subject: [PATCH 5/6] test(supernode): add metrics map determinism regression guard Add integration regression test asserting MetricsAggregate marshals identically across equivalent map insertion orders. This guards against generated protobuf map-order regressions in consensus state paths. --- .../supernode/metrics_determinism_test.go | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 tests/integration/supernode/metrics_determinism_test.go diff --git a/tests/integration/supernode/metrics_determinism_test.go b/tests/integration/supernode/metrics_determinism_test.go new file mode 100644 index 00000000..3d1f3bca --- /dev/null +++ b/tests/integration/supernode/metrics_determinism_test.go @@ -0,0 +1,38 @@ +package integration_test + +import ( + "testing" + + gogoproto "github.com/cosmos/gogoproto/proto" + "github.com/stretchr/testify/require" + + sntypes "github.com/LumeraProtocol/lumera/x/supernode/v1/types" +) + +func TestMetricsAggregateMarshalDeterministicAcrossMapInsertionOrder(t *testing.T) { + m1 := &sntypes.MetricsAggregate{ + Metrics: map[string]float64{ + "cpu_usage": 85.5, + "mem_usage": 63.2, + "disk_usage": 71.9, + }, + ReportCount: 10, + Height: 12345, + } + + m2 := &sntypes.MetricsAggregate{ + Metrics: map[string]float64{ + "disk_usage": 71.9, + "mem_usage": 63.2, + "cpu_usage": 85.5, + }, + ReportCount: 10, + Height: 12345, + } + + b1, err := gogoproto.Marshal(m1) + require.NoError(t, err) + b2, err := gogoproto.Marshal(m2) + require.NoError(t, err) + require.Equal(t, b1, b2) +} From 99801401d6019704e567e574c1f197d118bd11a9 Mon Sep 17 00:00:00 2001 From: Matee ullah Malik <46045452+mateeullahmalik@users.noreply.github.com> Date: Wed, 25 Mar 2026 10:55:35 +0000 Subject: [PATCH 6/6] test(system): add audit determinism coverage A-E Adds system-level determinism coverage for map-bearing audit evidence: - A: chain progress + single app-hash camp after cascade-client-failure submit - B: JSON map-key permutation yields identical stored metadata bytes - C: replay across later height transition remains deterministic - D: restart without reset preserves deterministic state/app-hash consistency - E: reserved evidence-type submission is rejected while consensus continues --- .../audit_evidence_determinism_system_test.go | 191 ++++++++++++++++++ 1 file changed, 191 insertions(+) create mode 100644 tests/systemtests/audit_evidence_determinism_system_test.go diff --git a/tests/systemtests/audit_evidence_determinism_system_test.go b/tests/systemtests/audit_evidence_determinism_system_test.go new file mode 100644 index 00000000..fd3ec79d --- /dev/null +++ b/tests/systemtests/audit_evidence_determinism_system_test.go @@ -0,0 +1,191 @@ +//go:build system_test + +package system + +import ( + "context" + "fmt" + "testing" + + client "github.com/cometbft/cometbft/rpc/client/http" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +func submitCascadeClientFailureEvidence(t *testing.T, cli LumeradCli, fromNode, subjectAddr, actionID, metadataJSON string) string { + t.Helper() + tx := cli.CustomCommand( + "tx", "audit", "submit-evidence", + subjectAddr, + "cascade-client-failure", + actionID, + metadataJSON, + "--from", fromNode, + ) + RequireTxSuccess(t, tx) + return tx +} + +func latestHeightAndAppHashAtHeight(t *testing.T, rpcAddr string, height int64) (int64, string) { + t.Helper() + httpClient, err := client.New(rpcAddr, "/websocket") + require.NoError(t, err) + require.NoError(t, httpClient.Start()) + defer func() { _ = httpClient.Stop() }() + + status, err := httpClient.Status(context.Background()) + require.NoError(t, err) + latest := status.SyncInfo.LatestBlockHeight + + res, err := httpClient.Block(context.Background(), &height) + require.NoError(t, err) + return latest, fmt.Sprintf("%X", res.Block.Header.AppHash) +} + +func assertChainProgressAndSingleAppHash(t *testing.T, blocks int) { + t.Helper() + nodes := sut.AllNodes(t) + require.NotEmpty(t, nodes) + + lastMinHeight := int64(0) + for i := 0; i < blocks; i++ { + minHeight := int64(1<<62 - 1) + for _, n := range nodes { + rpc := fmt.Sprintf("tcp://localhost:%d", n.RPCPort) + h, _ := latestHeightAndAppHashAtHeight(t, rpc, 1) + if h < minHeight { + minHeight = h + } + } + require.Greater(t, minHeight, int64(0)) + + var expectedHash string + for _, n := range nodes { + rpc := fmt.Sprintf("tcp://localhost:%d", n.RPCPort) + _, hash := latestHeightAndAppHashAtHeight(t, rpc, minHeight) + if expectedHash == "" { + expectedHash = hash + continue + } + require.Equal(t, expectedHash, hash, "app hash mismatch at height %d", minHeight) + } + + if i > 0 { + require.GreaterOrEqual(t, minHeight, lastMinHeight) + } + lastMinHeight = minHeight + sut.AwaitNextBlock(t) + } + require.Greater(t, lastMinHeight, int64(1), "chain did not progress") +} + +func queryEvidenceMetadataBase64ByAction(t *testing.T, cli LumeradCli, actionID string) string { + t.Helper() + out := cli.CustomQuery("q", "audit", "evidence-by-action", actionID) + meta := gjson.Get(out, "evidence.0.metadata") + if !meta.Exists() { + meta = gjson.Get(out, "evidences.0.metadata") + } + require.True(t, meta.Exists(), "missing metadata in response: %s", out) + return meta.String() +} + +func bootFreshChain(t *testing.T) { + t.Helper() + sut.ResetChain(t) + sut.StartChain(t) + t.Cleanup(func() { sut.StopChain() }) +} + +func TestAuditEvidenceDeterminism_A_ChainProgressSingleAppHash(t *testing.T) { + bootFreshChain(t) + cli := NewLumeradCLI(t, sut, true) + n0 := getNodeIdentity(t, cli, "node0") + n1 := getNodeIdentity(t, cli, "node1") + + metadata := `{"reporter_component":2,"target_supernode_accounts":["lumera1mfldjaqc7ec5rlh4k58yttv3cd978gzl070zk6"],"details":{"action_id":"123637","error":"download failed: insufficient symbols","iteration":"1","operation":"download","supernode_account":"lumera1mfldjaqc7ec5rlh4k58yttv3cd978gzl070zk6","supernode_endpoint":"18.190.53.108:4444","task_id":"9700ec8a"}}` + submitCascadeClientFailureEvidence(t, *cli, n0.nodeName, n1.accAddr, "sys-a-1", metadata) + assertChainProgressAndSingleAppHash(t, 8) +} + +func TestAuditEvidenceDeterminism_B_JSONPermutationStableMetadata(t *testing.T) { + bootFreshChain(t) + cli := NewLumeradCLI(t, sut, true) + n0 := getNodeIdentity(t, cli, "node0") + n1 := getNodeIdentity(t, cli, "node1") + + meta1 := `{"reporter_component":2,"target_supernode_accounts":["lumera1mfldjaqc7ec5rlh4k58yttv3cd978gzl070zk6"],"details":{"action_id":"123637","error":"download failed: insufficient symbols","iteration":"1","operation":"download","supernode_account":"lumera1mfldjaqc7ec5rlh4k58yttv3cd978gzl070zk6","supernode_endpoint":"18.190.53.108:4444","task_id":"9700ec8a"}}` + meta2 := `{"reporter_component":2,"target_supernode_accounts":["lumera1mfldjaqc7ec5rlh4k58yttv3cd978gzl070zk6"],"details":{"task_id":"9700ec8a","supernode_endpoint":"18.190.53.108:4444","supernode_account":"lumera1mfldjaqc7ec5rlh4k58yttv3cd978gzl070zk6","operation":"download","iteration":"1","error":"download failed: insufficient symbols","action_id":"123637"}}` + + submitCascadeClientFailureEvidence(t, *cli, n0.nodeName, n1.accAddr, "sys-b-1", meta1) + submitCascadeClientFailureEvidence(t, *cli, n0.nodeName, n1.accAddr, "sys-b-2", meta2) + sut.AwaitNextBlock(t) + + m1 := queryEvidenceMetadataBase64ByAction(t, *cli, "sys-b-1") + m2 := queryEvidenceMetadataBase64ByAction(t, *cli, "sys-b-2") + require.Equal(t, m1, m2) + assertChainProgressAndSingleAppHash(t, 6) +} + +func TestAuditEvidenceDeterminism_C_ReplayAfterHeightTransition(t *testing.T) { + bootFreshChain(t) + cli := NewLumeradCLI(t, sut, true) + n0 := getNodeIdentity(t, cli, "node0") + n1 := getNodeIdentity(t, cli, "node1") + metadata := `{"reporter_component":2,"target_supernode_accounts":["lumera1mfldjaqc7ec5rlh4k58yttv3cd978gzl070zk6"],"details":{"action_id":"123637","error":"download failed: insufficient symbols","iteration":"1","operation":"download","supernode_account":"lumera1mfldjaqc7ec5rlh4k58yttv3cd978gzl070zk6","supernode_endpoint":"18.190.53.108:4444","task_id":"9700ec8a"}}` + + submitCascadeClientFailureEvidence(t, *cli, n0.nodeName, n1.accAddr, "sys-c-1", metadata) + assertChainProgressAndSingleAppHash(t, 4) + + // High-level replay across further block transitions. + targetHeight := sut.AwaitNextBlock(t) + 8 + sut.AwaitBlockHeight(t, targetHeight) + + submitCascadeClientFailureEvidence(t, *cli, n0.nodeName, n1.accAddr, "sys-c-2", metadata) + assertChainProgressAndSingleAppHash(t, 8) +} + +func TestAuditEvidenceDeterminism_D_RestartKeepsDeterministicState(t *testing.T) { + bootFreshChain(t) + cli := NewLumeradCLI(t, sut, true) + n0 := getNodeIdentity(t, cli, "node0") + n1 := getNodeIdentity(t, cli, "node1") + metadata := `{"reporter_component":2,"target_supernode_accounts":["lumera1mfldjaqc7ec5rlh4k58yttv3cd978gzl070zk6"],"details":{"action_id":"123637","error":"download failed: insufficient symbols","iteration":"1","operation":"download","supernode_account":"lumera1mfldjaqc7ec5rlh4k58yttv3cd978gzl070zk6","supernode_endpoint":"18.190.53.108:4444","task_id":"9700ec8a"}}` + + submitCascadeClientFailureEvidence(t, *cli, n0.nodeName, n1.accAddr, "sys-d-1", metadata) + h := sut.AwaitNextBlock(t) + nodes := sut.AllNodes(t) + require.NotEmpty(t, nodes) + _, beforeHash := latestHeightAndAppHashAtHeight(t, fmt.Sprintf("tcp://localhost:%d", nodes[0].RPCPort), h) + + // restart full validator set without reset and verify deterministic state remains consistent. + sut.StopChain() + sut.StartChain(t) + sut.AwaitNodeUp(t, "tcp://localhost:26657") + sut.AwaitBlockHeight(t, h+3) + + for _, n := range sut.AllNodes(t) { + _, got := latestHeightAndAppHashAtHeight(t, fmt.Sprintf("tcp://localhost:%d", n.RPCPort), h) + require.Equal(t, beforeHash, got) + } + assertChainProgressAndSingleAppHash(t, 6) +} + +func TestAuditEvidenceDeterminism_E_ReservedEvidenceTypeRejectedChainContinues(t *testing.T) { + bootFreshChain(t) + cli := NewLumeradCLI(t, sut, true) + n0 := getNodeIdentity(t, cli, "node0") + n1 := getNodeIdentity(t, cli, "node1") + + // ACTION_EXPIRED is reserved for action module; direct msg submission must fail. + resp := cli.CustomCommand( + "tx", "audit", "submit-evidence", + n1.accAddr, + "action-expired", + "sys-e-1", + `{"top_10_validator_addresses":[]}`, + "--from", n0.nodeName, + ) + RequireTxFailure(t, resp, "reserved for the action module") + assertChainProgressAndSingleAppHash(t, 6) +}