Skip to content

Commit d6c16e6

Browse files
committed
feat(sync): sync from trusted height
1 parent d20b1ac commit d6c16e6

6 files changed

Lines changed: 247 additions & 38 deletions

File tree

block/internal/syncing/syncer.go

Lines changed: 79 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,33 @@ import (
3030

3131
var _ BlockSyncer = (*Syncer)(nil)
3232

33+
// getTrustedHeader loads and verifies the trusted header from the store
34+
func (s *Syncer) getTrustedHeader(ctx context.Context) (*types.SignedHeader, error) {
35+
if s.config.P2P.TrustedHeight == 0 {
36+
return nil, fmt.Errorf("trusted_height is not configured")
37+
}
38+
39+
// Load the signed header from the store
40+
header, err := s.store.GetHeader(ctx, s.config.P2P.TrustedHeight)
41+
if err != nil {
42+
return nil, fmt.Errorf("failed to load trusted header at height %d: %w", s.config.P2P.TrustedHeight, err)
43+
}
44+
45+
// Verify the header hash matches the trusted hash
46+
expectedHash := s.config.P2P.TrustedHeaderHash
47+
actualHash := header.Hash().String()
48+
if actualHash != expectedHash {
49+
return nil, fmt.Errorf("trusted header hash mismatch at height %d: expected %s, got %s",
50+
s.config.P2P.TrustedHeight, expectedHash, actualHash)
51+
}
52+
53+
s.logger.Info().Uint64("height", s.config.P2P.TrustedHeight).
54+
Str("hash", actualHash).
55+
Msg("trusted header loaded and verified")
56+
57+
return header, nil
58+
}
59+
3360
// forcedInclusionGracePeriodConfig contains internal configuration for forced inclusion grace periods.
3461
type forcedInclusionGracePeriodConfig struct {
3562
// basePeriod is the base number of additional epochs allowed for including forced inclusion transactions
@@ -304,25 +331,59 @@ func (s *Syncer) initializeState() error {
304331
// Load state from store
305332
state, err := s.store.GetState(s.ctx)
306333
if err != nil {
307-
// Initialize new chain state for a fresh full node (no prior state on disk)
308-
// Mirror executor initialization to ensure AppHash matches headers produced by the sequencer.
309-
stateRoot, initErr := s.exec.InitChain(
310-
s.ctx,
311-
s.genesis.StartTime,
312-
s.genesis.InitialHeight,
313-
s.genesis.ChainID,
314-
)
315-
if initErr != nil {
316-
return fmt.Errorf("failed to initialize execution client: %w", initErr)
317-
}
334+
// initializeStateFromTrustedHeight initializes the sync state from a trusted height.
335+
// This allows a syncing node to start from a known, verified block height instead of genesis.
336+
if s.config.P2P.TrustedHeight > 0 {
337+
s.logger.Info().Uint64("trusted_height", s.config.P2P.TrustedHeight).Msg("initializing state from trusted height")
338+
339+
// Load and verify the trusted header
340+
trustedHeader, err := s.getTrustedHeader(s.ctx)
341+
if err != nil {
342+
return fmt.Errorf("failed to load trusted header: %w", err)
343+
}
318344

319-
state = types.State{
320-
ChainID: s.genesis.ChainID,
321-
InitialHeight: s.genesis.InitialHeight,
322-
LastBlockHeight: s.genesis.InitialHeight - 1,
323-
LastBlockTime: s.genesis.StartTime,
324-
DAHeight: s.genesis.DAStartHeight,
325-
AppHash: stateRoot,
345+
// Initialize new chain state from the trusted header
346+
stateRoot, initErr := s.exec.InitChain(
347+
s.ctx,
348+
trustedHeader.Time(),
349+
trustedHeader.Height(),
350+
trustedHeader.ChainID(),
351+
)
352+
if initErr != nil {
353+
return fmt.Errorf("failed to initialize execution client: %w", initErr)
354+
}
355+
356+
state = types.State{
357+
Version: types.InitStateVersion,
358+
ChainID: trustedHeader.ChainID(),
359+
InitialHeight: trustedHeader.Height(),
360+
LastBlockHeight: trustedHeader.Height(),
361+
LastBlockTime: trustedHeader.Time(),
362+
LastHeaderHash: trustedHeader.Hash(), // Hash of the trusted header
363+
DAHeight: s.genesis.DAStartHeight,
364+
AppHash: stateRoot,
365+
}
366+
} else {
367+
// Initialize new chain state for a fresh full node (no prior state on disk)
368+
// Mirror executor initialization to ensure AppHash matches headers produced by the sequencer.
369+
stateRoot, initErr := s.exec.InitChain(
370+
s.ctx,
371+
s.genesis.StartTime,
372+
s.genesis.InitialHeight,
373+
s.genesis.ChainID,
374+
)
375+
if initErr != nil {
376+
return fmt.Errorf("failed to initialize execution client: %w", initErr)
377+
}
378+
379+
state = types.State{
380+
ChainID: s.genesis.ChainID,
381+
InitialHeight: s.genesis.InitialHeight,
382+
LastBlockHeight: s.genesis.InitialHeight - 1,
383+
LastBlockTime: s.genesis.StartTime,
384+
DAHeight: s.genesis.DAStartHeight,
385+
AppHash: stateRoot,
386+
}
326387
}
327388
}
328389
if state.DAHeight != 0 && state.DAHeight < s.genesis.DAStartHeight {

docs/learn/config.md

Lines changed: 35 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -977,23 +977,46 @@ signer:
977977
`--rollkit.signer.signer_path <string>`
978978
_Example:_ `--rollkit.signer.signer_path ./config`
979979
_Default:_ (Depends on application)
980-
_Constant:_ `FlagSignerPath`
981980

982-
### Signer Passphrase
981+
---
982+
983+
## Sync Configuration (`sync`)
984+
985+
The `sync` section contains options for controlling how the node synchronizes with the network.
986+
987+
### Trusted Height
983988

984989
**Description:**
985-
The passphrase required to decrypt or access the signer key, particularly if using a `file` signer and the key is encrypted, or if the aggregator mode is enabled and requires it. This flag is not directly a field in the `SignerConfig` struct but is used in conjunction with it.
990+
Trusted height allows a syncing node to start synchronization from a known, verified block height instead of from genesis. This can significantly speed up the initial sync process for new nodes. When using trusted height, you must also provide the corresponding header hash for security verification.
991+
992+
This is particularly useful when:
993+
994+
- Joining a long-running network and wanting to skip the history
995+
- Restoring from a backup at a specific height
996+
- Testing with a known good state
997+
998+
**Security Consideration:** When using trusted height, you must provide the `trusted_header_hash` to prevent against history rewrites or malicious nodes trying to sync from an invalid state.
986999

9871000
**YAML:**
988-
This is typically not stored in the YAML file for security reasons but provided via flag or environment variable.
9891001

990-
**Command-line Flag:**
991-
`--rollkit.signer.passphrase <string>`
992-
_Example:_ `--rollkit.signer.passphrase "mysecretpassphrase"`
993-
_Default:_ `""` (empty)
994-
_Constant:_ `FlagSignerPassphrase`
995-
_Note:_ Be cautious with providing passphrases directly on the command line in shared environments due to history logging. Environment variables or secure input methods are often preferred.
1002+
```yaml
1003+
sync:
1004+
trusted_height: 100000 # Block height to trust for sync initialization
1005+
trusted_header_hash: "a1b2c3d4e5f6..." # Hex-encoded hash of the header at trusted_height
1006+
```
9961007

997-
---
1008+
**Command-line Flags:**
1009+
1010+
- `--evnode.sync.trusted_height <uint64>` - Block height to trust for sync initialization
1011+
- `--evnode.sync.trusted_header_hash <string>` - Hash of the trusted header for security verification (hex-encoded)
1012+
1013+
**Example:**
1014+
1015+
```bash
1016+
testapp start \
1017+
--evnode.sync.trusted_height 100000 \
1018+
--evnode.sync.trusted_header_hash "abc123def456..."
1019+
```
9981020

999-
This reference should help you configure your Evolve node effectively. Always refer to the specific version of Evolve you are using, as options and defaults may change over time.
1021+
_Default:_ `0` (disabled - sync from genesis)
1022+
_Constant:_ `FlagTrustedHeight`, `FlagTrustedHeaderHash`

pkg/config/config.go

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,10 @@ const (
9797
FlagP2PBlockedPeers = FlagPrefixEvnode + "p2p.blocked_peers"
9898
// FlagP2PAllowedPeers is a flag for specifying the P2P allowed peers
9999
FlagP2PAllowedPeers = FlagPrefixEvnode + "p2p.allowed_peers"
100+
// FlagTrustedHeight is a flag for specifying the trusted block height to start sync from
101+
FlagTrustedHeight = FlagPrefixEvnode + "p2p.trusted_height"
102+
// FlagTrustedHeaderHash is a flag for specifying the trusted header hash for verification
103+
FlagTrustedHeaderHash = FlagPrefixEvnode + "p2p.trusted_header_hash"
100104

101105
// Instrumentation configuration flags
102106

@@ -272,10 +276,12 @@ type LogConfig struct {
272276

273277
// P2PConfig contains all peer-to-peer networking configuration parameters
274278
type P2PConfig struct {
275-
ListenAddress string `mapstructure:"listen_address" yaml:"listen_address" comment:"Address to listen for incoming connections (host:port)"`
276-
Peers string `mapstructure:"peers" yaml:"peers" comment:"Comma-separated list of peers to connect to"`
277-
BlockedPeers string `mapstructure:"blocked_peers" yaml:"blocked_peers" comment:"Comma-separated list of peer IDs to block from connecting"`
278-
AllowedPeers string `mapstructure:"allowed_peers" yaml:"allowed_peers" comment:"Comma-separated list of peer IDs to allow connections from"`
279+
ListenAddress string `mapstructure:"listen_address" yaml:"listen_address" comment:"Address to listen for incoming connections (host:port)"`
280+
Peers string `mapstructure:"peers" yaml:"peers" comment:"Comma-separated list of peers to connect to"`
281+
BlockedPeers string `mapstructure:"blocked_peers" yaml:"blocked_peers" comment:"Comma-separated list of peer IDs to block from connecting"`
282+
AllowedPeers string `mapstructure:"allowed_peers" yaml:"allowed_peers" comment:"Comma-separated list of peer IDs to allow connections from"`
283+
TrustedHeight uint64 `mapstructure:"trusted_height" yaml:"trusted_height" comment:"Block height to trust for sync initialization. When set, sync starts from this height instead of genesis. Must be accompanied by trusted_header_hash for security."`
284+
TrustedHeaderHash string `mapstructure:"trusted_header_hash" yaml:"trusted_header_hash" comment:"Hash of the trusted header for security verification. This should be the hex-encoded hash of the header at trusted_height. Prevents against history rewrites during sync."`
279285
}
280286

281287
// SignerConfig contains all signer configuration parameters
@@ -373,6 +379,13 @@ func (c *Config) Validate() error {
373379
return fmt.Errorf("LazyBlockInterval (%v) must be greater than BlockTime (%v) in lazy mode",
374380
c.Node.LazyBlockInterval.Duration, c.Node.BlockTime.Duration)
375381
}
382+
383+
// Validate trusted height configuration
384+
if c.P2P.TrustedHeight > 0 && c.P2P.TrustedHeaderHash == "" {
385+
return fmt.Errorf("trusted_height (%d) is set but trusted_header_hash is empty. When using trusted_height, trusted_header_hash must also be provided for security verification",
386+
c.P2P.TrustedHeight)
387+
}
388+
376389
if err := c.Raft.Validate(); err != nil {
377390
return err
378391
}
@@ -459,6 +472,8 @@ func AddFlags(cmd *cobra.Command) {
459472
cmd.Flags().String(FlagP2PPeers, def.P2P.Peers, "Comma separated list of seed nodes to connect to")
460473
cmd.Flags().String(FlagP2PBlockedPeers, def.P2P.BlockedPeers, "Comma separated list of nodes to ignore")
461474
cmd.Flags().String(FlagP2PAllowedPeers, def.P2P.AllowedPeers, "Comma separated list of nodes to whitelist")
475+
cmd.Flags().Uint64(FlagTrustedHeight, def.P2P.TrustedHeight, "block height to trust for sync initialization (0 = start from genesis)")
476+
cmd.Flags().String(FlagTrustedHeaderHash, def.P2P.TrustedHeaderHash, "hash of the trusted header for security verification (hex-encoded)")
462477

463478
// RPC configuration flags
464479
cmd.Flags().String(FlagRPCAddress, def.RPC.Address, "RPC server address (host:port)")

pkg/config/config_test.go

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,7 @@ func TestAddFlags(t *testing.T) {
112112
assertFlagValue(t, flags, FlagRPCEnableDAVisualization, DefaultConfig().RPC.EnableDAVisualization)
113113

114114
// Count the number of flags we're explicitly checking
115-
expectedFlagCount := 63 // Update this number if you add more flag checks above
115+
expectedFlagCount := 65 // Update this number if you add more flag checks above
116116

117117
// Get the actual number of flags (both regular and persistent)
118118
actualFlagCount := 0
@@ -513,3 +513,57 @@ func TestBasedSequencerValidation(t *testing.T) {
513513
})
514514
}
515515
}
516+
517+
func TestTrustedHeightValidation(t *testing.T) {
518+
tests := []struct {
519+
name string
520+
trustedHeight uint64
521+
trustedHash string
522+
expectError bool
523+
errorMsg string
524+
}{
525+
{
526+
name: "trusted height with empty hash should fail",
527+
trustedHeight: 100,
528+
trustedHash: "",
529+
expectError: true,
530+
errorMsg: "trusted_height (100) is set but trusted_header_hash is empty",
531+
},
532+
{
533+
name: "trusted height with valid hash should pass",
534+
trustedHeight: 100,
535+
trustedHash: "abc123",
536+
expectError: false,
537+
},
538+
{
539+
name: "zero trusted height with empty hash should pass",
540+
trustedHeight: 0,
541+
trustedHash: "",
542+
expectError: false,
543+
},
544+
{
545+
name: "zero trusted height with hash should pass (not validated)",
546+
trustedHeight: 0,
547+
trustedHash: "abc123",
548+
expectError: false,
549+
},
550+
}
551+
552+
for _, tt := range tests {
553+
t.Run(tt.name, func(t *testing.T) {
554+
cfg := DefaultConfig()
555+
cfg.RootDir = t.TempDir()
556+
cfg.P2P.TrustedHeight = tt.trustedHeight
557+
cfg.P2P.TrustedHeaderHash = tt.trustedHash
558+
559+
err := cfg.Validate()
560+
561+
if tt.expectError {
562+
require.Error(t, err)
563+
assert.Contains(t, err.Error(), tt.errorMsg)
564+
} else {
565+
require.NoError(t, err)
566+
}
567+
})
568+
}
569+
}

pkg/config/defaults.go

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -57,8 +57,10 @@ func DefaultConfig() Config {
5757
RootDir: DefaultRootDir,
5858
DBPath: "data",
5959
P2P: P2PConfig{
60-
ListenAddress: "/ip4/0.0.0.0/tcp/7676",
61-
Peers: "",
60+
ListenAddress: "/ip4/0.0.0.0/tcp/7676",
61+
Peers: "",
62+
TrustedHeight: 0,
63+
TrustedHeaderHash: "",
6264
},
6365
Node: NodeConfig{
6466
Aggregator: false,

pkg/sync/sync_service.go

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,11 @@ type SyncService[H store.EntityWithDAHint[H]] struct {
5959
topicSubscription header.Subscription[H]
6060

6161
storeInitialized atomic.Bool
62+
63+
// trustedHeight tracks the configured trusted height for sync initialization
64+
trustedHeight uint64
65+
// trustedHeaderHash is the expected hash of the trusted header
66+
trustedHeaderHash string
6267
}
6368

6469
// NewDataSyncService returns a new DataSyncService.
@@ -198,6 +203,10 @@ func (syncService *SyncService[H]) Start(ctx context.Context) error {
198203
return fmt.Errorf("failed to create syncer: %w", err)
199204
}
200205

206+
// Initialize trusted height configuration
207+
syncService.trustedHeight = syncService.conf.P2P.TrustedHeight
208+
syncService.trustedHeaderHash = syncService.conf.P2P.TrustedHeaderHash
209+
201210
// initialize stores from P2P (blocking until genesis is fetched for followers)
202211
// Aggregators (no peers configured) return immediately and initialize on first produced block.
203212
if err := syncService.initFromP2PWithRetry(ctx, peerIDs); err != nil {
@@ -331,11 +340,19 @@ func (s *SyncService[H]) Height() uint64 {
331340
// It inspects the local store to determine the first height to request:
332341
// - when the store already contains items, it reuses the latest height as the starting point;
333342
// - otherwise, it falls back to the configured genesis height.
343+
// - if trusted height is configured, it fetches from that height first and verifies the hash.
334344
func (syncService *SyncService[H]) initFromP2PWithRetry(ctx context.Context, peerIDs []peer.ID) error {
335345
if len(peerIDs) == 0 {
336346
return nil
337347
}
338348

349+
// If trusted height is configured, fetch from that height first
350+
if syncService.trustedHeight > 0 {
351+
if err := syncService.fetchAndVerifyTrustedHeader(ctx, peerIDs); err != nil {
352+
return fmt.Errorf("failed to fetch trusted header at height %d: %w", syncService.trustedHeight, err)
353+
}
354+
}
355+
339356
tryInit := func(ctx context.Context) (bool, error) {
340357
var (
341358
trusted H
@@ -346,7 +363,12 @@ func (syncService *SyncService[H]) initFromP2PWithRetry(ctx context.Context, pee
346363
head, headErr := syncService.store.Head(ctx)
347364
switch {
348365
case errors.Is(headErr, header.ErrNotFound), errors.Is(headErr, header.ErrEmptyStore):
349-
heightToQuery = syncService.genesis.InitialHeight
366+
// If we have a trusted header, use its height as the starting point
367+
if syncService.trustedHeight > 0 {
368+
heightToQuery = syncService.trustedHeight
369+
} else {
370+
heightToQuery = syncService.genesis.InitialHeight
371+
}
350372
case headErr != nil:
351373
return false, fmt.Errorf("failed to inspect local store head: %w", headErr)
352374
default:
@@ -405,6 +427,38 @@ func (syncService *SyncService[H]) initFromP2PWithRetry(ctx context.Context, pee
405427
}
406428
}
407429

430+
// fetchAndVerifyTrustedHeader fetches the header at the trusted height from P2P
431+
// and verifies it matches the trusted hash. If verification passes, it stores the header.
432+
func (syncService *SyncService[H]) fetchAndVerifyTrustedHeader(ctx context.Context, peerIDs []peer.ID) error {
433+
syncService.logger.Info().Uint64("height", syncService.trustedHeight).Msg("fetching trusted header from P2P")
434+
435+
// Fetch the header from trusted height
436+
trusted, err := syncService.ex.GetByHeight(ctx, syncService.trustedHeight)
437+
if err != nil {
438+
return fmt.Errorf("failed to fetch trusted header at height %d: %w", syncService.trustedHeight, err)
439+
}
440+
441+
// Verify the hash matches
442+
expectedHash := syncService.trustedHeaderHash
443+
actualHash := trusted.Hash().String()
444+
if actualHash != expectedHash {
445+
return fmt.Errorf("trusted header hash mismatch at height %d: expected %s, got %s",
446+
syncService.trustedHeight, expectedHash, actualHash)
447+
}
448+
449+
syncService.logger.Info().Uint64("height", syncService.trustedHeight).
450+
Str("hash", actualHash).
451+
Msg("trusted header verified and stored")
452+
453+
if err := syncService.store.Append(ctx, trusted); err != nil {
454+
return fmt.Errorf("failed to store trusted header: %w", err)
455+
}
456+
457+
syncService.storeInitialized.Store(true)
458+
459+
return nil
460+
}
461+
408462
// Stop is a part of Service interface.
409463
//
410464
// `store` is closed last because it's used by other services.

0 commit comments

Comments
 (0)