From 76dca272e004e11f778399daafa55026fa8a5ec3 Mon Sep 17 00:00:00 2001 From: Yacov Manevich Date: Thu, 16 Apr 2026 16:14:53 +0200 Subject: [PATCH 01/25] Simplex reconfiguration framework - Part I (Helpers) This commit adds some helpers for the Simplex reconfiguration framework. This commit entails: - README.md changes to align with latest implementation - misc.go contains helpers which will be removed once we moved the code to avalanchego. - builds_decision.go contains a wrapper to the mempool that also listens to P-chain changes, and returns either when the mempool needs to build a block or re-configuration is in order. - encoding.go contains the types that encode Simplex metadata and blocks. Signed-off-by: Yacov Manevich --- go.mod | 9 +- go.sum | 20 +- msm/README.md | 31 +- msm/build_decision.go | 158 +++ msm/build_decision_test.go | 203 ++++ msm/encoding.canoto.go | 1883 ++++++++++++++++++++++++++++++++++++ msm/encoding.go | 307 ++++++ msm/encoding_test.go | 514 ++++++++++ msm/misc.go | 122 +++ msm/misc_test.go | 123 +++ 10 files changed, 3349 insertions(+), 21 deletions(-) create mode 100644 msm/build_decision.go create mode 100644 msm/build_decision_test.go create mode 100644 msm/encoding.canoto.go create mode 100644 msm/encoding.go create mode 100644 msm/encoding_test.go create mode 100644 msm/misc.go create mode 100644 msm/misc_test.go diff --git a/go.mod b/go.mod index a1145162..da791ef9 100644 --- a/go.mod +++ b/go.mod @@ -1,15 +1,20 @@ module github.com/ava-labs/simplex -go 1.23.0 +go 1.25.0 require ( - github.com/stretchr/testify v1.9.0 + github.com/StephenButtolph/canoto v0.19.0 + github.com/stretchr/testify v1.10.0 go.uber.org/zap v1.26.0 ) require ( github.com/davecgh/go-spew v1.1.1 // indirect + github.com/fatih/structtag v1.2.0 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/spf13/cobra v1.8.1 // indirect + github.com/spf13/pflag v1.0.5 // indirect go.uber.org/multierr v1.10.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 56d9d664..bf0e562e 100644 --- a/go.sum +++ b/go.sum @@ -1,9 +1,25 @@ +github.com/StephenButtolph/canoto v0.19.0 h1:a28jijQ4gyaSW920h69nX1E/o9WilHlx6L9PVuLkB/Y= +github.com/StephenButtolph/canoto v0.19.0/go.mod h1:01RsiQp1gnV1eJ6LwygP6buPCLUoAz7jKadQSB0FI0o= +github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/fatih/structtag v1.2.0 h1:/OdNE99OxoI/PqaW/SuSK9uxxT3f/tcSZgon/ssNSx4= +github.com/fatih/structtag v1.2.0/go.mod h1:mBJUNpUnHmRKrKlQQlmCrh5PuhftFbNv8Ys4/aAZl94= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= -github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/sanity-io/litter v1.5.1 h1:dwnrSypP6q56o3lFxTU+t2fwQ9A+U5qrXVO4Qg9KwVU= +github.com/sanity-io/litter v1.5.1/go.mod h1:5Z71SvaYy5kcGtyglXOC9rrUi3c1E8CamFWjQsazTh0= +github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM= +github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y= +github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/thepudds/fzgen v0.4.3 h1:srUP/34BulQaEwPP/uHZkdjUcUjIzL7Jkf4CBVryiP8= +github.com/thepudds/fzgen v0.4.3/go.mod h1:BhhwtRhzgvLWAjjcHDJ9pEiLD2Z9hrVIFjBCHJ//zJ4= go.uber.org/goleak v1.2.0 h1:xqgm/S+aQvhWFTtR0XK3Jvg7z8kGV8P4X14IzwN3Eqk= go.uber.org/goleak v1.2.0/go.mod h1:XJYK+MuIchqpmGmUSAzotztawfKvYLUIgg7guXrwVUo= go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ= diff --git a/msm/README.md b/msm/README.md index 898891f0..25a81d65 100644 --- a/msm/README.md +++ b/msm/README.md @@ -86,7 +86,7 @@ message SimplexEpochInfo { - The validator set of the epoch numbered `epoch_number` is derived from `p_chain_reference_height`. - The `prev_sealing_block_hash` is the hash of the sealing block of the previous epoch, and it is used to efficiently validate the sealing block of the previous epoch. - If there is no previous epoch (i.e., the current epoch is the first ever epoch), then it is nil. + If there is no previous epoch (i.e., the current epoch is the first ever epoch), then is equal to the hash of the first Simplex block. - The `next_p_chain_reference_height` is the P-chain height of the next epoch, otherwise it is set to `0`. @@ -378,35 +378,30 @@ ____________________ | ```proto -message SimplexBlock { +message OuterBlock { bytes inner_block = 1; // The inner block built by the VM, opaque to Simplex. - OuterBlock outer_block = 2; // The outer block that wraps the inner block without the inner block. - bytes protocol_metadata = 3 // The Simplex protocol metadata, set by the Simplex consensus protocol. + StateMachineMetadata metadata = 2; // The the metadata of the block. } ``` -where `OuterBlock` is a protobuf message that contains the ICM epoch information, the Simplex epoch information, and auxiliary information: +where `StateMachineMetadata` is a protobuf message that contains the ICM epoch information, the Simplex epoch information, and auxiliary information: ```proto -message OuterBlock { +message StateMachineMetadata { ICMEpochInfo icm_epoch_info = 1; // The ICM epoch information. SimplexEpochInfo simplex_epoch_info = 2; // The Simplex epoch information. - AuxiliaryInfo auxiliary_info = 3; // The auxiliary information. + bytes protocol_metadata = 3; // The Simplex protocol metadata, set by the Simplex consensus protocol. + bytes blacklist = 4; // The blacklist of the Simplex protocol. + AuxiliaryInfo auxiliary_info = 5; // The auxiliary information. + uint64 p_chain_height = 6; // The P-chain height sampled when building the block. + uint64 timestamp = 7; // The timestamp of the block, set by the block builder. } ``` The digest of the simplex block is computed as follows: -Let $h_i$ be the hash of the inner block. -The digest of the Simplex block is the hash of the following encoding: - -```proto -message HashPreImage { - bytes h_i = 1; // The inner block hash - OuterBlock outer_block = 2; - bytes protocol_metadata = 3; -} -``` +Let $h_i$ be the hash of the inner block and $h_m$ be the hash of the metadata. +The digest of the Simplex block is the hash of the following encoding: `h_i || h_m` where `||` denotes concatenation. This way of hashing the block allows any holder of a finalization certificate for the block to authenticate the block while hiding the content of the inner block. @@ -426,6 +421,7 @@ The Simplex epoch information is a canoto encoded message with the following sch message NodeBLSMapping { bytes node_id = 1; // The nodeID bytes bls_key = 2; // The BLS key of the node + uint64 weight = 3; // The weight of the node in the validator set, used for quorum calculations. } message BlockValidationDescriptor { @@ -453,5 +449,6 @@ message SimplexEpochInfo { uint64 prev_vm_block_seq = 5; // The sequence of the previous VM block BlockValidationDescriptor block_validation_descriptor = 6; // Describes how to validate the blocks of the next epoch NextEpochApprovals next_epoch_approvals = 7; // The epoch change approvals of the next epoch by at least n-f nodes. + uint64 sealing_block_seq = 8; // The sequence number of the sealing block of the current epoch. } ``` diff --git a/msm/build_decision.go b/msm/build_decision.go new file mode 100644 index 00000000..7bf80c38 --- /dev/null +++ b/msm/build_decision.go @@ -0,0 +1,158 @@ +// Copyright (C) 2019-2025, Ava Labs, Inc. All rights reserved. +// See the file LICENSE for licensing terms. + +package metadata + +import ( + "context" + "sync" + "time" + + "go.uber.org/zap" +) + +// blockBuildingDecision represents the decision of whether we should build a block at the current time, +// and if so, whether we should also transition to a new epoch along the way. +type blockBuildingDecision int8 + +const ( + blockBuildingDecisionUndefined blockBuildingDecision = iota + blockBuildingDecisionBuildBlock // We should build a block, and we don't need to transition to a new epoch. + blockBuildingDecisionTransitionEpoch // We should transition to a new epoch immediately, but we don't need to build a block. + blockBuildingDecisionBuildBlockAndTransitionEpoch // We should build a block and transition to a new epoch along the way. + blockBuildingDecisionContextCanceled +) + +func (bbd blockBuildingDecision) String() string { + switch bbd { + case blockBuildingDecisionUndefined: + return "undefined" + case blockBuildingDecisionBuildBlock: + return "build block" + case blockBuildingDecisionTransitionEpoch: + return "transition epoch" + case blockBuildingDecisionBuildBlockAndTransitionEpoch: + return "build block and transition epoch" + case blockBuildingDecisionContextCanceled: + return "context canceled" + default: + return "unknown" + } +} + +// PChainProgressListener listens for changes in the P-chain height. +type PChainProgressListener interface { + // WaitForProgress should block until either the context is cancelled, or the P-chain height has increased from the provided pChainHeight. + WaitForProgress(ctx context.Context, pChainHeight uint64) error +} + +type blockBuildingDecider struct { + logger Logger + maxBlockBuildingWaitTime time.Duration + pChainlistener PChainProgressListener + waitForPendingBlock func(ctx context.Context) + shouldTransitionEpoch func(pChainHeight uint64) (bool, error) + getPChainHeight func() uint64 +} + +// shouldBuildBlock determines whether we should build a block at the current time, +// based on the current P-chain height and whether we should transition to a new epoch. +// It returns a blockBuildingDecision, the current P-chain height sampled at the time of deciding, +// and an error if the decision cannot be made. +// The P-chain height is returned because sampling the P-chain height afterwards might be inconsistent with the decision that was made. +func (bbd *blockBuildingDecider) shouldBuildBlock( + ctx context.Context, +) (blockBuildingDecision, uint64, error) { + for { + pChainHeight := bbd.getPChainHeight() + + shouldTransitionEpoch, err := bbd.shouldTransitionEpoch(pChainHeight) + if err != nil { + return blockBuildingDecisionUndefined, 0, err + } + + if shouldTransitionEpoch { + // If we should transition to a new epoch, maybe we can also build a block along the way. + return bbd.maybeBuildBlockWithEpochTransition(ctx), pChainHeight, nil + } + + // Else, we don't need to transition to a new epoch, but maybe we should build a block. + // We wait for either the P-chain height to change, or for a block to be ready to be built. + bbd.waitForPChainChangeOrPendingBlock(ctx, pChainHeight) + + // If the context was cancelled in the meantime, abandon evaluation. + if bbd.wasContextCanceled(ctx) { + return blockBuildingDecisionContextCanceled, 0, nil + } + + // If we've reached here, either the P-chain height has changed, or a block is ready to be built. + + // If the P-chain height changed, re-evaluate again whether we should transition to a new epoch, + // or continue waiting to build a block. + if bbd.getPChainHeight() != pChainHeight { + continue + } + + // Else, we have reached here because a block is ready to be built, and the P-chain height has not changed, + // which means we should build a block. + + return blockBuildingDecisionBuildBlock, pChainHeight, nil + } +} + +// waitForPChainChangeOrPendingBlock waits until either the given P-chain height changes from the provided pChainHeight, +// or a block is ready to be built. +func (bbd *blockBuildingDecider) waitForPChainChangeOrPendingBlock(ctx context.Context, pChainHeight uint64) { + pChainAwareContext, cancel := context.WithCancel(ctx) + + var wg sync.WaitGroup + wg.Add(1) + + defer wg.Wait() + defer cancel() + + go func() { + defer wg.Done() + err := bbd.pChainlistener.WaitForProgress(pChainAwareContext, pChainHeight) + if err != nil && pChainAwareContext.Err() == nil { + bbd.logger.Warn("error while waiting for P-chain progress", zap.Error(err)) + } + cancel() + }() + + bbd.waitForPendingBlock(pChainAwareContext) +} + +// maybeBuildBlockWithEpochTransition decides if we should build a block while transitioning to a new epoch. +// It waits up to a limited amount of time (bbd.maxBlockBuildingWaitTime) for a block to be ready to be built, +// and if no block is ready by then, it returns the decision to transition epoch without building a block. +// Otherwise, it returns the decision to build a block and transition epoch along the way. +func (bbd *blockBuildingDecider) maybeBuildBlockWithEpochTransition(ctx context.Context) blockBuildingDecision { + impatientContext, cancel := context.WithTimeout(ctx, bbd.maxBlockBuildingWaitTime) + defer cancel() + + // We should transition to a new epoch, so we wait some time just in case we can also build a block along the way. + // waitForPendingBlock will return in case a block is ready to be built, or when the context times out. + bbd.waitForPendingBlock(impatientContext) + + if impatientContext.Err() != nil { + // Check if we have returned because the parent context was cancelled + if bbd.wasContextCanceled(ctx) { + return blockBuildingDecisionContextCanceled + } + // We have returned from waitForPendingBlock because the context has timed out, which means we don't need to build a block. + return blockBuildingDecisionTransitionEpoch + } + + // Block is ready to be built + return blockBuildingDecisionBuildBlockAndTransitionEpoch +} + +func (bbd *blockBuildingDecider) wasContextCanceled(ctx context.Context) bool { + select { + case <-ctx.Done(): + return true + default: + return false + } +} diff --git a/msm/build_decision_test.go b/msm/build_decision_test.go new file mode 100644 index 00000000..d6dc8e8e --- /dev/null +++ b/msm/build_decision_test.go @@ -0,0 +1,203 @@ +// Copyright (C) 2019-2025, Ava Labs, Inc. All rights reserved. +// See the file LICENSE for licensing terms. + +package metadata + +import ( + "context" + "sync/atomic" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +type fakePChainListener struct { + onListen func(ctx context.Context, pChainHeight uint64) +} + +func (f *fakePChainListener) WaitForProgress(ctx context.Context, pChainHeight uint64) error { + f.onListen(ctx, pChainHeight) + return nil // We don't do anything with the error but log it, so it's fine to always return nil here. +} + +func TestShouldBuildBlock_VMSignalsBlock(t *testing.T) { + // No epoch transition needed, VM signals a block is ready. + bbd := &blockBuildingDecider{ + maxBlockBuildingWaitTime: time.Second, + pChainlistener: &fakePChainListener{ + onListen: func(ctx context.Context, _ uint64) { + <-ctx.Done() + }, + }, + waitForPendingBlock: func(ctx context.Context) {}, + shouldTransitionEpoch: func(uint64) (bool, error) { return false, nil }, + getPChainHeight: func() uint64 { return 100 }, + } + + result, pChainHeight, err := bbd.shouldBuildBlock(t.Context()) + require.NoError(t, err) + require.Equal(t, blockBuildingDecisionBuildBlock, result) + require.Equal(t, uint64(100), pChainHeight) +} + +func TestShouldBuildBlock_ContextCanceled(t *testing.T) { + // No epoch transition, parent context is cancelled while waiting. + ctx, cancel := context.WithCancel(t.Context()) + + bbd := &blockBuildingDecider{ + maxBlockBuildingWaitTime: time.Second, + pChainlistener: &fakePChainListener{ + onListen: func(ctx context.Context, _ uint64) { + <-ctx.Done() + }, + }, + waitForPendingBlock: func(ctx context.Context) { + cancel() + <-ctx.Done() + }, + shouldTransitionEpoch: func(uint64) (bool, error) { return false, nil }, + getPChainHeight: func() uint64 { return 100 }, + } + + result, _, err := bbd.shouldBuildBlock(ctx) + require.NoError(t, err) + require.Equal(t, blockBuildingDecisionContextCanceled, result) +} + +func TestShouldBuildBlock_PChainHeightChangeTriggersEpochTransition(t *testing.T) { + // First iteration: no epoch transition, P-chain listener fires (height changes). + // Second iteration: shouldTransitionEpoch returns true, VM doesn't signal a block before timeout. + var pChainHeight atomic.Uint64 + pChainHeight.Store(100) + + bbd := &blockBuildingDecider{ + maxBlockBuildingWaitTime: 10 * time.Millisecond, + pChainlistener: &fakePChainListener{ + onListen: func(ctx context.Context, height uint64) { + // On the first call, simulate a P-chain height change. + if height == 100 { + pChainHeight.Store(200) + return + } + <-ctx.Done() + }, + }, + waitForPendingBlock: func(ctx context.Context) { + <-ctx.Done() + }, + shouldTransitionEpoch: func(height uint64) (bool, error) { + return height == 200, nil + }, + getPChainHeight: func() uint64 { return pChainHeight.Load() }, + } + + result, resultPChainHeight, err := bbd.shouldBuildBlock(t.Context()) + require.NoError(t, err) + require.Equal(t, blockBuildingDecisionTransitionEpoch, result) + require.Equal(t, uint64(200), resultPChainHeight) +} + +func TestShouldBuildBlock_PChainHeightChangeButNoEpochTransition(t *testing.T) { + // P-chain height changes on first iteration but shouldTransitionEpoch stays false. + // On second iteration, VM signals a block. + var pChainHeight atomic.Uint64 + pChainHeight.Store(100) + + var iteration atomic.Int32 + + bbd := &blockBuildingDecider{ + maxBlockBuildingWaitTime: time.Second, + pChainlistener: &fakePChainListener{ + onListen: func(ctx context.Context, height uint64) { + if height == 100 { + pChainHeight.Store(200) + return + } + <-ctx.Done() + }, + }, + waitForPendingBlock: func(ctx context.Context) { + // First iteration: block on context (P-chain listener will cancel it). + // Second iteration: return immediately (VM has a block). + if iteration.Add(1) == 1 { + <-ctx.Done() + return + } + }, + shouldTransitionEpoch: func(uint64) (bool, error) { return false, nil }, + getPChainHeight: func() uint64 { return pChainHeight.Load() }, + } + + result, resultPChainHeight, err := bbd.shouldBuildBlock(t.Context()) + require.NoError(t, err) + require.Equal(t, blockBuildingDecisionBuildBlock, result) + require.Equal(t, uint64(200), resultPChainHeight) +} + +func TestShouldBuildBlock_EpochTransitionWithVMBlock(t *testing.T) { + // Epoch transition needed, but VM signals a block before the timeout. + bbd := &blockBuildingDecider{ + maxBlockBuildingWaitTime: time.Second, + pChainlistener: &fakePChainListener{ + onListen: func(ctx context.Context, _ uint64) { + <-ctx.Done() + }, + }, + waitForPendingBlock: func(ctx context.Context) {}, + shouldTransitionEpoch: func(uint64) (bool, error) { return true, nil }, + getPChainHeight: func() uint64 { return 100 }, + } + + result, pChainHeight, err := bbd.shouldBuildBlock(t.Context()) + require.NoError(t, err) + require.Equal(t, blockBuildingDecisionBuildBlockAndTransitionEpoch, result) + require.Equal(t, uint64(100), pChainHeight) +} + +func TestShouldBuildBlock_EpochTransitionWithoutVMBlock(t *testing.T) { + // Epoch transition needed, VM doesn't signal a block before timeout. + bbd := &blockBuildingDecider{ + maxBlockBuildingWaitTime: 10 * time.Millisecond, + pChainlistener: &fakePChainListener{ + onListen: func(ctx context.Context, _ uint64) { + <-ctx.Done() + }, + }, + waitForPendingBlock: func(ctx context.Context) { + <-ctx.Done() + }, + shouldTransitionEpoch: func(uint64) (bool, error) { return true, nil }, + getPChainHeight: func() uint64 { return 100 }, + } + + result, pChainHeight, err := bbd.shouldBuildBlock(t.Context()) + require.NoError(t, err) + require.Equal(t, blockBuildingDecisionTransitionEpoch, result) + require.Equal(t, uint64(100), pChainHeight) +} + +func TestShouldBuildBlock_EpochTransitionContextCanceled(t *testing.T) { + // Epoch transition needed, but parent context is cancelled during the wait. + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + + bbd := &blockBuildingDecider{ + maxBlockBuildingWaitTime: time.Second, + pChainlistener: &fakePChainListener{ + onListen: func(ctx context.Context, _ uint64) { + <-ctx.Done() + }, + }, + waitForPendingBlock: func(ctx context.Context) { + cancel() + <-ctx.Done() + }, + shouldTransitionEpoch: func(uint64) (bool, error) { return true, nil }, + getPChainHeight: func() uint64 { return 100 }, + } + + result, _, err := bbd.shouldBuildBlock(ctx) + require.NoError(t, err) + require.Equal(t, blockBuildingDecisionContextCanceled, result) +} diff --git a/msm/encoding.canoto.go b/msm/encoding.canoto.go new file mode 100644 index 00000000..51d3752e --- /dev/null +++ b/msm/encoding.canoto.go @@ -0,0 +1,1883 @@ +// Code generated by canoto. DO NOT EDIT. +// versions: +// canoto v0.19.0 +// source: encoding.go + +package metadata + +import ( + "io" + "reflect" + "sync/atomic" + + "github.com/StephenButtolph/canoto" +) + +// Ensure that the generated code is compatible with the library version. +const ( + _ uint = canoto.VersionCompatibility - 1 + _ uint = 1 - canoto.VersionCompatibility +) + +// Ensure that unused imports do not error +var _ = io.ErrUnexpectedEOF + +const ( + canotoNumber_OuterBlock__InnerBlock = 1 + canotoNumber_OuterBlock__Metadata = 2 + + canotoTag_OuterBlock__InnerBlock = "\x0a" // canoto.Tag(canotoNumber_OuterBlock__InnerBlock, canoto.Len) + canotoTag_OuterBlock__Metadata = "\x12" // canoto.Tag(canotoNumber_OuterBlock__Metadata, canoto.Len) +) + +type canotoData_OuterBlock struct { + size uint64 +} + +// CanotoSpec returns the specification of this canoto message. +func (*OuterBlock) CanotoSpec(types ...reflect.Type) *canoto.Spec { + types = append(types, reflect.TypeFor[OuterBlock]()) + var zero OuterBlock + s := &canoto.Spec{ + Name: "OuterBlock", + Fields: []canoto.FieldType{ + { + FieldNumber: canotoNumber_OuterBlock__InnerBlock, + Name: "InnerBlock", + OneOf: "", + TypeBytes: true, + }, + canoto.FieldTypeFromField( + /*type inference:*/ (&zero.Metadata), + /*FieldNumber: */ canotoNumber_OuterBlock__Metadata, + /*Name: */ "Metadata", + /*FixedLength: */ 0, + /*Repeated: */ false, + /*OneOf: */ "", + /*Pointer: */ false, + /*types: */ types, + ), + }, + } + s.CalculateCanotoCache() + return s +} + +// UnmarshalCanoto unmarshals a Canoto-encoded byte slice into the struct. +// +// During parsing, the canoto cache is saved. +func (c *OuterBlock) UnmarshalCanoto(bytes []byte) error { + r := canoto.Reader{ + B: bytes, + } + return c.UnmarshalCanotoFrom(r) +} + +// UnmarshalCanotoFrom populates the struct from a [canoto.Reader]. Most users +// should just use UnmarshalCanoto. +// +// During parsing, the canoto cache is saved. +// +// This function enables configuration of reader options. +func (c *OuterBlock) UnmarshalCanotoFrom(r canoto.Reader) error { + // Zero the struct before unmarshaling. + *c = OuterBlock{} + atomic.StoreUint64(&c.canotoData.size, uint64(len(r.B))) + + var minField uint32 + for canoto.HasNext(&r) { + field, wireType, err := canoto.ReadTag(&r) + if err != nil { + return err + } + if field < minField { + return canoto.ErrInvalidFieldOrder + } + + switch field { + case canotoNumber_OuterBlock__InnerBlock: + if wireType != canoto.Len { + return canoto.ErrUnexpectedWireType + } + + if err := canoto.ReadBytes(&r, &c.InnerBlock); err != nil { + return err + } + if len(c.InnerBlock) == 0 { + return canoto.ErrZeroValue + } + case canotoNumber_OuterBlock__Metadata: + if wireType != canoto.Len { + return canoto.ErrUnexpectedWireType + } + + // Read the bytes for the field. + originalUnsafe := r.Unsafe + r.Unsafe = true + var msgBytes []byte + if err := canoto.ReadBytes(&r, &msgBytes); err != nil { + return err + } + if len(msgBytes) == 0 { + return canoto.ErrZeroValue + } + r.Unsafe = originalUnsafe + + // Unmarshal the field from the bytes. + remainingBytes := r.B + r.B = msgBytes + if err := (&c.Metadata).UnmarshalCanotoFrom(r); err != nil { + return err + } + r.B = remainingBytes + default: + return canoto.ErrUnknownField + } + + minField = field + 1 + } + return nil +} + +// ValidCanoto validates that the struct can be correctly marshaled into the +// Canoto format. +// +// Specifically, ValidCanoto ensures: +// 1. All OneOfs are specified at most once. +// 2. All strings are valid utf-8. +// 3. All custom fields are ValidCanoto. +func (c *OuterBlock) ValidCanoto() bool { + if !(&c.Metadata).ValidCanoto() { + return false + } + return true +} + +// CalculateCanotoCache populates size and OneOf caches based on the current +// values in the struct. +// +// It is not safe to copy this struct concurrently. +func (c *OuterBlock) CalculateCanotoCache() { + var size uint64 + if len(c.InnerBlock) != 0 { + size += uint64(len(canotoTag_OuterBlock__InnerBlock)) + canoto.SizeBytes(c.InnerBlock) + } + (&c.Metadata).CalculateCanotoCache() + if fieldSize := (&c.Metadata).CachedCanotoSize(); fieldSize != 0 { + size += uint64(len(canotoTag_OuterBlock__Metadata)) + canoto.SizeUint(fieldSize) + fieldSize + } + atomic.StoreUint64(&c.canotoData.size, size) +} + +// CachedCanotoSize returns the previously calculated size of the Canoto +// representation from CalculateCanotoCache. +// +// If CalculateCanotoCache has not yet been called, it will return 0. +// +// If the struct has been modified since the last call to CalculateCanotoCache, +// the returned size may be incorrect. +func (c *OuterBlock) CachedCanotoSize() uint64 { + return atomic.LoadUint64(&c.canotoData.size) +} + +// MarshalCanoto returns the Canoto representation of this struct. +// +// It is assumed that this struct is ValidCanoto. +// +// It is not safe to copy this struct concurrently. +func (c *OuterBlock) MarshalCanoto() []byte { + c.CalculateCanotoCache() + w := canoto.Writer{ + B: make([]byte, 0, c.CachedCanotoSize()), + } + w = c.MarshalCanotoInto(w) + return w.B +} + +// MarshalCanotoInto writes the struct into a [canoto.Writer] and returns the +// resulting [canoto.Writer]. Most users should just use MarshalCanoto. +// +// It is assumed that CalculateCanotoCache has been called since the last +// modification to this struct. +// +// It is assumed that this struct is ValidCanoto. +// +// It is not safe to copy this struct concurrently. +func (c *OuterBlock) MarshalCanotoInto(w canoto.Writer) canoto.Writer { + if len(c.InnerBlock) != 0 { + canoto.Append(&w, canotoTag_OuterBlock__InnerBlock) + canoto.AppendBytes(&w, c.InnerBlock) + } + if fieldSize := (&c.Metadata).CachedCanotoSize(); fieldSize != 0 { + canoto.Append(&w, canotoTag_OuterBlock__Metadata) + canoto.AppendUint(&w, fieldSize) + w = (&c.Metadata).MarshalCanotoInto(w) + } + return w +} + +const ( + canotoNumber_StateMachineMetadata__SimplexEpochInfo = 1 + canotoNumber_StateMachineMetadata__SimplexProtocolMetadata = 2 + canotoNumber_StateMachineMetadata__SimplexBlacklist = 3 + canotoNumber_StateMachineMetadata__PChainHeight = 4 + canotoNumber_StateMachineMetadata__Timestamp = 5 + + canotoTag_StateMachineMetadata__SimplexEpochInfo = "\x0a" // canoto.Tag(canotoNumber_StateMachineMetadata__SimplexEpochInfo, canoto.Len) + canotoTag_StateMachineMetadata__SimplexProtocolMetadata = "\x12" // canoto.Tag(canotoNumber_StateMachineMetadata__SimplexProtocolMetadata, canoto.Len) + canotoTag_StateMachineMetadata__SimplexBlacklist = "\x1a" // canoto.Tag(canotoNumber_StateMachineMetadata__SimplexBlacklist, canoto.Len) + canotoTag_StateMachineMetadata__PChainHeight = "\x20" // canoto.Tag(canotoNumber_StateMachineMetadata__PChainHeight, canoto.Varint) + canotoTag_StateMachineMetadata__Timestamp = "\x28" // canoto.Tag(canotoNumber_StateMachineMetadata__Timestamp, canoto.Varint) +) + +type canotoData_StateMachineMetadata struct { + size uint64 +} + +// CanotoSpec returns the specification of this canoto message. +func (*StateMachineMetadata) CanotoSpec(types ...reflect.Type) *canoto.Spec { + types = append(types, reflect.TypeFor[StateMachineMetadata]()) + var zero StateMachineMetadata + s := &canoto.Spec{ + Name: "StateMachineMetadata", + Fields: []canoto.FieldType{ + canoto.FieldTypeFromField( + /*type inference:*/ (&zero.SimplexEpochInfo), + /*FieldNumber: */ canotoNumber_StateMachineMetadata__SimplexEpochInfo, + /*Name: */ "SimplexEpochInfo", + /*FixedLength: */ 0, + /*Repeated: */ false, + /*OneOf: */ "", + /*Pointer: */ false, + /*types: */ types, + ), + { + FieldNumber: canotoNumber_StateMachineMetadata__SimplexProtocolMetadata, + Name: "SimplexProtocolMetadata", + OneOf: "", + TypeBytes: true, + }, + { + FieldNumber: canotoNumber_StateMachineMetadata__SimplexBlacklist, + Name: "SimplexBlacklist", + OneOf: "", + TypeBytes: true, + }, + { + FieldNumber: canotoNumber_StateMachineMetadata__PChainHeight, + Name: "PChainHeight", + OneOf: "", + TypeUint: canoto.SizeOf(zero.PChainHeight), + }, + { + FieldNumber: canotoNumber_StateMachineMetadata__Timestamp, + Name: "Timestamp", + OneOf: "", + TypeUint: canoto.SizeOf(zero.Timestamp), + }, + }, + } + s.CalculateCanotoCache() + return s +} + +// UnmarshalCanoto unmarshals a Canoto-encoded byte slice into the struct. +// +// During parsing, the canoto cache is saved. +func (c *StateMachineMetadata) UnmarshalCanoto(bytes []byte) error { + r := canoto.Reader{ + B: bytes, + } + return c.UnmarshalCanotoFrom(r) +} + +// UnmarshalCanotoFrom populates the struct from a [canoto.Reader]. Most users +// should just use UnmarshalCanoto. +// +// During parsing, the canoto cache is saved. +// +// This function enables configuration of reader options. +func (c *StateMachineMetadata) UnmarshalCanotoFrom(r canoto.Reader) error { + // Zero the struct before unmarshaling. + *c = StateMachineMetadata{} + atomic.StoreUint64(&c.canotoData.size, uint64(len(r.B))) + + var minField uint32 + for canoto.HasNext(&r) { + field, wireType, err := canoto.ReadTag(&r) + if err != nil { + return err + } + if field < minField { + return canoto.ErrInvalidFieldOrder + } + + switch field { + case canotoNumber_StateMachineMetadata__SimplexEpochInfo: + if wireType != canoto.Len { + return canoto.ErrUnexpectedWireType + } + + // Read the bytes for the field. + originalUnsafe := r.Unsafe + r.Unsafe = true + var msgBytes []byte + if err := canoto.ReadBytes(&r, &msgBytes); err != nil { + return err + } + if len(msgBytes) == 0 { + return canoto.ErrZeroValue + } + r.Unsafe = originalUnsafe + + // Unmarshal the field from the bytes. + remainingBytes := r.B + r.B = msgBytes + if err := (&c.SimplexEpochInfo).UnmarshalCanotoFrom(r); err != nil { + return err + } + r.B = remainingBytes + case canotoNumber_StateMachineMetadata__SimplexProtocolMetadata: + if wireType != canoto.Len { + return canoto.ErrUnexpectedWireType + } + + if err := canoto.ReadBytes(&r, &c.SimplexProtocolMetadata); err != nil { + return err + } + if len(c.SimplexProtocolMetadata) == 0 { + return canoto.ErrZeroValue + } + case canotoNumber_StateMachineMetadata__SimplexBlacklist: + if wireType != canoto.Len { + return canoto.ErrUnexpectedWireType + } + + if err := canoto.ReadBytes(&r, &c.SimplexBlacklist); err != nil { + return err + } + if len(c.SimplexBlacklist) == 0 { + return canoto.ErrZeroValue + } + case canotoNumber_StateMachineMetadata__PChainHeight: + if wireType != canoto.Varint { + return canoto.ErrUnexpectedWireType + } + + if err := canoto.ReadUint(&r, &c.PChainHeight); err != nil { + return err + } + if canoto.IsZero(c.PChainHeight) { + return canoto.ErrZeroValue + } + case canotoNumber_StateMachineMetadata__Timestamp: + if wireType != canoto.Varint { + return canoto.ErrUnexpectedWireType + } + + if err := canoto.ReadUint(&r, &c.Timestamp); err != nil { + return err + } + if canoto.IsZero(c.Timestamp) { + return canoto.ErrZeroValue + } + default: + return canoto.ErrUnknownField + } + + minField = field + 1 + } + return nil +} + +// ValidCanoto validates that the struct can be correctly marshaled into the +// Canoto format. +// +// Specifically, ValidCanoto ensures: +// 1. All OneOfs are specified at most once. +// 2. All strings are valid utf-8. +// 3. All custom fields are ValidCanoto. +func (c *StateMachineMetadata) ValidCanoto() bool { + if !(&c.SimplexEpochInfo).ValidCanoto() { + return false + } + return true +} + +// CalculateCanotoCache populates size and OneOf caches based on the current +// values in the struct. +// +// It is not safe to copy this struct concurrently. +func (c *StateMachineMetadata) CalculateCanotoCache() { + var size uint64 + (&c.SimplexEpochInfo).CalculateCanotoCache() + if fieldSize := (&c.SimplexEpochInfo).CachedCanotoSize(); fieldSize != 0 { + size += uint64(len(canotoTag_StateMachineMetadata__SimplexEpochInfo)) + canoto.SizeUint(fieldSize) + fieldSize + } + if len(c.SimplexProtocolMetadata) != 0 { + size += uint64(len(canotoTag_StateMachineMetadata__SimplexProtocolMetadata)) + canoto.SizeBytes(c.SimplexProtocolMetadata) + } + if len(c.SimplexBlacklist) != 0 { + size += uint64(len(canotoTag_StateMachineMetadata__SimplexBlacklist)) + canoto.SizeBytes(c.SimplexBlacklist) + } + if !canoto.IsZero(c.PChainHeight) { + size += uint64(len(canotoTag_StateMachineMetadata__PChainHeight)) + canoto.SizeUint(c.PChainHeight) + } + if !canoto.IsZero(c.Timestamp) { + size += uint64(len(canotoTag_StateMachineMetadata__Timestamp)) + canoto.SizeUint(c.Timestamp) + } + atomic.StoreUint64(&c.canotoData.size, size) +} + +// CachedCanotoSize returns the previously calculated size of the Canoto +// representation from CalculateCanotoCache. +// +// If CalculateCanotoCache has not yet been called, it will return 0. +// +// If the struct has been modified since the last call to CalculateCanotoCache, +// the returned size may be incorrect. +func (c *StateMachineMetadata) CachedCanotoSize() uint64 { + return atomic.LoadUint64(&c.canotoData.size) +} + +// MarshalCanoto returns the Canoto representation of this struct. +// +// It is assumed that this struct is ValidCanoto. +// +// It is not safe to copy this struct concurrently. +func (c *StateMachineMetadata) MarshalCanoto() []byte { + c.CalculateCanotoCache() + w := canoto.Writer{ + B: make([]byte, 0, c.CachedCanotoSize()), + } + w = c.MarshalCanotoInto(w) + return w.B +} + +// MarshalCanotoInto writes the struct into a [canoto.Writer] and returns the +// resulting [canoto.Writer]. Most users should just use MarshalCanoto. +// +// It is assumed that CalculateCanotoCache has been called since the last +// modification to this struct. +// +// It is assumed that this struct is ValidCanoto. +// +// It is not safe to copy this struct concurrently. +func (c *StateMachineMetadata) MarshalCanotoInto(w canoto.Writer) canoto.Writer { + if fieldSize := (&c.SimplexEpochInfo).CachedCanotoSize(); fieldSize != 0 { + canoto.Append(&w, canotoTag_StateMachineMetadata__SimplexEpochInfo) + canoto.AppendUint(&w, fieldSize) + w = (&c.SimplexEpochInfo).MarshalCanotoInto(w) + } + if len(c.SimplexProtocolMetadata) != 0 { + canoto.Append(&w, canotoTag_StateMachineMetadata__SimplexProtocolMetadata) + canoto.AppendBytes(&w, c.SimplexProtocolMetadata) + } + if len(c.SimplexBlacklist) != 0 { + canoto.Append(&w, canotoTag_StateMachineMetadata__SimplexBlacklist) + canoto.AppendBytes(&w, c.SimplexBlacklist) + } + if !canoto.IsZero(c.PChainHeight) { + canoto.Append(&w, canotoTag_StateMachineMetadata__PChainHeight) + canoto.AppendUint(&w, c.PChainHeight) + } + if !canoto.IsZero(c.Timestamp) { + canoto.Append(&w, canotoTag_StateMachineMetadata__Timestamp) + canoto.AppendUint(&w, c.Timestamp) + } + return w +} + +const ( + canotoNumber_SimplexEpochInfo__PChainReferenceHeight = 1 + canotoNumber_SimplexEpochInfo__EpochNumber = 2 + canotoNumber_SimplexEpochInfo__PrevSealingBlockHash = 3 + canotoNumber_SimplexEpochInfo__NextPChainReferenceHeight = 4 + canotoNumber_SimplexEpochInfo__PrevVMBlockSeq = 5 + canotoNumber_SimplexEpochInfo__BlockValidationDescriptor = 6 + canotoNumber_SimplexEpochInfo__NextEpochApprovals = 7 + canotoNumber_SimplexEpochInfo__SealingBlockSeq = 8 + + canotoTag_SimplexEpochInfo__PChainReferenceHeight = "\x08" // canoto.Tag(canotoNumber_SimplexEpochInfo__PChainReferenceHeight, canoto.Varint) + canotoTag_SimplexEpochInfo__EpochNumber = "\x10" // canoto.Tag(canotoNumber_SimplexEpochInfo__EpochNumber, canoto.Varint) + canotoTag_SimplexEpochInfo__PrevSealingBlockHash = "\x1a" // canoto.Tag(canotoNumber_SimplexEpochInfo__PrevSealingBlockHash, canoto.Len) + canotoTag_SimplexEpochInfo__NextPChainReferenceHeight = "\x20" // canoto.Tag(canotoNumber_SimplexEpochInfo__NextPChainReferenceHeight, canoto.Varint) + canotoTag_SimplexEpochInfo__PrevVMBlockSeq = "\x28" // canoto.Tag(canotoNumber_SimplexEpochInfo__PrevVMBlockSeq, canoto.Varint) + canotoTag_SimplexEpochInfo__BlockValidationDescriptor = "\x32" // canoto.Tag(canotoNumber_SimplexEpochInfo__BlockValidationDescriptor, canoto.Len) + canotoTag_SimplexEpochInfo__NextEpochApprovals = "\x3a" // canoto.Tag(canotoNumber_SimplexEpochInfo__NextEpochApprovals, canoto.Len) + canotoTag_SimplexEpochInfo__SealingBlockSeq = "\x40" // canoto.Tag(canotoNumber_SimplexEpochInfo__SealingBlockSeq, canoto.Varint) +) + +type canotoData_SimplexEpochInfo struct { + size uint64 +} + +// CanotoSpec returns the specification of this canoto message. +func (*SimplexEpochInfo) CanotoSpec(types ...reflect.Type) *canoto.Spec { + types = append(types, reflect.TypeFor[SimplexEpochInfo]()) + var zero SimplexEpochInfo + s := &canoto.Spec{ + Name: "SimplexEpochInfo", + Fields: []canoto.FieldType{ + { + FieldNumber: canotoNumber_SimplexEpochInfo__PChainReferenceHeight, + Name: "PChainReferenceHeight", + OneOf: "", + TypeUint: canoto.SizeOf(zero.PChainReferenceHeight), + }, + { + FieldNumber: canotoNumber_SimplexEpochInfo__EpochNumber, + Name: "EpochNumber", + OneOf: "", + TypeUint: canoto.SizeOf(zero.EpochNumber), + }, + { + FieldNumber: canotoNumber_SimplexEpochInfo__PrevSealingBlockHash, + Name: "PrevSealingBlockHash", + OneOf: "", + TypeFixedBytes: uint64(len(zero.PrevSealingBlockHash)), + }, + { + FieldNumber: canotoNumber_SimplexEpochInfo__NextPChainReferenceHeight, + Name: "NextPChainReferenceHeight", + OneOf: "", + TypeUint: canoto.SizeOf(zero.NextPChainReferenceHeight), + }, + { + FieldNumber: canotoNumber_SimplexEpochInfo__PrevVMBlockSeq, + Name: "PrevVMBlockSeq", + OneOf: "", + TypeUint: canoto.SizeOf(zero.PrevVMBlockSeq), + }, + canoto.FieldTypeFromField( + /*type inference:*/ (zero.BlockValidationDescriptor), + /*FieldNumber: */ canotoNumber_SimplexEpochInfo__BlockValidationDescriptor, + /*Name: */ "BlockValidationDescriptor", + /*FixedLength: */ 0, + /*Repeated: */ false, + /*OneOf: */ "", + /*Pointer: */ true, + /*types: */ types, + ), + canoto.FieldTypeFromField( + /*type inference:*/ (zero.NextEpochApprovals), + /*FieldNumber: */ canotoNumber_SimplexEpochInfo__NextEpochApprovals, + /*Name: */ "NextEpochApprovals", + /*FixedLength: */ 0, + /*Repeated: */ false, + /*OneOf: */ "", + /*Pointer: */ true, + /*types: */ types, + ), + { + FieldNumber: canotoNumber_SimplexEpochInfo__SealingBlockSeq, + Name: "SealingBlockSeq", + OneOf: "", + TypeUint: canoto.SizeOf(zero.SealingBlockSeq), + }, + }, + } + s.CalculateCanotoCache() + return s +} + +// UnmarshalCanoto unmarshals a Canoto-encoded byte slice into the struct. +// +// During parsing, the canoto cache is saved. +func (c *SimplexEpochInfo) UnmarshalCanoto(bytes []byte) error { + r := canoto.Reader{ + B: bytes, + } + return c.UnmarshalCanotoFrom(r) +} + +// UnmarshalCanotoFrom populates the struct from a [canoto.Reader]. Most users +// should just use UnmarshalCanoto. +// +// During parsing, the canoto cache is saved. +// +// This function enables configuration of reader options. +func (c *SimplexEpochInfo) UnmarshalCanotoFrom(r canoto.Reader) error { + // Zero the struct before unmarshaling. + *c = SimplexEpochInfo{} + atomic.StoreUint64(&c.canotoData.size, uint64(len(r.B))) + + var minField uint32 + for canoto.HasNext(&r) { + field, wireType, err := canoto.ReadTag(&r) + if err != nil { + return err + } + if field < minField { + return canoto.ErrInvalidFieldOrder + } + + switch field { + case canotoNumber_SimplexEpochInfo__PChainReferenceHeight: + if wireType != canoto.Varint { + return canoto.ErrUnexpectedWireType + } + + if err := canoto.ReadUint(&r, &c.PChainReferenceHeight); err != nil { + return err + } + if canoto.IsZero(c.PChainReferenceHeight) { + return canoto.ErrZeroValue + } + case canotoNumber_SimplexEpochInfo__EpochNumber: + if wireType != canoto.Varint { + return canoto.ErrUnexpectedWireType + } + + if err := canoto.ReadUint(&r, &c.EpochNumber); err != nil { + return err + } + if canoto.IsZero(c.EpochNumber) { + return canoto.ErrZeroValue + } + case canotoNumber_SimplexEpochInfo__PrevSealingBlockHash: + if wireType != canoto.Len { + return canoto.ErrUnexpectedWireType + } + + const ( + expectedLength = len(c.PrevSealingBlockHash) + expectedLengthUint64 = uint64(expectedLength) + ) + var length uint64 + if err := canoto.ReadUint(&r, &length); err != nil { + return err + } + if length != expectedLengthUint64 { + return canoto.ErrInvalidLength + } + if expectedLength > len(r.B) { + return io.ErrUnexpectedEOF + } + + copy((&c.PrevSealingBlockHash)[:], r.B) + if canoto.IsZero(c.PrevSealingBlockHash) { + return canoto.ErrZeroValue + } + r.B = r.B[expectedLength:] + case canotoNumber_SimplexEpochInfo__NextPChainReferenceHeight: + if wireType != canoto.Varint { + return canoto.ErrUnexpectedWireType + } + + if err := canoto.ReadUint(&r, &c.NextPChainReferenceHeight); err != nil { + return err + } + if canoto.IsZero(c.NextPChainReferenceHeight) { + return canoto.ErrZeroValue + } + case canotoNumber_SimplexEpochInfo__PrevVMBlockSeq: + if wireType != canoto.Varint { + return canoto.ErrUnexpectedWireType + } + + if err := canoto.ReadUint(&r, &c.PrevVMBlockSeq); err != nil { + return err + } + if canoto.IsZero(c.PrevVMBlockSeq) { + return canoto.ErrZeroValue + } + case canotoNumber_SimplexEpochInfo__BlockValidationDescriptor: + if wireType != canoto.Len { + return canoto.ErrUnexpectedWireType + } + + // Read the bytes for the field. + originalUnsafe := r.Unsafe + r.Unsafe = true + var msgBytes []byte + if err := canoto.ReadBytes(&r, &msgBytes); err != nil { + return err + } + r.Unsafe = originalUnsafe + + // Unmarshal the field from the bytes. + remainingBytes := r.B + r.B = msgBytes + c.BlockValidationDescriptor = canoto.MakePointer(c.BlockValidationDescriptor) + if err := (c.BlockValidationDescriptor).UnmarshalCanotoFrom(r); err != nil { + return err + } + r.B = remainingBytes + case canotoNumber_SimplexEpochInfo__NextEpochApprovals: + if wireType != canoto.Len { + return canoto.ErrUnexpectedWireType + } + + // Read the bytes for the field. + originalUnsafe := r.Unsafe + r.Unsafe = true + var msgBytes []byte + if err := canoto.ReadBytes(&r, &msgBytes); err != nil { + return err + } + r.Unsafe = originalUnsafe + + // Unmarshal the field from the bytes. + remainingBytes := r.B + r.B = msgBytes + c.NextEpochApprovals = canoto.MakePointer(c.NextEpochApprovals) + if err := (c.NextEpochApprovals).UnmarshalCanotoFrom(r); err != nil { + return err + } + r.B = remainingBytes + case canotoNumber_SimplexEpochInfo__SealingBlockSeq: + if wireType != canoto.Varint { + return canoto.ErrUnexpectedWireType + } + + if err := canoto.ReadUint(&r, &c.SealingBlockSeq); err != nil { + return err + } + if canoto.IsZero(c.SealingBlockSeq) { + return canoto.ErrZeroValue + } + default: + return canoto.ErrUnknownField + } + + minField = field + 1 + } + return nil +} + +// ValidCanoto validates that the struct can be correctly marshaled into the +// Canoto format. +// +// Specifically, ValidCanoto ensures: +// 1. All OneOfs are specified at most once. +// 2. All strings are valid utf-8. +// 3. All custom fields are ValidCanoto. +func (c *SimplexEpochInfo) ValidCanoto() bool { + if c.BlockValidationDescriptor != nil && !(c.BlockValidationDescriptor).ValidCanoto() { + return false + } + if c.NextEpochApprovals != nil && !(c.NextEpochApprovals).ValidCanoto() { + return false + } + return true +} + +// CalculateCanotoCache populates size and OneOf caches based on the current +// values in the struct. +// +// It is not safe to copy this struct concurrently. +func (c *SimplexEpochInfo) CalculateCanotoCache() { + var size uint64 + if !canoto.IsZero(c.PChainReferenceHeight) { + size += uint64(len(canotoTag_SimplexEpochInfo__PChainReferenceHeight)) + canoto.SizeUint(c.PChainReferenceHeight) + } + if !canoto.IsZero(c.EpochNumber) { + size += uint64(len(canotoTag_SimplexEpochInfo__EpochNumber)) + canoto.SizeUint(c.EpochNumber) + } + if !canoto.IsZero(c.PrevSealingBlockHash) { + size += uint64(len(canotoTag_SimplexEpochInfo__PrevSealingBlockHash)) + canoto.SizeBytes((&c.PrevSealingBlockHash)[:]) + } + if !canoto.IsZero(c.NextPChainReferenceHeight) { + size += uint64(len(canotoTag_SimplexEpochInfo__NextPChainReferenceHeight)) + canoto.SizeUint(c.NextPChainReferenceHeight) + } + if !canoto.IsZero(c.PrevVMBlockSeq) { + size += uint64(len(canotoTag_SimplexEpochInfo__PrevVMBlockSeq)) + canoto.SizeUint(c.PrevVMBlockSeq) + } + if c.BlockValidationDescriptor != nil { + (c.BlockValidationDescriptor).CalculateCanotoCache() + fieldSize := (c.BlockValidationDescriptor).CachedCanotoSize() + size += uint64(len(canotoTag_SimplexEpochInfo__BlockValidationDescriptor)) + canoto.SizeUint(fieldSize) + fieldSize + } + if c.NextEpochApprovals != nil { + (c.NextEpochApprovals).CalculateCanotoCache() + fieldSize := (c.NextEpochApprovals).CachedCanotoSize() + size += uint64(len(canotoTag_SimplexEpochInfo__NextEpochApprovals)) + canoto.SizeUint(fieldSize) + fieldSize + } + if !canoto.IsZero(c.SealingBlockSeq) { + size += uint64(len(canotoTag_SimplexEpochInfo__SealingBlockSeq)) + canoto.SizeUint(c.SealingBlockSeq) + } + atomic.StoreUint64(&c.canotoData.size, size) +} + +// CachedCanotoSize returns the previously calculated size of the Canoto +// representation from CalculateCanotoCache. +// +// If CalculateCanotoCache has not yet been called, it will return 0. +// +// If the struct has been modified since the last call to CalculateCanotoCache, +// the returned size may be incorrect. +func (c *SimplexEpochInfo) CachedCanotoSize() uint64 { + return atomic.LoadUint64(&c.canotoData.size) +} + +// MarshalCanoto returns the Canoto representation of this struct. +// +// It is assumed that this struct is ValidCanoto. +// +// It is not safe to copy this struct concurrently. +func (c *SimplexEpochInfo) MarshalCanoto() []byte { + c.CalculateCanotoCache() + w := canoto.Writer{ + B: make([]byte, 0, c.CachedCanotoSize()), + } + w = c.MarshalCanotoInto(w) + return w.B +} + +// MarshalCanotoInto writes the struct into a [canoto.Writer] and returns the +// resulting [canoto.Writer]. Most users should just use MarshalCanoto. +// +// It is assumed that CalculateCanotoCache has been called since the last +// modification to this struct. +// +// It is assumed that this struct is ValidCanoto. +// +// It is not safe to copy this struct concurrently. +func (c *SimplexEpochInfo) MarshalCanotoInto(w canoto.Writer) canoto.Writer { + if !canoto.IsZero(c.PChainReferenceHeight) { + canoto.Append(&w, canotoTag_SimplexEpochInfo__PChainReferenceHeight) + canoto.AppendUint(&w, c.PChainReferenceHeight) + } + if !canoto.IsZero(c.EpochNumber) { + canoto.Append(&w, canotoTag_SimplexEpochInfo__EpochNumber) + canoto.AppendUint(&w, c.EpochNumber) + } + if !canoto.IsZero(c.PrevSealingBlockHash) { + canoto.Append(&w, canotoTag_SimplexEpochInfo__PrevSealingBlockHash) + canoto.AppendBytes(&w, (&c.PrevSealingBlockHash)[:]) + } + if !canoto.IsZero(c.NextPChainReferenceHeight) { + canoto.Append(&w, canotoTag_SimplexEpochInfo__NextPChainReferenceHeight) + canoto.AppendUint(&w, c.NextPChainReferenceHeight) + } + if !canoto.IsZero(c.PrevVMBlockSeq) { + canoto.Append(&w, canotoTag_SimplexEpochInfo__PrevVMBlockSeq) + canoto.AppendUint(&w, c.PrevVMBlockSeq) + } + if c.BlockValidationDescriptor != nil { + fieldSize := (c.BlockValidationDescriptor).CachedCanotoSize() + canoto.Append(&w, canotoTag_SimplexEpochInfo__BlockValidationDescriptor) + canoto.AppendUint(&w, fieldSize) + w = (c.BlockValidationDescriptor).MarshalCanotoInto(w) + } + if c.NextEpochApprovals != nil { + fieldSize := (c.NextEpochApprovals).CachedCanotoSize() + canoto.Append(&w, canotoTag_SimplexEpochInfo__NextEpochApprovals) + canoto.AppendUint(&w, fieldSize) + w = (c.NextEpochApprovals).MarshalCanotoInto(w) + } + if !canoto.IsZero(c.SealingBlockSeq) { + canoto.Append(&w, canotoTag_SimplexEpochInfo__SealingBlockSeq) + canoto.AppendUint(&w, c.SealingBlockSeq) + } + return w +} + +const ( + canotoNumber_NodeBLSMapping__NodeID = 1 + canotoNumber_NodeBLSMapping__BLSKey = 2 + canotoNumber_NodeBLSMapping__Weight = 3 + + canotoTag_NodeBLSMapping__NodeID = "\x0a" // canoto.Tag(canotoNumber_NodeBLSMapping__NodeID, canoto.Len) + canotoTag_NodeBLSMapping__BLSKey = "\x12" // canoto.Tag(canotoNumber_NodeBLSMapping__BLSKey, canoto.Len) + canotoTag_NodeBLSMapping__Weight = "\x18" // canoto.Tag(canotoNumber_NodeBLSMapping__Weight, canoto.Varint) +) + +type canotoData_NodeBLSMapping struct { + size uint64 +} + +// CanotoSpec returns the specification of this canoto message. +func (*NodeBLSMapping) CanotoSpec(...reflect.Type) *canoto.Spec { + var zero NodeBLSMapping + s := &canoto.Spec{ + Name: "NodeBLSMapping", + Fields: []canoto.FieldType{ + { + FieldNumber: canotoNumber_NodeBLSMapping__NodeID, + Name: "NodeID", + OneOf: "", + TypeFixedBytes: uint64(len(zero.NodeID)), + }, + { + FieldNumber: canotoNumber_NodeBLSMapping__BLSKey, + Name: "BLSKey", + OneOf: "", + TypeBytes: true, + }, + { + FieldNumber: canotoNumber_NodeBLSMapping__Weight, + Name: "Weight", + OneOf: "", + TypeUint: canoto.SizeOf(zero.Weight), + }, + }, + } + s.CalculateCanotoCache() + return s +} + +// UnmarshalCanoto unmarshals a Canoto-encoded byte slice into the struct. +// +// During parsing, the canoto cache is saved. +func (c *NodeBLSMapping) UnmarshalCanoto(bytes []byte) error { + r := canoto.Reader{ + B: bytes, + } + return c.UnmarshalCanotoFrom(r) +} + +// UnmarshalCanotoFrom populates the struct from a [canoto.Reader]. Most users +// should just use UnmarshalCanoto. +// +// During parsing, the canoto cache is saved. +// +// This function enables configuration of reader options. +func (c *NodeBLSMapping) UnmarshalCanotoFrom(r canoto.Reader) error { + // Zero the struct before unmarshaling. + *c = NodeBLSMapping{} + atomic.StoreUint64(&c.canotoData.size, uint64(len(r.B))) + + var minField uint32 + for canoto.HasNext(&r) { + field, wireType, err := canoto.ReadTag(&r) + if err != nil { + return err + } + if field < minField { + return canoto.ErrInvalidFieldOrder + } + + switch field { + case canotoNumber_NodeBLSMapping__NodeID: + if wireType != canoto.Len { + return canoto.ErrUnexpectedWireType + } + + const ( + expectedLength = len(c.NodeID) + expectedLengthUint64 = uint64(expectedLength) + ) + var length uint64 + if err := canoto.ReadUint(&r, &length); err != nil { + return err + } + if length != expectedLengthUint64 { + return canoto.ErrInvalidLength + } + if expectedLength > len(r.B) { + return io.ErrUnexpectedEOF + } + + copy((&c.NodeID)[:], r.B) + if canoto.IsZero(c.NodeID) { + return canoto.ErrZeroValue + } + r.B = r.B[expectedLength:] + case canotoNumber_NodeBLSMapping__BLSKey: + if wireType != canoto.Len { + return canoto.ErrUnexpectedWireType + } + + if err := canoto.ReadBytes(&r, &c.BLSKey); err != nil { + return err + } + if len(c.BLSKey) == 0 { + return canoto.ErrZeroValue + } + case canotoNumber_NodeBLSMapping__Weight: + if wireType != canoto.Varint { + return canoto.ErrUnexpectedWireType + } + + if err := canoto.ReadUint(&r, &c.Weight); err != nil { + return err + } + if canoto.IsZero(c.Weight) { + return canoto.ErrZeroValue + } + default: + return canoto.ErrUnknownField + } + + minField = field + 1 + } + return nil +} + +// ValidCanoto validates that the struct can be correctly marshaled into the +// Canoto format. +// +// Specifically, ValidCanoto ensures: +// 1. All OneOfs are specified at most once. +// 2. All strings are valid utf-8. +// 3. All custom fields are ValidCanoto. +func (c *NodeBLSMapping) ValidCanoto() bool { + return true +} + +// CalculateCanotoCache populates size and OneOf caches based on the current +// values in the struct. +// +// It is not safe to copy this struct concurrently. +func (c *NodeBLSMapping) CalculateCanotoCache() { + var size uint64 + if !canoto.IsZero(c.NodeID) { + size += uint64(len(canotoTag_NodeBLSMapping__NodeID)) + canoto.SizeBytes((&c.NodeID)[:]) + } + if len(c.BLSKey) != 0 { + size += uint64(len(canotoTag_NodeBLSMapping__BLSKey)) + canoto.SizeBytes(c.BLSKey) + } + if !canoto.IsZero(c.Weight) { + size += uint64(len(canotoTag_NodeBLSMapping__Weight)) + canoto.SizeUint(c.Weight) + } + atomic.StoreUint64(&c.canotoData.size, size) +} + +// CachedCanotoSize returns the previously calculated size of the Canoto +// representation from CalculateCanotoCache. +// +// If CalculateCanotoCache has not yet been called, it will return 0. +// +// If the struct has been modified since the last call to CalculateCanotoCache, +// the returned size may be incorrect. +func (c *NodeBLSMapping) CachedCanotoSize() uint64 { + return atomic.LoadUint64(&c.canotoData.size) +} + +// MarshalCanoto returns the Canoto representation of this struct. +// +// It is assumed that this struct is ValidCanoto. +// +// It is not safe to copy this struct concurrently. +func (c *NodeBLSMapping) MarshalCanoto() []byte { + c.CalculateCanotoCache() + w := canoto.Writer{ + B: make([]byte, 0, c.CachedCanotoSize()), + } + w = c.MarshalCanotoInto(w) + return w.B +} + +// MarshalCanotoInto writes the struct into a [canoto.Writer] and returns the +// resulting [canoto.Writer]. Most users should just use MarshalCanoto. +// +// It is assumed that CalculateCanotoCache has been called since the last +// modification to this struct. +// +// It is assumed that this struct is ValidCanoto. +// +// It is not safe to copy this struct concurrently. +func (c *NodeBLSMapping) MarshalCanotoInto(w canoto.Writer) canoto.Writer { + if !canoto.IsZero(c.NodeID) { + canoto.Append(&w, canotoTag_NodeBLSMapping__NodeID) + canoto.AppendBytes(&w, (&c.NodeID)[:]) + } + if len(c.BLSKey) != 0 { + canoto.Append(&w, canotoTag_NodeBLSMapping__BLSKey) + canoto.AppendBytes(&w, c.BLSKey) + } + if !canoto.IsZero(c.Weight) { + canoto.Append(&w, canotoTag_NodeBLSMapping__Weight) + canoto.AppendUint(&w, c.Weight) + } + return w +} + +const ( + canotoNumber_BlockValidationDescriptor__AggregatedMembership = 1 + + canotoTag_BlockValidationDescriptor__AggregatedMembership = "\x0a" // canoto.Tag(canotoNumber_BlockValidationDescriptor__AggregatedMembership, canoto.Len) +) + +type canotoData_BlockValidationDescriptor struct { + size uint64 +} + +// CanotoSpec returns the specification of this canoto message. +func (*BlockValidationDescriptor) CanotoSpec(types ...reflect.Type) *canoto.Spec { + types = append(types, reflect.TypeFor[BlockValidationDescriptor]()) + var zero BlockValidationDescriptor + s := &canoto.Spec{ + Name: "BlockValidationDescriptor", + Fields: []canoto.FieldType{ + canoto.FieldTypeFromField( + /*type inference:*/ (&zero.AggregatedMembership), + /*FieldNumber: */ canotoNumber_BlockValidationDescriptor__AggregatedMembership, + /*Name: */ "AggregatedMembership", + /*FixedLength: */ 0, + /*Repeated: */ false, + /*OneOf: */ "", + /*Pointer: */ false, + /*types: */ types, + ), + }, + } + s.CalculateCanotoCache() + return s +} + +// UnmarshalCanoto unmarshals a Canoto-encoded byte slice into the struct. +// +// During parsing, the canoto cache is saved. +func (c *BlockValidationDescriptor) UnmarshalCanoto(bytes []byte) error { + r := canoto.Reader{ + B: bytes, + } + return c.UnmarshalCanotoFrom(r) +} + +// UnmarshalCanotoFrom populates the struct from a [canoto.Reader]. Most users +// should just use UnmarshalCanoto. +// +// During parsing, the canoto cache is saved. +// +// This function enables configuration of reader options. +func (c *BlockValidationDescriptor) UnmarshalCanotoFrom(r canoto.Reader) error { + // Zero the struct before unmarshaling. + *c = BlockValidationDescriptor{} + atomic.StoreUint64(&c.canotoData.size, uint64(len(r.B))) + + var minField uint32 + for canoto.HasNext(&r) { + field, wireType, err := canoto.ReadTag(&r) + if err != nil { + return err + } + if field < minField { + return canoto.ErrInvalidFieldOrder + } + + switch field { + case canotoNumber_BlockValidationDescriptor__AggregatedMembership: + if wireType != canoto.Len { + return canoto.ErrUnexpectedWireType + } + + // Read the bytes for the field. + originalUnsafe := r.Unsafe + r.Unsafe = true + var msgBytes []byte + if err := canoto.ReadBytes(&r, &msgBytes); err != nil { + return err + } + if len(msgBytes) == 0 { + return canoto.ErrZeroValue + } + r.Unsafe = originalUnsafe + + // Unmarshal the field from the bytes. + remainingBytes := r.B + r.B = msgBytes + if err := (&c.AggregatedMembership).UnmarshalCanotoFrom(r); err != nil { + return err + } + r.B = remainingBytes + default: + return canoto.ErrUnknownField + } + + minField = field + 1 + } + return nil +} + +// ValidCanoto validates that the struct can be correctly marshaled into the +// Canoto format. +// +// Specifically, ValidCanoto ensures: +// 1. All OneOfs are specified at most once. +// 2. All strings are valid utf-8. +// 3. All custom fields are ValidCanoto. +func (c *BlockValidationDescriptor) ValidCanoto() bool { + if !(&c.AggregatedMembership).ValidCanoto() { + return false + } + return true +} + +// CalculateCanotoCache populates size and OneOf caches based on the current +// values in the struct. +// +// It is not safe to copy this struct concurrently. +func (c *BlockValidationDescriptor) CalculateCanotoCache() { + var size uint64 + (&c.AggregatedMembership).CalculateCanotoCache() + if fieldSize := (&c.AggregatedMembership).CachedCanotoSize(); fieldSize != 0 { + size += uint64(len(canotoTag_BlockValidationDescriptor__AggregatedMembership)) + canoto.SizeUint(fieldSize) + fieldSize + } + atomic.StoreUint64(&c.canotoData.size, size) +} + +// CachedCanotoSize returns the previously calculated size of the Canoto +// representation from CalculateCanotoCache. +// +// If CalculateCanotoCache has not yet been called, it will return 0. +// +// If the struct has been modified since the last call to CalculateCanotoCache, +// the returned size may be incorrect. +func (c *BlockValidationDescriptor) CachedCanotoSize() uint64 { + return atomic.LoadUint64(&c.canotoData.size) +} + +// MarshalCanoto returns the Canoto representation of this struct. +// +// It is assumed that this struct is ValidCanoto. +// +// It is not safe to copy this struct concurrently. +func (c *BlockValidationDescriptor) MarshalCanoto() []byte { + c.CalculateCanotoCache() + w := canoto.Writer{ + B: make([]byte, 0, c.CachedCanotoSize()), + } + w = c.MarshalCanotoInto(w) + return w.B +} + +// MarshalCanotoInto writes the struct into a [canoto.Writer] and returns the +// resulting [canoto.Writer]. Most users should just use MarshalCanoto. +// +// It is assumed that CalculateCanotoCache has been called since the last +// modification to this struct. +// +// It is assumed that this struct is ValidCanoto. +// +// It is not safe to copy this struct concurrently. +func (c *BlockValidationDescriptor) MarshalCanotoInto(w canoto.Writer) canoto.Writer { + if fieldSize := (&c.AggregatedMembership).CachedCanotoSize(); fieldSize != 0 { + canoto.Append(&w, canotoTag_BlockValidationDescriptor__AggregatedMembership) + canoto.AppendUint(&w, fieldSize) + w = (&c.AggregatedMembership).MarshalCanotoInto(w) + } + return w +} + +const ( + canotoNumber_AggregatedMembership__Members = 1 + + canotoTag_AggregatedMembership__Members = "\x0a" // canoto.Tag(canotoNumber_AggregatedMembership__Members, canoto.Len) +) + +type canotoData_AggregatedMembership struct { + size uint64 +} + +// CanotoSpec returns the specification of this canoto message. +func (*AggregatedMembership) CanotoSpec(types ...reflect.Type) *canoto.Spec { + types = append(types, reflect.TypeFor[AggregatedMembership]()) + var zero AggregatedMembership + s := &canoto.Spec{ + Name: "AggregatedMembership", + Fields: []canoto.FieldType{ + canoto.FieldTypeFromField( + /*type inference:*/ (canoto.MakeEntryNilPointer(zero.Members)), + /*FieldNumber: */ canotoNumber_AggregatedMembership__Members, + /*Name: */ "Members", + /*FixedLength: */ 0, + /*Repeated: */ true, + /*OneOf: */ "", + /*Pointer: */ false, + /*types: */ types, + ), + }, + } + s.CalculateCanotoCache() + return s +} + +// UnmarshalCanoto unmarshals a Canoto-encoded byte slice into the struct. +// +// During parsing, the canoto cache is saved. +func (c *AggregatedMembership) UnmarshalCanoto(bytes []byte) error { + r := canoto.Reader{ + B: bytes, + } + return c.UnmarshalCanotoFrom(r) +} + +// UnmarshalCanotoFrom populates the struct from a [canoto.Reader]. Most users +// should just use UnmarshalCanoto. +// +// During parsing, the canoto cache is saved. +// +// This function enables configuration of reader options. +func (c *AggregatedMembership) UnmarshalCanotoFrom(r canoto.Reader) error { + // Zero the struct before unmarshaling. + *c = AggregatedMembership{} + atomic.StoreUint64(&c.canotoData.size, uint64(len(r.B))) + + var minField uint32 + for canoto.HasNext(&r) { + field, wireType, err := canoto.ReadTag(&r) + if err != nil { + return err + } + if field < minField { + return canoto.ErrInvalidFieldOrder + } + + switch field { + case canotoNumber_AggregatedMembership__Members: + if wireType != canoto.Len { + return canoto.ErrUnexpectedWireType + } + + // Read the first entry manually because the tag is already + // stripped. + originalUnsafe := r.Unsafe + r.Unsafe = true + var msgBytes []byte + if err := canoto.ReadBytes(&r, &msgBytes); err != nil { + return err + } + r.Unsafe = originalUnsafe + + // Count the number of additional entries after the first entry. + countMinus1, err := canoto.CountBytes(r.B, canotoTag_AggregatedMembership__Members) + if err != nil { + return err + } + + c.Members = canoto.MakeSlice(c.Members, countMinus1+1) + field := c.Members + additionalField := field[1:] + if len(msgBytes) != 0 { + remainingBytes := r.B + r.B = msgBytes + if err := (&field[0]).UnmarshalCanotoFrom(r); err != nil { + return err + } + r.B = remainingBytes + } + + // Read the rest of the entries, stripping the tag each time. + for i := range additionalField { + r.B = r.B[len(canotoTag_AggregatedMembership__Members):] + r.Unsafe = true + if err := canoto.ReadBytes(&r, &msgBytes); err != nil { + return err + } + r.Unsafe = originalUnsafe + if len(msgBytes) == 0 { + continue + } + + remainingBytes := r.B + r.B = msgBytes + if err := (&additionalField[i]).UnmarshalCanotoFrom(r); err != nil { + return err + } + r.B = remainingBytes + } + default: + return canoto.ErrUnknownField + } + + minField = field + 1 + } + return nil +} + +// ValidCanoto validates that the struct can be correctly marshaled into the +// Canoto format. +// +// Specifically, ValidCanoto ensures: +// 1. All OneOfs are specified at most once. +// 2. All strings are valid utf-8. +// 3. All custom fields are ValidCanoto. +func (c *AggregatedMembership) ValidCanoto() bool { + { + field := c.Members + for i := range field { + if !(&field[i]).ValidCanoto() { + return false + } + } + } + return true +} + +// CalculateCanotoCache populates size and OneOf caches based on the current +// values in the struct. +// +// It is not safe to copy this struct concurrently. +func (c *AggregatedMembership) CalculateCanotoCache() { + var size uint64 + { + field := c.Members + for i := range field { + (&field[i]).CalculateCanotoCache() + fieldSize := (&field[i]).CachedCanotoSize() + size += uint64(len(canotoTag_AggregatedMembership__Members)) + canoto.SizeUint(fieldSize) + fieldSize + } + } + atomic.StoreUint64(&c.canotoData.size, size) +} + +// CachedCanotoSize returns the previously calculated size of the Canoto +// representation from CalculateCanotoCache. +// +// If CalculateCanotoCache has not yet been called, it will return 0. +// +// If the struct has been modified since the last call to CalculateCanotoCache, +// the returned size may be incorrect. +func (c *AggregatedMembership) CachedCanotoSize() uint64 { + return atomic.LoadUint64(&c.canotoData.size) +} + +// MarshalCanoto returns the Canoto representation of this struct. +// +// It is assumed that this struct is ValidCanoto. +// +// It is not safe to copy this struct concurrently. +func (c *AggregatedMembership) MarshalCanoto() []byte { + c.CalculateCanotoCache() + w := canoto.Writer{ + B: make([]byte, 0, c.CachedCanotoSize()), + } + w = c.MarshalCanotoInto(w) + return w.B +} + +// MarshalCanotoInto writes the struct into a [canoto.Writer] and returns the +// resulting [canoto.Writer]. Most users should just use MarshalCanoto. +// +// It is assumed that CalculateCanotoCache has been called since the last +// modification to this struct. +// +// It is assumed that this struct is ValidCanoto. +// +// It is not safe to copy this struct concurrently. +func (c *AggregatedMembership) MarshalCanotoInto(w canoto.Writer) canoto.Writer { + { + field := c.Members + for i := range field { + canoto.Append(&w, canotoTag_AggregatedMembership__Members) + canoto.AppendUint(&w, (&field[i]).CachedCanotoSize()) + w = (&field[i]).MarshalCanotoInto(w) + } + } + return w +} + +const ( + canotoNumber_NextEpochApprovals__NodeIDs = 1 + canotoNumber_NextEpochApprovals__Signature = 2 + + canotoTag_NextEpochApprovals__NodeIDs = "\x0a" // canoto.Tag(canotoNumber_NextEpochApprovals__NodeIDs, canoto.Len) + canotoTag_NextEpochApprovals__Signature = "\x12" // canoto.Tag(canotoNumber_NextEpochApprovals__Signature, canoto.Len) +) + +type canotoData_NextEpochApprovals struct { + size uint64 +} + +// CanotoSpec returns the specification of this canoto message. +func (*NextEpochApprovals) CanotoSpec(...reflect.Type) *canoto.Spec { + s := &canoto.Spec{ + Name: "NextEpochApprovals", + Fields: []canoto.FieldType{ + { + FieldNumber: canotoNumber_NextEpochApprovals__NodeIDs, + Name: "NodeIDs", + OneOf: "", + TypeBytes: true, + }, + { + FieldNumber: canotoNumber_NextEpochApprovals__Signature, + Name: "Signature", + OneOf: "", + TypeBytes: true, + }, + }, + } + s.CalculateCanotoCache() + return s +} + +// UnmarshalCanoto unmarshals a Canoto-encoded byte slice into the struct. +// +// During parsing, the canoto cache is saved. +func (c *NextEpochApprovals) UnmarshalCanoto(bytes []byte) error { + r := canoto.Reader{ + B: bytes, + } + return c.UnmarshalCanotoFrom(r) +} + +// UnmarshalCanotoFrom populates the struct from a [canoto.Reader]. Most users +// should just use UnmarshalCanoto. +// +// During parsing, the canoto cache is saved. +// +// This function enables configuration of reader options. +func (c *NextEpochApprovals) UnmarshalCanotoFrom(r canoto.Reader) error { + // Zero the struct before unmarshaling. + *c = NextEpochApprovals{} + atomic.StoreUint64(&c.canotoData.size, uint64(len(r.B))) + + var minField uint32 + for canoto.HasNext(&r) { + field, wireType, err := canoto.ReadTag(&r) + if err != nil { + return err + } + if field < minField { + return canoto.ErrInvalidFieldOrder + } + + switch field { + case canotoNumber_NextEpochApprovals__NodeIDs: + if wireType != canoto.Len { + return canoto.ErrUnexpectedWireType + } + + if err := canoto.ReadBytes(&r, &c.NodeIDs); err != nil { + return err + } + if len(c.NodeIDs) == 0 { + return canoto.ErrZeroValue + } + case canotoNumber_NextEpochApprovals__Signature: + if wireType != canoto.Len { + return canoto.ErrUnexpectedWireType + } + + if err := canoto.ReadBytes(&r, &c.Signature); err != nil { + return err + } + if len(c.Signature) == 0 { + return canoto.ErrZeroValue + } + default: + return canoto.ErrUnknownField + } + + minField = field + 1 + } + return nil +} + +// ValidCanoto validates that the struct can be correctly marshaled into the +// Canoto format. +// +// Specifically, ValidCanoto ensures: +// 1. All OneOfs are specified at most once. +// 2. All strings are valid utf-8. +// 3. All custom fields are ValidCanoto. +func (c *NextEpochApprovals) ValidCanoto() bool { + return true +} + +// CalculateCanotoCache populates size and OneOf caches based on the current +// values in the struct. +// +// It is not safe to copy this struct concurrently. +func (c *NextEpochApprovals) CalculateCanotoCache() { + var size uint64 + if len(c.NodeIDs) != 0 { + size += uint64(len(canotoTag_NextEpochApprovals__NodeIDs)) + canoto.SizeBytes(c.NodeIDs) + } + if len(c.Signature) != 0 { + size += uint64(len(canotoTag_NextEpochApprovals__Signature)) + canoto.SizeBytes(c.Signature) + } + atomic.StoreUint64(&c.canotoData.size, size) +} + +// CachedCanotoSize returns the previously calculated size of the Canoto +// representation from CalculateCanotoCache. +// +// If CalculateCanotoCache has not yet been called, it will return 0. +// +// If the struct has been modified since the last call to CalculateCanotoCache, +// the returned size may be incorrect. +func (c *NextEpochApprovals) CachedCanotoSize() uint64 { + return atomic.LoadUint64(&c.canotoData.size) +} + +// MarshalCanoto returns the Canoto representation of this struct. +// +// It is assumed that this struct is ValidCanoto. +// +// It is not safe to copy this struct concurrently. +func (c *NextEpochApprovals) MarshalCanoto() []byte { + c.CalculateCanotoCache() + w := canoto.Writer{ + B: make([]byte, 0, c.CachedCanotoSize()), + } + w = c.MarshalCanotoInto(w) + return w.B +} + +// MarshalCanotoInto writes the struct into a [canoto.Writer] and returns the +// resulting [canoto.Writer]. Most users should just use MarshalCanoto. +// +// It is assumed that CalculateCanotoCache has been called since the last +// modification to this struct. +// +// It is assumed that this struct is ValidCanoto. +// +// It is not safe to copy this struct concurrently. +func (c *NextEpochApprovals) MarshalCanotoInto(w canoto.Writer) canoto.Writer { + if len(c.NodeIDs) != 0 { + canoto.Append(&w, canotoTag_NextEpochApprovals__NodeIDs) + canoto.AppendBytes(&w, c.NodeIDs) + } + if len(c.Signature) != 0 { + canoto.Append(&w, canotoTag_NextEpochApprovals__Signature) + canoto.AppendBytes(&w, c.Signature) + } + return w +} + +const ( + canotoNumber_ValidatorSetApproval__NodeID = 1 + canotoNumber_ValidatorSetApproval__AuxInfoSeqDigest = 2 + canotoNumber_ValidatorSetApproval__PChainHeight = 3 + canotoNumber_ValidatorSetApproval__Signature = 4 + + canotoTag_ValidatorSetApproval__NodeID = "\x0a" // canoto.Tag(canotoNumber_ValidatorSetApproval__NodeID, canoto.Len) + canotoTag_ValidatorSetApproval__AuxInfoSeqDigest = "\x12" // canoto.Tag(canotoNumber_ValidatorSetApproval__AuxInfoSeqDigest, canoto.Len) + canotoTag_ValidatorSetApproval__PChainHeight = "\x18" // canoto.Tag(canotoNumber_ValidatorSetApproval__PChainHeight, canoto.Varint) + canotoTag_ValidatorSetApproval__Signature = "\x22" // canoto.Tag(canotoNumber_ValidatorSetApproval__Signature, canoto.Len) +) + +type canotoData_ValidatorSetApproval struct { + size uint64 +} + +// CanotoSpec returns the specification of this canoto message. +func (*ValidatorSetApproval) CanotoSpec(...reflect.Type) *canoto.Spec { + var zero ValidatorSetApproval + s := &canoto.Spec{ + Name: "ValidatorSetApproval", + Fields: []canoto.FieldType{ + { + FieldNumber: canotoNumber_ValidatorSetApproval__NodeID, + Name: "NodeID", + OneOf: "", + TypeFixedBytes: uint64(len(zero.NodeID)), + }, + { + FieldNumber: canotoNumber_ValidatorSetApproval__AuxInfoSeqDigest, + Name: "AuxInfoSeqDigest", + OneOf: "", + TypeFixedBytes: uint64(len(zero.AuxInfoSeqDigest)), + }, + { + FieldNumber: canotoNumber_ValidatorSetApproval__PChainHeight, + Name: "PChainHeight", + OneOf: "", + TypeUint: canoto.SizeOf(zero.PChainHeight), + }, + { + FieldNumber: canotoNumber_ValidatorSetApproval__Signature, + Name: "Signature", + OneOf: "", + TypeBytes: true, + }, + }, + } + s.CalculateCanotoCache() + return s +} + +// UnmarshalCanoto unmarshals a Canoto-encoded byte slice into the struct. +// +// During parsing, the canoto cache is saved. +func (c *ValidatorSetApproval) UnmarshalCanoto(bytes []byte) error { + r := canoto.Reader{ + B: bytes, + } + return c.UnmarshalCanotoFrom(r) +} + +// UnmarshalCanotoFrom populates the struct from a [canoto.Reader]. Most users +// should just use UnmarshalCanoto. +// +// During parsing, the canoto cache is saved. +// +// This function enables configuration of reader options. +func (c *ValidatorSetApproval) UnmarshalCanotoFrom(r canoto.Reader) error { + // Zero the struct before unmarshaling. + *c = ValidatorSetApproval{} + atomic.StoreUint64(&c.canotoData.size, uint64(len(r.B))) + + var minField uint32 + for canoto.HasNext(&r) { + field, wireType, err := canoto.ReadTag(&r) + if err != nil { + return err + } + if field < minField { + return canoto.ErrInvalidFieldOrder + } + + switch field { + case canotoNumber_ValidatorSetApproval__NodeID: + if wireType != canoto.Len { + return canoto.ErrUnexpectedWireType + } + + const ( + expectedLength = len(c.NodeID) + expectedLengthUint64 = uint64(expectedLength) + ) + var length uint64 + if err := canoto.ReadUint(&r, &length); err != nil { + return err + } + if length != expectedLengthUint64 { + return canoto.ErrInvalidLength + } + if expectedLength > len(r.B) { + return io.ErrUnexpectedEOF + } + + copy((&c.NodeID)[:], r.B) + if canoto.IsZero(c.NodeID) { + return canoto.ErrZeroValue + } + r.B = r.B[expectedLength:] + case canotoNumber_ValidatorSetApproval__AuxInfoSeqDigest: + if wireType != canoto.Len { + return canoto.ErrUnexpectedWireType + } + + const ( + expectedLength = len(c.AuxInfoSeqDigest) + expectedLengthUint64 = uint64(expectedLength) + ) + var length uint64 + if err := canoto.ReadUint(&r, &length); err != nil { + return err + } + if length != expectedLengthUint64 { + return canoto.ErrInvalidLength + } + if expectedLength > len(r.B) { + return io.ErrUnexpectedEOF + } + + copy((&c.AuxInfoSeqDigest)[:], r.B) + if canoto.IsZero(c.AuxInfoSeqDigest) { + return canoto.ErrZeroValue + } + r.B = r.B[expectedLength:] + case canotoNumber_ValidatorSetApproval__PChainHeight: + if wireType != canoto.Varint { + return canoto.ErrUnexpectedWireType + } + + if err := canoto.ReadUint(&r, &c.PChainHeight); err != nil { + return err + } + if canoto.IsZero(c.PChainHeight) { + return canoto.ErrZeroValue + } + case canotoNumber_ValidatorSetApproval__Signature: + if wireType != canoto.Len { + return canoto.ErrUnexpectedWireType + } + + if err := canoto.ReadBytes(&r, &c.Signature); err != nil { + return err + } + if len(c.Signature) == 0 { + return canoto.ErrZeroValue + } + default: + return canoto.ErrUnknownField + } + + minField = field + 1 + } + return nil +} + +// ValidCanoto validates that the struct can be correctly marshaled into the +// Canoto format. +// +// Specifically, ValidCanoto ensures: +// 1. All OneOfs are specified at most once. +// 2. All strings are valid utf-8. +// 3. All custom fields are ValidCanoto. +func (c *ValidatorSetApproval) ValidCanoto() bool { + return true +} + +// CalculateCanotoCache populates size and OneOf caches based on the current +// values in the struct. +// +// It is not safe to copy this struct concurrently. +func (c *ValidatorSetApproval) CalculateCanotoCache() { + var size uint64 + if !canoto.IsZero(c.NodeID) { + size += uint64(len(canotoTag_ValidatorSetApproval__NodeID)) + canoto.SizeBytes((&c.NodeID)[:]) + } + if !canoto.IsZero(c.AuxInfoSeqDigest) { + size += uint64(len(canotoTag_ValidatorSetApproval__AuxInfoSeqDigest)) + canoto.SizeBytes((&c.AuxInfoSeqDigest)[:]) + } + if !canoto.IsZero(c.PChainHeight) { + size += uint64(len(canotoTag_ValidatorSetApproval__PChainHeight)) + canoto.SizeUint(c.PChainHeight) + } + if len(c.Signature) != 0 { + size += uint64(len(canotoTag_ValidatorSetApproval__Signature)) + canoto.SizeBytes(c.Signature) + } + atomic.StoreUint64(&c.canotoData.size, size) +} + +// CachedCanotoSize returns the previously calculated size of the Canoto +// representation from CalculateCanotoCache. +// +// If CalculateCanotoCache has not yet been called, it will return 0. +// +// If the struct has been modified since the last call to CalculateCanotoCache, +// the returned size may be incorrect. +func (c *ValidatorSetApproval) CachedCanotoSize() uint64 { + return atomic.LoadUint64(&c.canotoData.size) +} + +// MarshalCanoto returns the Canoto representation of this struct. +// +// It is assumed that this struct is ValidCanoto. +// +// It is not safe to copy this struct concurrently. +func (c *ValidatorSetApproval) MarshalCanoto() []byte { + c.CalculateCanotoCache() + w := canoto.Writer{ + B: make([]byte, 0, c.CachedCanotoSize()), + } + w = c.MarshalCanotoInto(w) + return w.B +} + +// MarshalCanotoInto writes the struct into a [canoto.Writer] and returns the +// resulting [canoto.Writer]. Most users should just use MarshalCanoto. +// +// It is assumed that CalculateCanotoCache has been called since the last +// modification to this struct. +// +// It is assumed that this struct is ValidCanoto. +// +// It is not safe to copy this struct concurrently. +func (c *ValidatorSetApproval) MarshalCanotoInto(w canoto.Writer) canoto.Writer { + if !canoto.IsZero(c.NodeID) { + canoto.Append(&w, canotoTag_ValidatorSetApproval__NodeID) + canoto.AppendBytes(&w, (&c.NodeID)[:]) + } + if !canoto.IsZero(c.AuxInfoSeqDigest) { + canoto.Append(&w, canotoTag_ValidatorSetApproval__AuxInfoSeqDigest) + canoto.AppendBytes(&w, (&c.AuxInfoSeqDigest)[:]) + } + if !canoto.IsZero(c.PChainHeight) { + canoto.Append(&w, canotoTag_ValidatorSetApproval__PChainHeight) + canoto.AppendUint(&w, c.PChainHeight) + } + if len(c.Signature) != 0 { + canoto.Append(&w, canotoTag_ValidatorSetApproval__Signature) + canoto.AppendBytes(&w, c.Signature) + } + return w +} diff --git a/msm/encoding.go b/msm/encoding.go new file mode 100644 index 00000000..c5e642ba --- /dev/null +++ b/msm/encoding.go @@ -0,0 +1,307 @@ +// Copyright (C) 2019-2025, Ava Labs, Inc. All rights reserved. +// See the file LICENSE for licensing terms. + +package metadata + +import ( + "bytes" + "slices" +) + +// go:generate go run github.com/StephenButtolph/canoto/canoto encoding.go + +// OuterBlock is the top-level encoding of a Simplex block. +// It contains the inner block (the block built by the VM), +// as well as metadata created by the StateMachine. +type OuterBlock struct { + // InnerBlock is the block created by the VM, encoded as bytes and opaque to the StateMachine. + InnerBlock []byte `canoto:"bytes,1"` + // Metadata is created by the StateMachine. + Metadata StateMachineMetadata `canoto:"value,2"` + + canotoData canotoData_OuterBlock +} + +// StateMachineMetadata defines the metadata that the StateMachine uses to transition between epochs, +// and maintain ICM epoch information. +// TODO: change SimplexProtocolMetadata and SimplexBlacklist to be non-opaque types. +// TODO: This requires to encode the protocol metadata and blacklist using canoto. +type StateMachineMetadata struct { + // SimplexEpochInfo is the metadata that the StateMachine uses for its own epoching. + SimplexEpochInfo SimplexEpochInfo `canoto:"value,1"` + // SimplexProtocolMetadata is the metadata that Simplex uses for its protocol, such as sequence and round number. + SimplexProtocolMetadata []byte `canoto:"bytes,2"` + // SimplexBlacklist is the metadata that Simplex uses to keep track of blacklisted nodes. + // Blacklisted nodes do not become leaders. + SimplexBlacklist []byte `canoto:"bytes,3"` + // PChainHeight is the P-Chain height that the StateMachine sampled at the time of building the block. + // It's used for ICM epoching, not for Simplex epoching. + // For Simplex epoching, the P-Chain height that matters is the PChainReferenceHeight in the SimplexEpochInfo. + PChainHeight uint64 `canoto:"uint,4"` + // Timestamp is the time when the block is being built, in milliseconds since Unix epoch. + Timestamp uint64 `canoto:"uint,5"` + + canotoData canotoData_StateMachineMetadata +} + +// SimplexEpochInfo is metadata used by the StateMachine. +type SimplexEpochInfo struct { + // PChainReferenceHeight is the P-Chain height that the StateMachine uses as a reference for the current epoch. + // The validator set is determined based on the validators on the P-Chain at the PChainReferenceHeight. + PChainReferenceHeight uint64 `canoto:"uint,1"` + // EpochNumber is the current epoch number. + // The first epoch is numbered 1, and each successive epoch is numbered according to the block sequence + // of the sealing block of the previous epoch. + EpochNumber uint64 `canoto:"uint,2"` + // PrevSealingBlockHash is the hash of the sealing block of the previous epoch. + // It is empty for the first epoch, and the second epoch has the PrevSealingBlockHash set to be + // the hash of the first ever block built by the StateMachine. + PrevSealingBlockHash [32]byte `canoto:"fixed bytes,3"` + // NextPChainReferenceHeight is the P-Chain height that the StateMachine uses as a reference for the next epoch. + // When the NextPChainReferenceHeight is > 0, it means the StateMachine is on its way to transition to a new epoch + // in which the validator set will be based on the given P-chain height. + NextPChainReferenceHeight uint64 `canoto:"uint,4"` + // PrevVMBlockSeq is the block sequence of the previous block that has a VM block (inner block). + // This is used to know on which VM block to build the next block. + PrevVMBlockSeq uint64 `canoto:"uint,5"` + // BlockValidationDescriptor is the metadata that describes the validator set of the next epoch. + // It is only set in the sealing block, and nil in all other blocks. + BlockValidationDescriptor *BlockValidationDescriptor `canoto:"pointer,6"` + // NextEpochApprovals is the metadata that contains the approvals from validators for the next epoch. + // It is set only in the sealing block and the blocks preceding it starting from a block that has a NextPChainReferenceHeight set. + NextEpochApprovals *NextEpochApprovals `canoto:"pointer,7"` + // SealingBlockSeq is the block sequence of the sealing block of the current epoch. + // It defines the validator set of the next epoch. + SealingBlockSeq uint64 `canoto:"uint,8"` + + canotoData canotoData_SimplexEpochInfo +} + +func (sei *SimplexEpochInfo) IsZero() bool { + var zero SimplexEpochInfo + return sei.Equal(&zero) +} + +func (sei *SimplexEpochInfo) Equal(other *SimplexEpochInfo) bool { + if sei == nil { + return other == nil + } + if other == nil { + return false + } + if sei.BlockValidationDescriptor == nil && other.BlockValidationDescriptor != nil { + return false + } + if sei.BlockValidationDescriptor != nil && other.BlockValidationDescriptor == nil { + return false + } + if sei.NextEpochApprovals == nil && other.NextEpochApprovals != nil { + return false + } + if sei.NextEpochApprovals != nil && other.NextEpochApprovals == nil { + return false + } + + if sei.PChainReferenceHeight != other.PChainReferenceHeight || sei.EpochNumber != other.EpochNumber || + sei.NextPChainReferenceHeight != other.NextPChainReferenceHeight || + sei.PrevVMBlockSeq != other.PrevVMBlockSeq || sei.SealingBlockSeq != other.SealingBlockSeq { + return false + } + if !bytes.Equal(sei.PrevSealingBlockHash[:], other.PrevSealingBlockHash[:]) { + return false + } + if sei.BlockValidationDescriptor != nil && !sei.BlockValidationDescriptor.Equals(other.BlockValidationDescriptor) { + return false + } + if sei.NextEpochApprovals != nil && !sei.NextEpochApprovals.Equals(other.NextEpochApprovals) { + return false + } + return true +} + +type NodeBLSMapping struct { + NodeID nodeID `canoto:"fixed bytes,1"` + BLSKey []byte `canoto:"bytes,2"` + Weight uint64 `canoto:"uint,3"` + + canotoData canotoData_NodeBLSMapping +} + +func (nbm *NodeBLSMapping) Clone() NodeBLSMapping { + var cloned NodeBLSMapping + copy(cloned.NodeID[:], nbm.NodeID[:]) + cloned.BLSKey = make([]byte, len(nbm.BLSKey)) + copy(cloned.BLSKey, nbm.BLSKey) + cloned.Weight = nbm.Weight + return cloned +} + +func (nbm *NodeBLSMapping) Equals(other *NodeBLSMapping) bool { + if !slices.Equal(nbm.NodeID[:], other.NodeID[:]) { + return false + } + if !slices.Equal(nbm.BLSKey, other.BLSKey) { + return false + } + if nbm.Weight != other.Weight { + return false + } + return true +} + +type BlockValidationDescriptor struct { + AggregatedMembership AggregatedMembership `canoto:"value,1"` + + canotoData canotoData_BlockValidationDescriptor +} + +func (bvd *BlockValidationDescriptor) Equals(other *BlockValidationDescriptor) bool { + if bvd == nil && other == nil { + return true + } + if bvd == nil || other == nil { + return false + } + return bvd.AggregatedMembership.Equals(other.AggregatedMembership.Members) +} + +type AggregatedMembership struct { + Members []NodeBLSMapping `canoto:"repeated value,1"` + + canotoData canotoData_AggregatedMembership +} + +func (c *AggregatedMembership) Equals(members []NodeBLSMapping) bool { + if len(c.Members) != len(members) { + return false + } + + for i := range c.Members { + if !c.Members[i].Equals(&members[i]) { + return false + } + } + return true +} + +type NextEpochApprovals struct { + NodeIDs []byte `canoto:"bytes,1"` + Signature []byte `canoto:"bytes,2"` + + canotoData canotoData_NextEpochApprovals +} + +func (nea *NextEpochApprovals) Equals(other *NextEpochApprovals) bool { + if nea == nil && other == nil { + return true + } + if nea == nil || other == nil { + return false + } + if !bytes.Equal(nea.NodeIDs, other.NodeIDs) { + return false + } + if !bytes.Equal(nea.Signature, other.Signature) { + return false + } + return true +} + +type NodeBLSMappings []NodeBLSMapping + +func (nbms NodeBLSMappings) Clone() NodeBLSMappings { + cloned := make(NodeBLSMappings, len(nbms)) + for i, nbm := range nbms { + cloned[i] = nbm.Clone() + } + return cloned +} + +func (nbms NodeBLSMappings) TotalWeight() (uint64, error) { + return nbms.SumWeights(func(int, NodeBLSMapping) bool { + return true + }) +} + +func (nbms NodeBLSMappings) ForEach(selector func(int, NodeBLSMapping)) { + for i, nbm := range nbms { + selector(i, nbm) + } +} + +func (nbms NodeBLSMappings) SumWeights(selector func(int, NodeBLSMapping) bool) (uint64, error) { + var total uint64 + var err error + nbms.ForEach(func(i int, nbm NodeBLSMapping) { + if err != nil { + return + } + if selector(i, nbm) { + total, err = safeAdd(total, nbm.Weight) + } + }) + return total, err +} + +func (nbms NodeBLSMappings) Equal(other NodeBLSMappings) bool { + if len(nbms) != len(other) { + return false + } + + nbmsClone := nbms.Clone() + otherClone := other.Clone() + + slices.SortFunc(nbmsClone, func(a, b NodeBLSMapping) int { + return slices.Compare(a.NodeID[:], b.NodeID[:]) + }) + + slices.SortFunc(otherClone, func(a, b NodeBLSMapping) int { + return slices.Compare(a.NodeID[:], b.NodeID[:]) + }) + + for i := range nbmsClone { + if !nbmsClone[i].Equals(&otherClone[i]) { + return false + } + } + return true +} + +type ValidatorSetApproval struct { + NodeID nodeID `canoto:"fixed bytes,1"` + AuxInfoSeqDigest [32]byte `canoto:"fixed bytes,2"` + PChainHeight uint64 `canoto:"uint,3"` + Signature []byte `canoto:"bytes,4"` + + canotoData canotoData_ValidatorSetApproval +} + +type ValidatorSetApprovals []ValidatorSetApproval + +func (vsa ValidatorSetApprovals) ForEach(f func(int, ValidatorSetApproval)) { + for i, v := range vsa { + f(i, v) + } +} + +func (vsa ValidatorSetApprovals) Filter(f func(int, ValidatorSetApproval) bool) ValidatorSetApprovals { + result := make(ValidatorSetApprovals, 0, len(vsa)) + vsa.ForEach(func(i int, v ValidatorSetApproval) { + if f(i, v) { + result = append(result, v) + } + }) + return result +} + +func (vsa ValidatorSetApprovals) UniqueByNodeID() ValidatorSetApprovals { + seen := make(map[nodeID]struct{}) + result := make(ValidatorSetApprovals, 0, len(vsa)) + vsa.ForEach(func(i int, v ValidatorSetApproval) { + if _, exists := seen[v.NodeID]; !exists { + seen[v.NodeID] = struct{}{} + result = append(result, v) + } + }) + return result +} diff --git a/msm/encoding_test.go b/msm/encoding_test.go new file mode 100644 index 00000000..7a5d88be --- /dev/null +++ b/msm/encoding_test.go @@ -0,0 +1,514 @@ +// Copyright (C) 2019-2025, Ava Labs, Inc. All rights reserved. +// See the file LICENSE for licensing terms. + +package metadata + +import ( + "math" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestSimplexEpochInfoIsZero(t *testing.T) { + require.True(t, (&SimplexEpochInfo{}).IsZero()) + require.False(t, (&SimplexEpochInfo{EpochNumber: 1}).IsZero()) + require.False(t, (&SimplexEpochInfo{PChainReferenceHeight: 1}).IsZero()) +} + +func TestSimplexEpochInfoEqual(t *testing.T) { + hash1 := [32]byte{1, 2, 3} + hash2 := [32]byte{4, 5, 6} + + tests := []struct { + name string + a *SimplexEpochInfo + b *SimplexEpochInfo + expected bool + }{ + { + name: "first nil second nil", + a: nil, + b: nil, + expected: true, + }, + { + name: "first non-nil second nil", + a: &SimplexEpochInfo{}, + b: nil, + expected: false, + }, + { + name: "first nil second non-nil (via nil receiver)", + a: nil, + b: &SimplexEpochInfo{}, + expected: false, + }, + { + name: "both zero", + a: &SimplexEpochInfo{}, + b: &SimplexEpochInfo{}, + expected: true, + }, + { + name: "equal with all fields", + a: &SimplexEpochInfo{ + PChainReferenceHeight: 10, + EpochNumber: 2, + PrevSealingBlockHash: hash1, + NextPChainReferenceHeight: 20, + PrevVMBlockSeq: 5, + SealingBlockSeq: 15, + }, + b: &SimplexEpochInfo{ + PChainReferenceHeight: 10, + EpochNumber: 2, + PrevSealingBlockHash: hash1, + NextPChainReferenceHeight: 20, + PrevVMBlockSeq: 5, + SealingBlockSeq: 15, + }, + expected: true, + }, + { + name: "different PChainReferenceHeight", + a: &SimplexEpochInfo{PChainReferenceHeight: 1}, + b: &SimplexEpochInfo{PChainReferenceHeight: 2}, + expected: false, + }, + { + name: "different EpochNumber", + a: &SimplexEpochInfo{EpochNumber: 1}, + b: &SimplexEpochInfo{EpochNumber: 2}, + expected: false, + }, + { + name: "different PrevSealingBlockHash", + a: &SimplexEpochInfo{PrevSealingBlockHash: hash1}, + b: &SimplexEpochInfo{PrevSealingBlockHash: hash2}, + expected: false, + }, + { + name: "different NextPChainReferenceHeight", + a: &SimplexEpochInfo{NextPChainReferenceHeight: 1}, + b: &SimplexEpochInfo{NextPChainReferenceHeight: 2}, + expected: false, + }, + { + name: "different PrevVMBlockSeq", + a: &SimplexEpochInfo{PrevVMBlockSeq: 1}, + b: &SimplexEpochInfo{PrevVMBlockSeq: 2}, + expected: false, + }, + { + name: "different SealingBlockSeq", + a: &SimplexEpochInfo{SealingBlockSeq: 1}, + b: &SimplexEpochInfo{SealingBlockSeq: 2}, + expected: false, + }, + { + name: "with BlockValidationDescriptor equal", + a: &SimplexEpochInfo{ + BlockValidationDescriptor: &BlockValidationDescriptor{ + AggregatedMembership: AggregatedMembership{ + Members: []NodeBLSMapping{{NodeID: nodeID{1}, Weight: 10}}, + }, + }, + }, + b: &SimplexEpochInfo{ + BlockValidationDescriptor: &BlockValidationDescriptor{ + AggregatedMembership: AggregatedMembership{ + Members: []NodeBLSMapping{{NodeID: nodeID{1}, Weight: 10}}, + }, + }, + }, + expected: true, + }, + { + name: "with BlockValidationDescriptor different", + a: &SimplexEpochInfo{ + BlockValidationDescriptor: &BlockValidationDescriptor{ + AggregatedMembership: AggregatedMembership{ + Members: []NodeBLSMapping{{NodeID: nodeID{1}, Weight: 10}}, + }, + }, + }, + b: &SimplexEpochInfo{ + BlockValidationDescriptor: &BlockValidationDescriptor{ + AggregatedMembership: AggregatedMembership{ + Members: []NodeBLSMapping{{NodeID: nodeID{2}, Weight: 20}}, + }, + }, + }, + expected: false, + }, + { + name: "with NextEpochApprovals equal", + a: &SimplexEpochInfo{ + NextEpochApprovals: &NextEpochApprovals{NodeIDs: []byte{1, 2}, Signature: []byte{3, 4}}, + }, + b: &SimplexEpochInfo{ + NextEpochApprovals: &NextEpochApprovals{NodeIDs: []byte{1, 2}, Signature: []byte{3, 4}}, + }, + expected: true, + }, + { + name: "with NextEpochApprovals different", + a: &SimplexEpochInfo{ + NextEpochApprovals: &NextEpochApprovals{NodeIDs: []byte{1, 2}, Signature: []byte{3, 4}}, + }, + b: &SimplexEpochInfo{ + NextEpochApprovals: &NextEpochApprovals{NodeIDs: []byte{5, 6}, Signature: []byte{7, 8}}, + }, + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + require.Equal(t, tt.expected, tt.a.Equal(tt.b)) + }) + } +} + +func TestNodeBLSMappingEquals(t *testing.T) { + tests := []struct { + name string + a NodeBLSMapping + b NodeBLSMapping + expected bool + }{ + { + name: "both zero", + expected: true, + }, + { + name: "equal with values", + a: NodeBLSMapping{NodeID: nodeID{1, 2, 3}, BLSKey: []byte{4, 5}, Weight: 100}, + b: NodeBLSMapping{NodeID: nodeID{1, 2, 3}, BLSKey: []byte{4, 5}, Weight: 100}, + expected: true, + }, + { + name: "different NodeID", + a: NodeBLSMapping{NodeID: nodeID{1}}, + b: NodeBLSMapping{NodeID: nodeID{2}}, + expected: false, + }, + { + name: "different BLSKey", + a: NodeBLSMapping{BLSKey: []byte{1}}, + b: NodeBLSMapping{BLSKey: []byte{2}}, + expected: false, + }, + { + name: "different Weight", + a: NodeBLSMapping{Weight: 1}, + b: NodeBLSMapping{Weight: 2}, + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + require.Equal(t, tt.expected, tt.a.Equals(&tt.b)) + }) + } +} + +func TestBlockValidationDescriptorEquals(t *testing.T) { + tests := []struct { + name string + a *BlockValidationDescriptor + b *BlockValidationDescriptor + expected bool + }{ + { + name: "both nil", + expected: true, + }, + { + name: "first nil second non-nil", + b: &BlockValidationDescriptor{}, + expected: false, + }, + { + name: "first non-nil second nil", + a: &BlockValidationDescriptor{}, + expected: false, + }, + { + name: "equal members", + a: &BlockValidationDescriptor{ + AggregatedMembership: AggregatedMembership{ + Members: []NodeBLSMapping{{NodeID: nodeID{1}, Weight: 10}}, + }, + }, + b: &BlockValidationDescriptor{ + AggregatedMembership: AggregatedMembership{ + Members: []NodeBLSMapping{{NodeID: nodeID{1}, Weight: 10}}, + }, + }, + expected: true, + }, + { + name: "different members", + a: &BlockValidationDescriptor{ + AggregatedMembership: AggregatedMembership{ + Members: []NodeBLSMapping{{NodeID: nodeID{1}, Weight: 10}}, + }, + }, + b: &BlockValidationDescriptor{ + AggregatedMembership: AggregatedMembership{ + Members: []NodeBLSMapping{{NodeID: nodeID{2}, Weight: 20}}, + }, + }, + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + require.Equal(t, tt.expected, tt.a.Equals(tt.b)) + }) + } +} + +func TestAggregatedMembershipEquals(t *testing.T) { + tests := []struct { + name string + members []NodeBLSMapping + other []NodeBLSMapping + expected bool + }{ + { + name: "both empty", + expected: true, + }, + { + name: "different lengths", + members: []NodeBLSMapping{{Weight: 1}}, + expected: false, + }, + { + name: "equal", + members: []NodeBLSMapping{{NodeID: nodeID{1}, BLSKey: []byte{2}, Weight: 3}}, + other: []NodeBLSMapping{{NodeID: nodeID{1}, BLSKey: []byte{2}, Weight: 3}}, + expected: true, + }, + { + name: "different", + members: []NodeBLSMapping{{NodeID: nodeID{1}}}, + other: []NodeBLSMapping{{NodeID: nodeID{2}}}, + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + am := &AggregatedMembership{Members: tt.members} + require.Equal(t, tt.expected, am.Equals(tt.other)) + }) + } +} + +func TestNextEpochApprovalsEquals(t *testing.T) { + tests := []struct { + name string + a *NextEpochApprovals + b *NextEpochApprovals + expected bool + }{ + { + name: "both nil", + expected: true, + }, + { + name: "first nil second non-nil", + b: &NextEpochApprovals{}, + expected: false, + }, + { + name: "first non-nil second nil", + a: &NextEpochApprovals{}, + expected: false, + }, + { + name: "equal", + a: &NextEpochApprovals{NodeIDs: []byte{1, 2}, Signature: []byte{3, 4}}, + b: &NextEpochApprovals{NodeIDs: []byte{1, 2}, Signature: []byte{3, 4}}, + expected: true, + }, + { + name: "different NodeIDs", + a: &NextEpochApprovals{NodeIDs: []byte{1}}, + b: &NextEpochApprovals{NodeIDs: []byte{2}}, + expected: false, + }, + { + name: "different Signature", + a: &NextEpochApprovals{Signature: []byte{1}}, + b: &NextEpochApprovals{Signature: []byte{2}}, + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + require.Equal(t, tt.expected, tt.a.Equals(tt.b)) + }) + } +} + +func TestNodeBLSMappingsTotalWeight(t *testing.T) { + tests := []struct { + name string + mappings NodeBLSMappings + expected uint64 + expectError bool + }{ + { + name: "empty", + expected: 0, + }, + { + name: "single", + mappings: NodeBLSMappings{{Weight: 42}}, + expected: 42, + }, + { + name: "multiple", + mappings: NodeBLSMappings{{Weight: 10}, {Weight: 20}, {Weight: 30}}, + expected: 60, + }, + { + name: "overflow", + mappings: NodeBLSMappings{{Weight: math.MaxUint64}, {Weight: 1}}, + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + total, err := tt.mappings.TotalWeight() + if tt.expectError { + require.Error(t, err) + } else { + require.NoError(t, err) + require.Equal(t, tt.expected, total) + } + }) + } +} + +func TestNodeBLSMappingsSumWeights(t *testing.T) { + mappings := NodeBLSMappings{ + {NodeID: nodeID{1}, Weight: 10}, + {NodeID: nodeID{2}, Weight: 20}, + {NodeID: nodeID{3}, Weight: 30}, + } + + // Select only even indices + total, err := mappings.SumWeights(func(i int, _ NodeBLSMapping) bool { + return i%2 == 0 + }) + require.NoError(t, err) + require.Equal(t, uint64(40), total) // index 0 (10) + index 2 (30) + + // Select none + total, err = mappings.SumWeights(func(int, NodeBLSMapping) bool { + return false + }) + require.NoError(t, err) + require.Equal(t, uint64(0), total) +} + +func TestNodeBLSMappingsForEach(t *testing.T) { + mappings := NodeBLSMappings{ + {Weight: 1}, + {Weight: 2}, + {Weight: 3}, + } + + var visited []uint64 + mappings.ForEach(func(_ int, nbm NodeBLSMapping) { + visited = append(visited, nbm.Weight) + }) + require.Equal(t, []uint64{1, 2, 3}, visited) +} + +func TestNodeBLSMappingsCompare(t *testing.T) { + tests := []struct { + name string + a NodeBLSMappings + b NodeBLSMappings + expected bool + }{ + { + name: "both nil", + expected: true, + }, + { + name: "different lengths", + a: NodeBLSMappings{{Weight: 1}}, + expected: false, + }, + { + name: "equal same order", + a: NodeBLSMappings{{NodeID: nodeID{1}, Weight: 10}, {NodeID: nodeID{2}, Weight: 20}}, + b: NodeBLSMappings{{NodeID: nodeID{1}, Weight: 10}, {NodeID: nodeID{2}, Weight: 20}}, + expected: true, + }, + { + name: "equal different order", + a: NodeBLSMappings{{NodeID: nodeID{2}, Weight: 20}, {NodeID: nodeID{1}, Weight: 10}}, + b: NodeBLSMappings{{NodeID: nodeID{1}, Weight: 10}, {NodeID: nodeID{2}, Weight: 20}}, + expected: true, + }, + { + name: "different values", + a: NodeBLSMappings{{NodeID: nodeID{1}, Weight: 10}}, + b: NodeBLSMappings{{NodeID: nodeID{1}, Weight: 99}}, + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + require.Equal(t, tt.expected, tt.a.Equal(tt.b)) + }) + } +} + +func TestValidatorSetApprovalsForEach(t *testing.T) { + approvals := ValidatorSetApprovals{ + {NodeID: nodeID{1}, PChainHeight: 10}, + {NodeID: nodeID{2}, PChainHeight: 20}, + } + + var heights []uint64 + approvals.ForEach(func(_ int, v ValidatorSetApproval) { + heights = append(heights, v.PChainHeight) + }) + require.Equal(t, []uint64{10, 20}, heights) +} + +func TestValidatorSetApprovalsFilter(t *testing.T) { + approvals := ValidatorSetApprovals{ + {NodeID: nodeID{1}, PChainHeight: 10}, + {NodeID: nodeID{2}, PChainHeight: 20}, + {NodeID: nodeID{3}, PChainHeight: 30}, + } + + filtered := approvals.Filter(func(_ int, v ValidatorSetApproval) bool { + return v.PChainHeight > 15 + }) + require.Len(t, filtered, 2) + require.Equal(t, uint64(20), filtered[0].PChainHeight) + require.Equal(t, uint64(30), filtered[1].PChainHeight) + + // Filter all + filtered = approvals.Filter(func(int, ValidatorSetApproval) bool { + return false + }) + require.Empty(t, filtered) +} diff --git a/msm/misc.go b/msm/misc.go new file mode 100644 index 00000000..08a4a47d --- /dev/null +++ b/msm/misc.go @@ -0,0 +1,122 @@ +// Copyright (C) 2019-2025, Ava Labs, Inc. All rights reserved. +// See the file LICENSE for licensing terms. + +package metadata + +import ( + "context" + "fmt" + "math" + "math/big" + "time" + + "go.uber.org/zap" +) + +// This file contains implementations of utility methods and structures that exists in Avalanchego, +// but are not imported here to prevent us from importing the entire Avalanchego codebase. +// Once we incorporate Simplex into Avalanchego, we can remove this file and import the relevant code from Avalanchego instead. + +func safeAdd(a, b uint64) (uint64, error) { + if a > math.MaxUint64-b { + return 0, fmt.Errorf("overflow: %d + %d > maxuint64", a, b) + } + return a + b, nil +} + +type nodeID [20]byte + +type VMBlock interface { + // Digest returns a succinct representation of this block. + Digest() [32]byte + + // Height returns the height of this block in the chain. + Height() uint64 + + // Time this block was proposed at. This value should be consistent across + // all nodes. If this block hasn't been successfully verified, any value can + // be returned. If this block is the last accepted block, the timestamp must + // be returned correctly. Otherwise, accepted blocks can return any value. + Timestamp() time.Time + + // Verify that the state transition this block would make if accepted is + // valid. If the state transition is invalid, a non-nil error should be + // returned. + // + // It is guaranteed that the Parent has been successfully verified. + // + // If nil is returned, it is guaranteed that either Accept or Reject will be + // called on this block, unless the VM is shut down. + Verify(context.Context) error +} + +type UpgradeConfig = any + +type bitmask big.Int + +func (bm *bitmask) Bytes() []byte { + return (*big.Int)(bm).Bytes() +} + +func (bm *bitmask) Contains(i int) bool { + return (*big.Int)(bm).Bit(i) == 1 +} + +func (bm *bitmask) Add(i int) { + bits := (*big.Int)(bm) + bits.SetBit(bits, i, 1) +} + +func (bm *bitmask) Difference(bm2 *bitmask) { + bits := (*big.Int)(bm) + bits2 := (*big.Int)(bm2) + bits.AndNot(bits, bits2) +} + +func (bm *bitmask) Len() int { + bmAsBigInt := (*big.Int)(bm) + bits := new(big.Int).Set(bmAsBigInt) + + result := 0 + var zero big.Int + for bits.Cmp(&zero) > 0 { + lsb := bits.Bit(0) + if lsb == 1 { + result++ + } + bits.Rsh(bits, 1) + } + return result +} + +func bitmaskFromBytes(bytes []byte) bitmask { + var bm bitmask + (*big.Int)(&bm).SetBytes(bytes) + return bm +} + +// Logger defines the interface that is used to keep a record of all events that +// happen to the program +type Logger interface { + // Log that a fatal error has occurred. The program should likely exit soon + // after this is called + Fatal(msg string, fields ...zap.Field) + // Log that an error has occurred. The program should be able to recover + // from this error + Error(msg string, fields ...zap.Field) + // Log that an event has occurred that may indicate a future error or + // vulnerability + Warn(msg string, fields ...zap.Field) + // Log an event that may be useful for a user to see to measure the progress + // of the protocol + Info(msg string, fields ...zap.Field) + // Log an event that may be useful for understanding the order of the + // execution of the protocol + Trace(msg string, fields ...zap.Field) + // Log an event that may be useful for a programmer to see when debugging the + // execution of the protocol + Debug(msg string, fields ...zap.Field) + // Log extremely detailed events that can be useful for inspecting every + // aspect of the program + Verbo(msg string, fields ...zap.Field) +} diff --git a/msm/misc_test.go b/msm/misc_test.go new file mode 100644 index 00000000..25d87969 --- /dev/null +++ b/msm/misc_test.go @@ -0,0 +1,123 @@ +// Copyright (C) 2019-2025, Ava Labs, Inc. All rights reserved. +// See the file LICENSE for licensing terms. + +package metadata + +import ( + "math" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestSafeAdd(t *testing.T) { + for _, tc := range []struct { + name string + a, b uint64 + sum uint64 + err string + }{ + { + name: "zero plus zero", + a: 0, b: 0, + sum: 0, + }, + { + name: "normal addition", + a: 10, b: 20, + sum: 30, + }, + { + name: "max uint64 plus zero", + a: math.MaxUint64, b: 0, + sum: math.MaxUint64, + }, + { + name: "zero plus max uint64", + a: 0, b: math.MaxUint64, + sum: math.MaxUint64, + }, + { + name: "overflow by one", + a: math.MaxUint64, b: 1, + err: "overflow", + }, + { + name: "overflow both large", + a: math.MaxUint64 - 5, b: 10, + err: "overflow", + }, + { + name: "max uint64 boundary no overflow", + a: math.MaxUint64 - 5, b: 5, + sum: math.MaxUint64, + }, + } { + t.Run(tc.name, func(t *testing.T) { + result, err := safeAdd(tc.a, tc.b) + if tc.err != "" { + require.ErrorContains(t, err, tc.err) + } else { + require.NoError(t, err) + require.Equal(t, tc.sum, result) + } + }) + } +} + +func TestBitmask(t *testing.T) { + t.Run("empty bitmask", func(t *testing.T) { + bm := bitmaskFromBytes(nil) + require.Equal(t, 0, bm.Len()) + require.False(t, bm.Contains(0)) + require.False(t, bm.Contains(5)) + }) + + t.Run("from bytes and Contains", func(t *testing.T) { + // 0b00000111 = 7 → bits 0, 1, 2 are set + bm := bitmaskFromBytes([]byte{7}) + require.True(t, bm.Contains(0)) + require.True(t, bm.Contains(1)) + require.True(t, bm.Contains(2)) + require.False(t, bm.Contains(3)) + require.Equal(t, 3, bm.Len()) + }) + + t.Run("Add", func(t *testing.T) { + bm := bitmaskFromBytes([]byte{1}) // bit 0 + require.True(t, bm.Contains(0)) + require.False(t, bm.Contains(3)) + + bm.Add(3) + require.True(t, bm.Contains(3)) + require.Equal(t, 2, bm.Len()) + }) + + t.Run("Bytes round-trip", func(t *testing.T) { + bm := bitmaskFromBytes([]byte{0xAB}) + bm2 := bitmaskFromBytes(bm.Bytes()) + require.Equal(t, bm.Len(), bm2.Len()) + for i := 0; i < 8; i++ { + require.Equal(t, bm.Contains(i), bm2.Contains(i)) + } + }) + + t.Run("Difference", func(t *testing.T) { + // bm1 = bits 0,1,2 (0b111 = 7) + // bm2 = bits 0,1 (0b011 = 3) + // bm1.Difference(bm2) should leave only bit 2 + bm1 := bitmaskFromBytes([]byte{7}) + bm2 := bitmaskFromBytes([]byte{3}) + bm1.Difference(&bm2) + require.False(t, bm1.Contains(0)) + require.False(t, bm1.Contains(1)) + require.True(t, bm1.Contains(2)) + require.Equal(t, 1, bm1.Len()) + }) + + t.Run("Len with multiple bytes", func(t *testing.T) { + // 0xFF = 8 bits set, 0x01 = 1 bit set → 9 total + bm := bitmaskFromBytes([]byte{0x01, 0xFF}) + require.Equal(t, 9, bm.Len()) + }) +} From 965141c0a758974ee4c6ca379d2b21d6261dd1a2 Mon Sep 17 00:00:00 2001 From: Yacov Manevich Date: Thu, 16 Apr 2026 18:38:38 +0200 Subject: [PATCH 02/25] Simplex reconfiguration framework - Part II (more helpers and definitions) - Add some structs and interface definitions - Add some utility methods to be used in the next commit. Signed-off-by: Yacov Manevich --- msm/misc.go | 6 + msm/misc_test.go | 21 ++ msm/msm.go | 353 +++++++++++++++++++++++++++++++++ msm/msm_test.go | 498 +++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 878 insertions(+) create mode 100644 msm/msm.go create mode 100644 msm/msm_test.go diff --git a/msm/misc.go b/msm/misc.go index 08a4a47d..8b74cd73 100644 --- a/msm/misc.go +++ b/msm/misc.go @@ -58,6 +58,12 @@ func (bm *bitmask) Bytes() []byte { return (*big.Int)(bm).Bytes() } +func (bm *bitmask) Clone() *bitmask { + var newBM bitmask + (*big.Int)(&newBM).Set((*big.Int)(bm)) + return &newBM +} + func (bm *bitmask) Contains(i int) bool { return (*big.Int)(bm).Bit(i) == 1 } diff --git a/msm/misc_test.go b/msm/misc_test.go index 25d87969..b899aa6a 100644 --- a/msm/misc_test.go +++ b/msm/misc_test.go @@ -120,4 +120,25 @@ func TestBitmask(t *testing.T) { bm := bitmaskFromBytes([]byte{0x01, 0xFF}) require.Equal(t, 9, bm.Len()) }) + + t.Run("Clone produces independent copy", func(t *testing.T) { + bm := bitmaskFromBytes([]byte{7}) // bits 0,1,2 + cloned := bm.Clone() + + // Clone matches original + require.Equal(t, bm.Len(), cloned.Len()) + for i := 0; i < 3; i++ { + require.Equal(t, bm.Contains(i), cloned.Contains(i)) + } + + // Mutating clone does not affect original + cloned.Add(5) + require.True(t, cloned.Contains(5)) + require.False(t, bm.Contains(5)) + + // Mutating original does not affect clone + bm.Add(7) + require.True(t, bm.Contains(7)) + require.False(t, cloned.Contains(7)) + }) } diff --git a/msm/msm.go b/msm/msm.go new file mode 100644 index 00000000..a14871d5 --- /dev/null +++ b/msm/msm.go @@ -0,0 +1,353 @@ +// Copyright (C) 2019-2025, Ava Labs, Inc. All rights reserved. +// See the file LICENSE for licensing terms. + +package metadata + +import ( + "crypto/sha256" + "errors" + "fmt" + "math" + "sort" + + "github.com/ava-labs/simplex" +) + +// A StateMachineBlock is a representation of a parsed OuterBlock, containing the inner block and the metadata. +type StateMachineBlock struct { + // InnerBlock is the VM-level block, or nil if this is a block without an inner block (e.g., a Telock block). + InnerBlock VMBlock + // Metadata contains the state machine metadata associated with this block. + Metadata StateMachineMetadata +} + +// Digest returns the SHA-256 hash of the combined inner block digest and metadata digest. +func (smb *StateMachineBlock) Digest() [32]byte { + var blockDigest [32]byte + if smb.InnerBlock != nil { + blockDigest = smb.InnerBlock.Digest() + } else { + blockDigest = [32]byte{} + } + mdDigest := sha256.Sum256(smb.Metadata.MarshalCanoto()) + combined := make([]byte, 64) + copy(combined[:32], blockDigest[:]) + copy(combined[32:], mdDigest[:]) + return sha256.Sum256(combined) +} + +// ApprovalsRetriever retrieves the approvals from validators of the next epoch for the epoch change. +type ApprovalsRetriever interface { + RetrieveApprovals() ValidatorSetApprovals +} + +// SignatureVerifier verifies a cryptographic signature against a message and public key. +// Used to verify Approvals from validators for epoch transitions. +type SignatureVerifier interface { + VerifySignature(signature []byte, message []byte, publicKey []byte) error +} + +// SignatureAggregator combines multiple cryptographic signatures into a single aggregated signature. +// Used to aggregate validator signatures for epoch transitions. +type SignatureAggregator interface { + AggregateSignatures(signatures ...[]byte) ([]byte, error) +} + +// KeyAggregator combines multiple public keys into a single aggregated public key. +type KeyAggregator interface { + AggregateKeys(keys ...[]byte) ([]byte, error) +} + +// ValidatorSetRetriever retrieves the validator set at a given P-chain height. +type ValidatorSetRetriever func(pChainHeight uint64) (NodeBLSMappings, error) + +// RetrievingOpts specifies the options for retrieving a block by height and/or digest. +type RetrievingOpts struct { + // Height is the sequence number of the block to retrieve. + Height uint64 + // Digest is the expected hash of the block, used for validation. + Digest [32]byte +} + +// BlockRetriever retrieves a block and its finalization status given the retrieval options. +// If the block cannot be found it returns ErrBlockNotFound. +// If an error occurs during retrieval, it returns a non-nil error. +type BlockRetriever func(RetrievingOpts) (StateMachineBlock, *simplex.Finalization, error) + +type state uint8 + +const ( + stateFirstSimplexBlock state = iota + stateBuildBlockNormalOp + stateBuildCollectingApprovals + stateBuildBlockEpochSealed +) + +type BlockType uint8 + +const ( + BlockTypeNormal BlockType = iota + BlockTypeTelock + BlockTypeSealing + BlockTypeNewEpoch +) + +func (state BlockType) String() string { + switch state { + case BlockTypeNormal: + return "Normal" + case BlockTypeTelock: + return "Telock" + case BlockTypeSealing: + return "Sealing" + case BlockTypeNewEpoch: + return "NewEpoch" + default: + return fmt.Sprintf("UnknownBlockType(%d)", state) + } +} + +func identifyCurrentState(prevBlockSimplexEpochInfo SimplexEpochInfo) (state, error) { + // If this is the first ever epoch, then this is also the first ever block to be built by Simplex. + if prevBlockSimplexEpochInfo.EpochNumber == 0 { + return stateFirstSimplexBlock, nil + } + + // If we don't have a next P-chain preference height, it means we are not transitioning to a new epoch just yet. + if prevBlockSimplexEpochInfo.NextPChainReferenceHeight == 0 { + return stateBuildBlockNormalOp, nil + } + + // If the previous block has a sealing block sequence, it's a Telock. + // If it has a block validation descriptor, it's a sealing block. + // Eithe way, the epoch has been sealed. + if prevBlockSimplexEpochInfo.SealingBlockSeq > 0 || prevBlockSimplexEpochInfo.BlockValidationDescriptor != nil { + return stateBuildBlockEpochSealed, nil + } + + // In any other case, NextPChainReferenceHeight > 0 but the previous block is not a Telock or sealing block, + // it means we are in the process of collecting approvals for the next epoch. + return stateBuildCollectingApprovals, nil +} + +func IdentifyBlockType(nextBlockMD StateMachineMetadata, prevBlockMD StateMachineMetadata, prevSeq uint64) BlockType { + simplexEpochInfo := nextBlockMD.SimplexEpochInfo + prevSimplexEpochInfo := prevBlockMD.SimplexEpochInfo + + // Only sealing blocks carry block validation descriptors + if nextBlockMD.SimplexEpochInfo.BlockValidationDescriptor != nil { + return BlockTypeSealing + } + + // This block could be in the edges of an epoch, either at the end or at the beginning. + + // If the new block comes after a sealing block, it could be a Telock or the first block of the next epoch. + // [ Sealing Block ] <-- [ New Block ] + if prevSimplexEpochInfo.BlockValidationDescriptor != nil { + // The zero-epoch block has BlockValidationDescriptor but epoch number 1 and next P-chain reference height of 0., + // so the block following it is a normal block, not a Telock. + if prevSimplexEpochInfo.EpochNumber == 1 && prevSimplexEpochInfo.NextPChainReferenceHeight == 0 { + return BlockTypeNormal + } + + if simplexEpochInfo.EpochNumber == prevSeq { + // If the epoch number of the new block is the same as the previous block's sequence number, + // it means we have just transitioned to a new epoch as the previous block was a sealing block. + return BlockTypeNewEpoch + } + + // Otherwise, we haven't transitioned to a new epoch yet, so this block has to be a Telock, + // as after a sealing block we either have a Telock or the first block of the new epoch, + // and we have already ruled out the first block of the new epoch in the previous condition. + return BlockTypeTelock + } + + // Else, if the previous block has a sealing block sequence and is in the same epoch as this block, + // then this block has to be a Telock, as the sealing block sequence indicates that the sealing block has been created. + // [ Sealing Block ] <-- [ Prev block ] <-- [ New Block ] + if simplexEpochInfo.EpochNumber == prevSimplexEpochInfo.EpochNumber && prevSimplexEpochInfo.SealingBlockSeq != 0 { + return BlockTypeTelock + } + + // This block is the first block of its epoch if the epoch number is the sealing block sequence of the previous epoch + if simplexEpochInfo.EpochNumber == prevSimplexEpochInfo.SealingBlockSeq { + return BlockTypeNewEpoch + } + + // Otherwise, we do not fall into any of these cases, so it's a block in the middle of the epoch, + // not in the edges. + return BlockTypeNormal +} + +// computeNewApproverSignaturesAndSigners computes the signatures of the nodes that approve the next epoch including the previous aggregated signature, +// and bitmask of nodes that correspond to those signatures, and aggregates all signatures together. +func computeNewApproverSignaturesAndSigners(nextEpochApprovals *NextEpochApprovals, approvalsFromPeers ValidatorSetApprovals, oldApprovingNodes bitmask, nodeID2ValidatorIndex map[nodeID]int, aggregator SignatureAggregator) ([]byte, bitmask, error) { + // Prepare the new signatures from the new approvals that haven't approved yet and that agree with our candidate auxiliary info digest and P-Chain height. + newSignatures := make([][]byte, 0, len(approvalsFromPeers)+1) + + // We will overwrite the old approving nodes with the new approving nodes, by turning on the bits for the new approvers. + newApprovingNodes := oldApprovingNodes.Clone() + + approvalsFromPeers.ForEach(func(i int, approval ValidatorSetApproval) { + approvingNodeIndexOfNewApprover, exists := nodeID2ValidatorIndex[approval.NodeID] + if !exists { + // This should not happen, because we have already filtered approvals that are not in the validator set, but we check just in case. + return + } + // Turn on the bit for the new approver + newApprovingNodes.Add(approvingNodeIndexOfNewApprover) + newSignatures = append(newSignatures, approval.Signature) + }) + + // Add the existing signature into the list of signatures to aggregate + existingSignature := nextEpochApprovals.Signature + if existingSignature != nil { + newSignatures = append(newSignatures, existingSignature) + } + + // Finally, we aggregate all signatures together, to compute the new aggregated signature. + aggregatedSignature, err := aggregator.AggregateSignatures(newSignatures...) + if err != nil { + return nil, bitmask{}, fmt.Errorf("failed to aggregate signatures: %w", err) + } + + return aggregatedSignature, *newApprovingNodes, nil +} + +// sanitizeApprovals filters out approvals that are not valid by checking if they agree with our candidate auxiliary info digest and P-Chain height, +// and if they are from the validator set and haven't already been approved. +func sanitizeApprovals(approvals ValidatorSetApprovals, pChainHeight uint64, nodeID2ValidatorIndex map[nodeID]int, oldApprovingNodes bitmask) ValidatorSetApprovals { + filter1 := approvalsThatAgreeWithAuxInfoAndPChainHeight(pChainHeight) + filter2 := approvalsThatAreInValidatorSetAndHaveNotAlreadyApproved(oldApprovingNodes.Clone(), nodeID2ValidatorIndex) + return approvals.Filter(filter1).Filter(filter2).UniqueByNodeID() +} + +func approvalsThatAgreeWithAuxInfoAndPChainHeight(pChainHeight uint64) func(i int, approval ValidatorSetApproval) bool { + return func(i int, approval ValidatorSetApproval) bool { + // Pick only approvals that agree with our candidate auxiliary info digest and P-Chain height + return approval.PChainHeight == pChainHeight + } +} + +func approvalsThatAreInValidatorSetAndHaveNotAlreadyApproved(oldApprovingNodes *bitmask, nodeID2ValidatorIndex map[nodeID]int) func(i int, approval ValidatorSetApproval) bool { + return func(i int, approval ValidatorSetApproval) bool { + approvingNodeIndexOfNewApprover, exists := nodeID2ValidatorIndex[approval.NodeID] + if !exists { + // If the approving node is not in the validator set, we ignore this approval. + return false + } + // Only pick approvals from nodes that haven't already approved + return !oldApprovingNodes.Contains(approvingNodeIndexOfNewApprover) + } +} + +func computeApprovingWeight(validators NodeBLSMappings, approvingNodes *bitmask) (int64, error) { + var approvingWeight uint64 + var err error + validators.ForEach(func(i int, nbm NodeBLSMapping) { + if err != nil { + return + } + if !approvingNodes.Contains(i) { + return + } + approvingWeight, err = safeAdd(approvingWeight, nbm.Weight) + }) + + if err != nil { + return 0, fmt.Errorf("failed to compute approving weights: %w", err) + } + + if approvingWeight > math.MaxInt64 { + return 0, fmt.Errorf("approving weight of validators is too big, overflows int64: %d", approvingWeight) + } + + return int64(approvingWeight), nil +} + +func computeTotalWeight(validators NodeBLSMappings) (int64, error) { + totalWeight, err := validators.TotalWeight() + if err != nil { + return 0, fmt.Errorf("failed to sum weights of all nodes: %w", err) + } + + if totalWeight == 0 { + return 0, fmt.Errorf("total weight of validators is 0") + } + + if totalWeight > math.MaxInt64 { + return 0, fmt.Errorf("total weight of validators is too big, overflows int64: %d", totalWeight) + } + return int64(totalWeight), nil +} +func findFirstSimplexBlock(getBlock BlockRetriever, endHeight uint64) (uint64, error) { + var haltError error + + // Make sure the bound passed to sort.Search is valid, to avoid illegal input caused by overflow. + if endHeight >= math.MaxInt { + endHeight = math.MaxInt - 1 + } + + firstSimplexBlock := sort.Search(int(endHeight+1), func(i int) bool { + if haltError != nil { + return true + } + block, _, err := getBlock(RetrievingOpts{Height: uint64(i)}) + if errors.Is(err, simplex.ErrBlockNotFound) { + return false + } + if err != nil { + haltError = fmt.Errorf("error retrieving block at height %d: %w", i, err) + return false + } + // The first Simplex block is such that its epoch info isn't the zero value. + return !block.Metadata.SimplexEpochInfo.IsZero() + }) + if haltError != nil { + return 0, haltError + } + + if uint64(firstSimplexBlock) > endHeight { + return 0, fmt.Errorf("no simplex blocks found in range [%d, %d]", 0, endHeight) + } + + return uint64(firstSimplexBlock), nil +} + +func computePrevVMBlockSeq(parentBlock StateMachineBlock, prevBlockSeq uint64) uint64 { + // Either our parent block has no inner block, in which case we just inherit its previous VM block sequence, + if parentBlock.InnerBlock == nil { + return parentBlock.Metadata.SimplexEpochInfo.PrevVMBlockSeq + } + // or it has an inner block, in which case it is the previous block sequence. + return prevBlockSeq +} + +type approvals struct { + canSeal bool + nodeIDs []byte + signature []byte +} + +func ensureNextEpochApprovalsSignersSupersetOfApprovalsOfPrevBlock(prev SimplexEpochInfo, next SimplexEpochInfo) error { + if prev.NextEpochApprovals == nil { + // Condition satisifed vacously. + return nil + } + // Else, prev.NextEpochApprovals is not nil. + // If next.NextEpochApprovals is nil, condition is not satisfied. + if next.NextEpochApprovals == nil { + return fmt.Errorf("previous block has next epoch approvals but proposed block doesn't have next epoch approvals") + } + + // Make sure that previous signers are still there. + prevSigners := bitmaskFromBytes(prev.NextEpochApprovals.NodeIDs) + nextSigners := bitmaskFromBytes(next.NextEpochApprovals.NodeIDs) + // Remove all bits in nextSigners from prevSigners + prevSigners.Difference(&nextSigners) + // If we have some bits left, it means there was a bit in prevSigners that wasn't in nextSigners + if prevSigners.Len() > 0 { + return fmt.Errorf("some signers from parent block are missing from next epoch approvals of proposed block") + } + return nil +} diff --git a/msm/msm_test.go b/msm/msm_test.go new file mode 100644 index 00000000..f501ab5c --- /dev/null +++ b/msm/msm_test.go @@ -0,0 +1,498 @@ +// Copyright (C) 2019-2025, Ava Labs, Inc. All rights reserved. +// See the file LICENSE for licensing terms. + +package metadata + +import ( + "bytes" + "context" + "fmt" + "testing" + "time" + + "github.com/ava-labs/simplex" + "github.com/stretchr/testify/require" +) + +// fakeVMBlock is a minimal VMBlock implementation for tests. +type fakeVMBlock struct { + height uint64 +} + +func (f *fakeVMBlock) Digest() [32]byte { return [32]byte{} } +func (f *fakeVMBlock) Height() uint64 { return f.height } +func (f *fakeVMBlock) Timestamp() time.Time { return time.Time{} } +func (f *fakeVMBlock) Verify(_ context.Context) error { return nil } + +type outerBlock struct { + finalization *simplex.Finalization + block StateMachineBlock +} + +type blockStore map[uint64]*outerBlock + +func (bs blockStore) clone() blockStore { + newStore := make(blockStore) + for k, v := range bs { + newStore[k] = v + } + return newStore +} + +func (bs blockStore) getBlock(opts RetrievingOpts) (StateMachineBlock, *simplex.Finalization, error) { + blk, exits := bs[opts.Height] + if !exits { + return StateMachineBlock{}, nil, fmt.Errorf("%w: block %d not found", simplex.ErrBlockNotFound, opts.Height) + } + return blk.block, blk.finalization, nil +} + +func TestIdentifyCurrentState(t *testing.T) { + bvd := &BlockValidationDescriptor{} + for _, tc := range []struct { + name string + input SimplexEpochInfo + expected state + }{ + { + name: "epoch 0 is first simplex block", + input: SimplexEpochInfo{EpochNumber: 0}, + expected: stateFirstSimplexBlock, + }, + { + name: "no next p-chain ref height means normal op", + input: SimplexEpochInfo{EpochNumber: 1, NextPChainReferenceHeight: 0}, + expected: stateBuildBlockNormalOp, + }, + { + name: "has sealing block seq means epoch sealed", + input: SimplexEpochInfo{EpochNumber: 1, NextPChainReferenceHeight: 100, SealingBlockSeq: 5}, + expected: stateBuildBlockEpochSealed, + }, + { + name: "has block validation descriptor means epoch sealed", + input: SimplexEpochInfo{EpochNumber: 1, NextPChainReferenceHeight: 100, BlockValidationDescriptor: bvd}, + expected: stateBuildBlockEpochSealed, + }, + { + name: "next p-chain ref height > 0 without sealing means collecting approvals", + input: SimplexEpochInfo{EpochNumber: 1, NextPChainReferenceHeight: 100}, + expected: stateBuildCollectingApprovals, + }, + } { + t.Run(tc.name, func(t *testing.T) { + result, err := identifyCurrentState(tc.input) + require.NoError(t, err) + require.Equal(t, tc.expected, result) + }) + } +} + +func TestAreNextEpochApprovalsSignersSupersetOfApprovalsOfPrevBlock(t *testing.T) { + for _, tc := range []struct { + name string + prev SimplexEpochInfo + next SimplexEpochInfo + err string + }{ + { + name: "prev has nil approvals", + prev: SimplexEpochInfo{}, + next: SimplexEpochInfo{NextEpochApprovals: &NextEpochApprovals{NodeIDs: []byte{3}}}, + }, + { + name: "next is superset of prev", + prev: SimplexEpochInfo{NextEpochApprovals: &NextEpochApprovals{NodeIDs: []byte{1}}}, + next: SimplexEpochInfo{NextEpochApprovals: &NextEpochApprovals{NodeIDs: []byte{3}}}, + }, + { + name: "next equals prev", + prev: SimplexEpochInfo{NextEpochApprovals: &NextEpochApprovals{NodeIDs: []byte{3}}}, + next: SimplexEpochInfo{NextEpochApprovals: &NextEpochApprovals{NodeIDs: []byte{3}}}, + }, + { + name: "next is missing a signer from prev", + prev: SimplexEpochInfo{NextEpochApprovals: &NextEpochApprovals{NodeIDs: []byte{3}}}, + next: SimplexEpochInfo{NextEpochApprovals: &NextEpochApprovals{NodeIDs: []byte{1}}}, + err: "some signers from parent block are missing", + }, + { + name: "prev has approvals but next has nil approvals", + prev: SimplexEpochInfo{NextEpochApprovals: &NextEpochApprovals{NodeIDs: []byte{1}}}, + next: SimplexEpochInfo{}, + err: "previous block has next epoch approvals but proposed block doesn't have next epoch approvals", + }, + } { + t.Run(tc.name, func(t *testing.T) { + err := ensureNextEpochApprovalsSignersSupersetOfApprovalsOfPrevBlock(tc.prev, tc.next) + if tc.err != "" { + require.ErrorContains(t, err, tc.err) + } else { + require.NoError(t, err) + } + }) + } +} + +func TestComputePrevVMBlockSeq(t *testing.T) { + t.Run("parent has no inner block", func(t *testing.T) { + parent := StateMachineBlock{ + InnerBlock: nil, + Metadata: StateMachineMetadata{SimplexEpochInfo: SimplexEpochInfo{PrevVMBlockSeq: 42}}, + } + require.Equal(t, uint64(42), computePrevVMBlockSeq(parent, 100)) + }) + + t.Run("parent has inner block", func(t *testing.T) { + parent := StateMachineBlock{ + InnerBlock: &fakeVMBlock{height: 10}, + Metadata: StateMachineMetadata{SimplexEpochInfo: SimplexEpochInfo{PrevVMBlockSeq: 42}}, + } + require.Equal(t, uint64(100), computePrevVMBlockSeq(parent, 100)) + }) +} + +func TestFindFirstSimplexBlock(t *testing.T) { + t.Run("found at height 3", func(t *testing.T) { + getBlock := func(opts RetrievingOpts) (StateMachineBlock, *simplex.Finalization, error) { + if opts.Height < 3 { + return StateMachineBlock{}, nil, nil + } + return StateMachineBlock{ + Metadata: StateMachineMetadata{SimplexEpochInfo: SimplexEpochInfo{EpochNumber: 1}}, + }, nil, nil + } + result, err := findFirstSimplexBlock(getBlock, 5) + require.NoError(t, err) + require.Equal(t, uint64(3), result) + }) + + t.Run("no simplex blocks found", func(t *testing.T) { + getBlock := func(opts RetrievingOpts) (StateMachineBlock, *simplex.Finalization, error) { + return StateMachineBlock{}, nil, nil + } + _, err := findFirstSimplexBlock(getBlock, 5) + require.ErrorContains(t, err, "no simplex blocks found") + }) + + t.Run("block not found errors are skipped", func(t *testing.T) { + getBlock := func(opts RetrievingOpts) (StateMachineBlock, *simplex.Finalization, error) { + if opts.Height < 2 { + return StateMachineBlock{}, nil, simplex.ErrBlockNotFound + } + return StateMachineBlock{ + Metadata: StateMachineMetadata{SimplexEpochInfo: SimplexEpochInfo{EpochNumber: 1}}, + }, nil, nil + } + result, err := findFirstSimplexBlock(getBlock, 5) + require.NoError(t, err) + require.Equal(t, uint64(2), result) + }) + + t.Run("retrieval error propagated", func(t *testing.T) { + getBlock := func(opts RetrievingOpts) (StateMachineBlock, *simplex.Finalization, error) { + return StateMachineBlock{}, nil, fmt.Errorf("disk error") + } + _, err := findFirstSimplexBlock(getBlock, 5) + require.ErrorContains(t, err, "disk error") + }) +} + +func TestComputeTotalWeight(t *testing.T) { + t.Run("valid weights", func(t *testing.T) { + validators := NodeBLSMappings{ + {Weight: 100}, + {Weight: 200}, + {Weight: 300}, + } + total, err := computeTotalWeight(validators) + require.NoError(t, err) + require.Equal(t, int64(600), total) + }) + + t.Run("zero total weight", func(t *testing.T) { + validators := NodeBLSMappings{{Weight: 0}} + _, err := computeTotalWeight(validators) + require.ErrorContains(t, err, "total weight of validators is 0") + }) + + t.Run("empty validators", func(t *testing.T) { + _, err := computeTotalWeight(NodeBLSMappings{}) + require.ErrorContains(t, err, "total weight of validators is 0") + }) +} + +func TestComputeApprovingWeight(t *testing.T) { + validators := NodeBLSMappings{ + {Weight: 100}, + {Weight: 200}, + {Weight: 300}, + } + + t.Run("all approving", func(t *testing.T) { + bm := bitmaskFromBytes([]byte{7}) + weight, err := computeApprovingWeight(validators, &bm) + require.NoError(t, err) + require.Equal(t, int64(600), weight) + }) + + t.Run("partial approving", func(t *testing.T) { + bm := bitmaskFromBytes([]byte{5}) + weight, err := computeApprovingWeight(validators, &bm) + require.NoError(t, err) + require.Equal(t, int64(400), weight) + }) + + t.Run("none approving", func(t *testing.T) { + bm := bitmaskFromBytes(nil) + weight, err := computeApprovingWeight(validators, &bm) + require.NoError(t, err) + require.Equal(t, int64(0), weight) + }) + + t.Run("single validator approving", func(t *testing.T) { + bm := bitmaskFromBytes([]byte{2}) + weight, err := computeApprovingWeight(validators, &bm) + require.NoError(t, err) + require.Equal(t, int64(200), weight) + }) +} + +func TestSanitizeApprovals(t *testing.T) { + node0 := nodeID{0} + node1 := nodeID{1} + node2 := nodeID{2} + node3 := nodeID{3} + + nodeID2Index := map[nodeID]int{ + node0: 0, + node1: 1, + node2: 2, + } + + t.Run("filters by p-chain height", func(t *testing.T) { + approvals := ValidatorSetApprovals{ + {NodeID: node0, PChainHeight: 100}, + {NodeID: node1, PChainHeight: 200}, + } + oldApproving := bitmaskFromBytes(nil) + result := sanitizeApprovals(approvals, 100, nodeID2Index, oldApproving) + require.Len(t, result, 1) + require.Equal(t, node0, result[0].NodeID) + }) + + t.Run("filters out already approved", func(t *testing.T) { + approvals := ValidatorSetApprovals{ + {NodeID: node0, PChainHeight: 100}, + {NodeID: node1, PChainHeight: 100}, + } + oldApproving := bitmaskFromBytes([]byte{1}) + result := sanitizeApprovals(approvals, 100, nodeID2Index, oldApproving) + require.Len(t, result, 1) + require.Equal(t, node1, result[0].NodeID) + }) + + t.Run("filters out nodes not in validator set", func(t *testing.T) { + approvals := ValidatorSetApprovals{ + {NodeID: node3, PChainHeight: 100}, + {NodeID: node2, PChainHeight: 100}, + } + oldApproving := bitmaskFromBytes(nil) + result := sanitizeApprovals(approvals, 100, nodeID2Index, oldApproving) + require.Len(t, result, 1) + require.Equal(t, node2, result[0].NodeID) + }) + + t.Run("deduplicates by node ID", func(t *testing.T) { + approvals := ValidatorSetApprovals{ + {NodeID: node0, PChainHeight: 100}, + {NodeID: node0, PChainHeight: 100}, + } + oldApproving := bitmaskFromBytes(nil) + result := sanitizeApprovals(approvals, 100, nodeID2Index, oldApproving) + require.Len(t, result, 1) + }) +} + +func TestIdentifyBlockType(t *testing.T) { + bvd := &BlockValidationDescriptor{} + + for _, tc := range []struct { + name string + nextMD StateMachineMetadata + prevMD StateMachineMetadata + prevSeq uint64 + expected BlockType + }{ + { + name: "next block has BlockValidationDescriptor", + nextMD: StateMachineMetadata{SimplexEpochInfo: SimplexEpochInfo{BlockValidationDescriptor: bvd}}, + prevMD: StateMachineMetadata{}, + expected: BlockTypeSealing, + }, + { + name: "prev is zero-epoch block (epoch 1, NextPChainReferenceHeight 0)", + nextMD: StateMachineMetadata{SimplexEpochInfo: SimplexEpochInfo{EpochNumber: 1}}, + prevMD: StateMachineMetadata{SimplexEpochInfo: SimplexEpochInfo{ + EpochNumber: 1, + NextPChainReferenceHeight: 0, + }}, + expected: BlockTypeNormal, + }, + { + name: "prev is sealing block and next epoch matches prevSeq", + nextMD: StateMachineMetadata{SimplexEpochInfo: SimplexEpochInfo{EpochNumber: 10}}, + prevMD: StateMachineMetadata{SimplexEpochInfo: SimplexEpochInfo{ + BlockValidationDescriptor: bvd, + EpochNumber: 1, + NextPChainReferenceHeight: 200, + }}, + prevSeq: 10, + expected: BlockTypeNewEpoch, + }, + { + name: "prev is sealing block and next epoch does not match prevSeq (Telock)", + nextMD: StateMachineMetadata{SimplexEpochInfo: SimplexEpochInfo{EpochNumber: 1}}, + prevMD: StateMachineMetadata{SimplexEpochInfo: SimplexEpochInfo{ + BlockValidationDescriptor: bvd, + EpochNumber: 1, + NextPChainReferenceHeight: 200, + }}, + prevSeq: 10, + expected: BlockTypeTelock, + }, + { + name: "same epoch with non-zero SealingBlockSeq (Telock)", + nextMD: StateMachineMetadata{SimplexEpochInfo: SimplexEpochInfo{EpochNumber: 5}}, + prevMD: StateMachineMetadata{SimplexEpochInfo: SimplexEpochInfo{ + EpochNumber: 5, + SealingBlockSeq: 8, + }}, + expected: BlockTypeTelock, + }, + { + name: "epoch number matches prev SealingBlockSeq (NewEpoch)", + nextMD: StateMachineMetadata{SimplexEpochInfo: SimplexEpochInfo{EpochNumber: 8}}, + prevMD: StateMachineMetadata{SimplexEpochInfo: SimplexEpochInfo{ + EpochNumber: 5, + SealingBlockSeq: 8, + }}, + expected: BlockTypeNewEpoch, + }, + { + name: "normal block in the middle of an epoch", + nextMD: StateMachineMetadata{SimplexEpochInfo: SimplexEpochInfo{EpochNumber: 5}}, + prevMD: StateMachineMetadata{SimplexEpochInfo: SimplexEpochInfo{ + EpochNumber: 5, + }}, + expected: BlockTypeNormal, + }, + } { + t.Run(tc.name, func(t *testing.T) { + result := IdentifyBlockType(tc.nextMD, tc.prevMD, tc.prevSeq) + require.Equal(t, tc.expected, result) + }) + } +} + +// concatAggregator concatenates signatures for easy verification in tests. +type concatAggregator struct{} + +func (concatAggregator) AggregateSignatures(sigs ...[]byte) ([]byte, error) { + return bytes.Join(sigs, nil), nil +} + +type failingAggregator struct{} + +func (failingAggregator) AggregateSignatures(sigs ...[]byte) ([]byte, error) { + return nil, fmt.Errorf("aggregation failed") +} + +func TestComputeNewApproverSignaturesAndSigners(t *testing.T) { + node0 := nodeID{0} + node1 := nodeID{1} + node2 := nodeID{2} + + nodeID2Index := map[nodeID]int{ + node0: 0, + node1: 1, + node2: 2, + } + + t.Run("new approvals with no previous", func(t *testing.T) { + prevApprovals := &NextEpochApprovals{} + oldApproving := bitmaskFromBytes(nil) + + peers := ValidatorSetApprovals{ + {NodeID: node0, Signature: []byte("sig0")}, + {NodeID: node1, Signature: []byte("sig1")}, + } + + aggSig, newApproving, err := computeNewApproverSignaturesAndSigners(prevApprovals, peers, oldApproving, nodeID2Index, concatAggregator{}) + require.NoError(t, err) + require.True(t, newApproving.Contains(0)) + require.True(t, newApproving.Contains(1)) + require.False(t, newApproving.Contains(2)) + require.Equal(t, []byte("sig0sig1"), aggSig) + }) + + t.Run("new approvals added to existing", func(t *testing.T) { + prevApprovals := &NextEpochApprovals{ + NodeIDs: []byte{1}, // bit 0 + Signature: []byte("existing"), + } + oldApproving := bitmaskFromBytes([]byte{1}) // node0 already approved + + peers := ValidatorSetApprovals{ + {NodeID: node2, Signature: []byte("sig2")}, + } + + aggSig, newApproving, err := computeNewApproverSignaturesAndSigners(prevApprovals, peers, oldApproving, nodeID2Index, concatAggregator{}) + require.NoError(t, err) + require.True(t, newApproving.Contains(0)) // preserved from old + require.True(t, newApproving.Contains(2)) // newly added + require.False(t, newApproving.Contains(1)) // not approved + require.Equal(t, []byte("sig2existing"), aggSig) + }) + + t.Run("no new approvals with existing signature", func(t *testing.T) { + prevApprovals := &NextEpochApprovals{ + NodeIDs: []byte{1}, + Signature: []byte("existing"), + } + oldApproving := bitmaskFromBytes([]byte{1}) + + aggSig, newApproving, err := computeNewApproverSignaturesAndSigners(prevApprovals, nil, oldApproving, nodeID2Index, concatAggregator{}) + require.NoError(t, err) + require.True(t, newApproving.Contains(0)) + require.Equal(t, []byte("existing"), aggSig) + }) + + t.Run("peer not in validator set is skipped", func(t *testing.T) { + prevApprovals := &NextEpochApprovals{} + oldApproving := bitmaskFromBytes(nil) + unknownNode := nodeID{99} + + peers := ValidatorSetApprovals{ + {NodeID: unknownNode, Signature: []byte("unknown")}, + {NodeID: node0, Signature: []byte("sig0")}, + } + + aggSig, newApproving, err := computeNewApproverSignaturesAndSigners(prevApprovals, peers, oldApproving, nodeID2Index, concatAggregator{}) + require.NoError(t, err) + require.True(t, newApproving.Contains(0)) + require.Equal(t, 1, newApproving.Len()) + require.Equal(t, []byte("sig0"), aggSig) + }) + + t.Run("aggregation error propagated", func(t *testing.T) { + prevApprovals := &NextEpochApprovals{} + oldApproving := bitmaskFromBytes(nil) + peers := ValidatorSetApprovals{ + {NodeID: node0, Signature: []byte("sig0")}, + } + + _, _, err := computeNewApproverSignaturesAndSigners(prevApprovals, peers, oldApproving, nodeID2Index, failingAggregator{}) + require.ErrorContains(t, err, "aggregation failed") + }) +} From c6e0934c4977e655ef034836e90dfc36b4f2424c Mon Sep 17 00:00:00 2001 From: Yacov Manevich Date: Thu, 16 Apr 2026 21:30:17 +0200 Subject: [PATCH 03/25] Simplex reconfiguration framework - Part III (MSM implementation) - Add block building to msm.go - Add verification.go which contains logic for block verification - Add tests that mimic Simplex flow (fake_node_test.go) Signed-off-by: Yacov Manevich --- msm/fake_node_test.go | 428 ++++++++++++++++ msm/msm.go | 700 ++++++++++++++++++++++++++ msm/msm_test.go | 998 +++++++++++++++++++++++++++++++++++++ msm/verification.go | 515 +++++++++++++++++++ msm/verification_test.go | 1016 ++++++++++++++++++++++++++++++++++++++ 5 files changed, 3657 insertions(+) create mode 100644 msm/fake_node_test.go create mode 100644 msm/verification.go create mode 100644 msm/verification_test.go diff --git a/msm/fake_node_test.go b/msm/fake_node_test.go new file mode 100644 index 00000000..4c980a44 --- /dev/null +++ b/msm/fake_node_test.go @@ -0,0 +1,428 @@ +// Copyright (C) 2019-2025, Ava Labs, Inc. All rights reserved. +// See the file LICENSE for licensing terms. + +package metadata + +import ( + "context" + "crypto/rand" + "fmt" + "sync/atomic" + "testing" + "time" + + "github.com/ava-labs/simplex" + "github.com/stretchr/testify/require" +) + +func TestFakeNode(t *testing.T) { + validatorSetRetriever := validatorSetRetriever{ + resultMap: map[uint64]NodeBLSMappings{ + 100: {{BLSKey: []byte{1}, Weight: 1, NodeID: [20]byte{1}}, {BLSKey: []byte{2}, Weight: 1, NodeID: [20]byte{2}}}, + 200: {{BLSKey: []byte{1}, Weight: 1, NodeID: [20]byte{1}}, {BLSKey: []byte{2}, Weight: 2, NodeID: [20]byte{2}}, + {BLSKey: []byte{3}, Weight: 1, NodeID: [20]byte{3}}}, + 300: {{BLSKey: []byte{1}, Weight: 1, NodeID: [20]byte{1}}, {BLSKey: []byte{2}, Weight: 2, NodeID: [20]byte{2}}, + {BLSKey: []byte{3}, Weight: 3, NodeID: [20]byte{3}}, {BLSKey: []byte{4}, Weight: 1, NodeID: [20]byte{4}}}, + }, + } + + var pChainHeight atomic.Uint64 + pChainHeight.Store(100) + node := newFakeNode(t) + node.sm.GetValidatorSet = validatorSetRetriever.getValidatorSet + node.sm.GetPChainHeight = func() uint64 { + return pChainHeight.Load() + } + + // Create some blocks and finalize them, until we reach height 10 + for node.Height() < 10 { + node.act() + } + + // Next, we increase the P-Chain height, which should cause the node to update its validator set and move to the new epoch. + pChainHeight.Store(200) + + epoch := node.Epoch() + for node.Epoch() == epoch { + node.act() + if flipCoin() { + node.sm.ApprovalsRetriever = &approvalsRetriever{ + result: []ValidatorSetApproval{{NodeID: [20]byte{1}, PChainHeight: 200, Signature: []byte{1}, AuxInfoSeqDigest: [32]byte{}}}, + } + } else { + node.sm.ApprovalsRetriever = &approvalsRetriever{ + result: []ValidatorSetApproval{{NodeID: [20]byte{2}, PChainHeight: 200, Signature: []byte{2}, AuxInfoSeqDigest: [32]byte{}}}, + } + } + } + + t.Log("Epoch:", node.Epoch()) + require.Greater(t, node.Epoch(), uint64(1)) + + // Finally, we increase the P-Chain height again, which should cause the node to update its validator set and move to the new epoch. + + pChainHeight.Store(300) + + epoch = node.Epoch() + for node.Epoch() == epoch { + node.act() + if flipCoin() { + node.sm.ApprovalsRetriever = &approvalsRetriever{ + result: []ValidatorSetApproval{{NodeID: [20]byte{2}, PChainHeight: 300, Signature: []byte{2}, AuxInfoSeqDigest: [32]byte{}}}, + } + } else { + node.sm.ApprovalsRetriever = &approvalsRetriever{ + result: []ValidatorSetApproval{{NodeID: [20]byte{3}, PChainHeight: 300, Signature: []byte{3}, AuxInfoSeqDigest: [32]byte{}}}, + } + } + } + + t.Log("Epoch:", node.Epoch()) + require.Greater(t, node.Epoch(), epoch) +} + +func TestFakeNodeEmptyMempool(t *testing.T) { + validatorSetRetriever := validatorSetRetriever{ + resultMap: map[uint64]NodeBLSMappings{ + 100: {{BLSKey: []byte{1}, Weight: 1, NodeID: [20]byte{1}}, {BLSKey: []byte{2}, Weight: 1, NodeID: [20]byte{2}}}, + 200: {{BLSKey: []byte{1}, Weight: 1, NodeID: [20]byte{1}}, {BLSKey: []byte{2}, Weight: 2, NodeID: [20]byte{2}}, + {BLSKey: []byte{3}, Weight: 1, NodeID: [20]byte{3}}}, + 300: {{BLSKey: []byte{1}, Weight: 1, NodeID: [20]byte{1}}, {BLSKey: []byte{2}, Weight: 2, NodeID: [20]byte{2}}, + {BLSKey: []byte{3}, Weight: 3, NodeID: [20]byte{3}}, {BLSKey: []byte{4}, Weight: 1, NodeID: [20]byte{4}}}, + }, + } + + var pChainHeight uint64 = 100 + node := newFakeNode(t) + node.sm.MaxBlockBuildingWaitTime = 100 * time.Millisecond + node.sm.GetValidatorSet = validatorSetRetriever.getValidatorSet + node.sm.GetPChainHeight = func() uint64 { + return pChainHeight + } + + // Create some blocks and finalize them, until we reach height 10 + for node.Height() < 10 { + node.act() + } + + // Next, we increase the P-Chain height, which should cause the node to update its validator set and move to the new epoch. + pChainHeight = 200 + + // However, we mark the mempool as empty, which should cause the node to wait until it sees a change in the P-Chain height, rather than building blocks on top of the old epoch. + node.mempoolEmpty = true + + // We build blocks until the sealing block is finalized. + for node.finalizedBlocks[len(node.finalizedBlocks)-1].Metadata.SimplexEpochInfo.BlockValidationDescriptor == nil { + node.act() + if flipCoin() { + node.sm.ApprovalsRetriever = &approvalsRetriever{ + result: []ValidatorSetApproval{{NodeID: [20]byte{1}, PChainHeight: 200, Signature: []byte{1}, AuxInfoSeqDigest: [32]byte{}}}, + } + } else { + node.sm.ApprovalsRetriever = &approvalsRetriever{ + result: []ValidatorSetApproval{{NodeID: [20]byte{2}, PChainHeight: 200, Signature: []byte{2}, AuxInfoSeqDigest: [32]byte{}}}, + } + } + } + + node.mempoolEmpty = false + + // Build a new block and check that the node has transitioned to the new epoch, + // rather than building a block on top of the old epoch. + height := node.Height() + + for node.Height() == height { + node.act() + } + require.Greater(t, node.Epoch(), uint64(1)) + + t.Log("Epoch:", node.Epoch()) + + epoch := node.Epoch() + require.Greater(t, epoch, uint64(1)) + + // Finally, we increase the P-Chain height again, which should cause the node to update its validator set and move to the new epoch. + + pChainHeight = 300 + + for node.Height() < 30 { + node.act() + if flipCoin() { + node.sm.ApprovalsRetriever = &approvalsRetriever{ + result: []ValidatorSetApproval{{NodeID: [20]byte{2}, PChainHeight: 300, Signature: []byte{2}, AuxInfoSeqDigest: [32]byte{}}}, + } + } else { + node.sm.ApprovalsRetriever = &approvalsRetriever{ + result: []ValidatorSetApproval{{NodeID: [20]byte{3}, PChainHeight: 300, Signature: []byte{3}, AuxInfoSeqDigest: [32]byte{}}}, + } + } + } + + t.Log("Epoch:", node.Epoch()) + require.Greater(t, node.Epoch(), epoch) + require.Equal(t, node.Height(), uint64(30)) +} + +type innerBlock struct { + InnerBlock + Prev [32]byte +} + +type fakeNode struct { + t *testing.T + sm StateMachine + mempoolEmpty bool + notarizedBlocks []StateMachineBlock + finalizedBlocks []StateMachineBlock + innerChain []innerBlock +} + +func (fn *fakeNode) WaitForProgress(ctx context.Context, pChainHeight uint64) error { + for { + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(10 * time.Millisecond): + if fn.sm.GetPChainHeight() != pChainHeight { + return nil + } + } + } +} + +func (fn *fakeNode) WaitForPendingBlock(ctx context.Context) { + if fn.mempoolEmpty { + <-ctx.Done() + return + } +} + +func newFakeNode(t *testing.T) *fakeNode { + sm, _ := newStateMachine(t) + + fn := &fakeNode{ + t: t, + sm: sm, + } + + fn.sm.BlockBuilder = fn + fn.sm.PChainProgressListener = fn + + fn.sm.GetBlock = func(opts RetrievingOpts) (StateMachineBlock, *simplex.Finalization, error) { + if opts.Height == 0 { + return genesisBlock, nil, nil + } + for _, block := range fn.finalizedBlocks { + if block.Digest() == opts.Digest { + return block, &simplex.Finalization{}, nil + } + md, err := simplex.ProtocolMetadataFromBytes(block.Metadata.SimplexProtocolMetadata) + if err != nil { + return StateMachineBlock{}, nil, err + } + if md.Seq == opts.Height { + return block, &simplex.Finalization{}, nil + } + } + for _, block := range fn.notarizedBlocks { + if block.Digest() == opts.Digest { + return block, nil, nil + } + md, err := simplex.ProtocolMetadataFromBytes(block.Metadata.SimplexProtocolMetadata) + if err != nil { + return StateMachineBlock{}, nil, err + } + if md.Seq == opts.Height { + return block, nil, nil + } + } + + require.Failf(t, "not found block", "height: %d", opts.Height) + return StateMachineBlock{}, nil, fmt.Errorf("block not found") + } + + return fn +} + +func (fn *fakeNode) Height() uint64 { + return uint64(len(fn.finalizedBlocks)) +} + +func (fn *fakeNode) Epoch() uint64 { + return fn.notarizedBlocks[len(fn.notarizedBlocks)-1].Metadata.SimplexEpochInfo.EpochNumber +} + +func (fn *fakeNode) act() { + if fn.canFinalize() && flipCoin() { + fn.tryFinalizeNextBlock() + return + } + + if flipCoin() { + return + } + + fn.buildAndNotarizeBlock() +} + +func (fn *fakeNode) canFinalize() bool { + return len(fn.notarizedBlocks) > len(fn.finalizedBlocks) +} + +func (fn *fakeNode) tryFinalizeNextBlock() { + nextIndex := len(fn.finalizedBlocks) + + if fn.isNextBlockTelock() { + return + } + + block := fn.notarizedBlocks[nextIndex] + fn.finalizedBlocks = append(fn.finalizedBlocks, block) + + md, err := simplex.ProtocolMetadataFromBytes(block.Metadata.SimplexProtocolMetadata) + require.NoError(fn.t, err) + + fn.sm.LatestPersistedHeight = md.Seq + fn.t.Logf("Finalized block at height %d with epoch %d", md.Seq, block.Metadata.SimplexEpochInfo.EpochNumber) + + // If we just finalized a sealing block, trim trailing Telock blocks. + if block.Metadata.SimplexEpochInfo.BlockValidationDescriptor != nil { + fn.notarizedBlocks = fn.notarizedBlocks[:len(fn.finalizedBlocks)] + fn.t.Logf("Trimmed notarized blocks, new length: %d", len(fn.notarizedBlocks)) + } +} + +func (fn *fakeNode) isNextBlockTelock() bool { + if len(fn.finalizedBlocks) == 0 { + return false + } + return fn.notarizedBlocks[len(fn.finalizedBlocks)].Metadata.SimplexEpochInfo.SealingBlockSeq > 0 +} + +func (fn *fakeNode) buildAndNotarizeBlock() { + vmBlock, block := fn.buildBlock() + require.NoError(fn.t, fn.sm.VerifyBlock(context.Background(), block)) + + fn.notarizedBlocks = append(fn.notarizedBlocks, *block) + + if vmBlock != nil { + fn.innerChain = append(fn.innerChain, *vmBlock.(*innerBlock)) + } +} + +func (fn *fakeNode) buildBlock() (VMBlock, *StateMachineBlock) { + parentBlock := fn.getParentBlock() + + lastMD, prevBlockDigest := fn.prepareMetadataAndPrevBlockDigest() + + _, finalization, err := fn.sm.GetBlock(RetrievingOpts{ + Digest: prevBlockDigest, + Height: lastMD.Seq, + }) + require.NoError(fn.t, err) + + finalizedString := "not finalized" + if finalization != nil { + finalizedString = "finalized" + } + + fn.t.Logf("Building a block on top of %s parent with epoch %d", finalizedString, parentBlock.Metadata.SimplexEpochInfo.EpochNumber) + + block, err := fn.sm.BuildBlock(context.Background(), parentBlock, simplex.ProtocolMetadata{ + Seq: lastMD.Seq + 1, + Round: lastMD.Round + 1, + Prev: prevBlockDigest, + }, nil) + require.NoError(fn.t, err) + + return block.InnerBlock, block +} + +func (fn *fakeNode) prepareMetadataAndPrevBlockDigest() (*simplex.ProtocolMetadata, [32]byte) { + var lastMD *simplex.ProtocolMetadata + var err error + lastBlockDigest := genesisBlock.Digest() + if len(fn.notarizedBlocks) > 0 { + lastBlock := fn.notarizedBlocks[len(fn.notarizedBlocks)-1] + lastBlockDigest = lastBlock.Digest() + lastMD, err = simplex.ProtocolMetadataFromBytes(lastBlock.Metadata.SimplexProtocolMetadata) + require.NoError(fn.t, err) + } else { + lastMD = &simplex.ProtocolMetadata{ + Prev: lastBlockDigest, + } + } + return lastMD, lastBlockDigest +} + +func (fn *fakeNode) BuildBlock(context.Context, uint64) (VMBlock, error) { + // Count the number of inner blocks in the chain + var count int + for _, block := range fn.notarizedBlocks { + if block.InnerBlock != nil { + count++ + } + } + + vmBlock := &innerBlock{ + Prev: fn.getLastVMBlockDigest(), + InnerBlock: InnerBlock{ + Bytes: randomBuff(10), + TS: time.Now(), + BlockHeight: uint64(count), + }, + } + return vmBlock, nil +} + +func (fn *fakeNode) getParentBlock() StateMachineBlock { + var parentBlock StateMachineBlock + if len(fn.notarizedBlocks) > 0 { + parentBlock = fn.notarizedBlocks[len(fn.notarizedBlocks)-1] + } else { + gb := genesisBlock.InnerBlock.(*InnerBlock) + parentBlock = StateMachineBlock{ + InnerBlock: &innerBlock{ + InnerBlock: *gb, + }, + } + } + return parentBlock +} + +func (fn *fakeNode) getLastVMBlockDigest() [32]byte { + var lastVMBlockDigest = genesisBlock.Digest() + + notarizedBlocks := fn.notarizedBlocks + for len(notarizedBlocks) > 0 { + lastNotarizedBlock := notarizedBlocks[len(notarizedBlocks)-1] + if lastNotarizedBlock.InnerBlock == nil { + notarizedBlocks = notarizedBlocks[:len(notarizedBlocks)-1] + continue + } + lastVMBlockDigest = lastNotarizedBlock.Digest() + break + } + return lastVMBlockDigest +} + +func randomBuff(n int) []byte { + buff := make([]byte, n) + _, err := rand.Read(buff) + if err != nil { + panic(err) + } + return buff +} + +func flipCoin() bool { + buff := make([]byte, 1) + _, err := rand.Read(buff) + if err != nil { + panic(err) + } + + lsb := buff[0] & 1 + + return lsb == 1 +} diff --git a/msm/msm.go b/msm/msm.go index a14871d5..a256c8da 100644 --- a/msm/msm.go +++ b/msm/msm.go @@ -4,13 +4,17 @@ package metadata import ( + "context" "crypto/sha256" "errors" "fmt" "math" + "math/big" "sort" + "time" "github.com/ava-labs/simplex" + "go.uber.org/zap" ) // A StateMachineBlock is a representation of a parsed OuterBlock, containing the inner block and the metadata. @@ -74,6 +78,58 @@ type RetrievingOpts struct { // If an error occurs during retrieval, it returns a non-nil error. type BlockRetriever func(RetrievingOpts) (StateMachineBlock, *simplex.Finalization, error) +// BlockBuilder builds a new VM block with the given observed P-chain height. +type BlockBuilder interface { + BuildBlock(ctx context.Context, pChainHeight uint64) (VMBlock, error) + + // WaitForPendingBlock returns when either the given context is cancelled, + // or when the VM signals that a block should be built. + WaitForPendingBlock(ctx context.Context) +} + +// StateMachine manages block building and verification across epoch transitions. +type StateMachine struct { + // LatestPersistedHeight is the height of the most recently persisted block. + LatestPersistedHeight uint64 + // MaxBlockBuildingWaitTime is the maximum duration to wait for the VM to build a block + // before producing a block without an inner block. + MaxBlockBuildingWaitTime time.Duration + // TimeSkewLimit is the maximum allowed time difference between a block's timestamp and the current time. + TimeSkewLimit time.Duration + // GetTime returns the current time. + GetTime func() time.Time + // GetPChainHeight returns the latest known P-chain height. + GetPChainHeight func() uint64 + // GetUpgrades returns the current upgrade configuration. + GetUpgrades func() UpgradeConfig + // BlockBuilder builds new VM blocks. + BlockBuilder BlockBuilder + // Logger is used for logging state machine operations. + Logger Logger + // GetValidatorSet retrieves the validator set at a given P-chain height. + GetValidatorSet ValidatorSetRetriever + // GetBlock retrieves a previously built or finalized block. + GetBlock BlockRetriever + // ApprovalsRetriever retrieves validator approvals for epoch transitions. + ApprovalsRetriever ApprovalsRetriever + // SignatureAggregator aggregates signatures from validators. + SignatureAggregator SignatureAggregator + // KeyAggregator aggregates public keys from validators. + KeyAggregator KeyAggregator + // SignatureVerifier verifies signatures from validators. + SignatureVerifier SignatureVerifier + // PChainProgressListener listens for changes in the P-chain height to trigger block building or epoch transitions. + PChainProgressListener PChainProgressListener + + // initialized tracks whether the state machine has been initialized. + // This is used to lazily initialize the verifiers. + initialized bool + + // verifiers is the list of verifiers used to verify proposed blocks. + // Each verifier is responsible for verifying a specific aspect of the block's metadata. + verifiers []verifier +} + type state uint8 const ( @@ -107,6 +163,183 @@ func (state BlockType) String() string { } } +// BuildBlock constructs the next block on top of the given parent block, and passes in the provided simplex metadata and blacklist. +func (sm *StateMachine) BuildBlock(ctx context.Context, parentBlock StateMachineBlock, simplexMetadata simplex.ProtocolMetadata, simplexBlacklist *simplex.Blacklist) (*StateMachineBlock, error) { + sm.maybeInit() + + // The zero sequence number is reserved for the genesis block, which should never be built. + if simplexMetadata.Seq == 0 { + return nil, fmt.Errorf("invalid ProtocolMetadata sequence number: should be > 0, got %d", simplexMetadata.Seq) + } + + start := time.Now() + + sm.Logger.Debug("Building block", + zap.Uint64("seq", simplexMetadata.Seq), + zap.Uint64("epoch", simplexMetadata.Epoch), + zap.Stringer("prevHash", simplexMetadata.Prev)) + + defer func() { + elapsed := time.Since(start) + sm.Logger.Debug("Built block", + zap.Uint64("seq", simplexMetadata.Seq), + zap.Uint64("epoch", simplexMetadata.Epoch), + zap.Stringer("prevHash", simplexMetadata.Prev), + zap.Duration("elapsed", elapsed), + ) + }() + + var simplexBlacklistBytes []byte + if simplexBlacklist != nil { + simplexBlacklistBytes = simplexBlacklist.Bytes() + } + + // In order to know where in the epoch change process we are, + // we identify the current state by looking at the parent block's epoch info. + currentState, err := identifyCurrentState(parentBlock.Metadata.SimplexEpochInfo) + if err != nil { + return nil, err + } + + simplexMetadataBytes := simplexMetadata.Bytes() + prevBlockSeq := simplexMetadata.Seq - 1 + + switch currentState { + case stateFirstSimplexBlock: + return sm.buildBlockZero(ctx, parentBlock, simplexMetadataBytes, simplexBlacklistBytes) + case stateBuildBlockNormalOp: + return sm.buildBlockNormalOp(ctx, parentBlock, simplexMetadataBytes, simplexBlacklistBytes, prevBlockSeq) + case stateBuildCollectingApprovals: + return sm.buildBlockCollectingApprovals(ctx, parentBlock, simplexMetadataBytes, simplexBlacklistBytes, prevBlockSeq) + case stateBuildBlockEpochSealed: + return sm.buildBlockEpochSealed(ctx, parentBlock, simplexMetadataBytes, simplexBlacklistBytes, prevBlockSeq) + default: + return nil, fmt.Errorf("unknown state %d", currentState) + } +} + +// VerifyBlock validates a proposed block by checking its metadata, epoch info, +// and inner block against the previous block and the current state. +func (sm *StateMachine) VerifyBlock(ctx context.Context, block *StateMachineBlock) error { + sm.maybeInit() + + if block == nil { + return fmt.Errorf("InnerBlock is nil") + } + + pmd, err := simplex.ProtocolMetadataFromBytes(block.Metadata.SimplexProtocolMetadata) + if err != nil { + return fmt.Errorf("failed to parse ProtocolMetadata: %w", err) + } + + seq := pmd.Seq + + if seq == 0 { + return fmt.Errorf("attempted to build a genesis inner block") + } + + prevBlock, _, err := sm.GetBlock(RetrievingOpts{Digest: pmd.Prev, Height: seq - 1}) + if err != nil { + return fmt.Errorf("failed to retrieve previous (%d) inner block: %w", seq-1, err) + } + + prevMD := prevBlock.Metadata + currentState, err := identifyCurrentState(prevMD.SimplexEpochInfo) + if err != nil { + return fmt.Errorf("failed to identify previous state: %w", err) + } + + switch currentState { + case stateFirstSimplexBlock: + err = sm.verifyBlockZero(ctx, block, prevBlock) + default: + err = sm.verifyNonZeroBlock(ctx, block, prevBlock.Metadata, currentState, seq-1) + } + return err +} + +func (sm *StateMachine) maybeInit() { + if sm.initialized { + return + } + sm.init() + sm.initialized = true +} + +func (sm *StateMachine) init() { + sm.verifiers = []verifier{ + &pChainHeightVerifier{ + getPChainHeight: sm.GetPChainHeight, + }, + ×tampVerifier{ + timeSkewLimit: sm.TimeSkewLimit, + getTime: sm.GetTime, + }, + &pChainReferenceHeightVerifier{}, + &epochNumberVerifier{}, + &prevSealingBlockHashVerifier{ + getBlock: sm.GetBlock, + latestPersistedHeight: &sm.LatestPersistedHeight, + }, + &nextPChainReferenceHeightVerifier{ + getPChainHeight: sm.GetPChainHeight, + getValidatorSet: sm.GetValidatorSet, + }, + &vmBlockSeqVerifier{ + getBlock: sm.GetBlock, + }, + &validationDescriptorVerifier{ + getValidatorSet: sm.GetValidatorSet, + }, + &nextEpochApprovalsVerifier{ + getValidatorSet: sm.GetValidatorSet, + keyAggregator: sm.KeyAggregator, + sigVerifier: sm.SignatureVerifier, + }, + &sealingBlockSeqVerifier{}, + } +} + +func (sm *StateMachine) verifyNonZeroBlock(ctx context.Context, block *StateMachineBlock, prevBlockMD StateMachineMetadata, state state, prevSeq uint64) error { + blockType := IdentifyBlockType(block.Metadata, prevBlockMD, prevSeq) + sm.Logger.Debug("Identified block type", + zap.Stringer("blockType", blockType), + zap.Bool("nextHasBVD", block.Metadata.SimplexEpochInfo.BlockValidationDescriptor != nil), + zap.Uint64("nextEpochNumber", block.Metadata.SimplexEpochInfo.EpochNumber), + zap.Bool("prevHasBVD", prevBlockMD.SimplexEpochInfo.BlockValidationDescriptor != nil), + zap.Uint64("prevEpochNumber", prevBlockMD.SimplexEpochInfo.EpochNumber), + zap.Uint64("prevNextPChainRefHeight", prevBlockMD.SimplexEpochInfo.NextPChainReferenceHeight), + zap.Uint64("prevSealingBlockSeq", prevBlockMD.SimplexEpochInfo.SealingBlockSeq), + zap.Uint64("prevSeq", prevSeq), + ) + + var innerBlockTimestamp time.Time + if block.InnerBlock != nil { + innerBlockTimestamp = block.InnerBlock.Timestamp() + } + + for _, verifier := range sm.verifiers { + if err := verifier.Verify(verificationInput{ + proposedBlockMD: block.Metadata, + nextBlockType: blockType, + prevMD: prevBlockMD, + state: state, + prevBlockSeq: prevSeq, + hasInnerBlock: block.InnerBlock != nil, + innerBlockTimestamp: innerBlockTimestamp, + }); err != nil { + sm.Logger.Debug("Invalid block", zap.Error(err)) + return err + } + } + + if block.InnerBlock == nil { + return nil + } + + return block.InnerBlock.Verify(ctx) +} + func identifyCurrentState(prevBlockSimplexEpochInfo SimplexEpochInfo) (state, error) { // If this is the first ever epoch, then this is also the first ever block to be built by Simplex. if prevBlockSimplexEpochInfo.EpochNumber == 0 { @@ -130,6 +363,389 @@ func identifyCurrentState(prevBlockSimplexEpochInfo SimplexEpochInfo) (state, er return stateBuildCollectingApprovals, nil } +// buildBlockNormalOp builds a block while not trying to transition to a new epoch. +func (sm *StateMachine) buildBlockNormalOp(ctx context.Context, parentBlock StateMachineBlock, simplexMetadata, simplexBlacklist []byte, prevBlockSeq uint64) (*StateMachineBlock, error) { + // Since in the previous block, we were not transitioning to a new epoch, + // the P-chain reference height and epoch of the new block should remain the same. + newSimplexEpochInfo := SimplexEpochInfo{ + PChainReferenceHeight: parentBlock.Metadata.SimplexEpochInfo.PChainReferenceHeight, + EpochNumber: parentBlock.Metadata.SimplexEpochInfo.EpochNumber, + PrevVMBlockSeq: computePrevVMBlockSeq(parentBlock, prevBlockSeq), + } + + blockBuildingDecider := sm.createBlockBuildingDecider(parentBlock) + decisionToBuildBlock, pChainHeight, err := blockBuildingDecider.shouldBuildBlock(ctx) + if err != nil { + return nil, err + } + + sm.Logger.Debug("Block building decision", zap.Stringer("decision", decisionToBuildBlock)) + + var childBlock VMBlock + + switch decisionToBuildBlock { + case blockBuildingDecisionBuildBlock, blockBuildingDecisionBuildBlockAndTransitionEpoch: + // If we reached here, we need to build a new block, and maybe also transition to a new epoch. + return sm.buildBlockAndMaybeTransitionEpoch(ctx, parentBlock, simplexMetadata, simplexBlacklist, childBlock, decisionToBuildBlock, newSimplexEpochInfo, pChainHeight) + case blockBuildingDecisionTransitionEpoch: + // If we reached here, we don't need to build an inner block, yet we need to transition to a new epoch. + // Initiate the epoch transition by setting the next P-chain reference height for the new epoch info, + // and build a block without an inner block. + newSimplexEpochInfo.NextPChainReferenceHeight = pChainHeight + return sm.wrapBlock(parentBlock, nil, newSimplexEpochInfo, pChainHeight, simplexMetadata, simplexBlacklist), nil + case blockBuildingDecisionContextCanceled: + return nil, ctx.Err() + default: + return nil, fmt.Errorf("unknown block building decision %d", decisionToBuildBlock) + } +} + +func (sm *StateMachine) createBlockBuildingDecider(parentBlock StateMachineBlock) blockBuildingDecider { + blockBuildingDecider := blockBuildingDecider{ + logger: sm.Logger, + maxBlockBuildingWaitTime: sm.MaxBlockBuildingWaitTime, + pChainlistener: sm.PChainProgressListener, + getPChainHeight: sm.GetPChainHeight, + waitForPendingBlock: sm.BlockBuilder.WaitForPendingBlock, + shouldTransitionEpoch: func(pChainHeight uint64) (bool, error) { + // The given pChainHeight was sampled by the caller of shouldTransitionEpoch(). + // We compare between the current validator set, defined by the P-chain reference height in the parent block, + // and the new validator set defined by the given pChainHeight. + // If they are different, then we should transition to a new epoch. + + currentValidatorSet, err := sm.GetValidatorSet(parentBlock.Metadata.SimplexEpochInfo.PChainReferenceHeight) + if err != nil { + return false, err + } + + newValidatorSet, err := sm.GetValidatorSet(pChainHeight) + if err != nil { + return false, err + } + + if !currentValidatorSet.Equal(newValidatorSet) { + return true, nil + } + return false, nil + }, + } + return blockBuildingDecider +} + +func (sm *StateMachine) buildBlockAndMaybeTransitionEpoch(ctx context.Context, parentBlock StateMachineBlock, simplexMetadata []byte, simplexBlacklist []byte, childBlock VMBlock, decisionToBuildBlock blockBuildingDecision, newSimplexEpochInfo SimplexEpochInfo, pChainHeight uint64) (*StateMachineBlock, error) { + // TODO: This P-chain height should be taken from the ICM epoch + childBlock, err := sm.BlockBuilder.BuildBlock(ctx, pChainHeight) + if err != nil { + return nil, err + } + + if decisionToBuildBlock == blockBuildingDecisionBuildBlockAndTransitionEpoch { + // We need to also transition to a new epoch, in addition to building an inner block, + // so set the next P-chain reference height for the new epoch info. + newSimplexEpochInfo.NextPChainReferenceHeight = pChainHeight + } + + return sm.wrapBlock(parentBlock, childBlock, newSimplexEpochInfo, pChainHeight, simplexMetadata, simplexBlacklist), nil +} + +// buildBlockZero builds the first ever block for Simplex, +// which is a special block that introduces the first validator set and starts the first epoch. +func (sm *StateMachine) buildBlockZero(ctx context.Context, parentBlock StateMachineBlock, simplexMetadata, simplexBlacklist []byte) (*StateMachineBlock, error) { + pChainHeight := sm.GetPChainHeight() + + newValidatorSet, err := sm.GetValidatorSet(pChainHeight) + if err != nil { + return nil, err + } + + var prevVMBlockSeq uint64 + if parentBlock.InnerBlock != nil { + prevVMBlockSeq = parentBlock.InnerBlock.Height() + } else { + // We can only have blocks without inner blocks in Simplex blocks, but this is the first Simplex block. + // Therefore, the parent block must have an inner block. + sm.Logger.Error("Parent block has no inner block, cannot determine previous VM block sequence for zero block") + return nil, fmt.Errorf("failed constructing zero block: parent block has no inner block") + } + simplexEpochInfo := constructSimplexZeroBlock(pChainHeight, newValidatorSet, prevVMBlockSeq) + + return sm.buildBlockImpatiently(ctx, parentBlock, simplexMetadata, simplexBlacklist, simplexEpochInfo, pChainHeight) +} + +func (sm *StateMachine) verifyBlockZero(ctx context.Context, block *StateMachineBlock, prevBlock StateMachineBlock) error { + if block == nil { + return fmt.Errorf("block is nil") + } + + simplexEpochInfo := block.Metadata.SimplexEpochInfo + + if simplexEpochInfo.EpochNumber != 1 { + return fmt.Errorf("invalid epoch number (%d), should be 1", simplexEpochInfo.EpochNumber) + } + + if prevBlock.InnerBlock == nil { + return fmt.Errorf("parent inner block (%s) has no inner block", prevBlock.Digest()) + } + + prevVMBlockSeq := prevBlock.InnerBlock.Height() + + currentPChainHeight := sm.GetPChainHeight() + + if block.Metadata.PChainHeight > currentPChainHeight { + return fmt.Errorf("invalid P-chain height (%d) is too big, expected to be ≤ %d", + block.Metadata.PChainHeight, currentPChainHeight) + } + + if prevBlock.Metadata.PChainHeight > block.Metadata.PChainHeight { + return fmt.Errorf("invalid P-chain height (%d) is smaller than parent InnerBlock's P-chain height (%d)", + block.Metadata.PChainHeight, prevBlock.Metadata.PChainHeight) + } + + expectedValidatorSet, err := sm.GetValidatorSet(simplexEpochInfo.PChainReferenceHeight) + if err != nil { + return fmt.Errorf("failed to retrieve validator set at height %d: %w", simplexEpochInfo.PChainReferenceHeight, err) + } + + if simplexEpochInfo.BlockValidationDescriptor == nil { + return fmt.Errorf("invalid BlockValidationDescriptor: should not be nil") + } + + membership := simplexEpochInfo.BlockValidationDescriptor.AggregatedMembership.Members + if !NodeBLSMappings(membership).Equal(expectedValidatorSet) { + return fmt.Errorf("invalid BlockValidationDescriptor: should match validator set at P-chain height %d", simplexEpochInfo.PChainReferenceHeight) + } + + // If we have compared all fields so far, the rest of the fields we compare by constructing an explicit expected SimplexEpochInfo + expectedSimplexEpochInfo := constructSimplexZeroBlock(simplexEpochInfo.PChainReferenceHeight, expectedValidatorSet, prevVMBlockSeq) + + if !expectedSimplexEpochInfo.Equal(&simplexEpochInfo) { + return fmt.Errorf("invalid SimplexEpochInfo: expected %v, got %v", expectedSimplexEpochInfo, simplexEpochInfo) + } + + _, err = sm.verifyZeroBlockTimestamp(block, prevBlock) + if err != nil { + return err + } + + if block.InnerBlock == nil { + return nil + } + + return block.InnerBlock.Verify(ctx) +} + +func (sm *StateMachine) verifyZeroBlockTimestamp(block *StateMachineBlock, prevBlock StateMachineBlock) (time.Time, error) { + var proposedTime time.Time + if block.InnerBlock != nil { + proposedTime = block.InnerBlock.Timestamp() + } else { + proposedTime = time.UnixMilli(int64(prevBlock.Metadata.Timestamp)) + } + + expectedTimestamp := proposedTime.UnixMilli() + if expectedTimestamp != int64(block.Metadata.Timestamp) { + return time.Time{}, fmt.Errorf("expected timestamp to be %d but got %d", expectedTimestamp, int64(block.Metadata.Timestamp)) + } + currentTime := sm.GetTime() + if currentTime.Add(sm.TimeSkewLimit).Before(proposedTime) { + return time.Time{}, fmt.Errorf("proposed block timestamp is too far in the future, current time is %s but got %s", currentTime.String(), proposedTime.String()) + } + if prevBlock.Metadata.Timestamp > block.Metadata.Timestamp { + return time.Time{}, fmt.Errorf("proposed block timestamp is older than parent block's timestamp, parent timestamp is %d but got %d", prevBlock.Metadata.Timestamp, block.Metadata.Timestamp) + } + return proposedTime, nil +} + +func (sm *StateMachine) buildBlockCollectingApprovals(ctx context.Context, parentBlock StateMachineBlock, simplexMetadata, simplexBlacklist []byte, prevBlockSeq uint64) (*StateMachineBlock, error) { + // The P-chain reference height and epoch number should remain the same until we transition to the new epoch. + // The next P-chain reference height should have been set in the previous block, + // which is the reason why we are collecting approvals in the first place. + newSimplexEpochInfo := SimplexEpochInfo{ + PChainReferenceHeight: parentBlock.Metadata.SimplexEpochInfo.PChainReferenceHeight, + EpochNumber: parentBlock.Metadata.SimplexEpochInfo.EpochNumber, + NextPChainReferenceHeight: parentBlock.Metadata.SimplexEpochInfo.NextPChainReferenceHeight, + PrevVMBlockSeq: computePrevVMBlockSeq(parentBlock, prevBlockSeq), + } + + // We prepare information that is needed to compute the approvals for the new epoch, + // such as the validator set for the next epoch, and the approvals from peers. + validators, err := sm.GetValidatorSet(parentBlock.Metadata.SimplexEpochInfo.NextPChainReferenceHeight) + if err != nil { + return nil, err + } + + // We retrieve approvals that validators have sent us for the next epoch. + // These approvals are signed by validators of the next epoch. + approvalsFromPeers := sm.ApprovalsRetriever.RetrieveApprovals() + nextPChainHeight := newSimplexEpochInfo.NextPChainReferenceHeight + prevNextEpochApprovals := parentBlock.Metadata.SimplexEpochInfo.NextEpochApprovals + + newApprovals, err := computeNewApprovals(prevNextEpochApprovals, approvalsFromPeers, nextPChainHeight, sm.SignatureAggregator, validators) + if err != nil { + return nil, err + } + + // This might be the first time we created approvals for the next epoch, + // so we need to initialize the NextEpochApprovals. + if newSimplexEpochInfo.NextEpochApprovals == nil { + newSimplexEpochInfo.NextEpochApprovals = &NextEpochApprovals{} + } + // The node IDs and signature are aggregated across all past and present approvals. + newSimplexEpochInfo.NextEpochApprovals.NodeIDs = newApprovals.nodeIDs + newSimplexEpochInfo.NextEpochApprovals.Signature = newApprovals.signature + pChainHeight := parentBlock.Metadata.PChainHeight + + // We might not have enough approvals to seal the current epoch, + // in which case we just carry over the approvals we have so far to the next block, + // so that eventually we'll have enough approvals to seal the epoch. + if !newApprovals.canSeal { + return sm.buildBlockImpatiently(ctx, parentBlock, simplexMetadata, simplexBlacklist, newSimplexEpochInfo, pChainHeight) + } + + // Else, we have enough approvals to seal the epoch, so we create the sealing block. + return sm.createSealingBlock(ctx, parentBlock, simplexMetadata, simplexBlacklist, newSimplexEpochInfo, newApprovals, pChainHeight) +} + +// buildBlockImpatiently builds a block by waiting for the VM to build a block until MaxBlockBuildingWaitTime. +// If the VM fails to build a block within that time, we build a block without an inner block, +// so that we can continue making progress and not get stuck waiting for the VM. +func (sm *StateMachine) buildBlockImpatiently(ctx context.Context, parentBlock StateMachineBlock, simplexMetadata []byte, simplexBlacklist []byte, simplexEpochInfo SimplexEpochInfo, pChainHeight uint64) (*StateMachineBlock, error) { + impatientContext, cancel := context.WithTimeout(ctx, sm.MaxBlockBuildingWaitTime) + defer cancel() + + start := time.Now() + + // TODO: This P-chain height should be taken from the ICM epoch + childBlock, err := sm.BlockBuilder.BuildBlock(impatientContext, pChainHeight) + if err != nil && impatientContext.Err() == nil { + // If we got an error building the block, and we didn't time out, log the error but continue building the block without the inner block, + // so that we can continue making progress and not get stuck on a single block. + sm.Logger.Error("Error building block, building block without inner block instead", zap.Error(err)) + } + if impatientContext.Err() != nil { + sm.Logger.Debug("Timed out waiting for block to be built, building block without inner block instead", + zap.Duration("elapsed", time.Since(start)), zap.Duration("maxBlockBuildingWaitTime", sm.MaxBlockBuildingWaitTime)) + } + return sm.wrapBlock(parentBlock, childBlock, simplexEpochInfo, pChainHeight, simplexMetadata, simplexBlacklist), nil +} + +func (sm *StateMachine) createSealingBlock(ctx context.Context, parentBlock StateMachineBlock, simplexMetadata []byte, simplexBlacklist []byte, simplexEpochInfo SimplexEpochInfo, newApprovals *approvals, pChainHeight uint64) (*StateMachineBlock, error) { + validators, err := sm.GetValidatorSet(simplexEpochInfo.NextPChainReferenceHeight) + if err != nil { + return nil, err + } + if simplexEpochInfo.BlockValidationDescriptor == nil { + simplexEpochInfo.BlockValidationDescriptor = &BlockValidationDescriptor{} + } + simplexEpochInfo.BlockValidationDescriptor.AggregatedMembership.Members = validators + + // If this is not the first epoch, and this is the sealing block, we set the hash of the previous sealing block. + if simplexEpochInfo.EpochNumber > 1 { + prevSealingBlock, finalization, err := sm.GetBlock(RetrievingOpts{Height: simplexEpochInfo.EpochNumber}) + if err != nil { + sm.Logger.Error("Error retrieving previous sealing block", zap.Uint64("seq", simplexEpochInfo.EpochNumber), zap.Error(err)) + return nil, fmt.Errorf("failed to retrieve previous sealing InnerBlock at epoch %d: %w", simplexEpochInfo.EpochNumber-1, err) + } + if finalization == nil { + sm.Logger.Error("Previous sealing block is not finalized", zap.Uint64("seq", simplexEpochInfo.EpochNumber)) + return nil, fmt.Errorf("previous sealing InnerBlock at epoch %d is not finalized", simplexEpochInfo.EpochNumber-1) + } + simplexEpochInfo.PrevSealingBlockHash = prevSealingBlock.Digest() + } else { // Else, this is the first epoch, so we use the hash of the first ever Simplex block. + + firstSimplexBlock, err := findFirstSimplexBlock(sm.GetBlock, sm.LatestPersistedHeight+1) + if err != nil { + return nil, fmt.Errorf("failed to find first simplex block: %w", err) + } + firstSimplexBlockRetrieved, _, err := sm.GetBlock(RetrievingOpts{Height: firstSimplexBlock}) + if err != nil { + return nil, fmt.Errorf("failed to retrieve first simplex block at height %d: %w", firstSimplexBlock, err) + } + simplexEpochInfo.PrevSealingBlockHash = firstSimplexBlockRetrieved.Digest() + } + + return sm.buildBlockImpatiently(ctx, parentBlock, simplexMetadata, simplexBlacklist, simplexEpochInfo, pChainHeight) +} + +// wrapBlock creates a new StateMachineBlock by wrapping the VM block (if applicable) and adding the appropriate metadata. +func (sm *StateMachine) wrapBlock(parentBlock StateMachineBlock, childBlock VMBlock, newSimplexEpochInfo SimplexEpochInfo, pChainHeight uint64, simplexMetadata, simplexBlacklist []byte) *StateMachineBlock { + parentMetadata := parentBlock.Metadata + timestamp := parentMetadata.Timestamp + + hasChildBlock := childBlock != nil + + var newTimestamp time.Time + if hasChildBlock { + newTimestamp = childBlock.Timestamp() + timestamp = uint64(newTimestamp.UnixMilli()) + } + + return &StateMachineBlock{ + InnerBlock: childBlock, + Metadata: StateMachineMetadata{ + Timestamp: timestamp, + SimplexProtocolMetadata: simplexMetadata, + SimplexBlacklist: simplexBlacklist, + SimplexEpochInfo: newSimplexEpochInfo, + PChainHeight: pChainHeight, + }, + } +} + +// buildBlockEpochSealed builds a block where the epoch is being sealed due to a sealing block already created in this epoch. +func (sm *StateMachine) buildBlockEpochSealed(ctx context.Context, parentBlock StateMachineBlock, simplexMetadata, simplexBlacklist []byte, prevBlockSeq uint64) (*StateMachineBlock, error) { + // We check if the sealing block has already been finalized. + // If not, we build a Telock block. + + sealingBlockSeq := parentBlock.Metadata.SimplexEpochInfo.SealingBlockSeq + + // If the sealing block sequence is still 0, it means previous block was the sealing block. + if sealingBlockSeq == 0 { + sealingBlockSeq = prevBlockSeq + } + + if sealingBlockSeq == 0 { + return nil, fmt.Errorf("cannot build epoch sealed block: sealing block sequence is 0 or undefined") + } + + newSimplexEpochInfo := SimplexEpochInfo{ + PChainReferenceHeight: parentBlock.Metadata.SimplexEpochInfo.PChainReferenceHeight, + EpochNumber: parentBlock.Metadata.SimplexEpochInfo.EpochNumber, + NextPChainReferenceHeight: parentBlock.Metadata.SimplexEpochInfo.NextPChainReferenceHeight, + SealingBlockSeq: sealingBlockSeq, + PrevVMBlockSeq: computePrevVMBlockSeq(parentBlock, prevBlockSeq), + } + + _, finalization, err := sm.GetBlock(RetrievingOpts{Height: sealingBlockSeq}) + if err != nil { + return nil, fmt.Errorf("failed to retrieve sealing block at sequence %d: %w", sealingBlockSeq, err) + } + + isSealingBlockFinalized := finalization != nil + + if !isSealingBlockFinalized { + pChainHeight := parentBlock.Metadata.PChainHeight + return sm.wrapBlock(parentBlock, nil, newSimplexEpochInfo, pChainHeight, simplexMetadata, simplexBlacklist), nil + } + + // Else, we build a block for the new epoch. + newSimplexEpochInfo = SimplexEpochInfo{ + // P-chain reference height is previous block's NextPChainReferenceHeight. + PChainReferenceHeight: parentBlock.Metadata.SimplexEpochInfo.NextPChainReferenceHeight, + // The epoch number is the sequence of the sealing block. + EpochNumber: sealingBlockSeq, + PrevVMBlockSeq: computePrevVMBlockSeq(parentBlock, prevBlockSeq), + } + + // TODO: This P-chain height should be taken from the ICM epoch + childBlock, err := sm.BlockBuilder.BuildBlock(ctx, sm.GetPChainHeight()) + if err != nil { + return nil, err + } + + return sm.wrapBlock(parentBlock, childBlock, newSimplexEpochInfo, parentBlock.Metadata.PChainHeight, simplexMetadata, simplexBlacklist), nil +} + func IdentifyBlockType(nextBlockMD StateMachineMetadata, prevBlockMD StateMachineMetadata, prevSeq uint64) BlockType { simplexEpochInfo := nextBlockMD.SimplexEpochInfo prevSimplexEpochInfo := prevBlockMD.SimplexEpochInfo @@ -179,6 +795,71 @@ func IdentifyBlockType(nextBlockMD StateMachineMetadata, prevBlockMD StateMachin return BlockTypeNormal } +// constructSimplexZeroBlock constructs the SimplexEpochInfo for the zero block, which is the first ever block built by Simplex. +func constructSimplexZeroBlock(pChainHeight uint64, newValidatorSet NodeBLSMappings, prevVMBlockSeq uint64) SimplexEpochInfo { + newSimplexEpochInfo := SimplexEpochInfo{ + PChainReferenceHeight: pChainHeight, + EpochNumber: 1, + // We treat the zero block as a special case, and we encode in it the block validation descriptor, + // despite it not actually being a sealing block. This is because the zero block is the first block that introduces the validator set. + BlockValidationDescriptor: &BlockValidationDescriptor{ + AggregatedMembership: AggregatedMembership{ + Members: newValidatorSet, + }, + }, + NextEpochApprovals: nil, // We don't need to collect approvals to seal the first ever epoch. + PrevVMBlockSeq: prevVMBlockSeq, + SealingBlockSeq: 0, // We don't have a sealing block in the zero block. + PrevSealingBlockHash: [32]byte{}, // The zero block has no previous sealing block. + NextPChainReferenceHeight: 0, + } + return newSimplexEpochInfo +} + +func computeNewApprovals( + nextEpochApprovals *NextEpochApprovals, + approvalsFromPeers ValidatorSetApprovals, + pChainHeight uint64, + aggregator SignatureAggregator, + validators NodeBLSMappings, +) (*approvals, error) { + if nextEpochApprovals == nil { + nextEpochApprovals = &NextEpochApprovals{} + } + + oldApprovingNodes := bitmaskFromBytes(nextEpochApprovals.NodeIDs) + + // We map each validator to its relative index in the validator set. + nodeID2ValidatorIndex := make(map[nodeID]int) + validators.ForEach(func(i int, nbm NodeBLSMapping) { + nodeID2ValidatorIndex[nbm.NodeID] = i + }) + + // We have the approvals obtained from peers, but we need to sanitize them by filtering out approvals that are not valid, + // such as approvals that do not agree with our candidate auxiliary info digest and P-Chain height, + // and approvals that are from nodes that are not in the validator set or have already approved in prior blocks. + approvalsFromPeers = sanitizeApprovals(approvalsFromPeers, pChainHeight, nodeID2ValidatorIndex, oldApprovingNodes) + + // Next we aggregate both previous and new approvals to compute the new aggregated signatures and the new bitmask of approving nodes. + aggregatedSignature, newApprovingNodes, err := computeNewApproverSignaturesAndSigners(nextEpochApprovals, approvalsFromPeers, oldApprovingNodes, nodeID2ValidatorIndex, aggregator) + if err != nil { + return nil, err + } + + // we check if we have enough approvals to seal the epoch by computing the relative approval ratio, + // which is the ratio of the total weight of approving nodes divided by the total weight of all validators. + canSeal, err := canSealBlock(validators, newApprovingNodes) + if err != nil { + return nil, err + } + + return &approvals{ + canSeal: canSeal, + signature: aggregatedSignature, + nodeIDs: newApprovingNodes.Bytes(), + }, nil +} + // computeNewApproverSignaturesAndSigners computes the signatures of the nodes that approve the next epoch including the previous aggregated signature, // and bitmask of nodes that correspond to those signatures, and aggregates all signatures together. func computeNewApproverSignaturesAndSigners(nextEpochApprovals *NextEpochApprovals, approvalsFromPeers ValidatorSetApprovals, oldApprovingNodes bitmask, nodeID2ValidatorIndex map[nodeID]int, aggregator SignatureAggregator) ([]byte, bitmask, error) { @@ -214,6 +895,25 @@ func computeNewApproverSignaturesAndSigners(nextEpochApprovals *NextEpochApprova return aggregatedSignature, *newApprovingNodes, nil } +func canSealBlock(validators NodeBLSMappings, newApprovingNodes bitmask) (bool, error) { + approvingWeight, err := computeApprovingWeight(validators, &newApprovingNodes) + if err != nil { + return false, err + } + + totalWeight, err := computeTotalWeight(validators) + if err != nil { + return false, err + } + + threshold := big.NewRat(2, 3) + + approvingRatio := big.NewRat(approvingWeight, totalWeight) + + canSeal := approvingRatio.Cmp(threshold) > 0 + return canSeal, nil +} + // sanitizeApprovals filters out approvals that are not valid by checking if they agree with our candidate auxiliary info digest and P-Chain height, // and if they are from the validator set and haven't already been approved. func sanitizeApprovals(approvals ValidatorSetApprovals, pChainHeight uint64, nodeID2ValidatorIndex map[nodeID]int, oldApprovingNodes bitmask) ValidatorSetApprovals { diff --git a/msm/msm_test.go b/msm/msm_test.go index f501ab5c..0b7a94d1 100644 --- a/msm/msm_test.go +++ b/msm/msm_test.go @@ -6,11 +6,14 @@ package metadata import ( "bytes" "context" + "crypto/rand" + "encoding/asn1" "fmt" "testing" "time" "github.com/ava-labs/simplex" + "github.com/ava-labs/simplex/testutil" "github.com/stretchr/testify/require" ) @@ -47,6 +50,1001 @@ func (bs blockStore) getBlock(opts RetrievingOpts) (StateMachineBlock, *simplex. return blk.block, blk.finalization, nil } +type approvalsRetriever struct { + result ValidatorSetApprovals +} + +func (a approvalsRetriever) RetrieveApprovals() ValidatorSetApprovals { + return a.result +} + +type signatureVerifier struct { + err error +} + +func (sv *signatureVerifier) VerifySignature(signature []byte, message []byte, publicKey []byte) error { + return sv.err +} + +type signatureAggregator struct { +} + +type aggregatrdSignature struct { + Signatures [][]byte +} + +func (sv *signatureAggregator) AggregateSignatures(signatures ...[]byte) ([]byte, error) { + bytes, err := asn1.Marshal(aggregatrdSignature{Signatures: signatures}) + if err != nil { + return nil, err + } + return bytes, nil +} + +type noOpPChainListener struct{} + +func (n *noOpPChainListener) WaitForProgress(ctx context.Context, _ uint64) error { + <-ctx.Done() + return ctx.Err() +} + +type blockBuilder struct { + block VMBlock + err error +} + +func (bb *blockBuilder) WaitForPendingBlock(_ context.Context) { + // Block is always ready in tests. +} + +func (bb *blockBuilder) BuildBlock(_ context.Context, _ uint64) (VMBlock, error) { + return bb.block, bb.err +} + +type validatorSetRetriever struct { + result NodeBLSMappings + resultMap map[uint64]NodeBLSMappings + err error +} + +func (vsr *validatorSetRetriever) getValidatorSet(height uint64) (NodeBLSMappings, error) { + if vsr.resultMap != nil { + if result, ok := vsr.resultMap[height]; ok { + return result, vsr.err + } + } + return vsr.result, vsr.err +} + +type keyAggregator struct{} + +func (ka *keyAggregator) AggregateKeys(keys ...[]byte) ([]byte, error) { + aggregated := make([]byte, 0) + for _, key := range keys { + aggregated = append(aggregated, key...) + } + return aggregated, nil +} + +var ( + genesisBlock = StateMachineBlock{ + // Genesis block metadata has all zero values + InnerBlock: &InnerBlock{ + TS: time.Now(), + Bytes: []byte{1, 2, 3}, + }, + } +) + +func TestMSMFirstBlockAfterGenesis(t *testing.T) { + validMD := simplex.ProtocolMetadata{ + Round: 0, + Seq: 1, + Epoch: 1, + Prev: genesisBlock.Digest(), + } + + for _, testCase := range []struct { + name string + md simplex.ProtocolMetadata + err string + configure func(*StateMachine, *testConfig) + mutateBlock func(*StateMachineBlock) + }{ + { + name: "correct information", + md: validMD, + }, + { + name: "trying to build a genesis block", + md: validMD, + mutateBlock: func(block *StateMachineBlock) { + md, err := simplex.ProtocolMetadataFromBytes(block.Metadata.SimplexProtocolMetadata) + require.NoError(t, err) + md.Seq = 0 + block.Metadata.SimplexProtocolMetadata = md.Bytes() + }, + err: "attempted to build a genesis inner block", + }, + { + name: "previous block not found", + md: validMD, + configure: func(_ *StateMachine, tc *testConfig) { + delete(tc.blockStore, 0) + }, + err: "failed to retrieve previous (0) inner block", + }, + { + name: "parent has no inner block", + md: validMD, + configure: func(_ *StateMachine, tc *testConfig) { + tc.blockStore[0] = &outerBlock{ + block: StateMachineBlock{}, + } + }, + err: "parent inner block (", + }, + { + name: "wrong epoch number", + md: validMD, + mutateBlock: func(block *StateMachineBlock) { + block.Metadata.SimplexEpochInfo.EpochNumber = 2 + }, + err: "invalid epoch number (2), should be 1", + }, + { + name: "P-chain height too big", + md: validMD, + mutateBlock: func(block *StateMachineBlock) { + block.Metadata.PChainHeight = 110 + }, + err: "invalid P-chain height (110) is too big", + }, + { + name: "P-chain height smaller than parent", + md: validMD, + configure: func(_ *StateMachine, tc *testConfig) { + tc.blockStore[0] = &outerBlock{ + block: StateMachineBlock{ + InnerBlock: &InnerBlock{TS: time.Now(), Bytes: []byte{1, 2, 3}}, + Metadata: StateMachineMetadata{PChainHeight: 110}, + }, + } + }, + err: "invalid P-chain height (100) is smaller than parent InnerBlock's P-chain height (110)", + }, + { + name: "validator set retrieval fails", + md: validMD, + configure: func(_ *StateMachine, tc *testConfig) { + tc.validatorSetRetriever.err = fmt.Errorf("validator set unavailable") + }, + err: "failed to retrieve validator set", + }, + { + name: "nil BlockValidationDescriptor", + md: validMD, + mutateBlock: func(block *StateMachineBlock) { + block.Metadata.SimplexEpochInfo.BlockValidationDescriptor = nil + }, + err: "invalid BlockValidationDescriptor: should not be nil", + }, + { + name: "membership mismatch", + md: validMD, + configure: func(_ *StateMachine, tc *testConfig) { + tc.validatorSetRetriever.result = NodeBLSMappings{ + {BLSKey: []byte{1}, Weight: 1}, + } + }, + err: "invalid BlockValidationDescriptor: should match validator set", + }, + { + name: "SimplexEpochInfo mismatch", + md: validMD, + mutateBlock: func(block *StateMachineBlock) { + block.Metadata.SimplexEpochInfo.PrevVMBlockSeq = 999 + }, + err: "invalid SimplexEpochInfo", + }, + } { + t.Run(testCase.name, func(t *testing.T) { + sm1, testConfig1 := newStateMachine(t) + sm2, testConfig2 := newStateMachine(t) + + testConfig1.blockStore[0] = &outerBlock{ + block: genesisBlock, + } + + testConfig2.blockStore[0] = &outerBlock{ + block: genesisBlock, + } + + if testCase.configure != nil { + testCase.configure(&sm2, testConfig2) + } + + block, err := sm1.BuildBlock(context.Background(), genesisBlock, testCase.md, nil) + require.NoError(t, err) + require.NotNil(t, block) + + if testCase.mutateBlock != nil { + testCase.mutateBlock(block) + } + + err = sm2.VerifyBlock(context.Background(), block) + if testCase.err != "" { + require.ErrorContains(t, err, testCase.err) + return + } + require.NoError(t, err) + }) + } +} + +func TestMSMFirstSimplexBlockAfterPreSimplexBlocks(t *testing.T) { + preSimplexParent := StateMachineBlock{ + InnerBlock: &InnerBlock{ + TS: time.Now(), + BlockHeight: 42, + Bytes: []byte{4, 5, 6}, + }, + // Zero-valued metadata means this is a pre-Simplex block or a genesis block. + // But since the height is 42, it can't be a genesis block, so it must be a pre-Simplex block. + Metadata: StateMachineMetadata{}, + } + + md := simplex.ProtocolMetadata{ + Round: 0, + Seq: 43, + Epoch: 1, + Prev: preSimplexParent.Digest(), + } + + sm1, testConfig1 := newStateMachine(t) + sm2, testConfig2 := newStateMachine(t) + + testConfig1.blockStore[42] = &outerBlock{block: preSimplexParent} + testConfig2.blockStore[42] = &outerBlock{block: preSimplexParent} + + testConfig1.blockBuilder.block = &InnerBlock{ + TS: time.Now(), + BlockHeight: 43, + Bytes: []byte{7, 8, 9}, + } + + block, err := sm1.BuildBlock(context.Background(), preSimplexParent, md, nil) + require.NoError(t, err) + require.NotNil(t, block) + + require.NoError(t, sm2.VerifyBlock(context.Background(), block)) + + require.Equal(t, &StateMachineBlock{ + InnerBlock: &InnerBlock{ + TS: testConfig1.blockBuilder.block.Timestamp(), + BlockHeight: 43, + Bytes: []byte{7, 8, 9}, + }, + Metadata: StateMachineMetadata{ + Timestamp: uint64(testConfig1.blockBuilder.block.Timestamp().UnixMilli()), + PChainHeight: 100, + SimplexProtocolMetadata: md.Bytes(), + SimplexEpochInfo: SimplexEpochInfo{ + PChainReferenceHeight: 100, + EpochNumber: 1, + PrevVMBlockSeq: 42, + BlockValidationDescriptor: &BlockValidationDescriptor{ + AggregatedMembership: AggregatedMembership{ + Members: testConfig1.validatorSetRetriever.result, + }, + }, + }, + }, + }, block) +} + +func TestMSMNormalOp(t *testing.T) { + newPChainHeight := uint64(200) + newValidatorSet := NodeBLSMappings{ + {BLSKey: []byte{5}, Weight: 1}, {BLSKey: []byte{6}, Weight: 1}, {BLSKey: []byte{7}, Weight: 1}, + } + + for _, testCase := range []struct { + name string + setup func(*StateMachine, *testConfig) + mutateBlock func(*StateMachineBlock) + err string + expectedPChainHeight uint64 + expectedNextPChainRefHeight uint64 + }{ + { + name: "correct information", + expectedPChainHeight: 100, + }, + { + name: "trying to build a genesis block", + mutateBlock: func(block *StateMachineBlock) { + md, err := simplex.ProtocolMetadataFromBytes(block.Metadata.SimplexProtocolMetadata) + require.NoError(t, err) + md.Seq = 0 + block.Metadata.SimplexProtocolMetadata = md.Bytes() + }, + err: "attempted to build a genesis inner block", + }, + { + name: "previous block not found", + mutateBlock: func(block *StateMachineBlock) { + md, err := simplex.ProtocolMetadataFromBytes(block.Metadata.SimplexProtocolMetadata) + require.NoError(t, err) + md.Seq = 999 + block.Metadata.SimplexProtocolMetadata = md.Bytes() + }, + err: "failed to retrieve previous (998) inner block", + }, + { + name: "P-chain height too big", + mutateBlock: func(block *StateMachineBlock) { + block.Metadata.PChainHeight = 110 + }, + err: "invalid P-chain height (110) is too big", + }, + { + name: "P-chain height smaller than parent", + mutateBlock: func(block *StateMachineBlock) { + block.Metadata.PChainHeight = 0 + }, + err: "invalid P-chain height (0) is smaller than parent block's P-chain height (100)", + }, + { + name: "wrong epoch number", + mutateBlock: func(block *StateMachineBlock) { + block.Metadata.SimplexEpochInfo.EpochNumber = 2 + }, + err: "expected epoch number to be 1 but got 2", + }, + { + name: "non-nil BlockValidationDescriptor", + mutateBlock: func(block *StateMachineBlock) { + block.Metadata.SimplexEpochInfo.BlockValidationDescriptor = &BlockValidationDescriptor{} + }, + err: "failed to find first Simplex block", + }, + { + name: "non-zero sealing block seq", + mutateBlock: func(block *StateMachineBlock) { + block.Metadata.SimplexEpochInfo.SealingBlockSeq = 5 + }, + err: "expected sealing block sequence number to be 0 but got 5", + }, + { + name: "wrong PChainReferenceHeight", + mutateBlock: func(block *StateMachineBlock) { + block.Metadata.SimplexEpochInfo.PChainReferenceHeight = 50 + }, + err: "expected P-chain reference height to be 100 but got 50", + }, + { + name: "non-empty PrevSealingBlockHash", + mutateBlock: func(block *StateMachineBlock) { + block.Metadata.SimplexEpochInfo.PrevSealingBlockHash = [32]byte{1, 2, 3} + }, + err: "expected prev sealing block hash of a non sealing block to be empty", + }, + { + name: "wrong PrevVMBlockSeq", + mutateBlock: func(block *StateMachineBlock) { + block.Metadata.SimplexEpochInfo.PrevVMBlockSeq = 999 + }, + err: "expected PrevVMBlockSeq to be", + }, + { + name: "validator set change detected", + setup: func(sm *StateMachine, tc *testConfig) { + tc.validatorSetRetriever.resultMap = map[uint64]NodeBLSMappings{ + newPChainHeight: newValidatorSet, + } + sm.GetPChainHeight = func() uint64 { return newPChainHeight } + }, + expectedPChainHeight: newPChainHeight, + expectedNextPChainRefHeight: newPChainHeight, + }, + } { + t.Run(testCase.name, func(t *testing.T) { + chain := makeChain(t, 5, 10) + sm1, testConfig1 := newStateMachine(t) + sm2, testConfig2 := newStateMachine(t) + + for i, block := range chain { + testConfig1.blockStore[uint64(i)] = &outerBlock{block: block} + testConfig2.blockStore[uint64(i)] = &outerBlock{block: block} + } + + lastBlock := chain[len(chain)-1] + md, err := simplex.ProtocolMetadataFromBytes(lastBlock.Metadata.SimplexProtocolMetadata) + require.NoError(t, err) + + md.Seq++ + md.Round++ + md.Prev = lastBlock.Digest() + + var blacklist simplex.Blacklist + blacklist.NodeCount = 4 + + blockTime := lastBlock.InnerBlock.Timestamp().Add(time.Second) + + content := make([]byte, 10) + _, err = rand.Read(content) + require.NoError(t, err) + + testConfig1.blockBuilder.block = &InnerBlock{ + TS: blockTime, + BlockHeight: lastBlock.InnerBlock.Height(), + Bytes: content, + } + + if testCase.setup != nil { + testCase.setup(&sm1, testConfig1) + testCase.setup(&sm2, testConfig2) + } + + block1, err := sm1.BuildBlock(context.Background(), lastBlock, *md, &blacklist) + require.NoError(t, err) + require.NotNil(t, block1) + + if testCase.mutateBlock != nil { + testCase.mutateBlock(block1) + } + + err = sm2.VerifyBlock(context.Background(), block1) + if testCase.err != "" { + require.ErrorContains(t, err, testCase.err) + return + } + require.NoError(t, err) + + require.Equal(t, &StateMachineBlock{ + InnerBlock: &InnerBlock{ + TS: blockTime, + BlockHeight: lastBlock.InnerBlock.Height(), + Bytes: content, + }, + Metadata: StateMachineMetadata{ + SimplexBlacklist: blacklist.Bytes(), + Timestamp: uint64(blockTime.UnixMilli()), + PChainHeight: testCase.expectedPChainHeight, + SimplexProtocolMetadata: md.Bytes(), + SimplexEpochInfo: SimplexEpochInfo{ + PChainReferenceHeight: 100, + EpochNumber: 1, + PrevVMBlockSeq: lastBlock.InnerBlock.Height(), + NextPChainReferenceHeight: testCase.expectedNextPChainRefHeight, + }, + }, + }, block1) + }) + } +} + +func TestMSMFullEpochLifecycle(t *testing.T) { + // Validator sets: epoch 1 uses validatorSet1, epoch 2 uses validatorSet2. + node1 := [20]byte{1} + node2 := [20]byte{2} + node3 := [20]byte{3} + + validatorSet1 := NodeBLSMappings{ + {NodeID: node1, BLSKey: []byte{1}, Weight: 1}, + {NodeID: node2, BLSKey: []byte{2}, Weight: 1}, + {NodeID: node3, BLSKey: []byte{3}, Weight: 1}, + } + validatorSet2 := NodeBLSMappings{ + {NodeID: node1, BLSKey: []byte{1}, Weight: 1}, + {NodeID: node2, BLSKey: []byte{4}, Weight: 1}, + {NodeID: node3, BLSKey: []byte{5}, Weight: 1}, + } + + pChainHeight1 := uint64(100) + pChainHeight2 := uint64(200) + + startTime := time.Now() + + nextBlock := func(height uint64) *InnerBlock { + return &InnerBlock{ + TS: startTime.Add(time.Duration(height) * time.Millisecond), + BlockHeight: height, + Bytes: []byte{byte(height)}, + } + } + + // ----- Step 0: Building on top of genesis or upgrading to Simplex----- + genesis := StateMachineBlock{ + InnerBlock: &InnerBlock{ + BlockHeight: 0, // Genesis block has height 0 + TS: startTime, + Bytes: []byte{0}, + }, + } + + notGenesis := StateMachineBlock{ + InnerBlock: &InnerBlock{ + BlockHeight: 42, + TS: startTime, + Bytes: []byte{0}, + }, + } + for _, testCase := range []struct { + name string + firstBlockBeforeSimplex StateMachineBlock + }{ + { + name: "building on top of genesis", + firstBlockBeforeSimplex: genesis, + }, + { + name: "upgrading to Simplex from pre-Simplex blocks", + firstBlockBeforeSimplex: notGenesis, + }, + } { + t.Run(testCase.name, func(t *testing.T) { + + currentPChainHeight := pChainHeight1 + + getValidatorSet := func(height uint64) (NodeBLSMappings, error) { + if height >= pChainHeight2 { + return validatorSet2, nil + } + return validatorSet1, nil + } + getPChainHeight := func() uint64 { + return currentPChainHeight + } + + // Create fresh state machine instances for each iteration. + sm, tc := newStateMachine(t) + sm.GetValidatorSet = getValidatorSet + sm.GetPChainHeight = getPChainHeight + + smVerify, tcVerify := newStateMachine(t) + smVerify.GetValidatorSet = getValidatorSet + smVerify.GetPChainHeight = getPChainHeight + + // addBlock adds a block to both block stores so builder and verifier stay in sync. + addBlock := func(seq uint64, block StateMachineBlock, fin *simplex.Finalization) { + tc.blockStore[seq] = &outerBlock{block: block, finalization: fin} + tcVerify.blockStore[seq] = &outerBlock{block: block, finalization: fin} + } + + baseSeq := testCase.firstBlockBeforeSimplex.InnerBlock.Height() + addBlock(baseSeq, testCase.firstBlockBeforeSimplex, nil) + + aggr := &signatureAggregator{} + + // ----- Step 1: Build zero epoch block (first simplex block) ----- + tc.blockBuilder.block = nextBlock(1) + md := simplex.ProtocolMetadata{ + Seq: baseSeq + 1, + Round: 0, + Epoch: 1, + Prev: testCase.firstBlockBeforeSimplex.Digest(), + } + + block1, err := sm.BuildBlock(context.Background(), testCase.firstBlockBeforeSimplex, md, nil) + require.NoError(t, err) + require.Equal(t, &StateMachineBlock{ + InnerBlock: nextBlock(1), + Metadata: StateMachineMetadata{ + Timestamp: uint64(startTime.Add(1 * time.Millisecond).UnixMilli()), + PChainHeight: pChainHeight1, + SimplexProtocolMetadata: md.Bytes(), + SimplexEpochInfo: SimplexEpochInfo{ + PChainReferenceHeight: pChainHeight1, + EpochNumber: 1, + PrevVMBlockSeq: baseSeq, + BlockValidationDescriptor: &BlockValidationDescriptor{ + AggregatedMembership: AggregatedMembership{ + Members: validatorSet1, + }, + }, + }, + }, + }, block1) + addBlock(md.Seq, *block1, nil) + + require.NoError(t, smVerify.VerifyBlock(context.Background(), block1)) + + // After we build the first block, the StateMachine should consider it as the latest persisted height. + sm.LatestPersistedHeight = baseSeq + 1 + smVerify.LatestPersistedHeight = baseSeq + 1 + + // ----- Step 2: Build a normal block (no validator set change) ----- + tc.blockBuilder.block = nextBlock(2) + md = simplex.ProtocolMetadata{Seq: baseSeq + 2, Round: 1, Epoch: 1, Prev: block1.Digest()} + block2, err := sm.BuildBlock(context.Background(), *block1, md, nil) + require.NoError(t, err) + require.Equal(t, &StateMachineBlock{ + InnerBlock: nextBlock(2), + Metadata: StateMachineMetadata{ + Timestamp: uint64(startTime.Add(2 * time.Millisecond).UnixMilli()), + PChainHeight: pChainHeight1, + SimplexProtocolMetadata: md.Bytes(), + SimplexEpochInfo: SimplexEpochInfo{ + PChainReferenceHeight: pChainHeight1, + EpochNumber: 1, + PrevVMBlockSeq: baseSeq + 1, + }, + }, + }, block2) + addBlock(md.Seq, *block2, nil) + + require.NoError(t, smVerify.VerifyBlock(context.Background(), block2)) + + // ----- Step 3: Build a normal block that detects a validator set change ----- + // Advance P-chain height so that GetValidatorSet returns a different set. + currentPChainHeight = pChainHeight2 + + tc.blockBuilder.block = nextBlock(3) + md = simplex.ProtocolMetadata{Seq: baseSeq + 3, Round: 2, Epoch: 1, Prev: block2.Digest()} + block3, err := sm.BuildBlock(context.Background(), *block2, md, nil) + require.NoError(t, err) + require.Equal(t, &StateMachineBlock{ + InnerBlock: nextBlock(3), + Metadata: StateMachineMetadata{ + Timestamp: uint64(startTime.Add(3 * time.Millisecond).UnixMilli()), + PChainHeight: pChainHeight2, + SimplexProtocolMetadata: md.Bytes(), + SimplexEpochInfo: SimplexEpochInfo{ + PChainReferenceHeight: pChainHeight1, + EpochNumber: 1, + PrevVMBlockSeq: baseSeq + 2, + NextPChainReferenceHeight: pChainHeight2, + }, + }, + }, block3) + addBlock(md.Seq, *block3, nil) + + require.NoError(t, smVerify.VerifyBlock(context.Background(), block3)) + + // ----- Step 4: First collecting block (1/3 approvals, not enough to seal) ----- + + // Override ApprovalsRetriever to use our dynamic approvals. + var approvalsResult ValidatorSetApprovals + sm.ApprovalsRetriever = &dynamicApprovalsRetriever{approvals: &approvalsResult} + + approvalsResult = ValidatorSetApprovals{ + { + NodeID: node1, + PChainHeight: pChainHeight2, + Signature: []byte("sig1"), + }, + } + + // node1 is at index 0 in validatorSet2 → bitmask bit 0 → {1} + bitmask := []byte{1} + sig, err := aggr.AggregateSignatures([]byte("sig1")) + require.NoError(t, err) + + tc.blockBuilder.block = nextBlock(4) + md = simplex.ProtocolMetadata{Seq: baseSeq + 4, Round: 3, Epoch: 1, Prev: block3.Digest()} + block4, err := sm.BuildBlock(context.Background(), *block3, md, nil) + require.NoError(t, err) + require.Equal(t, &StateMachineBlock{ + InnerBlock: nextBlock(4), + Metadata: StateMachineMetadata{ + Timestamp: uint64(startTime.Add(4 * time.Millisecond).UnixMilli()), + PChainHeight: pChainHeight2, + SimplexProtocolMetadata: md.Bytes(), + SimplexEpochInfo: SimplexEpochInfo{ + PChainReferenceHeight: pChainHeight1, + EpochNumber: 1, + PrevVMBlockSeq: baseSeq + 3, + NextPChainReferenceHeight: pChainHeight2, + NextEpochApprovals: &NextEpochApprovals{ + NodeIDs: bitmask, + Signature: sig, + }, + }, + }, + }, block4) + addBlock(md.Seq, *block4, nil) + + require.NoError(t, smVerify.VerifyBlock(context.Background(), block4)) + + // ----- Step 5: Second collecting block (2/3 approvals, still not enough since threshold is strictly > 2/3) ----- + approvalsResult = ValidatorSetApprovals{ + { + NodeID: node2, + PChainHeight: pChainHeight2, + Signature: []byte("sig2"), + }, + } + + // node2 is at index 1 → bitmask bits 0,1 → {3} + sig, err = aggr.AggregateSignatures([]byte("sig2"), sig) + require.NoError(t, err) + bitmask = []byte{3} + + tc.blockBuilder.block = nextBlock(5) + md = simplex.ProtocolMetadata{Seq: baseSeq + 5, Round: 4, Epoch: 1, Prev: block4.Digest()} + block5, err := sm.BuildBlock(context.Background(), *block4, md, nil) + require.NoError(t, err) + require.Equal(t, &StateMachineBlock{ + InnerBlock: nextBlock(5), + Metadata: StateMachineMetadata{ + Timestamp: uint64(startTime.Add(5 * time.Millisecond).UnixMilli()), + PChainHeight: pChainHeight2, + SimplexProtocolMetadata: md.Bytes(), + SimplexEpochInfo: SimplexEpochInfo{ + PChainReferenceHeight: pChainHeight1, + EpochNumber: 1, + PrevVMBlockSeq: baseSeq + 4, + NextPChainReferenceHeight: pChainHeight2, + NextEpochApprovals: &NextEpochApprovals{ + NodeIDs: bitmask, + Signature: sig, + }, + }, + }, + }, block5) + addBlock(md.Seq, *block5, nil) + + require.NoError(t, smVerify.VerifyBlock(context.Background(), block5)) + + // ----- Step 6: Sealing block (3/3 approvals, enough to seal) ----- + approvalsResult = ValidatorSetApprovals{ + { + NodeID: node3, + PChainHeight: pChainHeight2, + Signature: []byte("sig3"), + }, + } + + // node3 is at index 2 → bitmask bits 0,1,2 → {7} + sig6, err := aggr.AggregateSignatures([]byte("sig3"), sig) + require.NoError(t, err) + bitmask = []byte{7} + + tc.blockBuilder.block = nextBlock(6) + md = simplex.ProtocolMetadata{Seq: baseSeq + 6, Round: 5, Epoch: 1, Prev: block5.Digest()} + block6, err := sm.BuildBlock(context.Background(), *block5, md, nil) + require.NoError(t, err) + require.Equal(t, &StateMachineBlock{ + InnerBlock: nextBlock(6), + Metadata: StateMachineMetadata{ + Timestamp: uint64(startTime.Add(6 * time.Millisecond).UnixMilli()), + PChainHeight: pChainHeight2, + SimplexProtocolMetadata: md.Bytes(), + SimplexEpochInfo: SimplexEpochInfo{ + PChainReferenceHeight: pChainHeight1, + EpochNumber: 1, + PrevVMBlockSeq: baseSeq + 5, + NextPChainReferenceHeight: pChainHeight2, + SealingBlockSeq: 0, + PrevSealingBlockHash: block1.Digest(), + BlockValidationDescriptor: &BlockValidationDescriptor{ + AggregatedMembership: AggregatedMembership{ + Members: validatorSet2, + }, + }, + NextEpochApprovals: &NextEpochApprovals{ + NodeIDs: bitmask, + Signature: sig6, + }, + }, + }, + }, block6) + addBlock(md.Seq, *block6, nil) + + require.NoError(t, smVerify.VerifyBlock(context.Background(), block6)) + + sealingSeq := baseSeq + 6 // The sealing block's sequence (md.Seq from step 6) + + backupStoreTC := tc.blockStore.clone() + backupStoreTCVerify := tcVerify.blockStore.clone() + + for _, subTestCase := range []struct { + name string + setup func() + }{ + { + name: "sealing block not finalized yet", + setup: func() { + addBlock(sealingSeq, tc.blockStore[sealingSeq].block, nil) + }, + }, + { + name: "sealing block immediately finalized", + setup: func() { + addBlock(sealingSeq, tc.blockStore[sealingSeq].block, &simplex.Finalization{}) + }, + }, + } { + testName := fmt.Sprintf("%s-%s", testCase.name, subTestCase.name) + t.Run(testName, func(t *testing.T) { + tc.blockStore = backupStoreTC.clone() + sm.GetBlock = tc.blockStore.getBlock + tcVerify.blockStore = backupStoreTCVerify.clone() + smVerify.GetBlock = tcVerify.blockStore.getBlock + + subTestCase.setup() + + tc.blockBuilder.block = nextBlock(7) + md = simplex.ProtocolMetadata{Seq: baseSeq + 7, Round: 6, Epoch: 1, Prev: block6.Digest()} + + // If the sealing block isn't finalized yet, we expect to build a Telock. + // However, despite the fact that the block builder is willing to build a new block, + // a Telock shouldn't contain an inner block. + if tc.blockStore[sealingSeq].finalization == nil { + telock, err := sm.BuildBlock(context.Background(), *block6, md, nil) + require.NoError(t, err) + + require.Equal(t, &StateMachineBlock{ + InnerBlock: nil, + Metadata: StateMachineMetadata{ + Timestamp: uint64(startTime.Add(6 * time.Millisecond).UnixMilli()), + PChainHeight: pChainHeight2, + SimplexProtocolMetadata: md.Bytes(), + SimplexEpochInfo: SimplexEpochInfo{ + PChainReferenceHeight: pChainHeight1, + EpochNumber: 1, + NextPChainReferenceHeight: pChainHeight2, + PrevVMBlockSeq: baseSeq + 6, + SealingBlockSeq: sealingSeq, + }, + }, + }, telock) + + // Next, finalize the sealing block after we have built a Telock. + addBlock(sealingSeq, tc.blockStore[sealingSeq].block, &simplex.Finalization{}) + } + + // ----- Step 7: Build a new epoch block (sealing block is finalized) ----- + + block7, err := sm.BuildBlock(context.Background(), *block6, md, nil) + require.NoError(t, err) + require.Equal(t, &StateMachineBlock{ + InnerBlock: nextBlock(7), + Metadata: StateMachineMetadata{ + Timestamp: uint64(startTime.Add(7 * time.Millisecond).UnixMilli()), + PChainHeight: pChainHeight2, + SimplexProtocolMetadata: md.Bytes(), + SimplexEpochInfo: SimplexEpochInfo{ + PChainReferenceHeight: pChainHeight2, + EpochNumber: sealingSeq, + PrevVMBlockSeq: baseSeq + 6, + }, + }, + }, block7) + addBlock(md.Seq, *block7, nil) + + require.NoError(t, smVerify.VerifyBlock(context.Background(), block7)) + }) + } + }) + } +} + +type dynamicApprovalsRetriever struct { + approvals *ValidatorSetApprovals +} + +func (d *dynamicApprovalsRetriever) RetrieveApprovals() ValidatorSetApprovals { + return *d.approvals +} + +func makeChain(t *testing.T, simplexStartHeight uint64, endHeight uint64) []StateMachineBlock { + startTime := time.Now().Add(-time.Duration(endHeight+2) * time.Second) + blocks := make([]StateMachineBlock, 0, endHeight+1) + var round, seq uint64 + for h := uint64(0); h <= endHeight; h++ { + index := len(blocks) + + if h == 0 { + blocks = append(blocks, genesisBlock) + continue + } + + if h < simplexStartHeight { + blocks = append(blocks, makeNonSimplexBlock(t, simplexStartHeight, startTime, h)) + continue + } + + seq = uint64(index) + + blocks = append(blocks, makeNormalSimplexBlock(t, index, blocks, startTime, h, round, seq)) + round++ + } + return blocks +} + +func makeNormalSimplexBlock(t *testing.T, index int, blocks []StateMachineBlock, start time.Time, h uint64, round uint64, seq uint64) StateMachineBlock { + content := make([]byte, 10) + _, err := rand.Read(content) + require.NoError(t, err) + + prev := genesisBlock.Digest() + if index > 0 { + prev = blocks[index-1].Digest() + } + + return StateMachineBlock{ + InnerBlock: &InnerBlock{ + TS: start.Add(time.Duration(h) * time.Second), + BlockHeight: h, + Bytes: []byte{1, 2, 3}, + }, + Metadata: StateMachineMetadata{ + PChainHeight: 100, + SimplexProtocolMetadata: (&simplex.ProtocolMetadata{ + Round: round, + Seq: seq, + Epoch: 1, + Prev: prev, + }).Bytes(), + SimplexEpochInfo: SimplexEpochInfo{ + PrevSealingBlockHash: [32]byte{}, + PChainReferenceHeight: 100, + EpochNumber: 1, + PrevVMBlockSeq: uint64(index), + }, + }, + } +} + +func makeNonSimplexBlock(t *testing.T, startHeight uint64, start time.Time, h uint64) StateMachineBlock { + content := make([]byte, 10) + _, err := rand.Read(content) + require.NoError(t, err) + + return StateMachineBlock{ + InnerBlock: &InnerBlock{ + TS: start.Add(time.Duration(h-startHeight) * time.Second), + BlockHeight: h, + Bytes: []byte{1, 2, 3}, + }, + } +} + +type testConfig struct { + blockStore blockStore + approvalsRetriever approvalsRetriever + signatureVerifier signatureVerifier + signatureAggregator signatureAggregator + blockBuilder blockBuilder + keyAggregator keyAggregator + validatorSetRetriever validatorSetRetriever +} + +func newStateMachine(t *testing.T) (StateMachine, *testConfig) { + bs := make(blockStore) + + var testConfig testConfig + testConfig.blockStore = bs + testConfig.validatorSetRetriever.result = NodeBLSMappings{ + {BLSKey: []byte{1}, Weight: 1}, {BLSKey: []byte{2}, Weight: 1}, + } + + sm := StateMachine{ + GetTime: time.Now, + TimeSkewLimit: time.Second * 5, + Logger: testutil.MakeLogger(t), + GetBlock: testConfig.blockStore.getBlock, + MaxBlockBuildingWaitTime: time.Second, + ApprovalsRetriever: &testConfig.approvalsRetriever, + SignatureVerifier: &testConfig.signatureVerifier, + SignatureAggregator: &testConfig.signatureAggregator, + BlockBuilder: &testConfig.blockBuilder, + KeyAggregator: &testConfig.keyAggregator, + GetPChainHeight: func() uint64 { + return 100 + }, + GetUpgrades: func() any { + return nil + }, + GetValidatorSet: testConfig.validatorSetRetriever.getValidatorSet, + PChainProgressListener: &noOpPChainListener{}, + } + return sm, &testConfig +} + func TestIdentifyCurrentState(t *testing.T) { bvd := &BlockValidationDescriptor{} for _, tc := range []struct { diff --git a/msm/verification.go b/msm/verification.go new file mode 100644 index 00000000..ecf314aa --- /dev/null +++ b/msm/verification.go @@ -0,0 +1,515 @@ +// Copyright (C) 2019-2025, Ava Labs, Inc. All rights reserved. +// See the file LICENSE for licensing terms. + +package metadata + +import ( + "bytes" + "encoding/binary" + "fmt" + "time" + + "github.com/ava-labs/simplex" +) + +type verificationInput struct { + prevMD StateMachineMetadata + proposedBlockMD StateMachineMetadata + hasInnerBlock bool + innerBlockTimestamp time.Time // only set when hasInnerBlock is true + prevBlockSeq uint64 + nextBlockType BlockType + state state +} + +type verifier interface { + Verify(in verificationInput) error +} +type validationDescriptorVerifier struct { + getValidatorSet ValidatorSetRetriever +} + +func (vd *validationDescriptorVerifier) Verify(in verificationInput) error { + prev, next := in.prevMD.SimplexEpochInfo, in.proposedBlockMD.SimplexEpochInfo + switch in.nextBlockType { + case BlockTypeSealing: + return vd.verifySealingBlock(prev, next) + default: + return vd.verifyEmptyValidationDescriptor(prev, next) + } +} + +func (vd *validationDescriptorVerifier) verifySealingBlock(prev SimplexEpochInfo, next SimplexEpochInfo) error { + validators, err := vd.getValidatorSet(prev.NextPChainReferenceHeight) + if err != nil { + return err + } + + if next.BlockValidationDescriptor == nil { + return fmt.Errorf("validation descriptor should not be nil for a sealing block") + } + + if !validators.Equal(next.BlockValidationDescriptor.AggregatedMembership.Members) { + return fmt.Errorf("expected validator set specified at P-chain height %d does not match validator set encoded in new block", next.NextPChainReferenceHeight) + } + + return nil +} + +func (vd *validationDescriptorVerifier) verifyEmptyValidationDescriptor(_ SimplexEpochInfo, next SimplexEpochInfo) error { + if next.BlockValidationDescriptor != nil { + return fmt.Errorf("block validation descriptor should be nil but got %v", next.BlockValidationDescriptor) + } + return nil +} + +type nextEpochApprovalsVerifier struct { + sigVerifier SignatureVerifier + getValidatorSet ValidatorSetRetriever + keyAggregator KeyAggregator +} + +func (nv *nextEpochApprovalsVerifier) Verify(in verificationInput) error { + prev, next := in.prevMD.SimplexEpochInfo, in.proposedBlockMD.SimplexEpochInfo + + switch in.nextBlockType { + case BlockTypeSealing: + return nv.verifySealingBlock(prev, next) + case BlockTypeNormal: + return nv.verifyNormal(prev, next) + default: + return nv.verifyEmptyNextEpochApprovals(prev, next) + } +} + +func (nv *nextEpochApprovalsVerifier) verifySealingBlock(prev SimplexEpochInfo, next SimplexEpochInfo) error { + if next.NextEpochApprovals == nil { + return fmt.Errorf("next epoch approvals should not be nil for a sealing block") + } + + validators, err := nv.getValidatorSet(prev.NextPChainReferenceHeight) + if err != nil { + return err + } + + err = nv.verifySignature(prev, next, validators) + if err != nil { + return err + } + + approvingNodes := bitmaskFromBytes(next.NextEpochApprovals.NodeIDs) + canSeal, err := canSealBlock(validators, approvingNodes) + if err != nil { + return err + } + + if !canSeal { + return fmt.Errorf("not enough approvals to seal block") + } + + return nil +} + +func (nv *nextEpochApprovalsVerifier) verifyNormal(prev SimplexEpochInfo, next SimplexEpochInfo) error { + if prev.NextPChainReferenceHeight == 0 { + return nil + } + + // Otherwise, prev.NextPChainReferenceHeight > 0, so this means we're collecting approvals + + if next.NextEpochApprovals == nil { + // The node that proposed the block should have included at least its own approval. + return fmt.Errorf("next epoch approvals should not be nil when collecting approvals") + } + + validators, err := nv.getValidatorSet(prev.NextPChainReferenceHeight) + if err != nil { + return err + } + + err = nv.verifySignature(prev, next, validators) + if err != nil { + return err + } + + // A node cannot remove other nodes' approvals, only add its own approval if it wasn't included in the previous block. + // So the set of signers in next.NextEpochApprovals should be a superset of the set of signers in prev.NextEpochApprovals. + if err := areNextEpochApprovalsSignersSupersetOfApprovalsOfPrevBlock(prev, next); err != nil { + return err + } + + return nil +} + +func (nv *nextEpochApprovalsVerifier) verifyEmptyNextEpochApprovals(_ SimplexEpochInfo, next SimplexEpochInfo) error { + if next.NextEpochApprovals != nil { + return fmt.Errorf("next epoch approvals should be nil but got %v", next.NextEpochApprovals) + } + return nil +} + +func (nv *nextEpochApprovalsVerifier) verifySignature(prev SimplexEpochInfo, next SimplexEpochInfo, validators NodeBLSMappings) error { + // First figure out which validators are approving the next epoch by looking at the bitmask of approving nodes, + // and then aggregate their public keys together to verify the signature. + + nodeIDsBitmask := next.NextEpochApprovals.NodeIDs + aggPK, err := nv.aggregatePubKeysForBitmask(nodeIDsBitmask, validators) + if err != nil { + return err + } + + message := nv.createMessageToBeVerified(prev) + + if err := nv.sigVerifier.VerifySignature(next.NextEpochApprovals.Signature, message, aggPK); err != nil { + return fmt.Errorf("failed to verify signature: %w", err) + } + return nil +} + +func (nv *nextEpochApprovalsVerifier) createMessageToBeVerified(prev SimplexEpochInfo) []byte { + pChainHeightBuff := pChainNextReferenceHeightAsBytes(prev) + + var bb bytes.Buffer + bb.Write(pChainHeightBuff) + + message := bb.Bytes() + return message +} + +func (nv *nextEpochApprovalsVerifier) aggregatePubKeysForBitmask(nodeIDsBitmask []byte, validators NodeBLSMappings) ([]byte, error) { + approvingNodes := bitmaskFromBytes(nodeIDsBitmask) + publicKeys := make([][]byte, 0, len(validators)) + validators.ForEach(func(i int, nbm NodeBLSMapping) { + if !approvingNodes.Contains(i) { + return + } + publicKeys = append(publicKeys, nbm.BLSKey) + }) + + aggPK, err := nv.keyAggregator.AggregateKeys(publicKeys...) + if err != nil { + return nil, fmt.Errorf("failed to aggregate public keys: %w", err) + } + return aggPK, nil +} + +func pChainNextReferenceHeightAsBytes(prev SimplexEpochInfo) []byte { + pChainHeight := prev.NextPChainReferenceHeight + pChainHeightBuff := make([]byte, 8) + binary.BigEndian.PutUint64(pChainHeightBuff, pChainHeight) + return pChainHeightBuff +} + +type nextPChainReferenceHeightVerifier struct { + getValidatorSet ValidatorSetRetriever + getPChainHeight func() uint64 +} + +func (n *nextPChainReferenceHeightVerifier) Verify(in verificationInput) error { + prev, next := in.prevMD.SimplexEpochInfo, in.proposedBlockMD.SimplexEpochInfo + switch in.nextBlockType { + case BlockTypeTelock, BlockTypeSealing: + if prev.NextPChainReferenceHeight != next.NextPChainReferenceHeight { + return fmt.Errorf("expected P-chain reference height to be %d but got %d", prev.NextPChainReferenceHeight, next.NextPChainReferenceHeight) + } + case BlockTypeNormal: + return n.verifyNextPChainRefHeightNormal(in.prevMD, prev, next) + case BlockTypeNewEpoch: + if next.NextPChainReferenceHeight != 0 { + return fmt.Errorf("expected P-chain reference height to be 0 but got %d", next.NextPChainReferenceHeight) + } + default: + return fmt.Errorf("unknown block type: %d", in.nextBlockType) + } + return nil +} + +func (n *nextPChainReferenceHeightVerifier) verifyNextPChainRefHeightNormal(prevMD StateMachineMetadata, prev SimplexEpochInfo, next SimplexEpochInfo) error { + // Next P-chain height can only increase, not decrease. + if next.NextPChainReferenceHeight > 0 && prev.PChainReferenceHeight > next.NextPChainReferenceHeight { + return fmt.Errorf("expected P-chain reference height to be non-decreasing, "+ + "but the previous P-chain reference height is %d and the proposed P-chain reference height is %d", prev.PChainReferenceHeight, next.NextPChainReferenceHeight) + } + + // If the previous block already has a next P-chain reference height, + // we should keep the same next P-chain reference height until we reach it. + if prev.NextPChainReferenceHeight > 0 { + if next.NextPChainReferenceHeight != prev.NextPChainReferenceHeight { + return fmt.Errorf("expected P-chain reference height to be %d but got %d", prev.NextPChainReferenceHeight, next.NextPChainReferenceHeight) + } + return nil + } + + // If we reached here, then prev.NextPChainReferenceHeight == 0. + // It might be that this block is the first block that has set the next P-chain reference height for the epoch, + // so check if it has done so correctly by observing whether the validator set has indeed changed. + + currentValidatorSet, err := n.getValidatorSet(prevMD.SimplexEpochInfo.PChainReferenceHeight) + if err != nil { + return err + } + + newValidatorSet, err := n.getValidatorSet(next.NextPChainReferenceHeight) + if err != nil { + return err + } + + // If the validator set doesn't change, we shouldn't have increased the next P-chain reference height. + if currentValidatorSet.Equal(newValidatorSet) && next.NextPChainReferenceHeight > 0 { + return fmt.Errorf("validator set at proposed next P-chain reference height %d is the same as "+ + "validator set at previous block's P-chain reference height %d,"+ + "so expected next P-chain reference height to remain the same but got %d", + next.NextPChainReferenceHeight, prev.PChainReferenceHeight, next.NextPChainReferenceHeight) + } + + // Else, either the validator set has changed, or the next P-chain reference height is still 0. + // Both of these cases are fine, but we should verify that we have observed the next P-chain reference height if it is > 0. + + pChainHeight := n.getPChainHeight() + + if pChainHeight < next.NextPChainReferenceHeight { + return fmt.Errorf("haven't reached P-chain height %d yet, current P-chain height is only %d", next.NextPChainReferenceHeight, pChainHeight) + } + + return nil +} + +type epochNumberVerifier struct{} + +func (e *epochNumberVerifier) Verify(in verificationInput) error { + prev, next := in.prevMD.SimplexEpochInfo, in.proposedBlockMD.SimplexEpochInfo + + // An epoch number of 0 means this is not a Simplex block, so the next block should be the first Simplex block with epoch number 1. + if in.prevMD.SimplexEpochInfo.EpochNumber == 0 && in.proposedBlockMD.SimplexEpochInfo.EpochNumber != 1 { + return fmt.Errorf("expected epoch number of the first block created to be 1 but got %d", next.EpochNumber) + } + + // The only time in which we should increase the epoch number is when we have a block that marks the start of a new epoch. + switch in.nextBlockType { + case BlockTypeNewEpoch: + // TODO: we have to make sure that Telocks are pruned before moving to a new epoch, otherwise we hit a false negative below. + if in.prevBlockSeq != next.EpochNumber { + return fmt.Errorf("expected epoch number to be %d but got %d", in.prevBlockSeq, next.EpochNumber) + } + default: + if prev.EpochNumber != next.EpochNumber { + return fmt.Errorf("expected epoch number to be %d but got %d", prev.EpochNumber, next.EpochNumber) + } + } + return nil +} + +type sealingBlockSeqVerifier struct{} + +func (s *sealingBlockSeqVerifier) Verify(in verificationInput) error { + prev, next := in.prevMD.SimplexEpochInfo, in.proposedBlockMD.SimplexEpochInfo + + // A block should only have a sealing block if it is a Telock. + switch in.nextBlockType { + case BlockTypeNewEpoch, BlockTypeNormal, BlockTypeSealing: + if next.SealingBlockSeq != 0 { + return fmt.Errorf("expected sealing block sequence number to be 0 but got %d", next.SealingBlockSeq) + } + case BlockTypeTelock: + // This is not the first Telock, make sure the sealing block sequence number doesn't change. + + // prev.SealingBlockSeq > 0 means the previous block is a Telock. + if prev.SealingBlockSeq > 0 && next.SealingBlockSeq != prev.SealingBlockSeq { + return fmt.Errorf("expected sealing block sequence number to be %d but got %d", prev.SealingBlockSeq, next.SealingBlockSeq) + } + + // Else, either this is the first Telock, or the previous block's sealing block sequence is equal to this block's sealing block sequence. + + // We need to check the first case has a valid sealing block sequence, as the second case is fine by definition. + if prev.BlockValidationDescriptor != nil { + md, err := simplex.ProtocolMetadataFromBytes(in.prevMD.SimplexProtocolMetadata) + if err != nil { + return fmt.Errorf("failed parsing protocol metadata: %w", err) + } + if next.SealingBlockSeq != md.Seq { + return fmt.Errorf("expected sealing block sequence number to be %d but got %d", md.Seq, next.SealingBlockSeq) + } + } + default: + return fmt.Errorf("unknown block type: %d", in.nextBlockType) + } + + return nil +} + +type pChainHeightVerifier struct { + getPChainHeight func() uint64 +} + +func (p *pChainHeightVerifier) Verify(in verificationInput) error { + currentPChainHeight := p.getPChainHeight() + + if in.proposedBlockMD.PChainHeight > currentPChainHeight { + return fmt.Errorf("invalid P-chain height (%d) is too big, expected to be ≤ %d", + in.proposedBlockMD.PChainHeight, currentPChainHeight) + } + + if in.prevMD.PChainHeight > in.proposedBlockMD.PChainHeight { + return fmt.Errorf("invalid P-chain height (%d) is smaller than parent block's P-chain height (%d)", + in.proposedBlockMD.PChainHeight, in.prevMD.PChainHeight) + } + + return nil +} + +type pChainReferenceHeightVerifier struct{} + +func (p *pChainReferenceHeightVerifier) Verify(in verificationInput) error { + prev, next := in.prevMD.SimplexEpochInfo, in.proposedBlockMD.SimplexEpochInfo + + switch in.nextBlockType { + case BlockTypeNewEpoch: + if prev.NextPChainReferenceHeight != next.PChainReferenceHeight { + return fmt.Errorf("expected P-chain reference height of the first block of epoch %d to be %d but got %d", + prev.SealingBlockSeq, prev.NextPChainReferenceHeight, next.PChainReferenceHeight) + } + default: + if prev.PChainReferenceHeight != next.PChainReferenceHeight { + return fmt.Errorf("expected P-chain reference height to be %d but got %d", prev.PChainReferenceHeight, next.PChainReferenceHeight) + } + } + + return nil +} + +type timestampVerifier struct { + getTime func() time.Time + timeSkewLimit time.Duration +} + +func (t *timestampVerifier) Verify(in verificationInput) error { + if !in.hasInnerBlock { + // If no inner block, the timestamp is inherited from the parent block. + if in.proposedBlockMD.Timestamp != in.prevMD.Timestamp { + return fmt.Errorf("block without inner block should inherit parent timestamp %d but got %d", in.prevMD.Timestamp, in.proposedBlockMD.Timestamp) + } + } else { + // If there is an inner block, the timestamp should be the same as the inner block's timestamp. + if in.proposedBlockMD.Timestamp != uint64(in.innerBlockTimestamp.UnixMilli()) { + return fmt.Errorf("block timestamp %d does not match inner block timestamp %d", in.proposedBlockMD.Timestamp, in.innerBlockTimestamp.UnixMilli()) + } + } + + timestamp := time.UnixMilli(int64(in.proposedBlockMD.Timestamp)) + + currentTime := t.getTime() + if currentTime.Add(t.timeSkewLimit).Before(timestamp) { + return fmt.Errorf("proposed block timestamp is too far in the future, current time is %v but got %v", currentTime, timestamp) + } + + if in.prevMD.Timestamp > in.proposedBlockMD.Timestamp { + return fmt.Errorf("proposed block timestamp is older than parent block's timestamp, parent timestamp is %d but got %d", in.prevMD.Timestamp, in.proposedBlockMD.Timestamp) + } + return nil +} + +type prevSealingBlockHashVerifier struct { + getBlock BlockRetriever + latestPersistedHeight *uint64 +} + +func (p *prevSealingBlockHashVerifier) Verify(in verificationInput) error { + prev, _ := in.prevMD.SimplexEpochInfo, in.proposedBlockMD.SimplexEpochInfo + + // Sealing block of the first epoch must point to the first ever Simplex block as the previous sealing block. + if prev.EpochNumber == 1 && in.nextBlockType == BlockTypeSealing { + firstEverSimplexBlockSeq, err := findFirstSimplexBlock(p.getBlock, *p.latestPersistedHeight+1) + if err != nil { + return fmt.Errorf("failed to find first Simplex block: %w", err) + } + + block, _, err := p.getBlock(RetrievingOpts{Height: firstEverSimplexBlockSeq}) + if err != nil { + return fmt.Errorf("failed retrieving first ever simplex block %d: %w", firstEverSimplexBlockSeq, err) + } + + hash := block.Digest() + if !bytes.Equal(in.proposedBlockMD.SimplexEpochInfo.PrevSealingBlockHash[:], hash[:]) { + return fmt.Errorf("expected prev sealing block hash of the first ever simplex block to be %x but got %x", hash, in.proposedBlockMD.SimplexEpochInfo.PrevSealingBlockHash) + } + + return nil + } + + // Otherwise, we can only have a previous sealing block hash if this is a sealing block, + // and in that case, the previous sealing block hash should match the hash of the sealing block of the previous epoch. + + switch in.nextBlockType { + case BlockTypeSealing: + prevSealingBlock, _, err := p.getBlock(RetrievingOpts{Height: in.prevMD.SimplexEpochInfo.EpochNumber}) + if err != nil { + return fmt.Errorf("failed retrieving block: %w", err) + } + hash := prevSealingBlock.Digest() + if !bytes.Equal(in.proposedBlockMD.SimplexEpochInfo.PrevSealingBlockHash[:], hash[:]) { + return fmt.Errorf("expected prev sealing block hash to be %x but got %x", hash, in.proposedBlockMD.SimplexEpochInfo.PrevSealingBlockHash) + } + default: // non-sealing blocks should have an empty previous sealing block hash + if in.proposedBlockMD.SimplexEpochInfo.PrevSealingBlockHash != [32]byte{} { + return fmt.Errorf("expected prev sealing block hash of a non sealing block to be empty but got %x", in.proposedBlockMD.SimplexEpochInfo.PrevSealingBlockHash) + } + } + + return nil +} + +type vmBlockSeqVerifier struct { + getBlock BlockRetriever +} + +func (v *vmBlockSeqVerifier) Verify(in verificationInput) error { + prev, next := in.prevMD.SimplexEpochInfo, in.proposedBlockMD.SimplexEpochInfo + + // If this is the first ever Simplex block, the PrevVMBlockSeq is simply the seq of the previous block. + if prev.EpochNumber == 0 { + if next.PrevVMBlockSeq != in.prevBlockSeq { + return fmt.Errorf("expected PrevVMBlockSeq to be %d but got %d", in.prevBlockSeq, next.PrevVMBlockSeq) + } + return nil + } + + md, err := simplex.ProtocolMetadataFromBytes(in.proposedBlockMD.SimplexProtocolMetadata) + if err != nil { + return fmt.Errorf("failed parsing protocol metadata: %w", err) + } + + // Else, if the previous block has an inner block, we point to it. + // Otherwise, we point to the parent block's previous VM block seq. + prevBlock, _, err := v.getBlock(RetrievingOpts{Height: in.prevBlockSeq, Digest: md.Prev}) + if err != nil { + return fmt.Errorf("failed retrieving block: %w", err) + } + + expectedPrevVMBlockSeq := in.prevMD.SimplexEpochInfo.PrevVMBlockSeq + + if prevBlock.InnerBlock != nil { + expectedPrevVMBlockSeq = in.prevBlockSeq + } + + if next.PrevVMBlockSeq != expectedPrevVMBlockSeq { + return fmt.Errorf("expected PrevVMBlockSeq to be %d but got %d", expectedPrevVMBlockSeq, next.PrevVMBlockSeq) + } + + return nil +} + +func areNextEpochApprovalsSignersSupersetOfApprovalsOfPrevBlock(prev SimplexEpochInfo, next SimplexEpochInfo) error { + if prev.NextEpochApprovals == nil { + return nil + } + // Make sure that previous signers are still there. + prevSigners := bitmaskFromBytes(prev.NextEpochApprovals.NodeIDs) + nextSigners := bitmaskFromBytes(next.NextEpochApprovals.NodeIDs) + // Remove all bits in nextSigners from prevSigners + prevSigners.Difference(&nextSigners) + // If we have some bits left, it means there was a bit in prevSigners that wasn't in nextSigners + if prevSigners.Len() > 0 { + return fmt.Errorf("some signers from parent block are missing from next epoch approvals of proposed block") + } + return nil +} diff --git a/msm/verification_test.go b/msm/verification_test.go new file mode 100644 index 00000000..7881a133 --- /dev/null +++ b/msm/verification_test.go @@ -0,0 +1,1016 @@ +// Copyright (C) 2019-2025, Ava Labs, Inc. All rights reserved. +// See the file LICENSE for licensing terms. + +package metadata + +import ( + "context" + "crypto/sha256" + "fmt" + "testing" + "time" + + "github.com/ava-labs/simplex" + "github.com/stretchr/testify/require" +) + +func TestPChainHeightVerifier(t *testing.T) { + for _, tc := range []struct { + name string + pChainHeight uint64 + prevHeight uint64 + nextHeight uint64 + err string + }{ + { + name: "valid height", + pChainHeight: 200, + prevHeight: 100, + nextHeight: 150, + }, + { + name: "height equal to current", + pChainHeight: 200, + prevHeight: 100, + nextHeight: 200, + }, + { + name: "height too big", + pChainHeight: 100, + prevHeight: 50, + nextHeight: 150, + err: "invalid P-chain height (150) is too big, expected to be ≤ 100", + }, + { + name: "height smaller than parent", + pChainHeight: 200, + prevHeight: 150, + nextHeight: 100, + err: "invalid P-chain height (100) is smaller than parent block's P-chain height (150)", + }, + { + name: "height equal to parent", + pChainHeight: 200, + prevHeight: 100, + nextHeight: 100, + }, + } { + t.Run(tc.name, func(t *testing.T) { + v := &pChainHeightVerifier{ + getPChainHeight: func() uint64 { return tc.pChainHeight }, + } + err := v.Verify(verificationInput{ + prevMD: StateMachineMetadata{PChainHeight: tc.prevHeight}, + proposedBlockMD: StateMachineMetadata{PChainHeight: tc.nextHeight}, + }) + if tc.err != "" { + require.EqualError(t, err, tc.err) + } else { + require.NoError(t, err) + } + }) + } +} + +func TestTimestampVerifier(t *testing.T) { + now := time.Now() + + timeSkewLimit := 5 * time.Second + + futureTime := now.Add(10 * time.Second) + + for _, tc := range []struct { + name string + hasInnerBlock bool + innerBlockTimestamp time.Time + timestamp uint64 + parentTimestamp uint64 + err string + }{ + { + name: "valid timestamp with inner block", + hasInnerBlock: true, + innerBlockTimestamp: now, + timestamp: uint64(now.UnixMilli()), + }, + { + name: "metadata timestamp does not match inner block", + hasInnerBlock: true, + innerBlockTimestamp: now, + timestamp: uint64(now.UnixMilli()) + 100, + err: fmt.Sprintf("block timestamp %d does not match inner block timestamp %d", uint64(now.UnixMilli())+100, now.UnixMilli()), + }, + { + name: "timestamp too far in the future", + hasInnerBlock: true, + innerBlockTimestamp: futureTime, + timestamp: uint64(futureTime.UnixMilli()), + err: fmt.Sprintf("proposed block timestamp is too far in the future, current time is %v but got %v", now, time.UnixMilli(futureTime.UnixMilli())), + }, + { + name: "timestamp older than parent", + hasInnerBlock: true, + innerBlockTimestamp: now, + timestamp: uint64(now.UnixMilli()), + parentTimestamp: uint64(now.UnixMilli()) + 10, + err: fmt.Sprintf("proposed block timestamp is older than parent block's timestamp, parent timestamp is %d but got %d", uint64(now.UnixMilli())+10, uint64(now.UnixMilli())), + }, + { + name: "no inner block inherits parent timestamp", + hasInnerBlock: false, + timestamp: uint64(now.UnixMilli()), + parentTimestamp: uint64(now.UnixMilli()), + }, + { + name: "no inner block with different timestamp than parent", + hasInnerBlock: false, + timestamp: uint64(now.UnixMilli()) + 100, + parentTimestamp: uint64(now.UnixMilli()), + err: fmt.Sprintf("block without inner block should inherit parent timestamp %d but got %d", uint64(now.UnixMilli()), uint64(now.UnixMilli())+100), + }, + } { + t.Run(tc.name, func(t *testing.T) { + v := ×tampVerifier{ + getTime: func() time.Time { return now }, + timeSkewLimit: timeSkewLimit, + } + err := v.Verify(verificationInput{ + hasInnerBlock: tc.hasInnerBlock, + innerBlockTimestamp: tc.innerBlockTimestamp, + proposedBlockMD: StateMachineMetadata{Timestamp: tc.timestamp}, + prevMD: StateMachineMetadata{Timestamp: tc.parentTimestamp}, + }) + if tc.err != "" { + require.EqualError(t, err, tc.err) + } else { + require.NoError(t, err) + } + }) + } +} + +func TestPChainReferenceHeightVerifier(t *testing.T) { + for _, tc := range []struct { + name string + nextBlockType BlockType + prev SimplexEpochInfo + next SimplexEpochInfo + err string + }{ + { + name: "new epoch block matching prev NextPChainReferenceHeight", + nextBlockType: BlockTypeNewEpoch, + prev: SimplexEpochInfo{NextPChainReferenceHeight: 200, SealingBlockSeq: 5}, + next: SimplexEpochInfo{PChainReferenceHeight: 200}, + }, + { + name: "new epoch block not matching prev NextPChainReferenceHeight", + nextBlockType: BlockTypeNewEpoch, + prev: SimplexEpochInfo{NextPChainReferenceHeight: 200, SealingBlockSeq: 5}, + next: SimplexEpochInfo{PChainReferenceHeight: 100}, + err: "expected P-chain reference height of the first block of epoch 5 to be 200 but got 100", + }, + { + name: "normal block matching prev PChainReferenceHeight", + nextBlockType: BlockTypeNormal, + prev: SimplexEpochInfo{PChainReferenceHeight: 100}, + next: SimplexEpochInfo{PChainReferenceHeight: 100}, + }, + { + name: "normal block not matching prev PChainReferenceHeight", + nextBlockType: BlockTypeNormal, + prev: SimplexEpochInfo{PChainReferenceHeight: 100}, + next: SimplexEpochInfo{PChainReferenceHeight: 200}, + err: "expected P-chain reference height to be 100 but got 200", + }, + { + name: "sealing block matching prev PChainReferenceHeight", + nextBlockType: BlockTypeSealing, + prev: SimplexEpochInfo{PChainReferenceHeight: 100}, + next: SimplexEpochInfo{PChainReferenceHeight: 100}, + }, + { + name: "telock block matching prev PChainReferenceHeight", + nextBlockType: BlockTypeTelock, + prev: SimplexEpochInfo{PChainReferenceHeight: 100}, + next: SimplexEpochInfo{PChainReferenceHeight: 100}, + }, + } { + t.Run(tc.name, func(t *testing.T) { + v := &pChainReferenceHeightVerifier{} + err := v.Verify(verificationInput{ + nextBlockType: tc.nextBlockType, + prevMD: StateMachineMetadata{SimplexEpochInfo: tc.prev}, + proposedBlockMD: StateMachineMetadata{SimplexEpochInfo: tc.next}, + }) + if tc.err != "" { + require.EqualError(t, err, tc.err) + } else { + require.NoError(t, err) + } + }) + } +} + +func TestEpochNumberVerifier(t *testing.T) { + for _, tc := range []struct { + name string + nextBlockType BlockType + prevBlockSeq uint64 + prev SimplexEpochInfo + next SimplexEpochInfo + err string + }{ + { + name: "prev epoch 0 with wrong next epoch", + nextBlockType: BlockTypeNormal, + prev: SimplexEpochInfo{EpochNumber: 0}, + next: SimplexEpochInfo{EpochNumber: 5}, + err: "expected epoch number of the first block created to be 1 but got 5", + }, + { + name: "new epoch block matching sealing seq", + nextBlockType: BlockTypeNewEpoch, + prevBlockSeq: 10, + prev: SimplexEpochInfo{EpochNumber: 1}, + next: SimplexEpochInfo{EpochNumber: 10}, + }, + { + name: "new epoch block not matching sealing seq", + nextBlockType: BlockTypeNewEpoch, + prevBlockSeq: 10, + prev: SimplexEpochInfo{EpochNumber: 1}, + next: SimplexEpochInfo{EpochNumber: 5}, + err: "expected epoch number to be 10 but got 5", + }, + { + name: "normal block same epoch", + nextBlockType: BlockTypeNormal, + prev: SimplexEpochInfo{EpochNumber: 3}, + next: SimplexEpochInfo{EpochNumber: 3}, + }, + { + name: "normal block different epoch", + nextBlockType: BlockTypeNormal, + prev: SimplexEpochInfo{EpochNumber: 3}, + next: SimplexEpochInfo{EpochNumber: 4}, + err: "expected epoch number to be 3 but got 4", + }, + { + name: "sealing block same epoch", + nextBlockType: BlockTypeSealing, + prev: SimplexEpochInfo{EpochNumber: 2}, + next: SimplexEpochInfo{EpochNumber: 2}, + }, + { + name: "telock block same epoch", + nextBlockType: BlockTypeTelock, + prev: SimplexEpochInfo{EpochNumber: 2}, + next: SimplexEpochInfo{EpochNumber: 2}, + }, + } { + t.Run(tc.name, func(t *testing.T) { + v := &epochNumberVerifier{} + err := v.Verify(verificationInput{ + nextBlockType: tc.nextBlockType, + prevBlockSeq: tc.prevBlockSeq, + prevMD: StateMachineMetadata{SimplexEpochInfo: tc.prev}, + proposedBlockMD: StateMachineMetadata{SimplexEpochInfo: tc.next}, + }) + if tc.err != "" { + require.EqualError(t, err, tc.err) + } else { + require.NoError(t, err) + } + }) + } +} + +func TestPrevSealingBlockHashVerifier(t *testing.T) { + // A simplex block (EpochNumber > 0) so findFirstSimplexBlock can locate it. + firstSimplexBlock := StateMachineBlock{ + InnerBlock: &testVMBlock{bytes: []byte{1, 2, 3}}, + Metadata: StateMachineMetadata{SimplexEpochInfo: SimplexEpochInfo{EpochNumber: 1}}, + } + firstSimplexBlockHash := firstSimplexBlock.Digest() + + // A block used for epoch >1 sealing lookups. + prevSealingBlock := StateMachineBlock{ + InnerBlock: &testVMBlock{bytes: []byte{4, 5, 6}}, + Metadata: StateMachineMetadata{SimplexEpochInfo: SimplexEpochInfo{EpochNumber: 5}}, + } + prevSealingBlockHash := prevSealingBlock.Digest() + + bs := make(testBlockStore) + bs[1] = firstSimplexBlock + bs[5] = prevSealingBlock + latestPersisted := uint64(1) + + for _, tc := range []struct { + name string + nextBlockType BlockType + prev SimplexEpochInfo + next SimplexEpochInfo + err string + }{ + { + name: "epoch 1 sealing block with correct hash", + nextBlockType: BlockTypeSealing, + prev: SimplexEpochInfo{EpochNumber: 1}, + next: SimplexEpochInfo{ + PrevSealingBlockHash: firstSimplexBlockHash, + }, + }, + { + name: "epoch 1 sealing block with wrong hash", + nextBlockType: BlockTypeSealing, + prev: SimplexEpochInfo{EpochNumber: 1}, + next: SimplexEpochInfo{ + PrevSealingBlockHash: [32]byte{9, 9, 9}, + }, + err: fmt.Sprintf("expected prev sealing block hash of the first ever simplex block to be %x but got %x", firstSimplexBlockHash, [32]byte{9, 9, 9}), + }, + { + name: "epoch >1 sealing block with correct hash", + nextBlockType: BlockTypeSealing, + prev: SimplexEpochInfo{EpochNumber: 5}, + next: SimplexEpochInfo{ + PrevSealingBlockHash: prevSealingBlockHash, + }, + }, + { + name: "epoch >1 sealing block with wrong hash", + nextBlockType: BlockTypeSealing, + prev: SimplexEpochInfo{EpochNumber: 5}, + next: SimplexEpochInfo{ + PrevSealingBlockHash: [32]byte{9, 9, 9}, + }, + err: fmt.Sprintf("expected prev sealing block hash to be %x but got %x", prevSealingBlockHash, [32]byte{9, 9, 9}), + }, + { + name: "non-sealing block with empty hash", + nextBlockType: BlockTypeNormal, + prev: SimplexEpochInfo{EpochNumber: 1}, + next: SimplexEpochInfo{}, + }, + { + name: "non-sealing block with non-empty hash", + nextBlockType: BlockTypeNormal, + prev: SimplexEpochInfo{EpochNumber: 1}, + next: SimplexEpochInfo{ + PrevSealingBlockHash: [32]byte{1}, + }, + err: fmt.Sprintf("expected prev sealing block hash of a non sealing block to be empty but got %x", [32]byte{1}), + }, + { + name: "telock block with empty hash", + nextBlockType: BlockTypeTelock, + prev: SimplexEpochInfo{EpochNumber: 2}, + next: SimplexEpochInfo{}, + }, + { + name: "new epoch block with empty hash", + nextBlockType: BlockTypeNewEpoch, + prev: SimplexEpochInfo{EpochNumber: 2}, + next: SimplexEpochInfo{}, + }, + } { + t.Run(tc.name, func(t *testing.T) { + v := &prevSealingBlockHashVerifier{ + getBlock: bs.getBlock, + latestPersistedHeight: &latestPersisted, + } + err := v.Verify(verificationInput{ + nextBlockType: tc.nextBlockType, + prevMD: StateMachineMetadata{SimplexEpochInfo: tc.prev}, + proposedBlockMD: StateMachineMetadata{SimplexEpochInfo: tc.next}, + }) + if tc.err != "" { + require.EqualError(t, err, tc.err) + } else { + require.NoError(t, err) + } + }) + } +} + +func TestNextPChainReferenceHeightVerifier(t *testing.T) { + validators1 := NodeBLSMappings{{BLSKey: []byte{1}, Weight: 1}} + validators2 := NodeBLSMappings{{BLSKey: []byte{2}, Weight: 1}} + + for _, tc := range []struct { + name string + nextBlockType BlockType + prev SimplexEpochInfo + prevPChainRef uint64 + next SimplexEpochInfo + getValidator ValidatorSetRetriever + pChainHeight uint64 + err string + }{ + { + name: "telock block matching height", + nextBlockType: BlockTypeTelock, + prev: SimplexEpochInfo{NextPChainReferenceHeight: 200}, + next: SimplexEpochInfo{NextPChainReferenceHeight: 200}, + }, + { + name: "telock block mismatched height", + nextBlockType: BlockTypeTelock, + prev: SimplexEpochInfo{NextPChainReferenceHeight: 200}, + next: SimplexEpochInfo{NextPChainReferenceHeight: 300}, + err: "expected P-chain reference height to be 200 but got 300", + }, + { + name: "sealing block matching height", + nextBlockType: BlockTypeSealing, + prev: SimplexEpochInfo{NextPChainReferenceHeight: 200}, + next: SimplexEpochInfo{NextPChainReferenceHeight: 200}, + }, + { + name: "sealing block mismatched height", + nextBlockType: BlockTypeSealing, + prev: SimplexEpochInfo{NextPChainReferenceHeight: 200}, + next: SimplexEpochInfo{NextPChainReferenceHeight: 100}, + err: "expected P-chain reference height to be 200 but got 100", + }, + { + name: "normal block prev already has next height set", + nextBlockType: BlockTypeNormal, + prev: SimplexEpochInfo{NextPChainReferenceHeight: 200}, + next: SimplexEpochInfo{NextPChainReferenceHeight: 200}, + }, + { + name: "normal block prev already has next height set mismatch", + nextBlockType: BlockTypeNormal, + prev: SimplexEpochInfo{NextPChainReferenceHeight: 200}, + next: SimplexEpochInfo{NextPChainReferenceHeight: 300}, + err: "expected P-chain reference height to be 200 but got 300", + }, + { + name: "normal block next p-chain reference height less than current", + nextBlockType: BlockTypeNormal, + prev: SimplexEpochInfo{PChainReferenceHeight: 200}, + next: SimplexEpochInfo{NextPChainReferenceHeight: 100}, + err: "expected P-chain reference height to be non-decreasing, but the previous P-chain reference height is 200 and the proposed P-chain reference height is 100", + }, + { + name: "normal block same validator set with non-zero next height", + nextBlockType: BlockTypeNormal, + prev: SimplexEpochInfo{PChainReferenceHeight: 100}, + next: SimplexEpochInfo{NextPChainReferenceHeight: 200}, + getValidator: func(h uint64) (NodeBLSMappings, error) { return validators1, nil }, + err: "validator set at proposed next P-chain reference height 200 is the same as validator set at previous block's P-chain reference height 100,so expected next P-chain reference height to remain the same but got 200", + }, + { + name: "normal block no validator change and next height is zero", + nextBlockType: BlockTypeNormal, + prev: SimplexEpochInfo{PChainReferenceHeight: 100}, + next: SimplexEpochInfo{NextPChainReferenceHeight: 0}, + getValidator: func(h uint64) (NodeBLSMappings, error) { return validators1, nil }, + }, + { + name: "normal block validator change detected and p-chain height reached", + nextBlockType: BlockTypeNormal, + prev: SimplexEpochInfo{PChainReferenceHeight: 100}, + next: SimplexEpochInfo{NextPChainReferenceHeight: 200}, + getValidator: func(h uint64) (NodeBLSMappings, error) { + if h == 200 { + return validators2, nil + } + return validators1, nil + }, + pChainHeight: 200, + }, + { + name: "normal block validator change but p-chain height not reached", + nextBlockType: BlockTypeNormal, + prev: SimplexEpochInfo{PChainReferenceHeight: 100}, + next: SimplexEpochInfo{NextPChainReferenceHeight: 200}, + getValidator: func(h uint64) (NodeBLSMappings, error) { + if h == 200 { + return validators2, nil + } + return validators1, nil + }, + pChainHeight: 150, + err: "haven't reached P-chain height 200 yet, current P-chain height is only 150", + }, + { + name: "new epoch block with zero next height", + nextBlockType: BlockTypeNewEpoch, + next: SimplexEpochInfo{NextPChainReferenceHeight: 0}, + }, + { + name: "new epoch block with non-zero next height", + nextBlockType: BlockTypeNewEpoch, + next: SimplexEpochInfo{NextPChainReferenceHeight: 100}, + err: "expected P-chain reference height to be 0 but got 100", + }, + } { + t.Run(tc.name, func(t *testing.T) { + v := &nextPChainReferenceHeightVerifier{ + getValidatorSet: tc.getValidator, + getPChainHeight: func() uint64 { return tc.pChainHeight }, + } + err := v.Verify(verificationInput{ + nextBlockType: tc.nextBlockType, + prevMD: StateMachineMetadata{SimplexEpochInfo: tc.prev}, + proposedBlockMD: StateMachineMetadata{SimplexEpochInfo: tc.next}, + }) + if tc.err != "" { + require.EqualError(t, err, tc.err) + } else { + require.NoError(t, err) + } + }) + } +} + +func TestVMBlockSeqVerifier(t *testing.T) { + prevMDBytes := (&simplex.ProtocolMetadata{Seq: 5, Prev: [32]byte{1}}).Bytes() + proposedMDBytes := (&simplex.ProtocolMetadata{Seq: 6, Prev: [32]byte{2}}).Bytes() + + blockWithInner := StateMachineBlock{ + InnerBlock: &testVMBlock{bytes: []byte{1}}, + } + blockWithoutInner := StateMachineBlock{} + + for _, tc := range []struct { + name string + prev SimplexEpochInfo + prevMD StateMachineMetadata + next SimplexEpochInfo + prevBlockSeq uint64 + block StateMachineBlock + err string + }{ + { + name: "first simplex block matching seq", + prev: SimplexEpochInfo{EpochNumber: 0}, + next: SimplexEpochInfo{PrevVMBlockSeq: 42}, + prevBlockSeq: 42, + }, + { + name: "first simplex block wrong seq", + prev: SimplexEpochInfo{EpochNumber: 0}, + next: SimplexEpochInfo{PrevVMBlockSeq: 10}, + prevBlockSeq: 42, + err: "expected PrevVMBlockSeq to be 42 but got 10", + }, + { + name: "prev block has block", + prev: SimplexEpochInfo{EpochNumber: 1, PrevVMBlockSeq: 3}, + prevMD: StateMachineMetadata{SimplexProtocolMetadata: prevMDBytes, SimplexEpochInfo: SimplexEpochInfo{EpochNumber: 1, PrevVMBlockSeq: 3}}, + next: SimplexEpochInfo{PrevVMBlockSeq: 4}, + prevBlockSeq: 4, + block: blockWithInner, + }, + { + name: "prev block has block wrong seq", + prev: SimplexEpochInfo{EpochNumber: 1, PrevVMBlockSeq: 3}, + prevMD: StateMachineMetadata{SimplexProtocolMetadata: prevMDBytes, SimplexEpochInfo: SimplexEpochInfo{EpochNumber: 1, PrevVMBlockSeq: 3}}, + next: SimplexEpochInfo{PrevVMBlockSeq: 99}, + prevBlockSeq: 4, + block: blockWithInner, + err: "expected PrevVMBlockSeq to be 4 but got 99", + }, + { + name: "prev block has no block uses parent PrevVMBlockSeq", + prev: SimplexEpochInfo{EpochNumber: 1, PrevVMBlockSeq: 3}, + prevMD: StateMachineMetadata{SimplexProtocolMetadata: prevMDBytes, SimplexEpochInfo: SimplexEpochInfo{EpochNumber: 1, PrevVMBlockSeq: 3}}, + next: SimplexEpochInfo{PrevVMBlockSeq: 3}, + prevBlockSeq: 4, + block: blockWithoutInner, + }, + { + name: "prev block has no block wrong seq", + prev: SimplexEpochInfo{EpochNumber: 1, PrevVMBlockSeq: 3}, + prevMD: StateMachineMetadata{SimplexProtocolMetadata: prevMDBytes, SimplexEpochInfo: SimplexEpochInfo{EpochNumber: 1, PrevVMBlockSeq: 3}}, + next: SimplexEpochInfo{PrevVMBlockSeq: 99}, + prevBlockSeq: 4, + block: blockWithoutInner, + err: "expected PrevVMBlockSeq to be 3 but got 99", + }, + } { + t.Run(tc.name, func(t *testing.T) { + bs := make(testBlockStore) + bs[tc.prevBlockSeq] = tc.block + + v := &vmBlockSeqVerifier{ + getBlock: bs.getBlock, + } + + prevMD := tc.prevMD + if prevMD.SimplexEpochInfo.EpochNumber == 0 && tc.prev.EpochNumber == 0 { + prevMD.SimplexEpochInfo = tc.prev + } + + err := v.Verify(verificationInput{ + prevMD: prevMD, + proposedBlockMD: StateMachineMetadata{SimplexEpochInfo: tc.next, SimplexProtocolMetadata: proposedMDBytes}, + prevBlockSeq: tc.prevBlockSeq, + }) + if tc.err != "" { + require.EqualError(t, err, tc.err) + } else { + require.NoError(t, err) + } + }) + } +} + +func TestValidationDescriptorVerifier(t *testing.T) { + validators := NodeBLSMappings{ + {BLSKey: []byte{1}, Weight: 1}, + {BLSKey: []byte{2}, Weight: 1}, + } + + otherValidators := NodeBLSMappings{ + {BLSKey: []byte{3}, Weight: 1}, + } + + for _, tc := range []struct { + name string + nextBlockType BlockType + next SimplexEpochInfo + getValidator ValidatorSetRetriever + err string + }{ + { + name: "sealing block with matching validators", + nextBlockType: BlockTypeSealing, + next: SimplexEpochInfo{ + NextPChainReferenceHeight: 100, + BlockValidationDescriptor: &BlockValidationDescriptor{ + AggregatedMembership: AggregatedMembership{Members: validators}, + }, + }, + getValidator: func(h uint64) (NodeBLSMappings, error) { return validators, nil }, + }, + { + name: "sealing block with mismatching validators", + nextBlockType: BlockTypeSealing, + next: SimplexEpochInfo{ + NextPChainReferenceHeight: 100, + BlockValidationDescriptor: &BlockValidationDescriptor{ + AggregatedMembership: AggregatedMembership{Members: otherValidators}, + }, + }, + getValidator: func(h uint64) (NodeBLSMappings, error) { return validators, nil }, + err: "expected validator set specified at P-chain height 100 does not match validator set encoded in new block", + }, + { + name: "sealing block with validator retrieval error", + nextBlockType: BlockTypeSealing, + next: SimplexEpochInfo{ + NextPChainReferenceHeight: 100, + BlockValidationDescriptor: &BlockValidationDescriptor{}, + }, + getValidator: func(h uint64) (NodeBLSMappings, error) { return nil, fmt.Errorf("unavailable") }, + err: "unavailable", + }, + { + name: "normal block with nil descriptor", + nextBlockType: BlockTypeNormal, + next: SimplexEpochInfo{}, + }, + { + name: "normal block with non-nil descriptor", + nextBlockType: BlockTypeNormal, + next: SimplexEpochInfo{ + BlockValidationDescriptor: &BlockValidationDescriptor{}, + }, + err: "block validation descriptor should be nil but got &{{[] {0}} {0}}", + }, + { + name: "telock block with nil descriptor", + nextBlockType: BlockTypeTelock, + next: SimplexEpochInfo{}, + }, + { + name: "new epoch block with nil descriptor", + nextBlockType: BlockTypeNewEpoch, + next: SimplexEpochInfo{}, + }, + } { + t.Run(tc.name, func(t *testing.T) { + v := &validationDescriptorVerifier{ + getValidatorSet: tc.getValidator, + } + err := v.Verify(verificationInput{ + nextBlockType: tc.nextBlockType, + proposedBlockMD: StateMachineMetadata{SimplexEpochInfo: tc.next}, + }) + if tc.err != "" { + require.EqualError(t, err, tc.err) + } else { + require.NoError(t, err) + } + }) + } +} + +func TestNextEpochApprovalsVerifier(t *testing.T) { + validators := NodeBLSMappings{ + {BLSKey: []byte{1}, Weight: 1}, + {BLSKey: []byte{2}, Weight: 1}, + {BLSKey: []byte{3}, Weight: 1}, + } + + for _, tc := range []struct { + name string + nextBlockType BlockType + prev SimplexEpochInfo + next SimplexEpochInfo + getValidator ValidatorSetRetriever + sigVerifier SignatureVerifier + keyAggregator KeyAggregator + err string + }{ + { + name: "sealing block with nil approvals", + nextBlockType: BlockTypeSealing, + next: SimplexEpochInfo{}, + err: "next epoch approvals should not be nil for a sealing block", + }, + { + name: "sealing block with validator retrieval error", + nextBlockType: BlockTypeSealing, + next: SimplexEpochInfo{ + NextPChainReferenceHeight: 100, + NextEpochApprovals: &NextEpochApprovals{NodeIDs: []byte{7}, Signature: []byte("sig")}, + }, + getValidator: func(h uint64) (NodeBLSMappings, error) { return nil, fmt.Errorf("unavailable") }, + err: "unavailable", + }, + { + name: "sealing block not enough approvals", + nextBlockType: BlockTypeSealing, + next: SimplexEpochInfo{ + NextPChainReferenceHeight: 100, + NextEpochApprovals: &NextEpochApprovals{NodeIDs: []byte{1}, Signature: []byte("sig")}, + }, + getValidator: func(h uint64) (NodeBLSMappings, error) { return validators, nil }, + sigVerifier: &testSigVerifier{}, + keyAggregator: &testKeyAggregator{}, + err: "not enough approvals to seal block", + }, + { + name: "sealing block enough approvals", + nextBlockType: BlockTypeSealing, + next: SimplexEpochInfo{ + NextPChainReferenceHeight: 100, + NextEpochApprovals: &NextEpochApprovals{NodeIDs: []byte{7}, Signature: []byte("sig")}, + }, + getValidator: func(h uint64) (NodeBLSMappings, error) { return validators, nil }, + sigVerifier: &testSigVerifier{}, + keyAggregator: &testKeyAggregator{}, + }, + { + name: "normal block no validator change", + nextBlockType: BlockTypeNormal, + prev: SimplexEpochInfo{NextPChainReferenceHeight: 0}, + next: SimplexEpochInfo{}, + }, + { + name: "normal block collecting approvals with nil approvals", + nextBlockType: BlockTypeNormal, + prev: SimplexEpochInfo{NextPChainReferenceHeight: 100}, + next: SimplexEpochInfo{NextPChainReferenceHeight: 100}, + err: "next epoch approvals should not be nil when collecting approvals", + }, + { + name: "normal block collecting approvals valid", + nextBlockType: BlockTypeNormal, + prev: SimplexEpochInfo{ + NextPChainReferenceHeight: 100, + PChainReferenceHeight: 50, + }, + next: SimplexEpochInfo{ + NextPChainReferenceHeight: 100, + NextEpochApprovals: &NextEpochApprovals{NodeIDs: []byte{1}, Signature: []byte("sig")}, + }, + getValidator: func(h uint64) (NodeBLSMappings, error) { return validators, nil }, + sigVerifier: &testSigVerifier{}, + keyAggregator: &testKeyAggregator{}, + }, + { + name: "normal block collecting approvals signers not superset of prev", + nextBlockType: BlockTypeNormal, + prev: SimplexEpochInfo{ + NextPChainReferenceHeight: 100, + PChainReferenceHeight: 50, + NextEpochApprovals: &NextEpochApprovals{NodeIDs: []byte{3}, Signature: []byte("sig")}, // bits 0,1 + }, + next: SimplexEpochInfo{ + NextPChainReferenceHeight: 100, + NextEpochApprovals: &NextEpochApprovals{NodeIDs: []byte{1}, Signature: []byte("sig")}, // bit 0 only + }, + getValidator: func(h uint64) (NodeBLSMappings, error) { return validators, nil }, + sigVerifier: &testSigVerifier{}, + keyAggregator: &testKeyAggregator{}, + err: "some signers from parent block are missing from next epoch approvals of proposed block", + }, + { + name: "telock block with nil approvals", + nextBlockType: BlockTypeTelock, + next: SimplexEpochInfo{}, + }, + { + name: "telock block with non-nil approvals", + nextBlockType: BlockTypeTelock, + next: SimplexEpochInfo{ + NextEpochApprovals: &NextEpochApprovals{}, + }, + err: "next epoch approvals should be nil but got &{[] [] {0}}", + }, + { + name: "new epoch block with nil approvals", + nextBlockType: BlockTypeNewEpoch, + next: SimplexEpochInfo{}, + }, + { + name: "new epoch block with non-nil approvals", + nextBlockType: BlockTypeNewEpoch, + next: SimplexEpochInfo{ + NextEpochApprovals: &NextEpochApprovals{}, + }, + err: "next epoch approvals should be nil but got &{[] [] {0}}", + }, + } { + t.Run(tc.name, func(t *testing.T) { + v := &nextEpochApprovalsVerifier{ + sigVerifier: tc.sigVerifier, + getValidatorSet: tc.getValidator, + keyAggregator: tc.keyAggregator, + } + err := v.Verify(verificationInput{ + nextBlockType: tc.nextBlockType, + prevMD: StateMachineMetadata{SimplexEpochInfo: tc.prev}, + proposedBlockMD: StateMachineMetadata{SimplexEpochInfo: tc.next}, + }) + if tc.err != "" { + require.EqualError(t, err, tc.err) + } else { + require.NoError(t, err) + } + }) + } +} + +func TestSealingBlockSeqVerifier(t *testing.T) { + prevProtocolMD := (&simplex.ProtocolMetadata{Seq: 5}).Bytes() + + for _, tc := range []struct { + name string + nextBlockType BlockType + prev SimplexEpochInfo + prevMD StateMachineMetadata + next SimplexEpochInfo + err string + }{ + { + name: "normal block with zero sealing seq", + nextBlockType: BlockTypeNormal, + next: SimplexEpochInfo{SealingBlockSeq: 0}, + }, + { + name: "normal block with non-zero sealing seq", + nextBlockType: BlockTypeNormal, + next: SimplexEpochInfo{SealingBlockSeq: 5}, + err: "expected sealing block sequence number to be 0 but got 5", + }, + { + name: "new epoch block with zero sealing seq", + nextBlockType: BlockTypeNewEpoch, + next: SimplexEpochInfo{SealingBlockSeq: 0}, + }, + { + name: "new epoch block with non-zero sealing seq", + nextBlockType: BlockTypeNewEpoch, + next: SimplexEpochInfo{SealingBlockSeq: 3}, + err: "expected sealing block sequence number to be 0 but got 3", + }, + { + name: "telock block matching prev sealing seq", + nextBlockType: BlockTypeTelock, + prev: SimplexEpochInfo{SealingBlockSeq: 10}, + next: SimplexEpochInfo{SealingBlockSeq: 10}, + }, + { + name: "telock block mismatching prev sealing seq", + nextBlockType: BlockTypeTelock, + prev: SimplexEpochInfo{SealingBlockSeq: 10}, + next: SimplexEpochInfo{SealingBlockSeq: 11}, + err: "expected sealing block sequence number to be 10 but got 11", + }, + { + name: "sealing block with zero seq", + nextBlockType: BlockTypeSealing, + prevMD: StateMachineMetadata{SimplexProtocolMetadata: prevProtocolMD}, + next: SimplexEpochInfo{SealingBlockSeq: 0}, + }, + { + name: "sealing block with non-zero seq", + nextBlockType: BlockTypeSealing, + prevMD: StateMachineMetadata{SimplexProtocolMetadata: prevProtocolMD}, + next: SimplexEpochInfo{SealingBlockSeq: 10}, + err: "expected sealing block sequence number to be 0 but got 10", + }, + } { + t.Run(tc.name, func(t *testing.T) { + v := &sealingBlockSeqVerifier{} + prevMD := tc.prevMD + prevMD.SimplexEpochInfo = tc.prev + err := v.Verify(verificationInput{ + nextBlockType: tc.nextBlockType, + prevMD: prevMD, + proposedBlockMD: StateMachineMetadata{SimplexEpochInfo: tc.next}, + }) + if tc.err != "" { + require.EqualError(t, err, tc.err) + } else { + require.NoError(t, err) + } + }) + } +} + +// Test helpers + +type testBlockStore map[uint64]StateMachineBlock + +func (bs testBlockStore) getBlock(opts RetrievingOpts) (StateMachineBlock, *simplex.Finalization, error) { + blk, ok := bs[opts.Height] + if !ok { + return StateMachineBlock{}, nil, fmt.Errorf("%w: block %d", simplex.ErrBlockNotFound, opts.Height) + } + return blk, nil, nil +} + +type testVMBlock struct { + bytes []byte + height uint64 +} + +func (b *testVMBlock) Digest() [32]byte { + return sha256.Sum256(b.bytes) +} + +func (b *testVMBlock) Height() uint64 { + return b.height +} + +func (b *testVMBlock) Timestamp() time.Time { + return time.Now() +} + +func (b *testVMBlock) Verify(_ context.Context) error { + return nil +} + +type testSigVerifier struct { + err error +} + +func (sv *testSigVerifier) VerifySignature(_, _, _ []byte) error { + return sv.err +} + +type testKeyAggregator struct { + err error +} + +func (ka *testKeyAggregator) AggregateKeys(keys ...[]byte) ([]byte, error) { + if ka.err != nil { + return nil, ka.err + } + var agg []byte + for _, k := range keys { + agg = append(agg, k...) + } + return agg, nil +} + +type InnerBlock struct { + TS time.Time + BlockHeight uint64 + Bytes []byte +} + +func (i *InnerBlock) Digest() [32]byte { + return sha256.Sum256(i.Bytes) +} + +func (i *InnerBlock) Height() uint64 { + return i.BlockHeight +} + +func (i *InnerBlock) Timestamp() time.Time { + return i.TS +} + +func (i *InnerBlock) Verify(_ context.Context) error { + return nil +} From 501e0f1e24be313c6d570623820a844062ed54a1 Mon Sep 17 00:00:00 2001 From: Yacov Manevich Date: Thu, 16 Apr 2026 22:42:38 +0200 Subject: [PATCH 04/25] msm: remove unused newApprovals param in createSealingBlock Addresses review comment: parameter was threaded through but never read. Co-Authored-By: Claude Opus 4.7 (1M context) --- msm/msm.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/msm/msm.go b/msm/msm.go index a256c8da..f0fc58ae 100644 --- a/msm/msm.go +++ b/msm/msm.go @@ -603,7 +603,7 @@ func (sm *StateMachine) buildBlockCollectingApprovals(ctx context.Context, paren } // Else, we have enough approvals to seal the epoch, so we create the sealing block. - return sm.createSealingBlock(ctx, parentBlock, simplexMetadata, simplexBlacklist, newSimplexEpochInfo, newApprovals, pChainHeight) + return sm.createSealingBlock(ctx, parentBlock, simplexMetadata, simplexBlacklist, newSimplexEpochInfo, pChainHeight) } // buildBlockImpatiently builds a block by waiting for the VM to build a block until MaxBlockBuildingWaitTime. @@ -629,7 +629,7 @@ func (sm *StateMachine) buildBlockImpatiently(ctx context.Context, parentBlock S return sm.wrapBlock(parentBlock, childBlock, simplexEpochInfo, pChainHeight, simplexMetadata, simplexBlacklist), nil } -func (sm *StateMachine) createSealingBlock(ctx context.Context, parentBlock StateMachineBlock, simplexMetadata []byte, simplexBlacklist []byte, simplexEpochInfo SimplexEpochInfo, newApprovals *approvals, pChainHeight uint64) (*StateMachineBlock, error) { +func (sm *StateMachine) createSealingBlock(ctx context.Context, parentBlock StateMachineBlock, simplexMetadata []byte, simplexBlacklist []byte, simplexEpochInfo SimplexEpochInfo, pChainHeight uint64) (*StateMachineBlock, error) { validators, err := sm.GetValidatorSet(simplexEpochInfo.NextPChainReferenceHeight) if err != nil { return nil, err From f2dda7dfa4f0625d353b2c44e6d14e197d32946d Mon Sep 17 00:00:00 2001 From: Yacov Manevich Date: Thu, 16 Apr 2026 22:43:27 +0200 Subject: [PATCH 05/25] msm: remove unused innerChain field from fakeNode Addresses review comment: field was only written to, never read. Co-Authored-By: Claude Opus 4.7 (1M context) --- msm/fake_node_test.go | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/msm/fake_node_test.go b/msm/fake_node_test.go index 4c980a44..4ecdc951 100644 --- a/msm/fake_node_test.go +++ b/msm/fake_node_test.go @@ -174,7 +174,6 @@ type fakeNode struct { mempoolEmpty bool notarizedBlocks []StateMachineBlock finalizedBlocks []StateMachineBlock - innerChain []innerBlock } func (fn *fakeNode) WaitForProgress(ctx context.Context, pChainHeight uint64) error { @@ -300,14 +299,10 @@ func (fn *fakeNode) isNextBlockTelock() bool { } func (fn *fakeNode) buildAndNotarizeBlock() { - vmBlock, block := fn.buildBlock() + _, block := fn.buildBlock() require.NoError(fn.t, fn.sm.VerifyBlock(context.Background(), block)) fn.notarizedBlocks = append(fn.notarizedBlocks, *block) - - if vmBlock != nil { - fn.innerChain = append(fn.innerChain, *vmBlock.(*innerBlock)) - } } func (fn *fakeNode) buildBlock() (VMBlock, *StateMachineBlock) { From 72cd9c844467fdb0edab211b56a8918d422dd8b9 Mon Sep 17 00:00:00 2001 From: Yacov Manevich Date: Thu, 16 Apr 2026 22:44:03 +0200 Subject: [PATCH 06/25] msm: simplify fakeNode.buildBlock to return only the outer block Addresses review comment: the returned VMBlock was unused after dropping innerChain. Co-Authored-By: Claude Opus 4.7 (1M context) --- msm/fake_node_test.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/msm/fake_node_test.go b/msm/fake_node_test.go index 4ecdc951..7a59c7ce 100644 --- a/msm/fake_node_test.go +++ b/msm/fake_node_test.go @@ -299,13 +299,13 @@ func (fn *fakeNode) isNextBlockTelock() bool { } func (fn *fakeNode) buildAndNotarizeBlock() { - _, block := fn.buildBlock() + block := fn.buildBlock() require.NoError(fn.t, fn.sm.VerifyBlock(context.Background(), block)) fn.notarizedBlocks = append(fn.notarizedBlocks, *block) } -func (fn *fakeNode) buildBlock() (VMBlock, *StateMachineBlock) { +func (fn *fakeNode) buildBlock() *StateMachineBlock { parentBlock := fn.getParentBlock() lastMD, prevBlockDigest := fn.prepareMetadataAndPrevBlockDigest() @@ -330,7 +330,7 @@ func (fn *fakeNode) buildBlock() (VMBlock, *StateMachineBlock) { }, nil) require.NoError(fn.t, err) - return block.InnerBlock, block + return block } func (fn *fakeNode) prepareMetadataAndPrevBlockDigest() (*simplex.ProtocolMetadata, [32]byte) { From 5c2d1a02aad1b341dac22eaf51a78259f53f2af7 Mon Sep 17 00:00:00 2001 From: Yacov Manevich Date: Thu, 16 Apr 2026 22:44:25 +0200 Subject: [PATCH 07/25] msm: document fakeNode.act behavior Addresses review comment: clarify that act randomly chooses between finalizing, building, or no-op. Co-Authored-By: Claude Opus 4.7 (1M context) --- msm/fake_node_test.go | 1 + 1 file changed, 1 insertion(+) diff --git a/msm/fake_node_test.go b/msm/fake_node_test.go index 7a59c7ce..be0e90dc 100644 --- a/msm/fake_node_test.go +++ b/msm/fake_node_test.go @@ -251,6 +251,7 @@ func (fn *fakeNode) Epoch() uint64 { return fn.notarizedBlocks[len(fn.notarizedBlocks)-1].Metadata.SimplexEpochInfo.EpochNumber } +// act randomly either finalizes a notarized block, builds and notarizes a new block, or does nothing. func (fn *fakeNode) act() { if fn.canFinalize() && flipCoin() { fn.tryFinalizeNextBlock() From 7d7d0c1c2833e9e898124a52ca39fb3f890f48d1 Mon Sep 17 00:00:00 2001 From: Yacov Manevich Date: Thu, 16 Apr 2026 22:44:49 +0200 Subject: [PATCH 08/25] msm: polish computePrevVMBlockSeq comment Addresses review comment. Co-Authored-By: Claude Opus 4.7 (1M context) --- msm/msm.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/msm/msm.go b/msm/msm.go index f0fc58ae..a4991ed8 100644 --- a/msm/msm.go +++ b/msm/msm.go @@ -1015,11 +1015,11 @@ func findFirstSimplexBlock(getBlock BlockRetriever, endHeight uint64) (uint64, e } func computePrevVMBlockSeq(parentBlock StateMachineBlock, prevBlockSeq uint64) uint64 { - // Either our parent block has no inner block, in which case we just inherit its previous VM block sequence, + // Either our parent block has no inner block, in which case we just inherit its previous VM block sequence. if parentBlock.InnerBlock == nil { return parentBlock.Metadata.SimplexEpochInfo.PrevVMBlockSeq } - // or it has an inner block, in which case it is the previous block sequence. + // Otherwise, it has an inner block, in which case it is the previous block sequence. return prevBlockSeq } From 93abf7a9e85537f26773d158765c92a9acc298d2 Mon Sep 17 00:00:00 2001 From: Yacov Manevich Date: Thu, 16 Apr 2026 22:45:15 +0200 Subject: [PATCH 09/25] msm: rename TestFakeNode to TestStateMachineEpochTransition Addresses review comment: test names should describe what is being tested (state machine epoch transitions), not the test fixture. Co-Authored-By: Claude Opus 4.7 (1M context) --- msm/fake_node_test.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/msm/fake_node_test.go b/msm/fake_node_test.go index be0e90dc..fcee88e3 100644 --- a/msm/fake_node_test.go +++ b/msm/fake_node_test.go @@ -15,7 +15,7 @@ import ( "github.com/stretchr/testify/require" ) -func TestFakeNode(t *testing.T) { +func TestStateMachineEpochTransition(t *testing.T) { validatorSetRetriever := validatorSetRetriever{ resultMap: map[uint64]NodeBLSMappings{ 100: {{BLSKey: []byte{1}, Weight: 1, NodeID: [20]byte{1}}, {BLSKey: []byte{2}, Weight: 1, NodeID: [20]byte{2}}}, @@ -81,7 +81,7 @@ func TestFakeNode(t *testing.T) { require.Greater(t, node.Epoch(), epoch) } -func TestFakeNodeEmptyMempool(t *testing.T) { +func TestStateMachineEpochTransitionEmptyMempool(t *testing.T) { validatorSetRetriever := validatorSetRetriever{ resultMap: map[uint64]NodeBLSMappings{ 100: {{BLSKey: []byte{1}, Weight: 1, NodeID: [20]byte{1}}, {BLSKey: []byte{2}, Weight: 1, NodeID: [20]byte{2}}}, From 44e73fbd4df4cc9b0216a0d23b82c68ec7e64bc1 Mon Sep 17 00:00:00 2001 From: Yacov Manevich Date: Thu, 16 Apr 2026 22:45:52 +0200 Subject: [PATCH 10/25] msm: rename fakeNode to multiEpochNode Addresses review comment: the type's purpose is to drive the state machine across epoch transitions, so a more descriptive name is used. The file is renamed accordingly. Co-Authored-By: Claude Opus 4.7 (1M context) --- ..._node_test.go => multi_epoch_node_test.go} | 38 +++++++++---------- 1 file changed, 19 insertions(+), 19 deletions(-) rename msm/{fake_node_test.go => multi_epoch_node_test.go} (91%) diff --git a/msm/fake_node_test.go b/msm/multi_epoch_node_test.go similarity index 91% rename from msm/fake_node_test.go rename to msm/multi_epoch_node_test.go index fcee88e3..2a080557 100644 --- a/msm/fake_node_test.go +++ b/msm/multi_epoch_node_test.go @@ -28,7 +28,7 @@ func TestStateMachineEpochTransition(t *testing.T) { var pChainHeight atomic.Uint64 pChainHeight.Store(100) - node := newFakeNode(t) + node := newMultiEpochNode(t) node.sm.GetValidatorSet = validatorSetRetriever.getValidatorSet node.sm.GetPChainHeight = func() uint64 { return pChainHeight.Load() @@ -93,7 +93,7 @@ func TestStateMachineEpochTransitionEmptyMempool(t *testing.T) { } var pChainHeight uint64 = 100 - node := newFakeNode(t) + node := newMultiEpochNode(t) node.sm.MaxBlockBuildingWaitTime = 100 * time.Millisecond node.sm.GetValidatorSet = validatorSetRetriever.getValidatorSet node.sm.GetPChainHeight = func() uint64 { @@ -168,7 +168,7 @@ type innerBlock struct { Prev [32]byte } -type fakeNode struct { +type multiEpochNode struct { t *testing.T sm StateMachine mempoolEmpty bool @@ -176,7 +176,7 @@ type fakeNode struct { finalizedBlocks []StateMachineBlock } -func (fn *fakeNode) WaitForProgress(ctx context.Context, pChainHeight uint64) error { +func (fn *multiEpochNode) WaitForProgress(ctx context.Context, pChainHeight uint64) error { for { select { case <-ctx.Done(): @@ -189,17 +189,17 @@ func (fn *fakeNode) WaitForProgress(ctx context.Context, pChainHeight uint64) er } } -func (fn *fakeNode) WaitForPendingBlock(ctx context.Context) { +func (fn *multiEpochNode) WaitForPendingBlock(ctx context.Context) { if fn.mempoolEmpty { <-ctx.Done() return } } -func newFakeNode(t *testing.T) *fakeNode { +func newMultiEpochNode(t *testing.T) *multiEpochNode { sm, _ := newStateMachine(t) - fn := &fakeNode{ + fn := &multiEpochNode{ t: t, sm: sm, } @@ -243,16 +243,16 @@ func newFakeNode(t *testing.T) *fakeNode { return fn } -func (fn *fakeNode) Height() uint64 { +func (fn *multiEpochNode) Height() uint64 { return uint64(len(fn.finalizedBlocks)) } -func (fn *fakeNode) Epoch() uint64 { +func (fn *multiEpochNode) Epoch() uint64 { return fn.notarizedBlocks[len(fn.notarizedBlocks)-1].Metadata.SimplexEpochInfo.EpochNumber } // act randomly either finalizes a notarized block, builds and notarizes a new block, or does nothing. -func (fn *fakeNode) act() { +func (fn *multiEpochNode) act() { if fn.canFinalize() && flipCoin() { fn.tryFinalizeNextBlock() return @@ -265,11 +265,11 @@ func (fn *fakeNode) act() { fn.buildAndNotarizeBlock() } -func (fn *fakeNode) canFinalize() bool { +func (fn *multiEpochNode) canFinalize() bool { return len(fn.notarizedBlocks) > len(fn.finalizedBlocks) } -func (fn *fakeNode) tryFinalizeNextBlock() { +func (fn *multiEpochNode) tryFinalizeNextBlock() { nextIndex := len(fn.finalizedBlocks) if fn.isNextBlockTelock() { @@ -292,21 +292,21 @@ func (fn *fakeNode) tryFinalizeNextBlock() { } } -func (fn *fakeNode) isNextBlockTelock() bool { +func (fn *multiEpochNode) isNextBlockTelock() bool { if len(fn.finalizedBlocks) == 0 { return false } return fn.notarizedBlocks[len(fn.finalizedBlocks)].Metadata.SimplexEpochInfo.SealingBlockSeq > 0 } -func (fn *fakeNode) buildAndNotarizeBlock() { +func (fn *multiEpochNode) buildAndNotarizeBlock() { block := fn.buildBlock() require.NoError(fn.t, fn.sm.VerifyBlock(context.Background(), block)) fn.notarizedBlocks = append(fn.notarizedBlocks, *block) } -func (fn *fakeNode) buildBlock() *StateMachineBlock { +func (fn *multiEpochNode) buildBlock() *StateMachineBlock { parentBlock := fn.getParentBlock() lastMD, prevBlockDigest := fn.prepareMetadataAndPrevBlockDigest() @@ -334,7 +334,7 @@ func (fn *fakeNode) buildBlock() *StateMachineBlock { return block } -func (fn *fakeNode) prepareMetadataAndPrevBlockDigest() (*simplex.ProtocolMetadata, [32]byte) { +func (fn *multiEpochNode) prepareMetadataAndPrevBlockDigest() (*simplex.ProtocolMetadata, [32]byte) { var lastMD *simplex.ProtocolMetadata var err error lastBlockDigest := genesisBlock.Digest() @@ -351,7 +351,7 @@ func (fn *fakeNode) prepareMetadataAndPrevBlockDigest() (*simplex.ProtocolMetada return lastMD, lastBlockDigest } -func (fn *fakeNode) BuildBlock(context.Context, uint64) (VMBlock, error) { +func (fn *multiEpochNode) BuildBlock(context.Context, uint64) (VMBlock, error) { // Count the number of inner blocks in the chain var count int for _, block := range fn.notarizedBlocks { @@ -371,7 +371,7 @@ func (fn *fakeNode) BuildBlock(context.Context, uint64) (VMBlock, error) { return vmBlock, nil } -func (fn *fakeNode) getParentBlock() StateMachineBlock { +func (fn *multiEpochNode) getParentBlock() StateMachineBlock { var parentBlock StateMachineBlock if len(fn.notarizedBlocks) > 0 { parentBlock = fn.notarizedBlocks[len(fn.notarizedBlocks)-1] @@ -386,7 +386,7 @@ func (fn *fakeNode) getParentBlock() StateMachineBlock { return parentBlock } -func (fn *fakeNode) getLastVMBlockDigest() [32]byte { +func (fn *multiEpochNode) getLastVMBlockDigest() [32]byte { var lastVMBlockDigest = genesisBlock.Digest() notarizedBlocks := fn.notarizedBlocks From 2ac42b51ff2c513959ec664d9618495852bbaf65 Mon Sep 17 00:00:00 2001 From: Yacov Manevich Date: Thu, 16 Apr 2026 22:47:56 +0200 Subject: [PATCH 11/25] msm: consolidate notarized/finalized blocks into blockState slice Addresses review comments: finalized blocks were a prefix of notarized blocks, so keeping two separate slices was redundant and error prone (e.g. tryFinalizeNextBlock panicked when the two went out of sync). Replaces both with a single blocks []blockState slice where each entry carries a finalized flag. Also removes the duplicate lookup in the GetBlock test fixture that would return finalized blocks as non-finalized. Co-Authored-By: Claude Opus 4.7 (1M context) --- msm/multi_epoch_node_test.go | 147 +++++++++++++++++++---------------- 1 file changed, 81 insertions(+), 66 deletions(-) diff --git a/msm/multi_epoch_node_test.go b/msm/multi_epoch_node_test.go index 2a080557..b967762c 100644 --- a/msm/multi_epoch_node_test.go +++ b/msm/multi_epoch_node_test.go @@ -112,7 +112,7 @@ func TestStateMachineEpochTransitionEmptyMempool(t *testing.T) { node.mempoolEmpty = true // We build blocks until the sealing block is finalized. - for node.finalizedBlocks[len(node.finalizedBlocks)-1].Metadata.SimplexEpochInfo.BlockValidationDescriptor == nil { + for node.lastFinalizedBlock().Metadata.SimplexEpochInfo.BlockValidationDescriptor == nil { node.act() if flipCoin() { node.sm.ApprovalsRetriever = &approvalsRetriever{ @@ -168,12 +168,18 @@ type innerBlock struct { Prev [32]byte } +type blockState struct { + block StateMachineBlock + finalized bool +} + type multiEpochNode struct { - t *testing.T - sm StateMachine - mempoolEmpty bool - notarizedBlocks []StateMachineBlock - finalizedBlocks []StateMachineBlock + t *testing.T + sm StateMachine + mempoolEmpty bool + // blocks holds notarized blocks in order. Finalized blocks always form a + // prefix: all finalized entries precede all non-finalized entries. + blocks []blockState } func (fn *multiEpochNode) WaitForProgress(ctx context.Context, pChainHeight uint64) error { @@ -211,28 +217,21 @@ func newMultiEpochNode(t *testing.T) *multiEpochNode { if opts.Height == 0 { return genesisBlock, nil, nil } - for _, block := range fn.finalizedBlocks { - if block.Digest() == opts.Digest { - return block, &simplex.Finalization{}, nil - } - md, err := simplex.ProtocolMetadataFromBytes(block.Metadata.SimplexProtocolMetadata) - if err != nil { - return StateMachineBlock{}, nil, err + for _, bs := range fn.blocks { + match := bs.block.Digest() == opts.Digest + if !match { + md, err := simplex.ProtocolMetadataFromBytes(bs.block.Metadata.SimplexProtocolMetadata) + if err != nil { + return StateMachineBlock{}, nil, err + } + match = md.Seq == opts.Height } - if md.Seq == opts.Height { - return block, &simplex.Finalization{}, nil - } - } - for _, block := range fn.notarizedBlocks { - if block.Digest() == opts.Digest { - return block, nil, nil - } - md, err := simplex.ProtocolMetadataFromBytes(block.Metadata.SimplexProtocolMetadata) - if err != nil { - return StateMachineBlock{}, nil, err - } - if md.Seq == opts.Height { - return block, nil, nil + if match { + var fin *simplex.Finalization + if bs.finalized { + fin = &simplex.Finalization{} + } + return bs.block, fin, nil } } @@ -243,12 +242,29 @@ func newMultiEpochNode(t *testing.T) *multiEpochNode { return fn } +// lastFinalizedBlock returns the most recently finalized block. +// Panics if nothing has been finalized. +func (fn *multiEpochNode) lastFinalizedBlock() StateMachineBlock { + for i := len(fn.blocks) - 1; i >= 0; i-- { + if fn.blocks[i].finalized { + return fn.blocks[i].block + } + } + panic("no finalized block") +} + func (fn *multiEpochNode) Height() uint64 { - return uint64(len(fn.finalizedBlocks)) + var count uint64 + for _, bs := range fn.blocks { + if bs.finalized { + count++ + } + } + return count } func (fn *multiEpochNode) Epoch() uint64 { - return fn.notarizedBlocks[len(fn.notarizedBlocks)-1].Metadata.SimplexEpochInfo.EpochNumber + return fn.blocks[len(fn.blocks)-1].block.Metadata.SimplexEpochInfo.EpochNumber } // act randomly either finalizes a notarized block, builds and notarizes a new block, or does nothing. @@ -266,18 +282,27 @@ func (fn *multiEpochNode) act() { } func (fn *multiEpochNode) canFinalize() bool { - return len(fn.notarizedBlocks) > len(fn.finalizedBlocks) + return fn.nextUnfinalizedIndex() < len(fn.blocks) +} + +func (fn *multiEpochNode) nextUnfinalizedIndex() int { + for i, bs := range fn.blocks { + if !bs.finalized { + return i + } + } + return len(fn.blocks) } func (fn *multiEpochNode) tryFinalizeNextBlock() { - nextIndex := len(fn.finalizedBlocks) + nextIndex := fn.nextUnfinalizedIndex() - if fn.isNextBlockTelock() { + if fn.isNextBlockTelock(nextIndex) { return } - block := fn.notarizedBlocks[nextIndex] - fn.finalizedBlocks = append(fn.finalizedBlocks, block) + fn.blocks[nextIndex].finalized = true + block := fn.blocks[nextIndex].block md, err := simplex.ProtocolMetadataFromBytes(block.Metadata.SimplexProtocolMetadata) require.NoError(fn.t, err) @@ -287,23 +312,23 @@ func (fn *multiEpochNode) tryFinalizeNextBlock() { // If we just finalized a sealing block, trim trailing Telock blocks. if block.Metadata.SimplexEpochInfo.BlockValidationDescriptor != nil { - fn.notarizedBlocks = fn.notarizedBlocks[:len(fn.finalizedBlocks)] - fn.t.Logf("Trimmed notarized blocks, new length: %d", len(fn.notarizedBlocks)) + fn.blocks = fn.blocks[:nextIndex+1] + fn.t.Logf("Trimmed notarized blocks, new length: %d", len(fn.blocks)) } } -func (fn *multiEpochNode) isNextBlockTelock() bool { - if len(fn.finalizedBlocks) == 0 { +func (fn *multiEpochNode) isNextBlockTelock(nextIndex int) bool { + if nextIndex == 0 { return false } - return fn.notarizedBlocks[len(fn.finalizedBlocks)].Metadata.SimplexEpochInfo.SealingBlockSeq > 0 + return fn.blocks[nextIndex].block.Metadata.SimplexEpochInfo.SealingBlockSeq > 0 } func (fn *multiEpochNode) buildAndNotarizeBlock() { block := fn.buildBlock() require.NoError(fn.t, fn.sm.VerifyBlock(context.Background(), block)) - fn.notarizedBlocks = append(fn.notarizedBlocks, *block) + fn.blocks = append(fn.blocks, blockState{block: *block}) } func (fn *multiEpochNode) buildBlock() *StateMachineBlock { @@ -338,8 +363,8 @@ func (fn *multiEpochNode) prepareMetadataAndPrevBlockDigest() (*simplex.Protocol var lastMD *simplex.ProtocolMetadata var err error lastBlockDigest := genesisBlock.Digest() - if len(fn.notarizedBlocks) > 0 { - lastBlock := fn.notarizedBlocks[len(fn.notarizedBlocks)-1] + if len(fn.blocks) > 0 { + lastBlock := fn.blocks[len(fn.blocks)-1].block lastBlockDigest = lastBlock.Digest() lastMD, err = simplex.ProtocolMetadataFromBytes(lastBlock.Metadata.SimplexProtocolMetadata) require.NoError(fn.t, err) @@ -354,8 +379,8 @@ func (fn *multiEpochNode) prepareMetadataAndPrevBlockDigest() (*simplex.Protocol func (fn *multiEpochNode) BuildBlock(context.Context, uint64) (VMBlock, error) { // Count the number of inner blocks in the chain var count int - for _, block := range fn.notarizedBlocks { - if block.InnerBlock != nil { + for _, bs := range fn.blocks { + if bs.block.InnerBlock != nil { count++ } } @@ -372,34 +397,24 @@ func (fn *multiEpochNode) BuildBlock(context.Context, uint64) (VMBlock, error) { } func (fn *multiEpochNode) getParentBlock() StateMachineBlock { - var parentBlock StateMachineBlock - if len(fn.notarizedBlocks) > 0 { - parentBlock = fn.notarizedBlocks[len(fn.notarizedBlocks)-1] - } else { - gb := genesisBlock.InnerBlock.(*InnerBlock) - parentBlock = StateMachineBlock{ - InnerBlock: &innerBlock{ - InnerBlock: *gb, - }, - } + if len(fn.blocks) > 0 { + return fn.blocks[len(fn.blocks)-1].block + } + gb := genesisBlock.InnerBlock.(*InnerBlock) + return StateMachineBlock{ + InnerBlock: &innerBlock{ + InnerBlock: *gb, + }, } - return parentBlock } func (fn *multiEpochNode) getLastVMBlockDigest() [32]byte { - var lastVMBlockDigest = genesisBlock.Digest() - - notarizedBlocks := fn.notarizedBlocks - for len(notarizedBlocks) > 0 { - lastNotarizedBlock := notarizedBlocks[len(notarizedBlocks)-1] - if lastNotarizedBlock.InnerBlock == nil { - notarizedBlocks = notarizedBlocks[:len(notarizedBlocks)-1] - continue + for i := len(fn.blocks) - 1; i >= 0; i-- { + if fn.blocks[i].block.InnerBlock != nil { + return fn.blocks[i].block.Digest() } - lastVMBlockDigest = lastNotarizedBlock.Digest() - break } - return lastVMBlockDigest + return genesisBlock.Digest() } func randomBuff(n int) []byte { From 20a72cd32d0deea372799420278038003c53b605 Mon Sep 17 00:00:00 2001 From: Yacov Manevich Date: Thu, 16 Apr 2026 22:48:16 +0200 Subject: [PATCH 12/25] msm: start state and BlockType enums at iota+1 Addresses review comment: a zero value should not match a valid variant, so callers get a clear error if an uninitialized value sneaks through. Co-Authored-By: Claude Opus 4.7 (1M context) --- msm/msm.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/msm/msm.go b/msm/msm.go index a4991ed8..a56b400c 100644 --- a/msm/msm.go +++ b/msm/msm.go @@ -133,7 +133,7 @@ type StateMachine struct { type state uint8 const ( - stateFirstSimplexBlock state = iota + stateFirstSimplexBlock state = iota + 1 stateBuildBlockNormalOp stateBuildCollectingApprovals stateBuildBlockEpochSealed @@ -142,7 +142,7 @@ const ( type BlockType uint8 const ( - BlockTypeNormal BlockType = iota + BlockTypeNormal BlockType = iota + 1 BlockTypeTelock BlockTypeSealing BlockTypeNewEpoch From 95df444dc242f67c6dd6e1bf7ade98b951ef6d0d Mon Sep 17 00:00:00 2001 From: Yacov Manevich Date: Thu, 16 Apr 2026 22:48:38 +0200 Subject: [PATCH 13/25] msm: simplify NodeBLSMappings.Equal using slices.EqualFunc Addresses review comment: inline the equality loop with slices.EqualFunc and share the sort comparator. Co-Authored-By: Claude Opus 4.7 (1M context) --- msm/encoding.go | 21 ++++++++------------- 1 file changed, 8 insertions(+), 13 deletions(-) diff --git a/msm/encoding.go b/msm/encoding.go index c5e642ba..8b295f44 100644 --- a/msm/encoding.go +++ b/msm/encoding.go @@ -248,23 +248,18 @@ func (nbms NodeBLSMappings) Equal(other NodeBLSMappings) bool { return false } + sortByNodeID := func(a, b NodeBLSMapping) int { + return slices.Compare(a.NodeID[:], b.NodeID[:]) + } + nbmsClone := nbms.Clone() otherClone := other.Clone() + slices.SortFunc(nbmsClone, sortByNodeID) + slices.SortFunc(otherClone, sortByNodeID) - slices.SortFunc(nbmsClone, func(a, b NodeBLSMapping) int { - return slices.Compare(a.NodeID[:], b.NodeID[:]) - }) - - slices.SortFunc(otherClone, func(a, b NodeBLSMapping) int { - return slices.Compare(a.NodeID[:], b.NodeID[:]) + return slices.EqualFunc(nbmsClone, otherClone, func(a, b NodeBLSMapping) bool { + return a.Equals(&b) }) - - for i := range nbmsClone { - if !nbmsClone[i].Equals(&otherClone[i]) { - return false - } - } - return true } type ValidatorSetApproval struct { From c77a88b1f1729585d30b0a07f4148a22b208d5df Mon Sep 17 00:00:00 2001 From: Yacov Manevich Date: Thu, 16 Apr 2026 22:50:36 +0200 Subject: [PATCH 14/25] msm: replace ForEach/SumWeights closures with plain loops Addresses review comment: the ForEach/SumWeights wrappers added unnecessary indirection. Inline them with ordinary for loops in encoding.go, msm.go, and verification.go. Filter predicates no longer need the unused index parameter, and ForEach/SumWeights themselves are deleted along with their dedicated tests. Co-Authored-By: Claude Opus 4.7 (1M context) --- msm/encoding.go | 51 +++++++++++++----------------------------- msm/encoding_test.go | 53 ++------------------------------------------ msm/msm.go | 38 ++++++++++++++----------------- msm/verification.go | 6 ++--- 4 files changed, 38 insertions(+), 110 deletions(-) diff --git a/msm/encoding.go b/msm/encoding.go index 8b295f44..4e5e1a48 100644 --- a/msm/encoding.go +++ b/msm/encoding.go @@ -218,29 +218,15 @@ func (nbms NodeBLSMappings) Clone() NodeBLSMappings { } func (nbms NodeBLSMappings) TotalWeight() (uint64, error) { - return nbms.SumWeights(func(int, NodeBLSMapping) bool { - return true - }) -} - -func (nbms NodeBLSMappings) ForEach(selector func(int, NodeBLSMapping)) { - for i, nbm := range nbms { - selector(i, nbm) - } -} - -func (nbms NodeBLSMappings) SumWeights(selector func(int, NodeBLSMapping) bool) (uint64, error) { var total uint64 - var err error - nbms.ForEach(func(i int, nbm NodeBLSMapping) { + for _, nbm := range nbms { + sum, err := safeAdd(total, nbm.Weight) if err != nil { - return + return 0, err } - if selector(i, nbm) { - total, err = safeAdd(total, nbm.Weight) - } - }) - return total, err + total = sum + } + return total, nil } func (nbms NodeBLSMappings) Equal(other NodeBLSMappings) bool { @@ -273,30 +259,25 @@ type ValidatorSetApproval struct { type ValidatorSetApprovals []ValidatorSetApproval -func (vsa ValidatorSetApprovals) ForEach(f func(int, ValidatorSetApproval)) { - for i, v := range vsa { - f(i, v) - } -} - -func (vsa ValidatorSetApprovals) Filter(f func(int, ValidatorSetApproval) bool) ValidatorSetApprovals { +func (vsa ValidatorSetApprovals) Filter(keep func(ValidatorSetApproval) bool) ValidatorSetApprovals { result := make(ValidatorSetApprovals, 0, len(vsa)) - vsa.ForEach(func(i int, v ValidatorSetApproval) { - if f(i, v) { + for _, v := range vsa { + if keep(v) { result = append(result, v) } - }) + } return result } func (vsa ValidatorSetApprovals) UniqueByNodeID() ValidatorSetApprovals { seen := make(map[nodeID]struct{}) result := make(ValidatorSetApprovals, 0, len(vsa)) - vsa.ForEach(func(i int, v ValidatorSetApproval) { - if _, exists := seen[v.NodeID]; !exists { - seen[v.NodeID] = struct{}{} - result = append(result, v) + for _, v := range vsa { + if _, exists := seen[v.NodeID]; exists { + continue } - }) + seen[v.NodeID] = struct{}{} + result = append(result, v) + } return result } diff --git a/msm/encoding_test.go b/msm/encoding_test.go index 7a5d88be..6bafa430 100644 --- a/msm/encoding_test.go +++ b/msm/encoding_test.go @@ -400,42 +400,6 @@ func TestNodeBLSMappingsTotalWeight(t *testing.T) { } } -func TestNodeBLSMappingsSumWeights(t *testing.T) { - mappings := NodeBLSMappings{ - {NodeID: nodeID{1}, Weight: 10}, - {NodeID: nodeID{2}, Weight: 20}, - {NodeID: nodeID{3}, Weight: 30}, - } - - // Select only even indices - total, err := mappings.SumWeights(func(i int, _ NodeBLSMapping) bool { - return i%2 == 0 - }) - require.NoError(t, err) - require.Equal(t, uint64(40), total) // index 0 (10) + index 2 (30) - - // Select none - total, err = mappings.SumWeights(func(int, NodeBLSMapping) bool { - return false - }) - require.NoError(t, err) - require.Equal(t, uint64(0), total) -} - -func TestNodeBLSMappingsForEach(t *testing.T) { - mappings := NodeBLSMappings{ - {Weight: 1}, - {Weight: 2}, - {Weight: 3}, - } - - var visited []uint64 - mappings.ForEach(func(_ int, nbm NodeBLSMapping) { - visited = append(visited, nbm.Weight) - }) - require.Equal(t, []uint64{1, 2, 3}, visited) -} - func TestNodeBLSMappingsCompare(t *testing.T) { tests := []struct { name string @@ -479,19 +443,6 @@ func TestNodeBLSMappingsCompare(t *testing.T) { } } -func TestValidatorSetApprovalsForEach(t *testing.T) { - approvals := ValidatorSetApprovals{ - {NodeID: nodeID{1}, PChainHeight: 10}, - {NodeID: nodeID{2}, PChainHeight: 20}, - } - - var heights []uint64 - approvals.ForEach(func(_ int, v ValidatorSetApproval) { - heights = append(heights, v.PChainHeight) - }) - require.Equal(t, []uint64{10, 20}, heights) -} - func TestValidatorSetApprovalsFilter(t *testing.T) { approvals := ValidatorSetApprovals{ {NodeID: nodeID{1}, PChainHeight: 10}, @@ -499,7 +450,7 @@ func TestValidatorSetApprovalsFilter(t *testing.T) { {NodeID: nodeID{3}, PChainHeight: 30}, } - filtered := approvals.Filter(func(_ int, v ValidatorSetApproval) bool { + filtered := approvals.Filter(func(v ValidatorSetApproval) bool { return v.PChainHeight > 15 }) require.Len(t, filtered, 2) @@ -507,7 +458,7 @@ func TestValidatorSetApprovalsFilter(t *testing.T) { require.Equal(t, uint64(30), filtered[1].PChainHeight) // Filter all - filtered = approvals.Filter(func(int, ValidatorSetApproval) bool { + filtered = approvals.Filter(func(ValidatorSetApproval) bool { return false }) require.Empty(t, filtered) diff --git a/msm/msm.go b/msm/msm.go index a56b400c..a98fd092 100644 --- a/msm/msm.go +++ b/msm/msm.go @@ -830,10 +830,10 @@ func computeNewApprovals( oldApprovingNodes := bitmaskFromBytes(nextEpochApprovals.NodeIDs) // We map each validator to its relative index in the validator set. - nodeID2ValidatorIndex := make(map[nodeID]int) - validators.ForEach(func(i int, nbm NodeBLSMapping) { + nodeID2ValidatorIndex := make(map[nodeID]int, len(validators)) + for i, nbm := range validators { nodeID2ValidatorIndex[nbm.NodeID] = i - }) + } // We have the approvals obtained from peers, but we need to sanitize them by filtering out approvals that are not valid, // such as approvals that do not agree with our candidate auxiliary info digest and P-Chain height, @@ -869,16 +869,16 @@ func computeNewApproverSignaturesAndSigners(nextEpochApprovals *NextEpochApprova // We will overwrite the old approving nodes with the new approving nodes, by turning on the bits for the new approvers. newApprovingNodes := oldApprovingNodes.Clone() - approvalsFromPeers.ForEach(func(i int, approval ValidatorSetApproval) { + for _, approval := range approvalsFromPeers { approvingNodeIndexOfNewApprover, exists := nodeID2ValidatorIndex[approval.NodeID] if !exists { // This should not happen, because we have already filtered approvals that are not in the validator set, but we check just in case. - return + continue } // Turn on the bit for the new approver newApprovingNodes.Add(approvingNodeIndexOfNewApprover) newSignatures = append(newSignatures, approval.Signature) - }) + } // Add the existing signature into the list of signatures to aggregate existingSignature := nextEpochApprovals.Signature @@ -922,15 +922,15 @@ func sanitizeApprovals(approvals ValidatorSetApprovals, pChainHeight uint64, nod return approvals.Filter(filter1).Filter(filter2).UniqueByNodeID() } -func approvalsThatAgreeWithAuxInfoAndPChainHeight(pChainHeight uint64) func(i int, approval ValidatorSetApproval) bool { - return func(i int, approval ValidatorSetApproval) bool { +func approvalsThatAgreeWithAuxInfoAndPChainHeight(pChainHeight uint64) func(approval ValidatorSetApproval) bool { + return func(approval ValidatorSetApproval) bool { // Pick only approvals that agree with our candidate auxiliary info digest and P-Chain height return approval.PChainHeight == pChainHeight } } -func approvalsThatAreInValidatorSetAndHaveNotAlreadyApproved(oldApprovingNodes *bitmask, nodeID2ValidatorIndex map[nodeID]int) func(i int, approval ValidatorSetApproval) bool { - return func(i int, approval ValidatorSetApproval) bool { +func approvalsThatAreInValidatorSetAndHaveNotAlreadyApproved(oldApprovingNodes *bitmask, nodeID2ValidatorIndex map[nodeID]int) func(approval ValidatorSetApproval) bool { + return func(approval ValidatorSetApproval) bool { approvingNodeIndexOfNewApprover, exists := nodeID2ValidatorIndex[approval.NodeID] if !exists { // If the approving node is not in the validator set, we ignore this approval. @@ -943,19 +943,15 @@ func approvalsThatAreInValidatorSetAndHaveNotAlreadyApproved(oldApprovingNodes * func computeApprovingWeight(validators NodeBLSMappings, approvingNodes *bitmask) (int64, error) { var approvingWeight uint64 - var err error - validators.ForEach(func(i int, nbm NodeBLSMapping) { - if err != nil { - return - } + for i, nbm := range validators { if !approvingNodes.Contains(i) { - return + continue } - approvingWeight, err = safeAdd(approvingWeight, nbm.Weight) - }) - - if err != nil { - return 0, fmt.Errorf("failed to compute approving weights: %w", err) + sum, err := safeAdd(approvingWeight, nbm.Weight) + if err != nil { + return 0, fmt.Errorf("failed to compute approving weights: %w", err) + } + approvingWeight = sum } if approvingWeight > math.MaxInt64 { diff --git a/msm/verification.go b/msm/verification.go index ecf314aa..91af5e39 100644 --- a/msm/verification.go +++ b/msm/verification.go @@ -179,12 +179,12 @@ func (nv *nextEpochApprovalsVerifier) createMessageToBeVerified(prev SimplexEpoc func (nv *nextEpochApprovalsVerifier) aggregatePubKeysForBitmask(nodeIDsBitmask []byte, validators NodeBLSMappings) ([]byte, error) { approvingNodes := bitmaskFromBytes(nodeIDsBitmask) publicKeys := make([][]byte, 0, len(validators)) - validators.ForEach(func(i int, nbm NodeBLSMapping) { + for i, nbm := range validators { if !approvingNodes.Contains(i) { - return + continue } publicKeys = append(publicKeys, nbm.BLSKey) - }) + } aggPK, err := nv.keyAggregator.AggregateKeys(publicKeys...) if err != nil { From ff79a8386a9c078d176e544b3f2d844973de6bd5 Mon Sep 17 00:00:00 2001 From: Yacov Manevich Date: Thu, 16 Apr 2026 22:51:22 +0200 Subject: [PATCH 15/25] msm: move CurrentState onto SimplexEpochInfo, drop error return Addresses review comment: identifyCurrentState didn't belong on StateMachine and never actually returned an error, so hoist it onto SimplexEpochInfo and simplify the return. Callers and the existing test are updated accordingly. Co-Authored-By: Claude Opus 4.7 (1M context) --- msm/msm.go | 30 +++++++++++++----------------- msm/msm_test.go | 4 +--- 2 files changed, 14 insertions(+), 20 deletions(-) diff --git a/msm/msm.go b/msm/msm.go index a98fd092..1cbd3b2a 100644 --- a/msm/msm.go +++ b/msm/msm.go @@ -196,10 +196,7 @@ func (sm *StateMachine) BuildBlock(ctx context.Context, parentBlock StateMachine // In order to know where in the epoch change process we are, // we identify the current state by looking at the parent block's epoch info. - currentState, err := identifyCurrentState(parentBlock.Metadata.SimplexEpochInfo) - if err != nil { - return nil, err - } + currentState := parentBlock.Metadata.SimplexEpochInfo.CurrentState() simplexMetadataBytes := simplexMetadata.Bytes() prevBlockSeq := simplexMetadata.Seq - 1 @@ -244,10 +241,7 @@ func (sm *StateMachine) VerifyBlock(ctx context.Context, block *StateMachineBloc } prevMD := prevBlock.Metadata - currentState, err := identifyCurrentState(prevMD.SimplexEpochInfo) - if err != nil { - return fmt.Errorf("failed to identify previous state: %w", err) - } + currentState := prevMD.SimplexEpochInfo.CurrentState() switch currentState { case stateFirstSimplexBlock: @@ -340,27 +334,29 @@ func (sm *StateMachine) verifyNonZeroBlock(ctx context.Context, block *StateMach return block.InnerBlock.Verify(ctx) } -func identifyCurrentState(prevBlockSimplexEpochInfo SimplexEpochInfo) (state, error) { +// CurrentState identifies, given the SimplexEpochInfo of the parent block, which +// state the state machine is in for the purpose of building the next block. +func (sei *SimplexEpochInfo) CurrentState() state { // If this is the first ever epoch, then this is also the first ever block to be built by Simplex. - if prevBlockSimplexEpochInfo.EpochNumber == 0 { - return stateFirstSimplexBlock, nil + if sei.EpochNumber == 0 { + return stateFirstSimplexBlock } // If we don't have a next P-chain preference height, it means we are not transitioning to a new epoch just yet. - if prevBlockSimplexEpochInfo.NextPChainReferenceHeight == 0 { - return stateBuildBlockNormalOp, nil + if sei.NextPChainReferenceHeight == 0 { + return stateBuildBlockNormalOp } // If the previous block has a sealing block sequence, it's a Telock. // If it has a block validation descriptor, it's a sealing block. - // Eithe way, the epoch has been sealed. - if prevBlockSimplexEpochInfo.SealingBlockSeq > 0 || prevBlockSimplexEpochInfo.BlockValidationDescriptor != nil { - return stateBuildBlockEpochSealed, nil + // Either way, the epoch has been sealed. + if sei.SealingBlockSeq > 0 || sei.BlockValidationDescriptor != nil { + return stateBuildBlockEpochSealed } // In any other case, NextPChainReferenceHeight > 0 but the previous block is not a Telock or sealing block, // it means we are in the process of collecting approvals for the next epoch. - return stateBuildCollectingApprovals, nil + return stateBuildCollectingApprovals } // buildBlockNormalOp builds a block while not trying to transition to a new epoch. diff --git a/msm/msm_test.go b/msm/msm_test.go index 0b7a94d1..116f766f 100644 --- a/msm/msm_test.go +++ b/msm/msm_test.go @@ -1079,9 +1079,7 @@ func TestIdentifyCurrentState(t *testing.T) { }, } { t.Run(tc.name, func(t *testing.T) { - result, err := identifyCurrentState(tc.input) - require.NoError(t, err) - require.Equal(t, tc.expected, result) + require.Equal(t, tc.expected, tc.input.CurrentState()) }) } } From 8be5e9b03f6e06aa053ba1855b322e02835e6713 Mon Sep 17 00:00:00 2001 From: Yacov Manevich Date: Thu, 16 Apr 2026 22:58:32 +0200 Subject: [PATCH 16/25] msm: add NewStateMachine constructor, drop lazy maybeInit Addresses review comment: a lazy-init pattern based on a sticky 'initialized' bool hid the dependency between field assignment and first-call wiring. Introduces NewStateMachine(config) that eagerly wires verifiers and returns *StateMachine. NewStateMachine wires the verifiers through closures over the returned *StateMachine, so callers (including tests) can keep mutating fields like sm.GetBlock, sm.GetValidatorSet, and sm.GetPChainHeight after construction and the verifiers will see the fresh values. Verifier types are unchanged. The test helper newStateMachine now returns *StateMachine. Co-Authored-By: Claude Opus 4.7 (1M context) --- msm/msm.go | 109 +++++++++++++++++++---------------- msm/msm_test.go | 12 ++-- msm/multi_epoch_node_test.go | 2 +- 3 files changed, 65 insertions(+), 58 deletions(-) diff --git a/msm/msm.go b/msm/msm.go index 1cbd3b2a..ddc5dd0a 100644 --- a/msm/msm.go +++ b/msm/msm.go @@ -87,8 +87,7 @@ type BlockBuilder interface { WaitForPendingBlock(ctx context.Context) } -// StateMachine manages block building and verification across epoch transitions. -type StateMachine struct { +type Config struct { // LatestPersistedHeight is the height of the most recently persisted block. LatestPersistedHeight uint64 // MaxBlockBuildingWaitTime is the maximum duration to wait for the VM to build a block @@ -120,16 +119,70 @@ type StateMachine struct { SignatureVerifier SignatureVerifier // PChainProgressListener listens for changes in the P-chain height to trigger block building or epoch transitions. PChainProgressListener PChainProgressListener +} - // initialized tracks whether the state machine has been initialized. - // This is used to lazily initialize the verifiers. - initialized bool +// StateMachine manages block building and verification across epoch transitions. +type StateMachine struct { + Config // verifiers is the list of verifiers used to verify proposed blocks. // Each verifier is responsible for verifying a specific aspect of the block's metadata. verifiers []verifier } +// NewStateMachine builds a StateMachine from the given configuration and wires +// up its block verifiers. Callers should use this constructor rather than +// constructing a StateMachine literal directly. +// +// The verifiers read their dependencies through closures over the returned +// pointer, so later mutations of sm.GetBlock, sm.GetValidatorSet, and the +// other function-valued fields are visible to them. +func NewStateMachine(config Config) *StateMachine { + out := &StateMachine{Config: config} + + getPChainHeight := func() uint64 { return out.GetPChainHeight() } + getTime := func() time.Time { return out.GetTime() } + getBlock := func(opts RetrievingOpts) (StateMachineBlock, *simplex.Finalization, error) { + return out.GetBlock(opts) + } + getValidatorSet := func(height uint64) (NodeBLSMappings, error) { + return out.GetValidatorSet(height) + } + + out.verifiers = []verifier{ + &pChainHeightVerifier{ + getPChainHeight: getPChainHeight, + }, + ×tampVerifier{ + timeSkewLimit: out.TimeSkewLimit, + getTime: getTime, + }, + &pChainReferenceHeightVerifier{}, + &epochNumberVerifier{}, + &prevSealingBlockHashVerifier{ + getBlock: getBlock, + latestPersistedHeight: &out.LatestPersistedHeight, + }, + &nextPChainReferenceHeightVerifier{ + getPChainHeight: getPChainHeight, + getValidatorSet: getValidatorSet, + }, + &vmBlockSeqVerifier{ + getBlock: getBlock, + }, + &validationDescriptorVerifier{ + getValidatorSet: getValidatorSet, + }, + &nextEpochApprovalsVerifier{ + getValidatorSet: getValidatorSet, + keyAggregator: out.KeyAggregator, + sigVerifier: out.SignatureVerifier, + }, + &sealingBlockSeqVerifier{}, + } + return out +} + type state uint8 const ( @@ -165,8 +218,6 @@ func (state BlockType) String() string { // BuildBlock constructs the next block on top of the given parent block, and passes in the provided simplex metadata and blacklist. func (sm *StateMachine) BuildBlock(ctx context.Context, parentBlock StateMachineBlock, simplexMetadata simplex.ProtocolMetadata, simplexBlacklist *simplex.Blacklist) (*StateMachineBlock, error) { - sm.maybeInit() - // The zero sequence number is reserved for the genesis block, which should never be built. if simplexMetadata.Seq == 0 { return nil, fmt.Errorf("invalid ProtocolMetadata sequence number: should be > 0, got %d", simplexMetadata.Seq) @@ -218,8 +269,6 @@ func (sm *StateMachine) BuildBlock(ctx context.Context, parentBlock StateMachine // VerifyBlock validates a proposed block by checking its metadata, epoch info, // and inner block against the previous block and the current state. func (sm *StateMachine) VerifyBlock(ctx context.Context, block *StateMachineBlock) error { - sm.maybeInit() - if block == nil { return fmt.Errorf("InnerBlock is nil") } @@ -252,48 +301,6 @@ func (sm *StateMachine) VerifyBlock(ctx context.Context, block *StateMachineBloc return err } -func (sm *StateMachine) maybeInit() { - if sm.initialized { - return - } - sm.init() - sm.initialized = true -} - -func (sm *StateMachine) init() { - sm.verifiers = []verifier{ - &pChainHeightVerifier{ - getPChainHeight: sm.GetPChainHeight, - }, - ×tampVerifier{ - timeSkewLimit: sm.TimeSkewLimit, - getTime: sm.GetTime, - }, - &pChainReferenceHeightVerifier{}, - &epochNumberVerifier{}, - &prevSealingBlockHashVerifier{ - getBlock: sm.GetBlock, - latestPersistedHeight: &sm.LatestPersistedHeight, - }, - &nextPChainReferenceHeightVerifier{ - getPChainHeight: sm.GetPChainHeight, - getValidatorSet: sm.GetValidatorSet, - }, - &vmBlockSeqVerifier{ - getBlock: sm.GetBlock, - }, - &validationDescriptorVerifier{ - getValidatorSet: sm.GetValidatorSet, - }, - &nextEpochApprovalsVerifier{ - getValidatorSet: sm.GetValidatorSet, - keyAggregator: sm.KeyAggregator, - sigVerifier: sm.SignatureVerifier, - }, - &sealingBlockSeqVerifier{}, - } -} - func (sm *StateMachine) verifyNonZeroBlock(ctx context.Context, block *StateMachineBlock, prevBlockMD StateMachineMetadata, state state, prevSeq uint64) error { blockType := IdentifyBlockType(block.Metadata, prevBlockMD, prevSeq) sm.Logger.Debug("Identified block type", diff --git a/msm/msm_test.go b/msm/msm_test.go index 116f766f..d870134e 100644 --- a/msm/msm_test.go +++ b/msm/msm_test.go @@ -261,7 +261,7 @@ func TestMSMFirstBlockAfterGenesis(t *testing.T) { } if testCase.configure != nil { - testCase.configure(&sm2, testConfig2) + testCase.configure(sm2, testConfig2) } block, err := sm1.BuildBlock(context.Background(), genesisBlock, testCase.md, nil) @@ -483,8 +483,8 @@ func TestMSMNormalOp(t *testing.T) { } if testCase.setup != nil { - testCase.setup(&sm1, testConfig1) - testCase.setup(&sm2, testConfig2) + testCase.setup(sm1, testConfig1) + testCase.setup(sm2, testConfig2) } block1, err := sm1.BuildBlock(context.Background(), lastBlock, *md, &blacklist) @@ -1013,7 +1013,7 @@ type testConfig struct { validatorSetRetriever validatorSetRetriever } -func newStateMachine(t *testing.T) (StateMachine, *testConfig) { +func newStateMachine(t *testing.T) (*StateMachine, *testConfig) { bs := make(blockStore) var testConfig testConfig @@ -1022,7 +1022,7 @@ func newStateMachine(t *testing.T) (StateMachine, *testConfig) { {BLSKey: []byte{1}, Weight: 1}, {BLSKey: []byte{2}, Weight: 1}, } - sm := StateMachine{ + sm := NewStateMachine(Config{ GetTime: time.Now, TimeSkewLimit: time.Second * 5, Logger: testutil.MakeLogger(t), @@ -1041,7 +1041,7 @@ func newStateMachine(t *testing.T) (StateMachine, *testConfig) { }, GetValidatorSet: testConfig.validatorSetRetriever.getValidatorSet, PChainProgressListener: &noOpPChainListener{}, - } + }) return sm, &testConfig } diff --git a/msm/multi_epoch_node_test.go b/msm/multi_epoch_node_test.go index b967762c..76bb4a01 100644 --- a/msm/multi_epoch_node_test.go +++ b/msm/multi_epoch_node_test.go @@ -175,7 +175,7 @@ type blockState struct { type multiEpochNode struct { t *testing.T - sm StateMachine + sm *StateMachine mempoolEmpty bool // blocks holds notarized blocks in order. Finalized blocks always form a // prefix: all finalized entries precede all non-finalized entries. From 763761539f47d6a5d276dc9dba4350e1391a3d59 Mon Sep 17 00:00:00 2001 From: Yacov Manevich Date: Thu, 16 Apr 2026 22:59:12 +0200 Subject: [PATCH 17/25] msm: extract newBlockBuildingDecider top-level constructor Addresses review comment: moves the block-building decider wiring out of StateMachine.createBlockBuildingDecider into a package-level newBlockBuildingDecider alongside the decider type itself. This keeps the decider's initialization next to the decider, not the state machine. Co-Authored-By: Claude Opus 4.7 (1M context) --- msm/build_decision.go | 33 +++++++++++++++++++++++++++++++++ msm/msm.go | 34 +--------------------------------- 2 files changed, 34 insertions(+), 33 deletions(-) diff --git a/msm/build_decision.go b/msm/build_decision.go index 7bf80c38..d962a75f 100644 --- a/msm/build_decision.go +++ b/msm/build_decision.go @@ -55,6 +55,39 @@ type blockBuildingDecider struct { getPChainHeight func() uint64 } +// newBlockBuildingDecider wires a blockBuildingDecider around a StateMachine +// and the parent block it is extending. A fresh decider is created per call +// because shouldTransitionEpoch closes over the parent block. +func newBlockBuildingDecider(sm *StateMachine, parentBlock StateMachineBlock) blockBuildingDecider { + return blockBuildingDecider{ + logger: sm.Logger, + maxBlockBuildingWaitTime: sm.MaxBlockBuildingWaitTime, + pChainlistener: sm.PChainProgressListener, + getPChainHeight: sm.GetPChainHeight, + waitForPendingBlock: sm.BlockBuilder.WaitForPendingBlock, + shouldTransitionEpoch: func(pChainHeight uint64) (bool, error) { + // The given pChainHeight was sampled by the caller of shouldTransitionEpoch(). + // We compare between the current validator set, defined by the P-chain reference height in the parent block, + // and the new validator set defined by the given pChainHeight. + // If they are different, then we should transition to a new epoch. + currentValidatorSet, err := sm.GetValidatorSet(parentBlock.Metadata.SimplexEpochInfo.PChainReferenceHeight) + if err != nil { + return false, err + } + + newValidatorSet, err := sm.GetValidatorSet(pChainHeight) + if err != nil { + return false, err + } + + if !currentValidatorSet.Equal(newValidatorSet) { + return true, nil + } + return false, nil + }, + } +} + // shouldBuildBlock determines whether we should build a block at the current time, // based on the current P-chain height and whether we should transition to a new epoch. // It returns a blockBuildingDecision, the current P-chain height sampled at the time of deciding, diff --git a/msm/msm.go b/msm/msm.go index ddc5dd0a..c28f9a0a 100644 --- a/msm/msm.go +++ b/msm/msm.go @@ -376,7 +376,7 @@ func (sm *StateMachine) buildBlockNormalOp(ctx context.Context, parentBlock Stat PrevVMBlockSeq: computePrevVMBlockSeq(parentBlock, prevBlockSeq), } - blockBuildingDecider := sm.createBlockBuildingDecider(parentBlock) + blockBuildingDecider := newBlockBuildingDecider(sm, parentBlock) decisionToBuildBlock, pChainHeight, err := blockBuildingDecider.shouldBuildBlock(ctx) if err != nil { return nil, err @@ -403,38 +403,6 @@ func (sm *StateMachine) buildBlockNormalOp(ctx context.Context, parentBlock Stat } } -func (sm *StateMachine) createBlockBuildingDecider(parentBlock StateMachineBlock) blockBuildingDecider { - blockBuildingDecider := blockBuildingDecider{ - logger: sm.Logger, - maxBlockBuildingWaitTime: sm.MaxBlockBuildingWaitTime, - pChainlistener: sm.PChainProgressListener, - getPChainHeight: sm.GetPChainHeight, - waitForPendingBlock: sm.BlockBuilder.WaitForPendingBlock, - shouldTransitionEpoch: func(pChainHeight uint64) (bool, error) { - // The given pChainHeight was sampled by the caller of shouldTransitionEpoch(). - // We compare between the current validator set, defined by the P-chain reference height in the parent block, - // and the new validator set defined by the given pChainHeight. - // If they are different, then we should transition to a new epoch. - - currentValidatorSet, err := sm.GetValidatorSet(parentBlock.Metadata.SimplexEpochInfo.PChainReferenceHeight) - if err != nil { - return false, err - } - - newValidatorSet, err := sm.GetValidatorSet(pChainHeight) - if err != nil { - return false, err - } - - if !currentValidatorSet.Equal(newValidatorSet) { - return true, nil - } - return false, nil - }, - } - return blockBuildingDecider -} - func (sm *StateMachine) buildBlockAndMaybeTransitionEpoch(ctx context.Context, parentBlock StateMachineBlock, simplexMetadata []byte, simplexBlacklist []byte, childBlock VMBlock, decisionToBuildBlock blockBuildingDecision, newSimplexEpochInfo SimplexEpochInfo, pChainHeight uint64) (*StateMachineBlock, error) { // TODO: This P-chain height should be taken from the ICM epoch childBlock, err := sm.BlockBuilder.BuildBlock(ctx, pChainHeight) From 59bcbb0022b6ef6f4923272dc1c2f9a1eb516899 Mon Sep 17 00:00:00 2001 From: Yacov Manevich Date: Thu, 16 Apr 2026 22:59:48 +0200 Subject: [PATCH 18/25] msm: drop dead childBlock param from buildBlockAndMaybeTransitionEpoch Addresses review comment: the childBlock VMBlock parameter was always nil at the call site and immediately overwritten by BuildBlock. Replace the blockBuildingDecision value with a plain transitionEpoch bool so the function signature spells out what actually matters. Co-Authored-By: Claude Opus 4.7 (1M context) --- msm/msm.go | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/msm/msm.go b/msm/msm.go index c28f9a0a..2cb75490 100644 --- a/msm/msm.go +++ b/msm/msm.go @@ -384,12 +384,11 @@ func (sm *StateMachine) buildBlockNormalOp(ctx context.Context, parentBlock Stat sm.Logger.Debug("Block building decision", zap.Stringer("decision", decisionToBuildBlock)) - var childBlock VMBlock - switch decisionToBuildBlock { case blockBuildingDecisionBuildBlock, blockBuildingDecisionBuildBlockAndTransitionEpoch: // If we reached here, we need to build a new block, and maybe also transition to a new epoch. - return sm.buildBlockAndMaybeTransitionEpoch(ctx, parentBlock, simplexMetadata, simplexBlacklist, childBlock, decisionToBuildBlock, newSimplexEpochInfo, pChainHeight) + transitionEpoch := decisionToBuildBlock == blockBuildingDecisionBuildBlockAndTransitionEpoch + return sm.buildBlockAndMaybeTransitionEpoch(ctx, parentBlock, simplexMetadata, simplexBlacklist, newSimplexEpochInfo, pChainHeight, transitionEpoch) case blockBuildingDecisionTransitionEpoch: // If we reached here, we don't need to build an inner block, yet we need to transition to a new epoch. // Initiate the epoch transition by setting the next P-chain reference height for the new epoch info, @@ -403,14 +402,21 @@ func (sm *StateMachine) buildBlockNormalOp(ctx context.Context, parentBlock Stat } } -func (sm *StateMachine) buildBlockAndMaybeTransitionEpoch(ctx context.Context, parentBlock StateMachineBlock, simplexMetadata []byte, simplexBlacklist []byte, childBlock VMBlock, decisionToBuildBlock blockBuildingDecision, newSimplexEpochInfo SimplexEpochInfo, pChainHeight uint64) (*StateMachineBlock, error) { +func (sm *StateMachine) buildBlockAndMaybeTransitionEpoch( + ctx context.Context, + parentBlock StateMachineBlock, + simplexMetadata, simplexBlacklist []byte, + newSimplexEpochInfo SimplexEpochInfo, + pChainHeight uint64, + transitionEpoch bool, +) (*StateMachineBlock, error) { // TODO: This P-chain height should be taken from the ICM epoch childBlock, err := sm.BlockBuilder.BuildBlock(ctx, pChainHeight) if err != nil { return nil, err } - if decisionToBuildBlock == blockBuildingDecisionBuildBlockAndTransitionEpoch { + if transitionEpoch { // We need to also transition to a new epoch, in addition to building an inner block, // so set the next P-chain reference height for the new epoch info. newSimplexEpochInfo.NextPChainReferenceHeight = pChainHeight From 2b869d5d73d8f7ee08cf9cbd73252ee05a8b61d7 Mon Sep 17 00:00:00 2001 From: Yacov Manevich Date: Thu, 16 Apr 2026 23:05:15 +0200 Subject: [PATCH 19/25] msm: pass blacklist by value and drop the simplex prefix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses review comment: simplexBlacklist doesn't need to be a pointer to express 'none' — the zero value already does — and the simplex prefix is redundant because the type itself (simplex.Blacklist) carries that context. Callers now pass a simplex.Blacklist value, the encoded bytes are always written to the block (even when empty), and tests use the emptyBlacklistBytes helper when they expect the no-op encoding. Co-Authored-By: Claude Opus 4.7 (1M context) --- msm/msm.go | 53 +++++++++++++++++------------------- msm/msm_test.go | 35 ++++++++++++++++-------- msm/multi_epoch_node_test.go | 2 +- 3 files changed, 50 insertions(+), 40 deletions(-) diff --git a/msm/msm.go b/msm/msm.go index 2cb75490..10ee7856 100644 --- a/msm/msm.go +++ b/msm/msm.go @@ -217,7 +217,7 @@ func (state BlockType) String() string { } // BuildBlock constructs the next block on top of the given parent block, and passes in the provided simplex metadata and blacklist. -func (sm *StateMachine) BuildBlock(ctx context.Context, parentBlock StateMachineBlock, simplexMetadata simplex.ProtocolMetadata, simplexBlacklist *simplex.Blacklist) (*StateMachineBlock, error) { +func (sm *StateMachine) BuildBlock(ctx context.Context, parentBlock StateMachineBlock, simplexMetadata simplex.ProtocolMetadata, blacklist simplex.Blacklist) (*StateMachineBlock, error) { // The zero sequence number is reserved for the genesis block, which should never be built. if simplexMetadata.Seq == 0 { return nil, fmt.Errorf("invalid ProtocolMetadata sequence number: should be > 0, got %d", simplexMetadata.Seq) @@ -240,10 +240,7 @@ func (sm *StateMachine) BuildBlock(ctx context.Context, parentBlock StateMachine ) }() - var simplexBlacklistBytes []byte - if simplexBlacklist != nil { - simplexBlacklistBytes = simplexBlacklist.Bytes() - } + blacklistBytes := blacklist.Bytes() // In order to know where in the epoch change process we are, // we identify the current state by looking at the parent block's epoch info. @@ -254,13 +251,13 @@ func (sm *StateMachine) BuildBlock(ctx context.Context, parentBlock StateMachine switch currentState { case stateFirstSimplexBlock: - return sm.buildBlockZero(ctx, parentBlock, simplexMetadataBytes, simplexBlacklistBytes) + return sm.buildBlockZero(ctx, parentBlock, simplexMetadataBytes, blacklistBytes) case stateBuildBlockNormalOp: - return sm.buildBlockNormalOp(ctx, parentBlock, simplexMetadataBytes, simplexBlacklistBytes, prevBlockSeq) + return sm.buildBlockNormalOp(ctx, parentBlock, simplexMetadataBytes, blacklistBytes, prevBlockSeq) case stateBuildCollectingApprovals: - return sm.buildBlockCollectingApprovals(ctx, parentBlock, simplexMetadataBytes, simplexBlacklistBytes, prevBlockSeq) + return sm.buildBlockCollectingApprovals(ctx, parentBlock, simplexMetadataBytes, blacklistBytes, prevBlockSeq) case stateBuildBlockEpochSealed: - return sm.buildBlockEpochSealed(ctx, parentBlock, simplexMetadataBytes, simplexBlacklistBytes, prevBlockSeq) + return sm.buildBlockEpochSealed(ctx, parentBlock, simplexMetadataBytes, blacklistBytes, prevBlockSeq) default: return nil, fmt.Errorf("unknown state %d", currentState) } @@ -367,7 +364,7 @@ func (sei *SimplexEpochInfo) CurrentState() state { } // buildBlockNormalOp builds a block while not trying to transition to a new epoch. -func (sm *StateMachine) buildBlockNormalOp(ctx context.Context, parentBlock StateMachineBlock, simplexMetadata, simplexBlacklist []byte, prevBlockSeq uint64) (*StateMachineBlock, error) { +func (sm *StateMachine) buildBlockNormalOp(ctx context.Context, parentBlock StateMachineBlock, simplexMetadata, blacklist []byte, prevBlockSeq uint64) (*StateMachineBlock, error) { // Since in the previous block, we were not transitioning to a new epoch, // the P-chain reference height and epoch of the new block should remain the same. newSimplexEpochInfo := SimplexEpochInfo{ @@ -388,13 +385,13 @@ func (sm *StateMachine) buildBlockNormalOp(ctx context.Context, parentBlock Stat case blockBuildingDecisionBuildBlock, blockBuildingDecisionBuildBlockAndTransitionEpoch: // If we reached here, we need to build a new block, and maybe also transition to a new epoch. transitionEpoch := decisionToBuildBlock == blockBuildingDecisionBuildBlockAndTransitionEpoch - return sm.buildBlockAndMaybeTransitionEpoch(ctx, parentBlock, simplexMetadata, simplexBlacklist, newSimplexEpochInfo, pChainHeight, transitionEpoch) + return sm.buildBlockAndMaybeTransitionEpoch(ctx, parentBlock, simplexMetadata, blacklist, newSimplexEpochInfo, pChainHeight, transitionEpoch) case blockBuildingDecisionTransitionEpoch: // If we reached here, we don't need to build an inner block, yet we need to transition to a new epoch. // Initiate the epoch transition by setting the next P-chain reference height for the new epoch info, // and build a block without an inner block. newSimplexEpochInfo.NextPChainReferenceHeight = pChainHeight - return sm.wrapBlock(parentBlock, nil, newSimplexEpochInfo, pChainHeight, simplexMetadata, simplexBlacklist), nil + return sm.wrapBlock(parentBlock, nil, newSimplexEpochInfo, pChainHeight, simplexMetadata, blacklist), nil case blockBuildingDecisionContextCanceled: return nil, ctx.Err() default: @@ -405,7 +402,7 @@ func (sm *StateMachine) buildBlockNormalOp(ctx context.Context, parentBlock Stat func (sm *StateMachine) buildBlockAndMaybeTransitionEpoch( ctx context.Context, parentBlock StateMachineBlock, - simplexMetadata, simplexBlacklist []byte, + simplexMetadata, blacklist []byte, newSimplexEpochInfo SimplexEpochInfo, pChainHeight uint64, transitionEpoch bool, @@ -422,12 +419,12 @@ func (sm *StateMachine) buildBlockAndMaybeTransitionEpoch( newSimplexEpochInfo.NextPChainReferenceHeight = pChainHeight } - return sm.wrapBlock(parentBlock, childBlock, newSimplexEpochInfo, pChainHeight, simplexMetadata, simplexBlacklist), nil + return sm.wrapBlock(parentBlock, childBlock, newSimplexEpochInfo, pChainHeight, simplexMetadata, blacklist), nil } // buildBlockZero builds the first ever block for Simplex, // which is a special block that introduces the first validator set and starts the first epoch. -func (sm *StateMachine) buildBlockZero(ctx context.Context, parentBlock StateMachineBlock, simplexMetadata, simplexBlacklist []byte) (*StateMachineBlock, error) { +func (sm *StateMachine) buildBlockZero(ctx context.Context, parentBlock StateMachineBlock, simplexMetadata, blacklist []byte) (*StateMachineBlock, error) { pChainHeight := sm.GetPChainHeight() newValidatorSet, err := sm.GetValidatorSet(pChainHeight) @@ -446,7 +443,7 @@ func (sm *StateMachine) buildBlockZero(ctx context.Context, parentBlock StateMac } simplexEpochInfo := constructSimplexZeroBlock(pChainHeight, newValidatorSet, prevVMBlockSeq) - return sm.buildBlockImpatiently(ctx, parentBlock, simplexMetadata, simplexBlacklist, simplexEpochInfo, pChainHeight) + return sm.buildBlockImpatiently(ctx, parentBlock, simplexMetadata, blacklist, simplexEpochInfo, pChainHeight) } func (sm *StateMachine) verifyBlockZero(ctx context.Context, block *StateMachineBlock, prevBlock StateMachineBlock) error { @@ -533,7 +530,7 @@ func (sm *StateMachine) verifyZeroBlockTimestamp(block *StateMachineBlock, prevB return proposedTime, nil } -func (sm *StateMachine) buildBlockCollectingApprovals(ctx context.Context, parentBlock StateMachineBlock, simplexMetadata, simplexBlacklist []byte, prevBlockSeq uint64) (*StateMachineBlock, error) { +func (sm *StateMachine) buildBlockCollectingApprovals(ctx context.Context, parentBlock StateMachineBlock, simplexMetadata, blacklist []byte, prevBlockSeq uint64) (*StateMachineBlock, error) { // The P-chain reference height and epoch number should remain the same until we transition to the new epoch. // The next P-chain reference height should have been set in the previous block, // which is the reason why we are collecting approvals in the first place. @@ -576,17 +573,17 @@ func (sm *StateMachine) buildBlockCollectingApprovals(ctx context.Context, paren // in which case we just carry over the approvals we have so far to the next block, // so that eventually we'll have enough approvals to seal the epoch. if !newApprovals.canSeal { - return sm.buildBlockImpatiently(ctx, parentBlock, simplexMetadata, simplexBlacklist, newSimplexEpochInfo, pChainHeight) + return sm.buildBlockImpatiently(ctx, parentBlock, simplexMetadata, blacklist, newSimplexEpochInfo, pChainHeight) } // Else, we have enough approvals to seal the epoch, so we create the sealing block. - return sm.createSealingBlock(ctx, parentBlock, simplexMetadata, simplexBlacklist, newSimplexEpochInfo, pChainHeight) + return sm.createSealingBlock(ctx, parentBlock, simplexMetadata, blacklist, newSimplexEpochInfo, pChainHeight) } // buildBlockImpatiently builds a block by waiting for the VM to build a block until MaxBlockBuildingWaitTime. // If the VM fails to build a block within that time, we build a block without an inner block, // so that we can continue making progress and not get stuck waiting for the VM. -func (sm *StateMachine) buildBlockImpatiently(ctx context.Context, parentBlock StateMachineBlock, simplexMetadata []byte, simplexBlacklist []byte, simplexEpochInfo SimplexEpochInfo, pChainHeight uint64) (*StateMachineBlock, error) { +func (sm *StateMachine) buildBlockImpatiently(ctx context.Context, parentBlock StateMachineBlock, simplexMetadata []byte, blacklist []byte, simplexEpochInfo SimplexEpochInfo, pChainHeight uint64) (*StateMachineBlock, error) { impatientContext, cancel := context.WithTimeout(ctx, sm.MaxBlockBuildingWaitTime) defer cancel() @@ -603,10 +600,10 @@ func (sm *StateMachine) buildBlockImpatiently(ctx context.Context, parentBlock S sm.Logger.Debug("Timed out waiting for block to be built, building block without inner block instead", zap.Duration("elapsed", time.Since(start)), zap.Duration("maxBlockBuildingWaitTime", sm.MaxBlockBuildingWaitTime)) } - return sm.wrapBlock(parentBlock, childBlock, simplexEpochInfo, pChainHeight, simplexMetadata, simplexBlacklist), nil + return sm.wrapBlock(parentBlock, childBlock, simplexEpochInfo, pChainHeight, simplexMetadata, blacklist), nil } -func (sm *StateMachine) createSealingBlock(ctx context.Context, parentBlock StateMachineBlock, simplexMetadata []byte, simplexBlacklist []byte, simplexEpochInfo SimplexEpochInfo, pChainHeight uint64) (*StateMachineBlock, error) { +func (sm *StateMachine) createSealingBlock(ctx context.Context, parentBlock StateMachineBlock, simplexMetadata []byte, blacklist []byte, simplexEpochInfo SimplexEpochInfo, pChainHeight uint64) (*StateMachineBlock, error) { validators, err := sm.GetValidatorSet(simplexEpochInfo.NextPChainReferenceHeight) if err != nil { return nil, err @@ -641,11 +638,11 @@ func (sm *StateMachine) createSealingBlock(ctx context.Context, parentBlock Stat simplexEpochInfo.PrevSealingBlockHash = firstSimplexBlockRetrieved.Digest() } - return sm.buildBlockImpatiently(ctx, parentBlock, simplexMetadata, simplexBlacklist, simplexEpochInfo, pChainHeight) + return sm.buildBlockImpatiently(ctx, parentBlock, simplexMetadata, blacklist, simplexEpochInfo, pChainHeight) } // wrapBlock creates a new StateMachineBlock by wrapping the VM block (if applicable) and adding the appropriate metadata. -func (sm *StateMachine) wrapBlock(parentBlock StateMachineBlock, childBlock VMBlock, newSimplexEpochInfo SimplexEpochInfo, pChainHeight uint64, simplexMetadata, simplexBlacklist []byte) *StateMachineBlock { +func (sm *StateMachine) wrapBlock(parentBlock StateMachineBlock, childBlock VMBlock, newSimplexEpochInfo SimplexEpochInfo, pChainHeight uint64, simplexMetadata, blacklist []byte) *StateMachineBlock { parentMetadata := parentBlock.Metadata timestamp := parentMetadata.Timestamp @@ -662,7 +659,7 @@ func (sm *StateMachine) wrapBlock(parentBlock StateMachineBlock, childBlock VMBl Metadata: StateMachineMetadata{ Timestamp: timestamp, SimplexProtocolMetadata: simplexMetadata, - SimplexBlacklist: simplexBlacklist, + SimplexBlacklist: blacklist, SimplexEpochInfo: newSimplexEpochInfo, PChainHeight: pChainHeight, }, @@ -670,7 +667,7 @@ func (sm *StateMachine) wrapBlock(parentBlock StateMachineBlock, childBlock VMBl } // buildBlockEpochSealed builds a block where the epoch is being sealed due to a sealing block already created in this epoch. -func (sm *StateMachine) buildBlockEpochSealed(ctx context.Context, parentBlock StateMachineBlock, simplexMetadata, simplexBlacklist []byte, prevBlockSeq uint64) (*StateMachineBlock, error) { +func (sm *StateMachine) buildBlockEpochSealed(ctx context.Context, parentBlock StateMachineBlock, simplexMetadata, blacklist []byte, prevBlockSeq uint64) (*StateMachineBlock, error) { // We check if the sealing block has already been finalized. // If not, we build a Telock block. @@ -702,7 +699,7 @@ func (sm *StateMachine) buildBlockEpochSealed(ctx context.Context, parentBlock S if !isSealingBlockFinalized { pChainHeight := parentBlock.Metadata.PChainHeight - return sm.wrapBlock(parentBlock, nil, newSimplexEpochInfo, pChainHeight, simplexMetadata, simplexBlacklist), nil + return sm.wrapBlock(parentBlock, nil, newSimplexEpochInfo, pChainHeight, simplexMetadata, blacklist), nil } // Else, we build a block for the new epoch. @@ -720,7 +717,7 @@ func (sm *StateMachine) buildBlockEpochSealed(ctx context.Context, parentBlock S return nil, err } - return sm.wrapBlock(parentBlock, childBlock, newSimplexEpochInfo, parentBlock.Metadata.PChainHeight, simplexMetadata, simplexBlacklist), nil + return sm.wrapBlock(parentBlock, childBlock, newSimplexEpochInfo, parentBlock.Metadata.PChainHeight, simplexMetadata, blacklist), nil } func IdentifyBlockType(nextBlockMD StateMachineMetadata, prevBlockMD StateMachineMetadata, prevSeq uint64) BlockType { diff --git a/msm/msm_test.go b/msm/msm_test.go index d870134e..3a8211bb 100644 --- a/msm/msm_test.go +++ b/msm/msm_test.go @@ -134,6 +134,10 @@ var ( Bytes: []byte{1, 2, 3}, }, } + emptyBlacklistBytes = func() []byte { + var b simplex.Blacklist + return b.Bytes() + }() ) func TestMSMFirstBlockAfterGenesis(t *testing.T) { @@ -264,7 +268,7 @@ func TestMSMFirstBlockAfterGenesis(t *testing.T) { testCase.configure(sm2, testConfig2) } - block, err := sm1.BuildBlock(context.Background(), genesisBlock, testCase.md, nil) + block, err := sm1.BuildBlock(context.Background(), genesisBlock, testCase.md, simplex.Blacklist{}) require.NoError(t, err) require.NotNil(t, block) @@ -313,7 +317,7 @@ func TestMSMFirstSimplexBlockAfterPreSimplexBlocks(t *testing.T) { Bytes: []byte{7, 8, 9}, } - block, err := sm1.BuildBlock(context.Background(), preSimplexParent, md, nil) + block, err := sm1.BuildBlock(context.Background(), preSimplexParent, md, simplex.Blacklist{}) require.NoError(t, err) require.NotNil(t, block) @@ -329,6 +333,7 @@ func TestMSMFirstSimplexBlockAfterPreSimplexBlocks(t *testing.T) { Timestamp: uint64(testConfig1.blockBuilder.block.Timestamp().UnixMilli()), PChainHeight: 100, SimplexProtocolMetadata: md.Bytes(), + SimplexBlacklist: emptyBlacklistBytes, SimplexEpochInfo: SimplexEpochInfo{ PChainReferenceHeight: 100, EpochNumber: 1, @@ -487,7 +492,7 @@ func TestMSMNormalOp(t *testing.T) { testCase.setup(sm2, testConfig2) } - block1, err := sm1.BuildBlock(context.Background(), lastBlock, *md, &blacklist) + block1, err := sm1.BuildBlock(context.Background(), lastBlock, *md, blacklist) require.NoError(t, err) require.NotNil(t, block1) @@ -627,7 +632,7 @@ func TestMSMFullEpochLifecycle(t *testing.T) { Prev: testCase.firstBlockBeforeSimplex.Digest(), } - block1, err := sm.BuildBlock(context.Background(), testCase.firstBlockBeforeSimplex, md, nil) + block1, err := sm.BuildBlock(context.Background(), testCase.firstBlockBeforeSimplex, md, simplex.Blacklist{}) require.NoError(t, err) require.Equal(t, &StateMachineBlock{ InnerBlock: nextBlock(1), @@ -635,6 +640,7 @@ func TestMSMFullEpochLifecycle(t *testing.T) { Timestamp: uint64(startTime.Add(1 * time.Millisecond).UnixMilli()), PChainHeight: pChainHeight1, SimplexProtocolMetadata: md.Bytes(), + SimplexBlacklist: emptyBlacklistBytes, SimplexEpochInfo: SimplexEpochInfo{ PChainReferenceHeight: pChainHeight1, EpochNumber: 1, @@ -658,7 +664,7 @@ func TestMSMFullEpochLifecycle(t *testing.T) { // ----- Step 2: Build a normal block (no validator set change) ----- tc.blockBuilder.block = nextBlock(2) md = simplex.ProtocolMetadata{Seq: baseSeq + 2, Round: 1, Epoch: 1, Prev: block1.Digest()} - block2, err := sm.BuildBlock(context.Background(), *block1, md, nil) + block2, err := sm.BuildBlock(context.Background(), *block1, md, simplex.Blacklist{}) require.NoError(t, err) require.Equal(t, &StateMachineBlock{ InnerBlock: nextBlock(2), @@ -666,6 +672,7 @@ func TestMSMFullEpochLifecycle(t *testing.T) { Timestamp: uint64(startTime.Add(2 * time.Millisecond).UnixMilli()), PChainHeight: pChainHeight1, SimplexProtocolMetadata: md.Bytes(), + SimplexBlacklist: emptyBlacklistBytes, SimplexEpochInfo: SimplexEpochInfo{ PChainReferenceHeight: pChainHeight1, EpochNumber: 1, @@ -683,7 +690,7 @@ func TestMSMFullEpochLifecycle(t *testing.T) { tc.blockBuilder.block = nextBlock(3) md = simplex.ProtocolMetadata{Seq: baseSeq + 3, Round: 2, Epoch: 1, Prev: block2.Digest()} - block3, err := sm.BuildBlock(context.Background(), *block2, md, nil) + block3, err := sm.BuildBlock(context.Background(), *block2, md, simplex.Blacklist{}) require.NoError(t, err) require.Equal(t, &StateMachineBlock{ InnerBlock: nextBlock(3), @@ -691,6 +698,7 @@ func TestMSMFullEpochLifecycle(t *testing.T) { Timestamp: uint64(startTime.Add(3 * time.Millisecond).UnixMilli()), PChainHeight: pChainHeight2, SimplexProtocolMetadata: md.Bytes(), + SimplexBlacklist: emptyBlacklistBytes, SimplexEpochInfo: SimplexEpochInfo{ PChainReferenceHeight: pChainHeight1, EpochNumber: 1, @@ -724,7 +732,7 @@ func TestMSMFullEpochLifecycle(t *testing.T) { tc.blockBuilder.block = nextBlock(4) md = simplex.ProtocolMetadata{Seq: baseSeq + 4, Round: 3, Epoch: 1, Prev: block3.Digest()} - block4, err := sm.BuildBlock(context.Background(), *block3, md, nil) + block4, err := sm.BuildBlock(context.Background(), *block3, md, simplex.Blacklist{}) require.NoError(t, err) require.Equal(t, &StateMachineBlock{ InnerBlock: nextBlock(4), @@ -732,6 +740,7 @@ func TestMSMFullEpochLifecycle(t *testing.T) { Timestamp: uint64(startTime.Add(4 * time.Millisecond).UnixMilli()), PChainHeight: pChainHeight2, SimplexProtocolMetadata: md.Bytes(), + SimplexBlacklist: emptyBlacklistBytes, SimplexEpochInfo: SimplexEpochInfo{ PChainReferenceHeight: pChainHeight1, EpochNumber: 1, @@ -764,7 +773,7 @@ func TestMSMFullEpochLifecycle(t *testing.T) { tc.blockBuilder.block = nextBlock(5) md = simplex.ProtocolMetadata{Seq: baseSeq + 5, Round: 4, Epoch: 1, Prev: block4.Digest()} - block5, err := sm.BuildBlock(context.Background(), *block4, md, nil) + block5, err := sm.BuildBlock(context.Background(), *block4, md, simplex.Blacklist{}) require.NoError(t, err) require.Equal(t, &StateMachineBlock{ InnerBlock: nextBlock(5), @@ -772,6 +781,7 @@ func TestMSMFullEpochLifecycle(t *testing.T) { Timestamp: uint64(startTime.Add(5 * time.Millisecond).UnixMilli()), PChainHeight: pChainHeight2, SimplexProtocolMetadata: md.Bytes(), + SimplexBlacklist: emptyBlacklistBytes, SimplexEpochInfo: SimplexEpochInfo{ PChainReferenceHeight: pChainHeight1, EpochNumber: 1, @@ -804,7 +814,7 @@ func TestMSMFullEpochLifecycle(t *testing.T) { tc.blockBuilder.block = nextBlock(6) md = simplex.ProtocolMetadata{Seq: baseSeq + 6, Round: 5, Epoch: 1, Prev: block5.Digest()} - block6, err := sm.BuildBlock(context.Background(), *block5, md, nil) + block6, err := sm.BuildBlock(context.Background(), *block5, md, simplex.Blacklist{}) require.NoError(t, err) require.Equal(t, &StateMachineBlock{ InnerBlock: nextBlock(6), @@ -812,6 +822,7 @@ func TestMSMFullEpochLifecycle(t *testing.T) { Timestamp: uint64(startTime.Add(6 * time.Millisecond).UnixMilli()), PChainHeight: pChainHeight2, SimplexProtocolMetadata: md.Bytes(), + SimplexBlacklist: emptyBlacklistBytes, SimplexEpochInfo: SimplexEpochInfo{ PChainReferenceHeight: pChainHeight1, EpochNumber: 1, @@ -873,7 +884,7 @@ func TestMSMFullEpochLifecycle(t *testing.T) { // However, despite the fact that the block builder is willing to build a new block, // a Telock shouldn't contain an inner block. if tc.blockStore[sealingSeq].finalization == nil { - telock, err := sm.BuildBlock(context.Background(), *block6, md, nil) + telock, err := sm.BuildBlock(context.Background(), *block6, md, simplex.Blacklist{}) require.NoError(t, err) require.Equal(t, &StateMachineBlock{ @@ -882,6 +893,7 @@ func TestMSMFullEpochLifecycle(t *testing.T) { Timestamp: uint64(startTime.Add(6 * time.Millisecond).UnixMilli()), PChainHeight: pChainHeight2, SimplexProtocolMetadata: md.Bytes(), + SimplexBlacklist: emptyBlacklistBytes, SimplexEpochInfo: SimplexEpochInfo{ PChainReferenceHeight: pChainHeight1, EpochNumber: 1, @@ -898,7 +910,7 @@ func TestMSMFullEpochLifecycle(t *testing.T) { // ----- Step 7: Build a new epoch block (sealing block is finalized) ----- - block7, err := sm.BuildBlock(context.Background(), *block6, md, nil) + block7, err := sm.BuildBlock(context.Background(), *block6, md, simplex.Blacklist{}) require.NoError(t, err) require.Equal(t, &StateMachineBlock{ InnerBlock: nextBlock(7), @@ -906,6 +918,7 @@ func TestMSMFullEpochLifecycle(t *testing.T) { Timestamp: uint64(startTime.Add(7 * time.Millisecond).UnixMilli()), PChainHeight: pChainHeight2, SimplexProtocolMetadata: md.Bytes(), + SimplexBlacklist: emptyBlacklistBytes, SimplexEpochInfo: SimplexEpochInfo{ PChainReferenceHeight: pChainHeight2, EpochNumber: sealingSeq, diff --git a/msm/multi_epoch_node_test.go b/msm/multi_epoch_node_test.go index 76bb4a01..2c8b7de9 100644 --- a/msm/multi_epoch_node_test.go +++ b/msm/multi_epoch_node_test.go @@ -353,7 +353,7 @@ func (fn *multiEpochNode) buildBlock() *StateMachineBlock { Seq: lastMD.Seq + 1, Round: lastMD.Round + 1, Prev: prevBlockDigest, - }, nil) + }, simplex.Blacklist{}) require.NoError(fn.t, err) return block From 79d6a85ed63a635a798e65d33ee7e73a148b3de4 Mon Sep 17 00:00:00 2001 From: Yacov Manevich Date: Thu, 16 Apr 2026 23:06:13 +0200 Subject: [PATCH 20/25] msm: move BlockType and IdentifyBlockType to block_type.go Addresses review comment: both are self-contained and not specifically about the StateMachine wiring. Relocating them out of msm.go keeps that file focused on the state machine itself. Co-Authored-By: Claude Opus 4.7 (1M context) --- msm/block_type.go | 85 +++++++++++++++++++++++++++++++++++++++++++++++ msm/msm.go | 73 ---------------------------------------- 2 files changed, 85 insertions(+), 73 deletions(-) create mode 100644 msm/block_type.go diff --git a/msm/block_type.go b/msm/block_type.go new file mode 100644 index 00000000..4d55d533 --- /dev/null +++ b/msm/block_type.go @@ -0,0 +1,85 @@ +// Copyright (C) 2019-2025, Ava Labs, Inc. All rights reserved. +// See the file LICENSE for licensing terms. + +package metadata + +import "fmt" + +// BlockType classifies a Simplex block by the role it plays in its epoch: +// a normal block in the middle of an epoch, a Telock (a pre-sealing block +// that carries no inner VM block), a sealing block that finalizes the +// current epoch, or the first block of a new epoch. +type BlockType uint8 + +const ( + BlockTypeNormal BlockType = iota + 1 + BlockTypeTelock + BlockTypeSealing + BlockTypeNewEpoch +) + +func (t BlockType) String() string { + switch t { + case BlockTypeNormal: + return "Normal" + case BlockTypeTelock: + return "Telock" + case BlockTypeSealing: + return "Sealing" + case BlockTypeNewEpoch: + return "NewEpoch" + default: + return fmt.Sprintf("UnknownBlockType(%d)", t) + } +} + +// IdentifyBlockType classifies a proposed block relative to its parent by +// inspecting the epoch information and the parent's sequence number. +func IdentifyBlockType(nextBlockMD StateMachineMetadata, prevBlockMD StateMachineMetadata, prevSeq uint64) BlockType { + simplexEpochInfo := nextBlockMD.SimplexEpochInfo + prevSimplexEpochInfo := prevBlockMD.SimplexEpochInfo + + // Only sealing blocks carry block validation descriptors + if nextBlockMD.SimplexEpochInfo.BlockValidationDescriptor != nil { + return BlockTypeSealing + } + + // This block could be in the edges of an epoch, either at the end or at the beginning. + + // If the new block comes after a sealing block, it could be a Telock or the first block of the next epoch. + // [ Sealing Block ] <-- [ New Block ] + if prevSimplexEpochInfo.BlockValidationDescriptor != nil { + // The zero-epoch block has BlockValidationDescriptor but epoch number 1 and next P-chain reference height of 0, + // so the block following it is a normal block, not a Telock. + if prevSimplexEpochInfo.EpochNumber == 1 && prevSimplexEpochInfo.NextPChainReferenceHeight == 0 { + return BlockTypeNormal + } + + if simplexEpochInfo.EpochNumber == prevSeq { + // If the epoch number of the new block is the same as the previous block's sequence number, + // it means we have just transitioned to a new epoch as the previous block was a sealing block. + return BlockTypeNewEpoch + } + + // Otherwise, we haven't transitioned to a new epoch yet, so this block has to be a Telock, + // as after a sealing block we either have a Telock or the first block of the new epoch, + // and we have already ruled out the first block of the new epoch in the previous condition. + return BlockTypeTelock + } + + // Else, if the previous block has a sealing block sequence and is in the same epoch as this block, + // then this block has to be a Telock, as the sealing block sequence indicates that the sealing block has been created. + // [ Sealing Block ] <-- [ Prev block ] <-- [ New Block ] + if simplexEpochInfo.EpochNumber == prevSimplexEpochInfo.EpochNumber && prevSimplexEpochInfo.SealingBlockSeq != 0 { + return BlockTypeTelock + } + + // This block is the first block of its epoch if the epoch number is the sealing block sequence of the previous epoch + if simplexEpochInfo.EpochNumber == prevSimplexEpochInfo.SealingBlockSeq { + return BlockTypeNewEpoch + } + + // Otherwise, we do not fall into any of these cases, so it's a block in the middle of the epoch, + // not in the edges. + return BlockTypeNormal +} diff --git a/msm/msm.go b/msm/msm.go index 10ee7856..f7a933cb 100644 --- a/msm/msm.go +++ b/msm/msm.go @@ -192,30 +192,6 @@ const ( stateBuildBlockEpochSealed ) -type BlockType uint8 - -const ( - BlockTypeNormal BlockType = iota + 1 - BlockTypeTelock - BlockTypeSealing - BlockTypeNewEpoch -) - -func (state BlockType) String() string { - switch state { - case BlockTypeNormal: - return "Normal" - case BlockTypeTelock: - return "Telock" - case BlockTypeSealing: - return "Sealing" - case BlockTypeNewEpoch: - return "NewEpoch" - default: - return fmt.Sprintf("UnknownBlockType(%d)", state) - } -} - // BuildBlock constructs the next block on top of the given parent block, and passes in the provided simplex metadata and blacklist. func (sm *StateMachine) BuildBlock(ctx context.Context, parentBlock StateMachineBlock, simplexMetadata simplex.ProtocolMetadata, blacklist simplex.Blacklist) (*StateMachineBlock, error) { // The zero sequence number is reserved for the genesis block, which should never be built. @@ -720,55 +696,6 @@ func (sm *StateMachine) buildBlockEpochSealed(ctx context.Context, parentBlock S return sm.wrapBlock(parentBlock, childBlock, newSimplexEpochInfo, parentBlock.Metadata.PChainHeight, simplexMetadata, blacklist), nil } -func IdentifyBlockType(nextBlockMD StateMachineMetadata, prevBlockMD StateMachineMetadata, prevSeq uint64) BlockType { - simplexEpochInfo := nextBlockMD.SimplexEpochInfo - prevSimplexEpochInfo := prevBlockMD.SimplexEpochInfo - - // Only sealing blocks carry block validation descriptors - if nextBlockMD.SimplexEpochInfo.BlockValidationDescriptor != nil { - return BlockTypeSealing - } - - // This block could be in the edges of an epoch, either at the end or at the beginning. - - // If the new block comes after a sealing block, it could be a Telock or the first block of the next epoch. - // [ Sealing Block ] <-- [ New Block ] - if prevSimplexEpochInfo.BlockValidationDescriptor != nil { - // The zero-epoch block has BlockValidationDescriptor but epoch number 1 and next P-chain reference height of 0., - // so the block following it is a normal block, not a Telock. - if prevSimplexEpochInfo.EpochNumber == 1 && prevSimplexEpochInfo.NextPChainReferenceHeight == 0 { - return BlockTypeNormal - } - - if simplexEpochInfo.EpochNumber == prevSeq { - // If the epoch number of the new block is the same as the previous block's sequence number, - // it means we have just transitioned to a new epoch as the previous block was a sealing block. - return BlockTypeNewEpoch - } - - // Otherwise, we haven't transitioned to a new epoch yet, so this block has to be a Telock, - // as after a sealing block we either have a Telock or the first block of the new epoch, - // and we have already ruled out the first block of the new epoch in the previous condition. - return BlockTypeTelock - } - - // Else, if the previous block has a sealing block sequence and is in the same epoch as this block, - // then this block has to be a Telock, as the sealing block sequence indicates that the sealing block has been created. - // [ Sealing Block ] <-- [ Prev block ] <-- [ New Block ] - if simplexEpochInfo.EpochNumber == prevSimplexEpochInfo.EpochNumber && prevSimplexEpochInfo.SealingBlockSeq != 0 { - return BlockTypeTelock - } - - // This block is the first block of its epoch if the epoch number is the sealing block sequence of the previous epoch - if simplexEpochInfo.EpochNumber == prevSimplexEpochInfo.SealingBlockSeq { - return BlockTypeNewEpoch - } - - // Otherwise, we do not fall into any of these cases, so it's a block in the middle of the epoch, - // not in the edges. - return BlockTypeNormal -} - // constructSimplexZeroBlock constructs the SimplexEpochInfo for the zero block, which is the first ever block built by Simplex. func constructSimplexZeroBlock(pChainHeight uint64, newValidatorSet NodeBLSMappings, prevVMBlockSeq uint64) SimplexEpochInfo { newSimplexEpochInfo := SimplexEpochInfo{ From 10188dc510ab11cefd743d0f4e47adb8dffbb064 Mon Sep 17 00:00:00 2001 From: Yacov Manevich Date: Thu, 16 Apr 2026 23:07:26 +0200 Subject: [PATCH 21/25] msm: move test helper structs into helpers_test.go Addresses review comment: msm_test.go was cluttered with the fakeVMBlock, blockStore, signatureVerifier/aggregator, validatorSetRetriever, keyAggregator, blockBuilder stubs plus newStateMachine/testConfig. They are test plumbing, not test cases, so relocate them to helpers_test.go to make the actual tests easier to navigate. Co-Authored-By: Claude Opus 4.7 (1M context) --- msm/helpers_test.go | 179 ++++++++++++++++++++++++++++++++++++++++++++ msm/msm_test.go | 167 ----------------------------------------- 2 files changed, 179 insertions(+), 167 deletions(-) create mode 100644 msm/helpers_test.go diff --git a/msm/helpers_test.go b/msm/helpers_test.go new file mode 100644 index 00000000..085af089 --- /dev/null +++ b/msm/helpers_test.go @@ -0,0 +1,179 @@ +// Copyright (C) 2019-2025, Ava Labs, Inc. All rights reserved. +// See the file LICENSE for licensing terms. + +package metadata + +import ( + "context" + "encoding/asn1" + "fmt" + "testing" + "time" + + "github.com/ava-labs/simplex" + "github.com/ava-labs/simplex/testutil" +) + +// fakeVMBlock is a minimal VMBlock implementation for tests. +type fakeVMBlock struct { + height uint64 +} + +func (f *fakeVMBlock) Digest() [32]byte { return [32]byte{} } +func (f *fakeVMBlock) Height() uint64 { return f.height } +func (f *fakeVMBlock) Timestamp() time.Time { return time.Time{} } +func (f *fakeVMBlock) Verify(_ context.Context) error { return nil } + +type outerBlock struct { + finalization *simplex.Finalization + block StateMachineBlock +} + +type blockStore map[uint64]*outerBlock + +func (bs blockStore) clone() blockStore { + newStore := make(blockStore) + for k, v := range bs { + newStore[k] = v + } + return newStore +} + +func (bs blockStore) getBlock(opts RetrievingOpts) (StateMachineBlock, *simplex.Finalization, error) { + blk, exits := bs[opts.Height] + if !exits { + return StateMachineBlock{}, nil, fmt.Errorf("%w: block %d not found", simplex.ErrBlockNotFound, opts.Height) + } + return blk.block, blk.finalization, nil +} + +type approvalsRetriever struct { + result ValidatorSetApprovals +} + +func (a approvalsRetriever) RetrieveApprovals() ValidatorSetApprovals { + return a.result +} + +type signatureVerifier struct { + err error +} + +func (sv *signatureVerifier) VerifySignature(signature []byte, message []byte, publicKey []byte) error { + return sv.err +} + +type signatureAggregator struct{} + +type aggregatrdSignature struct { + Signatures [][]byte +} + +func (sv *signatureAggregator) AggregateSignatures(signatures ...[]byte) ([]byte, error) { + bytes, err := asn1.Marshal(aggregatrdSignature{Signatures: signatures}) + if err != nil { + return nil, err + } + return bytes, nil +} + +type noOpPChainListener struct{} + +func (n *noOpPChainListener) WaitForProgress(ctx context.Context, _ uint64) error { + <-ctx.Done() + return ctx.Err() +} + +type blockBuilder struct { + block VMBlock + err error +} + +func (bb *blockBuilder) WaitForPendingBlock(_ context.Context) { + // Block is always ready in tests. +} + +func (bb *blockBuilder) BuildBlock(_ context.Context, _ uint64) (VMBlock, error) { + return bb.block, bb.err +} + +type validatorSetRetriever struct { + result NodeBLSMappings + resultMap map[uint64]NodeBLSMappings + err error +} + +func (vsr *validatorSetRetriever) getValidatorSet(height uint64) (NodeBLSMappings, error) { + if vsr.resultMap != nil { + if result, ok := vsr.resultMap[height]; ok { + return result, vsr.err + } + } + return vsr.result, vsr.err +} + +type keyAggregator struct{} + +func (ka *keyAggregator) AggregateKeys(keys ...[]byte) ([]byte, error) { + aggregated := make([]byte, 0) + for _, key := range keys { + aggregated = append(aggregated, key...) + } + return aggregated, nil +} + +var ( + genesisBlock = StateMachineBlock{ + // Genesis block metadata has all zero values + InnerBlock: &InnerBlock{ + TS: time.Now(), + Bytes: []byte{1, 2, 3}, + }, + } + emptyBlacklistBytes = func() []byte { + var b simplex.Blacklist + return b.Bytes() + }() +) + +type testConfig struct { + blockStore blockStore + approvalsRetriever approvalsRetriever + signatureVerifier signatureVerifier + signatureAggregator signatureAggregator + blockBuilder blockBuilder + keyAggregator keyAggregator + validatorSetRetriever validatorSetRetriever +} + +func newStateMachine(t *testing.T) (*StateMachine, *testConfig) { + bs := make(blockStore) + + var testConfig testConfig + testConfig.blockStore = bs + testConfig.validatorSetRetriever.result = NodeBLSMappings{ + {BLSKey: []byte{1}, Weight: 1}, {BLSKey: []byte{2}, Weight: 1}, + } + + sm := NewStateMachine(Config{ + GetTime: time.Now, + TimeSkewLimit: time.Second * 5, + Logger: testutil.MakeLogger(t), + GetBlock: testConfig.blockStore.getBlock, + MaxBlockBuildingWaitTime: time.Second, + ApprovalsRetriever: &testConfig.approvalsRetriever, + SignatureVerifier: &testConfig.signatureVerifier, + SignatureAggregator: &testConfig.signatureAggregator, + BlockBuilder: &testConfig.blockBuilder, + KeyAggregator: &testConfig.keyAggregator, + GetPChainHeight: func() uint64 { + return 100 + }, + GetUpgrades: func() any { + return nil + }, + GetValidatorSet: testConfig.validatorSetRetriever.getValidatorSet, + PChainProgressListener: &noOpPChainListener{}, + }) + return sm, &testConfig +} diff --git a/msm/msm_test.go b/msm/msm_test.go index 3a8211bb..981009c0 100644 --- a/msm/msm_test.go +++ b/msm/msm_test.go @@ -7,139 +7,14 @@ import ( "bytes" "context" "crypto/rand" - "encoding/asn1" "fmt" "testing" "time" "github.com/ava-labs/simplex" - "github.com/ava-labs/simplex/testutil" "github.com/stretchr/testify/require" ) -// fakeVMBlock is a minimal VMBlock implementation for tests. -type fakeVMBlock struct { - height uint64 -} - -func (f *fakeVMBlock) Digest() [32]byte { return [32]byte{} } -func (f *fakeVMBlock) Height() uint64 { return f.height } -func (f *fakeVMBlock) Timestamp() time.Time { return time.Time{} } -func (f *fakeVMBlock) Verify(_ context.Context) error { return nil } - -type outerBlock struct { - finalization *simplex.Finalization - block StateMachineBlock -} - -type blockStore map[uint64]*outerBlock - -func (bs blockStore) clone() blockStore { - newStore := make(blockStore) - for k, v := range bs { - newStore[k] = v - } - return newStore -} - -func (bs blockStore) getBlock(opts RetrievingOpts) (StateMachineBlock, *simplex.Finalization, error) { - blk, exits := bs[opts.Height] - if !exits { - return StateMachineBlock{}, nil, fmt.Errorf("%w: block %d not found", simplex.ErrBlockNotFound, opts.Height) - } - return blk.block, blk.finalization, nil -} - -type approvalsRetriever struct { - result ValidatorSetApprovals -} - -func (a approvalsRetriever) RetrieveApprovals() ValidatorSetApprovals { - return a.result -} - -type signatureVerifier struct { - err error -} - -func (sv *signatureVerifier) VerifySignature(signature []byte, message []byte, publicKey []byte) error { - return sv.err -} - -type signatureAggregator struct { -} - -type aggregatrdSignature struct { - Signatures [][]byte -} - -func (sv *signatureAggregator) AggregateSignatures(signatures ...[]byte) ([]byte, error) { - bytes, err := asn1.Marshal(aggregatrdSignature{Signatures: signatures}) - if err != nil { - return nil, err - } - return bytes, nil -} - -type noOpPChainListener struct{} - -func (n *noOpPChainListener) WaitForProgress(ctx context.Context, _ uint64) error { - <-ctx.Done() - return ctx.Err() -} - -type blockBuilder struct { - block VMBlock - err error -} - -func (bb *blockBuilder) WaitForPendingBlock(_ context.Context) { - // Block is always ready in tests. -} - -func (bb *blockBuilder) BuildBlock(_ context.Context, _ uint64) (VMBlock, error) { - return bb.block, bb.err -} - -type validatorSetRetriever struct { - result NodeBLSMappings - resultMap map[uint64]NodeBLSMappings - err error -} - -func (vsr *validatorSetRetriever) getValidatorSet(height uint64) (NodeBLSMappings, error) { - if vsr.resultMap != nil { - if result, ok := vsr.resultMap[height]; ok { - return result, vsr.err - } - } - return vsr.result, vsr.err -} - -type keyAggregator struct{} - -func (ka *keyAggregator) AggregateKeys(keys ...[]byte) ([]byte, error) { - aggregated := make([]byte, 0) - for _, key := range keys { - aggregated = append(aggregated, key...) - } - return aggregated, nil -} - -var ( - genesisBlock = StateMachineBlock{ - // Genesis block metadata has all zero values - InnerBlock: &InnerBlock{ - TS: time.Now(), - Bytes: []byte{1, 2, 3}, - }, - } - emptyBlacklistBytes = func() []byte { - var b simplex.Blacklist - return b.Bytes() - }() -) - func TestMSMFirstBlockAfterGenesis(t *testing.T) { validMD := simplex.ProtocolMetadata{ Round: 0, @@ -1016,48 +891,6 @@ func makeNonSimplexBlock(t *testing.T, startHeight uint64, start time.Time, h ui } } -type testConfig struct { - blockStore blockStore - approvalsRetriever approvalsRetriever - signatureVerifier signatureVerifier - signatureAggregator signatureAggregator - blockBuilder blockBuilder - keyAggregator keyAggregator - validatorSetRetriever validatorSetRetriever -} - -func newStateMachine(t *testing.T) (*StateMachine, *testConfig) { - bs := make(blockStore) - - var testConfig testConfig - testConfig.blockStore = bs - testConfig.validatorSetRetriever.result = NodeBLSMappings{ - {BLSKey: []byte{1}, Weight: 1}, {BLSKey: []byte{2}, Weight: 1}, - } - - sm := NewStateMachine(Config{ - GetTime: time.Now, - TimeSkewLimit: time.Second * 5, - Logger: testutil.MakeLogger(t), - GetBlock: testConfig.blockStore.getBlock, - MaxBlockBuildingWaitTime: time.Second, - ApprovalsRetriever: &testConfig.approvalsRetriever, - SignatureVerifier: &testConfig.signatureVerifier, - SignatureAggregator: &testConfig.signatureAggregator, - BlockBuilder: &testConfig.blockBuilder, - KeyAggregator: &testConfig.keyAggregator, - GetPChainHeight: func() uint64 { - return 100 - }, - GetUpgrades: func() any { - return nil - }, - GetValidatorSet: testConfig.validatorSetRetriever.getValidatorSet, - PChainProgressListener: &noOpPChainListener{}, - }) - return sm, &testConfig -} - func TestIdentifyCurrentState(t *testing.T) { bvd := &BlockValidationDescriptor{} for _, tc := range []struct { From d59e5e3d8769fb1c1544ede3828a92852b4a64d2 Mon Sep 17 00:00:00 2001 From: Yacov Manevich Date: Thu, 16 Apr 2026 23:08:58 +0200 Subject: [PATCH 22/25] msm: thread simplex.ProtocolMetadata through build paths Addresses review comment: buildBlockNormalOp / buildBlockCollectingApprovals / buildBlockEpochSealed / buildBlockImpatiently / createSealingBlock / wrapBlock all took simplexMetadata bytes plus a separately drilled prevBlockSeq. Replace both with a single simplex.ProtocolMetadata argument; prevBlockSeq is now derived locally as metadata.Seq - 1, and wrapBlock serialises the metadata when building the block. Co-Authored-By: Claude Opus 4.7 (1M context) --- msm/msm.go | 35 +++++++++++++++++------------------ 1 file changed, 17 insertions(+), 18 deletions(-) diff --git a/msm/msm.go b/msm/msm.go index f7a933cb..c215761c 100644 --- a/msm/msm.go +++ b/msm/msm.go @@ -222,18 +222,15 @@ func (sm *StateMachine) BuildBlock(ctx context.Context, parentBlock StateMachine // we identify the current state by looking at the parent block's epoch info. currentState := parentBlock.Metadata.SimplexEpochInfo.CurrentState() - simplexMetadataBytes := simplexMetadata.Bytes() - prevBlockSeq := simplexMetadata.Seq - 1 - switch currentState { case stateFirstSimplexBlock: - return sm.buildBlockZero(ctx, parentBlock, simplexMetadataBytes, blacklistBytes) + return sm.buildBlockZero(ctx, parentBlock, simplexMetadata, blacklistBytes) case stateBuildBlockNormalOp: - return sm.buildBlockNormalOp(ctx, parentBlock, simplexMetadataBytes, blacklistBytes, prevBlockSeq) + return sm.buildBlockNormalOp(ctx, parentBlock, simplexMetadata, blacklistBytes) case stateBuildCollectingApprovals: - return sm.buildBlockCollectingApprovals(ctx, parentBlock, simplexMetadataBytes, blacklistBytes, prevBlockSeq) + return sm.buildBlockCollectingApprovals(ctx, parentBlock, simplexMetadata, blacklistBytes) case stateBuildBlockEpochSealed: - return sm.buildBlockEpochSealed(ctx, parentBlock, simplexMetadataBytes, blacklistBytes, prevBlockSeq) + return sm.buildBlockEpochSealed(ctx, parentBlock, simplexMetadata, blacklistBytes) default: return nil, fmt.Errorf("unknown state %d", currentState) } @@ -340,13 +337,13 @@ func (sei *SimplexEpochInfo) CurrentState() state { } // buildBlockNormalOp builds a block while not trying to transition to a new epoch. -func (sm *StateMachine) buildBlockNormalOp(ctx context.Context, parentBlock StateMachineBlock, simplexMetadata, blacklist []byte, prevBlockSeq uint64) (*StateMachineBlock, error) { +func (sm *StateMachine) buildBlockNormalOp(ctx context.Context, parentBlock StateMachineBlock, simplexMetadata simplex.ProtocolMetadata, blacklist []byte) (*StateMachineBlock, error) { // Since in the previous block, we were not transitioning to a new epoch, // the P-chain reference height and epoch of the new block should remain the same. newSimplexEpochInfo := SimplexEpochInfo{ PChainReferenceHeight: parentBlock.Metadata.SimplexEpochInfo.PChainReferenceHeight, EpochNumber: parentBlock.Metadata.SimplexEpochInfo.EpochNumber, - PrevVMBlockSeq: computePrevVMBlockSeq(parentBlock, prevBlockSeq), + PrevVMBlockSeq: computePrevVMBlockSeq(parentBlock, simplexMetadata.Seq-1), } blockBuildingDecider := newBlockBuildingDecider(sm, parentBlock) @@ -378,7 +375,8 @@ func (sm *StateMachine) buildBlockNormalOp(ctx context.Context, parentBlock Stat func (sm *StateMachine) buildBlockAndMaybeTransitionEpoch( ctx context.Context, parentBlock StateMachineBlock, - simplexMetadata, blacklist []byte, + simplexMetadata simplex.ProtocolMetadata, + blacklist []byte, newSimplexEpochInfo SimplexEpochInfo, pChainHeight uint64, transitionEpoch bool, @@ -400,7 +398,7 @@ func (sm *StateMachine) buildBlockAndMaybeTransitionEpoch( // buildBlockZero builds the first ever block for Simplex, // which is a special block that introduces the first validator set and starts the first epoch. -func (sm *StateMachine) buildBlockZero(ctx context.Context, parentBlock StateMachineBlock, simplexMetadata, blacklist []byte) (*StateMachineBlock, error) { +func (sm *StateMachine) buildBlockZero(ctx context.Context, parentBlock StateMachineBlock, simplexMetadata simplex.ProtocolMetadata, blacklist []byte) (*StateMachineBlock, error) { pChainHeight := sm.GetPChainHeight() newValidatorSet, err := sm.GetValidatorSet(pChainHeight) @@ -506,7 +504,7 @@ func (sm *StateMachine) verifyZeroBlockTimestamp(block *StateMachineBlock, prevB return proposedTime, nil } -func (sm *StateMachine) buildBlockCollectingApprovals(ctx context.Context, parentBlock StateMachineBlock, simplexMetadata, blacklist []byte, prevBlockSeq uint64) (*StateMachineBlock, error) { +func (sm *StateMachine) buildBlockCollectingApprovals(ctx context.Context, parentBlock StateMachineBlock, simplexMetadata simplex.ProtocolMetadata, blacklist []byte) (*StateMachineBlock, error) { // The P-chain reference height and epoch number should remain the same until we transition to the new epoch. // The next P-chain reference height should have been set in the previous block, // which is the reason why we are collecting approvals in the first place. @@ -514,7 +512,7 @@ func (sm *StateMachine) buildBlockCollectingApprovals(ctx context.Context, paren PChainReferenceHeight: parentBlock.Metadata.SimplexEpochInfo.PChainReferenceHeight, EpochNumber: parentBlock.Metadata.SimplexEpochInfo.EpochNumber, NextPChainReferenceHeight: parentBlock.Metadata.SimplexEpochInfo.NextPChainReferenceHeight, - PrevVMBlockSeq: computePrevVMBlockSeq(parentBlock, prevBlockSeq), + PrevVMBlockSeq: computePrevVMBlockSeq(parentBlock, simplexMetadata.Seq-1), } // We prepare information that is needed to compute the approvals for the new epoch, @@ -559,7 +557,7 @@ func (sm *StateMachine) buildBlockCollectingApprovals(ctx context.Context, paren // buildBlockImpatiently builds a block by waiting for the VM to build a block until MaxBlockBuildingWaitTime. // If the VM fails to build a block within that time, we build a block without an inner block, // so that we can continue making progress and not get stuck waiting for the VM. -func (sm *StateMachine) buildBlockImpatiently(ctx context.Context, parentBlock StateMachineBlock, simplexMetadata []byte, blacklist []byte, simplexEpochInfo SimplexEpochInfo, pChainHeight uint64) (*StateMachineBlock, error) { +func (sm *StateMachine) buildBlockImpatiently(ctx context.Context, parentBlock StateMachineBlock, simplexMetadata simplex.ProtocolMetadata, blacklist []byte, simplexEpochInfo SimplexEpochInfo, pChainHeight uint64) (*StateMachineBlock, error) { impatientContext, cancel := context.WithTimeout(ctx, sm.MaxBlockBuildingWaitTime) defer cancel() @@ -579,7 +577,7 @@ func (sm *StateMachine) buildBlockImpatiently(ctx context.Context, parentBlock S return sm.wrapBlock(parentBlock, childBlock, simplexEpochInfo, pChainHeight, simplexMetadata, blacklist), nil } -func (sm *StateMachine) createSealingBlock(ctx context.Context, parentBlock StateMachineBlock, simplexMetadata []byte, blacklist []byte, simplexEpochInfo SimplexEpochInfo, pChainHeight uint64) (*StateMachineBlock, error) { +func (sm *StateMachine) createSealingBlock(ctx context.Context, parentBlock StateMachineBlock, simplexMetadata simplex.ProtocolMetadata, blacklist []byte, simplexEpochInfo SimplexEpochInfo, pChainHeight uint64) (*StateMachineBlock, error) { validators, err := sm.GetValidatorSet(simplexEpochInfo.NextPChainReferenceHeight) if err != nil { return nil, err @@ -618,7 +616,7 @@ func (sm *StateMachine) createSealingBlock(ctx context.Context, parentBlock Stat } // wrapBlock creates a new StateMachineBlock by wrapping the VM block (if applicable) and adding the appropriate metadata. -func (sm *StateMachine) wrapBlock(parentBlock StateMachineBlock, childBlock VMBlock, newSimplexEpochInfo SimplexEpochInfo, pChainHeight uint64, simplexMetadata, blacklist []byte) *StateMachineBlock { +func (sm *StateMachine) wrapBlock(parentBlock StateMachineBlock, childBlock VMBlock, newSimplexEpochInfo SimplexEpochInfo, pChainHeight uint64, simplexMetadata simplex.ProtocolMetadata, blacklist []byte) *StateMachineBlock { parentMetadata := parentBlock.Metadata timestamp := parentMetadata.Timestamp @@ -634,7 +632,7 @@ func (sm *StateMachine) wrapBlock(parentBlock StateMachineBlock, childBlock VMBl InnerBlock: childBlock, Metadata: StateMachineMetadata{ Timestamp: timestamp, - SimplexProtocolMetadata: simplexMetadata, + SimplexProtocolMetadata: simplexMetadata.Bytes(), SimplexBlacklist: blacklist, SimplexEpochInfo: newSimplexEpochInfo, PChainHeight: pChainHeight, @@ -643,10 +641,11 @@ func (sm *StateMachine) wrapBlock(parentBlock StateMachineBlock, childBlock VMBl } // buildBlockEpochSealed builds a block where the epoch is being sealed due to a sealing block already created in this epoch. -func (sm *StateMachine) buildBlockEpochSealed(ctx context.Context, parentBlock StateMachineBlock, simplexMetadata, blacklist []byte, prevBlockSeq uint64) (*StateMachineBlock, error) { +func (sm *StateMachine) buildBlockEpochSealed(ctx context.Context, parentBlock StateMachineBlock, simplexMetadata simplex.ProtocolMetadata, blacklist []byte) (*StateMachineBlock, error) { // We check if the sealing block has already been finalized. // If not, we build a Telock block. + prevBlockSeq := simplexMetadata.Seq - 1 sealingBlockSeq := parentBlock.Metadata.SimplexEpochInfo.SealingBlockSeq // If the sealing block sequence is still 0, it means previous block was the sealing block. From ef828b2add8c672ded364ff5d7d208d9f3df6f2a Mon Sep 17 00:00:00 2001 From: Yacov Manevich Date: Fri, 17 Apr 2026 19:20:45 +0200 Subject: [PATCH 23/25] Add test case for sequence 0 Signed-off-by: Yacov Manevich --- msm/msm_test.go | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/msm/msm_test.go b/msm/msm_test.go index 981009c0..6732008f 100644 --- a/msm/msm_test.go +++ b/msm/msm_test.go @@ -161,6 +161,22 @@ func TestMSMFirstBlockAfterGenesis(t *testing.T) { } } +func TestMSMBuildBlockRejectsZeroSeq(t *testing.T) { + sm, tc := newStateMachine(t) + tc.blockStore[0] = &outerBlock{block: genesisBlock} + + md := simplex.ProtocolMetadata{ + Round: 0, + Seq: 0, + Epoch: 1, + Prev: genesisBlock.Digest(), + } + + block, err := sm.BuildBlock(context.Background(), genesisBlock, md, simplex.Blacklist{}) + require.Nil(t, block) + require.ErrorContains(t, err, "invalid ProtocolMetadata sequence number: should be > 0, got 0") +} + func TestMSMFirstSimplexBlockAfterPreSimplexBlocks(t *testing.T) { preSimplexParent := StateMachineBlock{ InnerBlock: &InnerBlock{ From 32bf41e2196998936159916d0b1962c151aca8d5 Mon Sep 17 00:00:00 2001 From: Yacov Manevich Date: Fri, 17 Apr 2026 21:06:56 +0200 Subject: [PATCH 24/25] Remove RetrievingOpts in favor of explicit parameters Signed-off-by: Yacov Manevich --- msm/helpers_test.go | 8 ++++---- msm/msm.go | 34 +++++++++++++++------------------- msm/msm_test.go | 12 ++++++------ msm/multi_epoch_node_test.go | 15 ++++++--------- msm/verification.go | 12 ++++-------- msm/verification_test.go | 6 +++--- 6 files changed, 38 insertions(+), 49 deletions(-) diff --git a/msm/helpers_test.go b/msm/helpers_test.go index 085af089..dcb36d02 100644 --- a/msm/helpers_test.go +++ b/msm/helpers_test.go @@ -39,10 +39,10 @@ func (bs blockStore) clone() blockStore { return newStore } -func (bs blockStore) getBlock(opts RetrievingOpts) (StateMachineBlock, *simplex.Finalization, error) { - blk, exits := bs[opts.Height] - if !exits { - return StateMachineBlock{}, nil, fmt.Errorf("%w: block %d not found", simplex.ErrBlockNotFound, opts.Height) +func (bs blockStore) getBlock(seq uint64, _ [32]byte) (StateMachineBlock, *simplex.Finalization, error) { + blk, exists := bs[seq] + if !exists { + return StateMachineBlock{}, nil, fmt.Errorf("%w: block %d not found", simplex.ErrBlockNotFound, seq) } return blk.block, blk.finalization, nil } diff --git a/msm/msm.go b/msm/msm.go index c215761c..96c04d92 100644 --- a/msm/msm.go +++ b/msm/msm.go @@ -65,18 +65,13 @@ type KeyAggregator interface { // ValidatorSetRetriever retrieves the validator set at a given P-chain height. type ValidatorSetRetriever func(pChainHeight uint64) (NodeBLSMappings, error) -// RetrievingOpts specifies the options for retrieving a block by height and/or digest. -type RetrievingOpts struct { - // Height is the sequence number of the block to retrieve. - Height uint64 - // Digest is the expected hash of the block, used for validation. - Digest [32]byte -} - -// BlockRetriever retrieves a block and its finalization status given the retrieval options. +// BlockRetriever retrieves the block at the given sequence number along with +// its finalization status. The digest is the expected hash of the block and +// can be used for validation; callers that don't care about digest validation +// may pass the zero value. // If the block cannot be found it returns ErrBlockNotFound. // If an error occurs during retrieval, it returns a non-nil error. -type BlockRetriever func(RetrievingOpts) (StateMachineBlock, *simplex.Finalization, error) +type BlockRetriever func(seq uint64, digest [32]byte) (StateMachineBlock, *simplex.Finalization, error) // BlockBuilder builds a new VM block with the given observed P-chain height. type BlockBuilder interface { @@ -142,8 +137,8 @@ func NewStateMachine(config Config) *StateMachine { getPChainHeight := func() uint64 { return out.GetPChainHeight() } getTime := func() time.Time { return out.GetTime() } - getBlock := func(opts RetrievingOpts) (StateMachineBlock, *simplex.Finalization, error) { - return out.GetBlock(opts) + getBlock := func(seq uint64, digest [32]byte) (StateMachineBlock, *simplex.Finalization, error) { + return out.GetBlock(seq, digest) } getValidatorSet := func(height uint64) (NodeBLSMappings, error) { return out.GetValidatorSet(height) @@ -254,7 +249,7 @@ func (sm *StateMachine) VerifyBlock(ctx context.Context, block *StateMachineBloc return fmt.Errorf("attempted to build a genesis inner block") } - prevBlock, _, err := sm.GetBlock(RetrievingOpts{Digest: pmd.Prev, Height: seq - 1}) + prevBlock, _, err := sm.GetBlock(seq-1, pmd.Prev) if err != nil { return fmt.Errorf("failed to retrieve previous (%d) inner block: %w", seq-1, err) } @@ -266,12 +261,12 @@ func (sm *StateMachine) VerifyBlock(ctx context.Context, block *StateMachineBloc case stateFirstSimplexBlock: err = sm.verifyBlockZero(ctx, block, prevBlock) default: - err = sm.verifyNonZeroBlock(ctx, block, prevBlock.Metadata, currentState, seq-1) + err = sm.verifyNonZeroBlock(ctx, block, prevBlock.Metadata, currentState, pmd.Prev, seq-1) } return err } -func (sm *StateMachine) verifyNonZeroBlock(ctx context.Context, block *StateMachineBlock, prevBlockMD StateMachineMetadata, state state, prevSeq uint64) error { +func (sm *StateMachine) verifyNonZeroBlock(ctx context.Context, block *StateMachineBlock, prevBlockMD StateMachineMetadata, state state, prevHash [32]byte, prevSeq uint64) error { blockType := IdentifyBlockType(block.Metadata, prevBlockMD, prevSeq) sm.Logger.Debug("Identified block type", zap.Stringer("blockType", blockType), @@ -296,6 +291,7 @@ func (sm *StateMachine) verifyNonZeroBlock(ctx context.Context, block *StateMach prevMD: prevBlockMD, state: state, prevBlockSeq: prevSeq, + prevBlockHash: prevHash, hasInnerBlock: block.InnerBlock != nil, innerBlockTimestamp: innerBlockTimestamp, }); err != nil { @@ -589,7 +585,7 @@ func (sm *StateMachine) createSealingBlock(ctx context.Context, parentBlock Stat // If this is not the first epoch, and this is the sealing block, we set the hash of the previous sealing block. if simplexEpochInfo.EpochNumber > 1 { - prevSealingBlock, finalization, err := sm.GetBlock(RetrievingOpts{Height: simplexEpochInfo.EpochNumber}) + prevSealingBlock, finalization, err := sm.GetBlock(simplexEpochInfo.EpochNumber, [32]byte{}) if err != nil { sm.Logger.Error("Error retrieving previous sealing block", zap.Uint64("seq", simplexEpochInfo.EpochNumber), zap.Error(err)) return nil, fmt.Errorf("failed to retrieve previous sealing InnerBlock at epoch %d: %w", simplexEpochInfo.EpochNumber-1, err) @@ -605,7 +601,7 @@ func (sm *StateMachine) createSealingBlock(ctx context.Context, parentBlock Stat if err != nil { return nil, fmt.Errorf("failed to find first simplex block: %w", err) } - firstSimplexBlockRetrieved, _, err := sm.GetBlock(RetrievingOpts{Height: firstSimplexBlock}) + firstSimplexBlockRetrieved, _, err := sm.GetBlock(firstSimplexBlock, [32]byte{}) if err != nil { return nil, fmt.Errorf("failed to retrieve first simplex block at height %d: %w", firstSimplexBlock, err) } @@ -665,7 +661,7 @@ func (sm *StateMachine) buildBlockEpochSealed(ctx context.Context, parentBlock S PrevVMBlockSeq: computePrevVMBlockSeq(parentBlock, prevBlockSeq), } - _, finalization, err := sm.GetBlock(RetrievingOpts{Height: sealingBlockSeq}) + _, finalization, err := sm.GetBlock(sealingBlockSeq, [32]byte{}) if err != nil { return nil, fmt.Errorf("failed to retrieve sealing block at sequence %d: %w", sealingBlockSeq, err) } @@ -888,7 +884,7 @@ func findFirstSimplexBlock(getBlock BlockRetriever, endHeight uint64) (uint64, e if haltError != nil { return true } - block, _, err := getBlock(RetrievingOpts{Height: uint64(i)}) + block, _, err := getBlock(uint64(i), [32]byte{}) if errors.Is(err, simplex.ErrBlockNotFound) { return false } diff --git a/msm/msm_test.go b/msm/msm_test.go index 6732008f..647f97b0 100644 --- a/msm/msm_test.go +++ b/msm/msm_test.go @@ -1012,8 +1012,8 @@ func TestComputePrevVMBlockSeq(t *testing.T) { func TestFindFirstSimplexBlock(t *testing.T) { t.Run("found at height 3", func(t *testing.T) { - getBlock := func(opts RetrievingOpts) (StateMachineBlock, *simplex.Finalization, error) { - if opts.Height < 3 { + getBlock := func(seq uint64, _ [32]byte) (StateMachineBlock, *simplex.Finalization, error) { + if seq < 3 { return StateMachineBlock{}, nil, nil } return StateMachineBlock{ @@ -1026,7 +1026,7 @@ func TestFindFirstSimplexBlock(t *testing.T) { }) t.Run("no simplex blocks found", func(t *testing.T) { - getBlock := func(opts RetrievingOpts) (StateMachineBlock, *simplex.Finalization, error) { + getBlock := func(_ uint64, _ [32]byte) (StateMachineBlock, *simplex.Finalization, error) { return StateMachineBlock{}, nil, nil } _, err := findFirstSimplexBlock(getBlock, 5) @@ -1034,8 +1034,8 @@ func TestFindFirstSimplexBlock(t *testing.T) { }) t.Run("block not found errors are skipped", func(t *testing.T) { - getBlock := func(opts RetrievingOpts) (StateMachineBlock, *simplex.Finalization, error) { - if opts.Height < 2 { + getBlock := func(seq uint64, _ [32]byte) (StateMachineBlock, *simplex.Finalization, error) { + if seq < 2 { return StateMachineBlock{}, nil, simplex.ErrBlockNotFound } return StateMachineBlock{ @@ -1048,7 +1048,7 @@ func TestFindFirstSimplexBlock(t *testing.T) { }) t.Run("retrieval error propagated", func(t *testing.T) { - getBlock := func(opts RetrievingOpts) (StateMachineBlock, *simplex.Finalization, error) { + getBlock := func(_ uint64, _ [32]byte) (StateMachineBlock, *simplex.Finalization, error) { return StateMachineBlock{}, nil, fmt.Errorf("disk error") } _, err := findFirstSimplexBlock(getBlock, 5) diff --git a/msm/multi_epoch_node_test.go b/msm/multi_epoch_node_test.go index 2c8b7de9..3bdcc38c 100644 --- a/msm/multi_epoch_node_test.go +++ b/msm/multi_epoch_node_test.go @@ -213,18 +213,18 @@ func newMultiEpochNode(t *testing.T) *multiEpochNode { fn.sm.BlockBuilder = fn fn.sm.PChainProgressListener = fn - fn.sm.GetBlock = func(opts RetrievingOpts) (StateMachineBlock, *simplex.Finalization, error) { - if opts.Height == 0 { + fn.sm.GetBlock = func(seq uint64, digest [32]byte) (StateMachineBlock, *simplex.Finalization, error) { + if seq == 0 { return genesisBlock, nil, nil } for _, bs := range fn.blocks { - match := bs.block.Digest() == opts.Digest + match := bs.block.Digest() == digest if !match { md, err := simplex.ProtocolMetadataFromBytes(bs.block.Metadata.SimplexProtocolMetadata) if err != nil { return StateMachineBlock{}, nil, err } - match = md.Seq == opts.Height + match = md.Seq == seq } if match { var fin *simplex.Finalization @@ -235,7 +235,7 @@ func newMultiEpochNode(t *testing.T) *multiEpochNode { } } - require.Failf(t, "not found block", "height: %d", opts.Height) + require.Failf(t, "not found block", "height: %d", seq) return StateMachineBlock{}, nil, fmt.Errorf("block not found") } @@ -336,10 +336,7 @@ func (fn *multiEpochNode) buildBlock() *StateMachineBlock { lastMD, prevBlockDigest := fn.prepareMetadataAndPrevBlockDigest() - _, finalization, err := fn.sm.GetBlock(RetrievingOpts{ - Digest: prevBlockDigest, - Height: lastMD.Seq, - }) + _, finalization, err := fn.sm.GetBlock(lastMD.Seq, prevBlockDigest) require.NoError(fn.t, err) finalizedString := "not finalized" diff --git a/msm/verification.go b/msm/verification.go index 91af5e39..c769fc73 100644 --- a/msm/verification.go +++ b/msm/verification.go @@ -18,6 +18,7 @@ type verificationInput struct { hasInnerBlock bool innerBlockTimestamp time.Time // only set when hasInnerBlock is true prevBlockSeq uint64 + prevBlockHash [32]byte nextBlockType BlockType state state } @@ -423,7 +424,7 @@ func (p *prevSealingBlockHashVerifier) Verify(in verificationInput) error { return fmt.Errorf("failed to find first Simplex block: %w", err) } - block, _, err := p.getBlock(RetrievingOpts{Height: firstEverSimplexBlockSeq}) + block, _, err := p.getBlock(firstEverSimplexBlockSeq, [32]byte{}) if err != nil { return fmt.Errorf("failed retrieving first ever simplex block %d: %w", firstEverSimplexBlockSeq, err) } @@ -441,7 +442,7 @@ func (p *prevSealingBlockHashVerifier) Verify(in verificationInput) error { switch in.nextBlockType { case BlockTypeSealing: - prevSealingBlock, _, err := p.getBlock(RetrievingOpts{Height: in.prevMD.SimplexEpochInfo.EpochNumber}) + prevSealingBlock, _, err := p.getBlock(in.prevMD.SimplexEpochInfo.EpochNumber, [32]byte{}) if err != nil { return fmt.Errorf("failed retrieving block: %w", err) } @@ -473,14 +474,9 @@ func (v *vmBlockSeqVerifier) Verify(in verificationInput) error { return nil } - md, err := simplex.ProtocolMetadataFromBytes(in.proposedBlockMD.SimplexProtocolMetadata) - if err != nil { - return fmt.Errorf("failed parsing protocol metadata: %w", err) - } - // Else, if the previous block has an inner block, we point to it. // Otherwise, we point to the parent block's previous VM block seq. - prevBlock, _, err := v.getBlock(RetrievingOpts{Height: in.prevBlockSeq, Digest: md.Prev}) + prevBlock, _, err := v.getBlock(in.prevBlockSeq, in.prevBlockHash) if err != nil { return fmt.Errorf("failed retrieving block: %w", err) } diff --git a/msm/verification_test.go b/msm/verification_test.go index 7881a133..e0497681 100644 --- a/msm/verification_test.go +++ b/msm/verification_test.go @@ -941,10 +941,10 @@ func TestSealingBlockSeqVerifier(t *testing.T) { type testBlockStore map[uint64]StateMachineBlock -func (bs testBlockStore) getBlock(opts RetrievingOpts) (StateMachineBlock, *simplex.Finalization, error) { - blk, ok := bs[opts.Height] +func (bs testBlockStore) getBlock(seq uint64, _ [32]byte) (StateMachineBlock, *simplex.Finalization, error) { + blk, ok := bs[seq] if !ok { - return StateMachineBlock{}, nil, fmt.Errorf("%w: block %d", simplex.ErrBlockNotFound, opts.Height) + return StateMachineBlock{}, nil, fmt.Errorf("%w: block %d", simplex.ErrBlockNotFound, seq) } return blk, nil, nil } From a9a87d1b5f4305be463907de7ca077233de824e7 Mon Sep 17 00:00:00 2001 From: Yacov Manevich Date: Tue, 21 Apr 2026 15:15:10 +0200 Subject: [PATCH 25/25] Simplex reconfiguration framework - Part V (ICM epoch + authInfo) Signed-off-by: Yacov Manevich --- msm/encoding.canoto.go | 490 +++++++++++++++++++++++++++++++++++ msm/encoding.go | 60 +++++ msm/helpers_test.go | 32 ++- msm/msm.go | 190 +++++++++++++- msm/msm_test.go | 20 +- msm/multi_epoch_node_test.go | 22 +- msm/verification.go | 80 ++++-- 7 files changed, 841 insertions(+), 53 deletions(-) diff --git a/msm/encoding.canoto.go b/msm/encoding.canoto.go index 51d3752e..9dcb9541 100644 --- a/msm/encoding.canoto.go +++ b/msm/encoding.canoto.go @@ -222,12 +222,16 @@ const ( canotoNumber_StateMachineMetadata__SimplexBlacklist = 3 canotoNumber_StateMachineMetadata__PChainHeight = 4 canotoNumber_StateMachineMetadata__Timestamp = 5 + canotoNumber_StateMachineMetadata__AuxiliaryInfo = 6 + canotoNumber_StateMachineMetadata__ICMEpochInfo = 7 canotoTag_StateMachineMetadata__SimplexEpochInfo = "\x0a" // canoto.Tag(canotoNumber_StateMachineMetadata__SimplexEpochInfo, canoto.Len) canotoTag_StateMachineMetadata__SimplexProtocolMetadata = "\x12" // canoto.Tag(canotoNumber_StateMachineMetadata__SimplexProtocolMetadata, canoto.Len) canotoTag_StateMachineMetadata__SimplexBlacklist = "\x1a" // canoto.Tag(canotoNumber_StateMachineMetadata__SimplexBlacklist, canoto.Len) canotoTag_StateMachineMetadata__PChainHeight = "\x20" // canoto.Tag(canotoNumber_StateMachineMetadata__PChainHeight, canoto.Varint) canotoTag_StateMachineMetadata__Timestamp = "\x28" // canoto.Tag(canotoNumber_StateMachineMetadata__Timestamp, canoto.Varint) + canotoTag_StateMachineMetadata__AuxiliaryInfo = "\x32" // canoto.Tag(canotoNumber_StateMachineMetadata__AuxiliaryInfo, canoto.Len) + canotoTag_StateMachineMetadata__ICMEpochInfo = "\x3a" // canoto.Tag(canotoNumber_StateMachineMetadata__ICMEpochInfo, canoto.Len) ) type canotoData_StateMachineMetadata struct { @@ -275,6 +279,26 @@ func (*StateMachineMetadata) CanotoSpec(types ...reflect.Type) *canoto.Spec { OneOf: "", TypeUint: canoto.SizeOf(zero.Timestamp), }, + canoto.FieldTypeFromField( + /*type inference:*/ (zero.AuxiliaryInfo), + /*FieldNumber: */ canotoNumber_StateMachineMetadata__AuxiliaryInfo, + /*Name: */ "AuxiliaryInfo", + /*FixedLength: */ 0, + /*Repeated: */ false, + /*OneOf: */ "", + /*Pointer: */ true, + /*types: */ types, + ), + canoto.FieldTypeFromField( + /*type inference:*/ (&zero.ICMEpochInfo), + /*FieldNumber: */ canotoNumber_StateMachineMetadata__ICMEpochInfo, + /*Name: */ "ICMEpochInfo", + /*FixedLength: */ 0, + /*Repeated: */ false, + /*OneOf: */ "", + /*Pointer: */ false, + /*types: */ types, + ), }, } s.CalculateCanotoCache() @@ -381,6 +405,52 @@ func (c *StateMachineMetadata) UnmarshalCanotoFrom(r canoto.Reader) error { if canoto.IsZero(c.Timestamp) { return canoto.ErrZeroValue } + case canotoNumber_StateMachineMetadata__AuxiliaryInfo: + if wireType != canoto.Len { + return canoto.ErrUnexpectedWireType + } + + // Read the bytes for the field. + originalUnsafe := r.Unsafe + r.Unsafe = true + var msgBytes []byte + if err := canoto.ReadBytes(&r, &msgBytes); err != nil { + return err + } + r.Unsafe = originalUnsafe + + // Unmarshal the field from the bytes. + remainingBytes := r.B + r.B = msgBytes + c.AuxiliaryInfo = canoto.MakePointer(c.AuxiliaryInfo) + if err := (c.AuxiliaryInfo).UnmarshalCanotoFrom(r); err != nil { + return err + } + r.B = remainingBytes + case canotoNumber_StateMachineMetadata__ICMEpochInfo: + if wireType != canoto.Len { + return canoto.ErrUnexpectedWireType + } + + // Read the bytes for the field. + originalUnsafe := r.Unsafe + r.Unsafe = true + var msgBytes []byte + if err := canoto.ReadBytes(&r, &msgBytes); err != nil { + return err + } + if len(msgBytes) == 0 { + return canoto.ErrZeroValue + } + r.Unsafe = originalUnsafe + + // Unmarshal the field from the bytes. + remainingBytes := r.B + r.B = msgBytes + if err := (&c.ICMEpochInfo).UnmarshalCanotoFrom(r); err != nil { + return err + } + r.B = remainingBytes default: return canoto.ErrUnknownField } @@ -401,6 +471,12 @@ func (c *StateMachineMetadata) ValidCanoto() bool { if !(&c.SimplexEpochInfo).ValidCanoto() { return false } + if c.AuxiliaryInfo != nil && !(c.AuxiliaryInfo).ValidCanoto() { + return false + } + if !(&c.ICMEpochInfo).ValidCanoto() { + return false + } return true } @@ -426,6 +502,15 @@ func (c *StateMachineMetadata) CalculateCanotoCache() { if !canoto.IsZero(c.Timestamp) { size += uint64(len(canotoTag_StateMachineMetadata__Timestamp)) + canoto.SizeUint(c.Timestamp) } + if c.AuxiliaryInfo != nil { + (c.AuxiliaryInfo).CalculateCanotoCache() + fieldSize := (c.AuxiliaryInfo).CachedCanotoSize() + size += uint64(len(canotoTag_StateMachineMetadata__AuxiliaryInfo)) + canoto.SizeUint(fieldSize) + fieldSize + } + (&c.ICMEpochInfo).CalculateCanotoCache() + if fieldSize := (&c.ICMEpochInfo).CachedCanotoSize(); fieldSize != 0 { + size += uint64(len(canotoTag_StateMachineMetadata__ICMEpochInfo)) + canoto.SizeUint(fieldSize) + fieldSize + } atomic.StoreUint64(&c.canotoData.size, size) } @@ -485,6 +570,411 @@ func (c *StateMachineMetadata) MarshalCanotoInto(w canoto.Writer) canoto.Writer canoto.Append(&w, canotoTag_StateMachineMetadata__Timestamp) canoto.AppendUint(&w, c.Timestamp) } + if c.AuxiliaryInfo != nil { + fieldSize := (c.AuxiliaryInfo).CachedCanotoSize() + canoto.Append(&w, canotoTag_StateMachineMetadata__AuxiliaryInfo) + canoto.AppendUint(&w, fieldSize) + w = (c.AuxiliaryInfo).MarshalCanotoInto(w) + } + if fieldSize := (&c.ICMEpochInfo).CachedCanotoSize(); fieldSize != 0 { + canoto.Append(&w, canotoTag_StateMachineMetadata__ICMEpochInfo) + canoto.AppendUint(&w, fieldSize) + w = (&c.ICMEpochInfo).MarshalCanotoInto(w) + } + return w +} + +const ( + canotoNumber_ICMEpochInfo__EpochStartTime = 1 + canotoNumber_ICMEpochInfo__EpochNumber = 2 + canotoNumber_ICMEpochInfo__PChainEpochHeight = 3 + + canotoTag_ICMEpochInfo__EpochStartTime = "\x08" // canoto.Tag(canotoNumber_ICMEpochInfo__EpochStartTime, canoto.Varint) + canotoTag_ICMEpochInfo__EpochNumber = "\x10" // canoto.Tag(canotoNumber_ICMEpochInfo__EpochNumber, canoto.Varint) + canotoTag_ICMEpochInfo__PChainEpochHeight = "\x18" // canoto.Tag(canotoNumber_ICMEpochInfo__PChainEpochHeight, canoto.Varint) +) + +type canotoData_ICMEpochInfo struct { + size uint64 +} + +// CanotoSpec returns the specification of this canoto message. +func (*ICMEpochInfo) CanotoSpec(...reflect.Type) *canoto.Spec { + var zero ICMEpochInfo + s := &canoto.Spec{ + Name: "ICMEpochInfo", + Fields: []canoto.FieldType{ + { + FieldNumber: canotoNumber_ICMEpochInfo__EpochStartTime, + Name: "EpochStartTime", + OneOf: "", + TypeUint: canoto.SizeOf(zero.EpochStartTime), + }, + { + FieldNumber: canotoNumber_ICMEpochInfo__EpochNumber, + Name: "EpochNumber", + OneOf: "", + TypeUint: canoto.SizeOf(zero.EpochNumber), + }, + { + FieldNumber: canotoNumber_ICMEpochInfo__PChainEpochHeight, + Name: "PChainEpochHeight", + OneOf: "", + TypeUint: canoto.SizeOf(zero.PChainEpochHeight), + }, + }, + } + s.CalculateCanotoCache() + return s +} + +// UnmarshalCanoto unmarshals a Canoto-encoded byte slice into the struct. +// +// During parsing, the canoto cache is saved. +func (c *ICMEpochInfo) UnmarshalCanoto(bytes []byte) error { + r := canoto.Reader{ + B: bytes, + } + return c.UnmarshalCanotoFrom(r) +} + +// UnmarshalCanotoFrom populates the struct from a [canoto.Reader]. Most users +// should just use UnmarshalCanoto. +// +// During parsing, the canoto cache is saved. +// +// This function enables configuration of reader options. +func (c *ICMEpochInfo) UnmarshalCanotoFrom(r canoto.Reader) error { + // Zero the struct before unmarshaling. + *c = ICMEpochInfo{} + atomic.StoreUint64(&c.canotoData.size, uint64(len(r.B))) + + var minField uint32 + for canoto.HasNext(&r) { + field, wireType, err := canoto.ReadTag(&r) + if err != nil { + return err + } + if field < minField { + return canoto.ErrInvalidFieldOrder + } + + switch field { + case canotoNumber_ICMEpochInfo__EpochStartTime: + if wireType != canoto.Varint { + return canoto.ErrUnexpectedWireType + } + + if err := canoto.ReadUint(&r, &c.EpochStartTime); err != nil { + return err + } + if canoto.IsZero(c.EpochStartTime) { + return canoto.ErrZeroValue + } + case canotoNumber_ICMEpochInfo__EpochNumber: + if wireType != canoto.Varint { + return canoto.ErrUnexpectedWireType + } + + if err := canoto.ReadUint(&r, &c.EpochNumber); err != nil { + return err + } + if canoto.IsZero(c.EpochNumber) { + return canoto.ErrZeroValue + } + case canotoNumber_ICMEpochInfo__PChainEpochHeight: + if wireType != canoto.Varint { + return canoto.ErrUnexpectedWireType + } + + if err := canoto.ReadUint(&r, &c.PChainEpochHeight); err != nil { + return err + } + if canoto.IsZero(c.PChainEpochHeight) { + return canoto.ErrZeroValue + } + default: + return canoto.ErrUnknownField + } + + minField = field + 1 + } + return nil +} + +// ValidCanoto validates that the struct can be correctly marshaled into the +// Canoto format. +// +// Specifically, ValidCanoto ensures: +// 1. All OneOfs are specified at most once. +// 2. All strings are valid utf-8. +// 3. All custom fields are ValidCanoto. +func (c *ICMEpochInfo) ValidCanoto() bool { + return true +} + +// CalculateCanotoCache populates size and OneOf caches based on the current +// values in the struct. +// +// It is not safe to copy this struct concurrently. +func (c *ICMEpochInfo) CalculateCanotoCache() { + var size uint64 + if !canoto.IsZero(c.EpochStartTime) { + size += uint64(len(canotoTag_ICMEpochInfo__EpochStartTime)) + canoto.SizeUint(c.EpochStartTime) + } + if !canoto.IsZero(c.EpochNumber) { + size += uint64(len(canotoTag_ICMEpochInfo__EpochNumber)) + canoto.SizeUint(c.EpochNumber) + } + if !canoto.IsZero(c.PChainEpochHeight) { + size += uint64(len(canotoTag_ICMEpochInfo__PChainEpochHeight)) + canoto.SizeUint(c.PChainEpochHeight) + } + atomic.StoreUint64(&c.canotoData.size, size) +} + +// CachedCanotoSize returns the previously calculated size of the Canoto +// representation from CalculateCanotoCache. +// +// If CalculateCanotoCache has not yet been called, it will return 0. +// +// If the struct has been modified since the last call to CalculateCanotoCache, +// the returned size may be incorrect. +func (c *ICMEpochInfo) CachedCanotoSize() uint64 { + return atomic.LoadUint64(&c.canotoData.size) +} + +// MarshalCanoto returns the Canoto representation of this struct. +// +// It is assumed that this struct is ValidCanoto. +// +// It is not safe to copy this struct concurrently. +func (c *ICMEpochInfo) MarshalCanoto() []byte { + c.CalculateCanotoCache() + w := canoto.Writer{ + B: make([]byte, 0, c.CachedCanotoSize()), + } + w = c.MarshalCanotoInto(w) + return w.B +} + +// MarshalCanotoInto writes the struct into a [canoto.Writer] and returns the +// resulting [canoto.Writer]. Most users should just use MarshalCanoto. +// +// It is assumed that CalculateCanotoCache has been called since the last +// modification to this struct. +// +// It is assumed that this struct is ValidCanoto. +// +// It is not safe to copy this struct concurrently. +func (c *ICMEpochInfo) MarshalCanotoInto(w canoto.Writer) canoto.Writer { + if !canoto.IsZero(c.EpochStartTime) { + canoto.Append(&w, canotoTag_ICMEpochInfo__EpochStartTime) + canoto.AppendUint(&w, c.EpochStartTime) + } + if !canoto.IsZero(c.EpochNumber) { + canoto.Append(&w, canotoTag_ICMEpochInfo__EpochNumber) + canoto.AppendUint(&w, c.EpochNumber) + } + if !canoto.IsZero(c.PChainEpochHeight) { + canoto.Append(&w, canotoTag_ICMEpochInfo__PChainEpochHeight) + canoto.AppendUint(&w, c.PChainEpochHeight) + } + return w +} + +const ( + canotoNumber_AuxiliaryInfo__Info = 1 + canotoNumber_AuxiliaryInfo__PrevAuxInfoSeq = 2 + canotoNumber_AuxiliaryInfo__ApplicationID = 3 + + canotoTag_AuxiliaryInfo__Info = "\x0a" // canoto.Tag(canotoNumber_AuxiliaryInfo__Info, canoto.Len) + canotoTag_AuxiliaryInfo__PrevAuxInfoSeq = "\x10" // canoto.Tag(canotoNumber_AuxiliaryInfo__PrevAuxInfoSeq, canoto.Varint) + canotoTag_AuxiliaryInfo__ApplicationID = "\x18" // canoto.Tag(canotoNumber_AuxiliaryInfo__ApplicationID, canoto.Varint) +) + +type canotoData_AuxiliaryInfo struct { + size uint64 +} + +// CanotoSpec returns the specification of this canoto message. +func (*AuxiliaryInfo) CanotoSpec(...reflect.Type) *canoto.Spec { + var zero AuxiliaryInfo + s := &canoto.Spec{ + Name: "AuxiliaryInfo", + Fields: []canoto.FieldType{ + { + FieldNumber: canotoNumber_AuxiliaryInfo__Info, + Name: "Info", + OneOf: "", + TypeBytes: true, + }, + { + FieldNumber: canotoNumber_AuxiliaryInfo__PrevAuxInfoSeq, + Name: "PrevAuxInfoSeq", + OneOf: "", + TypeUint: canoto.SizeOf(zero.PrevAuxInfoSeq), + }, + { + FieldNumber: canotoNumber_AuxiliaryInfo__ApplicationID, + Name: "ApplicationID", + OneOf: "", + TypeUint: canoto.SizeOf(zero.ApplicationID), + }, + }, + } + s.CalculateCanotoCache() + return s +} + +// UnmarshalCanoto unmarshals a Canoto-encoded byte slice into the struct. +// +// During parsing, the canoto cache is saved. +func (c *AuxiliaryInfo) UnmarshalCanoto(bytes []byte) error { + r := canoto.Reader{ + B: bytes, + } + return c.UnmarshalCanotoFrom(r) +} + +// UnmarshalCanotoFrom populates the struct from a [canoto.Reader]. Most users +// should just use UnmarshalCanoto. +// +// During parsing, the canoto cache is saved. +// +// This function enables configuration of reader options. +func (c *AuxiliaryInfo) UnmarshalCanotoFrom(r canoto.Reader) error { + // Zero the struct before unmarshaling. + *c = AuxiliaryInfo{} + atomic.StoreUint64(&c.canotoData.size, uint64(len(r.B))) + + var minField uint32 + for canoto.HasNext(&r) { + field, wireType, err := canoto.ReadTag(&r) + if err != nil { + return err + } + if field < minField { + return canoto.ErrInvalidFieldOrder + } + + switch field { + case canotoNumber_AuxiliaryInfo__Info: + if wireType != canoto.Len { + return canoto.ErrUnexpectedWireType + } + + if err := canoto.ReadBytes(&r, &c.Info); err != nil { + return err + } + if len(c.Info) == 0 { + return canoto.ErrZeroValue + } + case canotoNumber_AuxiliaryInfo__PrevAuxInfoSeq: + if wireType != canoto.Varint { + return canoto.ErrUnexpectedWireType + } + + if err := canoto.ReadUint(&r, &c.PrevAuxInfoSeq); err != nil { + return err + } + if canoto.IsZero(c.PrevAuxInfoSeq) { + return canoto.ErrZeroValue + } + case canotoNumber_AuxiliaryInfo__ApplicationID: + if wireType != canoto.Varint { + return canoto.ErrUnexpectedWireType + } + + if err := canoto.ReadUint(&r, &c.ApplicationID); err != nil { + return err + } + if canoto.IsZero(c.ApplicationID) { + return canoto.ErrZeroValue + } + default: + return canoto.ErrUnknownField + } + + minField = field + 1 + } + return nil +} + +// ValidCanoto validates that the struct can be correctly marshaled into the +// Canoto format. +// +// Specifically, ValidCanoto ensures: +// 1. All OneOfs are specified at most once. +// 2. All strings are valid utf-8. +// 3. All custom fields are ValidCanoto. +func (c *AuxiliaryInfo) ValidCanoto() bool { + return true +} + +// CalculateCanotoCache populates size and OneOf caches based on the current +// values in the struct. +// +// It is not safe to copy this struct concurrently. +func (c *AuxiliaryInfo) CalculateCanotoCache() { + var size uint64 + if len(c.Info) != 0 { + size += uint64(len(canotoTag_AuxiliaryInfo__Info)) + canoto.SizeBytes(c.Info) + } + if !canoto.IsZero(c.PrevAuxInfoSeq) { + size += uint64(len(canotoTag_AuxiliaryInfo__PrevAuxInfoSeq)) + canoto.SizeUint(c.PrevAuxInfoSeq) + } + if !canoto.IsZero(c.ApplicationID) { + size += uint64(len(canotoTag_AuxiliaryInfo__ApplicationID)) + canoto.SizeUint(c.ApplicationID) + } + atomic.StoreUint64(&c.canotoData.size, size) +} + +// CachedCanotoSize returns the previously calculated size of the Canoto +// representation from CalculateCanotoCache. +// +// If CalculateCanotoCache has not yet been called, it will return 0. +// +// If the struct has been modified since the last call to CalculateCanotoCache, +// the returned size may be incorrect. +func (c *AuxiliaryInfo) CachedCanotoSize() uint64 { + return atomic.LoadUint64(&c.canotoData.size) +} + +// MarshalCanoto returns the Canoto representation of this struct. +// +// It is assumed that this struct is ValidCanoto. +// +// It is not safe to copy this struct concurrently. +func (c *AuxiliaryInfo) MarshalCanoto() []byte { + c.CalculateCanotoCache() + w := canoto.Writer{ + B: make([]byte, 0, c.CachedCanotoSize()), + } + w = c.MarshalCanotoInto(w) + return w.B +} + +// MarshalCanotoInto writes the struct into a [canoto.Writer] and returns the +// resulting [canoto.Writer]. Most users should just use MarshalCanoto. +// +// It is assumed that CalculateCanotoCache has been called since the last +// modification to this struct. +// +// It is assumed that this struct is ValidCanoto. +// +// It is not safe to copy this struct concurrently. +func (c *AuxiliaryInfo) MarshalCanotoInto(w canoto.Writer) canoto.Writer { + if len(c.Info) != 0 { + canoto.Append(&w, canotoTag_AuxiliaryInfo__Info) + canoto.AppendBytes(&w, c.Info) + } + if !canoto.IsZero(c.PrevAuxInfoSeq) { + canoto.Append(&w, canotoTag_AuxiliaryInfo__PrevAuxInfoSeq) + canoto.AppendUint(&w, c.PrevAuxInfoSeq) + } + if !canoto.IsZero(c.ApplicationID) { + canoto.Append(&w, canotoTag_AuxiliaryInfo__ApplicationID) + canoto.AppendUint(&w, c.ApplicationID) + } return w } diff --git a/msm/encoding.go b/msm/encoding.go index 4e5e1a48..0f2ec601 100644 --- a/msm/encoding.go +++ b/msm/encoding.go @@ -40,10 +40,70 @@ type StateMachineMetadata struct { PChainHeight uint64 `canoto:"uint,4"` // Timestamp is the time when the block is being built, in milliseconds since Unix epoch. Timestamp uint64 `canoto:"uint,5"` + // AuxiliaryInfo is application-specific information that the StateMachine doesn't need to understand, + // but can be used by applications that care about epoch changes, such as threshold distributed public key generation. + AuxiliaryInfo *AuxiliaryInfo `canoto:"pointer,6"` + // ICMEpochInfo is the metadata that the StateMachine uses for ICM epoching. + ICMEpochInfo ICMEpochInfo `canoto:"value,7"` + canotoData canotoData_StateMachineMetadata } +// ICMEpochInfo is metadata used for the ICM protocol. +// The StateMachine maintains this metadata in a similar fashion to proposerVM. +type ICMEpochInfo struct { + EpochStartTime uint64 `canoto:"uint,1"` + EpochNumber uint64 `canoto:"uint,2"` + PChainEpochHeight uint64 `canoto:"uint,3"` + + canotoData canotoData_ICMEpochInfo +} + +func (ei *ICMEpochInfo) Equal(other *ICMEpochInfo) bool { + if ei == nil { + return other == nil + } + if other == nil { + return ei == nil + } + return ei.EpochStartTime == other.EpochStartTime && ei.EpochNumber == other.EpochNumber && ei.PChainEpochHeight == other.PChainEpochHeight +} + +// AppID is an identifier for applications that care about epoch changes. +type AppID uint32 + +// AuxiliaryInfo defines application-specific information for applications that might care about epoch change, +// such as threshold distributed public key generation. +type AuxiliaryInfo struct { + // Info is opaque bytes that can be used by applications to encode any information that describes + // the current state for the application. + Info []byte `canoto:"bytes,1"` + // PrevAuxInfoSeq is a sequence number that applications can use to find previous AuxiliaryInfo in the chain. + // It is zero if this is the first AuxiliaryInfo for this epoch. + PrevAuxInfoSeq uint64 `canoto:"uint,2"` + // ApplicationID is an identifier that identifies the application. + // Can be used for backward-compatibility and upgrade purposes. + ApplicationID AppID `canoto:"uint,3"` + + canotoData canotoData_AuxiliaryInfo +} + +func (ai *AuxiliaryInfo) IsZero() bool { + var zero AuxiliaryInfo + return ai.Equal(&zero) +} + +func (ai *AuxiliaryInfo) Equal(a *AuxiliaryInfo) bool { + if ai == nil { + return a == nil + } + if a == nil { + return ai == nil + } + return bytes.Equal(ai.Info, a.Info) && ai.PrevAuxInfoSeq == a.PrevAuxInfoSeq && ai.ApplicationID == a.ApplicationID +} + // SimplexEpochInfo is metadata used by the StateMachine. type SimplexEpochInfo struct { // PChainReferenceHeight is the P-Chain height that the StateMachine uses as a reference for the current epoch. diff --git a/msm/helpers_test.go b/msm/helpers_test.go index dcb36d02..93a99533 100644 --- a/msm/helpers_test.go +++ b/msm/helpers_test.go @@ -122,6 +122,15 @@ func (ka *keyAggregator) AggregateKeys(keys ...[]byte) ([]byte, error) { return aggregated, nil } +type auxiliaryInfoGenerator struct { + info []byte + appID AppID +} + +func (g *auxiliaryInfoGenerator) GenerateAuxiliaryInfo(_ []byte) ([]byte, AppID) { + return g.info, g.appID +} + var ( genesisBlock = StateMachineBlock{ // Genesis block metadata has all zero values @@ -137,13 +146,14 @@ var ( ) type testConfig struct { - blockStore blockStore - approvalsRetriever approvalsRetriever - signatureVerifier signatureVerifier - signatureAggregator signatureAggregator - blockBuilder blockBuilder - keyAggregator keyAggregator - validatorSetRetriever validatorSetRetriever + blockStore blockStore + approvalsRetriever approvalsRetriever + signatureVerifier signatureVerifier + signatureAggregator signatureAggregator + blockBuilder blockBuilder + keyAggregator keyAggregator + validatorSetRetriever validatorSetRetriever + auxiliaryInfoGenerator auxiliaryInfoGenerator } func newStateMachine(t *testing.T) (*StateMachine, *testConfig) { @@ -156,6 +166,13 @@ func newStateMachine(t *testing.T) (*StateMachine, *testConfig) { } sm := NewStateMachine(Config{ + ComputeICMEpoch: func(config UpgradeConfig, input ICMEpochInput) ICMEpoch { + return ICMEpoch{ + EpochNumber: 0, + EpochStartTime: 0, + PChainEpochHeight: 0, + } + }, GetTime: time.Now, TimeSkewLimit: time.Second * 5, Logger: testutil.MakeLogger(t), @@ -174,6 +191,7 @@ func newStateMachine(t *testing.T) (*StateMachine, *testConfig) { }, GetValidatorSet: testConfig.validatorSetRetriever.getValidatorSet, PChainProgressListener: &noOpPChainListener{}, + AuxiliaryInfoGenerator: &testConfig.auxiliaryInfoGenerator, }) return sm, &testConfig } diff --git a/msm/msm.go b/msm/msm.go index 96c04d92..146652a6 100644 --- a/msm/msm.go +++ b/msm/msm.go @@ -17,6 +17,30 @@ import ( "go.uber.org/zap" ) +// ICMEpochInput defines the input for computing the ICM Epoch information for the next block. +type ICMEpochInput struct { + // ParentPChainHeight is the P-chain height recorded in the parent block. + ParentPChainHeight uint64 + // ParentTimestamp is the timestamp of the parent block. + ParentTimestamp time.Time + // ChildTimestamp is the timestamp of the block being built. + ChildTimestamp time.Time + // ParentEpoch is the ICM epoch information from the parent block. + ParentEpoch ICMEpoch +} + +// ICMEpoch defines the ICM epoch information that is maintained by the StateMachine and used for the ICM protocol. +// The Statemachine maintains this information identically to how the proposerVM maintains this information, +// and it does so by building the ICMEpochInput and then passing it into the StateMachine's ComputeICMEpoch function. +type ICMEpoch struct { + // EpochStartTime is the Unix timestamp when this ICM epoch started. + EpochStartTime uint64 + // EpochNumber is the sequential identifier of this ICM epoch. + EpochNumber uint64 + // PChainEpochHeight is the P-chain height associated with this ICM epoch. + PChainEpochHeight uint64 +} + // A StateMachineBlock is a representation of a parsed OuterBlock, containing the inner block and the metadata. type StateMachineBlock struct { // InnerBlock is the VM-level block, or nil if this is a block without an inner block (e.g., a Telock block). @@ -40,6 +64,25 @@ func (smb *StateMachineBlock) Digest() [32]byte { return sha256.Sum256(combined) } +// AuxiliaryInfoGenerator generates the AuxiliaryInfo for the next block in case the MSM is collecting approvals. +// The AuxiliaryInfo is opaque information that is included in the block metadata +// and is contributed by the block builder. +type AuxiliaryInfoGenerator interface { + // GenerateAuxiliaryInfo generates the AuxiliaryInfo for the next block and returns it along with the AppID. + // The AppID is used to identify the type of the AuxiliaryInfo and it exists for the purpose of backwards compatibility. + // The input is the previous AuxiliaryInfo, if applicable, which can be used to determine if the current node has already contributed + // its own AuxiliaryInfo for the current epoch transition. + GenerateAuxiliaryInfo([]byte) ([]byte, AppID) +} + +// AuxiliaryInfoVerifier verifies the AuxiliaryInfo included in the block metadata when we are collecting approvals. +type AuxiliaryInfoVerifier interface { + // VerifyAuxiliaryInfo verifies the given new AuxiliaryInfo included in the block metadata. + // The given prevAuxInfos are the AuxiliaryInfo from previous blocks in the current epoch transition, + // which can be used for verification. + VerifyAuxiliaryInfo(auxInfo *AuxiliaryInfo, prevAuxInfos []AuxiliaryInfo) error +} + // ApprovalsRetriever retrieves the approvals from validators of the next epoch for the epoch change. type ApprovalsRetriever interface { RetrieveApprovals() ValidatorSetApprovals @@ -62,6 +105,9 @@ type KeyAggregator interface { AggregateKeys(keys ...[]byte) ([]byte, error) } +// ICMEpochTransition computes the next ICM epoch given the current upgrade configuration and epoch input. +type ICMEpochTransition func(UpgradeConfig, ICMEpochInput) ICMEpoch + // ValidatorSetRetriever retrieves the validator set at a given P-chain height. type ValidatorSetRetriever func(pChainHeight uint64) (NodeBLSMappings, error) @@ -83,6 +129,10 @@ type BlockBuilder interface { } type Config struct { + // AuxiliaryInfoVerifier verifies the AuxiliaryInfo included in the block metadata when we are collecting approvals for an epoch transition. + AuxiliaryInfoVerifier AuxiliaryInfoVerifier + // AuxiliaryInfoGenerator generates the AuxiliaryInfo for the next block when we are collecting approvals for an epoch transition. + AuxiliaryInfoGenerator AuxiliaryInfoGenerator // LatestPersistedHeight is the height of the most recently persisted block. LatestPersistedHeight uint64 // MaxBlockBuildingWaitTime is the maximum duration to wait for the VM to build a block @@ -92,6 +142,8 @@ type Config struct { TimeSkewLimit time.Duration // GetTime returns the current time. GetTime func() time.Time + // ComputeICMEpoch computes the ICM epoch for the next block. + ComputeICMEpoch ICMEpochTransition // GetPChainHeight returns the latest known P-chain height. GetPChainHeight func() uint64 // GetUpgrades returns the current upgrade configuration. @@ -145,6 +197,11 @@ func NewStateMachine(config Config) *StateMachine { } out.verifiers = []verifier{ + &AuxInfoVerifier{}, + &icmEpochInfoVerifier{ + computeICMEpoch: config.ComputeICMEpoch, + getUpdates: config.GetUpgrades, + }, &pChainHeightVerifier{ getPChainHeight: getPChainHeight, }, @@ -360,7 +417,7 @@ func (sm *StateMachine) buildBlockNormalOp(ctx context.Context, parentBlock Stat // Initiate the epoch transition by setting the next P-chain reference height for the new epoch info, // and build a block without an inner block. newSimplexEpochInfo.NextPChainReferenceHeight = pChainHeight - return sm.wrapBlock(parentBlock, nil, newSimplexEpochInfo, pChainHeight, simplexMetadata, blacklist), nil + return sm.wrapBlock(parentBlock, nil, newSimplexEpochInfo, pChainHeight, simplexMetadata, blacklist, nil), nil case blockBuildingDecisionContextCanceled: return nil, ctx.Err() default: @@ -377,8 +434,7 @@ func (sm *StateMachine) buildBlockAndMaybeTransitionEpoch( pChainHeight uint64, transitionEpoch bool, ) (*StateMachineBlock, error) { - // TODO: This P-chain height should be taken from the ICM epoch - childBlock, err := sm.BlockBuilder.BuildBlock(ctx, pChainHeight) + childBlock, err := sm.BlockBuilder.BuildBlock(ctx, parentBlock.Metadata.ICMEpochInfo.PChainEpochHeight) if err != nil { return nil, err } @@ -389,7 +445,7 @@ func (sm *StateMachine) buildBlockAndMaybeTransitionEpoch( newSimplexEpochInfo.NextPChainReferenceHeight = pChainHeight } - return sm.wrapBlock(parentBlock, childBlock, newSimplexEpochInfo, pChainHeight, simplexMetadata, blacklist), nil + return sm.wrapBlock(parentBlock, childBlock, newSimplexEpochInfo, pChainHeight, simplexMetadata, blacklist, nil), nil } // buildBlockZero builds the first ever block for Simplex, @@ -466,11 +522,17 @@ func (sm *StateMachine) verifyBlockZero(ctx context.Context, block *StateMachine return fmt.Errorf("invalid SimplexEpochInfo: expected %v, got %v", expectedSimplexEpochInfo, simplexEpochInfo) } - _, err = sm.verifyZeroBlockTimestamp(block, prevBlock) + proposedTime, err := sm.verifyZeroBlockTimestamp(block, prevBlock) if err != nil { return err } + // Verify ICM epoch info + expectedICMInfo := nextICMEpochInfo(prevBlock.Metadata, block.InnerBlock != nil, sm.GetUpgrades, sm.ComputeICMEpoch, proposedTime) + if !expectedICMInfo.Equal(&block.Metadata.ICMEpochInfo) { + return fmt.Errorf("expected ICM epoch info to be %v but got %v", expectedICMInfo, block.Metadata.ICMEpochInfo) + } + if block.InnerBlock == nil { return nil } @@ -521,10 +583,11 @@ func (sm *StateMachine) buildBlockCollectingApprovals(ctx context.Context, paren // We retrieve approvals that validators have sent us for the next epoch. // These approvals are signed by validators of the next epoch. approvalsFromPeers := sm.ApprovalsRetriever.RetrieveApprovals() + auxInfo := parentBlock.Metadata.AuxiliaryInfo nextPChainHeight := newSimplexEpochInfo.NextPChainReferenceHeight prevNextEpochApprovals := parentBlock.Metadata.SimplexEpochInfo.NextEpochApprovals - newApprovals, err := computeNewApprovals(prevNextEpochApprovals, approvalsFromPeers, nextPChainHeight, sm.SignatureAggregator, validators) + newApprovals, err := computeNewApprovals(prevNextEpochApprovals, auxInfo, approvalsFromPeers, nextPChainHeight, sm.SignatureAggregator, validators) if err != nil { return nil, err } @@ -559,8 +622,7 @@ func (sm *StateMachine) buildBlockImpatiently(ctx context.Context, parentBlock S start := time.Now() - // TODO: This P-chain height should be taken from the ICM epoch - childBlock, err := sm.BlockBuilder.BuildBlock(impatientContext, pChainHeight) + childBlock, err := sm.BlockBuilder.BuildBlock(impatientContext, parentBlock.Metadata.ICMEpochInfo.PChainEpochHeight) if err != nil && impatientContext.Err() == nil { // If we got an error building the block, and we didn't time out, log the error but continue building the block without the inner block, // so that we can continue making progress and not get stuck on a single block. @@ -570,7 +632,67 @@ func (sm *StateMachine) buildBlockImpatiently(ctx context.Context, parentBlock S sm.Logger.Debug("Timed out waiting for block to be built, building block without inner block instead", zap.Duration("elapsed", time.Since(start)), zap.Duration("maxBlockBuildingWaitTime", sm.MaxBlockBuildingWaitTime)) } - return sm.wrapBlock(parentBlock, childBlock, simplexEpochInfo, pChainHeight, simplexMetadata, blacklist), nil + + newAuxInfo, err := sm.generateAuxInfoForNextBlock(parentBlock, simplexMetadata, simplexEpochInfo) + if err != nil { + return nil, err + } + + return sm.wrapBlock(parentBlock, childBlock, simplexEpochInfo, pChainHeight, simplexMetadata, blacklist, newAuxInfo), nil +} + +func (sm *StateMachine) generateAuxInfoForNextBlock(parentBlock StateMachineBlock, simplexMetadata simplex.ProtocolMetadata, simplexEpochInfo SimplexEpochInfo) (*AuxiliaryInfo, error) { + // The zero block shouldn't have auxiliary info. + if parentBlock.Metadata.SimplexEpochInfo.EpochNumber == 0 { + return nil, nil + } + + // The sealing block also shouldn't have auxiliary info, as we have decided that we can seal the epoch + // based on the auxiliary info of the previous block. + if simplexEpochInfo.BlockValidationDescriptor != nil { + return nil, nil + } + + parentSeq := simplexMetadata.Seq - 1 + prevAuxInfoSeq, prevAuxInfo, err := sm.retrievePreviousAuxiliaryInfo(parentBlock, parentSeq) + if err != nil { + return nil, fmt.Errorf("failed to retrieve previous AuxiliaryInfo: %w", err) + } + + auxInfo, appID := sm.AuxiliaryInfoGenerator.GenerateAuxiliaryInfo(prevAuxInfo) + + newAuxInfo := &AuxiliaryInfo{ + PrevAuxInfoSeq: prevAuxInfoSeq, + ApplicationID: appID, + Info: auxInfo, + } + return newAuxInfo, nil +} + +func (sm *StateMachine) retrievePreviousAuxiliaryInfo(parentBlock StateMachineBlock, parentSeq uint64) (uint64, []byte, error) { + prevAuxInfo := parentBlock.Metadata.AuxiliaryInfo + + if prevAuxInfo == nil || prevAuxInfo.IsZero() { + return 0, nil, nil + } + + if len(prevAuxInfo.Info) > 0 { + return parentSeq, prevAuxInfo.Info, nil + } + + if prevAuxInfo.PrevAuxInfoSeq > 0 { + block, _, err := sm.GetBlock(prevAuxInfo.PrevAuxInfoSeq, [32]byte{}) + if err != nil { + return 0, nil, fmt.Errorf("failed to retrieve block for previous AuxiliaryInfo sequence %d: %w", prevAuxInfo.PrevAuxInfoSeq, err) + } + if block.Metadata.AuxiliaryInfo == nil { + return 0, nil, fmt.Errorf("block for previous AuxiliaryInfo sequence %d has no AuxiliaryInfo in metadata", prevAuxInfo.PrevAuxInfoSeq) + } + return prevAuxInfo.PrevAuxInfoSeq, block.Metadata.AuxiliaryInfo.Info, nil + } + + // Else, prevAuxInfo.PrevAuxInfoSeq is 0 and prevAuxInfo.Info is empty. + return 0, nil, nil } func (sm *StateMachine) createSealingBlock(ctx context.Context, parentBlock StateMachineBlock, simplexMetadata simplex.ProtocolMetadata, blacklist []byte, simplexEpochInfo SimplexEpochInfo, pChainHeight uint64) (*StateMachineBlock, error) { @@ -612,11 +734,13 @@ func (sm *StateMachine) createSealingBlock(ctx context.Context, parentBlock Stat } // wrapBlock creates a new StateMachineBlock by wrapping the VM block (if applicable) and adding the appropriate metadata. -func (sm *StateMachine) wrapBlock(parentBlock StateMachineBlock, childBlock VMBlock, newSimplexEpochInfo SimplexEpochInfo, pChainHeight uint64, simplexMetadata simplex.ProtocolMetadata, blacklist []byte) *StateMachineBlock { +func (sm *StateMachine) wrapBlock(parentBlock StateMachineBlock, childBlock VMBlock, newSimplexEpochInfo SimplexEpochInfo, pChainHeight uint64, simplexMetadata simplex.ProtocolMetadata, blacklist []byte, auxInfo *AuxiliaryInfo) *StateMachineBlock { parentMetadata := parentBlock.Metadata timestamp := parentMetadata.Timestamp hasChildBlock := childBlock != nil + getUpgrades := sm.GetUpgrades + icmEpochTransition := sm.ComputeICMEpoch var newTimestamp time.Time if hasChildBlock { @@ -624,14 +748,18 @@ func (sm *StateMachine) wrapBlock(parentBlock StateMachineBlock, childBlock VMBl timestamp = uint64(newTimestamp.UnixMilli()) } + icmEpochInfo := nextICMEpochInfo(parentMetadata, hasChildBlock, getUpgrades, icmEpochTransition, newTimestamp) + return &StateMachineBlock{ InnerBlock: childBlock, Metadata: StateMachineMetadata{ + AuxiliaryInfo: auxInfo, Timestamp: timestamp, SimplexProtocolMetadata: simplexMetadata.Bytes(), SimplexBlacklist: blacklist, SimplexEpochInfo: newSimplexEpochInfo, PChainHeight: pChainHeight, + ICMEpochInfo: icmEpochInfo, }, } } @@ -670,7 +798,7 @@ func (sm *StateMachine) buildBlockEpochSealed(ctx context.Context, parentBlock S if !isSealingBlockFinalized { pChainHeight := parentBlock.Metadata.PChainHeight - return sm.wrapBlock(parentBlock, nil, newSimplexEpochInfo, pChainHeight, simplexMetadata, blacklist), nil + return sm.wrapBlock(parentBlock, nil, newSimplexEpochInfo, pChainHeight, simplexMetadata, blacklist, nil), nil } // Else, we build a block for the new epoch. @@ -682,13 +810,12 @@ func (sm *StateMachine) buildBlockEpochSealed(ctx context.Context, parentBlock S PrevVMBlockSeq: computePrevVMBlockSeq(parentBlock, prevBlockSeq), } - // TODO: This P-chain height should be taken from the ICM epoch - childBlock, err := sm.BlockBuilder.BuildBlock(ctx, sm.GetPChainHeight()) + childBlock, err := sm.BlockBuilder.BuildBlock(ctx, parentBlock.Metadata.ICMEpochInfo.PChainEpochHeight) if err != nil { return nil, err } - return sm.wrapBlock(parentBlock, childBlock, newSimplexEpochInfo, parentBlock.Metadata.PChainHeight, simplexMetadata, blacklist), nil + return sm.wrapBlock(parentBlock, childBlock, newSimplexEpochInfo, parentBlock.Metadata.PChainHeight, simplexMetadata, blacklist, nil), nil } // constructSimplexZeroBlock constructs the SimplexEpochInfo for the zero block, which is the first ever block built by Simplex. @@ -714,6 +841,7 @@ func constructSimplexZeroBlock(pChainHeight uint64, newValidatorSet NodeBLSMappi func computeNewApprovals( nextEpochApprovals *NextEpochApprovals, + auxInfo *AuxiliaryInfo, approvalsFromPeers ValidatorSetApprovals, pChainHeight uint64, aggregator SignatureAggregator, @@ -872,6 +1000,40 @@ func computeTotalWeight(validators NodeBLSMappings) (int64, error) { } return int64(totalWeight), nil } + +func computeICMEpochInfo(getUpgrades func() UpgradeConfig, icmEpochTransition ICMEpochTransition, parentMetadata StateMachineMetadata, parentTimestamp, childTimestamp time.Time) ICMEpoch { + upgrades := getUpgrades() + + icmEpoch := icmEpochTransition(upgrades, ICMEpochInput{ + ParentPChainHeight: parentMetadata.PChainHeight, + ParentTimestamp: parentTimestamp, + ChildTimestamp: childTimestamp, + ParentEpoch: ICMEpoch{ + EpochStartTime: parentMetadata.ICMEpochInfo.EpochStartTime, + EpochNumber: parentMetadata.ICMEpochInfo.EpochNumber, + PChainEpochHeight: parentMetadata.ICMEpochInfo.PChainEpochHeight, + }, + }) + return icmEpoch +} + + + +func nextICMEpochInfo(parentMetadata StateMachineMetadata, hasChildBlock bool, getUpgrades func() UpgradeConfig, icmEpochTransition ICMEpochTransition, newTimestamp time.Time) ICMEpochInfo { + icmEpochInfo := parentMetadata.ICMEpochInfo + + if hasChildBlock { + parentTimestamp := time.UnixMilli(int64(parentMetadata.Timestamp)) + icmEpoch := computeICMEpochInfo(getUpgrades, icmEpochTransition, parentMetadata, parentTimestamp, newTimestamp) + icmEpochInfo = ICMEpochInfo{ + EpochStartTime: icmEpoch.EpochStartTime, + EpochNumber: icmEpoch.EpochNumber, + PChainEpochHeight: icmEpoch.PChainEpochHeight, + } + } + return icmEpochInfo +} + func findFirstSimplexBlock(getBlock BlockRetriever, endHeight uint64) (uint64, error) { var haltError error diff --git a/msm/msm_test.go b/msm/msm_test.go index 647f97b0..7a4a6131 100644 --- a/msm/msm_test.go +++ b/msm/msm_test.go @@ -7,6 +7,7 @@ import ( "bytes" "context" "crypto/rand" + "crypto/sha256" "fmt" "testing" "time" @@ -642,6 +643,7 @@ func TestMSMFullEpochLifecycle(t *testing.T) { Signature: sig, }, }, + AuxiliaryInfo: &AuxiliaryInfo{}, }, }, block4) addBlock(md.Seq, *block4, nil) @@ -649,11 +651,15 @@ func TestMSMFullEpochLifecycle(t *testing.T) { require.NoError(t, smVerify.VerifyBlock(context.Background(), block4)) // ----- Step 5: Second collecting block (2/3 approvals, still not enough since threshold is strictly > 2/3) ----- + // Once block4 carries AuxiliaryInfo (with empty Info bytes), peers must agree on + // the digest of those bytes — sha256(nil) — instead of the all-zero digest. + emptyAuxDigest := sha256.Sum256(nil) approvalsResult = ValidatorSetApprovals{ { - NodeID: node2, - PChainHeight: pChainHeight2, - Signature: []byte("sig2"), + NodeID: node2, + PChainHeight: pChainHeight2, + Signature: []byte("sig2"), + AuxInfoSeqDigest: emptyAuxDigest, }, } @@ -683,6 +689,7 @@ func TestMSMFullEpochLifecycle(t *testing.T) { Signature: sig, }, }, + AuxiliaryInfo: &AuxiliaryInfo{PrevAuxInfoSeq: baseSeq + 4}, }, }, block5) addBlock(md.Seq, *block5, nil) @@ -692,9 +699,10 @@ func TestMSMFullEpochLifecycle(t *testing.T) { // ----- Step 6: Sealing block (3/3 approvals, enough to seal) ----- approvalsResult = ValidatorSetApprovals{ { - NodeID: node3, - PChainHeight: pChainHeight2, - Signature: []byte("sig3"), + NodeID: node3, + PChainHeight: pChainHeight2, + Signature: []byte("sig3"), + AuxInfoSeqDigest: emptyAuxDigest, }, } diff --git a/msm/multi_epoch_node_test.go b/msm/multi_epoch_node_test.go index 3bdcc38c..6b4ec955 100644 --- a/msm/multi_epoch_node_test.go +++ b/msm/multi_epoch_node_test.go @@ -6,6 +6,7 @@ package metadata import ( "context" "crypto/rand" + "crypto/sha256" "fmt" "sync/atomic" "testing" @@ -15,6 +16,11 @@ import ( "github.com/stretchr/testify/require" ) +// emptyAuxInfoDigest is the digest peers must agree with once a parent block +// carries AuxiliaryInfo whose Info bytes are empty/nil — which is what the +// test's auxiliaryInfoGenerator produces. +var emptyAuxInfoDigest = sha256.Sum256(nil) + func TestStateMachineEpochTransition(t *testing.T) { validatorSetRetriever := validatorSetRetriever{ resultMap: map[uint64]NodeBLSMappings{ @@ -47,11 +53,11 @@ func TestStateMachineEpochTransition(t *testing.T) { node.act() if flipCoin() { node.sm.ApprovalsRetriever = &approvalsRetriever{ - result: []ValidatorSetApproval{{NodeID: [20]byte{1}, PChainHeight: 200, Signature: []byte{1}, AuxInfoSeqDigest: [32]byte{}}}, + result: []ValidatorSetApproval{{NodeID: [20]byte{1}, PChainHeight: 200, Signature: []byte{1}, AuxInfoSeqDigest: emptyAuxInfoDigest}}, } } else { node.sm.ApprovalsRetriever = &approvalsRetriever{ - result: []ValidatorSetApproval{{NodeID: [20]byte{2}, PChainHeight: 200, Signature: []byte{2}, AuxInfoSeqDigest: [32]byte{}}}, + result: []ValidatorSetApproval{{NodeID: [20]byte{2}, PChainHeight: 200, Signature: []byte{2}, AuxInfoSeqDigest: emptyAuxInfoDigest}}, } } } @@ -68,11 +74,11 @@ func TestStateMachineEpochTransition(t *testing.T) { node.act() if flipCoin() { node.sm.ApprovalsRetriever = &approvalsRetriever{ - result: []ValidatorSetApproval{{NodeID: [20]byte{2}, PChainHeight: 300, Signature: []byte{2}, AuxInfoSeqDigest: [32]byte{}}}, + result: []ValidatorSetApproval{{NodeID: [20]byte{2}, PChainHeight: 300, Signature: []byte{2}, AuxInfoSeqDigest: emptyAuxInfoDigest}}, } } else { node.sm.ApprovalsRetriever = &approvalsRetriever{ - result: []ValidatorSetApproval{{NodeID: [20]byte{3}, PChainHeight: 300, Signature: []byte{3}, AuxInfoSeqDigest: [32]byte{}}}, + result: []ValidatorSetApproval{{NodeID: [20]byte{3}, PChainHeight: 300, Signature: []byte{3}, AuxInfoSeqDigest: emptyAuxInfoDigest}}, } } } @@ -116,11 +122,11 @@ func TestStateMachineEpochTransitionEmptyMempool(t *testing.T) { node.act() if flipCoin() { node.sm.ApprovalsRetriever = &approvalsRetriever{ - result: []ValidatorSetApproval{{NodeID: [20]byte{1}, PChainHeight: 200, Signature: []byte{1}, AuxInfoSeqDigest: [32]byte{}}}, + result: []ValidatorSetApproval{{NodeID: [20]byte{1}, PChainHeight: 200, Signature: []byte{1}, AuxInfoSeqDigest: emptyAuxInfoDigest}}, } } else { node.sm.ApprovalsRetriever = &approvalsRetriever{ - result: []ValidatorSetApproval{{NodeID: [20]byte{2}, PChainHeight: 200, Signature: []byte{2}, AuxInfoSeqDigest: [32]byte{}}}, + result: []ValidatorSetApproval{{NodeID: [20]byte{2}, PChainHeight: 200, Signature: []byte{2}, AuxInfoSeqDigest: emptyAuxInfoDigest}}, } } } @@ -149,11 +155,11 @@ func TestStateMachineEpochTransitionEmptyMempool(t *testing.T) { node.act() if flipCoin() { node.sm.ApprovalsRetriever = &approvalsRetriever{ - result: []ValidatorSetApproval{{NodeID: [20]byte{2}, PChainHeight: 300, Signature: []byte{2}, AuxInfoSeqDigest: [32]byte{}}}, + result: []ValidatorSetApproval{{NodeID: [20]byte{2}, PChainHeight: 300, Signature: []byte{2}, AuxInfoSeqDigest: emptyAuxInfoDigest}}, } } else { node.sm.ApprovalsRetriever = &approvalsRetriever{ - result: []ValidatorSetApproval{{NodeID: [20]byte{3}, PChainHeight: 300, Signature: []byte{3}, AuxInfoSeqDigest: [32]byte{}}}, + result: []ValidatorSetApproval{{NodeID: [20]byte{3}, PChainHeight: 300, Signature: []byte{3}, AuxInfoSeqDigest: emptyAuxInfoDigest}}, } } } diff --git a/msm/verification.go b/msm/verification.go index c769fc73..4510f0ac 100644 --- a/msm/verification.go +++ b/msm/verification.go @@ -75,15 +75,15 @@ func (nv *nextEpochApprovalsVerifier) Verify(in verificationInput) error { switch in.nextBlockType { case BlockTypeSealing: - return nv.verifySealingBlock(prev, next) + return nv.verifySealingBlock(prev, next, in.proposedBlockMD.AuxiliaryInfo) case BlockTypeNormal: - return nv.verifyNormal(prev, next) + return nv.verifyNormal(prev, next, in.proposedBlockMD.AuxiliaryInfo) default: return nv.verifyEmptyNextEpochApprovals(prev, next) } } -func (nv *nextEpochApprovalsVerifier) verifySealingBlock(prev SimplexEpochInfo, next SimplexEpochInfo) error { +func (nv *nextEpochApprovalsVerifier) verifySealingBlock(prev SimplexEpochInfo, next SimplexEpochInfo, auxInfo *AuxiliaryInfo) error { if next.NextEpochApprovals == nil { return fmt.Errorf("next epoch approvals should not be nil for a sealing block") } @@ -93,7 +93,7 @@ func (nv *nextEpochApprovalsVerifier) verifySealingBlock(prev SimplexEpochInfo, return err } - err = nv.verifySignature(prev, next, validators) + err = nv.verifySignature(prev, next, auxInfo, validators) if err != nil { return err } @@ -111,7 +111,7 @@ func (nv *nextEpochApprovalsVerifier) verifySealingBlock(prev SimplexEpochInfo, return nil } -func (nv *nextEpochApprovalsVerifier) verifyNormal(prev SimplexEpochInfo, next SimplexEpochInfo) error { +func (nv *nextEpochApprovalsVerifier) verifyNormal(prev SimplexEpochInfo, next SimplexEpochInfo, auxInfo *AuxiliaryInfo) error { if prev.NextPChainReferenceHeight == 0 { return nil } @@ -128,7 +128,7 @@ func (nv *nextEpochApprovalsVerifier) verifyNormal(prev SimplexEpochInfo, next S return err } - err = nv.verifySignature(prev, next, validators) + err = nv.verifySignature(prev, next, auxInfo, validators) if err != nil { return err } @@ -149,7 +149,7 @@ func (nv *nextEpochApprovalsVerifier) verifyEmptyNextEpochApprovals(_ SimplexEpo return nil } -func (nv *nextEpochApprovalsVerifier) verifySignature(prev SimplexEpochInfo, next SimplexEpochInfo, validators NodeBLSMappings) error { +func (nv *nextEpochApprovalsVerifier) verifySignature(prev SimplexEpochInfo, next SimplexEpochInfo, auxinfo *AuxiliaryInfo, validators NodeBLSMappings) error { // First figure out which validators are approving the next epoch by looking at the bitmask of approving nodes, // and then aggregate their public keys together to verify the signature. @@ -159,7 +159,7 @@ func (nv *nextEpochApprovalsVerifier) verifySignature(prev SimplexEpochInfo, nex return err } - message := nv.createMessageToBeVerified(prev) + message := pChainNextReferenceHeightAsBytes(prev) if err := nv.sigVerifier.VerifySignature(next.NextEpochApprovals.Signature, message, aggPK); err != nil { return fmt.Errorf("failed to verify signature: %w", err) @@ -167,16 +167,6 @@ func (nv *nextEpochApprovalsVerifier) verifySignature(prev SimplexEpochInfo, nex return nil } -func (nv *nextEpochApprovalsVerifier) createMessageToBeVerified(prev SimplexEpochInfo) []byte { - pChainHeightBuff := pChainNextReferenceHeightAsBytes(prev) - - var bb bytes.Buffer - bb.Write(pChainHeightBuff) - - message := bb.Bytes() - return message -} - func (nv *nextEpochApprovalsVerifier) aggregatePubKeysForBitmask(nodeIDsBitmask []byte, validators NodeBLSMappings) ([]byte, error) { approvingNodes := bitmaskFromBytes(nodeIDsBitmask) publicKeys := make([][]byte, 0, len(validators)) @@ -378,6 +368,28 @@ func (p *pChainReferenceHeightVerifier) Verify(in verificationInput) error { return nil } +type icmEpochInfoVerifier struct { + getUpdates func() UpgradeConfig + computeICMEpoch ICMEpochTransition +} + +func (i *icmEpochInfoVerifier) Verify(in verificationInput) error { + prevMD, nextMD := in.prevMD, in.proposedBlockMD + + timestamp := time.UnixMilli(int64(in.prevMD.Timestamp)) + if in.hasInnerBlock { + timestamp = in.innerBlockTimestamp + } + + expectedICMInfo := nextICMEpochInfo(prevMD, in.hasInnerBlock, i.getUpdates, i.computeICMEpoch, timestamp) + + if !expectedICMInfo.Equal(&nextMD.ICMEpochInfo) { + return fmt.Errorf("expected ICM epoch info to be %v but got %v", expectedICMInfo, nextMD.ICMEpochInfo) + } + + return nil +} + type timestampVerifier struct { getTime func() time.Time timeSkewLimit time.Duration @@ -494,6 +506,38 @@ func (v *vmBlockSeqVerifier) Verify(in verificationInput) error { return nil } +type AuxInfoVerifier struct { + auxiliaryInfoVerifier AuxiliaryInfoVerifier + getBlock BlockRetriever +} + +func (a *AuxInfoVerifier) Verify(in verificationInput) error { + // We want to have an auxiliary info only if: + // 1) We haven't sealed the block in this block (block type is normal) + // 2) We are collecting approvals for the next epoch + // 3) The previous block has set a next P-chain reference height, + // which means this isn't the first block that did so. + // This is needed to ensure nodes won't contribute auxiliary info in the block that transition + // from stateBuildBlockNormalOp to stateBuildCollectingApprovals, to make the code simpler. + if in.nextBlockType == BlockTypeNormal && in.state == stateBuildCollectingApprovals && in.prevMD.SimplexEpochInfo.NextPChainReferenceHeight > 0 { + if in.proposedBlockMD.AuxiliaryInfo == nil { + return fmt.Errorf("auxiliary info should not be nil when collecting approvals") + } + // Next, verify the auxiliary info is correct. + + // We first need to collect all auxiliary info in this epoch so far. + var prevAuxInfos []*AuxiliaryInfo + prevAuxInf := in.prevMD.AuxiliaryInfo + for prevAuxInf != nil { + prevAuxInfos = append(prevAuxInfos, prevAuxInf) + + } + } else if in.proposedBlockMD.AuxiliaryInfo != nil { + return fmt.Errorf("auxiliary info should be nil when not collecting approvals") + } + return nil +} + func areNextEpochApprovalsSignersSupersetOfApprovalsOfPrevBlock(prev SimplexEpochInfo, next SimplexEpochInfo) error { if prev.NextEpochApprovals == nil { return nil