diff --git a/op-supervisor/supervisor/backend/depset/depset_test.go b/op-supervisor/supervisor/backend/depset/depset_test.go index 31b86cbd0b04b..de1eda30b6e9c 100644 --- a/op-supervisor/supervisor/backend/depset/depset_test.go +++ b/op-supervisor/supervisor/backend/depset/depset_test.go @@ -1,19 +1,68 @@ package depset import ( + "bytes" "context" "encoding/json" "os" "path" "testing" + "github.com/BurntSushi/toml" "github.com/ethereum-optimism/optimism/op-node/params" "github.com/ethereum-optimism/optimism/op-service/eth" "github.com/stretchr/testify/require" ) func TestDependencySet(t *testing.T) { - d := path.Join(t.TempDir(), "tmp_dep_set.json") + t.Run("JSON serialization", func(t *testing.T) { + testDependencySetSerialization(t, "json", + func(depSet *StaticConfigDependencySet) ([]byte, error) { return json.Marshal(depSet) }, + func(data []byte, depSet *StaticConfigDependencySet) error { return json.Unmarshal(data, depSet) }, + ) + }) + + t.Run("TOML serialization", func(t *testing.T) { + testDependencySetSerialization(t, "toml", + func(depSet *StaticConfigDependencySet) ([]byte, error) { + var buf bytes.Buffer + encoder := toml.NewEncoder(&buf) + if err := encoder.Encode(depSet); err != nil { + return nil, err + } + return buf.Bytes(), nil + }, + func(data []byte, depSet *StaticConfigDependencySet) error { + _, err := toml.Decode(string(data), depSet) + return err + }, + ) + }) + + t.Run("invalid TOML", func(t *testing.T) { + bad := []byte(`dependencies = { bad = 1 }`) + var ds StaticConfigDependencySet + _, err := toml.Decode(string(bad), &ds) + require.Error(t, err) + }) + + t.Run("duplicate index", func(t *testing.T) { + _, err := NewStaticConfigDependencySet(map[eth.ChainID]*StaticConfigDependency{ + eth.ChainIDFromUInt64(900): {ChainIndex: 1}, + eth.ChainIDFromUInt64(901): {ChainIndex: 1}, // duplicate + }) + require.ErrorIs(t, err, errDuplicateChainIndex) + }) + +} + +func testDependencySetSerialization( + t *testing.T, + fileExt string, + marshal func(*StaticConfigDependencySet) ([]byte, error), + unmarshal func([]byte, *StaticConfigDependencySet) error, +) { + d := path.Join(t.TempDir(), "tmp_dep_set."+fileExt) depSet, err := NewStaticConfigDependencySet( map[eth.ChainID]*StaticConfigDependency{ @@ -29,88 +78,123 @@ func TestDependencySet(t *testing.T) { }, }) require.NoError(t, err) - data, err := json.Marshal(depSet) - require.NoError(t, err) - require.NoError(t, os.WriteFile(d, data, 0644)) + t.Run("DefaultExpiryWindow", func(t *testing.T) { + data, err := marshal(depSet) + require.NoError(t, err) + + require.NoError(t, os.WriteFile(d, data, 0644)) + + // For JSON, use the loader. For TOML, unmarshal directly + var result DependencySet + if fileExt == "json" { + loader := &JsonDependencySetLoader{Path: d} + result, err = loader.LoadDependencySet(context.Background()) + require.NoError(t, err) + } else { + fileData, err := os.ReadFile(d) + require.NoError(t, err) + + var newDepSet StaticConfigDependencySet + err = unmarshal(fileData, &newDepSet) + require.NoError(t, err) + result = &newDepSet + } + + chainIDs := result.Chains() + require.ElementsMatch(t, []eth.ChainID{ + eth.ChainIDFromUInt64(900), + eth.ChainIDFromUInt64(901), + }, chainIDs) + + require.Equal(t, uint64(params.MessageExpiryTimeSecondsInterop), result.MessageExpiryWindow()) + testChainCapabilities(t, result) + }) + + t.Run("CustomExpiryWindow", func(t *testing.T) { + depSet.overrideMessageExpiryWindow = 15 + + data, err := marshal(depSet) + require.NoError(t, err) + require.NoError(t, os.WriteFile(d, data, 0644)) + + var result DependencySet + if fileExt == "json" { + loader := &JsonDependencySetLoader{Path: d} + result, err = loader.LoadDependencySet(context.Background()) + require.NoError(t, err) + } else { + fileData, err := os.ReadFile(d) + require.NoError(t, err) + + var newDepSet StaticConfigDependencySet + err = unmarshal(fileData, &newDepSet) + require.NoError(t, err) + result = &newDepSet + } + + require.Equal(t, uint64(15), result.MessageExpiryWindow()) + testChainCapabilities(t, result) + }) + + t.Run("chain index round trip", func(t *testing.T) { + id900 := eth.ChainIDFromUInt64(900) + idx, _ := depSet.ChainIndexFromID(id900) + idBack, _ := depSet.ChainIDFromIndex(idx) + require.Equal(t, id900, idBack) + + _, err := depSet.ChainIndexFromID(eth.ChainIDFromUInt64(999)) + require.ErrorContains(t, err, "unknown chain") + }) + + t.Run("HasChain", func(t *testing.T) { + require.True(t, depSet.HasChain(eth.ChainIDFromUInt64(900))) + require.False(t, depSet.HasChain(eth.ChainIDFromUInt64(902))) + }) - loader := &JsonDependencySetLoader{Path: d} - result, err := loader.LoadDependencySet(context.Background()) - require.NoError(t, err) - - chainIDs := result.Chains() - require.Equal(t, []eth.ChainID{ - eth.ChainIDFromUInt64(900), - eth.ChainIDFromUInt64(901), - }, chainIDs) +} +func testChainCapabilities(t *testing.T, result DependencySet) { + // Test chain 900 v, err := result.CanExecuteAt(eth.ChainIDFromUInt64(900), 42) require.NoError(t, err) require.True(t, v) + v, err = result.CanExecuteAt(eth.ChainIDFromUInt64(900), 41) require.NoError(t, err) require.False(t, v) + v, err = result.CanInitiateAt(eth.ChainIDFromUInt64(900), 100) require.NoError(t, err) require.True(t, v) + v, err = result.CanInitiateAt(eth.ChainIDFromUInt64(900), 99) require.NoError(t, err) require.False(t, v) + // Test chain 901 v, err = result.CanExecuteAt(eth.ChainIDFromUInt64(901), 30) require.NoError(t, err) require.True(t, v) + v, err = result.CanExecuteAt(eth.ChainIDFromUInt64(901), 29) require.NoError(t, err) require.False(t, v) + v, err = result.CanInitiateAt(eth.ChainIDFromUInt64(901), 20) require.NoError(t, err) require.True(t, v) + v, err = result.CanInitiateAt(eth.ChainIDFromUInt64(901), 19) require.NoError(t, err) require.False(t, v) + // Test non-existent chain v, err = result.CanExecuteAt(eth.ChainIDFromUInt64(902), 100000) require.NoError(t, err) require.False(t, v, "902 not a dependency") + v, err = result.CanInitiateAt(eth.ChainIDFromUInt64(902), 100000) require.NoError(t, err) require.False(t, v, "902 not a dependency") - - require.Equal(t, uint64(params.MessageExpiryTimeSecondsInterop), result.MessageExpiryWindow()) -} - -func TestDependencySetWithMessageExpiryOverride(t *testing.T) { - d := path.Join(t.TempDir(), "tmp_dep_set.json") - - depSet, err := NewStaticConfigDependencySet( - map[eth.ChainID]*StaticConfigDependency{ - eth.ChainIDFromUInt64(900): { - ChainIndex: 900, - ActivationTime: 42, - HistoryMinTime: 100, - }, - eth.ChainIDFromUInt64(901): { - ChainIndex: 901, - ActivationTime: 30, - HistoryMinTime: 20, - }, - }) - require.NoError(t, err) - depSet.overrideMessageExpiryWindow = 10 - data, err := json.Marshal(depSet) - require.NoError(t, err) - - require.NoError(t, os.WriteFile(d, data, 0644)) - - loader := &JsonDependencySetLoader{Path: d} - result, err := loader.LoadDependencySet(context.Background()) - require.NoError(t, err) - - chainIDs := result.Chains() - require.Equal(t, []eth.ChainID{ - eth.ChainIDFromUInt64(900), - eth.ChainIDFromUInt64(901), - }, chainIDs) - require.Equal(t, uint64(10), result.MessageExpiryWindow()) } diff --git a/op-supervisor/supervisor/backend/depset/static.go b/op-supervisor/supervisor/backend/depset/static.go index 055360ed9ab5a..302a714126a93 100644 --- a/op-supervisor/supervisor/backend/depset/static.go +++ b/op-supervisor/supervisor/backend/depset/static.go @@ -1,6 +1,7 @@ package depset import ( + "bytes" "context" "encoding/json" "errors" @@ -8,6 +9,7 @@ import ( "slices" "sort" + "github.com/BurntSushi/toml" "github.com/ethereum-optimism/optimism/op-node/params" "github.com/ethereum-optimism/optimism/op-service/eth" "github.com/ethereum-optimism/optimism/op-supervisor/supervisor/types" @@ -17,16 +19,16 @@ var errDuplicateChainIndex = errors.New("duplicate chain index") type StaticConfigDependency struct { // ChainIndex is the unique short identifier of this chain. - ChainIndex types.ChainIndex `json:"chainIndex"` + ChainIndex types.ChainIndex `json:"chainIndex" toml:"chain_index"` // ActivationTime is when the chain becomes part of the dependency set. // This is the minimum timestamp of the inclusion of an executing message. - ActivationTime uint64 `json:"activationTime"` + ActivationTime uint64 `json:"activationTime" toml:"activation_time"` // HistoryMinTime is what the lower bound of data is to store. // This is the minimum timestamp of an initiating message to be accessible to others. // This is set to 0 when all data since genesis is executable. - HistoryMinTime uint64 `json:"historyMinTime"` + HistoryMinTime uint64 `json:"historyMinTime" toml:"history_min_time"` } // StaticConfigDependencySet statically declares a DependencySet. @@ -60,32 +62,93 @@ func NewStaticConfigDependencySetWithMessageExpiryOverride(dependencies map[eth. return out, nil } -// jsonStaticConfigDependencySet is a util for JSON encoding/decoding, -// to encode/decode just the attributes that matter, +// minStaticConfigDependencySet is a util for JSON/TOML encoding/decoding, +// for just the minimal set of attributes that matter, // while wrapping the decoding functionality with additional hydration step. -type jsonStaticConfigDependencySet struct { - Dependencies map[eth.ChainID]*StaticConfigDependency `json:"dependencies"` - OverrideMessageExpiryWindow uint64 `json:"overrideMessageExpiryWindow,omitempty"` +type minStaticConfigDependencySet struct { + Dependencies map[string]*StaticConfigDependency `json:"dependencies" toml:"dependencies"` + OverrideMessageExpiryWindow uint64 `json:"overrideMessageExpiryWindow,omitempty" toml:"override_message_expiry_window,omitempty"` } func (ds *StaticConfigDependencySet) MarshalJSON() ([]byte, error) { - out := &jsonStaticConfigDependencySet{ - Dependencies: ds.dependencies, + // Convert map keys to strings + stringMap := make(map[string]*StaticConfigDependency) + for id, dep := range ds.dependencies { + stringMap[id.String()] = dep + } + + out := &minStaticConfigDependencySet{ + Dependencies: stringMap, OverrideMessageExpiryWindow: ds.overrideMessageExpiryWindow, } return json.Marshal(out) } func (ds *StaticConfigDependencySet) UnmarshalJSON(data []byte) error { - var v jsonStaticConfigDependencySet + var v minStaticConfigDependencySet if err := json.Unmarshal(data, &v); err != nil { return err } - ds.dependencies = v.Dependencies + + // Convert string keys back to ChainID + ds.dependencies = make(map[eth.ChainID]*StaticConfigDependency) + for idStr, dep := range v.Dependencies { + id, err := eth.ParseDecimalChainID(idStr) + if err != nil { + return fmt.Errorf("invalid chain ID in JSON: %w", err) + } + ds.dependencies[id] = dep + } + ds.overrideMessageExpiryWindow = v.OverrideMessageExpiryWindow return ds.hydrate() } +func (ds *StaticConfigDependencySet) MarshalTOML() ([]byte, error) { + // Convert map keys (ChainID) to strings so TOML can encode the map. + stringMap := make(map[string]*StaticConfigDependency, len(ds.dependencies)) + for id, dep := range ds.dependencies { + stringMap[id.String()] = dep + } + + payload := &minStaticConfigDependencySet{ + Dependencies: stringMap, + OverrideMessageExpiryWindow: ds.overrideMessageExpiryWindow, + } + + var buf bytes.Buffer + if err := toml.NewEncoder(&buf).Encode(payload); err != nil { + return nil, err + } + return buf.Bytes(), nil +} + +func (ds *StaticConfigDependencySet) UnmarshalTOML(v interface{}) error { + var buf bytes.Buffer + if err := toml.NewEncoder(&buf).Encode(v); err != nil { + return fmt.Errorf("re-encoding TOML: %w", err) + } + + // Decode into the minimal helper struct that has the right tags. + var tmp minStaticConfigDependencySet + if _, err := toml.Decode(buf.String(), &tmp); err != nil { + return fmt.Errorf("decoding into helper struct: %w", err) + } + + // Convert string keys back to ChainID and copy the data. + ds.dependencies = make(map[eth.ChainID]*StaticConfigDependency, len(tmp.Dependencies)) + for idStr, dep := range tmp.Dependencies { + id, err := eth.ParseDecimalChainID(idStr) + if err != nil { + return fmt.Errorf("invalid chain ID %q: %w", idStr, err) + } + ds.dependencies[id] = dep + } + + ds.overrideMessageExpiryWindow = tmp.OverrideMessageExpiryWindow + return ds.hydrate() +} + // hydrate sets all the cached values, based on the dependencies attribute func (ds *StaticConfigDependencySet) hydrate() error { ds.indexToID = make(map[types.ChainIndex]eth.ChainID)