diff --git a/components/protocol/component.go b/components/protocol/component.go index 46ba40dc9..9fd04c4bb 100644 --- a/components/protocol/component.go +++ b/components/protocol/component.go @@ -147,6 +147,7 @@ func provide(c *dig.Container) error { ), ), protocol.WithSnapshotPath(ParamsProtocol.Snapshot.Path), + protocol.WithCommitmentCheck(ParamsProtocol.CommitmentCheck), protocol.WithMaxAllowedWallClockDrift(ParamsProtocol.Filter.MaxAllowedClockDrift), protocol.WithPreSolidFilterProvider( presolidblockfilter.NewProvider(), diff --git a/components/protocol/params.go b/components/protocol/params.go index 162d21d99..6aac38683 100644 --- a/components/protocol/params.go +++ b/components/protocol/params.go @@ -16,6 +16,8 @@ type ParametersProtocol struct { Depth int `default:"5" usage:"defines how many slot diffs are stored in the snapshot, starting from the full ledgerstate"` } + CommitmentCheck bool `default:"true" usage:"specifies whether commitment and ledger checks should be enabled"` + Filter struct { // MaxAllowedClockDrift defines the maximum drift our wall clock can have to future blocks being received from the network. MaxAllowedClockDrift time.Duration `default:"5s" usage:"the maximum drift our wall clock can have to future blocks being received from the network"` diff --git a/pkg/protocol/engine/engine.go b/pkg/protocol/engine/engine.go index fd6fd45da..db54673a9 100644 --- a/pkg/protocol/engine/engine.go +++ b/pkg/protocol/engine/engine.go @@ -87,6 +87,7 @@ type Engine struct { optsSnapshotPath string optsEntryPointsDepth int optsSnapshotDepth int + optsCheckCommitment bool optsBlockRequester []options.Option[eventticker.EventTicker[iotago.SlotIndex, iotago.BlockID]] *module.ReactiveModule @@ -129,8 +130,9 @@ func New( LatestCommitment: reactive.NewVariable[*model.Commitment](), Workers: workers, - optsSnapshotPath: "snapshot.bin", - optsSnapshotDepth: 5, + optsSnapshotPath: "snapshot.bin", + optsSnapshotDepth: 5, + optsCheckCommitment: true, }, opts, func(e *Engine) { e.ReactiveModule = e.initReactiveModule(logger) @@ -230,6 +232,13 @@ func New( e.Reset() } + // Check consistency of commitment and ledger state in the storage + if e.optsCheckCommitment { + if err := e.Storage.CheckCorrectnessCommitmentLedgerState(); err != nil { + panic(ierrors.Wrap(err, "commitment or ledger state are incorrect")) + } + } + e.Initialized.Trigger() e.LogTrace("initialized", "settings", e.Storage.Settings().String()) @@ -382,6 +391,8 @@ func (e *Engine) ImportContents(reader io.ReadSeeker) (err error) { return ierrors.Wrap(err, "failed to import attestation state") } else if err = e.UpgradeOrchestrator.Import(reader); err != nil { return ierrors.Wrap(err, "failed to import upgrade orchestrator") + } else if err = e.Storage.ImportRoots(reader, e.Storage.Settings().LatestCommitment()); err != nil { + return ierrors.Wrap(err, "failed to import roots") } return @@ -408,6 +419,8 @@ func (e *Engine) Export(writer io.WriteSeeker, targetSlot iotago.SlotIndex) (err return ierrors.Wrap(err, "failed to export attestation state") } else if err = e.UpgradeOrchestrator.Export(writer, targetSlot); err != nil { return ierrors.Wrap(err, "failed to export upgrade orchestrator") + } else if err = e.Storage.ExportRoots(writer, targetCommitment.Commitment()); err != nil { + return ierrors.Wrap(err, "failed to export roots") } return @@ -622,6 +635,12 @@ func WithSnapshotPath(snapshotPath string) options.Option[Engine] { } } +func WithCommitmentCheck(checkCommitment bool) options.Option[Engine] { + return func(e *Engine) { + e.optsCheckCommitment = checkCommitment + } +} + func WithEntryPointsDepth(entryPointsDepth int) options.Option[Engine] { return func(engine *Engine) { engine.optsEntryPointsDepth = entryPointsDepth diff --git a/pkg/protocol/engines.go b/pkg/protocol/engines.go index c6edf6740..5a2b9ca4b 100644 --- a/pkg/protocol/engines.go +++ b/pkg/protocol/engines.go @@ -146,7 +146,7 @@ func (e *Engines) ForkAtSlot(slot iotago.SlotIndex) (*engine.Engine, error) { } // loadMainEngine loads the main engine from disk or creates a new one if no engine exists. -func (e *Engines) loadMainEngine(snapshotPath string) (*engine.Engine, error) { +func (e *Engines) loadMainEngine(snapshotPath string, commitmentCheck bool) (*engine.Engine, error) { info := &engineInfo{} if err := ioutils.ReadJSONFromFile(e.infoFilePath(), info); err != nil && !ierrors.Is(err, os.ErrNotExist) { return nil, ierrors.Errorf("unable to read engine info file: %w", err) @@ -157,12 +157,12 @@ func (e *Engines) loadMainEngine(snapshotPath string) (*engine.Engine, error) { // load previous engine as main engine if it exists. if len(info.Name) > 0 { if exists, isDirectory, err := ioutils.PathExists(e.directory.Path(info.Name)); err == nil && exists && isDirectory { - return e.loadEngineInstanceFromSnapshot(info.Name, snapshotPath) + return e.loadEngineInstanceFromSnapshot(info.Name, snapshotPath, commitmentCheck) } } // load new engine if no previous engine exists. - return e.loadEngineInstanceFromSnapshot(lo.PanicOnErr(uuid.NewUUID()).String(), snapshotPath) + return e.loadEngineInstanceFromSnapshot(lo.PanicOnErr(uuid.NewUUID()).String(), snapshotPath, commitmentCheck) }) // cleanup candidates @@ -199,12 +199,12 @@ func (e *Engines) infoFilePath() string { } // loadEngineInstanceFromSnapshot loads an engine instance from a snapshot. -func (e *Engines) loadEngineInstanceFromSnapshot(engineAlias string, snapshotPath string) *engine.Engine { +func (e *Engines) loadEngineInstanceFromSnapshot(engineAlias string, snapshotPath string, commitmentCheck bool) *engine.Engine { errorHandler := func(err error) { e.protocol.LogError("engine error", "err", err, "name", engineAlias[0:8]) } - return e.loadEngineInstanceWithStorage(engineAlias, storage.Create(e.Logger, e.directory.Path(engineAlias), DatabaseVersion, errorHandler, e.protocol.Options.StorageOptions...), engine.WithSnapshotPath(snapshotPath)) + return e.loadEngineInstanceWithStorage(engineAlias, storage.Create(e.Logger, e.directory.Path(engineAlias), DatabaseVersion, errorHandler, e.protocol.Options.StorageOptions...), engine.WithSnapshotPath(snapshotPath), engine.WithCommitmentCheck(commitmentCheck)) } // loadEngineInstanceWithStorage loads an engine instance with the given storage. @@ -268,7 +268,7 @@ func (e *Engines) injectEngineInstances() (shutdown func()) { if newEngine, err := func() (*engine.Engine, error) { if e.Main.Get() == nil { - return e.loadMainEngine(e.protocol.Options.SnapshotPath) + return e.loadMainEngine(e.protocol.Options.SnapshotPath, e.protocol.Options.CommitmentCheck) } return e.ForkAtSlot(chain.ForkingPoint.Get().Slot() - 1) diff --git a/pkg/protocol/options.go b/pkg/protocol/options.go index fd1dcf702..2efe763b9 100644 --- a/pkg/protocol/options.go +++ b/pkg/protocol/options.go @@ -54,6 +54,9 @@ type Options struct { // SnapshotPath is the path to the snapshot file that should be used to initialize the protocol. SnapshotPath string + // CommitmentCheck is an opt flag that allows engines check correctness of commitment and ledger state upon startup. + CommitmentCheck bool + // MaxAllowedWallClockDrift specifies how far in the future are blocks allowed to be ahead of our own wall clock (defaults to 0 seconds). MaxAllowedWallClockDrift time.Duration @@ -162,6 +165,13 @@ func WithSnapshotPath(snapshot string) options.Option[Protocol] { } } +// WithCommitmentCheck is an option for the Protocol that allows to check the commitment and ledger state upon startup. +func WithCommitmentCheck(commitmentCheck bool) options.Option[Protocol] { + return func(p *Protocol) { + p.Options.CommitmentCheck = commitmentCheck + } +} + // WithMaxAllowedWallClockDrift specifies how far in the future are blocks allowed to be ahead of our own wall clock (defaults to 0 seconds). func WithMaxAllowedWallClockDrift(d time.Duration) options.Option[Protocol] { return func(p *Protocol) { diff --git a/pkg/storage/storage.go b/pkg/storage/storage.go index f0bc78b1d..e879e72d2 100644 --- a/pkg/storage/storage.go +++ b/pkg/storage/storage.go @@ -187,3 +187,62 @@ func (s *Storage) Flush() { s.permanent.Flush() s.prunable.Flush() } + +// Checks the correctness of the latest commitment. +// Additionally, for non-genesis slots it checks whether the ledger state corresponds to the state root. +func (s *Storage) CheckCorrectnessCommitmentLedgerState() error { + + // Get the latest commitment + latestCommitment := s.Settings().LatestCommitment() + + latestCommitmentID := latestCommitment.ID() + latestCommittedSlotIndex := latestCommitment.Slot() + + // Do the ledger state check only for non-genesis slots. + // TODO: once the genesis slot provides the roots, change this + if latestCommittedSlotIndex > s.Settings().APIProvider().CommittedAPI().ProtocolParameters().GenesisSlot() { + // Get the state root in the permanent storage (that corresponds to the last commitment) + latestStateRoot := s.Ledger().StateTreeRoot() + + // Load root storage from prunable storage + rootsStorage, err := s.Roots(latestCommittedSlotIndex) + if err != nil { + return ierrors.Wrap(err, "failed to load roots storage") + } + + // Load roots from prunable storage that correspond to the last committed slot index and commitment + roots, exists, err := rootsStorage.Load(latestCommitmentID) + if err != nil { + return ierrors.Wrap(err, "failed to load roots from prunable storage") + } else if !exists { + return ierrors.Wrap(err, "roots not found") + } + + // Check the correctness of stored state root and the state root computed from the stored ledger state + if roots.StateRoot != latestStateRoot { + return ierrors.Wrap(err, "computed state root from storage does not correspond to stored state root") + } + + if roots.ID() != latestCommitment.RootsID() { + return ierrors.Wrap(err, "root from prunable storage does not correspond to root from commitment") + } + + } + + // Verify the correctness of the slot commitment + computeCurrentCommitment := iotago.NewCommitment( + latestCommitment.Commitment().ProtocolVersion, + latestCommittedSlotIndex, + latestCommitment.PreviousCommitmentID(), + latestCommitment.RootsID(), + latestCommitment.CumulativeWeight(), + latestCommitment.ReferenceManaCost(), + ) + computeCurrentCommitmentID := computeCurrentCommitment.MustID() + + if computeCurrentCommitmentID != latestCommitmentID { + return ierrors.New("Computed commitment ID is different from the stored one") + } + + return nil +} diff --git a/pkg/storage/storage_prunable.go b/pkg/storage/storage_prunable.go index 9d320022b..6e0f54a6b 100644 --- a/pkg/storage/storage_prunable.go +++ b/pkg/storage/storage_prunable.go @@ -1,8 +1,11 @@ package storage import ( + "io" + "github.com/iotaledger/hive.go/ierrors" "github.com/iotaledger/hive.go/kvstore" + "github.com/iotaledger/hive.go/serializer/v2/stream" "github.com/iotaledger/iota-core/pkg/core/account" "github.com/iotaledger/iota-core/pkg/model" "github.com/iotaledger/iota-core/pkg/storage/prunable/epochstore" @@ -109,6 +112,141 @@ func (s *Storage) Roots(slot iotago.SlotIndex) (*slotstore.Store[iotago.Commitme return s.prunable.Roots(slot) } +func (s *Storage) ExportRoots(writer io.WriteSeeker, targetCommitment *iotago.Commitment) error { + + slotIndex := targetCommitment.Slot + + if slotIndex <= s.Settings().APIProvider().CommittedAPI().ProtocolParameters().GenesisSlot() { + return nil + } + + commitmentID, err := targetCommitment.ID() + + if err != nil { + return ierrors.Wrap(err, "can not retrieve commitment id") + } + // Load root storage from prunable storage + rootsStorage, errRoots := s.Roots(slotIndex) + + if errRoots != nil { + return ierrors.Wrap(err, "failed to load roots storage") + } + + roots, exists, errLoad := rootsStorage.Load(commitmentID) + if errLoad != nil { + return ierrors.Wrap(err, "failed to load roots from prunable storage") + } else if !exists { + return ierrors.Wrap(err, "roots not found") + } + + if errWrite := stream.Write(writer, roots.AccountRoot); errWrite != nil { + return ierrors.Wrapf(err, "failed to write account root bytes") + } + if errWrite := stream.Write(writer, roots.AttestationsRoot); errWrite != nil { + return ierrors.Wrapf(err, "failed to write attestation root bytes") + } + if errWrite := stream.Write(writer, roots.CommitteeRoot); errWrite != nil { + return ierrors.Wrapf(err, "failed to write committee root bytes") + } + if errWrite := stream.Write(writer, roots.ProtocolParametersHash); errWrite != nil { + return ierrors.Wrapf(err, "failed to write protocol parameters hash root bytes") + } + if errWrite := stream.Write(writer, roots.RewardsRoot); errWrite != nil { + return ierrors.Wrapf(err, "failed to write rewards root bytes") + } + if errWrite := stream.Write(writer, roots.StateMutationRoot); errWrite != nil { + return ierrors.Wrapf(err, "failed to write state mutation root bytes") + } + if errWrite := stream.Write(writer, roots.StateRoot); errWrite != nil { + return ierrors.Wrapf(err, "failed to write state root bytes") + } + if errWrite := stream.Write(writer, roots.TangleRoot); errWrite != nil { + return ierrors.Wrapf(err, "failed to write tangle root bytes") + } + + return err +} + +func (s *Storage) ImportRoots(reader io.ReadSeeker, targetCommitment *model.Commitment) error { + + slotIndex := targetCommitment.Commitment().Slot + + if slotIndex <= s.Settings().APIProvider().CommittedAPI().ProtocolParameters().GenesisSlot() { + return nil + } + + accountRoot, err := stream.Read[iotago.Identifier](reader) + if err != nil { + return ierrors.Wrap(err, "can not retrieve account root") + } + + attestationRoot, err := stream.Read[iotago.Identifier](reader) + if err != nil { + return ierrors.Wrap(err, "can not retrieve attestation root") + } + + committeeRoot, err := stream.Read[iotago.Identifier](reader) + if err != nil { + return ierrors.Wrap(err, "can not retrieve committee root") + } + + protocolParametersHash, err := stream.Read[iotago.Identifier](reader) + if err != nil { + return ierrors.Wrap(err, "can not retrieve protocol parameters hash") + } + + rewardsRoot, err := stream.Read[iotago.Identifier](reader) + if err != nil { + return ierrors.Wrap(err, "can not retrieve rewards root") + } + + stateMutationRoot, err := stream.Read[iotago.Identifier](reader) + if err != nil { + return ierrors.Wrap(err, "can not retrieve state mutation root") + } + + stateRoot, err := stream.Read[iotago.Identifier](reader) + if err != nil { + return ierrors.Wrap(err, "can not retrieve state root") + } + + tangleRoot, err := stream.Read[iotago.Identifier](reader) + if err != nil { + return ierrors.Wrap(err, "can not retrieve tangle root") + } + + commitmentID, err := targetCommitment.Commitment().ID() + + if err != nil { + return ierrors.Wrap(err, "can not retrieve commitment id") + } + // Load root storage from prunable storage + rootsStorage, errRoots := s.Roots(slotIndex) + + if errRoots != nil { + return ierrors.Wrap(err, "failed to load roots storage") + } + + roots := iotago.NewRoots( + tangleRoot, + stateMutationRoot, + attestationRoot, + stateRoot, + accountRoot, + committeeRoot, + rewardsRoot, + protocolParametersHash, + ) + + errStore := rootsStorage.Store(commitmentID, roots) + if errStore != nil { + return ierrors.Wrap(err, "unable to store roots in storage") + } + + return nil + +} + func (s *Storage) BlockMetadata(slot iotago.SlotIndex) (*slotstore.BlockMetadataStore, error) { if err := s.permanent.Settings().AdvanceLatestStoredSlot(slot); err != nil { return nil, ierrors.Wrap(err, "failed to advance latest stored slot when accessing block metadata") diff --git a/pkg/tests/engine_check_ledger_state_commitment_test.go b/pkg/tests/engine_check_ledger_state_commitment_test.go new file mode 100644 index 000000000..1af28064a --- /dev/null +++ b/pkg/tests/engine_check_ledger_state_commitment_test.go @@ -0,0 +1,94 @@ +package tests + +import ( + "fmt" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/iotaledger/iota-core/pkg/protocol" + "github.com/iotaledger/iota-core/pkg/testsuite" + "github.com/iotaledger/iota-core/pkg/testsuite/mock" + iotago "github.com/iotaledger/iota.go/v4" +) + +// Test function that checks the ledger state commitment from a non-genesis snapshot. +// It involves setting up a test suite, issuing blocks at specific slots, creating and using snapshots, and starting nodes based on those snapshots. +// The test verifies node states and commitments during the process. +func TestCheckLedgerStateCommitmentFromNonGenesisSnapshot(t *testing.T) { + ts := testsuite.NewTestSuite(t, + testsuite.WithProtocolParametersOptions( + iotago.WithTimeProviderOptions( + 0, + testsuite.GenesisTimeWithOffsetBySlots(100, testsuite.DefaultSlotDurationInSeconds), + testsuite.DefaultSlotDurationInSeconds, + 3, + ), + iotago.WithLivenessOptions( + 10, + 10, + 2, + 4, + 5, + ), + ), + ) + defer ts.Shutdown() + node0 := ts.AddValidatorNode("node0") + ts.AddDefaultWallet(node0) + ts.AddValidatorNode("node1") + ts.Run(true, nil) + // Issue up to slot 10, committing slot 8. + { + ts.IssueBlocksAtSlots("", []iotago.SlotIndex{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}, 3, "Genesis", ts.Nodes(), true, false) + ts.AssertNodeState(ts.Nodes(), + testsuite.WithLatestFinalizedSlot(7), + testsuite.WithLatestCommitmentSlotIndex(8), + testsuite.WithEqualStoredCommitmentAtIndex(8), + ) + } + // Create snapshot from node0 and start node 2 from it. + // During startup, it will correctly validate the ledger state for the last committed slot against the latest commitment. + // Stop node 0. + var node2 *mock.Node + { + snapshotPath := ts.Directory.Path(fmt.Sprintf("%d_snapshot", time.Now().Unix())) + require.NoError(t, ts.Node("node0").Protocol.Engines.Main.Get().WriteSnapshot(snapshotPath)) + node2 = ts.AddNode("node2") + node2.Validator = node0.Validator + node2.Initialize(true, + protocol.WithSnapshotPath(snapshotPath), + protocol.WithBaseDirectory(ts.Directory.PathWithCreate(node2.Name)), + ) + ts.Wait() + } + ts.RemoveNode(node0.Name) + node0.Shutdown() + + // Modify StateRoot and check whether the commitment check gets an error + { + curCommitment := ts.Node("node2").Protocol.Engines.Main.Get().Storage.Settings().LatestCommitment() + rootsStorage, _ := ts.Node("node2").Protocol.Engines.Main.Get().Storage.Roots(curCommitment.Slot()) + roots, _, _ := rootsStorage.Load(curCommitment.ID()) + + // create a modified root and store it + newRoots := iotago.NewRoots( + roots.TangleRoot, + roots.StateMutationRoot, + roots.AttestationsRoot, + iotago.Identifier{0}, + roots.AccountRoot, + roots.CommitteeRoot, + roots.RewardsRoot, + roots.ProtocolParametersHash, + ) + require.NoError(t, rootsStorage.Store(curCommitment.ID(), newRoots)) + + // check that with a modified root, it does not pass the required check + + require.Error(t, ts.Node("node2").Protocol.Engines.Main.Get().Storage.CheckCorrectnessCommitmentLedgerState()) + + ts.Wait() + } +} diff --git a/pkg/tests/protocol_startup_test.go b/pkg/tests/protocol_startup_test.go index 346452614..a501ff0aa 100644 --- a/pkg/tests/protocol_startup_test.go +++ b/pkg/tests/protocol_startup_test.go @@ -338,6 +338,7 @@ func Test_StartNodeFromSnapshotAndDisk(t *testing.T) { nodeD := ts.AddNode("nodeD") nodeD.Initialize(true, append(nodeOptions, protocol.WithSnapshotPath(snapshotPath), + protocol.WithCommitmentCheck(true), protocol.WithBaseDirectory(ts.Directory.PathWithCreate(nodeD.Name)), protocol.WithEngineOptions( engine.WithBlockRequesterOptions( @@ -487,6 +488,7 @@ func Test_StartNodeFromSnapshotAndDisk(t *testing.T) { nodeD := ts.AddNode("nodeE") nodeD.Initialize(true, append(nodeOptions, protocol.WithSnapshotPath(snapshotPath), + protocol.WithCommitmentCheck(true), protocol.WithBaseDirectory(ts.Directory.PathWithCreate(nodeD.Name)), protocol.WithEngineOptions( engine.WithBlockRequesterOptions( diff --git a/pkg/testsuite/snapshotcreator/snapshotcreator.go b/pkg/testsuite/snapshotcreator/snapshotcreator.go index 9dd271baa..650ef404d 100644 --- a/pkg/testsuite/snapshotcreator/snapshotcreator.go +++ b/pkg/testsuite/snapshotcreator/snapshotcreator.go @@ -117,7 +117,8 @@ func CreateSnapshot(opts ...options.Option[Options]) error { blockretainer.NewProvider(), txretainer.NewProvider(), signalingupgradeorchestrator.NewProvider(), - engine.WithSnapshotPath(""), // magic to disable loading snapshot + engine.WithSnapshotPath(""), // magic to disable loading snapshot + engine.WithCommitmentCheck(false), // to not check the commitment when creating a first snapshot ) defer engineInstance.Shutdown.Trigger() diff --git a/pkg/testsuite/testsuite.go b/pkg/testsuite/testsuite.go index 1b7c6b735..8003c3dbf 100644 --- a/pkg/testsuite/testsuite.go +++ b/pkg/testsuite/testsuite.go @@ -64,8 +64,10 @@ type TestSuite struct { wallets *orderedmap.OrderedMap[string, *mock.Wallet] running bool - snapshotPath string - blocks *shrinkingmap.ShrinkingMap[string, *blocks.Block] + snapshotPath string + commitmentCheck bool + + blocks *shrinkingmap.ShrinkingMap[string, *blocks.Block] API iotago.API ProtocolParameterOptions []options.Option[iotago.V3ProtocolParameters] @@ -108,6 +110,8 @@ func NewTestSuite(testingT *testing.T, opts ...options.Option[TestSuite]) *TestS genesisBlock := blocks.NewRootBlock(t.API.ProtocolParameters().GenesisBlockID(), iotago.NewEmptyCommitment(t.API).MustID(), time.Unix(t.API.ProtocolParameters().GenesisUnixTimestamp(), 0)) t.RegisterBlock("Genesis", genesisBlock) + t.commitmentCheck = true + t.snapshotPath = t.Directory.Path("genesis_snapshot.bin") defaultSnapshotOptions := []options.Option[snapshotcreator.Options]{ snapshotcreator.WithDatabaseVersion(protocol.DatabaseVersion), @@ -514,6 +518,7 @@ func (t *TestSuite) Run(failOnBlockFiltered bool, nodesOptions ...map[string][]o t.nodes.ForEach(func(_ string, node *mock.Node) bool { baseOpts := []options.Option[protocol.Protocol]{ protocol.WithSnapshotPath(t.snapshotPath), + protocol.WithCommitmentCheck(t.commitmentCheck), protocol.WithBaseDirectory(t.Directory.PathWithCreate(node.Name)), protocol.WithSybilProtectionProvider( sybilprotectionv1.NewProvider(),