Skip to content

Commit 23ee8f0

Browse files
committed
Simplex orchestration layer preliminary implementation
This commit contains an implementation of an orchestration layer that orchestrates epoch transition for validators and non-validators as they move through epochs. The orchestration layer introduces an Instance that drives the epoch lifecycle, switching between validator consensus and non-validator block tracking as the node's role changes across epochs. Key pieces: - Instance: manages the per-epoch lifecycle, ticks the active epoch or non-validator, and handles the handoff at epoch boundaries. - epochChangeSupression: drops outgoing messages and cancels VM operations while an epoch change is in progress, preventing side effects mid-transition. - EpochAwareStorage: ignores blocks from previous epochs and signals when a new epoch is detected. - Config: wiring for the P-chain, storage, crypto, and networking dependencies. Signed-off-by: Yacov Manevich <yacov.manevich@avalabs.org>
1 parent 545153e commit 23ee8f0

21 files changed

Lines changed: 2366 additions & 158 deletions

adapters.go

Lines changed: 318 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,318 @@
1+
// Copyright (C) 2019-2025, Ava Labs, Inc. All rights reserved.
2+
// See the file LICENSE for licensing terms.
3+
4+
package simplex
5+
6+
import (
7+
"context"
8+
"fmt"
9+
"sync"
10+
"sync/atomic"
11+
12+
"github.com/ava-labs/simplex/common"
13+
metadata "github.com/ava-labs/simplex/msm"
14+
)
15+
16+
// epochChangeSupression is used to suppress sending messages during an epoch change or using the VM.
17+
// The motivation is that an epoch change occurrs during indexing a block, and we don't want any further
18+
// external side effects to take place before the epoch change finishes.
19+
// We therefore drop all messages and cancel VM operations such as block building,
20+
// and delay VM transaction listening until the epoch change is complete.
21+
type epochChangeSupression struct {
22+
lock sync.RWMutex
23+
sealingBlockSeq uint64
24+
active bool
25+
}
26+
27+
func (ecs *epochChangeSupression) isSupressionActive() bool {
28+
ecs.lock.RLock()
29+
defer ecs.lock.RUnlock()
30+
return ecs.active
31+
}
32+
33+
func (ecs *epochChangeSupression) sendProhibited(seq uint64) bool {
34+
ecs.lock.RLock()
35+
defer ecs.lock.RUnlock()
36+
if !ecs.active {
37+
return false
38+
}
39+
return seq > ecs.sealingBlockSeq
40+
}
41+
42+
func (ecs *epochChangeSupression) setSupression(sealingBlockSeq uint64) {
43+
ecs.lock.Lock()
44+
defer ecs.lock.Unlock()
45+
ecs.sealingBlockSeq = sealingBlockSeq
46+
ecs.active = true
47+
}
48+
49+
func (ecs *epochChangeSupression) clearSupression() {
50+
ecs.lock.Lock()
51+
defer ecs.lock.Unlock()
52+
ecs.active = false
53+
}
54+
55+
type Communication struct {
56+
epochChangeSupression *epochChangeSupression
57+
nodes atomic.Value // common.Nodes
58+
Sender
59+
Broadcaster
60+
}
61+
62+
func (c *Communication) SetValidators(nodes common.Nodes) {
63+
c.nodes.Store(nodes)
64+
}
65+
66+
func (c *Communication) Validators() common.Nodes {
67+
nodes, ok := c.nodes.Load().(common.Nodes)
68+
if !ok {
69+
return nil
70+
}
71+
return nodes
72+
}
73+
74+
func (c *Communication) Broadcast(msg *common.Message) {
75+
if c.epochChangeSupression.sendProhibited(msg.Seq()) {
76+
return
77+
}
78+
79+
c.Broadcaster.Broadcast(msg)
80+
}
81+
82+
func (c *Communication) Send(msg *common.Message, destination common.NodeID) {
83+
if c.epochChangeSupression.sendProhibited(msg.Seq()) {
84+
return
85+
}
86+
87+
c.Sender.Send(msg, destination)
88+
}
89+
90+
// EpochAwareStorage is a wrapper around Storage that is aware of epoch changes.
91+
// Upon an epoch change, it will ignore blocks from previous epochs
92+
// and will call the OnEpochChange callback when a new epoch is detected.
93+
type EpochAwareStorage struct {
94+
msm *metadata.StateMachine
95+
OnEpochChange func(seq uint64, validators common.Nodes) error
96+
Storage
97+
Epoch uint64
98+
}
99+
100+
func (e *EpochAwareStorage) Retrieve(seq uint64) (common.VerifiedBlock, common.Finalization, error) {
101+
block, finalization, err := e.Storage.GetBlock(seq)
102+
if err != nil {
103+
return nil, common.Finalization{}, err
104+
}
105+
parsedBlock := &ParsedBlock{
106+
msm: e.msm,
107+
StateMachineBlock: block,
108+
}
109+
return parsedBlock, *finalization, nil
110+
}
111+
112+
func (e *EpochAwareStorage) Index(ctx context.Context, block common.VerifiedBlock, certificate common.Finalization) error {
113+
if block.BlockHeader().Epoch < e.Epoch {
114+
// This is a Telock from a previous h, so we ignore it and do not index it.
115+
return nil
116+
}
117+
if err := e.Storage.Index(ctx, block, certificate); err != nil {
118+
return err
119+
}
120+
if block.SealingBlockInfo() != nil {
121+
if err := e.OnEpochChange(block.BlockHeader().Seq, block.SealingBlockInfo().ValidatorSet); err != nil {
122+
return err
123+
}
124+
// We are now in a new h, so we update the h number to prevent indexing Telocks from the previous h.
125+
e.Epoch = block.BlockHeader().Seq
126+
}
127+
return nil
128+
}
129+
130+
// cachedBlock is a wrapper around ParsedBlock that caches the block in the CachedStorage upon verification.
131+
// It is needed for the MSM because the MSM needs to be able to retrieve blocks that aren't finalized during its execution.
132+
// These blocks are cached in the CachedStorage upon verification, and removed from the cache upon finalization (indexing).
133+
type cachedBlock struct {
134+
cache *CachedStorage
135+
*ParsedBlock
136+
}
137+
138+
func (cb *cachedBlock) Verify(ctx context.Context) (common.VerifiedBlock, error) {
139+
vb, err := cb.ParsedBlock.Verify(ctx)
140+
if err == nil {
141+
cb.cache.insertBlock(cb.ParsedBlock)
142+
}
143+
return vb, err
144+
}
145+
146+
type CachedStorage struct {
147+
msm *metadata.StateMachine
148+
lock sync.RWMutex
149+
Storage
150+
cache map[[32]byte]cachedBlock
151+
}
152+
153+
func NewCachedStorage(storage Storage) *CachedStorage {
154+
return &CachedStorage{
155+
Storage: storage,
156+
cache: make(map[[32]byte]cachedBlock),
157+
}
158+
}
159+
160+
func (cs *CachedStorage) RetrieveBlock(seq uint64, digest [32]byte) (metadata.StateMachineBlock, *common.Finalization, error) {
161+
block, finalization, err := cs.Retrieve(seq, digest)
162+
if err != nil {
163+
return metadata.StateMachineBlock{}, nil, err
164+
}
165+
166+
return block.(*ParsedBlock).StateMachineBlock, finalization, nil
167+
}
168+
169+
func (cs *CachedStorage) Retrieve(seq uint64, digest [32]byte) (common.VerifiedBlock, *common.Finalization, error) {
170+
cs.lock.RLock()
171+
item, exists := cs.cache[digest]
172+
if exists {
173+
cs.lock.RUnlock()
174+
// If the block is cached, it means it's not finalized yet, because upon finalizing the block (indexing)
175+
// we also remove it from the cache. Therefore, we return nil for the finalization.
176+
return item.ParsedBlock, nil, nil
177+
}
178+
cs.lock.RUnlock()
179+
180+
// We don't populate the cache here because we populate it externally.
181+
182+
block, finalization, err := cs.Storage.GetBlock(seq)
183+
if err != nil {
184+
return nil, nil, err
185+
}
186+
187+
return &ParsedBlock{
188+
StateMachineBlock: block,
189+
msm: cs.msm,
190+
}, finalization, nil
191+
}
192+
193+
func (cs *CachedStorage) Index(ctx context.Context, block common.VerifiedBlock, certificate common.Finalization) error {
194+
err := cs.Storage.Index(ctx, block, certificate)
195+
196+
if err == nil {
197+
// We delete the block from the cache after it has been indexed because now that it is persisted,
198+
// we can just lookup by sequence number instead of digest.
199+
cs.lock.Lock()
200+
defer cs.lock.Unlock()
201+
delete(cs.cache, block.BlockHeader().Digest)
202+
203+
// We also delete all blocks that are older than the indexed block, because they are now finalized and persisted.
204+
for digest, cachedBlock := range cs.cache {
205+
if cachedBlock.BlockHeader().Seq < block.BlockHeader().Seq {
206+
delete(cs.cache, digest)
207+
}
208+
}
209+
}
210+
211+
return err
212+
}
213+
214+
func (cs *CachedStorage) insertBlock(block *ParsedBlock) {
215+
cs.lock.Lock()
216+
defer cs.lock.Unlock()
217+
218+
cs.cache[block.Digest()] = cachedBlock{
219+
ParsedBlock: block,
220+
}
221+
}
222+
223+
type NoopAuxiliaryInfoApp struct{}
224+
225+
func (n *NoopAuxiliaryInfoApp) IsLegalAppend(versionID metadata.VersionID, nodes metadata.NodeBLSMappings, history [][]byte, x []byte) error {
226+
if len(x) > 0 {
227+
return fmt.Errorf("input should be empty")
228+
}
229+
return nil
230+
}
231+
232+
func (n *NoopAuxiliaryInfoApp) IsSufficient(versionID metadata.VersionID, nodes metadata.NodeBLSMappings, history [][]byte) (bool, error) {
233+
return true, nil
234+
}
235+
236+
func (n *NoopAuxiliaryInfoApp) Generate(metadata.VersionID, metadata.NodeBLSMappings, [][]byte) ([]byte, error) {
237+
return nil, nil
238+
}
239+
240+
func (n *NoopAuxiliaryInfoApp) DefaultVersionID() metadata.VersionID {
241+
return 0
242+
}
243+
244+
type BlockBuilderWaiter struct {
245+
epochChangeSupression *epochChangeSupression
246+
lock sync.Mutex
247+
cancel context.CancelFunc
248+
msm *metadata.StateMachine
249+
vm VM
250+
}
251+
252+
func (bw *BlockBuilderWaiter) stop() {
253+
bw.lock.Lock()
254+
defer bw.lock.Unlock()
255+
if bw.cancel != nil {
256+
bw.cancel()
257+
bw.cancel = nil
258+
}
259+
}
260+
261+
func (bw *BlockBuilderWaiter) WaitForPendingBlock(ctx context.Context) {
262+
if bw.epochChangeSupression.isSupressionActive() {
263+
<-ctx.Done() // We wait for the context to be cancelled once the epoch is tore down.
264+
return
265+
}
266+
267+
bw.lock.Lock()
268+
if bw.cancel != nil {
269+
bw.cancel()
270+
}
271+
ctx, cancel := context.WithCancel(ctx)
272+
bw.cancel = cancel
273+
bw.lock.Unlock()
274+
defer cancel()
275+
bw.vm.WaitForPendingBlock(ctx)
276+
}
277+
278+
func (bw *BlockBuilderWaiter) BuildBlock(ctx context.Context, metadata common.ProtocolMetadata, blacklist common.Blacklist) (common.VerifiedBlock, bool) {
279+
if bw.epochChangeSupression.isSupressionActive() {
280+
return nil, false
281+
}
282+
283+
block, err := bw.msm.BuildBlock(ctx, metadata, &blacklist)
284+
if err != nil {
285+
return nil, false
286+
}
287+
288+
pb := ParsedBlock{
289+
StateMachineBlock: *block,
290+
msm: bw.msm,
291+
}
292+
293+
return &pb, true
294+
}
295+
296+
type blockDeserializer struct {
297+
vm VM
298+
msm *metadata.StateMachine
299+
}
300+
301+
func (bp *blockDeserializer) DeserializeBlock(ctx context.Context, bytes []byte) (common.Block, error) {
302+
var rawBlock RawBlock
303+
if err := rawBlock.UnmarshalCanoto(bytes); err != nil {
304+
return nil, err
305+
}
306+
307+
block, err := bp.vm.ParseBlock(ctx, rawBlock.InnerBlockBytes)
308+
if err != nil {
309+
return nil, err
310+
}
311+
return &ParsedBlock{
312+
StateMachineBlock: metadata.StateMachineBlock{
313+
InnerBlock: block,
314+
Metadata: rawBlock.Metadata,
315+
},
316+
msm: bp.msm,
317+
}, nil
318+
}

adapters_test.go

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
// Copyright (C) 2019-2025, Ava Labs, Inc. All rights reserved.
2+
// See the file LICENSE for licensing terms.
3+
4+
package simplex
5+
6+
import (
7+
"testing"
8+
9+
"github.com/stretchr/testify/require"
10+
)
11+
12+
func TestEpochChangeSupression(t *testing.T) {
13+
t.Run("Inactive by default", func(t *testing.T) {
14+
var ecs epochChangeSupression
15+
require.False(t, ecs.isSupressionActive())
16+
// When inactive, nothing is prohibited regardless of sequence.
17+
require.False(t, ecs.sendProhibited(0))
18+
require.False(t, ecs.sendProhibited(100))
19+
})
20+
21+
t.Run("setSupression activates and prohibits sequences after the sealing block", func(t *testing.T) {
22+
var ecs epochChangeSupression
23+
const sealingBlockSeq = uint64(10)
24+
ecs.setSupression(sealingBlockSeq)
25+
26+
require.True(t, ecs.isSupressionActive())
27+
28+
// Sequences up to and including the sealing block are allowed.
29+
require.False(t, ecs.sendProhibited(sealingBlockSeq-1))
30+
require.False(t, ecs.sendProhibited(sealingBlockSeq))
31+
32+
// Sequences after the sealing block are prohibited.
33+
require.True(t, ecs.sendProhibited(sealingBlockSeq+1))
34+
require.True(t, ecs.sendProhibited(sealingBlockSeq+100))
35+
})
36+
37+
t.Run("clearSupression deactivates and allows all sequences", func(t *testing.T) {
38+
var ecs epochChangeSupression
39+
const sealingBlockSeq = uint64(10)
40+
ecs.setSupression(sealingBlockSeq)
41+
require.True(t, ecs.sendProhibited(sealingBlockSeq+1))
42+
43+
ecs.clearSupression()
44+
45+
require.False(t, ecs.isSupressionActive())
46+
// Once cleared, previously prohibited sequences are allowed again.
47+
require.False(t, ecs.sendProhibited(sealingBlockSeq+1))
48+
})
49+
50+
t.Run("setSupression overwrites the previous sealing block sequence", func(t *testing.T) {
51+
var ecs epochChangeSupression
52+
ecs.setSupression(5)
53+
require.True(t, ecs.sendProhibited(8))
54+
55+
ecs.setSupression(10)
56+
require.True(t, ecs.isSupressionActive())
57+
// The new, higher sealing block sequence now permits what was previously prohibited.
58+
require.False(t, ecs.sendProhibited(8))
59+
require.True(t, ecs.sendProhibited(11))
60+
})
61+
}

common/api.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@ type Signer interface {
7575
}
7676

7777
type SignatureVerifier interface {
78-
Verify(message []byte, signature []byte, publicKey []byte) error
78+
VerifySignature(message []byte, signature []byte, publicKey []byte) error
7979
}
8080

8181
type WriteAheadLog interface {
@@ -126,7 +126,7 @@ type VerifiedBlock interface {
126126
// BlockDeserializer deserializes blocks according to formatting
127127
// enforced by the application.
128128
type BlockDeserializer interface {
129-
// DeserializeBlock parses the given bytes and initializes a VerifiedBlock.
129+
// DeserializeBlock deserializes the given bytes and initializes a VerifiedBlock.
130130
// Returns an error upon failure.
131131
DeserializeBlock(ctx context.Context, bytes []byte) (Block, error)
132132
}

0 commit comments

Comments
 (0)