diff --git a/block/internal/syncing/syncer.go b/block/internal/syncing/syncer.go index 6df6b2c22..4abcd6887 100644 --- a/block/internal/syncing/syncer.go +++ b/block/internal/syncing/syncer.go @@ -304,25 +304,62 @@ func (s *Syncer) initializeState() error { // Load state from store state, err := s.store.GetState(s.ctx) if err != nil { - // Initialize new chain state for a fresh full node (no prior state on disk) - // Mirror executor initialization to ensure AppHash matches headers produced by the sequencer. - stateRoot, initErr := s.exec.InitChain( - s.ctx, - s.genesis.StartTime, - s.genesis.InitialHeight, - s.genesis.ChainID, - ) - if initErr != nil { - return fmt.Errorf("failed to initialize execution client: %w", initErr) - } + // initializeStateFromTrustedHeight initializes the sync state from a trusted height. + // This allows a syncing node to start from a known, verified block height instead of genesis. + if s.config.P2P.TrustedHeight > 0 { + s.logger.Info().Uint64("trusted_height", s.config.P2P.TrustedHeight).Msg("initializing state from trusted height") + + // Load and verify the trusted header from the P2P header store. + // The header is fetched via P2P and stored in the StoreAdapter's pending cache, + // so we must use headerStore.GetByHeight() which checks both the main store and pending cache. + p2pHeader, err := s.headerStore.GetByHeight(s.ctx, s.config.P2P.TrustedHeight) + if err != nil { + return fmt.Errorf("failed to load trusted header at height %d: %w", s.config.P2P.TrustedHeight, err) + } + trustedHeader := p2pHeader.SignedHeader + + // Initialize new chain state from the trusted header + stateRoot, initErr := s.exec.InitChain( + s.ctx, + trustedHeader.Time(), + trustedHeader.Height(), + trustedHeader.ChainID(), + ) + if initErr != nil { + return fmt.Errorf("failed to initialize execution client: %w", initErr) + } + + state = types.State{ + Version: types.InitStateVersion, + ChainID: trustedHeader.ChainID(), + InitialHeight: trustedHeader.Height(), + LastBlockHeight: trustedHeader.Height(), + LastBlockTime: trustedHeader.Time(), + LastHeaderHash: trustedHeader.Hash(), // Hash of the trusted header + DAHeight: s.genesis.DAStartHeight, + AppHash: stateRoot, + } + } else { + // Initialize new chain state for a fresh full node (no prior state on disk) + // Mirror executor initialization to ensure AppHash matches headers produced by the sequencer. + stateRoot, initErr := s.exec.InitChain( + s.ctx, + s.genesis.StartTime, + s.genesis.InitialHeight, + s.genesis.ChainID, + ) + if initErr != nil { + return fmt.Errorf("failed to initialize execution client: %w", initErr) + } - state = types.State{ - ChainID: s.genesis.ChainID, - InitialHeight: s.genesis.InitialHeight, - LastBlockHeight: s.genesis.InitialHeight - 1, - LastBlockTime: s.genesis.StartTime, - DAHeight: s.genesis.DAStartHeight, - AppHash: stateRoot, + state = types.State{ + ChainID: s.genesis.ChainID, + InitialHeight: s.genesis.InitialHeight, + LastBlockHeight: s.genesis.InitialHeight - 1, + LastBlockTime: s.genesis.StartTime, + DAHeight: s.genesis.DAStartHeight, + AppHash: stateRoot, + } } } if state.DAHeight != 0 && state.DAHeight < s.genesis.DAStartHeight { diff --git a/docs/learn/config.md b/docs/learn/config.md index ba900a163..87caa828d 100644 --- a/docs/learn/config.md +++ b/docs/learn/config.md @@ -977,23 +977,46 @@ signer: `--rollkit.signer.signer_path ` _Example:_ `--rollkit.signer.signer_path ./config` _Default:_ (Depends on application) -_Constant:_ `FlagSignerPath` -### Signer Passphrase +--- + +## Sync Configuration (`sync`) + +The `sync` section contains options for controlling how the node synchronizes with the network. + +### Trusted Height **Description:** -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. +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. + +This is particularly useful when: + +- Joining a long-running network and wanting to skip the history +- Restoring from a backup at a specific height +- Testing with a known good state + +**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. **YAML:** -This is typically not stored in the YAML file for security reasons but provided via flag or environment variable. -**Command-line Flag:** -`--rollkit.signer.passphrase ` -_Example:_ `--rollkit.signer.passphrase "mysecretpassphrase"` -_Default:_ `""` (empty) -_Constant:_ `FlagSignerPassphrase` -_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. +```yaml +sync: + trusted_height: 100000 # Block height to trust for sync initialization + trusted_header_hash: "a1b2c3d4e5f6..." # Hex-encoded hash of the header at trusted_height +``` ---- +**Command-line Flags:** + +- `--evnode.sync.trusted_height ` - Block height to trust for sync initialization +- `--evnode.sync.trusted_header_hash ` - Hash of the trusted header for security verification (hex-encoded) + +**Example:** + +```bash +testapp start \ + --evnode.sync.trusted_height 100000 \ + --evnode.sync.trusted_header_hash "abc123def456..." +``` -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. +_Default:_ `0` (disabled - sync from genesis) +_Constant:_ `FlagTrustedHeight`, `FlagTrustedHeaderHash` diff --git a/pkg/config/config.go b/pkg/config/config.go index e03a277ce..3ebddb8a0 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -97,6 +97,12 @@ const ( FlagP2PBlockedPeers = FlagPrefixEvnode + "p2p.blocked_peers" // FlagP2PAllowedPeers is a flag for specifying the P2P allowed peers FlagP2PAllowedPeers = FlagPrefixEvnode + "p2p.allowed_peers" + // FlagTrustedHeight is a flag for specifying the trusted block height to start sync from + FlagTrustedHeight = FlagPrefixEvnode + "p2p.trusted_height" + // FlagTrustedHeaderHash is a flag for specifying the trusted header hash for verification + FlagTrustedHeaderHash = FlagPrefixEvnode + "p2p.trusted_header_hash" + // FlagTrustedDataHash is a flag for specifying the trusted data hash for verification + FlagTrustedDataHash = FlagPrefixEvnode + "p2p.trusted_data_hash" // Instrumentation configuration flags @@ -272,10 +278,13 @@ type LogConfig struct { // P2PConfig contains all peer-to-peer networking configuration parameters type P2PConfig struct { - ListenAddress string `mapstructure:"listen_address" yaml:"listen_address" comment:"Address to listen for incoming connections (host:port)"` - Peers string `mapstructure:"peers" yaml:"peers" comment:"Comma-separated list of peers to connect to"` - BlockedPeers string `mapstructure:"blocked_peers" yaml:"blocked_peers" comment:"Comma-separated list of peer IDs to block from connecting"` - AllowedPeers string `mapstructure:"allowed_peers" yaml:"allowed_peers" comment:"Comma-separated list of peer IDs to allow connections from"` + ListenAddress string `mapstructure:"listen_address" yaml:"listen_address" comment:"Address to listen for incoming connections (host:port)"` + Peers string `mapstructure:"peers" yaml:"peers" comment:"Comma-separated list of peers to connect to"` + BlockedPeers string `mapstructure:"blocked_peers" yaml:"blocked_peers" comment:"Comma-separated list of peer IDs to block from connecting"` + AllowedPeers string `mapstructure:"allowed_peers" yaml:"allowed_peers" comment:"Comma-separated list of peer IDs to allow connections from"` + 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."` + TrustedHeaderHash string `mapstructure:"trusted_header_hash" yaml:"trusted_header_hash" comment:"Hash of the trusted header for security verification."` + TrustedDataHash string `mapstructure:"trusted_data_hash" yaml:"trusted_data_hash" comment:"Hash of the trusted data for security verification."` } // SignerConfig contains all signer configuration parameters @@ -373,6 +382,13 @@ func (c *Config) Validate() error { return fmt.Errorf("LazyBlockInterval (%v) must be greater than BlockTime (%v) in lazy mode", c.Node.LazyBlockInterval.Duration, c.Node.BlockTime.Duration) } + + // Validate trusted height configuration + if c.P2P.TrustedHeight > 0 && (c.P2P.TrustedHeaderHash == "" || c.P2P.TrustedDataHash == "") { + return fmt.Errorf("trusted_height (%d) is set but trusted_header_hash or trusted_data_hash is empty. When using trusted_height, both trusted_header_hash and trusted_data_hash must also be provided for security verification", + c.P2P.TrustedHeight) + } + if err := c.Raft.Validate(); err != nil { return err } @@ -459,6 +475,9 @@ func AddFlags(cmd *cobra.Command) { cmd.Flags().String(FlagP2PPeers, def.P2P.Peers, "Comma separated list of seed nodes to connect to") cmd.Flags().String(FlagP2PBlockedPeers, def.P2P.BlockedPeers, "Comma separated list of nodes to ignore") cmd.Flags().String(FlagP2PAllowedPeers, def.P2P.AllowedPeers, "Comma separated list of nodes to whitelist") + cmd.Flags().Uint64(FlagTrustedHeight, def.P2P.TrustedHeight, "block height to trust for sync initialization (0 = start from genesis)") + cmd.Flags().String(FlagTrustedHeaderHash, def.P2P.TrustedHeaderHash, "hash of the trusted header for security verification") + cmd.Flags().String(FlagTrustedDataHash, def.P2P.TrustedDataHash, "hash of the trusted data for security verification") // RPC configuration flags cmd.Flags().String(FlagRPCAddress, def.RPC.Address, "RPC server address (host:port)") diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go index 1834e1b40..53e9f8e49 100644 --- a/pkg/config/config_test.go +++ b/pkg/config/config_test.go @@ -112,7 +112,7 @@ func TestAddFlags(t *testing.T) { assertFlagValue(t, flags, FlagRPCEnableDAVisualization, DefaultConfig().RPC.EnableDAVisualization) // Count the number of flags we're explicitly checking - expectedFlagCount := 63 // Update this number if you add more flag checks above + expectedFlagCount := 66 // Update this number if you add more flag checks above // Get the actual number of flags (both regular and persistent) actualFlagCount := 0 @@ -513,3 +513,58 @@ func TestBasedSequencerValidation(t *testing.T) { }) } } + +func TestTrustedHeightValidation(t *testing.T) { + tests := []struct { + name string + trustedHeight uint64 + trustedHash string + expectError bool + errorMsg string + }{ + { + name: "trusted height with empty hash should fail", + trustedHeight: 100, + trustedHash: "", + expectError: true, + errorMsg: "trusted_height (100) is set but trusted_header_hash or trusted_data_hash is empty", + }, + { + name: "trusted height with valid hash should pass", + trustedHeight: 100, + trustedHash: "abc123", + expectError: false, + }, + { + name: "zero trusted height with empty hash should pass", + trustedHeight: 0, + trustedHash: "", + expectError: false, + }, + { + name: "zero trusted height with hash should pass (not validated)", + trustedHeight: 0, + trustedHash: "abc123", + expectError: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := DefaultConfig() + cfg.RootDir = t.TempDir() + cfg.P2P.TrustedHeight = tt.trustedHeight + cfg.P2P.TrustedHeaderHash = tt.trustedHash + cfg.P2P.TrustedDataHash = tt.trustedHash + + err := cfg.Validate() + + if tt.expectError { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.errorMsg) + } else { + require.NoError(t, err) + } + }) + } +} diff --git a/pkg/config/defaults.go b/pkg/config/defaults.go index 0de2f4bc2..fd9a6ed6f 100644 --- a/pkg/config/defaults.go +++ b/pkg/config/defaults.go @@ -57,8 +57,10 @@ func DefaultConfig() Config { RootDir: DefaultRootDir, DBPath: "data", P2P: P2PConfig{ - ListenAddress: "/ip4/0.0.0.0/tcp/7676", - Peers: "", + ListenAddress: "/ip4/0.0.0.0/tcp/7676", + Peers: "", + TrustedHeight: 0, + TrustedHeaderHash: "", }, Node: NodeConfig{ Aggregator: false, diff --git a/pkg/sync/sync_service.go b/pkg/sync/sync_service.go index 8567e7976..1f723c45a 100644 --- a/pkg/sync/sync_service.go +++ b/pkg/sync/sync_service.go @@ -59,6 +59,11 @@ type SyncService[H store.EntityWithDAHint[H]] struct { topicSubscription header.Subscription[H] storeInitialized atomic.Bool + + // trustedHeight tracks the configured trusted height for sync initialization + trustedHeight uint64 + // trustedHeaderHash, trustedDataHash is the expected hash of the trusted header + trustedHeaderHash, trustedDataHash string } // NewDataSyncService returns a new DataSyncService. @@ -198,6 +203,11 @@ func (syncService *SyncService[H]) Start(ctx context.Context) error { return fmt.Errorf("failed to create syncer: %w", err) } + // Initialize trusted height configuration + syncService.trustedHeight = syncService.conf.P2P.TrustedHeight + syncService.trustedHeaderHash = syncService.conf.P2P.TrustedHeaderHash + syncService.trustedDataHash = syncService.conf.P2P.TrustedDataHash + // initialize stores from P2P (blocking until genesis is fetched for followers) // Aggregators (no peers configured) return immediately and initialize on first produced block. if err := syncService.initFromP2PWithRetry(ctx, peerIDs); err != nil { @@ -322,7 +332,7 @@ func (syncService *SyncService[H]) startSubscriber(ctx context.Context) error { return nil } -// Height returns the current height stored +// Height returns the current height storeda func (s *SyncService[H]) Height() uint64 { return s.store.Height() } @@ -331,11 +341,19 @@ func (s *SyncService[H]) Height() uint64 { // It inspects the local store to determine the first height to request: // - when the store already contains items, it reuses the latest height as the starting point; // - otherwise, it falls back to the configured genesis height. +// - if trusted height is configured, it fetches from that height first and verifies the hash. func (syncService *SyncService[H]) initFromP2PWithRetry(ctx context.Context, peerIDs []peer.ID) error { if len(peerIDs) == 0 { return nil } + // If trusted height is configured, fetch from that height first + if syncService.trustedHeight > 0 { + if err := syncService.fetchAndVerifyTrustedHeader(ctx, peerIDs); err != nil { + return fmt.Errorf("failed to fetch trusted header at height %d: %w", syncService.trustedHeight, err) + } + } + tryInit := func(ctx context.Context) (bool, error) { var ( trusted H @@ -346,7 +364,12 @@ func (syncService *SyncService[H]) initFromP2PWithRetry(ctx context.Context, pee head, headErr := syncService.store.Head(ctx) switch { case errors.Is(headErr, header.ErrNotFound), errors.Is(headErr, header.ErrEmptyStore): - heightToQuery = syncService.genesis.InitialHeight + // If we have a trusted header, use its height as the starting point + if syncService.trustedHeight > 0 { + heightToQuery = syncService.trustedHeight + } else { + heightToQuery = syncService.genesis.InitialHeight + } case headErr != nil: return false, fmt.Errorf("failed to inspect local store head: %w", headErr) default: @@ -405,6 +428,37 @@ func (syncService *SyncService[H]) initFromP2PWithRetry(ctx context.Context, pee } } +// fetchAndVerifyTrustedHeader fetches the header at the trusted height from P2P +// and verifies it matches the trusted hash. If verification passes, it stores the header. +func (syncService *SyncService[H]) fetchAndVerifyTrustedHeader(ctx context.Context, peerIDs []peer.ID) error { + syncService.logger.Info().Uint64("height", syncService.trustedHeight).Msg("fetching trusted header from P2P") + + // Fetch the header from trusted height + trusted, err := syncService.ex.GetByHeight(ctx, syncService.trustedHeight) + if err != nil { + return fmt.Errorf("failed to fetch trusted header at height %d: %w", syncService.trustedHeight, err) + } + + // Verify the hash matches + actualHash := trusted.Hash().String() + if actualHash != syncService.trustedHeaderHash && actualHash != syncService.trustedDataHash { + return fmt.Errorf("trusted header hash mismatch at height %d: expected %s or %s, got %s", + syncService.trustedHeight, syncService.trustedHeaderHash, syncService.trustedDataHash, actualHash) + } + + syncService.logger.Info().Uint64("height", syncService.trustedHeight). + Str("hash", actualHash). + Msg("trusted header verified and stored") + + if err := syncService.store.Append(ctx, trusted); err != nil { + return fmt.Errorf("failed to store trusted header: %w", err) + } + + syncService.storeInitialized.Store(true) + + return nil +} + // Stop is a part of Service interface. // // `store` is closed last because it's used by other services.