Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
76dca27
Simplex reconfiguration framework - Part I (Helpers)
yacovm Apr 16, 2026
965141c
Simplex reconfiguration framework - Part II (more helpers and definit…
yacovm Apr 16, 2026
c6e0934
Simplex reconfiguration framework - Part III (MSM implementation)
yacovm Apr 16, 2026
501e0f1
msm: remove unused newApprovals param in createSealingBlock
yacovm Apr 16, 2026
f2dda7d
msm: remove unused innerChain field from fakeNode
yacovm Apr 16, 2026
72cd9c8
msm: simplify fakeNode.buildBlock to return only the outer block
yacovm Apr 16, 2026
5c2d1a0
msm: document fakeNode.act behavior
yacovm Apr 16, 2026
7d7d0c1
msm: polish computePrevVMBlockSeq comment
yacovm Apr 16, 2026
93abf7a
msm: rename TestFakeNode to TestStateMachineEpochTransition
yacovm Apr 16, 2026
44e73fb
msm: rename fakeNode to multiEpochNode
yacovm Apr 16, 2026
2ac42b5
msm: consolidate notarized/finalized blocks into blockState slice
yacovm Apr 16, 2026
20a72cd
msm: start state and BlockType enums at iota+1
yacovm Apr 16, 2026
95df444
msm: simplify NodeBLSMappings.Equal using slices.EqualFunc
yacovm Apr 16, 2026
c77a88b
msm: replace ForEach/SumWeights closures with plain loops
yacovm Apr 16, 2026
ff79a83
msm: move CurrentState onto SimplexEpochInfo, drop error return
yacovm Apr 16, 2026
8be5e9b
msm: add NewStateMachine constructor, drop lazy maybeInit
yacovm Apr 16, 2026
7637615
msm: extract newBlockBuildingDecider top-level constructor
yacovm Apr 16, 2026
59bcbb0
msm: drop dead childBlock param from buildBlockAndMaybeTransitionEpoch
yacovm Apr 16, 2026
2b869d5
msm: pass blacklist by value and drop the simplex prefix
yacovm Apr 16, 2026
79d6a85
msm: move BlockType and IdentifyBlockType to block_type.go
yacovm Apr 16, 2026
10188dc
msm: move test helper structs into helpers_test.go
yacovm Apr 16, 2026
d59e5e3
msm: thread simplex.ProtocolMetadata through build paths
yacovm Apr 16, 2026
ef828b2
Add test case for sequence 0
yacovm Apr 17, 2026
32bf41e
Remove RetrievingOpts in favor of explicit parameters
yacovm Apr 17, 2026
a9a87d1
Simplex reconfiguration framework - Part V (ICM epoch + authInfo)
yacovm Apr 21, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions go.mod
Original file line number Diff line number Diff line change
@@ -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
)
20 changes: 18 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
@@ -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=
Expand Down
31 changes: 14 additions & 17 deletions msm/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.

Expand Down Expand Up @@ -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.
Expand All @@ -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 {
Expand Down Expand Up @@ -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.
}
```
85 changes: 85 additions & 0 deletions msm/block_type.go
Original file line number Diff line number Diff line change
@@ -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
}
191 changes: 191 additions & 0 deletions msm/build_decision.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
// 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
}

// 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,
// 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
}
}
Loading
Loading