From 3d013a10756eefbd1aa64091c6d9fddf84e0898c Mon Sep 17 00:00:00 2001 From: Marcus Pasell <3690498+rickyrombo@users.noreply.github.com> Date: Thu, 8 Oct 2026 22:35:34 -0700 Subject: [PATCH] fix(core): accept SLA rollups with no reports isValidRollup compared the locally built reports with the proposed ones using reflect.DeepEqual. createRollup builds an empty non-nil slice, but the proposed rollup has been through proto unmarshalling, which decodes an empty repeated field as nil, and DeepEqual treats nil and empty as different. With no registered validators every rollup therefore failed validation on every node, its proposer included, and the chain could not advance past the first rollup height. This never happened on audius-mainnet-alpha-beta because it always had registered validators. A chain bootstrapped from genesis-writer output starts with an empty core_validators table and is due a rollup at its first live block, so the bootstrap node rejects its own proposal forever. Move the comparison into rollupsMatch and treat empty-on-both-sides as a match. Non-empty reports keep the original DeepEqual comparison, so existing chains validate exactly as before. Co-Authored-By: Claude Opus 5.5 --- pkg/core/server/auditor.go | 31 ++++++++++---- pkg/core/server/auditor_test.go | 73 +++++++++++++++++++++++++++++++++ 2 files changed, 95 insertions(+), 9 deletions(-) create mode 100644 pkg/core/server/auditor_test.go diff --git a/pkg/core/server/auditor.go b/pkg/core/server/auditor.go index 744b7271..dbd051b4 100644 --- a/pkg/core/server/auditor.go +++ b/pkg/core/server/auditor.go @@ -103,16 +103,29 @@ func (s *Server) isValidRollup(ctx context.Context, timestamp time.Time, height return false, err } - if myRollup.Timestamp.GetSeconds() != rollup.Timestamp.GetSeconds() || myRollup.Timestamp.GetNanos() != rollup.Timestamp.GetNanos() { - return false, nil - } else if myRollup.BlockStart != rollup.BlockStart { - return false, nil - } else if myRollup.BlockEnd != rollup.BlockEnd { - return false, nil - } else if !reflect.DeepEqual(myRollup.Reports, rollup.Reports) { - return false, nil + return rollupsMatch(myRollup, rollup), nil +} + +// rollupsMatch reports whether a proposed rollup equals the one this node +// built locally. The proposed rollup has been through proto unmarshalling, +// which decodes an empty repeated field as nil, while createRollup builds an +// empty non-nil slice; reflect.DeepEqual treats those as different, so a +// rollup with no reports (no registered validators, e.g. a freshly +// bootstrapped chain) would be rejected by every node, its proposer included. +// Non-empty reports keep the original DeepEqual comparison unchanged. +func rollupsMatch(mine, proposed *v1.SlaRollup) bool { + if mine.Timestamp.GetSeconds() != proposed.Timestamp.GetSeconds() || mine.Timestamp.GetNanos() != proposed.Timestamp.GetNanos() { + return false + } else if mine.BlockStart != proposed.BlockStart { + return false + } else if mine.BlockEnd != proposed.BlockEnd { + return false + } else if len(mine.Reports) == 0 && len(proposed.Reports) == 0 { + return true + } else if !reflect.DeepEqual(mine.Reports, proposed.Reports) { + return false } - return true, nil + return true } func (s *Server) shouldProposeNewRollup(ctx context.Context, height int64) bool { diff --git a/pkg/core/server/auditor_test.go b/pkg/core/server/auditor_test.go new file mode 100644 index 00000000..cd1cc7d4 --- /dev/null +++ b/pkg/core/server/auditor_test.go @@ -0,0 +1,73 @@ +package server + +import ( + "testing" + "time" + + v1 "github.com/OpenAudio/go-openaudio/pkg/api/core/v1" + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/types/known/timestamppb" +) + +// roundTripRollup sends a rollup through the same encoding a proposal takes +// between PrepareProposal and ProcessProposal. +func roundTripRollup(t *testing.T, rollup *v1.SlaRollup) *v1.SlaRollup { + t.Helper() + b, err := proto.Marshal(&v1.SignedTransaction{ + Transaction: &v1.SignedTransaction_SlaRollup{SlaRollup: rollup}, + }) + require.NoError(t, err) + var decoded v1.SignedTransaction + require.NoError(t, proto.Unmarshal(b, &decoded)) + return decoded.GetSlaRollup() +} + +func TestRollupsMatch(t *testing.T) { + ts := timestamppb.New(time.Date(2026, 8, 26, 3, 43, 54, 613043000, time.UTC)) + newRollup := func(reports ...*v1.SlaNodeReport) *v1.SlaRollup { + // mirrors createRollup, which always allocates a non-nil slice + r := make([]*v1.SlaNodeReport, 0, len(reports)) + return &v1.SlaRollup{Timestamp: ts, BlockStart: 0, BlockEnd: 5839, Reports: append(r, reports...)} + } + report := func(addr string, n int32) *v1.SlaNodeReport { + return &v1.SlaNodeReport{Address: addr, NumBlocksProposed: n} + } + + t.Run("no reports survives the proposal round trip", func(t *testing.T) { + proposed := roundTripRollup(t, newRollup()) + require.Nil(t, proposed.Reports, "proto decodes an empty repeated field as nil") + require.True(t, rollupsMatch(newRollup(), proposed)) + }) + + t.Run("matching reports survive the proposal round trip", func(t *testing.T) { + mine := newRollup(report("A", 3), report("B", 0)) + proposed := roundTripRollup(t, newRollup(report("A", 3), report("B", 0))) + require.True(t, rollupsMatch(mine, proposed)) + }) + + t.Run("different reports are rejected", func(t *testing.T) { + mine := newRollup(report("A", 3)) + proposed := roundTripRollup(t, newRollup(report("A", 4))) + require.False(t, rollupsMatch(mine, proposed)) + }) + + t.Run("reports on only one side are rejected", func(t *testing.T) { + require.False(t, rollupsMatch(newRollup(report("A", 0)), roundTripRollup(t, newRollup()))) + require.False(t, rollupsMatch(newRollup(), roundTripRollup(t, newRollup(report("A", 0))))) + }) + + t.Run("different block range or timestamp is rejected", func(t *testing.T) { + proposed := roundTripRollup(t, newRollup()) + proposed.BlockEnd = 5838 + require.False(t, rollupsMatch(newRollup(), proposed)) + + proposed = roundTripRollup(t, newRollup()) + proposed.BlockStart = 1 + require.False(t, rollupsMatch(newRollup(), proposed)) + + proposed = roundTripRollup(t, newRollup()) + proposed.Timestamp = timestamppb.New(ts.AsTime().Add(time.Nanosecond)) + require.False(t, rollupsMatch(newRollup(), proposed)) + }) +}