diff --git a/README.md b/README.md index 240948e8c..c5425b9c2 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,9 @@ Use **alpha** for reliability; use **dev** if you want the newest changes and ca ## Running Mithril -The `run` command starts Mithril as a live full node - it bootstraps from a Solana snapshot and continuously verifies new blocks as they are produced on mainnet-beta. +The `run` command starts Mithril as a live full node - it bootstraps from a Solana snapshot and continuously verifies new blocks as they are produced. + +**This branch builds an Alpenglow-only node.** It boots against Alpenglow clusters (`network.cluster = "alpenglow"`, the default), streams live blocks from native turbine shreds by default, and uses Alpenglow certificates as the source of truth for fork choice and durable state. TowerBFT clusters (`mainnet-beta`/`testnet`/`devnet`) need a build from the `dev` branch until those clusters upgrade to Alpenglow. ### Nix (NixOS / nix-darwin / Home Manager) @@ -122,25 +124,40 @@ This builds the `mithril` binary with version, commit, and branch information em ### Configuration -Generate a starter config with sensible defaults: +Mithril runs as one of two node types, selected by `[consensus].mode`: + +- **Verifying node** (`mode = "verifying"`, the default) — non-voting: observes, executes, and verifies the cluster. No keypairs required. +- **Validator** (`mode = "validator"`) — enforces the full voting-deployment shape at startup: identity + vote-account keypairs, the turbine block source with a gossip entrypoint, and the Votor QUIC listener. The voting engine has not landed yet, so a validator-mode node runs the same verifying pipeline and casts **no votes** — but selecting it now means the deployment is provisioned and the config stays valid when voting activates. Both node types share the same fork choice. + +Generate a starter config for your node type: ```bash +# Verifying node (default) ./mithril config init + +# Validator (keypair/socket fields laid out and required) +./mithril config init --validator ``` -This creates `config.toml`. **We strongly recommend reviewing [`config.example.toml`](config.example.toml)** for all available options and detailed documentation. +This creates `config.toml`. **We strongly recommend reviewing [`config.example.toml`](config.example.toml)** for all available options and detailed documentation. At minimum, set: + +- `[network].rpc` — RPC endpoint(s), used for catchup, tip polling, and execution verification +- `[turbine].gossip_entrypoint` — a gossip entrypoint of your Alpenglow cluster. **Required for the default turbine block source**: without it the node cannot join the shred tree. +- Validator profile only: the `[validator]` identity and vote-account keypair paths. Keep the authorized-withdrawer keypair **offline** — it is not needed at runtime. + +There is also an interactive wizard (`./mithril setup`) that asks for the node type first and generates the matching config, and `./mithril doctor` validates an existing one. **Important: RPC Configuration** -The default config uses `api.mainnet-beta.solana.com` as the RPC endpoint, but this public endpoint has low rate limits and is not suitable for getting blocks. For reliable operation, add a dedicated RPC provider (Helius, Triton, etc.) as the primary endpoint: +Public RPC endpoints have low rate limits. For reliable operation, use a dedicated RPC endpoint for your Alpenglow cluster as the primary: ```toml [network] - # Primary RPC first, public endpoint as fallback - rpc = ["https://your-rpc-provider.com", "https://api.mainnet-beta.solana.com"] + # Primary RPC first, fallbacks after + rpc = ["https://your-rpc-provider.example.com", "https://public-fallback.example.com"] ``` -Mithril will use the first endpoint for block fetching and fall back to others if needed. +Mithril uses the first endpoint and fails over to the others if needed. ### Running Mithril @@ -164,11 +181,11 @@ You can also specify a config file explicitly: **Note:** Do not run Mithril with `sudo`. The setup scripts automatically configure directory permissions for your user. **What happens:** -1. Mithril queries the Solana cluster to find reliable snapshot sources +1. Mithril queries the cluster to find reliable snapshot sources 2. The full snapshot is streamed and processed (optionally saved to disk for faster restarts) 3. An incremental snapshot is fetched to bring the state closer to the tip -4. Mithril block execution (aka replay) is initiated and blocks are retrieved with RPC `getBlock` calls and verified -5. Mithril keeps up very close to the tip of the chain with recommended hardware specs +4. Replay catches up toward the tip with RPC `getBlock` calls, then hands off to live blocks reconstructed from native turbine shreds (which carry the Alpenglow block ids and footer certificates) +5. Blocks execute the moment they are assembled; Alpenglow certificates drive fork choice and gate what is promoted to durable storage, and a trailing verifier cross-checks execution results against finalized RPC blocks ### Mithril's Simple RPC Server @@ -206,16 +223,15 @@ We're actively expanding RPC method coverage. Upcoming methods include transacti ### Current Limitations -- **Block Catchup**: Mithril currently relies on `getBlock` RPC calls to catch up to the tip of mainnet-beta. This dependency is temporary — we are actively working on direct shred replay, which will eliminate the need for external RPC sources entirely. +- **RPC still required**: live near-tip blocks stream from turbine shreds, but RPC `getBlock` is still used for catchup and by the trailing execution verifier (Alpenglow certificates attest block *data*, not execution results, so an external oracle cross-checks execution until peer bankhash cross-checking lands). +- **Voting engine not yet active**: validator mode provisions and enforces the full voting deployment shape, but the node runs verify-only until the voting engine lands. Block production, repair serving, and Rotor relay duty are also future work. ### RPC Sources -Mithril fetches blocks via `getBlock` RPC calls during catchup. For **short-term testing**, most free Solana RPC plans are sufficient to try out Mithril. +Mithril fetches blocks via `getBlock` RPC calls during catchup and uses RPC for trailing execution verification. For **short-term testing**, most free Solana RPC plans are sufficient to try out Mithril. For **extended testing** or if you'd like to help with longer-running nodes, reach out to us on the [Overclock Validator Discord](https://discord.gg/overclock) — we can provide access to our RPC endpoints. -Once direct shred replay is implemented, external RPC sources will no longer be required for block fetching. - ### Updating Mithril To update Mithril to a newer version: @@ -267,15 +283,15 @@ See [COMPATIBILITY.md](COMPATIBILITY.md) for supported networks and feature gate ### Milestone 3 (In Progress): Alpha Release and System Optimization - First formal audit (https://runtimeverification.com/ team is nearing end of audit). Includes development and intensive use of a robust and comprehensive 'conformance suite' for verification of compliance of the VM, interpreter, and runtime as a complete unit. Differential fuzzing will be used to detect differences versus relevant versions of the Labs client, and guided fuzzing will be used generally to uncover security and loss-of-availability issues. Any bugs identified during this phase will be remediated. - Thorough optimization work on entire system, including on components such as the Virtual Machine and AccountsDB. -- Consensus verification implementation. -- Direct shred replay support (alternative to RPC-based block fetching and requires consensus implementation). +- Consensus verification implementation (landed on this branch: the Alpenglow certificate engine drives fork choice and durable-state promotion). +- Direct shred replay support (landed on this branch: native turbine shred streaming is the default block source). - Achieve multi-epoch runs without bugs (e.g. bankhash mismatches with mainnet) - Transaction simulation and transaction sending - Earlier testing on testnet environments. - **Target**: More polished release midway through Q1 2026. ### Future Directions -- Implement Alpenglow consensus verification. +- Complete Alpenglow validator mode: the voting engine (Votor event loop, BLS vote signing, durable vote history) on top of the existing fork choice, plus block production, repair serving, and Rotor relay duty. - Add Agave ledger-tool type features for Mithril - gRPC interface support. - Expanded RPC feature set. diff --git a/cmd/mithril/configcmd/configcmd.go b/cmd/mithril/configcmd/configcmd.go index a3c75342b..2b0326959 100644 --- a/cmd/mithril/configcmd/configcmd.go +++ b/cmd/mithril/configcmd/configcmd.go @@ -30,6 +30,15 @@ var ( The generated config has all parameters with good defaults - you only need to customize the storage paths for your setup. +Two profiles: + mithril config init Verifying node (non-voting) — the default. + mithril config init --validator Validator — consensus.mode=validator with the + required keypair/socket fields laid out + (identity + vote-account keypairs, turbine + gossip entrypoint, Votor QUIC listener). + The voting engine is not yet active; the + node runs verify-only until it lands. + If config.toml already exists, this command will not overwrite it.`, Run: func(cmd *cobra.Command, args []string) { runConfigInit() @@ -77,8 +86,9 @@ Examples: }, } - outputPath string - configFile string + outputPath string + initValidator bool + configFile string ) func init() { @@ -86,6 +96,7 @@ func init() { ConfigCmd.AddCommand(&SetCmd) ConfigCmd.AddCommand(&GetCmd) InitCmd.Flags().StringVarP(&outputPath, "output", "o", "config.toml", "Output path for config file") + InitCmd.Flags().BoolVar(&initValidator, "validator", false, "Generate a validator config (consensus.mode=validator with required keypair/socket fields)") SetCmd.Flags().StringVarP(&configFile, "config", "c", "config.toml", "Path to config file") GetCmd.Flags().StringVarP(&configFile, "config", "c", "config.toml", "Path to config file") } @@ -98,7 +109,7 @@ func runConfigInit() { } // Generate the config content - config := generateStarterConfig() + config := generateStarterConfig(initValidator) // Write to file if err := tui.AtomicWriteFile(outputPath, []byte(config), 0600); err != nil { @@ -111,16 +122,58 @@ func runConfigInit() { fmt.Println() fmt.Println("Next steps:") fmt.Println(" 1. Edit the [storage] paths for your setup") - fmt.Println(" 2. Run: mithril run --config config.toml") + fmt.Println(" 2. Set [network].rpc and [turbine].gossip_entrypoint for your Alpenglow cluster") + if initValidator { + fmt.Println(" 3. Set [validator].identity_keypair and vote_account_keypair —") + fmt.Println(" validator mode refuses to start without them") + fmt.Println(" (keep the authorized withdrawer keypair OFFLINE; it is not needed at runtime)") + } else { + fmt.Println(" 3. For a staked node, set [validator].identity_keypair and") + fmt.Println(" [consensus].alpenglow_observer_bind_addr (Votor QUIC cert feed)") + } + fmt.Println(" 4. Run: mithril run --config config.toml") fmt.Println() fmt.Println("See config.example.toml for detailed documentation of all options.") } -func generateStarterConfig() string { +func generateStarterConfig(validator bool) string { // Pick storage paths that work for the current environment: production // /mnt/mithril-* when scripts/disk-setup.sh has been run, ~/.mithril/* // otherwise. See pkg/config/defaults.go for detection details. s := config.DefaultStoragePaths() + + // The [validator] + [consensus] sections are the profile split: a + // verifying node needs neither keypairs nor the Votor listener; validator + // mode REQUIRES identity + vote-account keypairs, a turbine gossip + // entrypoint, and the Votor QUIC listener (enforced at startup). + nodeSections := `[validator] +identity_keypair = "" # Validator identity — advertises this node into turbine gossip; set for a staked Alpenglow node +vote_account_keypair = "" # Vote account keypair path (used once voting activates) +authorized_withdrawer_keypair = "" # Authorized withdrawer keypair path (diagnostics only) + +[consensus] +mode = "verifying" # "verifying" (default, non-voting) | "validator" +alpenglow_observer_bind_addr = "" # Votor QUIC cert listener, e.g. "0.0.0.0:8010" (empty = rely on footer certs in shreds) +alpenglow_max_message_bytes = 0 # 0 = default +alpenglow_bls_dst = "" # BLS DST override (must match cluster solana-bls version)` + if validator { + nodeSections = `[validator] +# REQUIRED in validator mode — the node refuses to start without these two. +identity_keypair = "/path/to/validator-keypair.json" # Signs gossip/turbine identity and, once voting activates, votes +vote_account_keypair = "/path/to/vote-account-keypair.json" # The vote account votes are cast for +# NOT required at runtime — keep the withdrawer keypair OFFLINE. +authorized_withdrawer_keypair = "" + +[consensus] +# Validator mode enforces the full voting-deployment shape (keypairs above, +# turbine source + gossip entrypoint, Votor QUIC listener below) so the +# deployment is provisioned before the voting engine activates. Until it +# lands the node runs the same verifying pipeline and casts NO votes. +mode = "validator" +alpenglow_observer_bind_addr = "0.0.0.0:8010" # REQUIRED: Votor QUIC vote/cert listener +alpenglow_max_message_bytes = 0 # 0 = default +alpenglow_bls_dst = "" # BLS DST override (must match cluster solana-bls version)` + } return fmt.Sprintf(`# Mithril Configuration # Generated by: mithril config init # See config.example.toml for detailed documentation of all options. @@ -137,17 +190,20 @@ snapshots = %q # ~100GB for full + incremental logs = %q # Log files (created if missing) [network] -cluster = "mainnet-beta" # Required: "mainnet-beta" | "testnet" | "devnet" | "alpenglow" -rpc = ["https://api.mainnet-beta.solana.com"] +cluster = "alpenglow" # This build boots Alpenglow only (TowerBFT clusters need a dev-branch build) +rpc = ["https://alpenglow.rpcpool.com"] [block] -source = "rpc" # "rpc" | "lightbringer" | "turbine" +# "turbine" is the live mode: shreds carry the Alpenglow block ids and footer +# certificates that gate durable state. "rpc" is catch-up/debug only — RPC +# blocks carry no certificates, so near-tip operation cannot adjudicate them +# and durable folds stall without a Votor QUIC cert feed ([consensus] below). +source = "turbine" # "turbine" (live) | "rpc" (catch-up/debug) | "lightbringer" +turbine_bind_addr = "0.0.0.0:8001" # lightbringer_endpoint = "localhost:9000" -# turbine_bind_addr = "0.0.0.0:8001" -# [turbine] -# bind_addr = "0.0.0.0:8001" -# gossip_entrypoint = "1.2.3.4:8000" +[turbine] +gossip_entrypoint = "" # REQUIRED for turbine: a gossip entrypoint of your Alpenglow cluster # gossip_bind_addr = "0.0.0.0:65401" # advertised_ip = "203.0.113.10" # shred_version = 0 @@ -164,18 +220,7 @@ source = "rpc" # "rpc" | "lightbringer" | "turbine" [tuning] txpar = 24 # Recommended: 2x your CPU core count -[validator] -identity_keypair = "" # Optional validator identity for native turbine gossip -vote_account_keypair = "" # Optional vote account keypair path for diagnostics/future voting -authorized_withdrawer_keypair = "" # Optional authorized withdrawer keypair path for diagnostics - -[consensus] -mode = "classic" # "classic" | "alpenglow-observer" | "alpenglow" -alpenglow_observer_bind_addr = "" # Optional Votor QUIC listener for observer mode -alpenglow_max_message_bytes = 0 # 0 = default -unresolved_policy = "halt" # "halt" | "warn" -skip_path_max_depth = 64 -enforce_on_source = "stream" +%s [rpc] port = 8899 # Mithril's RPC server (binds to all interfaces) @@ -185,11 +230,11 @@ dir = %q # Log files (created if missing) level = "info" # "debug" | "info" | "warn" | "error" to_stdout = true # Also write to stdout max_size_mb = 100 # Max log file size before rotation -max_age_days = 7 # Delete logs older than this +# max_age_days = 0 # Delete logs older than N days (0/unset = never delete by age) # Advanced options (defaults work well for most setups) # See config.example.toml for: [tuning], [debug], [snapshot], [reporting] -`, s.Accounts, s.Shredstore, s.Snapshots, s.Logs, s.Logs) +`, s.Accounts, s.Shredstore, s.Snapshots, s.Logs, nodeSections, s.Logs) } // runConfigSet updates a key in the config file diff --git a/cmd/mithril/configcmd/edit.go b/cmd/mithril/configcmd/edit.go index e4e20f91c..acd3ffe41 100644 --- a/cmd/mithril/configcmd/edit.go +++ b/cmd/mithril/configcmd/edit.go @@ -117,7 +117,7 @@ type editModel struct { func newEditModel(cf string, v *viper.Viper) editModel { cluster := v.GetString("network.cluster") if cluster == "" { - cluster = "mainnet-beta" + cluster = "alpenglow" // the only cluster this build boots } rpcSlice := v.GetStringSlice("network.rpc") rpcEndpoint := "" @@ -275,10 +275,10 @@ func (m editModel) currentItems() []edItem { } case edScrCluster: return []edItem{ - {label: "mainnet-beta", value: "mainnet-beta"}, - {label: "testnet", value: "testnet"}, - {label: "devnet", value: "devnet"}, - {label: "alpenglow", value: "alpenglow"}, + {label: "alpenglow", value: "alpenglow", desc: "The only cluster this build boots"}, + {label: "mainnet-beta", value: "mainnet-beta", desc: "Requires a dev-branch (TowerBFT) build"}, + {label: "testnet", value: "testnet", desc: "Requires a dev-branch (TowerBFT) build"}, + {label: "devnet", value: "devnet", desc: "Requires a dev-branch (TowerBFT) build"}, {isSep: true}, {label: "← Back", value: "_back"}, } @@ -675,10 +675,11 @@ func (m *editModel) saveConfig() { content = setTomlValue(content, "lightbringer", "quiet", "false") } } else { - // Only force block.source="rpc" if no external lightbringer_endpoint is configured. - // External LB mode (enabled=false + endpoint set) is a valid runtime config. + // Only force a source change if no external lightbringer_endpoint is + // configured. External LB mode (enabled=false + endpoint set) is a valid + // runtime config. Turbine is the default live Alpenglow source. if m.v.GetString("block.lightbringer_endpoint") == "" { - content = setTomlValue(content, "block", "source", "\"rpc\"") + content = setTomlValue(content, "block", "source", "\"turbine\"") } if strings.Contains(content, "[lightbringer]") { content = setTomlValue(content, "lightbringer", "enabled", "false") diff --git a/cmd/mithril/dashboardcmd/data.go b/cmd/mithril/dashboardcmd/data.go index 46d786259..776a0d8fc 100644 --- a/cmd/mithril/dashboardcmd/data.go +++ b/cmd/mithril/dashboardcmd/data.go @@ -363,17 +363,13 @@ func runDoctorChecks(configFile string, cfg *configData) []checkResult { results = append(results, checkResult{"RPC endpoint", "fail", "no RPC endpoints configured"}) } - consensusMode := cfg.consensusMode - if consensusMode == "" { - consensusMode = "classic" - } - switch strings.ToLower(strings.TrimSpace(consensusMode)) { - case "classic", "legacy", "alpenglow-observer": - results = append(results, checkResult{"Consensus", "pass", consensusMode}) - case "alpenglow": - results = append(results, checkResult{"Consensus", "warn", "alpenglow voting mode is not implemented yet"}) + switch cfg.consensusMode { + case "", "verifying": + results = append(results, checkResult{"Node mode", "pass", "verifying (non-voting)"}) + case "validator": + results = append(results, checkResult{"Node mode", "pass", "validator (voting engine not yet active)"}) default: - results = append(results, checkResult{"Consensus", "fail", "invalid mode: " + consensusMode}) + results = append(results, checkResult{"Node mode", "fail", "invalid consensus.mode: " + cfg.consensusMode}) } if cfg.alpenglowBindAddr != "" { if _, _, err := net.SplitHostPort(cfg.alpenglowBindAddr); err != nil { diff --git a/cmd/mithril/node/node.go b/cmd/mithril/node/node.go index 758d30ab8..f640d08c8 100644 --- a/cmd/mithril/node/node.go +++ b/cmd/mithril/node/node.go @@ -6,6 +6,7 @@ import ( "context" "crypto/ed25519" "encoding/base64" + "encoding/json" "errors" "fmt" "io" @@ -67,14 +68,15 @@ var ( accountsPath string scratchDirectory string rpcEndpoints []string - cluster string // "mainnet-beta", "testnet", "devnet" - blockSource string // "rpc", "lightbringer", or "turbine" + cluster string // "alpenglow" (the only cluster this build boots) + blockSource string // "turbine" (default), "rpc", or "lightbringer" lightbringerEndpoint string - blockMaxRPS int // Rate limit for block fetching - blockMaxInflight int // Max concurrent block fetch workers - blockTipPollIntervalMs int // Tip poll interval in milliseconds - blockTipSafetyMargin int // Don't fetch within N slots of tip - consensusMode string + blockMaxRPS int // Rate limit for block fetching + blockMaxInflight int // Max concurrent block fetch workers + blockTipPollIntervalMs int // Tip poll interval in milliseconds + blockTipSafetyMargin int // Don't fetch within N slots of tip + consensusModeFlag string // raw --consensus-mode value (cobra binding) + consensusMode string // resolved: "verifying" (default) or "validator" alpenglowObserverBindAddr string alpenglowMaxMessageBytes int64 alpenglowBLSDST string @@ -95,6 +97,7 @@ var ( logDir string numReplaySlots int64 endSlot int64 + rewindToSlot int64 // --rewind-to-slot: restore durable state to a fold batch boundary at startup pprofPort int64 blockstorePath string txParallelism int64 @@ -169,10 +172,10 @@ func epochForStateSlot(s *state.MithrilState, slot uint64) uint64 { } // alpenglowAddrForGossip returns the Votor QUIC address to advertise in gossip, -// or "" when not in observer mode or the bind address has no fixed port. -func alpenglowAddrForGossip(mode consensusengine.Mode, bindAddr string) string { +// or "" when the bind address is empty or has no fixed port. +func alpenglowAddrForGossip(bindAddr string) string { bindAddr = strings.TrimSpace(bindAddr) - if mode != consensusengine.ModeAlpenglowObserver || bindAddr == "" { + if bindAddr == "" { return "" } _, portRaw, err := net.SplitHostPort(bindAddr) @@ -273,7 +276,7 @@ func init() { // [network] section flags Run.Flags().StringSliceVarP(&rpcEndpoints, "rpc", "r", []string{}, "URL(s) for RPC endpoint(s) - can specify multiple") - Run.Flags().StringVar(&cluster, "cluster", "", "Solana cluster: 'mainnet-beta', 'testnet', or 'devnet'") + Run.Flags().StringVar(&cluster, "cluster", "alpenglow", "Solana cluster: 'alpenglow' (default; the only cluster this build boots — 'mainnet-beta'/'testnet'/'devnet' need a dev-branch TowerBFT build until they upgrade to Alpenglow)") // [rpc] section flags (Mithril's RPC server) Run.Flags().IntVar(&rpcPort, "rpc-port", 0, "RPC server port. Default off.") @@ -284,8 +287,8 @@ func init() { Run.Flags().Int64VarP(&endSlot, "end-slot", "e", -1, "Block at which to stop replaying, inclusive (-1 = run continuously)") // [consensus] section flags - Run.Flags().StringVar(&consensusMode, "consensus-mode", string(consensusengine.ModeClassic), "Consensus mode: 'classic', 'alpenglow-observer', or 'alpenglow'") - Run.Flags().StringVar(&alpenglowObserverBindAddr, "alpenglow-observer-bind-addr", "", "Passive Alpenglow Votor QUIC listener address for consensus-mode=alpenglow-observer") + Run.Flags().StringVar(&consensusModeFlag, "consensus-mode", "verifying", "Node mode: 'verifying' (default; non-voting — observe, execute, and verify) or 'validator' (requires identity + vote-account keypairs, turbine source, gossip entrypoint, and the Votor QUIC listener; voting engine not yet active)") + Run.Flags().StringVar(&alpenglowObserverBindAddr, "alpenglow-observer-bind-addr", "", "Passive Alpenglow Votor QUIC listener address") Run.Flags().StringVar(&alpenglowBLSDST, "alpenglow-bls-dst", "", "BLS hash-to-curve DST override (must match cluster's solana-bls version; empty = default)") Run.Flags().Int64Var(&alpenglowMaxMessageBytes, "alpenglow-max-message-bytes", 0, "Maximum Alpenglow Votor QUIC stream payload size (0 = default)") Run.Flags().StringVar(&validatorIdentityKeypair, "identity-keypair", "", "Validator identity keypair for native turbine gossip (Solana keygen JSON)") @@ -305,6 +308,7 @@ func init() { Run.Flags().BoolVar(&sbpf.UsePool, "use-pool", true, "Disable to allocate fresh slices") Run.Flags().IntVar(&accountsdb.StoreAccountsWorkers, "store-accounts-workers", 128, "Number of workers to write account updates") Run.Flags().IntVar(&accountsdb.ProgramCacheMaxMB, "program-cache-max-mb", accountsdb.DefaultProgramCacheMaxMB, "Maximum approximate SBPF program cache size in MiB") + Run.Flags().Int64Var(&rewindToSlot, "rewind-to-slot", 0, "Rewind durable account state to the fold batch boundary at this slot before replaying (must be a retained boundary; run once to list boundaries on mismatch)") // [tuning.pprof] section flags Run.Flags().Int64Var(&pprofPort, "pprof-port", -1, "Port to serve HTTP pprof endpoint") @@ -319,7 +323,7 @@ func init() { Run.Flags().StringVar(&scratchDirectory, "scratch-directory", "/tmp", "Path for downloads (e.g. snapshots) and other temp state") // [block] section flags - Run.Flags().StringVar(&blockSource, "block-source", "rpc", "Block source: 'rpc', 'lightbringer', or 'turbine'") + Run.Flags().StringVar(&blockSource, "block-source", "turbine", "Block source: 'turbine' (default, live), 'rpc' (catch-up/debug), or 'lightbringer'") Run.Flags().StringVar(&lightbringerEndpoint, "lightbringer-endpoint", "", "Address for Lightbringer endpoint (only used when block-source=lightbringer)") Run.Flags().StringVar(&turbineBindAddr, "turbine-bind-addr", "", "UDP address for native turbine shred receiver (only used when block-source=turbine)") Run.Flags().StringVar(&turbineGossipEntrypoint, "turbine-gossip-entrypoint", "", "Solana gossip entrypoint for native turbine tree joining") @@ -537,17 +541,24 @@ func initConfigAndBindFlags(cmd *cobra.Command) error { rpcEndpoints = getStringSlice("rpc", "rpc.rpc") } - // Cluster is required for safety (prevents mainnet/testnet mixups) + // Cluster is required for safety (prevents mainnet/testnet mixups). + // This build is Alpenglow-only: replay applies Alpenglow clock/feature + // semantics unconditionally, which would diverge the bankhash on a + // TowerBFT cluster. Refuse anything except an Alpenglow cluster. cluster = getString("cluster", "network.cluster") if cluster == "" { - return fmt.Errorf("network.cluster is required - set to 'mainnet-beta', 'testnet', 'devnet', or 'alpenglow'") + cluster = "alpenglow" // the only cluster this build boots + mlog.Log.Infof("network.cluster not set; defaulting to %q", cluster) } - // Validate cluster value - switch cluster { - case "mainnet-beta", "testnet", "devnet", "alpenglow": - // Valid - default: - return fmt.Errorf("invalid network.cluster %q - must be 'mainnet-beta', 'testnet', 'devnet', or 'alpenglow'", cluster) + if cluster != "alpenglow" { + return fmt.Errorf("network.cluster %q is not supported by this Alpenglow-only build (TowerBFT clusters need a mithril build from the dev branch until they upgrade to Alpenglow)", cluster) + } + + // Must be decided before any OpenDb call: with the WAL off, the fold + // manifests are the index redo log (recovery replays them). + if config.IsSet("storage.index_wal") && !config.GetBool("storage.index_wal") { + accountsdb.DisableIndexWAL = true + mlog.Log.Warnf("storage.index_wal=false: account index runs without a Pebble WAL; fold manifests serve as the index redo log") } // [rpc] section - Mithril's RPC server @@ -556,16 +567,20 @@ func initConfigAndBindFlags(cmd *cobra.Command) error { // Top-level scratchDirectory = getString("scratch-directory", "scratch_directory") - // [consensus] mode + [validator] keypairs - rawConsensusMode := getString("consensus-mode", "consensus.mode") - normalizedConsensusMode, err := consensusengine.NormalizeMode(rawConsensusMode) - if err != nil { - return err - } - if strings.EqualFold(strings.TrimSpace(rawConsensusMode), "legacy") { - mlog.Log.Warnf("config: consensus.mode=\"legacy\" is accepted as an alias; prefer \"classic\"") + // [consensus] + [validator] keypairs + consensusMode = getString("consensus-mode", "consensus.mode") + switch consensusMode { + case "", "verifying": + consensusMode = "verifying" + case "validator": + // Selectable now so validator deployments (keypairs, sockets, gossip) + // are provisioned and validated ahead of the voting engine landing. + // Requirements are enforced below once the block/turbine settings are + // resolved; until the engine ships the node runs the same verifying + // pipeline and casts no votes (warned loudly at startup). + default: + return fmt.Errorf("unknown consensus.mode %q (valid: \"verifying\", \"validator\")", consensusMode) } - consensusMode = string(normalizedConsensusMode) alpenglowObserverBindAddr = getString("alpenglow-observer-bind-addr", "consensus.alpenglow_observer_bind_addr") alpenglowMaxMessageBytes = getInt64("alpenglow-max-message-bytes", "consensus.alpenglow_max_message_bytes") alpenglowBLSDST = getString("alpenglow-bls-dst", "consensus.alpenglow_bls_dst") @@ -575,8 +590,20 @@ func initConfigAndBindFlags(cmd *cobra.Command) error { // [block] section blockSource = getString("block-source", "block.source") + blockSourceExplicit := blockSource != "" // operator chose (flag or config) vs defaulted if blockSource == "" { - blockSource = "rpc" // default + blockSource = "turbine" // default: native turbine is the live Alpenglow source + } + // RPC is a catch-up/debug source on Alpenglow, not a live one: RPC blocks + // carry no Alpenglow block ids or footer certificates, so certificate + // gating cannot adjudicate their identity near tip, and durable folds only + // advance if certificates arrive some other way (the Votor QUIC listener). + if blockSource == "rpc" { + if alpenglowObserverBindAddr == "" { + mlog.Log.Warnf("block source \"rpc\" with no consensus.alpenglow_observer_bind_addr: no certificate feed exists, so durable folds will NOT advance (fail-closed halt once the in-RAM tail fills); use block source \"turbine\" for live operation") + } else { + mlog.Log.Warnf("block source \"rpc\" is catch-up/debug only on Alpenglow (RPC blocks carry no block ids or footer certificates); live near-tip operation should use \"turbine\"") + } } lightbringerEndpoint = getString("lightbringer-endpoint", "block.lightbringer_endpoint") turbineBindAddr = getString("turbine-bind-addr", "block.turbine_bind_addr") @@ -628,10 +655,10 @@ func initConfigAndBindFlags(cmd *cobra.Command) error { return fmt.Errorf("lightbringer.enabled=true but lightbringer.gossip_entrypoint is empty") } // Default block.source to "lightbringer" if not explicitly configured - if blockSource == "rpc" && !flagChanged("block-source") { + if !blockSourceExplicit { blockSource = "lightbringer" - } else if blockSource == "rpc" && flagChanged("block-source") { - mlog.Log.Warnf("lightbringer.enabled=true but --block-source=rpc was set explicitly; sidecar will start but will not be used for block delivery") + } else if blockSource != "lightbringer" { + mlog.Log.Warnf("lightbringer.enabled=true but block source %q was set explicitly; sidecar will start but will not be used for block delivery", blockSource) } // Auto-sync grpc_addr to lightbringer_endpoint if lightbringerEndpoint == "" { @@ -657,7 +684,8 @@ func initConfigAndBindFlags(cmd *cobra.Command) error { } case "turbine": if turbineBindAddr == "" { - return fmt.Errorf("block.source=turbine requires block.turbine_bind_addr or turbine.bind_addr") + turbineBindAddr = "0.0.0.0:8001" // documented default shred port + mlog.Log.Infof("turbine bind address not set; defaulting to %s", turbineBindAddr) } if turbineShredVersion < 0 || turbineShredVersion > 0xffff { return fmt.Errorf("turbine.shred_version must be between 0 and 65535") @@ -669,6 +697,34 @@ func initConfigAndBindFlags(cmd *cobra.Command) error { return fmt.Errorf("invalid block.source %q - must be 'rpc', 'lightbringer', or 'turbine'", blockSource) } + // Validator mode: enforce the deployment shape a voting node needs, even + // though the voting engine has not landed yet — operators provision once + // and the config stays valid when voting activates. + if consensusMode == "validator" { + var missing []string + if validatorIdentityKeypair == "" { + missing = append(missing, "validator.identity_keypair (signs gossip/turbine identity and, later, votes)") + } + if validatorVoteAccountKeypair == "" { + missing = append(missing, "validator.vote_account_keypair (the vote account votes are cast for)") + } + if blockSource != "turbine" { + missing = append(missing, fmt.Sprintf("block.source=turbine (a validator cannot run off %q)", blockSource)) + } + if turbineGossipEntrypoint == "" { + missing = append(missing, "turbine.gossip_entrypoint (join the cluster's turbine tree)") + } + if alpenglowObserverBindAddr == "" { + missing = append(missing, "consensus.alpenglow_observer_bind_addr (receive Votor vote/cert traffic)") + } + if len(missing) > 0 { + return fmt.Errorf("consensus.mode=validator requires:\n - %s", strings.Join(missing, "\n - ")) + } + // The authorized withdrawer is deliberately NOT required at runtime — + // best practice keeps it offline. + mlog.Log.Warnf("consensus.mode=validator: the voting engine is not implemented yet in this build — running the verifying pipeline, NO votes will be cast") + } + blockMaxRPS = getInt("block-max-rps", "block.max_rps") blockMaxInflight = getInt("block-max-inflight", "block.max_inflight") blockTipPollIntervalMs = getInt("block-tip-poll-ms", "block.tip_poll_interval_ms") @@ -923,11 +979,12 @@ func runLive(c *cobra.Command, args []string) { logCfg.MaxSizeMB = 100 } - // MaxAgeDays: default 7, but 0 means never delete + // MaxAgeDays: default 0 = never delete by age (retention is bounded by + // max_size_mb per file x max_backups). Set >0 to also prune by age. if config.IsSet("log.max_age_days") { logCfg.MaxAgeDays = config.GetInt("log.max_age_days") } else { - logCfg.MaxAgeDays = 7 + logCfg.MaxAgeDays = 0 } // MaxBackups: default 10, but 0 means unlimited @@ -986,7 +1043,7 @@ func runLive(c *cobra.Command, args []string) { } if validatorIdentityPubkey != "" { mlog.Log.Infof("validator identity configured for native gossip: %s", validatorIdentityPubkey) - } else if useTurbine && consensusMode == string(consensusengine.ModeAlpenglowObserver) && alpenglowObserverBindAddr != "" { + } else if useTurbine && alpenglowObserverBindAddr != "" { mlog.Log.Warnf("ALPENGLOW observer: no validator.identity_keypair configured; native gossip will use an ephemeral identity and may not receive staked Votor traffic") } @@ -1087,6 +1144,14 @@ func runLive(c *cobra.Command, args []string) { var accountsDb *accountsdb.AccountsDb var manifest *snapshot.SnapshotManifest var mithrilState *state.MithrilState + + // Fold recovery runs once on existing-DB startup, BEFORE + // ValidateAgainstBankhashDB, because it restores the index tail and the + // NoSync bankhash rows from the manifests — validating first could falsely + // condemn a healthy store whose R bankhash row was dropped by a hard kill. + // The result is memoized and reused for the state-watermark reconcile below. + var foldRecovery accountsdb.RecoveryResult + foldRecovered := false // Use configured snapshot directory (storage.snapshots / snapshot.download_path), not scratch snapshotDownloadPath := snapshotDlPath @@ -1189,6 +1254,10 @@ func runLive(c *cobra.Command, args []string) { klog.Fatalf("failed to load manifest: %v", err) } refreshManifestSeedFromManifest(accountsPath, mithrilState, manifest) + // Restore the durable fold frontier (index tail + NoSync bankhash rows) + // from the manifests before the integrity check reads those rows. + foldRecovery = mustRecoverFoldState(accountsDb) + foldRecovered = true // Run integrity check if we have a state file (warn only, don't fail - user chose force mode) if hasValidState { if err := mithrilState.ValidateAgainstBankhashDB(accountsDb); err != nil { @@ -1378,6 +1447,13 @@ func runLive(c *cobra.Command, args []string) { } refreshManifestSeedFromManifest(accountsPath, mithrilState, manifest) + // Restore the durable fold frontier (index tail + NoSync bankhash + // rows) from the manifests BEFORE the integrity check reads those + // rows — otherwise a hard kill that dropped a NoSync bankhash row + // would falsely condemn a healthy store and force a re-bootstrap. + foldRecovery = mustRecoverFoldState(accountsDb) + foldRecovered = true + // Validate state file matches AccountsDB (detect Ctrl+Z / kill -9 corruption) if err := mithrilState.ValidateAgainstBankhashDB(accountsDb); err != nil { mlog.Log.Errorf("INTEGRITY CHECK FAILED: %v", err) @@ -1497,7 +1573,7 @@ postBootstrap: if mithrilState != nil && mithrilState.LastRootedContext != nil { // Rooted-durable resume: build from the context as of the last rooted slot // (durable), not the Last* fields at the replayed tip (lost in RAM on restart). - rs, err := resumeStateFromRootedContext(mithrilState.LastRootedContext, mithrilState.ComputedEpochStakes) + rs, err := replay.ResumeStateFromRootedContext(mithrilState.LastRootedContext, mithrilState.ComputedEpochStakes) if err != nil { mlog.Log.Errorf("failed to build rooted-durable resume state: %v; will start fresh from snapshot", err) mithrilState = nil @@ -1543,7 +1619,7 @@ postBootstrap: // Decode blockhash context if mithrilState.LastRecentBlockhashes != nil && len(mithrilState.LastRecentBlockhashes) > 0 { - recentBlockhashes := decodeRecentBlockhashes(mithrilState.LastRecentBlockhashes) + recentBlockhashes := replay.DecodeRecentBlockhashes(mithrilState.LastRecentBlockhashes) resumeState.RecentBlockhashes = &recentBlockhashes if mithrilState.LastEvictedBlockhash != "" { @@ -1563,7 +1639,7 @@ postBootstrap: // Decode SlotHashes context (vote program needs accurate slot→hash mappings) if mithrilState.LastSlotHashes != nil && len(mithrilState.LastSlotHashes) > 0 { - slotHashes := decodeSlotHashes(mithrilState.LastSlotHashes) + slotHashes := replay.DecodeSlotHashes(mithrilState.LastSlotHashes) resumeState.SlotHashes = &slotHashes } @@ -1606,64 +1682,140 @@ postBootstrap: } accountsDb.InitCaches() - // Crash-safe durable commit (storage.durable_commit) and rooted-durable mode - // (storage.rooted_durable, keeps the store rooted-only, needs durable_commit). - accountsDb.DurableCommit = config.GetBool("storage.durable_commit") - accountsDb.RootedDurable = config.GetBool("storage.rooted_durable") - if accountsDb.RootedDurable && !accountsDb.DurableCommit { - klog.Fatalf("storage.rooted_durable requires storage.durable_commit=true (promotion uses the crash-safe commit path)") + // Alpenglow-only build: rooted-durable is the ONLY storage mode. Durable + // state holds finalized slots exclusively; replayed slots buffer in RAM and + // fold to disk in batches (storage.fold_batch_slots) once rooted. + if config.IsSet("storage.durable_commit") { + klog.Fatalf("storage.durable_commit was removed: batch folds replaced the per-slot redo-log commit path — delete the key (rooted-durable batching is always on)") + } + if config.IsSet("storage.rooted_durable") && !config.GetBool("storage.rooted_durable") { + klog.Fatalf("storage.rooted_durable=false is not supported by this build (durable state is rooted-only by design) — delete the key") + } + accountsDb.RootedDurable = true + if config.IsSet("storage.fork_aware") { + mlog.Log.Warnf("storage.fork_aware was removed: the working-set suffix engine handles fork switches via unwind+re-execute — delete the key") } - accountsDb.ForkAware = config.GetBool("storage.fork_aware") - if accountsDb.ForkAware && !accountsDb.RootedDurable { - klog.Fatalf("storage.fork_aware requires storage.rooted_durable=true (the branch tree buffers over the rooted-only store)") + // [verifier]: the trailing execution verifier (dual-watermark second leg). + vcfg := replay.TrailingVerifierDefaults() + if config.IsSet("verifier.enabled") { + vcfg.Enabled = config.GetBool("verifier.enabled") } - if accountsDb.ForkAware && config.GetString("consensus.unresolved_policy") == "warn" { - klog.Fatalf("storage.fork_aware requires consensus.unresolved_policy=halt (warn would keep replaying a divergent chain)") + if config.IsSet("verifier.required") { + vcfg.Required = config.GetBool("verifier.required") } - if accountsDb.ForkAware && config.GetString("consensus.enforce_on_source") != "all" { - klog.Fatalf("storage.fork_aware requires consensus.enforce_on_source=all (promotion folds only vote-verified slots; without it rooting stalls)") + if v := config.GetInt("verifier.lag_slots"); v > 0 { + vcfg.LagSlots = uint64(v) } - if consensusMode == string(consensusengine.ModeAlpenglowObserver) && !accountsDb.RootedDurable { - mlog.Log.Warnf("alpenglow-observer without storage.rooted_durable: every slot is written durably UNGATED — the finality promotion gate and dump-then-repair only protect rooted-durable runs") + if v := config.GetInt("verifier.max_rps"); v > 0 { + vcfg.MaxRPS = v } + replay.TrailingVerifierCfg = vcfg - // Crash recovery: roll forward any interrupted commit up to the DURABLE - // high-water (the last rooted slot in rooted-durable mode). Using the replayed tip - // would mishandle redos for the un-durable in-RAM slots above the last rooted slot. - if accountsDb.DurableCommit && mithrilState != nil { - recovered, rerr := accountsDb.ApplyPendingCommits(mithrilState.DurableHighWater()) - if rerr != nil { - klog.Fatalf("durable-commit recovery failed: %v", rerr) + if foldBatch := config.GetInt("storage.fold_batch_slots"); foldBatch > 0 { + replay.FoldBatchSlots = min(max(foldBatch, 32), 512) + if replay.FoldBatchSlots != foldBatch { + mlog.Log.Warnf("storage.fold_batch_slots=%d clamped to %d (allowed range 32..512)", foldBatch, replay.FoldBatchSlots) } - for _, s := range recovered { - if derr := accountsdb.DeleteRedo(accountsDb.AcctsDir, s); derr != nil { - mlog.Log.Errorf("failed to delete recovered redo for slot %d: %v", s, derr) - } + } + + // [storage] rewind horizon + compaction. The horizon bounds how far back + // RewindToBatchBoundary can restore durable state AND pins the files the + // compactor must not reclaim (undo-pointer targets stay alive inside it). + // Per-cycle work is deliberately small: CompactOnce holds the store's fold + // lock for a cycle, so large scan/move budgets could stall fold/promotion/ + // rewind — keep validator-safe defaults, overridable once soaked. + compactCfg := accountsdb.CompactionConfig{ + RewindHorizonBatches: 64, + MaxMoveBytesPerCycle: 64 << 20, // 64 MiB moved per cycle + MaxScanBytesPerCycle: 256 << 20, // 256 MiB scanned per cycle + } + if v := config.GetInt("storage.rewind_horizon_batches"); v > 0 { + compactCfg.RewindHorizonBatches = uint64(v) + } + if v := config.GetFloat64("storage.compact.min_dead_fraction"); v > 0 { + compactCfg.MinDeadFraction = v + } + if v := config.GetInt("storage.compact.max_move_mb"); v > 0 { + compactCfg.MaxMoveBytesPerCycle = int64(v) << 20 + } + if v := config.GetInt("storage.compact.max_scan_mb"); v > 0 { + compactCfg.MaxScanBytesPerCycle = int64(v) << 20 + } + // Background compaction is OFF by default until soaked (it holds the store's + // fold lock during each cycle); operators opt in via storage.compact.enabled. + compactEnabled := false + if config.IsSet("storage.compact.enabled") { + compactEnabled = config.GetBool("storage.compact.enabled") + } + + // Crash recovery reconcile: the durable fold frontier (recovered above from + // the store's manifests + index meta — the state file is written only on + // graceful shutdown and is stale after a hard kill) is reconciled against + // the in-memory state watermark. Fresh-build paths fall through to the + // recovery call here, which is a no-op on a store with no manifests. + if mithrilState != nil { + if !foldRecovered { + foldRecovery = mustRecoverFoldState(accountsDb) + foldRecovered = true } - if len(recovered) > 0 { - mlog.Log.Infof("durable-commit recovery re-applied %d pending slot(s): %v", len(recovered), recovered) - // Rooted-durable: a rolled-forward promotion makes those slots durable, - // so advance the rooted watermark to the highest re-applied slot. - if accountsDb.RootedDurable { - maxR := mithrilState.LastRootedSlot - for _, s := range recovered { - if s > maxR { - maxR = s - } - } - if maxR > mithrilState.LastRootedSlot { - mlog.Log.Infof("rooted-durable recovery advanced rooted watermark R from %d to %d", mithrilState.LastRootedSlot, maxR) - mithrilState.LastRootedSlot = maxR - } + rec := foldRecovery + switch { + case rec.RewindInProgress: + // An interrupted --rewind-to-slot left parked ".rewound" manifests. + // Complete it uniformly — regardless of whether the crash landed before + // or after the atomic meta rollback — by rewinding to the highest + // retained boundary below the parked suffix, then adopt that boundary. + completeInterruptedRewind(accountsDb, mithrilState) + case rec.DurableThrough > mithrilState.LastRootedSlot: + // Store is ahead of the (stale) state file: adopt the manifest-carried + // watermark + resume context. + var ctx state.ResumeContext + if len(rec.ResumeCtx) == 0 { + klog.Fatalf("store is durably folded through slot %d but state file says %d and the fold manifest carries no resume context; re-bootstrap with --bootstrap snapshot", + rec.DurableThrough, mithrilState.LastRootedSlot) } + if jerr := json.Unmarshal(rec.ResumeCtx, &ctx); jerr != nil { + klog.Fatalf("fold manifest resume context for slot %d is unreadable: %v; re-bootstrap with --bootstrap snapshot", rec.DurableThrough, jerr) + } + mlog.Log.Infof("fold recovery advanced rooted watermark R from %d to %d (manifest-carried context)", mithrilState.LastRootedSlot, rec.DurableThrough) + mithrilState.LastRootedSlot = rec.DurableThrough + mithrilState.LastRootedBankhash = ctx.Bankhash + mithrilState.LastRootedContext = &ctx + if mithrilState.LastSlot < rec.DurableThrough { + mithrilState.LastSlot = rec.DurableThrough + } + case rec.DurableThrough < mithrilState.LastRootedSlot: + // State file claims MORE durable progress than the store holds: the + // disk lost committed writes (or the wrong data dir is mounted). + klog.Fatalf("state file shows last_rooted_slot=%d but the store is only durably folded through %d — disk lost committed state; re-bootstrap with --bootstrap snapshot", + mithrilState.LastRootedSlot, rec.DurableThrough) + } + } + + // --rewind-to-slot: operator-directed restore of durable state to a retained + // fold batch boundary (e.g. a divergence whose root cause predates the slot + // the verifier halted at). Runs after fold recovery so the boundary set is + // authoritative; replay then re-executes forward from the boundary. + if rewindToSlot > 0 { + if mithrilState == nil { + klog.Fatalf("--rewind-to-slot=%d: no state file to reconcile (fresh bootstrap has nothing to rewind)", rewindToSlot) + } + res, rerr := accountsDb.RewindToBatchBoundary(uint64(rewindToSlot)) + if rerr != nil { + points, _ := accountsDb.ListRewindPoints() + klog.Fatalf("--rewind-to-slot=%d failed: %v\navailable fold boundaries: %s", rewindToSlot, rerr, formatRewindPoints(points)) } + if err := adoptRewindResult(mithrilState, res); err != nil { + klog.Fatalf("--rewind-to-slot=%d: %v; re-bootstrap with --bootstrap snapshot", rewindToSlot, err) + } + mlog.Log.Warnf("rewound durable state to fold boundary at slot %d (%d batches / %d keys undone); replay resumes from slot %d", + res.NewThrough, res.UndoneBatches, res.UndoneKeys, mithrilState.GetResumeSlot()) } - // Rooted-durable resume requires a context at the last rooted slot matching - // LastRootedSlot. If a prior run left state without one (crashed before promotion, - // or recovery rolled the last rooted slot forward past it), refuse to resume; - // re-bootstrap with --bootstrap snapshot. - if accountsDb.RootedDurable && mithrilState != nil && mithrilState.LastSlot > 0 { + // Rooted-durable resume needs a context at the last rooted slot. The fold + // manifest is the primary source (handled above); the state file is the + // fallback for stores from before the first fold. + if mithrilState != nil && mithrilState.LastSlot > 0 && mithrilState.LastRootedSlot > 0 { if mithrilState.LastRootedContext == nil || mithrilState.LastRootedContext.Slot != mithrilState.LastRootedSlot { ctxSlot := uint64(0) if mithrilState.LastRootedContext != nil { @@ -1717,12 +1869,8 @@ postBootstrap: NearTipPollMs: blockNearTipPollMs, NearTipLookahead: blockNearTipLookahead, } - // Build consensus options from config - engineMode, err := consensusengine.NormalizeMode(consensusMode) - if err != nil { - klog.Fatalf("%v", err) - } - consensusEngine, err := consensusengine.NewEngineWithConfig(engineMode, consensusengine.Config{ + // Alpenglow-only: the observer engine is the only consensus engine. + consensusEngine, err := consensusengine.NewEngine(consensusengine.Config{ AlpenglowObserverBindAddr: alpenglowObserverBindAddr, AlpenglowMaxMessageBytes: alpenglowMaxMessageBytes, AlpenglowBLSDST: alpenglowBLSDST, @@ -1739,45 +1887,37 @@ postBootstrap: } }() - consensusMaxDepth := config.GetInt("consensus.skip_path_max_depth") - if consensusMaxDepth <= 0 { - consensusMaxDepth = 64 - } - consensusPolicy := config.GetString("consensus.unresolved_policy") - if consensusPolicy == "" { - consensusPolicy = "halt" - } - switch consensusPolicy { - case "halt", "warn": - // valid - default: - mlog.Log.Errorf("invalid consensus.unresolved_policy %q (must be \"halt\" or \"warn\"), defaulting to \"halt\"", consensusPolicy) - consensusPolicy = "halt" - } - consensusEnforceSource := config.GetString("consensus.enforce_on_source") - if consensusEnforceSource == "" { - consensusEnforceSource = "stream" - } - switch consensusEnforceSource { - case "lightbringer", "turbine", "stream", "all": - // valid - default: - mlog.Log.Errorf("invalid consensus.enforce_on_source %q (must be \"lightbringer\", \"turbine\", \"stream\", or \"all\"), defaulting to \"stream\"", consensusEnforceSource) - consensusEnforceSource = "stream" - } consensusOpts := &replay.ConsensusOpts{ - SkipPathMaxDepth: consensusMaxDepth, - UnresolvedPolicy: consensusPolicy, - EnforceOnSource: consensusEnforceSource, - Mode: consensusEngine.Name(), - Engine: consensusEngine, + Engine: consensusEngine, } var slotCtxSetter replay.SlotCtxSetter if rpcServer != nil { slotCtxSetter = rpcServer } - turbineAlpenglowAddr := alpenglowAddrForGossip(engineMode, alpenglowObserverBindAddr) + + // Background compaction: folds never overwrite, so dead bytes accumulate in + // out-of-horizon segments and bootstrap appendvecs. Each cycle is bounded + // (move + scan budgets) and serialized against folds via the store's + // internal lock; the rewind horizon pins are what it must never touch. + if compactEnabled { + go func() { + ticker := time.NewTicker(10 * time.Minute) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + if _, cerr := accountsDb.CompactOnce(compactCfg); cerr != nil { + mlog.Log.Warnf("compaction cycle failed: %v", cerr) + } + } + } + }() + } + + turbineAlpenglowAddr := alpenglowAddrForGossip(alpenglowObserverBindAddr) result := runReplayWithRecovery(ctx, accountsDb, accountsPath, manifest, resumeState, uint64(startSlot), liveEndSlot, rpcEndpoints, lightbringerEndpoint, turbineBindAddr, turbineGossipEntrypoint, turbineGossipBindAddr, turbineAdvertisedIP, uint16(turbineShredVersion), turbineAlpenglowAddr, validatorIdentity, blockstorePath, int(txParallelism), true, useLightbringer, useTurbine, dbgOpts, metricsWriter, slotCtxSetter, mithrilState, blockFetchOpts, consensusOpts, replayStartTime) if result.Error != nil { @@ -2206,10 +2346,12 @@ func printStartupInfo(commandName string) { if validatorIdentityKeypair != "" { fmt.Printf(" Identity key: %s%s%s\n", gold, validatorIdentityKeypair, reset) } - if consensusMode != "" { - fmt.Printf(" Consensus: %s%s%s\n", gold, consensusMode, reset) + consensusLabel := "verifying (non-voting)" + if consensusMode == "validator" { + consensusLabel = "validator (voting engine not yet active)" } - if consensusMode == string(consensusengine.ModeAlpenglowObserver) && alpenglowObserverBindAddr != "" { + fmt.Printf(" Consensus: %s%s%s\n", gold, consensusLabel, reset) + if alpenglowObserverBindAddr != "" { fmt.Printf(" Votor QUIC: %s%s%s\n", gold, alpenglowObserverBindAddr, reset) } @@ -2556,135 +2698,143 @@ func killExistingMithrilProcesses() int { } // decodeRecentBlockhashes converts state.BlockhashEntry list to sealevel.SysvarRecentBlockhashes -func decodeRecentBlockhashes(entries []state.BlockhashEntry) sealevel.SysvarRecentBlockhashes { - result := make(sealevel.SysvarRecentBlockhashes, 0, len(entries)) - dropped := 0 - for _, entry := range entries { - hashBytes, err := base58.Decode(entry.Blockhash) - if err != nil || len(hashBytes) != 32 { - dropped++ - continue - } - var blockhash [32]byte - copy(blockhash[:], hashBytes) - result = append(result, sealevel.RecentBlockHashesEntry{ - Blockhash: blockhash, - FeeCalculator: sealevel.FeeCalculator{LamportsPerSignature: entry.LamportsPerSignature}, - }) +func createBufWriter(filename string) (io.Writer, func(), error) { + if filename == "" { + return nil, func() {}, nil } - if dropped > 0 { - mlog.Log.Errorf("dropped %d/%d RecentBlockhashes entries due to invalid base58 - state file may be corrupted", dropped, len(entries)) + + file, err := os.Create(filename) + if err != nil { + return nil, nil, err } - return result + + writer := bufio.NewWriter(file) + + cleanup := func() { + writer.Flush() + file.Close() + } + + return writer, cleanup, nil } -// decodeSlotHashes converts state.SlotHashEntry list to sealevel.SysvarSlotHashes -func decodeSlotHashes(entries []state.SlotHashEntry) sealevel.SysvarSlotHashes { - result := make(sealevel.SysvarSlotHashes, 0, len(entries)) - dropped := 0 - for _, entry := range entries { - hashBytes, err := base58.Decode(entry.Hash) - if err != nil || len(hashBytes) != 32 { - dropped++ - continue - } - var hash [32]byte - copy(hash[:], hashBytes) - result = append(result, sealevel.SlotHash{ - Slot: entry.Slot, - Hash: hash, - }) +// mustRecoverFoldState derives the durable fold frontier from the store +// (manifests + index meta), restoring the index tail and the NoSync bankhash +// rows, and fatals on error. Safe on a freshly built store (no manifests -> +// empty result). Callers run it before ValidateAgainstBankhashDB so the +// integrity check observes the manifest-restored bankhash rows. +func mustRecoverFoldState(accountsDb *accountsdb.AccountsDb) accountsdb.RecoveryResult { + rec, rerr := accountsDb.RecoverFoldState() + if rerr != nil { + klog.Fatalf("fold recovery failed: %v", rerr) } - if dropped > 0 { - mlog.Log.Errorf("dropped %d/%d SlotHashes entries due to invalid base58 - state file may be corrupted", dropped, len(entries)) + if len(rec.ReplayedBatches) > 0 { + mlog.Log.Infof("fold recovery completed %d decided batch(es) from manifests: %v", len(rec.ReplayedBatches), rec.ReplayedBatches) } - return result + if len(rec.OrphansRemoved) > 0 { + mlog.Log.Infof("fold recovery removed %d undecided orphan file(s)", len(rec.OrphansRemoved)) + } + return rec } -// resumeStateFromRootedContext builds a replay.ResumeState from the context -// captured at promotion (as of the last rooted slot); the next block is the slot -// after the last rooted slot, whose parent is the last rooted slot. -func resumeStateFromRootedContext(rc *state.ResumeContext, epochStakes map[uint64]string) (*replay.ResumeState, error) { - parentBankhash, err := base58.Decode(rc.Bankhash) - if err != nil { - return nil, fmt.Errorf("decode rooted bankhash: %w", err) +// formatRewindPoints renders the retained fold boundaries for operator messages. +func formatRewindPoints(points []accountsdb.RewindPoint) string { + if len(points) == 0 { + return "(none retained — horizon empty or store never folded)" } - ltHashBytes, err := base64.StdEncoding.DecodeString(rc.AcctsLtHash) - if err != nil { - return nil, fmt.Errorf("decode rooted accts_lt_hash: %w", err) - } - ltHash := <hash.LtHash{} - ltHash.InitWithHash(ltHashBytes) - - rs := &replay.ResumeState{ - ParentSlot: rc.Slot, - ParentBlockHeight: rc.BlockHeight, - ParentBankhash: parentBankhash, - AcctsLtHash: ltHash, - LamportsPerSignature: rc.LamportsPerSignature, - PrevLamportsPerSignature: rc.PrevLamportsPerSig, - NumSignatures: rc.NumSignatures, - Capitalization: rc.Capitalization, - SlotsPerYear: rc.SlotsPerYear, - InflationInitial: rc.InflationInitial, - InflationTerminal: rc.InflationTerminal, - InflationTaper: rc.InflationTaper, - InflationFoundation: rc.InflationFoundation, - InflationFoundationTerm: rc.InflationFoundationTerm, - } - - if len(rc.RecentBlockhashes) > 0 { - recentBlockhashes := decodeRecentBlockhashes(rc.RecentBlockhashes) - rs.RecentBlockhashes = &recentBlockhashes - if rc.EvictedBlockhash != "" { - if evb, err := base58.Decode(rc.EvictedBlockhash); err == nil && len(evb) == 32 { - copy(rs.EvictedBlockhash[:], evb) - } - } - if rc.Blockhash != "" { - if bb, err := base58.Decode(rc.Blockhash); err == nil && len(bb) == 32 { - copy(rs.LastBlockhash[:], bb) - } + slots := make([]string, 0, len(points)) + for _, p := range points { + slots = append(slots, strconv.FormatUint(p.ThroughSlot, 10)) + } + return strings.Join(slots, ", ") +} + +// adoptRewindResult reconciles the in-memory state with a completed store +// rewind: the manifest-carried context at the boundary becomes the rooted +// checkpoint and everything above it is forgotten (replay re-executes it). +func adoptRewindResult(s *state.MithrilState, res accountsdb.RewindResult) error { + if len(res.ResumeCtx) == 0 { + if res.NewThrough == s.LastRootedSlot && s.LastRootedContext != nil && s.LastRootedContext.Slot == res.NewThrough { + return nil // no-op rewind to the current boundary; state already consistent } + return fmt.Errorf("rewound to slot %d but its fold manifest carries no resume context", res.NewThrough) } - if len(rc.SlotHashes) > 0 { - slotHashes := decodeSlotHashes(rc.SlotHashes) - rs.SlotHashes = &slotHashes + var rctx state.ResumeContext + if err := json.Unmarshal(res.ResumeCtx, &rctx); err != nil { + return fmt.Errorf("resume context at rewound boundary %d is unreadable: %w", res.NewThrough, err) } - if rc.Clock != "" { - clockData, err := base64.StdEncoding.DecodeString(rc.Clock) - if err != nil { - return nil, fmt.Errorf("decode rooted clock sysvar: %w", err) - } - rs.Clock = clockData + if rctx.Slot != res.NewThrough { + return fmt.Errorf("resume context at rewound boundary %d names slot %d", res.NewThrough, rctx.Slot) } - if len(epochStakes) > 0 { - rs.ComputedEpochStakes = make(map[uint64][]byte, len(epochStakes)) - for epoch, data := range epochStakes { - rs.ComputedEpochStakes[epoch] = []byte(data) - } + s.LastRootedSlot = res.NewThrough + s.LastRootedBankhash = rctx.Bankhash + s.LastRootedContext = &rctx + if s.LastSlot > res.NewThrough { + s.LastSlot = res.NewThrough } - return rs, nil + return nil } -func createBufWriter(filename string) (io.Writer, func(), error) { - if filename == "" { - return nil, func() {}, nil - } +// completeInterruptedRewind finishes a rewind that crashed mid-flight (parked +// ".rewound" manifests remain). It rewinds to the highest retained boundary +// below the parked suffix — RewindToBatchBoundary is idempotent, so this works +// whether the crash landed before the meta rollback (does the full rewind now) +// or after it (a no-op that just finalizes the leftovers) — and adopts that +// boundary as the rooted checkpoint. Fatals only when nothing is left to finish +// the rewind with. +func completeInterruptedRewind(accountsDb *accountsdb.AccountsDb, mithrilState *state.MithrilState) { + points, err := accountsDb.ListRewindPoints() + if err != nil || len(points) == 0 { + klog.Fatalf("interrupted rewind detected but no retained fold boundary remains to complete it; re-bootstrap with --bootstrap snapshot") + } + // The rewind parks every batch above its target, so the highest still-present + // (non-parked) boundary IS the target. + target := points[len(points)-1].ThroughSlot + oldRooted := mithrilState.LastRootedSlot + res, rerr := accountsDb.RewindToBatchBoundary(target) + if rerr != nil { + klog.Fatalf("could not complete interrupted rewind to slot %d: %v; re-run --rewind-to-slot=%d", target, rerr, target) + } + if aerr := adoptRewindResult(mithrilState, res); aerr != nil { + klog.Fatalf("interrupted rewind at slot %d: %v; re-run --rewind-to-slot=%d", res.NewThrough, aerr, target) + } + mlog.Log.Warnf("completed interrupted rewind: durable state at fold boundary %d (state file said %d)", res.NewThrough, oldRooted) +} - file, err := os.Create(filename) +// rewindStoreBelowDivergence rewinds durable state to the newest retained fold +// boundary strictly below divSlot and adopts that boundary's context as the +// rooted checkpoint. Returns false (caller halts) when no boundary below the +// divergence is retained or the rewind/reconcile fails. +func rewindStoreBelowDivergence(accountsDb *accountsdb.AccountsDb, s *state.MithrilState, divSlot uint64) bool { + points, err := accountsDb.ListRewindPoints() if err != nil { - return nil, nil, err + mlog.Log.Errorf("fork switch: cannot list rewind boundaries: %v; halting", err) + return false } - - writer := bufio.NewWriter(file) - - cleanup := func() { - writer.Flush() - file.Close() + target, found := uint64(0), false + for _, p := range points { // ascending; keep the newest boundary below divSlot + if p.ThroughSlot < divSlot { + target, found = p.ThroughSlot, true + } } - - return writer, cleanup, nil + if !found { + mlog.Log.Errorf("fork switch: divergence at slot %d is at/below the durable watermark %d and no fold boundary below it is retained (rewind horizon exceeded) — re-bootstrap with --bootstrap snapshot; halting", + divSlot, s.LastRootedSlot) + return false + } + oldRooted := s.LastRootedSlot + res, err := accountsDb.RewindToBatchBoundary(target) + if err != nil { + mlog.Log.Errorf("fork switch: rewind to boundary %d failed: %v; halting", target, err) + return false + } + if err := adoptRewindResult(s, res); err != nil { + mlog.Log.Errorf("fork switch: %v; halting", err) + return false + } + mlog.Log.Warnf("fork switch: divergence at slot %d was already folded (R=%d); rewound durable state to boundary %d (%d batches / %d keys undone)", + divSlot, oldRooted, res.NewThrough, res.UndoneBatches, res.UndoneKeys) + return true } // maxForkSwitchRetries bounds fork-aware dump-then-repair re-replays per run. @@ -2851,12 +3001,14 @@ func runReplayWithRecovery( } var divSlot uint64 var key string - var div *replay.ConfirmedDivergence + var certSwitch *replay.CertifiedSwitch var finMismatch *replay.AlpenglowFinalityMismatch switch { - case errors.As(result.Error, &div): - divSlot = div.Slot - key = fmt.Sprintf("%d/%x/%x", div.Slot, div.Ours, div.Confirmed) + case errors.As(result.Error, &certSwitch): + // Execute-on-receipt ran the wrong sibling (or a certified-skipped + // slot); the certified version replays from the rooted checkpoint. + divSlot = certSwitch.Slot + key = fmt.Sprintf("sw:%d/%x/%x/%v", certSwitch.Slot, certSwitch.Executed, certSwitch.Certified, certSwitch.Skip) case errors.As(result.Error, &finMismatch): if finMismatch.Conflict { // Conflict-shaped (equivocation evidence, not a wrong local block): @@ -2875,7 +3027,22 @@ func runReplayWithRecovery( break } seenDiv[key] = true - if mithrilState == nil || mithrilState.LastRootedContext == nil { + if mithrilState == nil { + mlog.Log.Errorf("fork switch: no state to re-replay from; halting") + break + } + // Late-detected divergence: the contradicted slot is at/below the durable + // watermark, so it is already folded into the store — re-replaying from + // the rooted checkpoint would rebuild on corrupted ground. Rewind the + // store to the newest retained fold boundary BELOW the divergence first + // (this is exactly why durable state deliberately lags and undo pointers + // are retained for a horizon). Beyond the horizon -> fail closed. + if divSlot <= mithrilState.LastRootedSlot { + if !rewindStoreBelowDivergence(accountsDb, mithrilState, divSlot) { + break + } + } + if mithrilState.LastRootedContext == nil { mlog.Log.Errorf("fork switch: no rooted checkpoint context to re-replay from; halting") break } @@ -2895,16 +3062,19 @@ func runReplayWithRecovery( mlog.Log.Errorf("fork switch: divergent span %d..%d crosses an epoch boundary; halting (restart to recover)", retryStart, divSlot) break } - // Prefer the failed attempt's epoch stakes: the in-memory state file copy is - // only refreshed on shutdown and can be empty on a fresh bootstrap. + // Prefer the failed attempt's epoch stakes: the in-memory state file copy + // can lag (it refreshes at boundaries and on shutdown) and can be empty + // on a fresh bootstrap. The serialized form is raw PersistedEpochStakes + // JSON — pass it through verbatim (string(b), NOT base64: the consumer + // json.Unmarshals it directly, matching the state-file convention). stakes := mithrilState.ComputedEpochStakes if len(result.ComputedEpochStakes) > 0 { stakes = make(map[uint64]string, len(result.ComputedEpochStakes)) for e, b := range result.ComputedEpochStakes { - stakes[e] = base64.StdEncoding.EncodeToString(b) + stakes[e] = string(b) } } - rs, err := resumeStateFromRootedContext(mithrilState.LastRootedContext, stakes) + rs, err := replay.ResumeStateFromRootedContext(mithrilState.LastRootedContext, stakes) if err != nil { mlog.Log.Errorf("fork switch: cannot rebuild resume context: %v; halting", err) break diff --git a/cmd/mithril/setupcmd/doctor.go b/cmd/mithril/setupcmd/doctor.go index dce289200..b75f95c3d 100644 --- a/cmd/mithril/setupcmd/doctor.go +++ b/cmd/mithril/setupcmd/doctor.go @@ -47,18 +47,53 @@ func runDoctor() { return } - // 2. Cluster + // 2. Cluster — this build boots Alpenglow only. total++ cluster := config.GetString("network.cluster") - if cluster == "mainnet-beta" || cluster == "testnet" || cluster == "devnet" || cluster == "alpenglow" { + if cluster == "alpenglow" { fmt.Printf(" %s Network: %s\n", successStyle.Render("✓"), cluster) passed++ } else if cluster == "" { - fmt.Printf(" %s network.cluster not set\n", errorStyle.Render("✗")) + fmt.Printf(" %s Network: alpenglow (default; network.cluster not set)\n", successStyle.Render("✓")) + passed++ + } else if cluster == "mainnet-beta" || cluster == "testnet" || cluster == "devnet" { + fmt.Printf(" %s Cluster %q needs a dev-branch (TowerBFT) build until it upgrades to Alpenglow — this build boots \"alpenglow\" only\n", errorStyle.Render("✗"), cluster) } else { fmt.Printf(" %s Invalid cluster: %s\n", errorStyle.Render("✗"), cluster) } + // 2b. Node mode — "verifying" (non-voting) is the default; "validator" + // enforces the voting-deployment shape (keypairs, turbine, Votor listener). + total++ + consensusMode := config.GetString("consensus.mode") + switch consensusMode { + case "", "verifying": + fmt.Printf(" %s Node mode: verifying (non-voting)\n", successStyle.Render("✓")) + passed++ + case "validator": + var missing []string + if config.GetString("validator.identity_keypair") == "" { + missing = append(missing, "validator.identity_keypair") + } + if config.GetString("validator.vote_account_keypair") == "" { + missing = append(missing, "validator.vote_account_keypair") + } + if config.GetString("turbine.gossip_entrypoint") == "" { + missing = append(missing, "turbine.gossip_entrypoint") + } + if config.GetString("consensus.alpenglow_observer_bind_addr") == "" { + missing = append(missing, "consensus.alpenglow_observer_bind_addr") + } + if len(missing) == 0 { + fmt.Printf(" %s Node mode: validator (voting engine not yet active — runs verify-only)\n", successStyle.Render("✓")) + passed++ + } else { + fmt.Printf(" %s validator mode is missing: %s\n", errorStyle.Render("✗"), strings.Join(missing, ", ")) + } + default: + fmt.Printf(" %s Invalid consensus.mode: %q (valid: \"verifying\", \"validator\")\n", errorStyle.Render("✗"), consensusMode) + } + // 3. RPC endpoint total++ rpcEndpoints := config.GetStringSlice("network.rpc") diff --git a/cmd/mithril/setupcmd/migrate.go b/cmd/mithril/setupcmd/migrate.go index a98db86a9..0bdcbc1aa 100644 --- a/cmd/mithril/setupcmd/migrate.go +++ b/cmd/mithril/setupcmd/migrate.go @@ -31,15 +31,12 @@ func MigrateConfig(configPath string) bool { if !hasConsensus { additions += ` # ============================================================================ -# [consensus] - Vote-Anchored Consensus (added by mithril setup --migrate) +# [consensus] - Alpenglow Observer (added by mithril setup --migrate) # ============================================================================ # [consensus] -# mode = "classic" -# alpenglow_observer_bind_addr = "" +# alpenglow_observer_bind_addr = "" # Optional Votor QUIC listener (raw-vote cert feed) # alpenglow_max_message_bytes = 0 -# skip_path_max_depth = 64 -# unresolved_policy = "halt" -# enforce_on_source = "stream" +# alpenglow_bls_dst = "" ` } @@ -49,7 +46,7 @@ func MigrateConfig(configPath string) bool { # [validator] - Optional Validator Identity (added by mithril setup --migrate) # ============================================================================ # identity_keypair is used by native turbine gossip. Set it to a staked -# validator identity when running consensus.mode="alpenglow-observer". +# validator identity for better turbine tree positioning. # # [validator] # identity_keypair = "" diff --git a/cmd/mithril/setupcmd/setup.go b/cmd/mithril/setupcmd/setup.go index c773258e1..ec0e7a8e9 100644 --- a/cmd/mithril/setupcmd/setup.go +++ b/cmd/mithril/setupcmd/setup.go @@ -70,11 +70,14 @@ func runMigrate() { type screen int const ( - scrMode screen = iota + scrMode screen = iota + scrNodeType // verifying node vs validator scrCluster scrRPC scrLightbringer scrGossip + scrIdentityKey // validator identity keypair path (validator mode) + scrVoteKey // vote account keypair path (validator mode) scrLightbringerQuiet // log verbosity for managed Lightbringer (only shown when Lightbringer enabled) scrStorage // accountsPath scrStorageSnap // snapshotsPath @@ -83,7 +86,6 @@ const ( scrBlockTuning // maxRPS scrBlockInflight // maxInflight scrReplay - scrConsensus scrSnapshot scrLogLevel scrRPCPort @@ -108,24 +110,26 @@ type setupModel struct { inputErr string // Config values - mode string // quick, full, manual - cluster string - rpcEndpoint string - enableLB bool - gossipEntry string - lbQuiet bool // suppress Lightbringer info/debug logs - accountsPath string - snapshotsPath string - logsPath string - shredstorePath string - bootstrapMode string - blockMaxRPS string - blockInflight string - txpar string - consensusPolicy string - snapshotKeep string - logLevel string - rpcPort string + mode string // quick, full, manual + nodeType string // "verifying" (non-voting) or "validator" + cluster string + rpcEndpoint string + enableLB bool + gossipEntry string + identityKey string // validator identity keypair path (validator mode) + voteKey string // vote account keypair path (validator mode) + lbQuiet bool // suppress Lightbringer info/debug logs + accountsPath string + snapshotsPath string + logsPath string + shredstorePath string + bootstrapMode string + blockMaxRPS string + blockInflight string + txpar string + snapshotKeep string + logLevel string + rpcPort string // System cpuCores int @@ -139,25 +143,24 @@ func newSetupModel() setupModel { absPath, _ := filepath.Abs(outputPath) storage := config.DefaultStoragePaths() return setupModel{ - screen: scrMode, - cpuCores: runtime.NumCPU(), - disks: DetectDisks(), - cluster: "mainnet-beta", - rpcEndpoint: "https://api.mainnet-beta.solana.com", - lbQuiet: config.LightbringerQuietDefault, - accountsPath: storage.Accounts, - snapshotsPath: storage.Snapshots, - logsPath: storage.Logs, - shredstorePath: storage.Shredstore, - bootstrapMode: "auto", - blockMaxRPS: "8", - blockInflight: "8", - txpar: fmt.Sprintf("%d", runtime.NumCPU()*2), - consensusPolicy: "halt", - snapshotKeep: "1", - logLevel: "info", - rpcPort: "8899", - configPath: absPath, + screen: scrMode, + cpuCores: runtime.NumCPU(), + disks: DetectDisks(), + cluster: "alpenglow", + rpcEndpoint: "https://alpenglow.rpcpool.com", + lbQuiet: config.LightbringerQuietDefault, + accountsPath: storage.Accounts, + snapshotsPath: storage.Snapshots, + logsPath: storage.Logs, + shredstorePath: storage.Shredstore, + bootstrapMode: "auto", + blockMaxRPS: "8", + blockInflight: "8", + txpar: fmt.Sprintf("%d", runtime.NumCPU()*2), + snapshotKeep: "1", + logLevel: "info", + rpcPort: "8899", + configPath: absPath, } } @@ -173,6 +176,10 @@ func (m *setupModel) inputValueForScreen(scr screen) (string, bool) { return m.rpcEndpoint, true case scrGossip: return m.gossipEntry, true + case scrIdentityKey: + return m.identityKey, true + case scrVoteKey: + return m.voteKey, true case scrStorage: return m.accountsPath, true case scrStorageSnap: @@ -242,12 +249,19 @@ func (m setupModel) currentItems() []menuItem { menuOptionDesc("Full Config", "full", "Customize every setting with explanations"), menuOptionDesc("Manual", "manual", "Generate config.toml template for editing"), } + case scrNodeType: + return []menuItem{ + menuOptionDesc("Verifying node", "verifying", "Non-voting: observe, execute, and verify the cluster"), + menuOptionDesc("Validator", "validator", "Requires identity + vote-account keypairs; voting engine not yet active (runs verify-only until it lands)"), + menuSeparator(), + menuBack(), + } case scrCluster: return []menuItem{ - menuOptionDesc("mainnet-beta", "mainnet-beta", "Production Solana network"), - menuOptionDesc("testnet", "testnet", "Test network (more stable)"), - menuOptionDesc("devnet", "devnet", "Development network (frequent resets)"), - menuOptionDesc("alpenglow", "alpenglow", "Public Alpenglow test cluster"), + menuOptionDesc("alpenglow", "alpenglow", "Alpenglow test cluster (the only cluster this build boots)"), + menuOptionDesc("mainnet-beta", "mainnet-beta", "Requires a dev-branch (TowerBFT) build"), + menuOptionDesc("testnet", "testnet", "Requires a dev-branch (TowerBFT) build"), + menuOptionDesc("devnet", "devnet", "Requires a dev-branch (TowerBFT) build"), menuSeparator(), menuBack(), } @@ -267,13 +281,6 @@ func (m setupModel) currentItems() []menuItem { menuSeparator(), menuBack(), } - case scrConsensus: - return []menuItem{ - menuOptionDesc("halt", "halt", "Stop and write diagnostic (recommended)"), - menuOptionDesc("warn", "warn", "Log warning and continue (debug only)"), - menuSeparator(), - menuBack(), - } case scrSnapshot: return []menuItem{ menuOptionDesc("Keep 1", "1", "For debugging and faster restarts (recommended)"), @@ -388,6 +395,13 @@ func (m setupModel) handleSelect(value string) (tea.Model, tea.Cmd) { if value == "manual" { return m.generateManual() } + m.pushMenu(scrNodeType) + + case scrNodeType: + m.nodeType = value + if value == "validator" { + m.enableLB = false // a validator runs the native turbine source + } m.pushMenu(scrCluster) case scrCluster: @@ -409,22 +423,14 @@ func (m setupModel) handleSelect(value string) (tea.Model, tea.Cmd) { if !m.enableLB { m.lbQuiet = config.LightbringerQuietDefault // Reset dependent state so disable→re-enable starts clean. } - if m.enableLB { - m.pushInput(scrGossip) - } else if m.mode == "quick" { - m.pushMenu(scrReview) - } else { - m.pushInput(scrStorage) - } + // Both paths need a gossip entrypoint: lightbringer for its sidecar, + // turbine (the default source) to join the shred tree. + m.pushInput(scrGossip) case scrBootstrap: m.bootstrapMode = value m.pushInput(scrBlockTuning) - case scrConsensus: - m.consensusPolicy = value - m.pushMenu(scrSnapshot) - case scrSnapshot: m.snapshotKeep = value m.pushMenu(scrLogLevel) @@ -565,6 +571,18 @@ func (m *setupModel) validateAndApplyInput() bool { } m.gossipEntry = val + case scrIdentityKey: + if !m.requireNonEmpty(val) { + return false + } + m.identityKey = val + + case scrVoteKey: + if !m.requireNonEmpty(val) { + return false + } + m.voteKey = val + case scrStorage: if !m.requireNonEmpty(val) { return false @@ -621,16 +639,31 @@ func (m *setupModel) validateAndApplyInput() bool { func (m *setupModel) advanceFromInput() { switch m.screen { case scrRPC: - if m.mode == "quick" { - m.pushMenu(scrReview) // Quick Start skips Lightbringer (disabled by default) + if m.mode == "quick" || m.nodeType == "validator" { + // Turbine (the default source, and the only valid one for a + // validator) needs a gossip entrypoint; validators never use the + // Lightbringer sidecar. + m.pushInput(scrGossip) } else { - m.pushMenu(scrLightbringer) // Full Config lets user enable it + m.pushMenu(scrLightbringer) // Full Config lets user enable the sidecar } case scrGossip: + if m.nodeType == "validator" { + m.pushInput(scrIdentityKey) + } else if m.mode == "quick" { + m.pushMenu(scrReview) + } else if m.enableLB { + m.pushMenu(scrLightbringerQuiet) + } else { + m.pushInput(scrStorage) + } + case scrIdentityKey: + m.pushInput(scrVoteKey) + case scrVoteKey: if m.mode == "quick" { m.pushMenu(scrReview) } else { - m.pushMenu(scrLightbringerQuiet) + m.pushInput(scrStorage) } case scrStorage: m.pushInput(scrStorageSnap) @@ -643,7 +676,7 @@ func (m *setupModel) advanceFromInput() { case scrBlockInflight: m.pushInput(scrReplay) case scrReplay: - m.pushMenu(scrConsensus) + m.pushMenu(scrSnapshot) case scrRPCPort: m.pushMenu(scrReview) } @@ -679,6 +712,18 @@ func (m setupModel) View() string { "Used to receive shreds from the network", m.inputVal, m.inputErr, m.inputCur) + case scrIdentityKey: + return banner + "\n" + renderInput("Validator Identity Keypair", + "Path to the validator identity keypair (Solana keygen JSON)\n"+ + "Signs gossip/turbine identity and, once voting activates, votes", + m.inputVal, m.inputErr, m.inputCur) + + case scrVoteKey: + return banner + "\n" + renderInput("Vote Account Keypair", + "Path to the vote account keypair (Solana keygen JSON)\n"+ + "The vote account votes are cast for · keep the WITHDRAWER keypair offline", + m.inputVal, m.inputErr, m.inputCur) + case scrStorage: desc := "AccountsDB stores all ~500M on-chain accounts · needs fastest NVMe\n" + "Heavy random I/O — put this on your best drive" @@ -738,10 +783,25 @@ func (m setupModel) View() string { m.inputVal, m.inputErr, m.inputCur) case scrReview: + nodeType := m.nodeType + if nodeType == "" { + nodeType = "verifying" + } + nodeTypeLabel := "verifying (non-voting)" + if nodeType == "validator" { + nodeTypeLabel = "validator (voting engine not yet active)" + } rows := [][]string{ + {"Node type", nodeTypeLabel}, {"Cluster", m.cluster}, {"RPC", m.rpcEndpoint}, } + if nodeType == "validator" { + rows = append(rows, + []string{"Identity key", m.identityKey}, + []string{"Vote key", m.voteKey}, + ) + } if m.enableLB { summary := "enabled (gossip: " + m.gossipEntry + ")" if m.lbQuiet { @@ -749,7 +809,7 @@ func (m setupModel) View() string { } rows = append(rows, []string{"Lightbringer", summary}) } else { - rows = append(rows, []string{"Lightbringer", "disabled"}) + rows = append(rows, []string{"Block source", "turbine (gossip: " + m.gossipEntry + ")"}) } if m.mode == "quick" { rows = append(rows, []string{"AccountsDB", m.accountsPath + " (default)"}) @@ -766,7 +826,6 @@ func (m setupModel) View() string { rows = append(rows, []string{"Block RPS", m.blockMaxRPS}) rows = append(rows, []string{"Inflight", m.blockInflight}) rows = append(rows, []string{"RPC Port", m.rpcPort}) - rows = append(rows, []string{"Consensus", m.consensusPolicy}) rows = append(rows, []string{"Snapshot keep", m.snapshotKeep}) rows = append(rows, []string{"Log Level", m.logLevel}) } @@ -802,9 +861,6 @@ func (m setupModel) View() string { case scrBootstrap: title = "Bootstrap Mode" desc = "How Mithril initializes on startup." - case scrConsensus: - title = "Consensus Policy" - desc = "Action when blocks can't be verified via votes." case scrSnapshot: title = "Snapshot Storage" desc = "How many downloaded snapshots to keep." @@ -848,10 +904,15 @@ func (m setupModel) generateConfig() (tea.Model, tea.Cmd) { if m.enableLB { cfg.WriteString("source = \"lightbringer\"\n") } else { - cfg.WriteString("source = \"rpc\"\n") + cfg.WriteString("source = \"turbine\"\n") + cfg.WriteString("turbine_bind_addr = \"0.0.0.0:8001\"\n") } fmt.Fprintf(&cfg, "max_rps = %s\n", m.blockMaxRPS) fmt.Fprintf(&cfg, "max_inflight = %s\n\n", m.blockInflight) + if !m.enableLB { + cfg.WriteString("[turbine]\n") + fmt.Fprintf(&cfg, "gossip_entrypoint = %q\n\n", m.gossipEntry) + } if m.enableLB { cfg.WriteString("[lightbringer]\n") @@ -867,18 +928,31 @@ func (m setupModel) generateConfig() (tea.Model, tea.Cmd) { cfg.WriteString("[tuning]\n") fmt.Fprintf(&cfg, "txpar = %s\n\n", m.txpar) - cfg.WriteString("[validator]\n") - cfg.WriteString("identity_keypair = \"\"\n") - cfg.WriteString("vote_account_keypair = \"\"\n") - cfg.WriteString("authorized_withdrawer_keypair = \"\"\n\n") - - cfg.WriteString("[consensus]\n") - cfg.WriteString("mode = \"classic\"\n") - cfg.WriteString("alpenglow_observer_bind_addr = \"\"\n") - cfg.WriteString("alpenglow_max_message_bytes = 0\n") - fmt.Fprintf(&cfg, "unresolved_policy = %q\n", m.consensusPolicy) - cfg.WriteString("skip_path_max_depth = 64\n") - cfg.WriteString("enforce_on_source = \"stream\"\n\n") + if m.nodeType == "validator" { + cfg.WriteString("[validator]\n") + fmt.Fprintf(&cfg, "identity_keypair = %q\n", m.identityKey) + fmt.Fprintf(&cfg, "vote_account_keypair = %q\n", m.voteKey) + cfg.WriteString("# Keep the authorized withdrawer keypair OFFLINE — not needed at runtime.\n") + cfg.WriteString("authorized_withdrawer_keypair = \"\"\n\n") + + cfg.WriteString("[consensus]\n") + cfg.WriteString("# Validator mode: voting engine not yet active — runs verify-only until it lands.\n") + cfg.WriteString("mode = \"validator\"\n") + cfg.WriteString("alpenglow_observer_bind_addr = \"0.0.0.0:8010\" # REQUIRED: Votor QUIC vote/cert listener\n") + cfg.WriteString("alpenglow_max_message_bytes = 0\n") + cfg.WriteString("alpenglow_bls_dst = \"\"\n\n") + } else { + cfg.WriteString("[validator]\n") + cfg.WriteString("identity_keypair = \"\"\n") + cfg.WriteString("vote_account_keypair = \"\"\n") + cfg.WriteString("authorized_withdrawer_keypair = \"\"\n\n") + + cfg.WriteString("[consensus]\n") + cfg.WriteString("mode = \"verifying\"\n") + cfg.WriteString("alpenglow_observer_bind_addr = \"\"\n") + cfg.WriteString("alpenglow_max_message_bytes = 0\n") + cfg.WriteString("alpenglow_bls_dst = \"\"\n\n") + } cfg.WriteString("[snapshot]\n") fmt.Fprintf(&cfg, "max_full_snapshots = %s\n\n", m.snapshotKeep) @@ -891,7 +965,6 @@ func (m setupModel) generateConfig() (tea.Model, tea.Cmd) { fmt.Fprintf(&cfg, "level = %q\n", m.logLevel) cfg.WriteString("to_stdout = true\n") cfg.WriteString("max_size_mb = 100\n") - cfg.WriteString("max_age_days = 7\n") if err := tui.AtomicWriteFile(m.configPath, []byte(cfg.String()), 0600); err != nil { m.err = err @@ -923,19 +996,20 @@ snapshots = "/mnt/mithril-ledger/snapshots" # ~100GB for full + incremental logs = "/mnt/mithril-logs" # Log files (created if missing) [network] -cluster = "mainnet-beta" # Required: "mainnet-beta" | "testnet" | "devnet" | "alpenglow" -rpc = ["https://api.mainnet-beta.solana.com"] +cluster = "alpenglow" # This build boots Alpenglow only (TowerBFT clusters need a dev-branch build) +rpc = ["https://alpenglow.rpcpool.com"] [block] -source = "rpc" # "rpc" | "lightbringer" | "turbine" -# turbine_bind_addr = "0.0.0.0:8001" +# "turbine" is the live mode: shreds carry the Alpenglow block ids and footer +# certificates that gate durable state. "rpc" is catch-up/debug only. +source = "turbine" # "turbine" (live) | "rpc" (catch-up/debug) | "lightbringer" +turbine_bind_addr = "0.0.0.0:8001" # lightbringer_endpoint = "localhost:9000" max_rps = 8 max_inflight = 8 -# [turbine] -# bind_addr = "0.0.0.0:8001" -# gossip_entrypoint = "1.2.3.4:8000" +[turbine] +gossip_entrypoint = "" # REQUIRED for turbine: a gossip entrypoint of your Alpenglow cluster # gossip_bind_addr = "0.0.0.0:65401" # advertised_ip = "203.0.113.10" # shred_version = 0 @@ -958,12 +1032,10 @@ vote_account_keypair = "" # Optional vote account keypair path for diag authorized_withdrawer_keypair = "" # Optional authorized withdrawer keypair path for diagnostics [consensus] -mode = "classic" # "classic" | "alpenglow-observer" | "alpenglow" -alpenglow_observer_bind_addr = "" # Optional Votor QUIC listener for observer mode +mode = "verifying" # "verifying" (default, non-voting) | "validator" (requires keypairs + Votor listener; voting engine not yet active) +alpenglow_observer_bind_addr = "" # Optional Votor QUIC listener (raw-vote cert feed) alpenglow_max_message_bytes = 0 # 0 = default -unresolved_policy = "halt" # "halt" | "warn" -skip_path_max_depth = 64 -enforce_on_source = "stream" +alpenglow_bls_dst = "" # BLS DST override (must match cluster solana-bls version) [snapshot] max_full_snapshots = 1 # 0 = stream only, saves disk @@ -976,7 +1048,7 @@ dir = "/mnt/mithril-logs" # Log files (created if missing) level = "info" # "debug" | "info" | "warn" | "error" to_stdout = true # Also write to stdout max_size_mb = 100 # Max log file size before rotation -max_age_days = 7 # Delete logs older than this +# max_age_days = 0 # Delete logs older than N days (0/unset = never delete by age) # Advanced options (defaults work well for most setups) # See config.example.toml for: [tuning], [debug], [snapshot] tuning, [reporting] diff --git a/config.example.toml b/config.example.toml index 5d7a73d68..78459cae9 100644 --- a/config.example.toml +++ b/config.example.toml @@ -8,6 +8,31 @@ # 3. Run: mithril run --config config.toml # # Everything else has sensible defaults. +# +# ---------------------------------------------------------------------------- +# Alpenglow turbine node — the settings that matter for a live deployment. +# Each has a --flag equivalent; a config file replaces the long CLI. Setting +# them here is the same as running: +# +# mithril run --config config.toml +# +# instead of passing --cluster / --block-source / --turbine-bind-addr / +# --turbine-gossip-entrypoint / --alpenglow-observer-bind-addr / +# --identity-keypair / --rpc on the command line. +# +# [network] cluster = "alpenglow" + rpc = ["https://your-rpc"] +# [block] source = "turbine" + turbine_bind_addr = "0.0.0.0:8001" +# [turbine] gossip_entrypoint = "" (REQUIRED) +# [consensus] alpenglow_observer_bind_addr = "0.0.0.0:8010" (Votor QUIC) +# [validator] identity_keypair = "/path/to/validator-keypair.json" +# +# NOTE: this build is Alpenglow-only. [consensus].mode selects the node type: +# "verifying" (default; non-voting) or "validator" (enforces the full voting +# deployment shape — identity + vote-account keypairs, turbine + gossip +# entrypoint, Votor QUIC listener — but the voting engine has not landed yet, +# so it runs verify-only and casts NO votes). Both share the same fork choice. +# Generate a per-type starter config: mithril config init [--validator] +# ---------------------------------------------------------------------------- # ============================================================================ # GENERAL SETTINGS @@ -71,6 +96,39 @@ name = "mithril" # Put this on your fastest NVMe due to heavy random I/O. accounts = "/mnt/mithril-accounts" + # Fold batch size K: rooted (finalized+verified) slots fold to disk K at a + # time as ONE union-deduped sequential segment with ONE fsync. Larger K = + # fewer, larger writes (less NVMe wear: hot accounts written once per K + # slots instead of once per slot) but more RAM for the unrooted tail and a + # longer bounded re-execution window after a hard crash. Range 32..512. + fold_batch_slots = 128 + + # Account-index Pebble WAL. Set false to remove the WAL write stream + # entirely: fold manifests then serve as the index redo log and recovery + # replays them. Keep true until the deployment has soaked. + index_wal = true + + # Rewind horizon: how many fold batches of undo pointers stay actionable. + # Within the horizon, --rewind-to-slot (or the automatic late-divergence + # recovery) can restore durable state to any retained fold boundary without + # re-bootstrapping from a snapshot. Also pins those batches' files against + # compaction. At K=128 slots/batch, 64 batches =~ 55 minutes of chain. + rewind_horizon_batches = 64 + + # Background compaction: reclaims dead bytes from out-of-horizon segments + # and bootstrap appendvecs (folds never overwrite, so superseded account + # versions accumulate). OFF by default until soaked — each cycle holds the + # store's fold lock while it works, so on a latency-sensitive validator keep + # the per-cycle budgets small (defaults below) to avoid stalling folds. + #[storage.compact] + # enabled = false + # # Move a file's live records only once at least this fraction is dead. + # min_dead_fraction = 0.7 + # # Per-cycle caps on live bytes moved / bytes scanned (MiB). Smaller = + # # shorter fold-lock hold per cycle. + # max_move_mb = 64 + # max_scan_mb = 256 + # Shredstore - Lightbringer stores received shreds here # Used for block streaming and potential repair serving. shredstore = "/mnt/mithril-ledger/shredstore" @@ -95,19 +153,21 @@ name = "mithril" # probes the primary and restores to it when healthy. [network] - # Solana cluster (required): "mainnet-beta", "testnet", "devnet", or "alpenglow" - # This is validated against the RPC's genesis hash to prevent accidentally - # running mainnet state against testnet, or vice versa. - cluster = "mainnet-beta" + # Solana cluster. Defaults to "alpenglow" — the only cluster this build + # boots: replay applies Alpenglow clock/feature semantics unconditionally. + # "mainnet-beta"/"testnet"/"devnet" currently need a mithril build from the + # dev branch (TowerBFT) and will be usable here once those clusters upgrade + # to Alpenglow. + cluster = "alpenglow" # RPC endpoints in priority order (first = primary, rest = fallbacks) # # Example with primary + fallback: # rpc = [ - # "https://your-rpc.example.com", # Primary - # "https://api.mainnet-beta.solana.com" # Fallback + # "https://your-primary-alpenglow-rpc.example.com", # Primary + # "https://your-fallback-alpenglow-rpc.example.com" # Fallback # ] - rpc = ["https://api.mainnet-beta.solana.com"] + rpc = ["https://alpenglow.rpcpool.com"] # ============================================================================ # [block] - Block Source & Streaming @@ -116,20 +176,32 @@ name = "mithril" # Block source configuration. See also [lightbringer] below for sidecar settings. [block] - # Where to stream new blocks from: - # "rpc" - Fetch blocks via getBlock RPC calls + # Where to stream new blocks from (default: "turbine"): + # "turbine" - Native UDP turbine shred receiver (the LIVE mode, and + # the DEFAULT). Uses RPC for catchup, hands off to + # reconstructed shreds near tip. Shreds carry the + # Alpenglow block ids and footer certificates that gate + # durable state. + # "rpc" - Fetch blocks via getBlock RPC calls. CATCH-UP/DEBUG + # ONLY on Alpenglow: RPC blocks carry no block ids or + # footer certificates, so near-tip identity cannot be + # adjudicated and durable folds stall unless certificates + # arrive via the Votor QUIC listener ([consensus]). # "lightbringer" - Stream via Lightbringer sidecar (see [lightbringer] section) # Uses RPC for catchup, hands off to live stream near tip. - # "turbine" - Native UDP turbine shred receiver. - # Uses RPC for catchup, hands off to reconstructed shreds near tip. - source = "rpc" + source = "turbine" # Lightbringer endpoint address (only used when source = "lightbringer") # lightbringer_endpoint = "localhost:9000" - # Native turbine UDP bind address (only used when source = "turbine"). - # turbine_bind_addr = "0.0.0.0:8001" - # Gossip settings for native turbine live under [turbine]. + # Native turbine UDP bind address — the port this node receives shreds on + # (only used when source = "turbine"). Defaults to 0.0.0.0:8001 when unset + # (or use the [turbine].bind_addr equivalent). Must be reachable from the + # cluster. + turbine_bind_addr = "0.0.0.0:8001" + # Cluster-join / gossip settings for native turbine live under [turbine]; + # [turbine].gossip_entrypoint is also required to discover the shred version + # and advertise this node into the turbine tree. # ========================================================================= # Global Fetch Tuning @@ -194,40 +266,54 @@ name = "mithril" # [validator] - Optional Validator Identity # ============================================================================ # -# These keys are not used for signing votes in classic or alpenglow-observer -# mode. The identity keypair is used by native turbine gossip, which is required -# for staked Alpenglow observer nodes to receive Votor traffic. +# In verifying mode nothing here is required: the identity keypair, if set, is +# used by native turbine gossip to advertise this node's turbine/TVU and +# observer sockets into the cluster (set it so a staked node joins the turbine +# tree and receives shreds plus Votor traffic). +# +# In validator mode ([consensus].mode = "validator") the identity AND +# vote-account keypairs are REQUIRED — the node refuses to start without them. +# The authorized withdrawer keypair is NOT needed at runtime: keep it OFFLINE. [validator] + # identity_keypair = "/path/to/validator-keypair.json" identity_keypair = "" + # vote_account_keypair = "/path/to/vote-account-keypair.json" vote_account_keypair = "" + # authorized_withdrawer_keypair = "/path/to/authorized-withdrawer-keypair.json" authorized_withdrawer_keypair = "" # ============================================================================ -# [consensus] - Vote-Anchored Consensus +# [consensus] - Alpenglow Consensus # ============================================================================ # -# Controls how Mithril uses on-chain vote data to verify block correctness. -# The fork choice service accumulates vote stake per slot and determines -# which bank hash has reached 2/3 supermajority. -# -# When using Lightbringer as the block source, the consensus coordinator -# resolves ambiguous slot ranges by finding a valid skip path that chains -# to the vote-confirmed hash. If no valid path exists, the configured -# policy determines behavior. +# This build is Alpenglow-only: blocks execute immediately on receipt, and +# Alpenglow certificates (block / skip / finalize / conflict) are the source +# of truth for fork choice and durable-state promotion. [consensus] - # Consensus engine: - # "classic" - Current Solana vote-anchored consensus checks - # "alpenglow-observer" - Passive Alpenglow observer hooks; no votes are signed - # "alpenglow" - Reserved for future Alpenglow voting mode - mode = "classic" - - # Optional passive Alpenglow Votor QUIC listener for observer mode. + # Node mode: + # "verifying" - DEFAULT. Non-voting: observe, execute, and verify the + # cluster. Fork choice is identical to validator mode + # (there is exactly one fork-choice algorithm). + # "validator" - Enforces the full voting-deployment shape at startup: + # validator.identity_keypair, validator.vote_account_keypair, + # block.source = "turbine", turbine.gossip_entrypoint, and + # consensus.alpenglow_observer_bind_addr are all REQUIRED. + # The voting engine (Votor event loop, BLS vote signing, + # durable vote history) has NOT landed yet: a validator-mode + # node runs the same verifying pipeline and casts no votes — + # select it now so the deployment is provisioned and its + # config stays valid when voting activates. + mode = "verifying" + + # Passive Alpenglow Votor QUIC listener (e.g. "0.0.0.0:8010"). # Agave advertises this as the gossip "alpenglow" socket and sends # wincode ConsensusMessage payloads over Solana QUIC ("solana-tpu" ALPN). # When native turbine gossip is enabled, Mithril advertises this socket - # in CRDS so Alpenglow peers can discover it. Leave empty to disable. + # in CRDS so Alpenglow peers can discover it. This is the low-latency cert + # feed; leave empty to disable and rely only on footer certs carried in + # shreds (durable folds then lag until those arrive). alpenglow_observer_bind_addr = "" alpenglow_max_message_bytes = 0 @@ -237,22 +323,6 @@ name = "mithril" # built-in default. Wrong value = every signature verification fails. alpenglow_bls_dst = "" - # Maximum depth (number of slots) the skip-path solver will explore. - # Longer ranges take more memory. 64 covers ~26 seconds of slots. - skip_path_max_depth = 64 - - # What to do when a Lightbringer slot range cannot be resolved: - # "halt" - Graceful shutdown, write diagnostic artifact (recommended) - # "warn" - Log warning and continue (use only for debugging) - unresolved_policy = "halt" - - # Which block source to enforce consensus on: - # "lightbringer" - Only enforce on Lightbringer blocks (RPC blocks are trusted) - # "turbine" - Only enforce on native turbine blocks - # "stream" - Enforce on any live shred-stream path - # "all" - Enforce on all block sources (not yet implemented) - enforce_on_source = "stream" - # ============================================================================ # [turbine] - Native Turbine Receiver (block.source = "turbine") @@ -262,13 +332,18 @@ name = "mithril" # RPC remains configured for catchup, tip polling, and repair/fallback paths. [turbine] - # UDP address where Mithril listens for turbine shreds. + # Solana gossip entrypoint of your Alpenglow cluster (host:port). REQUIRED + # for a live turbine deployment: Mithril contacts it to discover the shred + # version and to advertise its turbine/TVU + observer sockets into the + # turbine tree. Without it the node cannot join the tree and will not + # receive shreds. Example: + # gossip_entrypoint = "1.2.3.4:9000" + gossip_entrypoint = "" + + # UDP address where Mithril listens for turbine shreds. Equivalent to + # [block].turbine_bind_addr; set either one (this key takes precedence). # bind_addr = "0.0.0.0:8001" - # Solana gossip entrypoint used to discover the cluster shred version and - # advertise Mithril's turbine/TVU socket into the turbine tree. - # gossip_entrypoint = "1.2.3.4:8000" - # UDP address where Mithril listens for gossip traffic. Leave empty to let # the OS choose an available port. Set a fixed port if your firewall/NAT # needs an explicit rule. @@ -561,9 +636,10 @@ name = "mithril" # TCP connection timeout for pre-check (milliseconds) tcp_timeout_ms = 1000 - # Minimum Solana version required (e.g., "3.0.0", empty = no filter). - # Defaults to "3.0.0" on classic clusters and "0.3.0" on alpenglow. - # min_node_version = "3.0.0" + # Minimum Solana version required (e.g., "0.3.0", empty = no filter). + # Defaults to "0.3.0": the Alpenglow test cluster advertises Agave/Votor + # node versions in the 0.x series. + # min_node_version = "0.3.0" # Allowed Solana versions (empty = all versions allowed) # Example: allowed_node_versions = ["2.2.0", "3.0.0"] @@ -623,8 +699,39 @@ name = "mithril" # Maximum log file size in MB before rotation (0 = no limit) max_size_mb = 100 - # Delete log files older than this many days (0 = never delete) - max_age_days = 30 + # Delete log files older than this many days (0 = never delete, the + # default). Retention is otherwise bounded by max_size_mb x max_backups. + max_age_days = 0 # Maximum number of old log files to keep (0 = unlimited) max_backups = 100 + +# ============================================================================ +# [verifier] - Trailing Execution Verifier +# ============================================================================ +# +# Alpenglow certificates attest a block's DATA (block id), never the results +# of executing it. The trailing verifier is the execution-correctness oracle: +# it re-derives each executed slot's per-transaction results (fee, status, +# pre/post balances) from RPC getBlock metadata at finalized commitment and +# compares them against what replay produced. Durable folds gate on +# min(certificate finality, verified watermark) — nothing reaches disk +# unverified. On a confirmed mismatch the node halts and records evidence; +# restarts refuse to fold past the disputed slot until the evidence is +# cleared after triage. + +[verifier] + enabled = true + + # Gate folds on the verified watermark. false = advisory only (folds gate + # on certificate finality alone — an execution divergence would reach disk + # undetected; use only for debugging). + required = true + + # Verify slots this far behind the executed tip (lets the RPC's finalized + # view catch up before the first attempt). + lag_slots = 32 + + # The verifier's own RPC request budget (it never shares the block-fetch + # budget). 8 rps is ~3x steady-state block production. + max_rps = 8 diff --git a/docs/alpenglow_branch_engine.md b/docs/alpenglow_branch_engine.md new file mode 100644 index 000000000..026e7aa2e --- /dev/null +++ b/docs/alpenglow_branch_engine.md @@ -0,0 +1,172 @@ +# Alpenglow Branch Engine + +This build targets Alpenglow clusters exclusively. It replaces the old +TowerBFT confirmed-block fork-choice heuristic (which buffered candidate +blocks and executed only after vote tallies confirmed a path) with an +**execute-on-receipt** engine where certificates gate *promotion to durable +state* rather than execution. + +> The historical TowerBFT heuristic — vote parsing, confirmed-leaf resolution, +> buffered execution — lives on the `dev` branch. It has been removed here +> along with `pkg/forkchoice`. + +## Model in one paragraph + +Blocks execute the moment they are assembled. Alpenglow certificates never +hold up execution; they drive fork/skip decisions and gate how far durable +on-disk state is allowed to advance. Because certificates attest block *data* +(the `block_id` = slice merkle root), not execution *results*, a trailing +execution verifier is the only execution-correctness oracle at the tip — so +durable state is promoted only up to the minimum of certificate finality and +verified execution. State is one canonical timeline plus a short in-RAM mutable +suffix; a certificate that contradicts an already-executed slot triggers an +unwind and re-execution of the certified alternative. + +## The pipeline + +### 1. Execute-on-receipt + +The replay loop (`pkg/replay/block.go`) runs each block as it is assembled from +the block source. There is no confirmation gate before execution and no +buffered-path resolver. Emission still applies three cheap consensus behaviors +at the block source (`applyAlpenglowDecisionLocked` in +`pkg/blockstream/block_source.go`): mark certificate-skipped slots, discard a +not-yet-emitted candidate whose block id a decisive certificate contradicts, +and halt on an equivocation conflict. + +### 2. Certificates as the decision oracle + +`ChainTracker` (`pkg/alpenglow/chain.go`) ingests certificates (from block +footers and from the local certificate pool, `pkg/alpenglow/certpool.go`, which +assembles certificates early from raw Votor votes). It answers the questions the +engine needs: + +- `CertifiedBlockAt(slot)` — the slot's decisively certified block (a + unique-strength notarize / fast-finalize / genesis certificate, at most one + per slot by protocol, or a block finalized directly or by ancestry). Fallback + certificates are ambiguous and never decisive. +- `SkipCertifiedAt(slot)` — whether the slot is certified skipped. +- `WantedBlocks(afterSlot, max)` — certified-but-unobserved blocks, for repair. + +A second decisive block in one slot, or a finalized block contradicted by a +skip, is Byzantine evidence: the tracker records a conflict and the node halts +(write-once, survives pruning). + +### 3. State: canonical timeline + WorkingSet suffix + +Durable AccountsDB holds one rooted timeline. Executed-but-not-yet-durable +slots live in an in-RAM `WorkingSet` (`pkg/accounts/working_set.go`): a flat map +for O(1) reads plus a per-slot undo journal. Siblings are never materialized as +state — they are parked as block bytes and re-served on demand. This bounds RAM +and makes the common (no-fork) path cheap while the rare fork case pays. + +- `PromotePrefix(through)` folds the oldest suffix slots into durable state. +- `EvictFrom(slot)` unwinds a suffix by replaying its undo journal + newest-layer-first, restoring the exact pre-suffix values. + +### 4. Dual-watermark promotion (the safety keystone) + +Because certificates do not attest execution, promotion to disk is clamped to +`min(certificate finality, trailing-verification watermark)` +(`pkg/replay/promotion.go`). The trailing verifier +(`pkg/replay/trailing_verifier.go`) re-derives a compact per-transaction digest +(`pkg/replay/txdigest.go` — fee, success/failure, balances; compute units +deliberately excluded) from finalized RPC block metadata some slots behind the +tip and compares it against what replay produced. A mismatch is an execution +divergence: record evidence and halt. With the verifier required and its RPC +feed cut, folds stall and the in-RAM tail eventually hits its cap and halts — +fail-closed by design. + +### 5. Fork switch: sweep + unwind + +Since a slot can execute before its certificate arrives, a later certificate can +contradict an executed slot (a sibling we lost the shred race on, or a skip over +a block we ran). The switch sweep (`pkg/replay/alpenglow_switch.go`, gated on new +certificate arrivals) walks the executed-but-unfolded window and reports the +first contradiction as a typed `CertifiedSwitch`. When the parent context is +retained and the span is safe (same epoch, not mid-rewards-distribution), the +engine unwinds in-RAM (`tryInLoopUnwind` → `WorkingSet.EvictFrom`) and +re-executes the certified alternative; otherwise it falls back to re-replay from +the durable rooted checkpoint. The block source's emission frontier is rewound +in lockstep (`RewindForAlpenglowSwitch`). + +### 6. Cert-driven repair + +`WantedBlocks` feeds a near-tip repair loop in the block source +(`alpenglowRepairLoop`): pin the turbine assembler to certified block ids, pull +repair for certified-but-unobserved slots, discard buffered candidates carrying +the wrong id, and cancel shred state for certificate-skipped slots. This also +keeps re-hinting the certified sibling after a switch until its data arrives. + +## Durable storage (wear-first) + +Rooted slots fold to disk in batches (`pkg/accountsdb/fold.go`, +`CommitBatch`): K slots union-deduped into one sequential segment file + one +manifest + one atomic index flip, instead of per-slot in-place writes. The +manifest is simultaneously the commit record, the index redo log (which allows +running the account index without a WAL), the undo-pointer log, and the carrier +of batch bankhashes plus the resume context at the batch boundary. Recovery +(`pkg/accountsdb/recovery.go`) reconstructs the durable frontier from the store +itself, so a hard `kill -9` no longer forces a re-bootstrap. + +Two capabilities fall out of the undo-pointer log: + +- **Rewind** (`pkg/accountsdb/rewind.go`, `RewindToBatchBoundary`): restore + durable state to an earlier retained fold boundary by applying undo pointers + in reverse. This is why durable state deliberately lags — a divergence whose + root cause was already folded can be undone without a snapshot restart. Wired + as `--rewind-to-slot` and as an automatic recovery arm. +- **Compaction** (`pkg/accountsdb/compact.go`, `CompactOnce`): reclaim dead + bytes from out-of-horizon segments and bootstrap appendvecs, pinned so it + never touches a file any in-horizon undo pointer still needs. + +## Voting readiness (one fork choice, no modes) + +The fork-choice layer is built so a voting engine can be added ON TOP without +changing it — the same single behavior serves observer and voting nodes, and a +mixed (heterogeneous-client) or Mithril-only cluster identically: + +- **Certificate semantics are Agave-parity.** Thresholds, vote-to-cert unions, + per-validator vote budgets, base/fallback bitmap disjointness, fallback-only + assembly (base3 with an empty base group), and slow finalization were + cross-checked against `anza-xyz/alpenglow` (votor) and SIMD-0326; wire + encoding is validated by `agave-votor-messages` fixtures in + `pkg/alpenglow/testdata/`. +- **Trigger freshness is guaranteed by the pool, always on.** Votor's fallback + triggers (transcribed from Agave `votor/src/common.rs`): + SafeToNotar(b) = `notar(b) >= 40%` OR `notar(b) >= 20% AND notar(b)+skip >= 60%`; + SafeToSkip = `skip + (notarTotal - topNotar) >= 40%` — over plain + notarize/skip votes only. The cert pool folds (batch-verifies) the involved + tallies the moment any trigger predicate passes on candidate stake, so + verified stake is never stale when a predicate could have crossed. A voting + engine evaluates the predicates on `CertPool.VerifiedVotorStakes(slot)` and + observes crossings at the same time an eager-verification client would. + Sub-trigger tallies still cost zero verification. +- **The voting engine's plug-in points**: `VerifiedVotorStakes` (trigger + stake), the cert emit callback (round-2 finalize votes react to notarization + certs), and the ChainTracker decision queries (parent-ready sequencing). + What voting mode adds lives entirely above this layer: the vote loop and + timeouts, vote signing/transmission, durable vote-history persistence, and + standstill participation. + +## What this proves — and does not + +The certificate layer proves which block *data* the cluster settled on. The +trailing verifier proves execution *correctness* against the cluster's +finalized results. Neither replaces Mithril's own transaction execution, +account loading, bankhash calculation, reward/rent handling, or bankhash +verification — those still run in full. The engine's job is to pick the right +block to execute, promote durable state only when it is safe, and halt (never +silently diverge) when it cannot. + +## Relevant code + +- `pkg/replay/block.go` — execute-on-receipt loop, promotion + switch wiring +- `pkg/replay/promotion.go` — dual-watermark fold gating, unrooted tail +- `pkg/replay/trailing_verifier.go`, `pkg/replay/txdigest.go` — execution oracle +- `pkg/replay/alpenglow_switch.go` — switch sweep + in-loop unwind +- `pkg/alpenglow/chain.go` — ChainTracker decisions / skips / wanted blocks +- `pkg/alpenglow/certpool.go` — early cert assembly from raw votes +- `pkg/accounts/working_set.go` — canonical-suffix state with undo journal +- `pkg/accountsdb/fold.go`, `recovery.go`, `rewind.go`, `compact.go` — durable store +- `pkg/blockstream/block_source.go` — emission, decision application, cert-driven repair diff --git a/docs/fork_choice_heuristic.md b/docs/fork_choice_heuristic.md deleted file mode 100644 index aeb6b827c..000000000 --- a/docs/fork_choice_heuristic.md +++ /dev/null @@ -1,290 +0,0 @@ -# Confirmed-Block Fork-Choice Heuristic - -Mithril uses a lightweight confirmed-block fork-choice heuristic when replaying recent blocks from Lightbringer. - -The goal is to decide which locally observed blocks should be executed before Mithril commits to executing them. This allows Mithril be independent of RPC providers for recent blocks (except for catch-up purposes): Lightbringer supplies the block data from Turbine and Repair, and Mithril parses vote transactions in received blocks to derive confirmed-block information, in addition to observing parent links to decide upon the correct execution path. - -These heuristics allow Mithril to select a PoH-consistent path of blocks and skipped slots. Mithril then executes selected blocks and verifies the resulting bankhash for consistency against the one expected based on vote tallies. - -## What Problem It Solves - -When Mithril is close to the tip of the chain, Lightbringer may observe: - -- blocks arriving from Turbine, -- blocks recovered through Repair, -- skipped slots, -- temporary gaps, -- and blocks ahead of the last slot Mithril has executed. - -Before executing forward, Mithril needs to know which observed blocks are on the confirmed path. - -Instead of asking an RPC provider for every recent block body, Mithril can use Lightbringer’s local block stream and ask: - -> Given my current execution anchor, which observed blocks connect to a confirmed leaf? - -The answer is a sequence of slot decisions: - -```text -slot n -> use block -slot n + 1 -> skipped -slot n + 2 -> skipped -slot n + 3 -> use block -``` - -Mithril then executes the selected blocks and skips the selected empty slots. - -## Terminology - -In this context, **blockhash** means the Solana PoH blockhash: the final PoH/entry hash for a slot. - -It does **not** mean the bankhash produced by executing the block. - -Mithril tracks both concepts: - -- **PoH blockhash**: used to connect slots and reason about skipped-slot paths. -- **Bankhash**: produced after executing the block and used to verify execution correctness. - -The fork-choice heuristic works with observed PoH parent relationships and vote-confirmed bankhash winners. Final execution correctness is still checked by Mithril’s bankhash verification path. - -## How The Heuristic Works - -At a high level, Mithril: - -1. Starts from the current execution anchor. -2. Observes blocks from Lightbringer before executing them. -3. Extracts vote information from those observed blocks. -4. Tracks which bankhash has reached supermajority for observed slots. -5. Finds the highest confirmed leaf reachable within the configured depth. -6. Walks backward from that confirmed leaf to the execution anchor using observed parent links. -7. Converts the parent chain into a list of slot decisions. -8. Executes blocks marked `UseBlock = true`. -9. Treats missing intermediate slots as skipped. - -The important detail is that the implementation does **not** brute-force every possible PoH branch. - -Instead, it uses the parent metadata already available from observed blocks: - -- Lightbringer blocks provide a known parent slot. -- RPC/all-source blocks can be linked by parent blockhash once Mithril has a valid PoH anchor. -- Missing slots between a child and its parent are treated as skipped. - -So the resolver is better described as a **backward parent-link path resolver**, not a forward exhaustive PoH search. - -## Execution Anchor - -The search starts from Mithril’s current execution anchor. - -The anchor can come from: - -- the last successfully executed slot during normal replay, -- persisted resume state after a restart, -- or the snapshot’s latest PoH blockhash on a fresh start. - -The snapshot bankhash is not used as a PoH anchor. Bankhash and PoH blockhash are separate values with different roles. - -The relevant anchor logic lives in: - -- `pkg/replay/block.go` - - `consensusExecutionAnchor(...)` - - `observeConsensusAnchor(...)` - -## Slot Decisions - -The resolver returns a list of slot decisions: - -```text -UseBlock = true -> execute the observed block at this slot -UseBlock = false -> treat this slot as empty/skipped -``` - -Example: - -```text -anchor: slot 100 - -observed: - slot 101 parent = 100 - slot 104 parent = 101 - -resolved path: - slot 101 -> use block - slot 102 -> skipped - slot 103 -> skipped - slot 104 -> use block -``` - -This means the confirmed path from slot 100 to slot 104 uses the observed blocks at 101 and 104, while treating 102 and 103 as skipped. - -A skipped decision does not mean no validator ever saw any data for that slot. It means the selected confirmed path does not include a block at that slot. - -## Relationship To Votes - -Mithril observes vote transactions inside candidate blocks and uses stake-weighted vote information to identify confirmed leaves. - -The fork-choice service tracks: - -- observed block metadata, -- vote-derived stake totals, -- supermajority bankhash winners, -- parent relationships, -- equivocations, -- and pending parent-blockhash links. - -Once a slot has a supermajority winner, the consensus coordinator can try to resolve a path from the current anchor to that confirmed slot. - -Relevant code: - -- `pkg/forkchoice/forkchoice.go` - - `ObserveBlock(...)` - - `FindConfirmedLeaf(...)` - - `ResolvePathToLeaf(...)` - - `IsBankhashCorrect(...)` - -- `pkg/forkchoice/vote_parser.go` - -- `pkg/forkchoice/vote_stake_accumulator.go` - -## Path Resolution - -The path resolver walks backward from a confirmed leaf slot toward the anchor. - -For each observed block, it checks the block’s parent slot. Any gap between the child slot and parent slot becomes skipped slots in the resolved path. - -Example: - -```text -anchor = 200 - -confirmed leaf: - slot 205 parent = 202 - -observed parent: - slot 202 parent = 200 - -resolved path: - 201 -> skipped - 202 -> use block - 203 -> skipped - 204 -> skipped - 205 -> use block -``` - -Relevant code: - -- `pkg/forkchoice/skip_path.go` - - `ResolvePohPath(...)` - -- `pkg/forkchoice/consensus_coordinator.go` - - `ResolveFromAnchor(...)` - -## Replay Integration - -During replay, Mithril buffers candidate blocks instead of immediately executing them when confirmed-block enforcement is active. - -Then it: - -1. observes candidate block metadata, -2. feeds votes into the fork-choice service, -3. asks the consensus coordinator for a resolved path, -4. executes only the blocks selected by that path, -5. records skipped slots as replay progress, -6. and verifies bankhash after execution. - -Relevant code: - -- `pkg/replay/block.go` - - `observeBlockForConsensus(...)` - - `syncConsensusBufferedExecutionMode(...)` - - `readyConsensusPath` - - `pendingConsensusPath` - -## Configuration - -The heuristic is configured under Mithril’s `[consensus]` section. - -Example: - -```toml -[consensus] -skip_path_max_depth = 64 -unresolved_policy = "halt" -enforce_on_source = "lightbringer" -``` - -Key options: - -- `skip_path_max_depth` - - Maximum number of slots the resolver will search from the current anchor. - -- `unresolved_policy` - - `"halt"`: stop gracefully if the path cannot be resolved. - - `"warn"`: log and continue. Mainly useful for debugging. - -- `enforce_on_source` - - `"lightbringer"`: enforce confirmed-block path selection for Lightbringer blocks. - - `"all"`: enforce across all block sources that can be linked by parent slot or parent blockhash. - -Relevant code: - -- `pkg/config/config.go` -- `cmd/mithril/node/node.go` -- `pkg/replay/block.go` - -## What This Heuristic Provides - -This heuristic provides: - -- a confirmed execution path from the current anchor, -- local use of Lightbringer block data, -- reduced dependence on RPC block bodies for recent slots, -- explicit skipped-slot decisions, -- and a bounded way to avoid executing blocks before Mithril knows they connect to a confirmed path. - -It is especially useful when Mithril is replaying near the tip and Lightbringer is receiving fresh Turbine/Repair data locally. - -## What It Does Not Prove - -This heuristic does not prove that a block executed correctly. - -It does not replace: - -- transaction execution, -- account loading, -- bankhash calculation, -- reward calculation, -- rent handling, -- or final bankhash verification. - -It also should not be described as a full consensus proof. It is a practical, bounded fork-choice heuristic used to select a local execution path before Mithril performs full execution verification. - -## Failure Modes - -Path resolution can fail when: - -- the target confirmed block has not arrived yet, -- intermediate shreds are missing, -- repair has not completed, -- the confirmed target is outside the configured search depth, -- parent metadata is incomplete, -- observed blocks conflict, -- vote data has not landed yet, -- or the local execution anchor is wrong. - -When the path is incomplete, Mithril can wait for more Lightbringer data or repair progress. - -When the path cannot be resolved within the configured bounds, Mithril follows the configured policy, such as halting gracefully or warning for debugging. - -## Summary - -Lightbringer gives Mithril recent block data from Turbine and Repair. - -Mithril’s confirmed-block fork-choice heuristic determines which observed blocks connect to a confirmed path. - -Mithril then executes that path and verifies the resulting bankhash. - -The heuristic is implemented in Mithril today, primarily in: - -- `pkg/forkchoice/forkchoice.go` -- `pkg/forkchoice/skip_path.go` -- `pkg/forkchoice/consensus_coordinator.go` -- `pkg/replay/block.go` diff --git a/pkg/accounts/branch_tree.go b/pkg/accounts/branch_tree.go deleted file mode 100644 index f51aa6a2a..000000000 --- a/pkg/accounts/branch_tree.go +++ /dev/null @@ -1,206 +0,0 @@ -package accounts - -import "sync" - -// branch is one node of the fork tree: a copy-on-write overlay of a single slot's -// account writes over its parent (nil parent = the durable rooted base). Only -// accounts written on this branch have delta entries; the rest resolve up the chain. -type branch struct { - id uint64 - slot uint64 - blockID [32]byte - parent *branch - children []*branch - delta map[[32]byte]*Account // this branch's own writes (zero-lamport = tombstone) - frozen bool // sticky: set once it has a child; never mutated again -} - -// PromotedSlot is one folded slot from a promoted chain: its branch id, slot, and -// account writes. The caller commits these durably (per-slot bankhash/context are -// tracked by the caller, keyed by BranchID). -type PromotedSlot struct { - BranchID uint64 - Slot uint64 - Delta []*Account -} - -// BranchTree holds the in-RAM tree of confirmed-but-unrooted branches over a durable -// rooted store: reads resolve nearest-ancestor, the finalized branch is promoted -// (folded to durable) and every non-descendant branch is evicted. Composition with -// the durable layer is the caller's job (see StateView). -type BranchTree struct { - mu sync.RWMutex - byID map[uint64]*branch - nextID uint64 -} - -// NewBranchTree creates an empty fork tree over the durable rooted base. -func NewBranchTree() *BranchTree { - return &BranchTree{byID: make(map[uint64]*branch)} -} - -// AddBranch creates a copy-on-write child of parentID (0 = over the durable base) -// for slot/blockID and freezes the parent. Returns (id, true), or (0, false) if a -// non-zero parentID is not found (refuses to create an orphan). -func (t *BranchTree) AddBranch(parentID, slot uint64, blockID [32]byte) (uint64, bool) { - t.mu.Lock() - defer t.mu.Unlock() - var parent *branch - if parentID != 0 { - if parent = t.byID[parentID]; parent == nil { - return 0, false - } - } - t.nextID++ - b := &branch{id: t.nextID, slot: slot, blockID: blockID, delta: make(map[[32]byte]*Account)} - if parent != nil { - b.parent = parent - parent.children = append(parent.children, b) - parent.frozen = true - } - t.byID[b.id] = b - return b.id, true -} - -// Commit installs a completed slot's writes on a leaf branch. No-op if the branch is -// unknown or frozen (ever had a child) — only never-parented leaves are mutable. -func (t *BranchTree) Commit(branchID uint64, delta []*Account) { - t.mu.Lock() - defer t.mu.Unlock() - b := t.byID[branchID] - if b == nil || b.frozen { - return - } - for _, a := range delta { - if a != nil { - b.delta[[32]byte(a.Key)] = a - } - } -} - -// Get resolves pubkey on branchID by walking to the root; returns (acct, true) at the -// nearest ancestor that wrote it, else (nil, false) to fall through to durable. The -// returned *Account is shared (immutable): callers must copy-on-write before mutating. -func (t *BranchTree) Get(branchID uint64, pubkey [32]byte) (*Account, bool) { - t.mu.RLock() - defer t.mu.RUnlock() - for b := t.byID[branchID]; b != nil; b = b.parent { - if a, ok := b.delta[pubkey]; ok { - return a, true - } - } - return nil, false -} - -// Len reports the number of live branches (for RAM bounding). -func (t *BranchTree) Len() int { - t.mu.RLock() - defer t.mu.RUnlock() - return len(t.byID) -} - -// LiveIDs returns the set of live branch ids, so callers can prune side maps keyed by -// branch id after Promote/EvictSubtree. -func (t *BranchTree) LiveIDs() map[uint64]bool { - t.mu.RLock() - defer t.mu.RUnlock() - ids := make(map[uint64]bool, len(t.byID)) - for id := range t.byID { - ids[id] = true - } - return ids -} - -// EvictSubtree drops branchID and all its descendants (a losing fork) and returns the -// count removed. -func (t *BranchTree) EvictSubtree(branchID uint64) int { - t.mu.Lock() - defer t.mu.Unlock() - b := t.byID[branchID] - if b == nil { - return 0 - } - if b.parent != nil { - b.parent.children = removeChild(b.parent.children, b) - } - return t.dropRec(b) -} - -// PromotionChain returns the ancestor chain up to branchID as folded slots ascending by -// slot, WITHOUT mutating the tree. Returned deltas reference stored *Account values -// (immutable); within a slot their order is unspecified. Two-phase with Promote: commit -// this chain durably first, THEN call Promote — mirrors UnrootedOverlay's contract. -func (t *BranchTree) PromotionChain(branchID uint64) []PromotedSlot { - t.mu.RLock() - defer t.mu.RUnlock() - target := t.byID[branchID] - if target == nil { - return nil - } - var chain []*branch - for b := target; b != nil; b = b.parent { - chain = append(chain, b) - } - for i, j := 0, len(chain)-1; i < j; i, j = i+1, j-1 { - chain[i], chain[j] = chain[j], chain[i] - } - out := make([]PromotedSlot, 0, len(chain)) - for _, b := range chain { - delta := make([]*Account, 0, len(b.delta)) - for _, a := range b.delta { - delta = append(delta, a) - } - out = append(out, PromotedSlot{BranchID: b.id, Slot: b.slot, Delta: delta}) - } - return out -} - -// Promote drops the folded chain up to branchID and every non-descendant branch (the -// chain plus all losing forks, including competing roots), and re-roots branchID's -// children over the new durable base. Call ONLY after the PromotionChain deltas are -// durable, else a re-rooted survivor read falls through to a not-yet-updated base. -func (t *BranchTree) Promote(branchID uint64) { - t.mu.Lock() - defer t.mu.Unlock() - target := t.byID[branchID] - if target == nil { - return - } - // Survivors = target's descendants; drop everything else, re-root direct children. - survivors := make(map[uint64]bool) - var collect func(b *branch) - collect = func(b *branch) { - for _, c := range b.children { - survivors[c.id] = true - collect(c) - } - } - collect(target) - for _, c := range target.children { - c.parent = nil - } - for id := range t.byID { - if !survivors[id] { - delete(t.byID, id) - } - } -} - -// dropRec removes b and its descendants from the id index. Caller holds t.mu. -func (t *BranchTree) dropRec(b *branch) int { - n := 1 - for _, c := range b.children { - n += t.dropRec(c) - } - delete(t.byID, b.id) - return n -} - -func removeChild(children []*branch, b *branch) []*branch { - for i, c := range children { - if c == b { - return append(children[:i], children[i+1:]...) - } - } - return children -} diff --git a/pkg/accounts/branch_tree_bench_test.go b/pkg/accounts/branch_tree_bench_test.go deleted file mode 100644 index a9e9bc5c3..000000000 --- a/pkg/accounts/branch_tree_bench_test.go +++ /dev/null @@ -1,59 +0,0 @@ -package accounts - -import ( - "testing" -) - -// Benchmarks for the fork-tree read path vs a flat resolved index, at the -// realistic depth (~31 unrooted slots) — the data behind the "resolved hot -// index" design decision. - -func buildDeepChain(depth int, writesPerSlot int) (*BranchTree, uint64) { - tree := NewBranchTree() - parent := uint64(0) - for d := 0; d < depth; d++ { - id, _ := tree.AddBranch(parent, uint64(d+1), [32]byte{byte(d + 1)}) - var delta []*Account - for w := 0; w < writesPerSlot; w++ { - k := key32(byte(d*writesPerSlot + w + 1)) - delta = append(delta, &Account{Key: k, Lamports: uint64(d + 1)}) - } - tree.Commit(id, delta) - parent = id - } - return tree, parent -} - -// Worst case: key not present anywhere in the chain — full depth walk. -func BenchmarkBranchTreeGetMissDepth31(b *testing.B) { - tree, tip := buildDeepChain(31, 8) - missKey := key32(0xFF) - b.ResetTimer() - for i := 0; i < b.N; i++ { - tree.Get(tip, missKey) - } -} - -// Hit at the far end (written in the oldest slot) — near-full walk. -func BenchmarkBranchTreeGetDeepHitDepth31(b *testing.B) { - tree, tip := buildDeepChain(31, 8) - oldKey := key32(1) // written in slot 1, 31 levels up - b.ResetTimer() - for i := 0; i < b.N; i++ { - tree.Get(tip, oldKey) - } -} - -// Baseline: one flat map lookup (what a resolved hot index would cost). -func BenchmarkFlatMapLookup(b *testing.B) { - m := make(map[[32]byte]*Account, 256) - for i := 1; i <= 248; i++ { - k := key32(byte(i)) - m[k] = &Account{Key: k} - } - missKey := key32(0xFF) - b.ResetTimer() - for i := 0; i < b.N; i++ { - _ = m[missKey] - } -} diff --git a/pkg/accounts/branch_tree_model_test.go b/pkg/accounts/branch_tree_model_test.go deleted file mode 100644 index 2fcfedc08..000000000 --- a/pkg/accounts/branch_tree_model_test.go +++ /dev/null @@ -1,202 +0,0 @@ -package accounts - -import ( - "maps" - "math/rand" - "testing" -) - -// modelTree is a naive reference: every branch holds a FULL copy of its composed -// visible state (durable base + ancestor writes + own writes). Trivially correct by -// construction; the real BranchTree composed with a durable map must match it. -type modelTree struct { - durable map[[32]byte]uint64 // the model's rooted base - state map[uint64]map[[32]byte]uint64 // branchID -> full composed view - parent map[uint64]uint64 - children map[uint64][]uint64 - frozen map[uint64]bool -} - -func newModelTree() *modelTree { - return &modelTree{ - durable: make(map[[32]byte]uint64), - state: make(map[uint64]map[[32]byte]uint64), - parent: make(map[uint64]uint64), - children: make(map[uint64][]uint64), - frozen: make(map[uint64]bool), - } -} - -func (m *modelTree) addBranch(id, parentID uint64) bool { - if parentID != 0 && m.state[parentID] == nil { - return false - } - full := make(map[[32]byte]uint64) - if parentID == 0 { - maps.Copy(full, m.durable) // root branches see the durable base - } else { - maps.Copy(full, m.state[parentID]) - } - m.state[id] = full - m.parent[id] = parentID - m.children[parentID] = append(m.children[parentID], id) - if parentID != 0 { - m.frozen[parentID] = true - } - return true -} - -func (m *modelTree) commit(id uint64, writes map[[32]byte]uint64) { - if m.state[id] == nil || m.frozen[id] { - return - } - maps.Copy(m.state[id], writes) -} - -func (m *modelTree) evict(id uint64) { - if m.state[id] == nil { - return - } - for _, c := range m.children[id] { - m.evict(c) - } - delete(m.state, id) - delete(m.frozen, id) - delete(m.children, id) -} - -func (m *modelTree) promote(id uint64) { - if m.state[id] == nil { - return - } - // The target's composed view becomes the new durable base (it already includes - // the old base + the folded chain's writes). Survivors = target's descendants. - m.durable = make(map[[32]byte]uint64) - maps.Copy(m.durable, m.state[id]) - survivors := make(map[uint64]bool) - var mark func(b uint64) - mark = func(b uint64) { - for _, c := range m.children[b] { - if m.state[c] != nil { - survivors[c] = true - mark(c) - } - } - } - mark(id) - for b := range m.state { - if !survivors[b] { - delete(m.state, b) - delete(m.frozen, b) - delete(m.children, b) - } - } -} - -// Model-based randomized test: ~1500 seeded random ops driven against the real -// BranchTree (composed with a durable map, exactly as the replay loop composes it) -// and the naive full-copy model; after every op, every live branch's COMPOSED view of -// every key must match. Catches interleaving bugs no hand-picked scenario covers. -func TestBranchTreeModelBased(t *testing.T) { - rng := rand.New(rand.NewSource(42)) // deterministic - tree := NewBranchTree() - model := newModelTree() - realDurable := make(map[[32]byte]uint64) // the real side's rooted base - - var live []uint64 - nextSlot := uint64(1) - keys := make([][32]byte, 12) - for i := range keys { - keys[i] = key32(byte(i + 1)) - } - - syncLive := func() { - ids := tree.LiveIDs() - live = live[:0] - for id := range ids { - live = append(live, id) - } - } - - // composed read: branch overlay chain, else durable — what the replay loop sees - composedGet := func(id uint64, k [32]byte) (uint64, bool) { - if a, ok := tree.Get(id, k); ok { - return a.Lamports, true - } - v, ok := realDurable[k] - return v, ok - } - - checkParity := func(op string, step int) { - for _, id := range live { - for _, k := range keys { - got, ok := composedGet(id, k) - want, wantOk := model.state[id][k] - if ok != wantOk { - t.Fatalf("step %d %s: branch %d key %d presence mismatch: real=%v model=%v", step, op, id, k[0], ok, wantOk) - } - if ok && got != want { - t.Fatalf("step %d %s: branch %d key %d value mismatch: real=%d model=%d", step, op, id, k[0], got, want) - } - } - } - } - - pickLive := func() uint64 { - if len(live) == 0 { - return 0 - } - return live[rng.Intn(len(live))] - } - - for step := range 1500 { - switch op := rng.Intn(10); { - case op < 4 || len(live) == 0: // add a branch (root or child of a random live one) - parent := uint64(0) - if len(live) > 0 && rng.Intn(3) > 0 { - parent = pickLive() - } - id, ok := tree.AddBranch(parent, nextSlot, [32]byte{byte(nextSlot)}) - if mok := model.addBranch(id, parent); ok != mok { - t.Fatalf("step %d: addBranch ok mismatch real=%v model=%v", step, ok, mok) - } - nextSlot++ - syncLive() - checkParity("add", step) - case op < 7: // commit random writes to a random live branch - id := pickLive() - writes := make(map[[32]byte]uint64) - var delta []*Account - for range rng.Intn(4) + 1 { - k := keys[rng.Intn(len(keys))] - v := uint64(rng.Intn(1000)) // 0 = tombstone, also exercised - writes[k] = v - delta = append(delta, &Account{Key: k, Lamports: v}) - } - tree.Commit(id, delta) - model.commit(id, writes) - checkParity("commit", step) - case op < 8: // evict a random live subtree - id := pickLive() - tree.EvictSubtree(id) - model.evict(id) - syncLive() - checkParity("evict", step) - default: // two-phase promote through a random live branch - id := pickLive() - chain := tree.PromotionChain(id) - for _, ps := range chain { // fold ascending into the real durable base - for _, a := range ps.Delta { - realDurable[[32]byte(a.Key)] = a.Lamports - } - } - tree.Promote(id) - model.promote(id) - syncLive() - checkParity("promote", step) - } - } - if tree.Len() != len(model.state) { - t.Fatalf("final live-count mismatch: real=%d model=%d", tree.Len(), len(model.state)) - } -} diff --git a/pkg/accounts/branch_tree_test.go b/pkg/accounts/branch_tree_test.go deleted file mode 100644 index d09725bc2..000000000 --- a/pkg/accounts/branch_tree_test.go +++ /dev/null @@ -1,331 +0,0 @@ -package accounts - -import "testing" - -// key32 adapts the pk helper to the [32]byte key BranchTree.Get expects. -func key32(b byte) [32]byte { return [32]byte(pk(b)) } - -func TestBranchTreeGetAndCommit(t *testing.T) { - tree := NewBranchTree() - b, _ := tree.AddBranch(0, 1, [32]byte{}) - tree.Commit(b, []*Account{uoAcct(1, 100)}) - - if a, ok := tree.Get(b, key32(1)); !ok || a.Lamports != 100 { - t.Fatalf("get written acct: ok=%v acct=%v", ok, a) - } - if _, ok := tree.Get(b, key32(2)); ok { - t.Fatal("unwritten key should miss (fall through to durable)") - } -} - -func TestBranchTreeCopyOnWriteAncestry(t *testing.T) { - tree := NewBranchTree() - parent, _ := tree.AddBranch(0, 1, [32]byte{}) - tree.Commit(parent, []*Account{uoAcct(1, 10)}) // parent writes key 1 - child, _ := tree.AddBranch(parent, 2, [32]byte{}) - tree.Commit(child, []*Account{uoAcct(2, 20)}) // child writes key 2 - - // child sees parent's key 1 (nearest-ancestor) and its own key 2 - if a, ok := tree.Get(child, key32(1)); !ok || a.Lamports != 10 { - t.Fatalf("child should inherit parent key1: ok=%v acct=%v", ok, a) - } - if a, ok := tree.Get(child, key32(2)); !ok || a.Lamports != 20 { - t.Fatalf("child should see own key2: ok=%v acct=%v", ok, a) - } - // parent must NOT see the child's write - if _, ok := tree.Get(parent, key32(2)); ok { - t.Fatal("parent must not see child's write") - } -} - -func TestBranchTreeChildOverridesParent(t *testing.T) { - tree := NewBranchTree() - parent, _ := tree.AddBranch(0, 1, [32]byte{}) - tree.Commit(parent, []*Account{uoAcct(1, 10)}) - child, _ := tree.AddBranch(parent, 2, [32]byte{}) - tree.Commit(child, []*Account{uoAcct(1, 77)}) // same key, newer value - - if a, _ := tree.Get(child, key32(1)); a.Lamports != 77 { - t.Fatalf("child override should win: %v", a) - } - if a, _ := tree.Get(parent, key32(1)); a.Lamports != 10 { - t.Fatalf("parent value must be unchanged: %v", a) - } -} - -func TestBranchTreeTombstoneShadows(t *testing.T) { - tree := NewBranchTree() - parent, _ := tree.AddBranch(0, 1, [32]byte{}) - tree.Commit(parent, []*Account{uoAcct(1, 10)}) - child, _ := tree.AddBranch(parent, 2, [32]byte{}) - tree.Commit(child, []*Account{uoAcct(1, 0)}) // zero-lamport tombstone - - a, ok := tree.Get(child, key32(1)) - if !ok || a.Lamports != 0 { - t.Fatalf("child should see the zero-lamport tombstone shadowing parent: ok=%v acct=%v", ok, a) - } -} - -func TestBranchTreeForkIsolation(t *testing.T) { - tree := NewBranchTree() - parent, _ := tree.AddBranch(0, 1, [32]byte{}) - tree.Commit(parent, []*Account{uoAcct(9, 1)}) - a, _ := tree.AddBranch(parent, 2, [32]byte{0xAA}) // competing children of the same parent - b, _ := tree.AddBranch(parent, 2, [32]byte{0xBB}) - tree.Commit(a, []*Account{uoAcct(1, 111)}) - tree.Commit(b, []*Account{uoAcct(1, 222)}) - - if av, _ := tree.Get(a, key32(1)); av.Lamports != 111 { - t.Fatalf("branch A isolation: %v", av) - } - if bv, _ := tree.Get(b, key32(1)); bv.Lamports != 222 { - t.Fatalf("branch B isolation: %v", bv) - } - // both inherit the shared parent account - if pv, ok := tree.Get(a, key32(9)); !ok || pv.Lamports != 1 { - t.Fatalf("branch A should inherit shared parent key9: ok=%v", ok) - } -} - -func TestBranchTreeFreezeOnChild(t *testing.T) { - tree := NewBranchTree() - parent, _ := tree.AddBranch(0, 1, [32]byte{}) - tree.Commit(parent, []*Account{uoAcct(1, 10)}) - tree.AddBranch(parent, 2, [32]byte{}) // parent now frozen - - tree.Commit(parent, []*Account{uoAcct(1, 99)}) // must be a no-op - if a, _ := tree.Get(parent, key32(1)); a.Lamports != 10 { - t.Fatalf("frozen parent must not accept writes: %v", a) - } -} - -func TestBranchTreeEvictSubtree(t *testing.T) { - tree := NewBranchTree() - p, _ := tree.AddBranch(0, 1, [32]byte{}) - a, _ := tree.AddBranch(p, 2, [32]byte{0xAA}) - b, _ := tree.AddBranch(p, 2, [32]byte{0xBB}) - c, _ := tree.AddBranch(a, 3, [32]byte{}) // descendant of A - if tree.Len() != 4 { - t.Fatalf("expected 4 branches, got %d", tree.Len()) - } - - removed := tree.EvictSubtree(a) // drop losing fork A and its child C - if removed != 2 { - t.Fatalf("expected 2 removed (A+C), got %d", removed) - } - if tree.Len() != 2 { - t.Fatalf("expected 2 remaining (P,B), got %d", tree.Len()) - } - if _, ok := tree.Get(c, key32(1)); ok { - t.Fatal("evicted branch C should be gone") - } - // sibling B and parent P survive - tree.Commit(b, []*Account{uoAcct(5, 5)}) - if _, ok := tree.Get(b, key32(5)); !ok { - t.Fatal("sibling B should survive eviction") - } -} - -func TestBranchTreePromoteThrough(t *testing.T) { - tree := NewBranchTree() - // Interleave AddBranch/Commit like real execution: a slot is committed (while a - // leaf) before the next slot's branch is created off it. Winning chain P(1)->a(2)->c(3). - p, _ := tree.AddBranch(0, 1, [32]byte{}) - tree.Commit(p, []*Account{uoAcct(1, 10)}) - a, _ := tree.AddBranch(p, 2, [32]byte{0x0A}) - tree.Commit(a, []*Account{uoAcct(2, 20)}) - loserB, _ := tree.AddBranch(p, 2, [32]byte{0x0B}) // competes with a - tree.Commit(loserB, []*Account{uoAcct(1, 999)}) - c, _ := tree.AddBranch(a, 3, [32]byte{0x0C}) - tree.Commit(c, []*Account{uoAcct(3, 30)}) - loserD, _ := tree.AddBranch(a, 3, [32]byte{0x0D}) // competes with c - tree.Commit(loserD, []*Account{uoAcct(4, 999)}) - e, _ := tree.AddBranch(c, 4, [32]byte{}) // survivor above the winner - tree.Commit(e, []*Account{uoAcct(5, 50)}) - - out := tree.PromotionChain(c) - tree.Promote(c) - - // deltas returned ascending by slot for durable commit - if len(out) != 3 || out[0].Slot != 1 || out[1].Slot != 2 || out[2].Slot != 3 { - t.Fatalf("expected slots [1,2,3], got %+v", out) - } - if out[0].Delta[0].Lamports != 10 || out[2].Delta[0].Lamports != 30 { - t.Fatalf("promoted deltas wrong: %+v", out) - } - // only the survivor E remains; losers + folded chain gone - if tree.Len() != 1 { - t.Fatalf("expected 1 branch (E) after promote, got %d", tree.Len()) - } - if _, ok := tree.Get(loserB, key32(1)); ok { - t.Fatal("loser B should be dropped") - } - if _, ok := tree.Get(loserD, key32(4)); ok { - t.Fatal("loser D should be dropped") - } - // E now sits over the durable base: its own write is visible, folded state is not - if v, ok := tree.Get(e, key32(5)); !ok || v.Lamports != 50 { - t.Fatalf("survivor E own write should remain: ok=%v", ok) - } - if _, ok := tree.Get(e, key32(3)); ok { - t.Fatal("folded chain state must no longer be in the tree (now durable)") - } -} - -// RED-verify (design-conformance #1): promote must evict competing non-descendant -// roots (e.g. an equivocating block at the same slot), per Alpenglow/Agave "evict -// every non-descendant of the finalized block". -func TestBranchTreePromoteEvictsOtherRoots(t *testing.T) { - tree := NewBranchTree() - r1, _ := tree.AddBranch(0, 1, [32]byte{0xA1}) - tree.Commit(r1, []*Account{uoAcct(1, 10)}) - r2, _ := tree.AddBranch(0, 1, [32]byte{0xB2}) // competing equivocating root - tree.Commit(r2, []*Account{uoAcct(1, 99)}) - - tree.Promote(r1) - if tree.Len() != 0 { - t.Fatalf("competing root must be evicted on promote; Len=%d", tree.Len()) - } -} - -// RED-verify (correctness F1): freeze must be sticky — a branch that ever had a -// child stays immutable even after that child is evicted. -func TestBranchTreeFreezeStickyAfterEvict(t *testing.T) { - tree := NewBranchTree() - p, _ := tree.AddBranch(0, 1, [32]byte{}) - tree.Commit(p, []*Account{uoAcct(1, 10)}) - a, _ := tree.AddBranch(p, 2, [32]byte{}) - tree.EvictSubtree(a) // p loses its only child - tree.Commit(p, []*Account{uoAcct(1, 99)}) // must stay a no-op - - if v, _ := tree.Get(p, key32(1)); v.Lamports != 10 { - t.Fatalf("frozen parent must stay immutable after child eviction; got %d", v.Lamports) - } -} - -// AddBranch must refuse to create an orphan when a non-zero parent is missing. -func TestBranchTreeAddBranchMissingParent(t *testing.T) { - tree := NewBranchTree() - if _, ok := tree.AddBranch(9999, 2, [32]byte{}); ok { - t.Fatal("AddBranch with a missing non-zero parent must return ok=false") - } - if tree.Len() != 0 { - t.Fatal("no orphan branch should be created") - } -} - -// The promote contract the durable applier relies on: the same key written on -// multiple chain slots is returned per-slot ascending, so applying slots in order -// lands on the highest-slot (newest) value. -func TestBranchTreePromoteCrossSlotOrdering(t *testing.T) { - tree := NewBranchTree() - p, _ := tree.AddBranch(0, 1, [32]byte{}) - tree.Commit(p, []*Account{uoAcct(1, 10)}) - a, _ := tree.AddBranch(p, 2, [32]byte{}) - tree.Commit(a, []*Account{uoAcct(1, 55)}) // same key, later slot - - out := tree.PromotionChain(a) - tree.Promote(a) - if len(out) != 2 || out[0].Slot != 1 || out[1].Slot != 2 { - t.Fatalf("expected slots [1,2] ascending, got %+v", out) - } - if out[0].Delta[0].Lamports != 10 || out[1].Delta[0].Lamports != 55 { - t.Fatalf("ascending order must let slot 2 (55) win last: %+v", out) - } -} - -// A tombstone (zero-lamport) on the winning chain must propagate into the promoted -// deltas so durable deletion is applied. -func TestBranchTreePromoteTombstone(t *testing.T) { - tree := NewBranchTree() - p, _ := tree.AddBranch(0, 1, [32]byte{}) - tree.Commit(p, []*Account{uoAcct(1, 0)}) // tombstone - - out := tree.PromotionChain(p) - tree.Promote(p) - if len(out) != 1 || len(out[0].Delta) != 1 || out[0].Delta[0].Lamports != 0 { - t.Fatalf("tombstone must appear in promoted delta: %+v", out) - } -} - -func TestBranchTreeDeepChainPromote(t *testing.T) { - tree := NewBranchTree() - var id uint64 - for slot := uint64(1); slot <= 6; slot++ { - nid, ok := tree.AddBranch(id, slot, [32]byte{byte(slot)}) - if !ok { - t.Fatalf("add slot %d", slot) - } - tree.Commit(nid, []*Account{uoAcct(byte(slot), slot*10)}) - id = nid - } - out := tree.PromotionChain(id) - tree.Promote(id) // promote the whole 6-deep chain - if len(out) != 6 { - t.Fatalf("expected 6 slot deltas, got %d", len(out)) - } - for i, sd := range out { - if sd.Slot != uint64(i+1) { - t.Fatalf("delta %d slot = %d, want %d", i, sd.Slot, i+1) - } - } - if tree.Len() != 0 { - t.Fatalf("whole chain promoted, tree should be empty; Len=%d", tree.Len()) - } -} - -// Concurrent readers hammering Get while the main goroutine mutates the tree — makes -// -race actually exercise the RWMutex (the other tests are single-goroutine). -func TestBranchTreeConcurrentReads(t *testing.T) { - tree := NewBranchTree() - root, _ := tree.AddBranch(0, 1, [32]byte{}) - tree.Commit(root, []*Account{uoAcct(1, 1)}) - - stop := make(chan struct{}) - done := make(chan struct{}) - for i := 0; i < 8; i++ { - go func() { - for { - select { - case <-stop: - done <- struct{}{} - return - default: - tree.Get(root, key32(1)) - tree.Len() - } - } - }() - } - - parent := root - for slot := uint64(2); slot <= 200; slot++ { - id, ok := tree.AddBranch(parent, slot, [32]byte{}) - if !ok { - continue - } - tree.Commit(id, []*Account{uoAcct(byte(slot), slot)}) - if slot%20 == 0 { - tree.Promote(id) // fold + re-root; parent ids above become stale - parent = id - } else { - parent = id - } - } - close(stop) - for range 8 { - <-done - } -} - -func TestBranchTreeLen(t *testing.T) { - tree := NewBranchTree() - if tree.Len() != 0 { - t.Fatal("empty tree") - } - p, _ := tree.AddBranch(0, 1, [32]byte{}) - tree.AddBranch(p, 2, [32]byte{}) - if tree.Len() != 2 { - t.Fatalf("expected 2, got %d", tree.Len()) - } -} diff --git a/pkg/accounts/overlay_test.go b/pkg/accounts/overlay_test.go index e02823a87..f40a26dfc 100644 --- a/pkg/accounts/overlay_test.go +++ b/pkg/accounts/overlay_test.go @@ -140,10 +140,10 @@ func TestOverlayWithoutLockVariants(t *testing.T) { assert.Equal(t, uint64(10), pGot.Lamports) // parent untouched } -// UnrootedOverlay: added slots shadow the durable store; newest slot wins. -func TestUnrootedOverlayAddAndRead(t *testing.T) { +// WorkingSet: added slots shadow the durable store; newest slot wins. +func TestWorkingSetAddAndRead(t *testing.T) { durable := baseWith(&Account{Key: pk(1), Lamports: 10}) - u := NewUnrootedOverlay() + u := NewWorkingSet() u.Add(101, []*Account{{Key: pk(1), Lamports: 11}, {Key: pk(2), Lamports: 20}, nil}) @@ -162,10 +162,10 @@ func TestUnrootedOverlayAddAndRead(t *testing.T) { assert.Equal(t, 2, u.HeldSlots()) } -// UnrootedOverlay: keys never written unrooted fall through to the durable store. -func TestUnrootedOverlayFallsThroughToDurable(t *testing.T) { +// WorkingSet: keys never written unrooted fall through to the durable store. +func TestWorkingSetFallsThroughToDurable(t *testing.T) { durable := baseWith(&Account{Key: pk(5), Lamports: 50}) - u := NewUnrootedOverlay() + u := NewWorkingSet() u.Add(101, []*Account{{Key: pk(1), Lamports: 11}}) g, err := uoRead(u, durable, 5) @@ -174,8 +174,8 @@ func TestUnrootedOverlayFallsThroughToDurable(t *testing.T) { } // Concurrent locked reads racing Add/PromotePrefix/EvictFrom must be race-free. -func TestUnrootedOverlayConcurrentStress(t *testing.T) { - u := NewUnrootedOverlay() +func TestWorkingSetConcurrentStress(t *testing.T) { + u := NewWorkingSet() var wg sync.WaitGroup wg.Add(1) @@ -209,7 +209,7 @@ func uoAcct(b byte, lamports uint64) *Account { return &Account{Key: pk(b), Lamp // uoRead mirrors the production read composition: unrooted Lookup first, else // fall through to the durable store. -func uoRead(u *UnrootedOverlay, durable Accounts, b byte) (*Account, error) { +func uoRead(u *WorkingSet, durable Accounts, b byte) (*Account, error) { if a, ok := u.Lookup([32]byte(pk(b))); ok { return a, nil } @@ -219,9 +219,9 @@ func uoRead(u *UnrootedOverlay, durable Accounts, b byte) (*Account, error) { // PromotePrefix TRAP: a key written in both the dropped prefix AND a newer held // slot must keep the newer held value, not fall through to the promoted one. -func TestUnrootedOverlayPromoteKeepsNewerHeld(t *testing.T) { +func TestWorkingSetPromoteKeepsNewerHeld(t *testing.T) { durable := baseWith() - u := NewUnrootedOverlay() + u := NewWorkingSet() u.Add(5, []*Account{uoAcct(1, 5)}) u.Add(7, []*Account{uoAcct(1, 7)}) require.NoError(t, durable.SetAccountWithoutLock(pk(1), uoAcct(1, 5))) // caller commits slot 5 @@ -233,9 +233,9 @@ func TestUnrootedOverlayPromoteKeepsNewerHeld(t *testing.T) { } // PromotePrefix mixed: promoted key -> durable; key with a held tip -> tip; held-only -> held. -func TestUnrootedOverlayPromoteMixed(t *testing.T) { +func TestWorkingSetPromoteMixed(t *testing.T) { durable := baseWith() - u := NewUnrootedOverlay() + u := NewWorkingSet() u.Add(5, []*Account{uoAcct(1, 51), uoAcct(2, 52)}) u.Add(7, []*Account{uoAcct(2, 72), uoAcct(3, 73)}) require.NoError(t, durable.SetAccountWithoutLock(pk(1), uoAcct(1, 51))) @@ -252,8 +252,8 @@ func TestUnrootedOverlayPromoteMixed(t *testing.T) { // PromotionPrefix returns held slots <= through, ascending, each carrying that // slot's writes — and excludes slots above through. -func TestUnrootedOverlayPromotionPrefixContents(t *testing.T) { - u := NewUnrootedOverlay() +func TestWorkingSetPromotionPrefixContents(t *testing.T) { + u := NewWorkingSet() u.Add(5, []*Account{uoAcct(1, 51), uoAcct(2, 52)}) u.Add(7, []*Account{uoAcct(2, 72)}) u.Add(9, []*Account{uoAcct(3, 93)}) // above through, must be excluded @@ -269,14 +269,14 @@ func TestUnrootedOverlayPromotionPrefixContents(t *testing.T) { // Full driver flow: commit the promotion batch to durable in slot order, then // PromotePrefix. A key whose only writer was promoted reads from durable; a key // with a newer still-held writer keeps the held value. -func TestUnrootedOverlayPromotionPrefixDriverFlow(t *testing.T) { +func TestWorkingSetPromotionPrefixDriverFlow(t *testing.T) { durable := baseWith() - u := NewUnrootedOverlay() + u := NewWorkingSet() u.Add(5, []*Account{uoAcct(1, 51), uoAcct(2, 52)}) u.Add(7, []*Account{uoAcct(2, 72)}) // key 2 rewritten at tip u.Add(9, []*Account{uoAcct(3, 93)}) - // Driver: commit each prefix slot's delta to durable (simulates CommitSlotAtomic). + // Driver: commit each prefix slot's delta to durable (simulates a fold). for _, sd := range u.PromotionPrefix(7) { for _, a := range sd.Delta { require.NoError(t, durable.SetAccountWithoutLock(a.Key, a)) @@ -295,8 +295,8 @@ func TestUnrootedOverlayPromotionPrefixDriverFlow(t *testing.T) { // Lookup returns the newest held value without durable fall-through; misses // report (nil,false) so the composing reader knows to consult durable. -func TestUnrootedOverlayLookup(t *testing.T) { - u := NewUnrootedOverlay() +func TestWorkingSetLookup(t *testing.T) { + u := NewWorkingSet() u.Add(5, []*Account{uoAcct(1, 51)}) u.Add(7, []*Account{uoAcct(1, 71), uoAcct(2, 72)}) // key 1 rewritten newer @@ -319,9 +319,9 @@ func TestUnrootedOverlayLookup(t *testing.T) { // EvictFrom TRAP: revert to the newest SURVIVING held value (newest-first), not // the durable value and not the oldest held. -func TestUnrootedOverlayEvictRevertsNewestSurviving(t *testing.T) { +func TestWorkingSetEvictRevertsNewestSurviving(t *testing.T) { durable := baseWith(uoAcct(1, 100)) - u := NewUnrootedOverlay() + u := NewWorkingSet() u.Add(10, []*Account{uoAcct(1, 10)}) u.Add(12, []*Account{uoAcct(1, 12)}) u.Add(15, []*Account{uoAcct(1, 15)}) @@ -333,9 +333,9 @@ func TestUnrootedOverlayEvictRevertsNewestSurviving(t *testing.T) { } // EvictFrom with no surviving held writer reverts to durable. -func TestUnrootedOverlayEvictToDurable(t *testing.T) { +func TestWorkingSetEvictToDurable(t *testing.T) { durable := baseWith(uoAcct(1, 100)) - u := NewUnrootedOverlay() + u := NewWorkingSet() u.Add(10, []*Account{uoAcct(1, 10)}) u.EvictFrom(10) @@ -345,9 +345,9 @@ func TestUnrootedOverlayEvictToDurable(t *testing.T) { } // Owner slot is the LAYER slot, not the account's payload Slot field. -func TestUnrootedOverlayOwnerIsLayerSlotNotPayload(t *testing.T) { +func TestWorkingSetOwnerIsLayerSlotNotPayload(t *testing.T) { durable := baseWith() - u := NewUnrootedOverlay() + u := NewWorkingSet() u.Add(3, []*Account{{Key: pk(1), Lamports: 33, Slot: 3}}) u.Add(8, []*Account{{Key: pk(1), Lamports: 88, Slot: 3}}) // payload Slot=3, layer slot=8 require.NoError(t, durable.SetAccountWithoutLock(pk(1), uoAcct(1, 33))) @@ -358,9 +358,9 @@ func TestUnrootedOverlayOwnerIsLayerSlotNotPayload(t *testing.T) { } // A zero-lamport (deleted) unrooted write shadows durable (tombstone); eviction reverts. -func TestUnrootedOverlayTombstoneShadowsThenReverts(t *testing.T) { +func TestWorkingSetTombstoneShadowsThenReverts(t *testing.T) { durable := baseWith(uoAcct(1, 100)) - u := NewUnrootedOverlay() + u := NewWorkingSet() u.Add(7, []*Account{uoAcct(1, 0)}) g, _ := uoRead(u, durable, 1) @@ -371,9 +371,9 @@ func TestUnrootedOverlayTombstoneShadowsThenReverts(t *testing.T) { } // EvictFrom dedups a key present in multiple removed layers. -func TestUnrootedOverlayEvictDedup(t *testing.T) { +func TestWorkingSetEvictDedup(t *testing.T) { durable := baseWith() - u := NewUnrootedOverlay() + u := NewWorkingSet() u.Add(10, []*Account{uoAcct(1, 10)}) u.Add(12, []*Account{uoAcct(1, 12)}) u.Add(14, []*Account{uoAcct(1, 14)}) @@ -384,9 +384,9 @@ func TestUnrootedOverlayEvictDedup(t *testing.T) { } // No-op edges and full drain. -func TestUnrootedOverlayNoopsAndDrain(t *testing.T) { +func TestWorkingSetNoopsAndDrain(t *testing.T) { durable := baseWith() - u := NewUnrootedOverlay() + u := NewWorkingSet() u.Add(5, []*Account{uoAcct(2, 5)}) u.EvictFrom(99) // nothing >= 99 u.PromotePrefix(0) // nothing <= 0 diff --git a/pkg/accounts/unrooted_overlay.go b/pkg/accounts/unrooted_overlay.go deleted file mode 100644 index 7be7d8180..000000000 --- a/pkg/accounts/unrooted_overlay.go +++ /dev/null @@ -1,160 +0,0 @@ -package accounts - -import "sync" - -// UnrootedOverlay buffers confirmed-but-unrooted slot writes in RAM over a durable -// rooted store; reads prefer the newest unrooted write, else fall through to durable. -type UnrootedOverlay struct { - mu sync.RWMutex - layers map[uint64]map[[32]byte]*Account // slot -> that slot's writes - order []uint64 // held slots, ascending - flat map[[32]byte]flatEntry // newest unrooted value per key -} - -type flatEntry struct { - slot uint64 // owner slot of acct (load-bearing for recompute) - acct *Account -} - -// NewUnrootedOverlay creates an empty unrooted tail holding only in-RAM layers; -// reads compose Lookup over the durable store externally (see pkg/replay). -func NewUnrootedOverlay() *UnrootedOverlay { - return &UnrootedOverlay{ - layers: make(map[uint64]map[[32]byte]*Account), - flat: make(map[[32]byte]flatEntry), - } -} - -// Add appends slot's account writes at the tip. Slots must arrive in ascending -// order (one confirmed fork), so an added slot is always >= every held slot. -func (u *UnrootedOverlay) Add(slot uint64, delta []*Account) { - u.mu.Lock() - defer u.mu.Unlock() - - layer, ok := u.layers[slot] - if !ok { - layer = make(map[[32]byte]*Account, len(delta)) - u.layers[slot] = layer - u.order = append(u.order, slot) - } - for _, a := range delta { - if a == nil { - continue - } - key := [32]byte(a.Key) - layer[key] = a - // Newest wins. Guard on owner slot so an out-of-order add can never - // install an older value over a newer one. - if e, exists := u.flat[key]; !exists || slot >= e.slot { - u.flat[key] = flatEntry{slot: slot, acct: a} - } - } -} - -// HeldSlots reports the number of buffered unrooted slots (for RAM bounding). -func (u *UnrootedOverlay) HeldSlots() int { - u.mu.RLock() - defer u.mu.RUnlock() - return len(u.order) -} - -// Lookup returns the newest unrooted value for pubkey (nil, false if none held). -// No fall-through to durable; the newest held value is the correct pre-root value. -func (u *UnrootedOverlay) Lookup(pubkey [32]byte) (*Account, bool) { - u.mu.RLock() - defer u.mu.RUnlock() - if e, ok := u.flat[pubkey]; ok { - return e.acct, true - } - return nil, false -} - -// SlotDelta is one held slot's account writes, returned for durable promotion. -type SlotDelta struct { - Slot uint64 - Delta []*Account -} - -// PromotionPrefix returns held slots <= through (ascending) with their writes, to -// durably commit before PromotePrefix(through). Values reference the stored accounts. -func (u *UnrootedOverlay) PromotionPrefix(through uint64) []SlotDelta { - u.mu.RLock() - defer u.mu.RUnlock() - - var batch []SlotDelta - for _, slot := range u.order { // order is ascending - if slot > through { - break - } - layer := u.layers[slot] - delta := make([]*Account, 0, len(layer)) - for _, a := range layer { - delta = append(delta, a) - } - batch = append(batch, SlotDelta{Slot: slot, Delta: delta}) - } - return batch -} - -// PromotePrefix drops the rooted prefix (held slots <= through); keys with no newer -// held writer fall through to durable. Caller MUST make them durable BEFORE this. -func (u *UnrootedOverlay) PromotePrefix(through uint64) { - u.mu.Lock() - defer u.mu.Unlock() - - kept := make([]uint64, 0, len(u.order)) - for _, slot := range u.order { - if slot > through { - kept = append(kept, slot) - continue - } - for key := range u.layers[slot] { - if e, ok := u.flat[key]; ok && e.slot <= through { - delete(u.flat, key) // no surviving held writer -> fall through to durable - } - } - delete(u.layers, slot) - } - u.order = kept -} - -// EvictFrom drops the abandoned suffix (held slots >= slot) on a reorg, reverting -// affected keys to their newest surviving value. Multi-branch (#14) only. -func (u *UnrootedOverlay) EvictFrom(slot uint64) { - u.mu.Lock() - defer u.mu.Unlock() - - kept := make([]uint64, 0, len(u.order)) - var removedKeys [][32]byte - for _, s := range u.order { - if s < slot { - kept = append(kept, s) - continue - } - for key := range u.layers[s] { - removedKeys = append(removedKeys, key) - } - delete(u.layers, s) - } - u.order = kept - - // Revert keys whose newest writer was evicted (ownerSlot >= slot). The guard - // dedups keys in several removed layers: first recompute drops ownerSlot, rest skip. - for _, key := range removedKeys { - if e, ok := u.flat[key]; ok && e.slot >= slot { - u.recomputeLocked(key) - } - } -} - -// recomputeLocked rescans kept layers newest-first for the surviving newest value -// for key, or removes it so reads fall through to durable. Caller holds u.mu. -func (u *UnrootedOverlay) recomputeLocked(key [32]byte) { - for i := len(u.order) - 1; i >= 0; i-- { - if acc, ok := u.layers[u.order[i]][key]; ok { - u.flat[key] = flatEntry{slot: u.order[i], acct: acc} - return - } - } - delete(u.flat, key) -} diff --git a/pkg/accounts/working_set.go b/pkg/accounts/working_set.go new file mode 100644 index 000000000..698c6da21 --- /dev/null +++ b/pkg/accounts/working_set.go @@ -0,0 +1,244 @@ +package accounts + +import "sync" + +// WorkingSet is the canonical timeline's mutable suffix: confirmed-but-unrooted +// slot writes buffered in RAM over the durable rooted store. Reads hit the +// flat map (newest unrooted value per key, O(1)); the per-slot undo journal +// makes suffix eviction — the execute-on-receipt fork switch — O(evicted +// writes) instead of a full rescan. Siblings are never materialized as state: +// a switch unwinds this suffix and re-executes the certified block. +type WorkingSet struct { + mu sync.RWMutex + bySlot map[uint64]*slotLayer + order []uint64 // held slots, ascending + flat map[[32]byte]flatEntry // newest unrooted value per key +} + +type flatEntry struct { + slot uint64 // owner slot of acct (load-bearing for undo application) + acct *Account +} + +type slotLayer struct { + slot uint64 + writes map[[32]byte]*Account + // undo records, one per FIRST write of a key in this slot: what the flat + // entry pointed at before this slot overwrote it. Applying a suffix's + // undos newest-layer-first restores the flat map exactly. + undo []undoPtr +} + +type undoPtr struct { + key [32]byte + prevSlot uint64 // meaningful when existed + existed bool // false: the key had no unrooted value before this slot +} + +// NewWorkingSet creates an empty suffix; reads compose Lookup over the durable +// store externally (see pkg/replay). +func NewWorkingSet() *WorkingSet { + return &WorkingSet{ + bySlot: make(map[uint64]*slotLayer), + flat: make(map[[32]byte]flatEntry), + } +} + +// Add appends slot's account writes at the tip, capturing one undo record per +// first-written key. Slots must arrive in ascending order (one confirmed +// chain), so an added slot is always >= every held slot. +func (w *WorkingSet) Add(slot uint64, delta []*Account) { + w.mu.Lock() + defer w.mu.Unlock() + + layer, ok := w.bySlot[slot] + if !ok { + layer = &slotLayer{slot: slot, writes: make(map[[32]byte]*Account, len(delta))} + w.bySlot[slot] = layer + w.order = append(w.order, slot) + } + for _, a := range delta { + if a == nil { + continue + } + key := [32]byte(a.Key) + if _, again := layer.writes[key]; !again { + // First write of this key in this slot: journal what flat held. + if e, exists := w.flat[key]; exists && e.slot != slot { + layer.undo = append(layer.undo, undoPtr{key: key, prevSlot: e.slot, existed: true}) + } else if !exists { + layer.undo = append(layer.undo, undoPtr{key: key}) + } + } + layer.writes[key] = a + // Newest wins. Guard on owner slot so an out-of-order add can never + // install an older value over a newer one. + if e, exists := w.flat[key]; !exists || slot >= e.slot { + w.flat[key] = flatEntry{slot: slot, acct: a} + } + } +} + +// HeldSlots reports the number of buffered unrooted slots (for RAM bounding). +func (w *WorkingSet) HeldSlots() int { + w.mu.RLock() + defer w.mu.RUnlock() + return len(w.order) +} + +// Lookup returns the newest unrooted value for pubkey (nil, false if none held). +// No fall-through to durable; the newest held value is the correct pre-root value. +func (w *WorkingSet) Lookup(pubkey [32]byte) (*Account, bool) { + w.mu.RLock() + defer w.mu.RUnlock() + if e, ok := w.flat[pubkey]; ok { + return e.acct, true + } + return nil, false +} + +// SlotDelta is one held slot's account writes, returned for durable promotion. +type SlotDelta struct { + Slot uint64 + Delta []*Account +} + +// PromotionPrefix returns held slots <= through (ascending) with their writes, +// to durably commit before PromotePrefix(through). Values reference the stored +// accounts. +func (w *WorkingSet) PromotionPrefix(through uint64) []SlotDelta { + w.mu.RLock() + defer w.mu.RUnlock() + + var batch []SlotDelta + for _, slot := range w.order { // ascending + if slot > through { + break + } + layer := w.bySlot[slot] + delta := make([]*Account, 0, len(layer.writes)) + for _, a := range layer.writes { + delta = append(delta, a) + } + batch = append(batch, SlotDelta{Slot: slot, Delta: delta}) + } + return batch +} + +// PromotePrefix drops the rooted prefix (held slots <= through); keys with no +// newer held writer fall through to durable. Caller MUST make them durable +// BEFORE this. +func (w *WorkingSet) PromotePrefix(through uint64) { + w.mu.Lock() + defer w.mu.Unlock() + + kept := make([]uint64, 0, len(w.order)) + for _, slot := range w.order { + if slot > through { + kept = append(kept, slot) + continue + } + for key := range w.bySlot[slot].writes { + if e, ok := w.flat[key]; ok && e.slot <= through { + delete(w.flat, key) // no surviving held writer -> durable + } + } + delete(w.bySlot, slot) + } + w.order = kept + // Undo records pointing into the promoted prefix now dangle; surviving + // layers' undos are rewritten to "restore = fall through to durable", + // which is exactly right: the promoted value IS the durable value. + for _, slot := range w.order { + layer := w.bySlot[slot] + for i := range layer.undo { + if layer.undo[i].existed && layer.undo[i].prevSlot <= through { + layer.undo[i].existed = false + } + } + } +} + +// EvictFrom drops the abandoned suffix (held slots >= slot) — the fork-switch +// unwind. Undo journals apply newest-layer-first, restoring each affected key +// to its newest surviving value (or removing it so reads fall through to +// durable). Cost is O(evicted writes). +func (w *WorkingSet) EvictFrom(slot uint64) { + w.mu.Lock() + defer w.mu.Unlock() + + // Identify the suffix (order is ascending). + cut := len(w.order) + for i, s := range w.order { + if s >= slot { + cut = i + break + } + } + suffix := w.order[cut:] + if len(suffix) == 0 { + return + } + + // Apply undos newest layer first: for keys written in several evicted + // layers, the OLDEST layer's undo applies last and wins — restoring the + // pre-suffix state exactly. + for i := len(suffix) - 1; i >= 0; i-- { + layer := w.bySlot[suffix[i]] + for _, u := range layer.undo { + if !u.existed { + delete(w.flat, u.key) + continue + } + if prev, held := w.bySlot[u.prevSlot]; held && u.prevSlot < slot { + if acct, ok := prev.writes[u.key]; ok { + w.flat[u.key] = flatEntry{slot: u.prevSlot, acct: acct} + continue + } + } + // Previous writer already promoted (or missing): durable owns it. + delete(w.flat, u.key) + } + } + for _, s := range suffix { + delete(w.bySlot, s) + } + w.order = w.order[:cut] +} + +// CheckInvariants verifies flat ≡ fold(bySlot in order) — test hook (I6). +func (w *WorkingSet) CheckInvariants() error { + w.mu.RLock() + defer w.mu.RUnlock() + + want := make(map[[32]byte]flatEntry) + for _, slot := range w.order { + for key, acct := range w.bySlot[slot].writes { + want[key] = flatEntry{slot: slot, acct: acct} + } + } + if len(want) != len(w.flat) { + return errInvariant("flat size", len(w.flat), len(want)) + } + for key, e := range want { + got, ok := w.flat[key] + if !ok || got.slot != e.slot || got.acct != e.acct { + return errInvariant("flat entry", got, e) + } + } + return nil +} + +type invariantError struct { + what string + got any + want any +} + +func (e *invariantError) Error() string { + return "working set invariant violated: " + e.what +} + +func errInvariant(what string, got, want any) error { + return &invariantError{what: what, got: got, want: want} +} diff --git a/pkg/accounts/working_set_test.go b/pkg/accounts/working_set_test.go new file mode 100644 index 000000000..890c1b390 --- /dev/null +++ b/pkg/accounts/working_set_test.go @@ -0,0 +1,205 @@ +package accounts + +import ( + "math/rand" + "testing" + + "github.com/gagliardetto/solana-go" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func wsAcct(key byte, lamports uint64) *Account { + return &Account{Key: solana.PublicKey{key}, Lamports: lamports} +} + +func wsKey(b byte) [32]byte { return [32]byte{b} } + +// EvictFrom restores the exact prior value via the undo journal. +func TestWorkingSetUndoRestoresExactPriorValue(t *testing.T) { + w := NewWorkingSet() + w.Add(5, []*Account{wsAcct(1, 100)}) + w.Add(7, []*Account{wsAcct(1, 700), wsAcct(2, 200)}) + w.Add(9, []*Account{wsAcct(1, 900)}) + + w.EvictFrom(9) + require.NoError(t, w.CheckInvariants()) + a, ok := w.Lookup(wsKey(1)) + require.True(t, ok) + assert.Equal(t, uint64(700), a.Lamports, "slot-7 value restored") + + w.EvictFrom(7) + require.NoError(t, w.CheckInvariants()) + a, ok = w.Lookup(wsKey(1)) + require.True(t, ok) + assert.Equal(t, uint64(100), a.Lamports, "slot-5 value restored") + _, ok = w.Lookup(wsKey(2)) + assert.False(t, ok, "key 2's only writer evicted -> falls through to durable") +} + +// Evicting a suffix spanning several layers restores the pre-suffix state +// (the oldest evicted layer's undo wins). +func TestWorkingSetEvictMultiLayerSuffix(t *testing.T) { + w := NewWorkingSet() + w.Add(5, []*Account{wsAcct(1, 100)}) + w.Add(7, []*Account{wsAcct(1, 700)}) + w.Add(8, []*Account{wsAcct(1, 800), wsAcct(3, 300)}) + w.Add(9, []*Account{wsAcct(1, 900), wsAcct(3, 390)}) + + w.EvictFrom(7) // evicts 7, 8, 9 in one call + require.NoError(t, w.CheckInvariants()) + a, ok := w.Lookup(wsKey(1)) + require.True(t, ok) + assert.Equal(t, uint64(100), a.Lamports) + _, ok = w.Lookup(wsKey(3)) + assert.False(t, ok) + assert.Equal(t, 1, w.HeldSlots()) +} + +// A key whose previous writer was already promoted falls through to durable +// on eviction (the promoted value IS the durable value). +func TestWorkingSetEvictAcrossPromotedPrevSlot(t *testing.T) { + w := NewWorkingSet() + w.Add(5, []*Account{wsAcct(1, 100)}) + w.Add(7, []*Account{wsAcct(1, 700)}) + + w.PromotePrefix(5) // slot 5 now durable + require.NoError(t, w.CheckInvariants()) + + w.EvictFrom(7) + require.NoError(t, w.CheckInvariants()) + _, ok := w.Lookup(wsKey(1)) + assert.False(t, ok, "prev writer promoted -> durable owns the value") + assert.Equal(t, 0, w.HeldSlots()) +} + +// Evict-then-re-add behaves like the slots never existed. +func TestWorkingSetEvictThenReAdd(t *testing.T) { + w := NewWorkingSet() + w.Add(5, []*Account{wsAcct(1, 100)}) + w.Add(7, []*Account{wsAcct(1, 700)}) + w.EvictFrom(7) + + w.Add(7, []*Account{wsAcct(1, 777), wsAcct(4, 40)}) + require.NoError(t, w.CheckInvariants()) + a, _ := w.Lookup(wsKey(1)) + assert.Equal(t, uint64(777), a.Lamports) + a, _ = w.Lookup(wsKey(4)) + assert.Equal(t, uint64(40), a.Lamports) + + // And the re-added slot unwinds cleanly again. + w.EvictFrom(7) + require.NoError(t, w.CheckInvariants()) + a, _ = w.Lookup(wsKey(1)) + assert.Equal(t, uint64(100), a.Lamports) + _, ok := w.Lookup(wsKey(4)) + assert.False(t, ok) +} + +// shadowSet is the naive reference implementation: layers only, lookups scan +// newest-first. The WorkingSet must agree with it under random operations. +type shadowSet struct { + order []uint64 + layers map[uint64]map[[32]byte]*Account +} + +func newShadow() *shadowSet { + return &shadowSet{layers: make(map[uint64]map[[32]byte]*Account)} +} + +func (s *shadowSet) add(slot uint64, delta []*Account) { + layer, ok := s.layers[slot] + if !ok { + layer = make(map[[32]byte]*Account) + s.layers[slot] = layer + s.order = append(s.order, slot) + } + for _, a := range delta { + layer[[32]byte(a.Key)] = a + } +} + +func (s *shadowSet) promote(through uint64) { + kept := s.order[:0] + for _, slot := range s.order { + if slot <= through { + delete(s.layers, slot) + continue + } + kept = append(kept, slot) + } + s.order = kept +} + +func (s *shadowSet) evictFrom(slot uint64) { + kept := s.order[:0] + for _, held := range s.order { + if held >= slot { + delete(s.layers, held) + continue + } + kept = append(kept, held) + } + s.order = kept +} + +func (s *shadowSet) lookup(key [32]byte) (*Account, bool) { + for i := len(s.order) - 1; i >= 0; i-- { + if a, ok := s.layers[s.order[i]][key]; ok { + return a, true + } + } + return nil, false +} + +// Model/fuzz: random Add/PromotePrefix/EvictFrom sequences must keep the +// WorkingSet observably identical to the naive shadow. +func TestWorkingSetModelAgainstShadow(t *testing.T) { + rng := rand.New(rand.NewSource(42)) + for round := 0; round < 50; round++ { + w := NewWorkingSet() + sh := newShadow() + slot := uint64(100) + heldLow := slot + + for op := 0; op < 200; op++ { + switch rng.Intn(10) { + case 0, 1, 2, 3, 4, 5: // add next slot + slot++ + n := rng.Intn(4) + delta := make([]*Account, 0, n) + for i := 0; i < n; i++ { + delta = append(delta, wsAcct(byte(rng.Intn(12)), uint64(rng.Intn(100000)))) + } + w.Add(slot, delta) + sh.add(slot, delta) + case 6, 7: // promote a prefix + if slot > heldLow { + through := heldLow + uint64(rng.Intn(int(slot-heldLow))) + w.PromotePrefix(through) + sh.promote(through) + heldLow = through + 1 + } + case 8, 9: // evict a suffix + if slot > heldLow { + from := heldLow + 1 + uint64(rng.Intn(int(slot-heldLow))) + w.EvictFrom(from) + sh.evictFrom(from) + slot = from - 1 + } + } + + if err := w.CheckInvariants(); err != nil { + t.Fatalf("round %d op %d: %v", round, op, err) + } + for k := 0; k < 12; k++ { + key := wsKey(byte(k)) + got, gok := w.Lookup(key) + want, wok := sh.lookup(key) + if gok != wok || (gok && got != want) { + t.Fatalf("round %d op %d key %d: workingset=(%v,%v) shadow=(%v,%v)", round, op, k, got, gok, want, wok) + } + } + } + } +} diff --git a/pkg/accountsdb/accountsdb.go b/pkg/accountsdb/accountsdb.go index be16e7a89..3398fede1 100644 --- a/pkg/accountsdb/accountsdb.go +++ b/pkg/accountsdb/accountsdb.go @@ -34,18 +34,23 @@ type AccountsDb struct { CommonAcctsCache otter.Cache[solana.PublicKey, *accounts.Account] ProgramCache otter.Cache[solana.PublicKey, *ProgramCacheEntry] - // DurableCommit routes block commits through the crash-safe CommitSlotAtomic - // path (redo + append-only + fsync) instead of the unsynced store. Off by default. - DurableCommit bool - - // RootedDurable keeps the canonical store rooted-only: replayed slots buffer in - // an in-RAM overlay, folded to disk via CommitSlotAtomic once rooted. Requires - // consensus + DurableCommit. Off by default. + // RootedDurable keeps the canonical store rooted-only: replayed slots buffer + // in an in-RAM working set (pkg/accounts) and fold to disk via CommitBatch + // once finalized+verified. The Alpenglow node forces this on at startup. RootedDurable bool - // ForkAware selects the branch-tree speculative engine (fork handling) over the - // linear tail. Requires RootedDurable. Off by default. - ForkAware bool + // IndexWALDisabled runs the Pebble index without a WAL; the fold manifests + // are the index redo log (recovery replays the contiguous manifest run + // above the committed fold meta). Off by default until soaked. + IndexWALDisabled bool + + // Batch-fold state (segment.go/fold.go/recovery.go). foldMu serializes + // CommitBatch, recovery, rewind, and compaction. + foldMu sync.Mutex + lastBatchSeq uint64 // guarded by foldMu; seeded by RecoverFoldState + durableThrough atomic.Uint64 // observability: highest durably folded slot + foldHooks foldTestHooks // test-only crash injection + compactCursor string // guarded by foldMu; scan resume point across CompactOnce cycles // A list of store requests. They are added to the back as they arrive and // removed from the front as they are persisted. @@ -56,12 +61,10 @@ type AccountsDb struct { } type storeRequest struct { - accts []*accounts.Account - slot uint64 - m map[solana.PublicKey]*accounts.Account - cb func() - durable bool // route through CommitSlotAtomic (crash-safe) instead of the unsynced store - bankhash []byte // committed bankhash, for the durable redo record + accts []*accounts.Account + slot uint64 + m map[solana.PublicKey]*accounts.Account + cb func() } func (accountsDb *AccountsDb) StoreQueueLen() int { @@ -94,11 +97,18 @@ const ( programCacheCostUnitBytes = 1 << 20 ) +// DisableIndexWAL (storage.index_wal=false) runs the account index without a +// Pebble WAL: the fold manifests are the index redo log, and recovery replays +// the contiguous manifest run above the committed fold meta. Set before +// OpenDb. Default false (WAL on) until soaked. +var DisableIndexWAL bool + func NewAccountsIndexPebbleOptions(logger pebble.Logger) *pebble.Options { return &pebble.Options{ Logger: logger, MemTableSize: indexPebbleMemTableSize, MemTableStopWritesThreshold: indexPebbleMemTableStopWritesThreshold, + DisableWAL: DisableIndexWAL, } } @@ -142,7 +152,8 @@ func OpenDb(accountsDbDir string) (*AccountsDb, error) { return nil, fmt.Errorf("opening bankhashDir=%s: %w", bankhashDir, err) } - accountsDb := &AccountsDb{Index: db, BankHashStore: bankhashDb, AcctsDir: appendVecsDir} + accountsDb := &AccountsDb{ + IndexWALDisabled: DisableIndexWAL, Index: db, BankHashStore: bankhashDb, AcctsDir: appendVecsDir} accountsDb.LargestFileId.Store(largestFileId) accountsDb.inProgressStoreRequests = list.New() @@ -285,54 +296,73 @@ func (accountsDb *AccountsDb) getStoredAccount(slot uint64, pubkey solana.Public r.End() defer trace.StartRegion(context.Background(), "GetStoredAccountDisk").End() + + // One-shot retry: between fetching the index entry and reading the file, + // a compaction cycle may move the record and unlink its old file (ENOENT, + // or in pathological interleavings a wrong-pubkey/short read). Re-fetching + // the index entry observes the moved location. Failing twice means real + // corruption and surfaces as an error rather than a panic. + acct, err := accountsDb.readIndexedAccount(pubkey) + if err == ErrNoAccount { + return nil, ErrNoAccount + } + if err != nil { + if acct, err = accountsDb.readIndexedAccount(pubkey); err != nil { + if err == ErrNoAccount { + return nil, ErrNoAccount + } + return nil, fmt.Errorf("accountsdb: read %s failed after retry: %w", pubkey, err) + } + } + + owner := solana.PublicKeyFromBytes(acct.Owner[:]) + if owner == addresses.VoteProgramAddr { + accountsDb.VoteAcctCache.Set(pubkey, acct) + } else { + accountsDb.CommonAcctsCache.Set(pubkey, acct) + } + + return acct, nil +} + +// readIndexedAccount performs one index-fetch + file-read attempt. +func (accountsDb *AccountsDb) readIndexedAccount(pubkey solana.PublicKey) (*accounts.Account, error) { acctIdxEntryBytes, c, err := accountsDb.Index.Get(pubkey[:]) if err != nil { - //mlog.Log.Debugf("no account found in accountsdb for pubkey %s: %s", pubkey, err) - return nil, ErrNoAccount + if errors.Is(err, pebble.ErrNotFound) { + return nil, ErrNoAccount + } + return nil, fmt.Errorf("index get: %w", err) } acctIdxEntry, err := UnmarshalAcctIdxEntry(acctIdxEntryBytes) + c.Close() if err != nil { - panic("failed to unmarshal AccountIndexEntry from index kv database") + return nil, fmt.Errorf("unmarshal index entry: %w", err) } - c.Close() appendVecFileName := fmt.Sprintf("%s/%d.%d", accountsDb.AcctsDir, acctIdxEntry.Slot, acctIdxEntry.FileId) appendVecFile, err := os.Open(appendVecFileName) if err != nil { - //mlog.Log.Debugf("failed to open appendvec file %s") return nil, err } defer appendVecFile.Close() - offset, err := appendVecFile.Seek(int64(acctIdxEntry.Offset), 0) - if err != nil { - panic(fmt.Sprintf("file seek failed: %s\n", err)) - } - if offset != int64(acctIdxEntry.Offset) { - panic(fmt.Sprintf("file seek gave wrong idx (%d)\n", offset)) + if _, err := appendVecFile.Seek(int64(acctIdxEntry.Offset), 0); err != nil { + return nil, fmt.Errorf("seek %s@%d: %w", appendVecFileName, acctIdxEntry.Offset, err) } acct, err := unmarshalAcctFromAppendVecAcctHeader(appendVecFile) if err != nil { - panic(fmt.Sprintf("failed to unmarshal account from appendvec file %s: %s", appendVecFileName, err)) + return nil, fmt.Errorf("unmarshal account at %s@%d: %w", appendVecFileName, acctIdxEntry.Offset, err) } - if acct.Key != pubkey { - panic(fmt.Sprintf("account unmarshaled from appendvec file %s has the wrong pubkey", appendVecFileName)) + return nil, fmt.Errorf("record at %s@%d holds %s (stale index entry)", appendVecFileName, acctIdxEntry.Offset, acct.Key) } acct.Slot = acctIdxEntry.Slot - - owner := solana.PublicKeyFromBytes(acct.Owner[:]) - if owner == addresses.VoteProgramAddr { - accountsDb.VoteAcctCache.Set(pubkey, acct) - } else { - accountsDb.CommonAcctsCache.Set(pubkey, acct) - } - - return acct, err + return acct, nil } // Returns a slice of the same length as the input with results matching indexes, nil if not found. @@ -362,6 +392,18 @@ func (accountsDb *AccountsDb) StoreAccounts( slot uint64, cb func(), ) error { + // Rooted-durable (the only mode of the Alpenglow-only build): direct stores + // are no-ops. Every write reaches disk exclusively via the fold path + // (CommitBatch) once finalized+verified — epoch-boundary code that still + // calls StoreAccounts directly is redundant with the slot delta it also + // feeds (block.EpochUpdatedAccts), and writing here would violate + // "durable state is rooted-only". + if accountsDb.RootedDurable { + if cb != nil { + cb() + } + return nil + } for _, acct := range accts { if acct == nil { continue @@ -384,28 +426,6 @@ func (accountsDb *AccountsDb) StoreAccounts( return nil } -// StoreAccountsDurable enqueues a CRASH-SAFE commit of a slot's accounts + bankhash -// on the same single storeWorker as StoreAccounts (single-writer/FIFO), routed -// through CommitSlotAtomic. The callback runs after the commit; it should DeleteRedo. -func (accountsDb *AccountsDb) StoreAccountsDurable(accts []*accounts.Account, slot uint64, bankhash []byte, cb func()) error { - for _, acct := range accts { - if acct != nil { - acct.Slot = slot - } - } - m := make(map[solana.PublicKey]*accounts.Account, len(accts)) - for _, a := range accts { - if a != nil { - m[a.Key] = a - } - } - accountsDb.inProgressStoreRequestsMu.Lock() - element := accountsDb.inProgressStoreRequests.PushBack(storeRequest{accts: accts, slot: slot, m: m, cb: cb, durable: true, bankhash: bankhash}) - accountsDb.inProgressStoreRequestsMu.Unlock() - accountsDb.storeRequestChan <- element - return nil -} - func (accountsDb *AccountsDb) storeAccountsSync(accts []*accounts.Account, slot uint64) { defer trace.StartRegion(context.Background(), "StoreAccounts").End() if StoreAccountsWorkers == 1 { @@ -444,16 +464,7 @@ func (accountsDb *AccountsDb) storeWorker() { defer close(accountsDb.storeWorkerDone) for elt := range accountsDb.storeRequestChan { sr := elt.Value.(storeRequest) - if sr.durable { - // Panic on error (like storeAccountsSync) so cb does NOT run: the redo is - // preserved for recovery. Running cb would DeleteRedo + advance as committed, - // silently losing the slot. - if err := accountsDb.CommitSlotAtomic(sr.accts, sr.slot, sr.bankhash); err != nil { - panic(fmt.Sprintf("durable commit failed for slot %d: %v", sr.slot, err)) - } - } else { - accountsDb.storeAccountsSync(sr.accts, sr.slot) - } + accountsDb.storeAccountsSync(sr.accts, sr.slot) if sr.cb != nil { sr.cb() } diff --git a/pkg/accountsdb/commit.go b/pkg/accountsdb/commit.go deleted file mode 100644 index 8335f00b1..000000000 --- a/pkg/accountsdb/commit.go +++ /dev/null @@ -1,180 +0,0 @@ -package accountsdb - -import ( - "bytes" - "encoding/binary" - "fmt" - "os" - "path/filepath" - - "github.com/Overclock-Validator/mithril/pkg/accounts" - "github.com/Overclock-Validator/mithril/pkg/mlog" - "github.com/cockroachdb/pebble" -) - -// CommitSlotAtomic durably commits a slot's accounts crash-safely, each step durable -// before the next: stage redo → fresh appendvec + fsync → index + bankhash (Sync). A -// crash before the caller's checkpoint/DeleteRedo re-applies the redo idempotently. -func (accountsDb *AccountsDb) CommitSlotAtomic(accts []*accounts.Account, slot uint64, bankhash []byte) error { - for _, a := range accts { - if a != nil { - a.Slot = slot - } - } - if err := WriteRedo(accountsDb.AcctsDir, slot, bankhash, accts); err != nil { - return fmt.Errorf("accountsdb: stage redo slot %d: %w", slot, err) - } - if err := accountsDb.applyAppendOnlySynced(accts, slot); err != nil { - return fmt.Errorf("accountsdb: durable apply slot %d: %w", slot, err) - } - if err := accountsDb.storeBankHashSynced(slot, bankhash); err != nil { - return fmt.Errorf("accountsdb: durable bankhash slot %d: %w", slot, err) - } - accountsDb.refreshReadCaches(accts) - return nil -} - -// CommitRootedSlot durably commits one rooted slot, then deletes its redo. A failed -// DeleteRedo is non-fatal: the redo is re-applied idempotently on next start. -func (accountsDb *AccountsDb) CommitRootedSlot(accts []*accounts.Account, slot uint64, bankhash []byte) error { - if err := accountsDb.CommitSlotAtomic(accts, slot, bankhash); err != nil { - return err - } - if derr := DeleteRedo(accountsDb.AcctsDir, slot); derr != nil { - mlog.Log.Errorf("accountsdb: failed to delete redo for promoted slot %d (harmless, re-applied on restart): %v", slot, derr) - } - return nil -} - -// ApplyPendingCommits roll-forwards commits interrupted before committedSlot advanced: -// redos <= committedSlot are discarded (re-applying would REGRESS), > committedSlot are -// re-applied idempotently and returned. A torn/unreadable redo is quarantined. -func (accountsDb *AccountsDb) ApplyPendingCommits(committedSlot uint64) ([]uint64, error) { - slots, err := ListPendingRedo(accountsDb.AcctsDir) - if err != nil { - return nil, err - } - applied := make([]uint64, 0, len(slots)) - for _, slot := range slots { - if slot <= committedSlot { - if derr := DeleteRedo(accountsDb.AcctsDir, slot); derr != nil { - return applied, derr - } - continue - } - - accts, bankhash, err := ReadRedo(accountsDb.AcctsDir, slot) - if err != nil { - // Torn or otherwise unreadable: quarantine and keep recovering - // rather than wedging startup. - if qerr := quarantineRedo(accountsDb.AcctsDir, slot); qerr != nil { - return applied, qerr - } - continue - } - if err := accountsDb.applyAppendOnlySynced(accts, slot); err != nil { - return applied, err - } - if err := accountsDb.storeBankHashSynced(slot, bankhash); err != nil { - return applied, err - } - accountsDb.refreshReadCaches(accts) - applied = append(applied, slot) - } - return applied, nil -} - -// applyAppendOnlySynced writes accounts to a fresh appendvec + fsync, then commits -// the index (Sync:true) AFTER the data it references — no in-place overwrite. -func (accountsDb *AccountsDb) applyAppendOnlySynced(accts []*accounts.Account, slot uint64) error { - live := make([]*accounts.Account, 0, len(accts)) - for _, a := range accts { - if a != nil { - live = append(live, a) - } - } - if len(live) == 0 { - return nil - } - - fileId := accountsDb.LargestFileId.Add(1) - // Persist the high-water mark so a restart does not rewind it and reuse a - // fileId, which (with O_TRUNC below) could clobber a committed appendvec. - if err := accountsDb.persistLargestFileId(); err != nil { - return err - } - name := fmt.Sprintf("%s/%d.%d", accountsDb.AcctsDir, slot, fileId) - f, err := os.OpenFile(name, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0o644) - if err != nil { - return err - } - - var buf bytes.Buffer - batch := accountsDb.Index.NewBatch() - defer batch.Close() - var idxBuf [24]byte - for _, acct := range live { - entry := AccountIndexEntry{Slot: slot, FileId: fileId, Offset: uint64(buf.Len())} - entry.Marshal(&idxBuf) - ava := AppendVecAccount{ - DataLen: uint64(len(acct.Data)), - Pubkey: acct.Key, - Lamports: acct.Lamports, - RentEpoch: acct.RentEpoch, - Owner: acct.Owner, - Executable: acct.Executable, - Data: acct.Data, - } - if _, err := ava.MarshalReturningLength(&buf); err != nil { - f.Close() - return err - } - if err := batch.Set(acct.Key[:], idxBuf[:], nil); err != nil { // pebble copies key+value - f.Close() - return err - } - } - - if _, err := f.Write(buf.Bytes()); err != nil { - f.Close() - return err - } - if err := f.Sync(); err != nil { - f.Close() - return err - } - if err := f.Close(); err != nil { - return err - } - if err := fsyncDir(accountsDb.AcctsDir); err != nil { - return err - } - return batch.Commit(&pebble.WriteOptions{Sync: true}) -} - -// persistLargestFileId durably records the appendvec file-id high-water mark so a -// restart cannot rewind it and reuse a fileId (which would clobber a committed appendvec). -func (accountsDb *AccountsDb) persistLargestFileId() error { - path := filepath.Join(filepath.Dir(accountsDb.AcctsDir), "largest_file_id") - var b [8]byte - binary.LittleEndian.PutUint64(b[:], accountsDb.LargestFileId.Load()) - f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE, 0o644) - if err != nil { - return err - } - if _, err := f.WriteAt(b[:], 0); err != nil { - f.Close() - return err - } - if err := f.Sync(); err != nil { - f.Close() - return err - } - return f.Close() -} - -func (accountsDb *AccountsDb) storeBankHashSynced(slot uint64, bankhash []byte) error { - var slotBytes [8]byte - binary.LittleEndian.PutUint64(slotBytes[:], slot) - return accountsDb.BankHashStore.Set(slotBytes[:], bankhash, &pebble.WriteOptions{Sync: true}) -} diff --git a/pkg/accountsdb/commit_test.go b/pkg/accountsdb/commit_test.go deleted file mode 100644 index c1c2a80fd..000000000 --- a/pkg/accountsdb/commit_test.go +++ /dev/null @@ -1,158 +0,0 @@ -package accountsdb - -import ( - "os" - "path/filepath" - "testing" - - "github.com/Overclock-Validator/mithril/pkg/accounts" - "github.com/gagliardetto/solana-go" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -// newTestDb scaffolds a minimal on-disk AccountsDb in a temp dir (Pebble DBs are -// auto-created by OpenDb). -func newTestDb(t *testing.T) *AccountsDb { - t.Helper() - dir := t.TempDir() - require.NoError(t, os.MkdirAll(filepath.Join(dir, "accounts"), 0o755)) - require.NoError(t, os.WriteFile(filepath.Join(dir, "largest_file_id"), make([]byte, 8), 0o644)) - db, err := OpenDb(dir) - require.NoError(t, err) - db.InitCaches() // node does this after OpenDb; needed for read-cache maintenance - return db -} - -// Happy path: a committed slot's accounts + bankhash read back. -func TestCommitSlotAtomicReadsBack(t *testing.T) { - db := newTestDb(t) - a := redoAcct(1, 500, []byte("hi")) - require.NoError(t, db.CommitSlotAtomic([]*accounts.Account{a}, 100, []byte("bankhash-100"))) - - got, err := db.GetAccount(100, solana.PublicKey{1}) - require.NoError(t, err) - assertAcctEqual(t, a, got) - - bh, err := db.GetBankHashForSlot(100) - require.NoError(t, err) - assert.Equal(t, []byte("bankhash-100"), bh) -} - -// Multi-account slot, read back FROM DISK (caches evicted) — actually exercises -// the appendvec encoding/offset/padding, not the cache. -func TestCommitSlotAtomicMultiAccountFromDisk(t *testing.T) { - db := newTestDb(t) - accts := []*accounts.Account{ - redoAcct(1, 100, []byte("12345")), // dataLen 5 -> pad 3 - redoAcct(3, 200, nil), // dataLen 0 -> pad 0 - redoAcct(5, 300, []byte("eightbyt")), // dataLen 8 -> pad 0 - redoAcct(7, 400, []byte("ninebytes")), // dataLen 9 -> pad 7 - } - require.NoError(t, db.CommitSlotAtomic(accts, 100, []byte("bh"))) - - for _, a := range accts { - db.CommonAcctsCache.Delete(a.Key) // force a cold disk read - got, err := db.GetAccount(100, a.Key) - require.NoError(t, err) - assertAcctEqual(t, a, got) - } -} - -// A nil entry in the slice must not panic (WriteRedo + apply both skip it). -func TestCommitSlotAtomicNilEntry(t *testing.T) { - db := newTestDb(t) - a := redoAcct(2, 50, []byte("ok")) - require.NotPanics(t, func() { - require.NoError(t, db.CommitSlotAtomic([]*accounts.Account{a, nil}, 100, []byte("bh"))) - }) - got, err := db.GetAccount(100, solana.PublicKey{2}) - require.NoError(t, err) - assertAcctEqual(t, a, got) -} - -// THE load-bearing test: a commit interrupted after staging the redo but before -// the store apply / checkpoint advance is rolled forward by recovery. -func TestApplyPendingCommitsRecoversAfterCrash(t *testing.T) { - db := newTestDb(t) - a := redoAcct(2, 700, []byte("recovered")) - a.Slot = 101 - - // Simulate: durable prepare landed, then crash before applying to the store. - require.NoError(t, WriteRedo(db.AcctsDir, 101, []byte("bankhash-101"), []*accounts.Account{a})) - _, err := db.GetAccount(101, solana.PublicKey{2}) - require.Error(t, err, "not in the store yet") - - applied, err := db.ApplyPendingCommits(100) // checkpoint is at slot 100 - require.NoError(t, err) - assert.Equal(t, []uint64{101}, applied) - - got, err := db.GetAccount(101, solana.PublicKey{2}) - require.NoError(t, err) - assertAcctEqual(t, a, got) - bh, err := db.GetBankHashForSlot(101) - require.NoError(t, err) - assert.Equal(t, []byte("bankhash-101"), bh, "bankhash restored from redo") -} - -// A redo for a slot at/below the checkpoint is STALE (already durable) and must -// be discarded, never re-applied — re-applying would regress the account. -func TestApplyPendingCommitsDiscardsStaleRedo(t *testing.T) { - db := newTestDb(t) - a := redoAcct(8, 1, []byte("old")) - a.Slot = 50 - require.NoError(t, WriteRedo(db.AcctsDir, 50, []byte("bh50"), []*accounts.Account{a})) - - applied, err := db.ApplyPendingCommits(100) // checkpoint already past slot 50 - require.NoError(t, err) - assert.Empty(t, applied, "stale redo not re-applied") - - pending, err := ListPendingRedo(db.AcctsDir) - require.NoError(t, err) - assert.Empty(t, pending, "stale redo deleted") - - _, err = db.GetAccount(50, solana.PublicKey{8}) - assert.Error(t, err, "no regression: stale value never written") -} - -// Recovery is idempotent: re-running it (a crash during recovery) is safe. -func TestApplyPendingCommitsIdempotent(t *testing.T) { - db := newTestDb(t) - a := redoAcct(3, 900, []byte("x")) - a.Slot = 102 - require.NoError(t, WriteRedo(db.AcctsDir, 102, []byte("bh102"), []*accounts.Account{a})) - - for range 2 { // recovery does not delete s>checkpoint redos, so a second pass re-applies - applied, err := db.ApplyPendingCommits(100) - require.NoError(t, err) - assert.Equal(t, []uint64{102}, applied) - } - got, err := db.GetAccount(102, solana.PublicKey{3}) - require.NoError(t, err) - assertAcctEqual(t, a, got) -} - -// A torn redo above the checkpoint is quarantined (not applied, not left to loop). -func TestApplyPendingCommitsQuarantinesTornRedo(t *testing.T) { - db := newTestDb(t) - a := redoAcct(4, 100, []byte("y")) - a.Slot = 103 - require.NoError(t, WriteRedo(db.AcctsDir, 103, []byte("bh103"), []*accounts.Account{a})) - - p := redoPath(db.AcctsDir, 103) - data, err := os.ReadFile(p) - require.NoError(t, err) - data[len(data)-1] ^= 0xFF // corrupt the crc - require.NoError(t, os.WriteFile(p, data, 0o644)) - - applied, err := db.ApplyPendingCommits(100) - require.NoError(t, err) - assert.Empty(t, applied, "torn redo not applied") - - pending, err := ListPendingRedo(db.AcctsDir) - require.NoError(t, err) - assert.Empty(t, pending, "torn redo quarantined (no longer pending)") - - _, err = db.GetAccount(103, solana.PublicKey{4}) - assert.Error(t, err, "nothing committed for a torn prepare") -} diff --git a/pkg/accountsdb/compact.go b/pkg/accountsdb/compact.go new file mode 100644 index 000000000..1b351d687 --- /dev/null +++ b/pkg/accountsdb/compact.go @@ -0,0 +1,449 @@ +package accountsdb + +import ( + "bytes" + "fmt" + "hash/crc32" + "os" + "path/filepath" + "sort" + "strings" + + "github.com/Overclock-Validator/mithril/pkg/mlog" + "github.com/Overclock-Validator/mithril/pkg/util" + "github.com/cockroachdb/pebble" +) + +// Compaction reclaims dead bytes from the append-only store. Folds never +// overwrite: every re-written account leaves its previous version behind as +// dead bytes in an older segment or bootstrap appendvec. CompactOnce scans +// mostly-dead files and moves the still-live records into a fresh output file +// (1 source -> 1 output), then deletes the source. +// +// The pin rule (invariant I5) is what keeps rewind sound: a file is untouchable +// while it is inside the rewind horizon — either it IS an in-horizon fold +// segment, or an in-horizon fold's undo pointer (ManifestRecord.Prev) names it. +// Once the horizon ages past a batch, its undo targets stop being needed and +// the files become ordinary candidates. Bootstrap appendvecs are never pinned +// by seq (they predate all folds) and compact like any other file once their +// live fraction decays — that is where the bulk of long-run reclaim comes from. +// +// Liveness is exact, not heuristic: a record is live iff the index still maps +// its pubkey to this exact (fileId, offset). FileIds are never reused (I7), so +// the check cannot alias across files. +// +// Concurrency: runs under foldMu, so it is serialized against CommitBatch, +// recovery, and rewind. It requires rooted-durable mode because the legacy +// direct-store path writes the index without foldMu and would race the +// liveness scan. Concurrent READERS are safe: the source file is unlinked only +// after the moved index entries are durable, and a reader that loses the race +// (fetched the old entry, then the file vanished) retries once with a fresh +// index entry (getStoredAccount). +// +// Crash safety (matrix C5): the output file + compact-kind manifest are written +// and fsynced BEFORE the index moves, and the source is unlinked only after the +// index move is durable (WAL fsync, or explicit Flush when the WAL is off — +// recovery does NOT replay compact manifests, their only job is marking the +// output as non-orphan). A crash anywhere leaves either duplicate bytes (source +// still authoritative) or a fully-dead source; both converge on the next cycle. + +// CompactionConfig bounds one CompactOnce cycle. +type CompactionConfig struct { + // RewindHorizonBatches pins the newest N fold batches and every file their + // undo pointers name. Must match (or exceed) the operational rewind + // horizon. Clamped to >= 1 so the committed head fold is always pinned. + RewindHorizonBatches uint64 + // MinDeadFraction is the dead-byte fraction a file must reach before its + // live records are moved. Defaults to 0.7. + MinDeadFraction float64 + // MaxMoveBytesPerCycle caps live bytes rewritten per call (wear/latency + // bound). Deleting fully-dead files is free and not counted. Default 256MB. + MaxMoveBytesPerCycle int64 + // MaxScanBytesPerCycle caps bytes read for liveness scans per call (read + // churn bound; mostly-live files cost a scan but yield no move). Progress + // across cycles is kept by a directory cursor. Default 1GB. + MaxScanBytesPerCycle int64 +} + +// CompactStats reports one CompactOnce cycle. +type CompactStats struct { + CandidatesScanned int + FilesCompacted int + FilesDeleted int // fully-dead fast path (no bytes moved) + LiveBytesMoved int64 + BytesReclaimed int64 // source bytes freed (compacted + deleted) +} + +const ( + defaultMinDeadFraction = 0.7 + defaultMaxMoveBytesPerCycle = int64(256 << 20) + defaultMaxScanBytesPerCycle = int64(1 << 30) +) + +type compactCandidate struct { + name string + slot uint64 + fileId uint64 + size int64 + manifestPath string // the source's own manifest; "" for bootstrap appendvecs +} + +// CompactOnce runs one bounded compaction cycle and returns what it did. +func (db *AccountsDb) CompactOnce(cfg CompactionConfig) (CompactStats, error) { + stats := CompactStats{} + if !db.RootedDurable { + return stats, fmt.Errorf("accountsdb: compaction requires rooted-durable mode (the direct store path writes the index outside foldMu)") + } + if cfg.RewindHorizonBatches == 0 { + cfg.RewindHorizonBatches = 1 + } + if cfg.MinDeadFraction <= 0 { + cfg.MinDeadFraction = defaultMinDeadFraction + } + if cfg.MaxMoveBytesPerCycle <= 0 { + cfg.MaxMoveBytesPerCycle = defaultMaxMoveBytesPerCycle + } + if cfg.MaxScanBytesPerCycle <= 0 { + cfg.MaxScanBytesPerCycle = defaultMaxScanBytesPerCycle + } + + db.foldMu.Lock() + defer db.foldMu.Unlock() + + meta, _, err := db.readFoldMeta() + if err != nil { + return stats, err + } + + pinned, manifestByFileId, err := db.compactionPinSet(meta.BatchSeq, cfg.RewindHorizonBatches) + if err != nil { + return stats, err + } + + bootstrapHigh := db.bootstrapHighFileId() + entries, err := os.ReadDir(db.AcctsDir) + if err != nil { + return stats, err + } + candidates := make([]compactCandidate, 0, len(entries)) + for _, e := range entries { + name := e.Name() + if e.IsDir() { + continue + } + slot, fileId, ok := parseDataFileName(name) + if !ok { + continue + } + if _, isPinned := pinned[fileId]; isPinned { + continue + } + mpath, hasManifest := manifestByFileId[fileId] + if !hasManifest && fileId > bootstrapHigh { + continue // undecided orphan — recovery's to delete, not ours + } + info, ierr := e.Info() + if ierr != nil { + continue + } + candidates = append(candidates, compactCandidate{ + name: name, slot: slot, fileId: fileId, size: info.Size(), manifestPath: mpath, + }) + } + // Deterministic order + resume after the previous cycle's cursor so large + // directories make steady progress instead of rescanning the same prefix. + sort.Slice(candidates, func(i, j int) bool { return candidates[i].name < candidates[j].name }) + if db.compactCursor != "" { + rotated := make([]compactCandidate, 0, len(candidates)) + var before []compactCandidate + for _, c := range candidates { + if c.name > db.compactCursor { + rotated = append(rotated, c) + } else { + before = append(before, c) + } + } + candidates = append(rotated, before...) + } + + var scannedBytes int64 + for _, c := range candidates { + if stats.LiveBytesMoved >= cfg.MaxMoveBytesPerCycle || scannedBytes >= cfg.MaxScanBytesPerCycle { + break + } + db.compactCursor = c.name + scannedBytes += c.size + stats.CandidatesScanned++ + + acted, moved, err := db.compactFile(c, cfg.MinDeadFraction) + if err != nil { + return stats, fmt.Errorf("accountsdb: compact %s: %w", c.name, err) + } + switch { + case !acted: + // mostly live — leave it to decay further + case moved == 0: + stats.FilesDeleted++ + stats.BytesReclaimed += c.size + default: + stats.FilesCompacted++ + stats.LiveBytesMoved += moved + stats.BytesReclaimed += c.size - moved + } + } + if stats.FilesCompacted > 0 || stats.FilesDeleted > 0 { + mlog.Log.Infof("accountsdb: compaction cycle — %d scanned, %d compacted, %d deleted, %s live moved, %s reclaimed", + stats.CandidatesScanned, stats.FilesCompacted, stats.FilesDeleted, + humanBytes(stats.LiveBytesMoved), humanBytes(stats.BytesReclaimed)) + } + return stats, nil +} + +// compactionPinSet returns the fileIds compaction must not touch (I5) plus a +// fileId -> manifest-path map for every current (non-parked) manifest. +// +// Pinned: in-horizon fold segments (BatchSeq > head-horizon), every file an +// in-horizon undo pointer names, and — unconditionally — parked ".rewound" +// manifests' segments and undo targets (an interrupted rewind resumes through +// them at the next startup; compaction must not pull files out from under it). +func (db *AccountsDb) compactionPinSet(headSeq, horizon uint64) (map[uint64]struct{}, map[uint64]string, error) { + horizonFloor := uint64(0) + if headSeq > horizon { + horizonFloor = headSeq - horizon + } + pinned := make(map[uint64]struct{}) + manifestByFileId := make(map[uint64]string) + + entries, err := os.ReadDir(db.AcctsDir) + if err != nil { + return nil, nil, err + } + for _, e := range entries { + name := e.Name() + if e.IsDir() || strings.HasSuffix(name, segManifestTmpSuffix) { + continue + } + parked := strings.HasSuffix(name, segManifestRewoundSuffix) + if !parked && !strings.HasSuffix(name, segManifestSuffix) { + continue + } + path := filepath.Join(db.AcctsDir, name) + hdr, herr := readManifestHeader(path) + if herr != nil { + continue // torn — recovery quarantines; its data file stays untouched (no manifest entry -> orphan rule) + } + if !parked { + manifestByFileId[hdr.FileId] = path + } + if hdr.Kind != ManifestKindFold { + continue + } + if parked || hdr.BatchSeq > horizonFloor { + pinned[hdr.FileId] = struct{}{} + m, merr := ReadSegmentManifest(path) + if merr != nil { + continue // segment itself stays pinned; undo targets unknown but recovery will quarantine this manifest + } + for i := range m.Records { + if m.Records[i].PrevValid { + pinned[m.Records[i].Prev.FileId] = struct{}{} + } + } + } + } + return pinned, manifestByFileId, nil +} + +// compactFile scans one candidate and, if dead enough, moves its live records +// to a fresh output file and unlinks the source. Returns (acted, liveBytesMoved). +// acted=false means the file was left alone (too live). liveBytesMoved==0 with +// acted=true means the fully-dead fast path (source deleted, nothing written). +func (db *AccountsDb) compactFile(c compactCandidate, minDeadFraction float64) (bool, int64, error) { + srcPath := filepath.Join(db.AcctsDir, c.name) + data, err := os.ReadFile(srcPath) + if err != nil { + if os.IsNotExist(err) { + return false, 0, nil // raced an external cleanup; nothing to do + } + return false, 0, err + } + + pubkeys, idxEntries, _, err := BuildIndexEntriesFromAppendVecs(data, uint64(len(data)), c.slot, c.fileId) + if err != nil { + return false, 0, err + } + + // Exact liveness: the index still names this (fileId, offset). The entry's + // slot must also equal the filename slot — the read path builds the file + // name from the entry's slot, so a mismatched entry could not be served + // from the output file and is safest left untouched. + liveIdx := make([]int, 0, len(idxEntries)) + var liveBytes int64 + for i := range idxEntries { + cur, closer, gerr := db.Index.Get(pubkeys[i][:]) + if gerr != nil { + if gerr == pebble.ErrNotFound { + continue + } + return false, 0, gerr + } + curEntry, derr := UnmarshalAcctIdxEntry(cur) + closer.Close() + if derr != nil { + return false, 0, derr + } + if curEntry.FileId == c.fileId && curEntry.Offset == idxEntries[i].Offset && curEntry.Slot == c.slot { + liveIdx = append(liveIdx, i) + liveBytes += recordLenAt(data, idxEntries[i].Offset) + } + } + + if len(data) == 0 || len(liveIdx) == 0 { + // Fully dead (or empty bankhash-only segment past the horizon): no + // index state references it; drop source + its manifest. + if err := removeSourceFiles(db.AcctsDir, srcPath, c.manifestPath); err != nil { + return false, 0, err + } + return true, 0, nil + } + + deadFraction := 1.0 - float64(liveBytes)/float64(len(data)) + if deadFraction < minDeadFraction { + return false, 0, nil + } + + // Allocate the output fileId, persisting the high-water mark before the + // first data byte (I7 — a crash must never lead to fileId reuse). + newFileId := db.LargestFileId.Add(1) + if err := db.persistLargestFileId(); err != nil { + return false, 0, err + } + + // Raw-copy each live record span (header + data + alignment padding) so the + // output is byte-identical per record; offsets are freshly assigned. + var buf bytes.Buffer + buf.Grow(int(liveBytes)) + records := make([]ManifestRecord, 0, len(liveIdx)) + for _, i := range liveIdx { + off := idxEntries[i].Offset + recLen := recordLenAt(data, off) + records = append(records, ManifestRecord{ + Pubkey: pubkeys[i], + Offset: uint64(buf.Len()), + OwnerSlot: c.slot, + }) + buf.Write(data[off : int64(off)+recLen]) + } + + outName := SegmentDataName(c.slot, newFileId) + outPath := filepath.Join(db.AcctsDir, outName) + f, err := os.OpenFile(outPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o644) + if err != nil { + return false, 0, err + } + if _, err := f.Write(buf.Bytes()); err != nil { + f.Close() + return false, 0, err + } + if err := f.Sync(); err != nil { + f.Close() + return false, 0, err + } + if err := f.Close(); err != nil { + return false, 0, err + } + if err := fsyncDir(db.AcctsDir); err != nil { + return false, 0, err + } + + // The compact manifest's only durable job is marking the output non-orphan; + // recovery never replays it (a crash before the index move leaves the + // source authoritative and the output as harmless duplicates). + manifest := &SegmentManifest{ + Version: segManifestVersion, + Kind: ManifestKindCompact, + BatchSeq: 0, + FromSlot: c.slot, + ThroughSlot: c.slot, + FileId: newFileId, + DataLen: uint64(buf.Len()), + DataCRC: crc32.ChecksumIEEE(buf.Bytes()), + Records: records, + } + if err := WriteSegmentManifest(db.AcctsDir, manifest); err != nil { + return false, 0, err + } + + // Move the index entries in one batch, then make the move durable BEFORE + // unlinking the source — with the WAL off that means an explicit Flush, + // because compact manifests are not replayed at recovery. + batch := db.Index.NewBatch() + defer batch.Close() + var idxBuf [24]byte + for _, rec := range records { + entry := AccountIndexEntry{Slot: c.slot, FileId: newFileId, Offset: rec.Offset} + entry.Marshal(&idxBuf) + if err := batch.Set(rec.Pubkey[:], idxBuf[:], nil); err != nil { + return false, 0, err + } + } + opts := pebble.Sync + if db.IndexWALDisabled { + opts = pebble.NoSync + } + if err := batch.Commit(opts); err != nil { + return false, 0, err + } + if db.IndexWALDisabled { + if err := db.Index.Flush(); err != nil { + return false, 0, err + } + } + + if err := removeSourceFiles(db.AcctsDir, srcPath, c.manifestPath); err != nil { + return false, 0, err + } + return true, int64(buf.Len()), nil +} + +// recordLenAt returns the byte length of the appendvec record starting at off +// (header + data + 8-byte alignment padding), clamped to the file end. +func recordLenAt(data []byte, off uint64) int64 { + if int64(off)+hdrLen > int64(len(data)) { + return int64(len(data)) - int64(off) + } + dataLen := uint64(0) + for i := 0; i < 8; i++ { + dataLen |= uint64(data[off+dataLenOffset+uint64(i)]) << (8 * i) + } + recLen := int64(hdrLen) + int64(util.AlignUp(dataLen, 8)) + if int64(off)+recLen > int64(len(data)) { + return int64(len(data)) - int64(off) + } + return recLen +} + +func removeSourceFiles(acctsDir, srcPath, manifestPath string) error { + if err := os.Remove(srcPath); err != nil && !os.IsNotExist(err) { + return err + } + if manifestPath != "" { + if err := os.Remove(manifestPath); err != nil && !os.IsNotExist(err) { + return err + } + } + return fsyncDir(acctsDir) +} + +func humanBytes(n int64) string { + switch { + case n >= 1<<30: + return fmt.Sprintf("%.1fGiB", float64(n)/float64(1<<30)) + case n >= 1<<20: + return fmt.Sprintf("%.1fMiB", float64(n)/float64(1<<20)) + case n >= 1<<10: + return fmt.Sprintf("%.1fKiB", float64(n)/float64(1<<10)) + default: + return fmt.Sprintf("%dB", n) + } +} diff --git a/pkg/accountsdb/files.go b/pkg/accountsdb/files.go new file mode 100644 index 000000000..58d052978 --- /dev/null +++ b/pkg/accountsdb/files.go @@ -0,0 +1,40 @@ +package accountsdb + +import ( + "encoding/binary" + "fmt" + "os" + "path/filepath" +) + +// fsyncDir makes a directory entry change (create/rename/unlink) durable. +func fsyncDir(dir string) error { + d, err := os.Open(dir) + if err != nil { + return fmt.Errorf("accountsdb: open dir for fsync: %w", err) + } + defer d.Close() + return d.Sync() +} + +// persistLargestFileId durably records the appendvec/segment file-id high-water +// mark so a restart cannot rewind it and reuse a fileId (which would clobber a +// committed data file). +func (accountsDb *AccountsDb) persistLargestFileId() error { + path := filepath.Join(filepath.Dir(accountsDb.AcctsDir), "largest_file_id") + var b [8]byte + binary.LittleEndian.PutUint64(b[:], accountsDb.LargestFileId.Load()) + f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE, 0o644) + if err != nil { + return err + } + if _, err := f.WriteAt(b[:], 0); err != nil { + f.Close() + return err + } + if err := f.Sync(); err != nil { + f.Close() + return err + } + return f.Close() +} diff --git a/pkg/accountsdb/fold.go b/pkg/accountsdb/fold.go new file mode 100644 index 000000000..ff09a894d --- /dev/null +++ b/pkg/accountsdb/fold.go @@ -0,0 +1,338 @@ +package accountsdb + +import ( + "bufio" + "bytes" + "encoding/binary" + "fmt" + "hash/crc32" + "io" + "os" + "path/filepath" + "runtime" + "sort" + + "github.com/Overclock-Validator/mithril/pkg/accounts" + "github.com/cockroachdb/pebble" + "golang.org/x/sync/errgroup" +) + +// metaKeyLastBatch is the Pebble index key holding the fold commit watermark +// {batchSeq, throughSlot, fileId}. Its length (!= 32) cannot collide with an +// account pubkey key. Writing it in the same batch as the index entries is the +// atomic "index epoch flip" that decides batch visibility. +var metaKeyLastBatch = []byte("\x00mithril.meta.last_batch") + +type foldMeta struct { + BatchSeq uint64 + ThroughSlot uint64 + FileId uint64 +} + +func encodeFoldMeta(m foldMeta) []byte { + var b [24]byte + binary.LittleEndian.PutUint64(b[0:8], m.BatchSeq) + binary.LittleEndian.PutUint64(b[8:16], m.ThroughSlot) + binary.LittleEndian.PutUint64(b[16:24], m.FileId) + return b[:] +} + +func decodeFoldMeta(data []byte) (foldMeta, error) { + if len(data) < 24 { + return foldMeta{}, fmt.Errorf("accountsdb: fold meta record has %d < 24 bytes", len(data)) + } + return foldMeta{ + BatchSeq: binary.LittleEndian.Uint64(data[0:8]), + ThroughSlot: binary.LittleEndian.Uint64(data[8:16]), + FileId: binary.LittleEndian.Uint64(data[16:24]), + }, nil +} + +// readFoldMeta returns the current fold watermark, or (zero, false) on a fresh +// (never-folded) store. +func (db *AccountsDb) readFoldMeta() (foldMeta, bool, error) { + val, closer, err := db.Index.Get(metaKeyLastBatch) + if err != nil { + if err == pebble.ErrNotFound { + return foldMeta{}, false, nil + } + return foldMeta{}, false, err + } + meta, derr := decodeFoldMeta(val) + closer.Close() + if derr != nil { + return foldMeta{}, false, derr + } + return meta, true, nil +} + +// foldTestHooks fire between CommitBatch stages so tests can inject crashes +// (each hook may panic) at every point of the crash matrix. +type foldTestHooks struct { + afterSegmentFsync func() + afterManifestRename func() + beforeIndexCommit func() + afterIndexCommit func() +} + +func fire(h func()) { + if h != nil { + h() + } +} + +// BatchCommitResult summarizes one durable fold. +type BatchCommitResult struct { + BatchSeq uint64 + FileId uint64 + ThroughSlot uint64 + Keys int + Bytes int64 +} + +type dedupedVersion struct { + acct *accounts.Account + ownerSlot uint64 +} + +// CommitBatch durably folds a batch of slot deltas as ONE sequential segment + +// ONE manifest + ONE index batch: +// +// 1. union-dedupe newest-wins across the deltas +// 2. allocate a fileId (persisting the high-water mark first — never reused) +// 3. write + fsync the segment data file (appendvec record encoding) +// 4. capture undo pointers (each key's current index entry) +// 5. write + fsync the manifest — once durable, the commit is DECIDED; +// recovery completes it from here (crash matrix C3) +// 6. record bankhashes (advisory, NoSync — recoverable from the manifest) +// 7. commit index entries + fold meta in one Pebble batch (the epoch flip) +// 8. refresh read caches and advance the in-memory watermark +// +// The fold never overwrites existing data files, so concurrent readers are +// never invalidated and old versions remain for rewind until GC'd past the +// horizon. +func (db *AccountsDb) CommitBatch( + deltas []accounts.SlotDelta, + throughSlot uint64, + bankhashes map[uint64][32]byte, + resumeCtx []byte, +) (BatchCommitResult, error) { + db.foldMu.Lock() + defer db.foldMu.Unlock() + + // (1) Union-dedupe, newest wins. Deltas MUST arrive strictly ascending by + // slot: newest-wins depends on iterating in order so a later slot's version + // overwrites an earlier one. Validate it so a future fork-aware caller can + // never silently fold stale state from mis-ordered deltas. + fromSlot := uint64(0) + prevSlot := uint64(0) + havePrev := false + union := make(map[[32]byte]dedupedVersion) + for i, sd := range deltas { + if havePrev && sd.Slot <= prevSlot { + return BatchCommitResult{}, fmt.Errorf("accountsdb: CommitBatch deltas must be strictly ascending by slot; slot %d does not follow %d", sd.Slot, prevSlot) + } + prevSlot, havePrev = sd.Slot, true + if i == 0 && sd.Slot > 0 { + fromSlot = sd.Slot - 1 + } + if sd.Slot > throughSlot { + return BatchCommitResult{}, fmt.Errorf("accountsdb: CommitBatch delta slot %d exceeds throughSlot %d", sd.Slot, throughSlot) + } + for _, a := range sd.Delta { + if a == nil { + continue + } + union[[32]byte(a.Key)] = dedupedVersion{acct: a, ownerSlot: sd.Slot} + } + } + // An all-empty batch (empty blocks) still folds: the manifest records the + // batch's bankhashes + resume context and advances the watermark; the data + // file is empty and no index entries are written. + + // Deterministic segment layout (test reproducibility + stable offsets). + keys := make([][32]byte, 0, len(union)) + for k := range union { + keys = append(keys, k) + } + sort.Slice(keys, func(i, j int) bool { return bytes.Compare(keys[i][:], keys[j][:]) < 0 }) + + // (2) fileId allocation; persist high-water BEFORE the first data byte (I7). + fileId := db.LargestFileId.Add(1) + if err := db.persistLargestFileId(); err != nil { + return BatchCommitResult{}, err + } + + // (3) Stream each deduped record straight to the segment file (buffered), + // updating a running CRC and byte count as we go, so the fully serialized + // segment is never materialized in RAM — only the bufio buffer is. Record + // offsets are the byte position before each record. + dataName := filepath.Join(db.AcctsDir, SegmentDataName(throughSlot, fileId)) + f, err := os.OpenFile(dataName, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o644) + if err != nil { + return BatchCommitResult{}, err + } + crc := crc32.NewIEEE() + bw := bufio.NewWriterSize(f, 1<<20) + segWriter := io.MultiWriter(bw, crc) + records := make([]ManifestRecord, 0, len(keys)) + var dataLen uint64 + segErr := func() error { + for _, k := range keys { + v := union[k] + records = append(records, ManifestRecord{Pubkey: k, Offset: dataLen, OwnerSlot: v.ownerSlot}) + ava := AppendVecAccount{ + DataLen: uint64(len(v.acct.Data)), + Pubkey: v.acct.Key, + Lamports: v.acct.Lamports, + RentEpoch: v.acct.RentEpoch, + Owner: v.acct.Owner, + Executable: v.acct.Executable, + Data: v.acct.Data, + } + n, err := ava.MarshalReturningLength(segWriter) + if err != nil { + return fmt.Errorf("accountsdb: encode segment record: %w", err) + } + dataLen += uint64(n) + } + return bw.Flush() + }() + if segErr != nil { + f.Close() + return BatchCommitResult{}, segErr + } + if err := f.Sync(); err != nil { + f.Close() + return BatchCommitResult{}, err + } + if err := f.Close(); err != nil { + return BatchCommitResult{}, err + } + if err := fsyncDir(db.AcctsDir); err != nil { + return BatchCommitResult{}, err + } + fire(db.foldHooks.afterSegmentFsync) + dataCRC := crc.Sum32() + + // (4) Undo pointers: the index entry each key had before this batch. Bloom + // filters make misses cheap; bounded parallelism keeps fold latency down. + prevs := make([]ManifestRecord, len(records)) + copy(prevs, records) + var g errgroup.Group + g.SetLimit(max(4, runtime.NumCPU()/2)) + for i := range prevs { + g.Go(func() error { + val, closer, err := db.Index.Get(prevs[i].Pubkey[:]) + if err != nil { + if err == pebble.ErrNotFound { + return nil // PrevValid stays false + } + return err + } + entry, derr := UnmarshalAcctIdxEntry(val) + closer.Close() + if derr != nil { + return derr + } + prevs[i].PrevValid = true + prevs[i].Prev = *entry + return nil + }) + } + if err := g.Wait(); err != nil { + return BatchCommitResult{}, fmt.Errorf("accountsdb: capture undo pointers: %w", err) + } + + // (5) Manifest: once this rename is durable the commit is decided. + batchSeq := db.lastBatchSeq + 1 + manifest := &SegmentManifest{ + Version: segManifestVersion, + Kind: ManifestKindFold, + BatchSeq: batchSeq, + FromSlot: fromSlot, + ThroughSlot: throughSlot, + FileId: fileId, + DataLen: dataLen, + DataCRC: dataCRC, + Bankhashes: sortedBankhashes(bankhashes), + Records: prevs, + ResumeCtx: resumeCtx, + } + if err := WriteSegmentManifest(db.AcctsDir, manifest); err != nil { + return BatchCommitResult{}, err + } + fire(db.foldHooks.afterManifestRename) + + // (6) Advisory bankhash rows; recoverable from the manifest, so NoSync. + for slot, bh := range bankhashes { + var slotBytes [8]byte + binary.LittleEndian.PutUint64(slotBytes[:], slot) + if err := db.BankHashStore.Set(slotBytes[:], bh[:], pebble.NoSync); err != nil { + return BatchCommitResult{}, err + } + } + + // (7) The index epoch flip: entries + meta in one batch. + fire(db.foldHooks.beforeIndexCommit) + if err := db.applyManifestToIndex(manifest); err != nil { + return BatchCommitResult{}, err + } + fire(db.foldHooks.afterIndexCommit) + + // (8) Publish. + live := make([]*accounts.Account, 0, len(union)) + for _, k := range keys { + live = append(live, union[k].acct) + } + db.refreshReadCaches(live) + db.lastBatchSeq = batchSeq + db.durableThrough.Store(throughSlot) + + return BatchCommitResult{ + BatchSeq: batchSeq, + FileId: fileId, + ThroughSlot: throughSlot, + Keys: len(records), + Bytes: int64(dataLen), + }, nil +} + +// applyManifestToIndex installs a manifest's index entries + the fold meta in +// one Pebble batch. Idempotent: re-applying an already-applied manifest writes +// identical values. Sync honors the WAL mode (a WAL-less index recovers its +// tail by replaying manifests instead). +func (db *AccountsDb) applyManifestToIndex(m *SegmentManifest) error { + batch := db.Index.NewBatch() + defer batch.Close() + var idxBuf [24]byte + for i := range m.Records { + r := &m.Records[i] + entry := AccountIndexEntry{Slot: m.ThroughSlot, FileId: m.FileId, Offset: r.Offset} + entry.Marshal(&idxBuf) + if err := batch.Set(r.Pubkey[:], idxBuf[:], nil); err != nil { + return err + } + } + if err := batch.Set(metaKeyLastBatch, encodeFoldMeta(foldMeta{ + BatchSeq: m.BatchSeq, + ThroughSlot: m.ThroughSlot, + FileId: m.FileId, + }), nil); err != nil { + return err + } + opts := pebble.Sync + if db.IndexWALDisabled { + opts = pebble.NoSync + } + return batch.Commit(opts) +} + +func sortedBankhashes(bankhashes map[uint64][32]byte) []SlotBankhash { + out := make([]SlotBankhash, 0, len(bankhashes)) + for slot, bh := range bankhashes { + out = append(out, SlotBankhash{Slot: slot, Bankhash: bh}) + } + sort.Slice(out, func(i, j int) bool { return out[i].Slot < out[j].Slot }) + return out +} diff --git a/pkg/accountsdb/fold_review_test.go b/pkg/accountsdb/fold_review_test.go new file mode 100644 index 000000000..2e78a63db --- /dev/null +++ b/pkg/accountsdb/fold_review_test.go @@ -0,0 +1,120 @@ +package accountsdb + +import ( + "os" + "path/filepath" + "testing" + + "github.com/Overclock-Validator/mithril/pkg/accounts" + "github.com/gagliardetto/solana-go" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// A manifest-only fold (empty/skip slots with no account writes) must still +// commit: it records the batch's bankhashes + resume context and advances the +// durable watermark, so empty slots never block durable progress. +func TestCommitBatchEmptyFoldAdvancesWatermark(t *testing.T) { + db, _ := newFoldTestDb(t) + defer db.CloseDb() + + // A real fold first, then a bankhash-only fold over empty slots. + commitTestBatch(t, db, 110, foldAcct(1, 100, []byte("v1"))) + res, err := db.CommitBatch( + foldDeltas( + accounts.SlotDelta{Slot: 111, Delta: nil}, + accounts.SlotDelta{Slot: 112, Delta: []*accounts.Account{nil}}, // only nils + ), + 112, + map[uint64][32]byte{111: bh(111), 112: bh(112)}, + []byte("ctx-112"), + ) + require.NoError(t, err) + assert.Equal(t, uint64(2), res.BatchSeq, "empty fold still advances the batch sequence") + assert.Equal(t, uint64(112), res.ThroughSlot) + assert.Equal(t, 0, res.Keys) + assert.Zero(t, res.Bytes, "no account bytes written") + + meta, ok, err := db.readFoldMeta() + require.NoError(t, err) + require.True(t, ok) + assert.Equal(t, uint64(112), meta.ThroughSlot, "watermark advanced past the empty slots") + + hb, err := db.GetBankHashForSlot(112) + require.NoError(t, err) + want := bh(112) + assert.Equal(t, want[:], hb, "empty slot's bankhash recorded") + + // The manifest carries the resume context and survives a reopen (recovery + // verifies the zero-length segment's CRC/length without error). + m, err := ReadSegmentManifest(segmentManifestPath(db.AcctsDir, 112, res.FileId)) + require.NoError(t, err) + assert.Equal(t, []byte("ctx-112"), m.ResumeCtx) + assert.Zero(t, m.DataLen) + + // The earlier account is still the only stored account. + assert.Equal(t, uint64(100), mustColdRead(t, db, 112, solana.PublicKey{1}).Lamports) +} + +// Newest-wins dedup depends on ascending delta order; a mis-ordered caller +// (e.g. a future fork-aware path) must be rejected, not silently fold stale state. +func TestCommitBatchRejectsNonAscendingDeltas(t *testing.T) { + db, _ := newFoldTestDb(t) + defer db.CloseDb() + + _, err := db.CommitBatch( + foldDeltas( + accounts.SlotDelta{Slot: 120, Delta: []*accounts.Account{foldAcct(1, 1, nil)}}, + accounts.SlotDelta{Slot: 110, Delta: []*accounts.Account{foldAcct(1, 2, nil)}}, // out of order + ), + 120, + map[uint64][32]byte{120: bh(120)}, + nil, + ) + require.ErrorContains(t, err, "strictly ascending") + + // Equal consecutive slots are also rejected (dedup would be order-dependent). + _, err = db.CommitBatch( + foldDeltas( + accounts.SlotDelta{Slot: 110, Delta: []*accounts.Account{foldAcct(1, 1, nil)}}, + accounts.SlotDelta{Slot: 110, Delta: []*accounts.Account{foldAcct(1, 2, nil)}}, + ), + 110, + map[uint64][32]byte{110: bh(110)}, + nil, + ) + require.ErrorContains(t, err, "strictly ascending") + + // A single delta and correctly-ordered deltas still fold. + _, err = db.CommitBatch( + foldDeltas( + accounts.SlotDelta{Slot: 110, Delta: []*accounts.Account{foldAcct(1, 1, nil)}}, + accounts.SlotDelta{Slot: 111, Delta: []*accounts.Account{foldAcct(1, 2, nil)}}, + ), + 111, + map[uint64][32]byte{111: bh(111)}, + nil, + ) + require.NoError(t, err) +} + +// A leftover ".manifest.tmp" (crash between manifest tmp-write and rename) is +// unlinked by recovery — it was never committed (the rename is the commit point). +func TestRecoveryRemovesStaleTmpManifest(t *testing.T) { + db, dir := newFoldTestDb(t) + commitTestBatch(t, db, 110, foldAcct(1, 100, []byte("v1"))) + + tmpPath := filepath.Join(db.AcctsDir, SegmentDataName(120, 999)+segManifestTmpSuffix) + require.NoError(t, os.WriteFile(tmpPath, []byte("partial-manifest-bytes"), 0o644)) + + db = reopenFoldTestDb(t, db, dir) + defer db.CloseDb() + rec, err := db.RecoverFoldState() + require.NoError(t, err) + + assert.NoFileExists(t, tmpPath, "stale manifest tmp must be unlinked by recovery") + assert.Contains(t, rec.OrphansRemoved, tmpPath) + // The committed batch is untouched. + assert.Equal(t, uint64(110), rec.DurableThrough) + assert.Equal(t, uint64(100), mustColdRead(t, db, 110, solana.PublicKey{1}).Lamports) +} diff --git a/pkg/accountsdb/fold_test.go b/pkg/accountsdb/fold_test.go new file mode 100644 index 000000000..19b28524e --- /dev/null +++ b/pkg/accountsdb/fold_test.go @@ -0,0 +1,378 @@ +package accountsdb + +import ( + "encoding/binary" + "os" + "path/filepath" + "testing" + + "github.com/Overclock-Validator/mithril/pkg/accounts" + "github.com/cockroachdb/pebble" + "github.com/gagliardetto/solana-go" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// newFoldTestDb scaffolds an on-disk AccountsDb with both sidecars present +// (largest_file_id + bootstrap_high_file_id), matching a snapshot-built store. +func newFoldTestDb(t *testing.T) (*AccountsDb, string) { + t.Helper() + dir := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(dir, "accounts"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "largest_file_id"), make([]byte, 8), 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "bootstrap_high_file_id"), make([]byte, 8), 0o644)) + db, err := OpenDb(dir) + require.NoError(t, err) + db.RootedDurable = true // production storage mode (node.go forces it on) + db.InitCaches() + return db, dir +} + +func reopenFoldTestDb(t *testing.T, db *AccountsDb, dir string) *AccountsDb { + t.Helper() + db.CloseDb() + re, err := OpenDb(dir) + require.NoError(t, err) + re.RootedDurable = true + re.InitCaches() + return re +} + +func foldAcct(b byte, lamports uint64, data []byte) *accounts.Account { + return &accounts.Account{ + Key: solana.PublicKey{b}, + Lamports: lamports, + Data: data, + Owner: [32]byte{b, b}, + RentEpoch: uint64(b), + } +} + +func foldDeltas(slots ...accounts.SlotDelta) []accounts.SlotDelta { return slots } + +func bh(slot uint64) [32]byte { + var h [32]byte + binary.LittleEndian.PutUint64(h[:], slot) + return h +} + +func mustColdRead(t *testing.T, db *AccountsDb, throughSlot uint64, key solana.PublicKey) *accounts.Account { + t.Helper() + db.CommonAcctsCache.Delete(key) + db.VoteAcctCache.Delete(key) + got, err := db.GetAccount(throughSlot, key) + require.NoError(t, err) + return got +} + +// Happy path: a folded batch's newest versions read back from disk, bankhashes +// land, and the fold meta advances. +func TestCommitBatchReadsBackAndDedupes(t *testing.T) { + db, _ := newFoldTestDb(t) + defer db.CloseDb() + + older := foldAcct(1, 100, []byte("old")) + newer := foldAcct(1, 999, []byte("new-version")) + other := foldAcct(2, 50, []byte("12345")) // dataLen 5 -> exercises padding + + res, err := db.CommitBatch(foldDeltas( + accounts.SlotDelta{Slot: 101, Delta: []*accounts.Account{older, other}}, + accounts.SlotDelta{Slot: 103, Delta: []*accounts.Account{nil, newer}}, // nil entries skipped + ), 104, map[uint64][32]byte{101: bh(101), 103: bh(103), 104: bh(104)}, []byte("ctx-104")) + require.NoError(t, err) + assert.Equal(t, uint64(1), res.BatchSeq) + assert.Equal(t, uint64(104), res.ThroughSlot) + assert.Equal(t, 2, res.Keys, "union across slots must dedupe key 1") + + got := mustColdRead(t, db, 104, solana.PublicKey{1}) + assert.Equal(t, uint64(999), got.Lamports, "newest version must win the dedupe") + assert.Equal(t, []byte("new-version"), got.Data) + + got2 := mustColdRead(t, db, 104, solana.PublicKey{2}) + assert.Equal(t, uint64(50), got2.Lamports) + + hb, err := db.GetBankHashForSlot(103) + require.NoError(t, err) + want := bh(103) + assert.Equal(t, want[:], hb) + + meta, ok, err := db.readFoldMeta() + require.NoError(t, err) + require.True(t, ok) + assert.Equal(t, foldMeta{BatchSeq: 1, ThroughSlot: 104, FileId: res.FileId}, meta) + + // Undo pointers for a second batch must name the first batch's entries. + newest := foldAcct(1, 1234, []byte("v3")) + res2, err := db.CommitBatch(foldDeltas( + accounts.SlotDelta{Slot: 106, Delta: []*accounts.Account{newest}}, + ), 108, map[uint64][32]byte{108: bh(108)}, []byte("ctx-108")) + require.NoError(t, err) + + manifest, err := ReadSegmentManifest(segmentManifestPath(db.AcctsDir, 108, res2.FileId)) + require.NoError(t, err) + require.Len(t, manifest.Records, 1) + require.True(t, manifest.Records[0].PrevValid, "key existed before -> undo pointer set") + assert.Equal(t, res.FileId, manifest.Records[0].Prev.FileId, "undo pointer names the prior segment") + assert.Equal(t, []byte("ctx-108"), manifest.ResumeCtx) +} + +// Manifest encode/decode round-trip, and CRC detection of a torn manifest. +func TestSegmentManifestRoundTripAndTornDetection(t *testing.T) { + dir := t.TempDir() + m := &SegmentManifest{ + Version: segManifestVersion, + Kind: ManifestKindFold, + BatchSeq: 7, + FromSlot: 99, + ThroughSlot: 130, + FileId: 42, + DataLen: 1024, + DataCRC: 0xDEADBEEF, + Bankhashes: []SlotBankhash{{Slot: 100, Bankhash: bh(100)}, {Slot: 130, Bankhash: bh(130)}}, + Records: []ManifestRecord{ + {Pubkey: [32]byte{1}, Offset: 0, OwnerSlot: 100, PrevValid: true, Prev: AccountIndexEntry{Slot: 90, FileId: 3, Offset: 77}}, + {Pubkey: [32]byte{2}, Offset: 136, OwnerSlot: 130}, + }, + ResumeCtx: []byte(`{"slot":130}`), + } + require.NoError(t, WriteSegmentManifest(dir, m)) + + got, err := ReadSegmentManifest(segmentManifestPath(dir, 130, 42)) + require.NoError(t, err) + assert.Equal(t, m, got) + + // Flip one byte -> torn. + path := segmentManifestPath(dir, 130, 42) + data, err := os.ReadFile(path) + require.NoError(t, err) + data[len(data)/2] ^= 0xFF + require.NoError(t, os.WriteFile(path, data, 0o644)) + _, err = ReadSegmentManifest(path) + assert.ErrorIs(t, err, ErrTornManifest) +} + +// Crash-point injection across the commit protocol. Each stage panics once, +// the store reopens, RecoverFoldState runs, and the outcome must match the +// crash matrix: before the manifest rename the batch never happened (orphan +// GC'd); at/after the rename the batch is decided and recovery completes it. +func TestCommitBatchCrashMatrix(t *testing.T) { + cases := []struct { + name string + arm func(h *foldTestHooks, boom func()) + batch2Survives bool + replayed bool // recovery had to replay the manifest into the index + }{ + {"after_segment_fsync", func(h *foldTestHooks, boom func()) { h.afterSegmentFsync = boom }, false, false}, + {"after_manifest_rename", func(h *foldTestHooks, boom func()) { h.afterManifestRename = boom }, true, true}, + {"before_index_commit", func(h *foldTestHooks, boom func()) { h.beforeIndexCommit = boom }, true, true}, + {"after_index_commit", func(h *foldTestHooks, boom func()) { h.afterIndexCommit = boom }, true, false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + db, dir := newFoldTestDb(t) + + base := foldAcct(1, 111, []byte("base")) + _, err := db.CommitBatch(foldDeltas( + accounts.SlotDelta{Slot: 101, Delta: []*accounts.Account{base}}, + ), 105, map[uint64][32]byte{105: bh(105)}, []byte("ctx-105")) + require.NoError(t, err) + + tc.arm(&db.foldHooks, func() { panic("injected crash: " + tc.name) }) + v2 := foldAcct(1, 222, []byte("second")) + func() { + defer func() { require.NotNil(t, recover(), "hook must panic") }() + _, _ = db.CommitBatch(foldDeltas( + accounts.SlotDelta{Slot: 106, Delta: []*accounts.Account{v2}}, + ), 110, map[uint64][32]byte{110: bh(110)}, []byte("ctx-110")) + }() + + db = reopenFoldTestDb(t, db, dir) + defer db.CloseDb() + res, err := db.RecoverFoldState() + require.NoError(t, err) + + if tc.batch2Survives { + assert.Equal(t, uint64(110), res.DurableThrough, "manifest was durable -> commit decided") + assert.Equal(t, uint64(2), res.BatchSeq) + assert.Equal(t, []byte("ctx-110"), res.ResumeCtx) + got := mustColdRead(t, db, 110, solana.PublicKey{1}) + assert.Equal(t, uint64(222), got.Lamports) + if tc.replayed { + assert.Equal(t, []uint64{2}, res.ReplayedBatches) + } else { + assert.Empty(t, res.ReplayedBatches) + } + } else { + assert.Equal(t, uint64(105), res.DurableThrough, "undecided batch must vanish") + assert.Equal(t, uint64(1), res.BatchSeq) + assert.Equal(t, []byte("ctx-105"), res.ResumeCtx) + got := mustColdRead(t, db, 105, solana.PublicKey{1}) + assert.Equal(t, uint64(111), got.Lamports) + assert.NotEmpty(t, res.OrphansRemoved, "orphan segment must be GC'd") + } + + // A fresh fold after recovery continues the sequence cleanly. + v3 := foldAcct(1, 333, []byte("third")) + res3, err := db.CommitBatch(foldDeltas( + accounts.SlotDelta{Slot: 111, Delta: []*accounts.Account{v3}}, + ), 112, map[uint64][32]byte{112: bh(112)}, nil) + require.NoError(t, err) + assert.Equal(t, res.BatchSeq+1, res3.BatchSeq, "BatchSeq must stay contiguous after recovery") + }) + } +} + +// Recovery is idempotent: a second run finds nothing to replay and reports the +// same watermark. +func TestRecoverFoldStateIdempotent(t *testing.T) { + db, dir := newFoldTestDb(t) + _, err := db.CommitBatch(foldDeltas( + accounts.SlotDelta{Slot: 101, Delta: []*accounts.Account{foldAcct(1, 1, nil)}}, + ), 105, map[uint64][32]byte{105: bh(105)}, []byte("ctx")) + require.NoError(t, err) + + db = reopenFoldTestDb(t, db, dir) + defer db.CloseDb() + first, err := db.RecoverFoldState() + require.NoError(t, err) + second, err := db.RecoverFoldState() + require.NoError(t, err) + assert.Equal(t, first.DurableThrough, second.DurableThrough) + assert.Equal(t, first.BatchSeq, second.BatchSeq) + assert.Empty(t, second.ReplayedBatches) + assert.Empty(t, second.OrphansRemoved) +} + +// A gap in the BatchSeq run bounds recovery: contiguous manifests replay, and +// everything above the gap is undecided -> deleted. +func TestRecoverFoldStateStopsAtGapAndRemovesAbove(t *testing.T) { + db, dir := newFoldTestDb(t) + + for i, through := range []uint64{105, 110, 115} { + _, err := db.CommitBatch(foldDeltas( + accounts.SlotDelta{Slot: through - 1, Delta: []*accounts.Account{foldAcct(byte(i+1), uint64(i+1), nil)}}, + ), through, map[uint64][32]byte{through: bh(through)}, nil) + require.NoError(t, err) + } + + // Rewind the index to "nothing applied" and delete seq 2's manifest to + // create a gap: seq 1 must replay, seq 3 must be treated as undecided. + require.NoError(t, db.Index.Delete(metaKeyLastBatch, pebble.Sync)) + headers, err := ListFoldManifests(db.AcctsDir) + require.NoError(t, err) + require.Len(t, headers, 3) + require.NoError(t, os.Remove(headers[1].Path)) + + db = reopenFoldTestDb(t, db, dir) + defer db.CloseDb() + res, err := db.RecoverFoldState() + require.NoError(t, err) + assert.Equal(t, uint64(1), res.BatchSeq) + assert.Equal(t, uint64(105), res.DurableThrough) + assert.Equal(t, []uint64{1}, res.ReplayedBatches) + assert.NotEmpty(t, res.OrphansRemoved, "seq-3 manifest + segment must be removed") + + remaining, err := ListFoldManifests(db.AcctsDir) + require.NoError(t, err) + assert.Len(t, remaining, 1) +} + +// A torn manifest stops the replay run exactly like a gap. +func TestRecoverFoldStateStopsAtTornManifest(t *testing.T) { + db, dir := newFoldTestDb(t) + for i, through := range []uint64{105, 110} { + _, err := db.CommitBatch(foldDeltas( + accounts.SlotDelta{Slot: through - 1, Delta: []*accounts.Account{foldAcct(byte(i+1), uint64(i+1), nil)}}, + ), through, map[uint64][32]byte{through: bh(through)}, nil) + require.NoError(t, err) + } + require.NoError(t, db.Index.Delete(metaKeyLastBatch, pebble.Sync)) + + headers, err := ListFoldManifests(db.AcctsDir) + require.NoError(t, err) + data, err := os.ReadFile(headers[1].Path) + require.NoError(t, err) + data[len(data)-2] ^= 0xFF // corrupt inside the CRC-covered region + require.NoError(t, os.WriteFile(headers[1].Path, data, 0o644)) + + db = reopenFoldTestDb(t, db, dir) + defer db.CloseDb() + res, err := db.RecoverFoldState() + require.NoError(t, err) + assert.Equal(t, uint64(1), res.BatchSeq) + assert.Equal(t, []uint64{1}, res.ReplayedBatches) +} + +// WAL-off mode: losing the index tail (simulated by wiping entries + meta) is +// fully repaired by manifest replay — the manifests ARE the index redo log. +func TestRecoverFoldStateRebuildsIndexTailWithWALOff(t *testing.T) { + db, dir := newFoldTestDb(t) + db.IndexWALDisabled = true + + a1 := foldAcct(1, 11, []byte("one")) + a2 := foldAcct(2, 22, []byte("two")) + _, err := db.CommitBatch(foldDeltas( + accounts.SlotDelta{Slot: 101, Delta: []*accounts.Account{a1}}, + ), 105, map[uint64][32]byte{105: bh(105)}, []byte("ctx-105")) + require.NoError(t, err) + _, err = db.CommitBatch(foldDeltas( + accounts.SlotDelta{Slot: 107, Delta: []*accounts.Account{a2}}, + ), 110, map[uint64][32]byte{110: bh(110)}, []byte("ctx-110")) + require.NoError(t, err) + + // Simulate the lost memtable tail: wipe both entries and the meta. + require.NoError(t, db.Index.Delete(metaKeyLastBatch, pebble.Sync)) + require.NoError(t, db.Index.Delete(a1.Key[:], pebble.Sync)) + require.NoError(t, db.Index.Delete(a2.Key[:], pebble.Sync)) + + db = reopenFoldTestDb(t, db, dir) + defer db.CloseDb() + res, err := db.RecoverFoldState() + require.NoError(t, err) + assert.Equal(t, []uint64{1, 2}, res.ReplayedBatches) + assert.Equal(t, uint64(110), res.DurableThrough) + assert.Equal(t, []byte("ctx-110"), res.ResumeCtx) + + got1 := mustColdRead(t, db, 110, solana.PublicKey{1}) + assert.Equal(t, uint64(11), got1.Lamports) + got2 := mustColdRead(t, db, 110, solana.PublicKey{2}) + assert.Equal(t, uint64(22), got2.Lamports) +} + +// Fresh store: no manifests, no meta — recovery reports a clean zero state. +func TestRecoverFoldStateFreshStore(t *testing.T) { + db, _ := newFoldTestDb(t) + defer db.CloseDb() + res, err := db.RecoverFoldState() + require.NoError(t, err) + assert.Zero(t, res.DurableThrough) + assert.Zero(t, res.BatchSeq) + assert.Nil(t, res.ResumeCtx) + assert.Empty(t, res.ReplayedBatches) +} + +// FileIds are never reused across a crash (I7): the high-water mark persists +// before any data byte, so an aborted fold still consumes its fileId. +func TestCommitBatchNeverReusesFileIdAfterCrash(t *testing.T) { + db, dir := newFoldTestDb(t) + + db.foldHooks.afterSegmentFsync = func() { panic("crash before manifest") } + func() { + defer func() { _ = recover() }() + _, _ = db.CommitBatch(foldDeltas( + accounts.SlotDelta{Slot: 101, Delta: []*accounts.Account{foldAcct(1, 1, nil)}}, + ), 105, map[uint64][32]byte{105: bh(105)}, nil) + }() + db.foldHooks.afterSegmentFsync = nil + + db = reopenFoldTestDb(t, db, dir) + defer db.CloseDb() + _, err := db.RecoverFoldState() + require.NoError(t, err) + + res, err := db.CommitBatch(foldDeltas( + accounts.SlotDelta{Slot: 106, Delta: []*accounts.Account{foldAcct(1, 2, nil)}}, + ), 110, map[uint64][32]byte{110: bh(110)}, nil) + require.NoError(t, err) + assert.GreaterOrEqual(t, res.FileId, uint64(2), "aborted fold's fileId must stay consumed") +} diff --git a/pkg/accountsdb/recovery.go b/pkg/accountsdb/recovery.go new file mode 100644 index 000000000..cac4a38e5 --- /dev/null +++ b/pkg/accountsdb/recovery.go @@ -0,0 +1,302 @@ +package accountsdb + +import ( + "encoding/binary" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/Overclock-Validator/mithril/pkg/mlog" +) + +// RecoveryResult reports the durable fold state derived from the store itself +// (manifests + index meta), independent of the state file — which is only +// written on graceful shutdown and may be stale after a hard crash. +type RecoveryResult struct { + // DurableThrough is R: the highest slot whose fold is durably committed. + // Zero with BatchSeq zero means a fresh (never-folded) store. + DurableThrough uint64 + BatchSeq uint64 + // RootedBankhash is the bankhash at DurableThrough (from its manifest); + // zero when no fold manifest covers R (fresh store). + RootedBankhash [32]byte + // ResumeCtx is the opaque serialized resume context at DurableThrough + // carried by its manifest; nil on a fresh store. + ResumeCtx []byte + // ReplayedBatches lists BatchSeqs whose index flip was completed during + // recovery (crash matrix C3/C4': manifest durable, index tail lost). + ReplayedBatches []uint64 + // OrphansRemoved lists files deleted as undecided leftovers (segments + // without a durable manifest, tmp manifests, torn manifests). + OrphansRemoved []string + // RewindInProgress is true when parked ".rewound" manifests remain — a + // RewindToBatchBoundary was interrupted. The store may already sit at the + // rewound boundary (index meta rolled back) while the state file still names + // the pre-rewind slot; the caller adopts the store's boundary rather than + // treating the intended rewind as data loss. + RewindInProgress bool +} + +// RecoverFoldState brings the index to the durable fold frontier: +// +// 1. read the fold meta (index watermark) +// 2. scan fold manifests; REPLAY the contiguous BatchSeq run above the +// watermark into the index (verifying segment length + CRC first) +// 3. stop at the first gap or torn manifest; everything above the stop point +// is undecided — delete those manifests + segments as orphans +// 4. orphan-GC data files newer than the bootstrap high-water mark that have +// no manifest (crash before the manifest rename: matrix C1/C2) +// +// It returns the recovered watermark plus the manifest-carried bankhash and +// resume context at R, which the caller reconciles against the state file. +func (db *AccountsDb) RecoverFoldState() (RecoveryResult, error) { + db.foldMu.Lock() + defer db.foldMu.Unlock() + + res := RecoveryResult{} + + meta, haveMeta, err := db.readFoldMeta() + if err != nil { + return res, err + } + + headers, err := ListFoldManifests(db.AcctsDir) + if err != nil { + return res, err + } + bySeq := make(map[uint64]ManifestHeader, len(headers)) + for _, h := range headers { + if h.BatchSeq == 0 { // unparseable header: quarantine below via orphan pass + res.OrphansRemoved = append(res.OrphansRemoved, h.Path) + if rerr := os.Remove(h.Path); rerr != nil && !os.IsNotExist(rerr) { + return res, rerr + } + continue + } + bySeq[h.BatchSeq] = h + } + + // The applied frontier: meta if present, else fresh (seq 0). + frontier := foldMeta{} + if haveMeta { + frontier = meta + } + + // Replay the contiguous run above the frontier. + stopSeq := frontier.BatchSeq // last VALID seq (inclusive) + for seq := frontier.BatchSeq + 1; ; seq++ { + hdr, ok := bySeq[seq] + if !ok { + break + } + manifest, verr := db.verifyAndReadManifest(hdr) + if verr != nil { + mlog.Log.Warnf("accountsdb: fold manifest seq %d failed verification (%v); treating batch and everything above as undecided", seq, verr) + break + } + if err := db.applyManifestToIndex(manifest); err != nil { + return res, fmt.Errorf("accountsdb: replay fold manifest seq %d: %w", seq, err) + } + db.storeManifestBankhashes(manifest) + res.ReplayedBatches = append(res.ReplayedBatches, seq) + stopSeq = seq + } + + // Everything above the stop point is undecided: remove manifests + data. + for seq, hdr := range bySeq { + if seq <= stopSeq { + continue + } + dataPath := filepath.Join(db.AcctsDir, SegmentDataName(hdr.ThroughSlot, hdr.FileId)) + for _, p := range []string{hdr.Path, dataPath} { + if rerr := os.Remove(p); rerr != nil && !os.IsNotExist(rerr) { + return res, rerr + } + res.OrphansRemoved = append(res.OrphansRemoved, p) + } + } + + // Orphan data files: newer than bootstrap, no manifest of any kind. + orphans, err := db.removeUnreferencedDataFiles() + if err != nil { + return res, err + } + res.OrphansRemoved = append(res.OrphansRemoved, orphans...) + + // Stale ".manifest.tmp" files: a crash between the manifest tmp-write and + // its rename. The rename is the commit point, so a leftover tmp is never + // valid — unlink it (runs under foldMu, before any fold can start). + tmps, err := db.removeStaleTmpManifests() + if err != nil { + return res, err + } + res.OrphansRemoved = append(res.OrphansRemoved, tmps...) + + // Resolve the final frontier's manifest for bankhash + resume context. + if stopSeq > 0 { + if hdr, ok := bySeq[stopSeq]; ok { + if manifest, err := ReadSegmentManifest(hdr.Path); err == nil { + res.DurableThrough = manifest.ThroughSlot + res.BatchSeq = manifest.BatchSeq + res.ResumeCtx = manifest.ResumeCtx + for _, bh := range manifest.Bankhashes { + if bh.Slot == manifest.ThroughSlot { + res.RootedBankhash = bh.Bankhash + } + } + } else { + // Meta says this seq committed but its manifest is unreadable: + // the index is still authoritative; report the watermark alone. + mlog.Log.Warnf("accountsdb: manifest for committed fold seq %d unreadable (%v); resume context unavailable from store", stopSeq, err) + res.DurableThrough = hdr.ThroughSlot + res.BatchSeq = hdr.BatchSeq + } + } else if haveMeta && stopSeq == meta.BatchSeq { + // Committed long ago and its manifest already GC'd (post-horizon). + res.DurableThrough = meta.ThroughSlot + res.BatchSeq = meta.BatchSeq + } + } + + // Parked ".rewound" manifests mean a RewindToBatchBoundary was interrupted: + // the store may already sit at the rewound boundary while the state file + // still names the pre-rewind slot. The caller uses this to complete the + // reconciliation instead of condemning the store as data loss. + res.RewindInProgress = hasParkedRewindManifests(db.AcctsDir) + + db.lastBatchSeq = res.BatchSeq + db.durableThrough.Store(res.DurableThrough) + return res, nil +} + +// verifyAndReadManifest fully reads a manifest and checks its segment data file +// exists with the recorded length and CRC. +func (db *AccountsDb) verifyAndReadManifest(hdr ManifestHeader) (*SegmentManifest, error) { + manifest, err := ReadSegmentManifest(hdr.Path) + if err != nil { + return nil, err + } + dataPath := filepath.Join(db.AcctsDir, SegmentDataName(manifest.ThroughSlot, manifest.FileId)) + crc, length, err := crcOfFile(dataPath) + if err != nil { + return nil, fmt.Errorf("segment data unreadable: %w", err) + } + if uint64(length) != manifest.DataLen { + return nil, fmt.Errorf("segment data length %d != manifest %d", length, manifest.DataLen) + } + if crc != manifest.DataCRC { + return nil, fmt.Errorf("segment data crc mismatch") + } + return manifest, nil +} + +func (db *AccountsDb) storeManifestBankhashes(m *SegmentManifest) { + for _, bh := range m.Bankhashes { + var slotBytes [8]byte + binary.LittleEndian.PutUint64(slotBytes[:], bh.Slot) + if err := db.BankHashStore.Set(slotBytes[:], bh.Bankhash[:], nil); err != nil { + mlog.Log.Warnf("accountsdb: recovery bankhash store slot %d: %v", bh.Slot, err) + } + } +} + +// bootstrapHighFileId reads the write-once sidecar recorded by snapshot build. +// Missing sidecar (pre-upgrade store) returns MaxUint64: every manifest-less +// file is then classified as bootstrap — a space leak at worst, never a +// correctness issue. +func (db *AccountsDb) bootstrapHighFileId() uint64 { + path := filepath.Join(filepath.Dir(db.AcctsDir), "bootstrap_high_file_id") + data, err := os.ReadFile(path) + if err != nil || len(data) < 8 { + return ^uint64(0) + } + return binary.LittleEndian.Uint64(data[:8]) +} + +// removeUnreferencedDataFiles deletes data files with fileId above the +// bootstrap high-water mark that no manifest (fold, compact, or rewound) +// references — segments whose commit never got decided (crash matrix C1/C2). +func (db *AccountsDb) removeUnreferencedDataFiles() ([]string, error) { + bootstrapHigh := db.bootstrapHighFileId() + if bootstrapHigh == ^uint64(0) { + return nil, nil + } + referenced, err := listAllManifestFileIds(db.AcctsDir) + if err != nil { + return nil, err + } + entries, err := os.ReadDir(db.AcctsDir) + if err != nil { + return nil, err + } + var removed []string + for _, e := range entries { + name := e.Name() + if e.IsDir() || filepath.Ext(name) != "" && !isDataFileName(name) { + continue + } + _, fileId, ok := parseDataFileName(name) + if !ok || fileId <= bootstrapHigh { + continue + } + if _, ok := referenced[fileId]; ok { + continue + } + p := filepath.Join(db.AcctsDir, name) + if rerr := os.Remove(p); rerr != nil && !os.IsNotExist(rerr) { + return removed, rerr + } + mlog.Log.Warnf("accountsdb: removed orphan segment %s (no manifest — commit never decided)", name) + removed = append(removed, p) + } + return removed, nil +} + +// isDataFileName reports whether name looks like "." (both +// numeric). Appendvec/segment files match; manifests and sidecars do not. +func isDataFileName(name string) bool { + _, _, ok := parseDataFileName(name) + return ok +} + +// hasParkedRewindManifests reports whether any ".rewound" manifest remains — an +// interrupted RewindToBatchBoundary. Re-running the rewind (or the startup +// reconcile) completes it; the leftovers are never orphan-GC'd. +func hasParkedRewindManifests(acctsDir string) bool { + entries, err := os.ReadDir(acctsDir) + if err != nil { + return false + } + for _, e := range entries { + if !e.IsDir() && strings.HasSuffix(e.Name(), segManifestRewoundSuffix) { + return true + } + } + return false +} + +// removeStaleTmpManifests unlinks leftover ".manifest.tmp" files. The manifest +// tmp-write is not the commit point (the rename is), so a tmp left behind by an +// interrupted WriteSegmentManifest is always discardable — its data segment, +// if any, is handled as an orphan by removeUnreferencedDataFiles. +func (db *AccountsDb) removeStaleTmpManifests() ([]string, error) { + entries, err := os.ReadDir(db.AcctsDir) + if err != nil { + return nil, err + } + var removed []string + for _, e := range entries { + name := e.Name() + if e.IsDir() || !strings.HasSuffix(name, segManifestTmpSuffix) { + continue + } + p := filepath.Join(db.AcctsDir, name) + if rerr := os.Remove(p); rerr != nil && !os.IsNotExist(rerr) { + return removed, rerr + } + mlog.Log.Warnf("accountsdb: removed stale manifest tmp %s (interrupted write)", name) + removed = append(removed, p) + } + return removed, nil +} diff --git a/pkg/accountsdb/redolog.go b/pkg/accountsdb/redolog.go deleted file mode 100644 index f17639fdd..000000000 --- a/pkg/accountsdb/redolog.go +++ /dev/null @@ -1,196 +0,0 @@ -package accountsdb - -import ( - "bytes" - "encoding/binary" - "fmt" - "hash/crc32" - "os" - "path/filepath" - "slices" - "strconv" - "strings" - - "github.com/Overclock-Validator/mithril/pkg/accounts" - bin "github.com/gagliardetto/binary" -) - -// The redo log makes a per-slot commit crash-safe: accounts are staged here durably -// before apply, and recovery re-applies the record (idempotent — absolute values) -// after a mid-apply crash. Deleted only once the superseding store is durable. -const ( - redoDirName = "redo" - redoFileSuffix = ".redo" - redoMagic = 0x4d52444f // "MRDO" - redoVersion = 1 -) - -// ErrTornRedo means a redo file failed its checksum — a partial/torn write left -// by a crash during WriteRedo. It names no committed state and is discarded. -var ErrTornRedo = fmt.Errorf("accountsdb: torn redo file (checksum mismatch)") - -func redoDir(acctsDir string) string { return filepath.Join(acctsDir, redoDirName) } - -func redoPath(acctsDir string, slot uint64) string { - return filepath.Join(redoDir(acctsDir), strconv.FormatUint(slot, 10)+redoFileSuffix) -} - -// WriteRedo durably stages slot's accounts + bankhash as a crc32-framed commit -// record via temp-write + fsync + rename + dir fsync. Nil entries are skipped. -func WriteRedo(acctsDir string, slot uint64, bankhash []byte, accts []*accounts.Account) error { - dir := redoDir(acctsDir) - if err := os.MkdirAll(dir, 0o755); err != nil { - return fmt.Errorf("accountsdb: mkdir redo: %w", err) - } - - live := make([]*accounts.Account, 0, len(accts)) - for _, a := range accts { - if a != nil { - live = append(live, a) - } - } - - var body bytes.Buffer - enc := bin.NewBinEncoder(&body) - _ = enc.WriteUint32(redoMagic, bin.LE) - _ = enc.WriteUint32(redoVersion, bin.LE) - _ = enc.WriteUint64(slot, bin.LE) - _ = enc.WriteUint32(uint32(len(bankhash)), bin.LE) - _ = enc.WriteBytes(bankhash, false) - _ = enc.WriteUint64(uint64(len(live)), bin.LE) - for _, a := range live { - if err := a.MarshalWithEncoder(enc); err != nil { - return fmt.Errorf("accountsdb: encode redo acct: %w", err) - } - } - var crcBuf [4]byte - binary.LittleEndian.PutUint32(crcBuf[:], crc32.ChecksumIEEE(body.Bytes())) - - tmp := redoPath(acctsDir, slot) + ".tmp" - f, err := os.OpenFile(tmp, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o644) - if err != nil { - return fmt.Errorf("accountsdb: create redo tmp: %w", err) - } - if _, err := f.Write(body.Bytes()); err != nil { - f.Close() - return fmt.Errorf("accountsdb: write redo body: %w", err) - } - if _, err := f.Write(crcBuf[:]); err != nil { - f.Close() - return fmt.Errorf("accountsdb: write redo crc: %w", err) - } - if err := f.Sync(); err != nil { - f.Close() - return fmt.Errorf("accountsdb: fsync redo tmp: %w", err) - } - if err := f.Close(); err != nil { - return fmt.Errorf("accountsdb: close redo tmp: %w", err) - } - if err := os.Rename(tmp, redoPath(acctsDir, slot)); err != nil { - return fmt.Errorf("accountsdb: rename redo: %w", err) - } - return fsyncDir(dir) -} - -// ReadRedo loads a staged redo record (accounts + bankhash), verifying its -// checksum. A torn or malformed file returns ErrTornRedo. -func ReadRedo(acctsDir string, slot uint64) ([]*accounts.Account, []byte, error) { - data, err := os.ReadFile(redoPath(acctsDir, slot)) - if err != nil { - return nil, nil, err - } - if len(data) < 4 { - return nil, nil, ErrTornRedo - } - body, crcBytes := data[:len(data)-4], data[len(data)-4:] - if crc32.ChecksumIEEE(body) != binary.LittleEndian.Uint32(crcBytes) { - return nil, nil, ErrTornRedo - } - - dec := bin.NewBinDecoder(body) - if magic, err := dec.ReadUint32(bin.LE); err != nil || magic != redoMagic { - return nil, nil, ErrTornRedo - } - if ver, err := dec.ReadUint32(bin.LE); err != nil || ver != redoVersion { - return nil, nil, fmt.Errorf("accountsdb: unsupported redo version (err=%v)", err) - } - if gotSlot, err := dec.ReadUint64(bin.LE); err != nil || gotSlot != slot { - return nil, nil, fmt.Errorf("accountsdb: redo slot mismatch (file=%d want=%d err=%v)", gotSlot, slot, err) - } - bankhashLen, err := dec.ReadUint32(bin.LE) - if err != nil || uint64(bankhashLen) > uint64(dec.Remaining()) { - return nil, nil, ErrTornRedo - } - bankhash, err := dec.ReadNBytes(int(bankhashLen)) - if err != nil { - return nil, nil, ErrTornRedo - } - count, err := dec.ReadUint64(bin.LE) - if err != nil { - return nil, nil, ErrTornRedo - } - out := make([]*accounts.Account, 0, count) - for range count { - var a accounts.Account - if err := a.UnmarshalWithDecoder(dec); err != nil { - return nil, nil, ErrTornRedo - } - out = append(out, &a) - } - return out, bankhash, nil -} - -// ListPendingRedo returns, ascending, the slots that have staged redo files — -// an interrupted commit to recover on startup. -func ListPendingRedo(acctsDir string) ([]uint64, error) { - entries, err := os.ReadDir(redoDir(acctsDir)) - if err != nil { - if os.IsNotExist(err) { - return nil, nil - } - return nil, err - } - var slots []uint64 - for _, e := range entries { - name := e.Name() - if e.IsDir() || !strings.HasSuffix(name, redoFileSuffix) { - continue // skip *.tmp and anything else - } - s, err := strconv.ParseUint(strings.TrimSuffix(name, redoFileSuffix), 10, 64) - if err != nil { - continue - } - slots = append(slots, s) - } - slices.Sort(slots) - return slots, nil -} - -// quarantineRedo renames a torn/unreadable redo aside (.corrupt) so recovery can -// continue without re-listing it, while preserving it for inspection. -func quarantineRedo(acctsDir string, slot uint64) error { - src := redoPath(acctsDir, slot) - if err := os.Rename(src, src+".corrupt"); err != nil && !os.IsNotExist(err) { - return err - } - return fsyncDir(redoDir(acctsDir)) -} - -// DeleteRedo removes a finalized slot's redo file and fsyncs the directory. -func DeleteRedo(acctsDir string, slot uint64) error { - if err := os.Remove(redoPath(acctsDir, slot)); err != nil && !os.IsNotExist(err) { - return err - } - return fsyncDir(redoDir(acctsDir)) -} - -// fsyncDir flushes a directory entry change to stable storage. Required on Linux: -// fsync of a file does not make its directory entry durable. -func fsyncDir(dir string) error { - d, err := os.Open(dir) - if err != nil { - return err - } - defer d.Close() - return d.Sync() -} diff --git a/pkg/accountsdb/redolog_test.go b/pkg/accountsdb/redolog_test.go deleted file mode 100644 index 96ef5512e..000000000 --- a/pkg/accountsdb/redolog_test.go +++ /dev/null @@ -1,143 +0,0 @@ -package accountsdb - -import ( - "bytes" - "os" - "path/filepath" - "testing" - - "github.com/Overclock-Validator/mithril/pkg/accounts" - "github.com/gagliardetto/solana-go" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func redoAcct(b byte, lamports uint64, data []byte) *accounts.Account { - return &accounts.Account{ - Slot: 100, - Key: solana.PublicKey{b}, - Lamports: lamports, - Data: data, - Owner: [32]byte{b, b}, - Executable: b%2 == 0, - RentEpoch: uint64(b), - } -} - -func assertAcctEqual(t *testing.T, want, got *accounts.Account) { - t.Helper() - assert.Equal(t, want.Key, got.Key) - assert.Equal(t, want.Lamports, got.Lamports) - assert.Equal(t, want.Owner, got.Owner) - assert.Equal(t, want.Executable, got.Executable) - assert.Equal(t, want.RentEpoch, got.RentEpoch) - assert.Equal(t, want.Slot, got.Slot) - assert.True(t, bytes.Equal(want.Data, got.Data), "data mismatch for %s", want.Key) -} - -// Round-trip across varied account shapes plus the bankhash. -func TestRedoRoundTrip(t *testing.T) { - dir := t.TempDir() - accts := []*accounts.Account{ - redoAcct(1, 1000, []byte("hello")), - redoAcct(2, 0, nil), // zero lamports, no data - redoAcct(3, 1<<40, bytes.Repeat([]byte{0xAB}, 4096)), // large data - } - require.NoError(t, WriteRedo(dir, 100, []byte("bankhash-100"), accts)) - - got, bankhash, err := ReadRedo(dir, 100) - require.NoError(t, err) - require.Len(t, got, len(accts)) - assert.Equal(t, []byte("bankhash-100"), bankhash) - for i := range accts { - assertAcctEqual(t, accts[i], got[i]) - } -} - -// Nil account entries are skipped, not panicked on (callers may pass sparse slices). -func TestRedoSkipsNilEntries(t *testing.T) { - dir := t.TempDir() - accts := []*accounts.Account{redoAcct(1, 5, []byte("x")), nil, redoAcct(2, 9, nil)} - require.NotPanics(t, func() { - require.NoError(t, WriteRedo(dir, 50, []byte("bh"), accts)) - }) - got, _, err := ReadRedo(dir, 50) - require.NoError(t, err) - assert.Len(t, got, 2, "nil filtered out") -} - -// An empty modified-account set still round-trips (a real slot can touch only sysvars). -func TestRedoEmpty(t *testing.T) { - dir := t.TempDir() - require.NoError(t, WriteRedo(dir, 7, []byte("bh7"), nil)) - got, bankhash, err := ReadRedo(dir, 7) - require.NoError(t, err) - assert.Empty(t, got) - assert.Equal(t, []byte("bh7"), bankhash) -} - -// A corrupted checksum (torn write) is detected, not silently accepted. -func TestRedoTornChecksumDetected(t *testing.T) { - dir := t.TempDir() - require.NoError(t, WriteRedo(dir, 100, []byte("bh"), []*accounts.Account{redoAcct(1, 5, []byte("x"))})) - - p := redoPath(dir, 100) - data, err := os.ReadFile(p) - require.NoError(t, err) - data[len(data)-1] ^= 0xFF // flip a crc byte - require.NoError(t, os.WriteFile(p, data, 0o644)) - - _, _, err = ReadRedo(dir, 100) - assert.ErrorIs(t, err, ErrTornRedo) -} - -// A truncated file (crash mid-write) is detected as torn. -func TestRedoTruncatedDetected(t *testing.T) { - dir := t.TempDir() - require.NoError(t, WriteRedo(dir, 100, []byte("bh"), []*accounts.Account{redoAcct(1, 5, bytes.Repeat([]byte{1}, 200))})) - - p := redoPath(dir, 100) - data, err := os.ReadFile(p) - require.NoError(t, err) - require.NoError(t, os.WriteFile(p, data[:len(data)/2], 0o644)) // chop the tail - - _, _, err = ReadRedo(dir, 100) - assert.ErrorIs(t, err, ErrTornRedo) -} - -// Pending redo slots are listed ascending; finalized (deleted) ones disappear. -func TestRedoListAndDelete(t *testing.T) { - dir := t.TempDir() - for _, s := range []uint64{5, 3, 9} { - require.NoError(t, WriteRedo(dir, s, []byte("bh"), []*accounts.Account{redoAcct(1, s, nil)})) - } - pending, err := ListPendingRedo(dir) - require.NoError(t, err) - assert.Equal(t, []uint64{3, 5, 9}, pending) - - require.NoError(t, DeleteRedo(dir, 5)) - pending, err = ListPendingRedo(dir) - require.NoError(t, err) - assert.Equal(t, []uint64{3, 9}, pending) - - _, _, err = ReadRedo(dir, 5) - assert.Error(t, err, "deleted redo cannot be read") -} - -// ListPendingRedo on a missing redo dir is empty, not an error. -func TestRedoListNoDir(t *testing.T) { - pending, err := ListPendingRedo(t.TempDir()) - require.NoError(t, err) - assert.Empty(t, pending) -} - -// WriteRedo leaves no .tmp file behind (rename completed). -func TestRedoNoTmpLeftover(t *testing.T) { - dir := t.TempDir() - require.NoError(t, WriteRedo(dir, 100, []byte("bh"), []*accounts.Account{redoAcct(1, 5, nil)})) - entries, err := os.ReadDir(redoDir(dir)) - require.NoError(t, err) - for _, e := range entries { - assert.False(t, filepath.Ext(e.Name()) == ".tmp", "stray tmp file: %s", e.Name()) - } -} diff --git a/pkg/accountsdb/rewind.go b/pkg/accountsdb/rewind.go new file mode 100644 index 000000000..cb0273c71 --- /dev/null +++ b/pkg/accountsdb/rewind.go @@ -0,0 +1,302 @@ +package accountsdb + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/Overclock-Validator/mithril/pkg/mlog" + "github.com/cockroachdb/pebble" +) + +// Rewind support (types + discovery). RewindToBatchBoundary — applying undo +// pointers in reverse to restore the index to an earlier batch boundary — +// lands with the compactor, which shares the pin rule that keeps every +// in-horizon Prev target alive. + +// RewindPoint is a batch boundary the store can be rewound to: every fold +// manifest still on disk is one (its undo pointers and their target files are +// retained until GC'd past the rewind horizon). +type RewindPoint struct { + BatchSeq uint64 + ThroughSlot uint64 + Bankhash [32]byte +} + +// ListRewindPoints returns the available batch boundaries, ascending. Only +// committed batches qualify (BatchSeq <= the fold meta watermark). +func (db *AccountsDb) ListRewindPoints() ([]RewindPoint, error) { + db.foldMu.Lock() + defer db.foldMu.Unlock() + + meta, haveMeta, err := db.readFoldMeta() + if err != nil { + return nil, err + } + if !haveMeta { + return nil, nil + } + headers, err := ListFoldManifests(db.AcctsDir) + if err != nil { + return nil, err + } + out := make([]RewindPoint, 0, len(headers)) + for _, h := range headers { + if h.BatchSeq == 0 || h.BatchSeq > meta.BatchSeq { + continue + } + pt := RewindPoint{BatchSeq: h.BatchSeq, ThroughSlot: h.ThroughSlot} + if m, err := ReadSegmentManifest(h.Path); err == nil { + for _, bh := range m.Bankhashes { + if bh.Slot == m.ThroughSlot { + pt.Bankhash = bh.Bankhash + } + } + } + out = append(out, pt) + } + return out, nil +} + +// RewindResult reports a completed rewind. +type RewindResult struct { + NewThrough uint64 + ResumeCtx []byte // manifest-carried resume context at the target boundary + UndoneBatches int + UndoneKeys int +} + +// manifestPathEither returns the manifest path for a segment, accepting the +// parked ".rewound" form (an interrupted rewind's leftovers) so re-running a +// rewind resumes cleanly. +func (db *AccountsDb) manifestPathEither(throughSlot, fileId uint64) (string, bool) { + p := segmentManifestPath(db.AcctsDir, throughSlot, fileId) + if _, err := os.Stat(p); err == nil { + return p, true + } + rp := p + ".rewound" + // segmentManifestPath ends in ".manifest"; the parked form is ".manifest.rewound". + if _, err := os.Stat(rp); err == nil { + return rp, true + } + return "", false +} + +// finalizeParkedRewindLeftoversLocked moves any parked ".rewound" fold manifests +// (and their segments) ABOVE throughSlot into rewound/ — the Step 3 that an +// interrupted rewind may have skipped after already rolling the meta back to +// this boundary. Idempotent and best-effort: it only clears the leftovers so +// RecoverFoldState stops flagging a rewind in progress. Caller holds foldMu. +func (db *AccountsDb) finalizeParkedRewindLeftoversLocked(throughSlot uint64) { + entries, err := os.ReadDir(db.AcctsDir) + if err != nil { + return + } + rewoundDir := filepath.Join(db.AcctsDir, "rewound") + madeDir := false + moved := false + for _, e := range entries { + name := e.Name() + if e.IsDir() || !strings.HasSuffix(name, segManifestRewoundSuffix) { + continue + } + path := filepath.Join(db.AcctsDir, name) + m, rerr := ReadSegmentManifest(path) + if rerr != nil || m.Kind != ManifestKindFold || m.ThroughSlot <= throughSlot { + continue // keep anything at/below the boundary + } + if !madeDir { + if mkerr := os.MkdirAll(rewoundDir, 0o755); mkerr != nil { + return + } + madeDir = true + } + dataName := SegmentDataName(m.ThroughSlot, m.FileId) + for src, dst := range map[string]string{ + filepath.Join(db.AcctsDir, dataName): filepath.Join(rewoundDir, dataName), + path: filepath.Join(rewoundDir, name), + } { + if merr := os.Rename(src, dst); merr != nil && !os.IsNotExist(merr) { + mlog.Log.Warnf("accountsdb: rewind finalize: could not move %s aside: %v", src, merr) + } + } + moved = true + } + if moved { + _ = fsyncDir(db.AcctsDir) + } +} + +// RewindToBatchBoundary restores the index to the state as of the fold batch +// whose ThroughSlot == throughSlot by applying the undo pointers of every +// LATER fold manifest in reverse order. The store must be quiesced (no +// replay, no folds) — callers are node startup (--rewind-to-slot) and the +// halt-path recovery loop. +// +// Crash-safe and idempotent: suffix manifests are parked as ".rewound" first +// (recovery ignores them; a re-run picks them back up), then ONE index batch +// applies all undo pointers + the meta rollback atomically, then the undone +// segment files move to rewound/ for forensics. +func (db *AccountsDb) RewindToBatchBoundary(throughSlot uint64) (RewindResult, error) { + db.foldMu.Lock() + defer db.foldMu.Unlock() + + res := RewindResult{} + meta, haveMeta, err := db.readFoldMeta() + if err != nil { + return res, err + } + if !haveMeta { + return res, fmt.Errorf("accountsdb: rewind: store has no fold meta (nothing folded)") + } + if meta.ThroughSlot == throughSlot { + res.NewThrough = throughSlot + if path, ok := db.manifestPathEither(throughSlot, meta.FileId); ok { + if m, rerr := ReadSegmentManifest(path); rerr == nil { + res.ResumeCtx = m.ResumeCtx + } + } + // A prior rewind may have rolled the meta back to this boundary but + // crashed before moving the undone files aside — finalize any leftover + // parked manifests now so recovery stops reporting a rewind in progress. + db.finalizeParkedRewindLeftoversLocked(throughSlot) + return res, nil // already at the target boundary + } + + // Collect every fold manifest (including parked ones) by seq. + type seqManifest struct { + manifest *SegmentManifest + path string + } + bySeq := make(map[uint64]*seqManifest) + entries, err := os.ReadDir(db.AcctsDir) + if err != nil { + return res, err + } + for _, e := range entries { + name := e.Name() + if e.IsDir() { + continue + } + if !strings.HasSuffix(name, segManifestSuffix) && !strings.HasSuffix(name, segManifestRewoundSuffix) { + continue + } + if strings.HasSuffix(name, segManifestTmpSuffix) { + continue + } + m, rerr := ReadSegmentManifest(filepath.Join(db.AcctsDir, name)) + if rerr != nil || m.Kind != ManifestKindFold { + continue + } + bySeq[m.BatchSeq] = &seqManifest{manifest: m, path: filepath.Join(db.AcctsDir, name)} + } + + target, ok := func() (*seqManifest, bool) { + for _, sm := range bySeq { + if sm.manifest.ThroughSlot == throughSlot { + return sm, true + } + } + return nil, false + }() + if !ok { + return res, fmt.Errorf("accountsdb: rewind: no fold boundary at slot %d within the retained horizon (see ListRewindPoints)", throughSlot) + } + seqT := target.manifest.BatchSeq + if seqT >= meta.BatchSeq { + return res, fmt.Errorf("accountsdb: rewind: boundary seq %d is not below the committed head seq %d", seqT, meta.BatchSeq) + } + // Every batch in (seqT, head] must be present to unwind completely. + for seq := seqT + 1; seq <= meta.BatchSeq; seq++ { + if bySeq[seq] == nil { + return res, fmt.Errorf("accountsdb: rewind: fold manifest seq %d missing — cannot unwind past it (horizon GC'd?)", seq) + } + } + + // Step 1: park suffix manifests (ascending) so recovery treats the + // batches as undone even if we crash mid-rewind. + for seq := seqT + 1; seq <= meta.BatchSeq; seq++ { + sm := bySeq[seq] + if strings.HasSuffix(sm.path, segManifestRewoundSuffix) { + continue // already parked by an interrupted rewind + } + parked := sm.path + ".rewound" + if err := os.Rename(sm.path, parked); err != nil { + return res, fmt.Errorf("accountsdb: rewind: park manifest seq %d: %w", seq, err) + } + sm.path = parked + } + if err := fsyncDir(db.AcctsDir); err != nil { + return res, err + } + + // Step 2: ONE index batch applying undo pointers newest-batch-first (a + // key overwritten in several undone batches ends at its pre-suffix + // entry) + the meta rollback. Atomic under Pebble. + batch := db.Index.NewBatch() + defer batch.Close() + var idxBuf [24]byte + for seq := meta.BatchSeq; seq > seqT; seq-- { + m := bySeq[seq].manifest + for i := range m.Records { + r := &m.Records[i] + if r.PrevValid { + r.Prev.Marshal(&idxBuf) + if err := batch.Set(r.Pubkey[:], idxBuf[:], nil); err != nil { + return res, err + } + } else { + if err := batch.Delete(r.Pubkey[:], nil); err != nil { + return res, err + } + } + res.UndoneKeys++ + } + res.UndoneBatches++ + } + if err := batch.Set(metaKeyLastBatch, encodeFoldMeta(foldMeta{ + BatchSeq: seqT, + ThroughSlot: target.manifest.ThroughSlot, + FileId: target.manifest.FileId, + }), nil); err != nil { + return res, err + } + if err := batch.Commit(pebble.Sync); err != nil { + return res, err + } + if db.IndexWALDisabled { + if err := db.Index.Flush(); err != nil { + return res, err + } + } + + // Step 3: move the undone segments + parked manifests aside (forensics). + rewoundDir := filepath.Join(db.AcctsDir, "rewound") + if err := os.MkdirAll(rewoundDir, 0o755); err != nil { + return res, err + } + for seq := seqT + 1; seq <= meta.BatchSeq; seq++ { + sm := bySeq[seq] + dataName := SegmentDataName(sm.manifest.ThroughSlot, sm.manifest.FileId) + for src, dst := range map[string]string{ + filepath.Join(db.AcctsDir, dataName): filepath.Join(rewoundDir, dataName), + sm.path: filepath.Join(rewoundDir, filepath.Base(sm.path)), + } { + if err := os.Rename(src, dst); err != nil && !os.IsNotExist(err) { + mlog.Log.Warnf("accountsdb: rewind: could not move %s aside: %v", src, err) + } + } + } + _ = fsyncDir(db.AcctsDir) + + db.lastBatchSeq = seqT + db.durableThrough.Store(target.manifest.ThroughSlot) + // Read caches may hold folded-then-rewound values; rebuild them. + db.InitCaches() + + res.NewThrough = target.manifest.ThroughSlot + res.ResumeCtx = target.manifest.ResumeCtx + mlog.Log.Warnf("accountsdb: REWOUND %d fold batch(es) (%d keys) — durable state restored to slot %d", res.UndoneBatches, res.UndoneKeys, res.NewThrough) + return res, nil +} diff --git a/pkg/accountsdb/rewind_compact_test.go b/pkg/accountsdb/rewind_compact_test.go new file mode 100644 index 000000000..f6818a59e --- /dev/null +++ b/pkg/accountsdb/rewind_compact_test.go @@ -0,0 +1,439 @@ +package accountsdb + +import ( + "bytes" + "fmt" + "os" + "path/filepath" + "testing" + + "github.com/Overclock-Validator/mithril/pkg/accounts" + "github.com/cockroachdb/pebble" + "github.com/gagliardetto/solana-go" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// An interrupted RewindToBatchBoundary — suffix manifests parked and the index +// meta rolled back, but the state file not yet updated — is detected by +// recovery (RewindInProgress) and surfaces the rewound boundary's context, so +// startup completes the rewind instead of condemning the store as data loss. +func TestRecoveryDetectsInterruptedRewind(t *testing.T) { + db, dir := newFoldTestDb(t) + r1 := commitTestBatch(t, db, 110, foldAcct(1, 100, []byte("v1"))) + r2 := commitTestBatch(t, db, 120, foldAcct(1, 111, []byte("v2"))) + r3 := commitTestBatch(t, db, 130, foldAcct(1, 122, []byte("v3"))) + + // Simulate the interruption: park the suffix manifests and roll the index + // meta back to batch 1 (slot 110), as the rewind's atomic step would, but + // die before moving segments / updating the state file. + for _, r := range []BatchCommitResult{r2, r3} { + p := segmentManifestPath(db.AcctsDir, r.ThroughSlot, r.FileId) + require.NoError(t, os.Rename(p, p+".rewound")) + } + require.NoError(t, db.Index.Set(metaKeyLastBatch, + encodeFoldMeta(foldMeta{BatchSeq: 1, ThroughSlot: 110, FileId: r1.FileId}), pebble.Sync)) + + db = reopenFoldTestDb(t, db, dir) + defer db.CloseDb() + rec, err := db.RecoverFoldState() + require.NoError(t, err) + assert.True(t, rec.RewindInProgress, "parked .rewound manifests must be detected") + assert.Equal(t, uint64(110), rec.DurableThrough, "store sits at the rewound boundary") + assert.Equal(t, []byte("ctx-110"), rec.ResumeCtx, "the rewound boundary's context is available for reconcile") + // The parked suffix segments are NOT orphan-GC'd (a re-run can still use them). + assert.FileExists(t, filepath.Join(db.AcctsDir, SegmentDataName(130, r3.FileId))) + + // Completion is idempotent: rewinding to the boundary the store already sits + // at finalizes the parked leftovers, so RewindInProgress clears. + res, err := db.RewindToBatchBoundary(110) + require.NoError(t, err) + assert.Equal(t, uint64(110), res.NewThrough) + assert.Equal(t, []byte("ctx-110"), res.ResumeCtx) + rec2, err := db.RecoverFoldState() + require.NoError(t, err) + assert.False(t, rec2.RewindInProgress, "early-return finalized the parked leftovers") +} + +// Crash after Step 1 (park) but BEFORE Step 2 (the atomic meta rollback): the +// meta still names the head, so the store never moved — completing the rewind +// must run the full rollback to the target boundary and clear the parked state. +func TestRewindCompletesAfterParkOnlyInterruption(t *testing.T) { + db, dir := newFoldTestDb(t) + commitTestBatch(t, db, 110, foldAcct(1, 100, []byte("v1"))) + r2 := commitTestBatch(t, db, 120, foldAcct(1, 111, []byte("v2"))) + r3 := commitTestBatch(t, db, 130, foldAcct(1, 122, []byte("v3"))) + + // Park the suffix manifests but leave the index meta at head (130). + for _, r := range []BatchCommitResult{r2, r3} { + p := segmentManifestPath(db.AcctsDir, r.ThroughSlot, r.FileId) + require.NoError(t, os.Rename(p, p+".rewound")) + } + + db = reopenFoldTestDb(t, db, dir) + defer db.CloseDb() + rec, err := db.RecoverFoldState() + require.NoError(t, err) + require.True(t, rec.RewindInProgress, "parked manifests detected even though meta was not rolled back") + + // Complete to boundary 110 (the highest non-parked boundary): full rollback. + res, err := db.RewindToBatchBoundary(110) + require.NoError(t, err) + assert.Equal(t, uint64(110), res.NewThrough) + assert.Equal(t, []byte("ctx-110"), res.ResumeCtx) + assert.Equal(t, uint64(100), mustColdRead(t, db, 110, solana.PublicKey{1}).Lamports, "index rolled back to the boundary") + + rec2, err := db.RecoverFoldState() + require.NoError(t, err) + assert.False(t, rec2.RewindInProgress, "parked manifests cleared after completion") + meta, ok, err := db.readFoldMeta() + require.NoError(t, err) + require.True(t, ok) + assert.Equal(t, uint64(110), meta.ThroughSlot) +} + +// snapshotIndex captures the full account index (pubkey -> entry), excluding +// the fold meta row, for bit-exact before/after comparisons. +func snapshotIndex(t *testing.T, db *AccountsDb) map[[32]byte]AccountIndexEntry { + t.Helper() + iter, err := db.Index.NewIter(nil) + require.NoError(t, err) + defer iter.Close() + out := make(map[[32]byte]AccountIndexEntry) + for iter.First(); iter.Valid(); iter.Next() { + k := iter.Key() + if len(k) != 32 { + continue // fold meta row + } + var key [32]byte + copy(key[:], k) + e, uerr := UnmarshalAcctIdxEntry(iter.Value()) + require.NoError(t, uerr) + out[key] = *e + } + return out +} + +func commitTestBatch(t *testing.T, db *AccountsDb, through uint64, delta ...*accounts.Account) BatchCommitResult { + t.Helper() + res, err := db.CommitBatch( + foldDeltas(accounts.SlotDelta{Slot: through, Delta: delta}), + through, + map[uint64][32]byte{through: bh(through)}, + []byte(fmt.Sprintf("ctx-%d", through)), + ) + require.NoError(t, err) + return res +} + +// Rewind restores the index bit-exactly to each earlier batch boundary +// (compared against snapshots taken when the boundary was the head), returns +// the boundary's manifest context, and rolls the fold meta back. +func TestRewindRoundTripAgainstShadowSnapshots(t *testing.T) { + db, _ := newFoldTestDb(t) + defer db.CloseDb() + + type boundary struct { + through uint64 + index map[[32]byte]AccountIndexEntry + } + var boundaries []boundary + snap := func(through uint64) { + boundaries = append(boundaries, boundary{through, snapshotIndex(t, db)}) + } + + commitTestBatch(t, db, 110, foldAcct(1, 100, []byte("a1")), foldAcct(2, 200, []byte("b1")), foldAcct(3, 300, []byte("c1"))) + snap(110) + commitTestBatch(t, db, 120, foldAcct(1, 111, []byte("a2-longer")), foldAcct(4, 400, nil)) + snap(120) + commitTestBatch(t, db, 130, foldAcct(2, 222, []byte("b3")), foldAcct(4, 444, []byte("d3"))) + snap(130) + commitTestBatch(t, db, 140, foldAcct(1, 133, []byte("a4")), foldAcct(5, 500, []byte("e4"))) + snap(140) + + // Boundary-by-boundary, newest-first. + for i := len(boundaries) - 2; i >= 0; i-- { + want := boundaries[i] + res, err := db.RewindToBatchBoundary(want.through) + require.NoError(t, err) + assert.Equal(t, want.through, res.NewThrough) + assert.Equal(t, []byte(fmt.Sprintf("ctx-%d", want.through)), res.ResumeCtx, "manifest context at the boundary") + assert.Equal(t, want.index, snapshotIndex(t, db), "index must be bit-equal to the boundary snapshot") + + meta, ok, merr := db.readFoldMeta() + require.NoError(t, merr) + require.True(t, ok) + assert.Equal(t, want.through, meta.ThroughSlot) + } + + // Values as of the oldest boundary; keys born later are gone. + assert.Equal(t, uint64(100), mustColdRead(t, db, 110, solana.PublicKey{1}).Lamports) + assert.Equal(t, uint64(200), mustColdRead(t, db, 110, solana.PublicKey{2}).Lamports) + _, err := db.GetAccount(110, solana.PublicKey{4}) + assert.ErrorIs(t, err, ErrNoAccount, "key first written after the boundary must vanish") + + // Undone segments were parked for forensics, and folding resumes cleanly + // from the rewound boundary (fresh fileIds, contiguous seq). + parked, err := os.ReadDir(filepath.Join(db.AcctsDir, "rewound")) + require.NoError(t, err) + assert.NotEmpty(t, parked) + res := commitTestBatch(t, db, 118, foldAcct(1, 118, []byte("again"))) + assert.Equal(t, uint64(2), res.BatchSeq, "seq continues from the rewound boundary") + assert.Equal(t, uint64(118), mustColdRead(t, db, 118, solana.PublicKey{1}).Lamports) +} + +// A multi-batch rewind in ONE call lands on the same state as the incremental +// path: a key overwritten in several undone batches ends at its pre-suffix entry. +func TestRewindMultiBatchSingleCall(t *testing.T) { + db, _ := newFoldTestDb(t) + defer db.CloseDb() + + commitTestBatch(t, db, 110, foldAcct(1, 100, []byte("v1")), foldAcct(2, 200, []byte("w1"))) + want := snapshotIndex(t, db) + commitTestBatch(t, db, 120, foldAcct(1, 111, []byte("v2"))) + commitTestBatch(t, db, 130, foldAcct(1, 122, []byte("v3")), foldAcct(2, 233, []byte("w3"))) + commitTestBatch(t, db, 140, foldAcct(1, 144, []byte("v4"))) + + res, err := db.RewindToBatchBoundary(110) + require.NoError(t, err) + assert.Equal(t, 3, res.UndoneBatches) + assert.Equal(t, want, snapshotIndex(t, db)) + assert.Equal(t, uint64(100), mustColdRead(t, db, 110, solana.PublicKey{1}).Lamports) + assert.Equal(t, uint64(200), mustColdRead(t, db, 110, solana.PublicKey{2}).Lamports) +} + +// Crash matrix C6: a rewind interrupted right after parking the suffix +// manifests (step 1) must (a) leave recovery unconfused — the committed head +// stays authoritative, nothing is orphan-deleted — and (b) complete when the +// rewind is re-issued, picking the parked manifests back up. +func TestRewindResumesAfterInterruptedParking(t *testing.T) { + db, dir := newFoldTestDb(t) + + commitTestBatch(t, db, 110, foldAcct(1, 100, []byte("v1"))) + r2 := commitTestBatch(t, db, 120, foldAcct(1, 111, []byte("v2"))) + r3 := commitTestBatch(t, db, 130, foldAcct(1, 122, []byte("v3"))) + + // Simulate the crash: park the suffix manifests exactly as step 1 does, + // then "restart" (reopen + fold recovery), as node startup would. + for _, r := range []BatchCommitResult{r2, r3} { + p := segmentManifestPath(db.AcctsDir, r.ThroughSlot, r.FileId) + require.NoError(t, os.Rename(p, p+".rewound")) + } + db = reopenFoldTestDb(t, db, dir) + defer db.CloseDb() + rec, err := db.RecoverFoldState() + require.NoError(t, err) + assert.Equal(t, uint64(130), rec.DurableThrough, "index meta stays authoritative after park-only crash") + assert.FileExists(t, filepath.Join(db.AcctsDir, SegmentDataName(120, r2.FileId)), "parked batches' segments must not be orphan-GC'd") + assert.FileExists(t, filepath.Join(db.AcctsDir, SegmentDataName(130, r3.FileId))) + + // Re-issued rewind resumes through the parked manifests. + res, err := db.RewindToBatchBoundary(110) + require.NoError(t, err) + assert.Equal(t, uint64(110), res.NewThrough) + assert.Equal(t, 2, res.UndoneBatches) + assert.Equal(t, uint64(100), mustColdRead(t, db, 110, solana.PublicKey{1}).Lamports) + + // Idempotence of completion: rewinding to the same boundary is a no-op + // that still reports the boundary's context. + res2, err := db.RewindToBatchBoundary(110) + require.NoError(t, err) + assert.Equal(t, uint64(110), res2.NewThrough) + assert.Equal(t, 0, res2.UndoneBatches) + assert.Equal(t, []byte("ctx-110"), res2.ResumeCtx) +} + +// Rewind fails closed: non-boundary targets, horizons broken by a missing +// intermediate manifest, and never-folded stores all refuse cleanly. +func TestRewindRefusalCases(t *testing.T) { + db, _ := newFoldTestDb(t) + defer db.CloseDb() + + commitTestBatch(t, db, 110, foldAcct(1, 100, []byte("v1"))) + r2 := commitTestBatch(t, db, 120, foldAcct(1, 111, []byte("v2"))) + commitTestBatch(t, db, 130, foldAcct(1, 122, []byte("v3"))) + + _, err := db.RewindToBatchBoundary(115) + require.ErrorContains(t, err, "no fold boundary", "mid-batch slot is not a boundary") + + require.NoError(t, os.Remove(segmentManifestPath(db.AcctsDir, 120, r2.FileId))) + _, err = db.RewindToBatchBoundary(110) + require.ErrorContains(t, err, "missing", "gap in the undo chain must refuse, not skip") + + fresh, _ := newFoldTestDb(t) + defer fresh.CloseDb() + _, err = fresh.RewindToBatchBoundary(100) + require.ErrorContains(t, err, "no fold meta") +} + +func TestListRewindPointsAscending(t *testing.T) { + db, _ := newFoldTestDb(t) + defer db.CloseDb() + + commitTestBatch(t, db, 110, foldAcct(1, 100, []byte("v1"))) + commitTestBatch(t, db, 120, foldAcct(1, 111, []byte("v2"))) + points, err := db.ListRewindPoints() + require.NoError(t, err) + require.Len(t, points, 2) + assert.Equal(t, uint64(110), points[0].ThroughSlot) + assert.Equal(t, uint64(120), points[1].ThroughSlot) + assert.Equal(t, bh(110), points[0].Bankhash) + assert.Equal(t, bh(120), points[1].Bankhash) +} + +// Compaction: live records move to a fresh manifest-backed output and the +// source (plus its manifest) is deleted; fully-dead files fast-path delete; +// in-horizon segments and undo-pointer targets are pinned untouched; and a +// rewind within the horizon still works afterwards. +func TestCompactMovesLiveRespectsPinsAndRewind(t *testing.T) { + db, _ := newFoldTestDb(t) + defer db.CloseDb() + + big := make([]byte, 4096) + // B1: keys 1,2 big (soon dead), key 3 small (stays live forever). + r1 := commitTestBatch(t, db, 110, foldAcct(1, 100, big), foldAcct(2, 200, big), foldAcct(3, 300, []byte("keep"))) + // B2..B4 rewrite keys 1,2 (B2 ends up fully dead; B3 is B4's undo target). + r2 := commitTestBatch(t, db, 120, foldAcct(1, 101, big), foldAcct(2, 201, big)) + r3 := commitTestBatch(t, db, 130, foldAcct(1, 102, big), foldAcct(2, 202, big)) + r4 := commitTestBatch(t, db, 140, foldAcct(1, 103, big), foldAcct(2, 203, big)) + + // Horizon 1 pins B4 (in horizon) and B3 (named by B4's undo pointers). + stats, err := db.CompactOnce(CompactionConfig{RewindHorizonBatches: 1, MinDeadFraction: 0.5}) + require.NoError(t, err) + assert.Equal(t, 1, stats.FilesCompacted, "B1 has one live record to move") + assert.Equal(t, 1, stats.FilesDeleted, "B2 is fully dead") + assert.Positive(t, stats.BytesReclaimed) + + assert.NoFileExists(t, filepath.Join(db.AcctsDir, SegmentDataName(110, r1.FileId))) + assert.NoFileExists(t, segmentManifestPath(db.AcctsDir, 110, r1.FileId)) + assert.NoFileExists(t, filepath.Join(db.AcctsDir, SegmentDataName(120, r2.FileId))) + assert.NoFileExists(t, segmentManifestPath(db.AcctsDir, 120, r2.FileId)) + assert.FileExists(t, filepath.Join(db.AcctsDir, SegmentDataName(130, r3.FileId)), "undo-pointer target is pinned") + assert.FileExists(t, filepath.Join(db.AcctsDir, SegmentDataName(140, r4.FileId)), "in-horizon head is pinned") + + // The moved key reads back cold from its new, compact-manifest-backed home. + idx := snapshotIndex(t, db) + e3, ok := idx[[32]byte(solana.PublicKey{3})] + require.True(t, ok) + assert.NotEqual(t, r1.FileId, e3.FileId, "index must name the compaction output") + assert.Equal(t, uint64(110), e3.Slot, "filename slot component is preserved") + assert.FileExists(t, filepath.Join(db.AcctsDir, SegmentDataName(110, e3.FileId))) + m, err := ReadSegmentManifest(segmentManifestPath(db.AcctsDir, 110, e3.FileId)) + require.NoError(t, err) + assert.Equal(t, ManifestKindCompact, m.Kind) + got := mustColdRead(t, db, 140, solana.PublicKey{3}) + assert.Equal(t, uint64(300), got.Lamports) + assert.Equal(t, []byte("keep"), got.Data) + assert.Equal(t, uint64(103), mustColdRead(t, db, 140, solana.PublicKey{1}).Lamports, "head values untouched") + + // The pinned horizon kept every file a rewind needs. + res, err := db.RewindToBatchBoundary(130) + require.NoError(t, err) + assert.Equal(t, uint64(130), res.NewThrough) + assert.Equal(t, uint64(102), mustColdRead(t, db, 130, solana.PublicKey{1}).Lamports) + assert.Equal(t, []byte("keep"), mustColdRead(t, db, 130, solana.PublicKey{3}).Data, "compacted key unaffected by rewind") +} + +// Mostly-live files are left to decay; the move budget bounds work per cycle +// while fully-dead deletion stays free. +func TestCompactThresholdAndBudget(t *testing.T) { + db, _ := newFoldTestDb(t) + defer db.CloseDb() + + big := make([]byte, 2048) + // B1: two live keys + one small dead one -> low dead fraction, untouched. + commitTestBatch(t, db, 110, foldAcct(1, 100, big), foldAcct(2, 200, big), foldAcct(3, 300, []byte("x"))) + commitTestBatch(t, db, 120, foldAcct(3, 301, []byte("y"))) + + stats, err := db.CompactOnce(CompactionConfig{RewindHorizonBatches: 1, MinDeadFraction: 0.5}) + require.NoError(t, err) + assert.Zero(t, stats.FilesCompacted, "mostly-live file must not be rewritten") + assert.Zero(t, stats.FilesDeleted) + + // Two compactable files, budget of 1 byte: only the first move lands this + // cycle; the cursor resumes at the second next cycle. + db2, _ := newFoldTestDb(t) + defer db2.CloseDb() + commitTestBatch(t, db2, 110, foldAcct(1, 100, big), foldAcct(4, 400, []byte("live-a"))) + commitTestBatch(t, db2, 120, foldAcct(1, 101, big), foldAcct(5, 500, []byte("live-b"))) + commitTestBatch(t, db2, 130, foldAcct(1, 102, big)) + commitTestBatch(t, db2, 140, foldAcct(1, 103, big)) + + stats, err = db2.CompactOnce(CompactionConfig{RewindHorizonBatches: 1, MinDeadFraction: 0.5, MaxMoveBytesPerCycle: 1}) + require.NoError(t, err) + assert.Equal(t, 1, stats.FilesCompacted, "move budget stops the cycle after one file") + stats, err = db2.CompactOnce(CompactionConfig{RewindHorizonBatches: 1, MinDeadFraction: 0.5, MaxMoveBytesPerCycle: 1}) + require.NoError(t, err) + assert.Equal(t, 1, stats.FilesCompacted, "next cycle picks up the remaining file") + assert.Equal(t, uint64(400), mustColdRead(t, db2, 140, solana.PublicKey{4}).Lamports) + assert.Equal(t, uint64(500), mustColdRead(t, db2, 140, solana.PublicKey{5}).Lamports) +} + +// Bootstrap appendvecs (no manifest, fileId <= bootstrap high-water) compact +// like any other file; undecided orphans above the high-water mark are left +// for recovery. +func TestCompactBootstrapAppendVecAndOrphanSkip(t *testing.T) { + db, _ := newFoldTestDb(t) // bootstrap_high_file_id = 0 + defer db.CloseDb() + + // Hand-build bootstrap file "50.0": three records, only key 2 indexed (live). + accts := []*accounts.Account{ + foldAcct(1, 100, make([]byte, 1024)), + foldAcct(2, 200, []byte("live")), + foldAcct(3, 300, make([]byte, 1024)), + } + var buf bytes.Buffer + offsets := make([]uint64, len(accts)) + for i, a := range accts { + offsets[i] = uint64(buf.Len()) + ava := AppendVecAccount{DataLen: uint64(len(a.Data)), Pubkey: a.Key, Lamports: a.Lamports, RentEpoch: a.RentEpoch, Owner: a.Owner, Data: a.Data} + _, err := ava.MarshalReturningLength(&buf) + require.NoError(t, err) + } + require.NoError(t, os.WriteFile(filepath.Join(db.AcctsDir, "50.0"), buf.Bytes(), 0o644)) + var idxBuf [24]byte + entry := AccountIndexEntry{Slot: 50, FileId: 0, Offset: offsets[1]} + entry.Marshal(&idxBuf) + require.NoError(t, db.Index.Set(accts[1].Key[:], idxBuf[:], nil)) + + // An undecided orphan (fileId above bootstrap high-water, no manifest). + require.NoError(t, os.WriteFile(filepath.Join(db.AcctsDir, "60.7"), []byte("junk"), 0o644)) + + stats, err := db.CompactOnce(CompactionConfig{RewindHorizonBatches: 1, MinDeadFraction: 0.5}) + require.NoError(t, err) + assert.Equal(t, 1, stats.FilesCompacted) + + assert.NoFileExists(t, filepath.Join(db.AcctsDir, "50.0")) + assert.FileExists(t, filepath.Join(db.AcctsDir, "60.7"), "orphans are recovery's to delete") + got := mustColdRead(t, db, 50, solana.PublicKey{2}) + assert.Equal(t, uint64(200), got.Lamports) + assert.Equal(t, []byte("live"), got.Data) +} + +// The read path errors (after one retry) instead of panicking when the index +// names a missing file or a record that holds a different pubkey. +func TestReadPathErrorsInsteadOfPanicking(t *testing.T) { + db, _ := newFoldTestDb(t) + defer db.CloseDb() + + // Dangling entry: file does not exist. + dangling := solana.PublicKey{7} + var idxBuf [24]byte + (&AccountIndexEntry{Slot: 99, FileId: 424242, Offset: 0}).Marshal(&idxBuf) + require.NoError(t, db.Index.Set(dangling[:], idxBuf[:], nil)) + _, err := db.GetAccount(99, dangling) + require.Error(t, err) + require.NotErrorIs(t, err, ErrNoAccount, "a broken read must not masquerade as account-absent") + + // Wrong-pubkey record: key 9's entry points at key 1's record. + r := commitTestBatch(t, db, 110, foldAcct(1, 100, []byte("v1"))) + wrong := solana.PublicKey{9} + (&AccountIndexEntry{Slot: 110, FileId: r.FileId, Offset: 0}).Marshal(&idxBuf) + require.NoError(t, db.Index.Set(wrong[:], idxBuf[:], nil)) + _, err = db.GetAccount(110, wrong) + require.Error(t, err) + require.ErrorContains(t, err, "after retry") + + // Healthy reads are unaffected. + assert.Equal(t, uint64(100), mustColdRead(t, db, 110, solana.PublicKey{1}).Lamports) +} diff --git a/pkg/accountsdb/segment.go b/pkg/accountsdb/segment.go new file mode 100644 index 000000000..164298376 --- /dev/null +++ b/pkg/accountsdb/segment.go @@ -0,0 +1,461 @@ +package accountsdb + +import ( + "bytes" + "encoding/binary" + "errors" + "fmt" + "hash/crc32" + "io" + "os" + "path/filepath" + "sort" + "strconv" + "strings" +) + +// Batch fold segments are the wear-friendly durable commit unit: one sequential +// data file holding the union-deduped newest account versions across a K-slot +// batch, plus a manifest. The data file reuses the appendvec record encoding and +// the "." filename scheme (slot = ThroughSlot), so the existing +// read path serves bootstrap appendvecs and fold segments identically. +// +// The manifest is four things at once: +// - the commit record: a durable manifest means the batch is decided; recovery +// completes the index flip from it (the redo role) +// - the index redo log: Records carry every index entry the batch installs, +// which is what makes running the Pebble index without a WAL safe +// - the undo pointer log: Prev fields name each key's index entry before this +// batch overwrote it, enabling rewind to a batch boundary within the horizon +// - the carrier of the batch's bankhashes and end-of-batch resume context, so +// the durable watermark survives a hard crash without the state file (which +// is only written on graceful shutdown) +const ( + segManifestMagic = uint32(0x4d534547) // "MSEG" + segManifestVersion = uint32(1) + + segManifestSuffix = ".manifest" + segManifestTmpSuffix = ".manifest.tmp" + // Parked by rewind: ignored by recovery replay, readable by rewind-resume. + segManifestRewoundSuffix = ".manifest.rewound" + + // ManifestKindFold marks a batch-fold commit (BatchSeq is meaningful and + // contiguous). ManifestKindCompact marks a compaction output (BatchSeq 0); + // its only job is proving the data file is not an orphan. + ManifestKindFold = uint8(1) + ManifestKindCompact = uint8(2) +) + +// ErrTornManifest reports a manifest that fails CRC or structural validation. +var ErrTornManifest = errors.New("accountsdb: torn or malformed segment manifest") + +// SlotBankhash records one slot's bankhash inside a fold batch. +type SlotBankhash struct { + Slot uint64 + Bankhash [32]byte +} + +// ManifestRecord describes one deduped account version in the segment and the +// index entry it replaced (the undo pointer). +type ManifestRecord struct { + Pubkey [32]byte + Offset uint64 // record offset within this segment's data file + OwnerSlot uint64 // slot that produced this version (observability) + PrevValid bool // false => the key was absent from the index before this batch + Prev AccountIndexEntry // index entry before this batch overwrote it +} + +// SegmentManifest is the durable commit record for one fold or compaction segment. +type SegmentManifest struct { + Version uint32 + Kind uint8 + BatchSeq uint64 // fold: contiguous and monotonic; compact: 0 + FromSlot uint64 // exclusive lower bound of the batch + ThroughSlot uint64 // inclusive; equals the data file's name slot + FileId uint64 + DataLen uint64 // exact data file length + DataCRC uint32 // crc32 (IEEE) of the data file bytes + Bankhashes []SlotBankhash + Records []ManifestRecord + ResumeCtx []byte // opaque serialized state.ResumeContext at ThroughSlot +} + +// ManifestHeader is the fixed-size prefix of a manifest, cheap to scan in bulk. +// CRC validation requires a full ReadSegmentManifest. +type ManifestHeader struct { + Kind uint8 + BatchSeq uint64 + FromSlot uint64 + ThroughSlot uint64 + FileId uint64 + DataLen uint64 + DataCRC uint32 + Path string +} + +// SegmentDataName returns the data-file basename for a segment. +func SegmentDataName(throughSlot, fileId uint64) string { + return fmt.Sprintf("%d.%d", throughSlot, fileId) +} + +func segmentManifestPath(acctsDir string, throughSlot, fileId uint64) string { + return filepath.Join(acctsDir, SegmentDataName(throughSlot, fileId)+segManifestSuffix) +} + +const manifestRecordSize = 32 + 8 + 8 + 1 + 24 + +func (m *SegmentManifest) encode() []byte { + var body bytes.Buffer + body.Grow(64 + len(m.Bankhashes)*40 + len(m.ResumeCtx) + len(m.Records)*manifestRecordSize) + var u64 [8]byte + var u32 [4]byte + put32 := func(v uint32) { binary.LittleEndian.PutUint32(u32[:], v); body.Write(u32[:]) } + put64 := func(v uint64) { binary.LittleEndian.PutUint64(u64[:], v); body.Write(u64[:]) } + + put32(segManifestMagic) + put32(segManifestVersion) + body.WriteByte(m.Kind) + put64(m.BatchSeq) + put64(m.FromSlot) + put64(m.ThroughSlot) + put64(m.FileId) + put64(m.DataLen) + put32(m.DataCRC) + + put32(uint32(len(m.Bankhashes))) + for _, bh := range m.Bankhashes { + put64(bh.Slot) + body.Write(bh.Bankhash[:]) + } + + put32(uint32(len(m.ResumeCtx))) + body.Write(m.ResumeCtx) + + put64(uint64(len(m.Records))) + var prevBuf [24]byte + for i := range m.Records { + r := &m.Records[i] + body.Write(r.Pubkey[:]) + put64(r.Offset) + put64(r.OwnerSlot) + if r.PrevValid { + body.WriteByte(1) + } else { + body.WriteByte(0) + } + r.Prev.Marshal(&prevBuf) + body.Write(prevBuf[:]) + } + + binary.LittleEndian.PutUint32(u32[:], crc32.ChecksumIEEE(body.Bytes())) + body.Write(u32[:]) + return body.Bytes() +} + +// WriteSegmentManifest durably stages a manifest: tmp write + fsync + rename + +// dir fsync. Once the rename is durable, the batch commit is decided — recovery +// completes the index flip from the manifest. +func WriteSegmentManifest(acctsDir string, m *SegmentManifest) error { + final := segmentManifestPath(acctsDir, m.ThroughSlot, m.FileId) + tmp := final + ".tmp" + data := m.encode() + f, err := os.OpenFile(tmp, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o644) + if err != nil { + return fmt.Errorf("accountsdb: create manifest tmp: %w", err) + } + if _, err := f.Write(data); err != nil { + f.Close() + return fmt.Errorf("accountsdb: write manifest: %w", err) + } + if err := f.Sync(); err != nil { + f.Close() + return fmt.Errorf("accountsdb: fsync manifest: %w", err) + } + if err := f.Close(); err != nil { + return fmt.Errorf("accountsdb: close manifest: %w", err) + } + if err := os.Rename(tmp, final); err != nil { + return fmt.Errorf("accountsdb: rename manifest: %w", err) + } + return fsyncDir(acctsDir) +} + +type manifestDecoder struct { + data []byte + off int +} + +func (d *manifestDecoder) remain() int { return len(d.data) - d.off } +func (d *manifestDecoder) u8() (byte, error) { + if d.remain() < 1 { + return 0, ErrTornManifest + } + v := d.data[d.off] + d.off++ + return v, nil +} +func (d *manifestDecoder) u32() (uint32, error) { + if d.remain() < 4 { + return 0, ErrTornManifest + } + v := binary.LittleEndian.Uint32(d.data[d.off:]) + d.off += 4 + return v, nil +} +func (d *manifestDecoder) u64() (uint64, error) { + if d.remain() < 8 { + return 0, ErrTornManifest + } + v := binary.LittleEndian.Uint64(d.data[d.off:]) + d.off += 8 + return v, nil +} +func (d *manifestDecoder) bytes(n int) ([]byte, error) { + if n < 0 || d.remain() < n { + return nil, ErrTornManifest + } + v := d.data[d.off : d.off+n] + d.off += n + return v, nil +} + +func decodeManifestPrefix(d *manifestDecoder, m *SegmentManifest) error { + magic, err := d.u32() + if err != nil || magic != segManifestMagic { + return ErrTornManifest + } + if m.Version, err = d.u32(); err != nil || m.Version != segManifestVersion { + return ErrTornManifest + } + if m.Kind, err = d.u8(); err != nil { + return err + } + if m.BatchSeq, err = d.u64(); err != nil { + return err + } + if m.FromSlot, err = d.u64(); err != nil { + return err + } + if m.ThroughSlot, err = d.u64(); err != nil { + return err + } + if m.FileId, err = d.u64(); err != nil { + return err + } + if m.DataLen, err = d.u64(); err != nil { + return err + } + if m.DataCRC, err = d.u32(); err != nil { + return err + } + return nil +} + +// ReadSegmentManifest loads and fully validates a manifest (trailing CRC included). +func ReadSegmentManifest(path string) (*SegmentManifest, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + if len(data) < 4 { + return nil, ErrTornManifest + } + body, tail := data[:len(data)-4], data[len(data)-4:] + if crc32.ChecksumIEEE(body) != binary.LittleEndian.Uint32(tail) { + return nil, ErrTornManifest + } + + d := &manifestDecoder{data: body} + m := &SegmentManifest{} + if err := decodeManifestPrefix(d, m); err != nil { + return nil, err + } + + nBank, err := d.u32() + if err != nil { + return nil, err + } + m.Bankhashes = make([]SlotBankhash, 0, nBank) + for i := uint32(0); i < nBank; i++ { + var bh SlotBankhash + if bh.Slot, err = d.u64(); err != nil { + return nil, err + } + raw, err := d.bytes(32) + if err != nil { + return nil, err + } + copy(bh.Bankhash[:], raw) + m.Bankhashes = append(m.Bankhashes, bh) + } + + ctxLen, err := d.u32() + if err != nil { + return nil, err + } + ctx, err := d.bytes(int(ctxLen)) + if err != nil { + return nil, err + } + m.ResumeCtx = append([]byte(nil), ctx...) + + nRec, err := d.u64() + if err != nil { + return nil, err + } + if nRec > uint64(d.remain())/manifestRecordSize+1 { + return nil, ErrTornManifest + } + m.Records = make([]ManifestRecord, 0, nRec) + for i := uint64(0); i < nRec; i++ { + var r ManifestRecord + pk, err := d.bytes(32) + if err != nil { + return nil, err + } + copy(r.Pubkey[:], pk) + if r.Offset, err = d.u64(); err != nil { + return nil, err + } + if r.OwnerSlot, err = d.u64(); err != nil { + return nil, err + } + pv, err := d.u8() + if err != nil { + return nil, err + } + r.PrevValid = pv == 1 + prevRaw, err := d.bytes(24) + if err != nil { + return nil, err + } + r.Prev.Unmarshal((*[24]byte)(prevRaw)) + m.Records = append(m.Records, r) + } + if d.remain() != 0 { + return nil, ErrTornManifest + } + return m, nil +} + +// readManifestHeader parses only the fixed prefix — no CRC validation. +func readManifestHeader(path string) (ManifestHeader, error) { + f, err := os.Open(path) + if err != nil { + return ManifestHeader{}, err + } + defer f.Close() + var buf [64]byte + n, err := f.Read(buf[:]) + if n < 61 { // magic+version+kind+5*u64+u32 = 4+4+1+40+12 + if err != nil { + return ManifestHeader{}, ErrTornManifest + } + return ManifestHeader{}, ErrTornManifest + } + d := &manifestDecoder{data: buf[:n]} + var m SegmentManifest + if err := decodeManifestPrefix(d, &m); err != nil { + return ManifestHeader{}, err + } + return ManifestHeader{ + Kind: m.Kind, + BatchSeq: m.BatchSeq, + FromSlot: m.FromSlot, + ThroughSlot: m.ThroughSlot, + FileId: m.FileId, + DataLen: m.DataLen, + DataCRC: m.DataCRC, + Path: path, + }, nil +} + +// ListFoldManifests scans acctsDir for fold-kind manifests (".manifest" only — +// ".rewound" and ".tmp" are ignored) and returns their headers ascending by +// BatchSeq. Headers that fail even prefix parsing are returned with BatchSeq 0 +// and Kind 0 so the caller can quarantine by path. +func ListFoldManifests(acctsDir string) ([]ManifestHeader, error) { + entries, err := os.ReadDir(acctsDir) + if err != nil { + return nil, err + } + var out []ManifestHeader + for _, e := range entries { + name := e.Name() + if e.IsDir() || !strings.HasSuffix(name, segManifestSuffix) || strings.HasSuffix(name, segManifestTmpSuffix) { + continue + } + hdr, err := readManifestHeader(filepath.Join(acctsDir, name)) + if err != nil { + out = append(out, ManifestHeader{Path: filepath.Join(acctsDir, name)}) + continue + } + if hdr.Kind != ManifestKindFold { + continue + } + out = append(out, hdr) + } + sort.Slice(out, func(i, j int) bool { return out[i].BatchSeq < out[j].BatchSeq }) + return out, nil +} + +// listAllManifestFileIds returns the fileId of every manifest-backed data file +// (fold and compact, including rewound manifests) for orphan classification. +func listAllManifestFileIds(acctsDir string) (map[uint64]struct{}, error) { + entries, err := os.ReadDir(acctsDir) + if err != nil { + return nil, err + } + ids := make(map[uint64]struct{}) + for _, e := range entries { + name := e.Name() + if e.IsDir() { + continue + } + if !strings.HasSuffix(name, segManifestSuffix) && !strings.HasSuffix(name, segManifestRewoundSuffix) { + continue + } + if strings.HasSuffix(name, segManifestTmpSuffix) { + continue + } + hdr, err := readManifestHeader(filepath.Join(acctsDir, name)) + if err != nil { + continue + } + ids[hdr.FileId] = struct{}{} + } + return ids, nil +} + +// parseDataFileName parses "." appendvec/segment basenames. +func parseDataFileName(name string) (slot uint64, fileId uint64, ok bool) { + dot := strings.IndexByte(name, '.') + if dot <= 0 || dot == len(name)-1 { + return 0, 0, false + } + var err error + if slot, err = strconv.ParseUint(name[:dot], 10, 64); err != nil { + return 0, 0, false + } + if fileId, err = strconv.ParseUint(name[dot+1:], 10, 64); err != nil { + return 0, 0, false + } + return slot, fileId, true +} + +// crcOfFile streams a file through a crc32 (IEEE) digest and returns the +// checksum with the byte length, without loading the whole file into memory +// (fold segments can be large on a busy cluster). +func crcOfFile(path string) (uint32, int64, error) { + f, err := os.Open(path) + if err != nil { + return 0, 0, err + } + defer f.Close() + h := crc32.NewIEEE() + n, err := io.Copy(h, f) + if err != nil { + return 0, 0, err + } + return h.Sum32(), n, nil +} diff --git a/pkg/alpenglow/certpool.go b/pkg/alpenglow/certpool.go new file mode 100644 index 000000000..469110862 --- /dev/null +++ b/pkg/alpenglow/certpool.go @@ -0,0 +1,921 @@ +package alpenglow + +import ( + "fmt" + "sync" + + bls12381 "github.com/Overclock-Validator/gnark-crypto/ecc/bls12-381" + "github.com/Overclock-Validator/mithril/pkg/mlog" + "github.com/gagliardetto/solana-go" +) + +// The certificate pool assembles Alpenglow certificates LOCALLY from raw +// votor votes, the way Agave's votor certificate pool does. Waiting for a +// peer's aggregated certificate costs an extra network hop and depends on +// peers' broadcast behavior; the pool instead tallies verified stake per +// (vote type, block) and emits a certificate the moment a threshold crosses. +// Footer certificates remain the guaranteed input for unstaked observers — +// the pool is the latency accelerator when raw Votor traffic is visible. +// +// TRUST BOUNDARY: raw votes are unauthenticated network input, not consensus +// facts. Ingest (AddVote) only shape-checks, bounds buffering, and parks the +// vote — it NEVER mutates dedupe, equivocation, disjointness, or tally state +// that assembly or fork choice depends on. Those mutate only AFTER the BLS +// signature verifies (foldTallyLocked). This prevents a bogus vote for a +// victim rank from suppressing that validator's real vote (dedupe poisoning) +// or forging equivocation evidence against an honest validator. +// +// Verification is lazy and batched: votes buffer unverified (attacker-cheap), +// and only when a tally's CANDIDATE stake first crosses its threshold does +// the pool fold the pending set — all votes in a tally sign the identical +// payload, so folding is sum(pubkeys) + sum(signatures) + ONE pairing check. +// A failed batch bisects to isolate and evidence-log the bad votes. This +// keeps steady-state pairing cost at a handful per slot instead of one per +// vote, and spends nothing at all on tallies that never approach quorum. + +// CertPoolConfig bounds the pool's attacker-controlled surfaces. All bounds +// are enforced on ingest (slots and ranks arrive pre-authentication). +type CertPoolConfig struct { + // MaxSlotsAhead rejects votes for slots beyond the trusted live watermark + // (max of the finalized floor and the replay-observed slot) plus this bound. + MaxSlotsAhead uint64 + // MaxPendingVotesPerSlot caps buffered unverified votes per slot across all + // tallies. + MaxPendingVotesPerSlot int + // MaxLiveSlots caps how many distinct slots the pool retains buffers for; a + // hard bound on pool memory independent of the slot window. + MaxLiveSlots int + // MaxPendingVotesTotal caps buffered unverified votes across ALL slots — the + // global memory ceiling. + MaxPendingVotesTotal int + // EquivocationCap caps retained equivocation evidence entries (FIFO). + EquivocationCap int +} + +// DefaultCertPoolConfig mirrors the deferred-cert bounds used elsewhere. +func DefaultCertPoolConfig() CertPoolConfig { + return CertPoolConfig{ + MaxSlotsAhead: 512, + MaxPendingVotesPerSlot: 8192, + MaxLiveSlots: 1024, + MaxPendingVotesTotal: 262144, + EquivocationCap: 4096, + } +} + +// EquivocationEvidence records a rank casting conflicting VERIFIED votes — +// retained (capped) even across pruning, surfaced via Snapshot. Because it is +// recorded only after both signatures verify, it cannot be forged against an +// honest validator. +type EquivocationEvidence struct { + Slot uint64 + Rank uint16 + Type VoteType + First solana.Hash + Second solana.Hash +} + +// CertPoolSnapshot summarizes pool state for logs/monitoring. +type CertPoolSnapshot struct { + Slots int + VotesAccepted uint64 + VotesRejected uint64 + VotesEquivocated uint64 + BatchesVerified uint64 + BadSignatures uint64 + CertsEmitted uint64 + Floor uint64 + HighestSlot uint64 + LiveSlot uint64 + PendingTotal int +} + +// tally accumulates one (vote type, block hash) target within a slot. +type tally struct { + pending map[uint16]VoteMessage // unverified, by rank + verified map[uint16]struct{} // ranks folded into the aggregate + aggSig bls12381.G2Affine // sum of verified signatures + aggPub bls12381.G1Affine // sum of verified pubkeys + stake uint64 // verified stake +} + +func newTally() *tally { + t := &tally{pending: make(map[uint16]VoteMessage), verified: make(map[uint16]struct{})} + t.aggSig.SetInfinity() + t.aggPub.SetInfinity() + return t +} + +func (t *tally) candidateStake(set *ValidatorSet) uint64 { + total := t.stake + for rank := range t.pending { + if int(rank) < len(set.Validators) { + total += set.Validators[rank].Stake + } + } + return total +} + +type tallyKey struct { + Type VoteType + Hash solana.Hash // zero for slot-only vote types +} + +type poolSlot struct { + tallies map[tallyKey]*tally + // verifiedHash tracks the block hashes a rank has cast VERIFIED votes for, + // per (rank, type), for equivocation/vote-budget enforcement. Populated only + // after signature verification — never from raw ingest — so a bogus vote can + // neither poison dedupe nor forge equivocation. Notarize-fallback legally + // allows up to 3 distinct hashes per rank (Def. 12 vote budget); every other + // type is single-shot. + verifiedHash map[voteDedupKey][]solana.Hash + pendingCount int +} + +type voteDedupKey struct { + Rank uint16 + Type VoteType +} + +// CertPool assembles certificates from raw votes. Emit receives fully +// stake+signature-verified certificates (identical trust to a verified wire +// cert) and must be fast (it runs after unlock, but serially). +type CertPool struct { + cfg CertPoolConfig + verifier *CertificateVerifier + emit func(Certificate) + + mu sync.Mutex + epochForSlot func(slot uint64) uint64 + slots map[uint64]*poolSlot + emitted map[CertificateKey]struct{} + floor uint64 + liveSlot uint64 // trusted replay/observed watermark (NOT advanced by raw votes) + highestSlot uint64 // observability only: highest vote slot seen + totalPending int + equivocation []EquivocationEvidence + snap CertPoolSnapshot +} + +// NewCertPool constructs a pool over the shared certificate verifier's +// validator sets. emit may be nil (tally-only mode). +func NewCertPool(cfg CertPoolConfig, verifier *CertificateVerifier, emit func(Certificate)) *CertPool { + if cfg.MaxSlotsAhead == 0 { + cfg.MaxSlotsAhead = 512 + } + if cfg.MaxPendingVotesPerSlot <= 0 { + cfg.MaxPendingVotesPerSlot = 8192 + } + if cfg.MaxLiveSlots <= 0 { + cfg.MaxLiveSlots = 1024 + } + if cfg.MaxPendingVotesTotal <= 0 { + cfg.MaxPendingVotesTotal = 262144 + } + if cfg.EquivocationCap <= 0 { + cfg.EquivocationCap = 4096 + } + return &CertPool{ + cfg: cfg, + verifier: verifier, + emit: emit, + slots: make(map[uint64]*poolSlot), + emitted: make(map[CertificateKey]struct{}), + } +} + +// SetEpochLookup wires slot→epoch resolution (validator set selection). +func (p *CertPool) SetEpochLookup(fn func(slot uint64) uint64) { + p.mu.Lock() + p.epochForSlot = fn + p.mu.Unlock() +} + +// NoteLiveSlot advances the trusted live watermark that anchors the vote window +// (from replay progress / observed finality — never from raw votes, so an +// attacker cannot slide the window forward). Monotonic. +func (p *CertPool) NoteLiveSlot(slot uint64) { + p.mu.Lock() + if slot > p.liveSlot { + p.liveSlot = slot + } + p.mu.Unlock() +} + +// windowAnchorLocked is the trusted upper anchor of the live vote window: the +// higher of the finalized floor and the replay-observed live slot. It is NOT +// derived from raw votes, so ingest cannot advance it. +func (p *CertPool) windowAnchorLocked() uint64 { + if p.floor > p.liveSlot { + return p.floor + } + return p.liveSlot +} + +// setForSlotLocked resolves the validator set covering slot. Returns nil (votes +// stay buffered) when the slot→epoch map is not installed or the epoch's set is +// unknown — the pool NEVER falls back to a guessed epoch, which could assemble +// against the wrong validator set across an epoch boundary. +func (p *CertPool) setForSlotLocked(slot uint64) *ValidatorSet { + if p.epochForSlot == nil { + return nil + } + set, ok := p.verifier.ValidatorSetForEpoch(p.epochForSlot(slot)) + if !ok { + return nil + } + return &set +} + +// AddVote ingests one raw (unverified) votor vote. It ONLY shape-checks, bounds +// buffering, and parks the vote in its (type, hash) tally. It does NOT touch +// dedupe/equivocation/disjointness state — those mutate only after the vote's +// signature verifies (foldTallyLocked). A malformed vote, a vote outside the +// trusted slot window, or a vote past a memory bound is dropped. +func (p *CertPool) AddVote(msg VoteMessage) { + if msg.Vote.ValidateBasic() != nil || len(msg.Signature) != BLSSignatureSize { + return + } + slot := msg.Vote.Slot + + var emits []Certificate + p.mu.Lock() + if slot <= p.floor { + p.snap.VotesRejected++ + p.mu.Unlock() + return + } + // Window anchored to the TRUSTED watermark (floor / replay-observed), not to + // the highest vote slot seen — otherwise an attacker could slide it forward + // vote by vote and retain arbitrarily many future slots. + if anchor := p.windowAnchorLocked(); anchor > 0 && slot > anchor+p.cfg.MaxSlotsAhead { + p.snap.VotesRejected++ + p.mu.Unlock() + return + } + + ps := p.slots[slot] + if ps == nil { + // Hard global bound on retained slots (independent of the window). + if len(p.slots) >= p.cfg.MaxLiveSlots { + p.snap.VotesRejected++ + p.mu.Unlock() + return + } + ps = &poolSlot{tallies: make(map[tallyKey]*tally), verifiedHash: make(map[voteDedupKey][]solana.Hash)} + p.slots[slot] = ps + } + + // Memory bounds only — no trust-sensitive state is touched here. + if ps.pendingCount >= p.cfg.MaxPendingVotesPerSlot || p.totalPending >= p.cfg.MaxPendingVotesTotal { + p.snap.VotesRejected++ + p.mu.Unlock() + return + } + + tk := tallyKey{Type: msg.Vote.Type, Hash: msg.Vote.BlockHash} + tl := ps.tallies[tk] + if tl == nil { + tl = newTally() + ps.tallies[tk] = tl + } + // One pending vote per rank per tally; a rank already verified in this tally + // needs no re-buffer. (Different block hashes are DIFFERENT tallies, so a + // bogus hash cannot displace the real one — no cross-tally poisoning.) + if _, dup := tl.pending[msg.Rank]; !dup { + if _, done := tl.verified[msg.Rank]; !done { + tl.pending[msg.Rank] = msg + ps.pendingCount++ + p.totalPending++ + p.snap.VotesAccepted++ + } + } + if slot > p.highestSlot { + p.highestSlot = slot + } + + // Keep verified stake fresh for the Votor fallback triggers. Only plain + // notarize/skip arrivals can change the trigger predicates. + if msg.Vote.Type == VoteTypeNotarize || msg.Vote.Type == VoteTypeSkip { + p.maybeFoldTriggersLocked(slot, ps) + } + emits = p.maybeAssembleLocked(slot, ps) + p.mu.Unlock() + + for _, cert := range emits { + p.emitCert(cert) + } +} + +// OnValidatorSetInstalled retries assembly for buffered slots that resolve to +// the newly-installed epoch. Requires a real slot→epoch lookup; without one no +// slot can be safely attributed to the epoch, so nothing is retried. +func (p *CertPool) OnValidatorSetInstalled(epoch uint64) { + var emits []Certificate + p.mu.Lock() + if p.epochForSlot != nil { + for slot, ps := range p.slots { + if p.epochForSlot(slot) != epoch { + continue + } + p.maybeFoldTriggersLocked(slot, ps) + emits = append(emits, p.maybeAssembleLocked(slot, ps)...) + } + } + p.mu.Unlock() + for _, cert := range emits { + p.emitCert(cert) + } +} + +// ObserveFloor prunes slots <= finalizedSlot (equivocation evidence is exempt). +func (p *CertPool) ObserveFloor(finalizedSlot uint64) { + p.mu.Lock() + if finalizedSlot > p.floor { + p.floor = finalizedSlot + for slot, ps := range p.slots { + if slot <= finalizedSlot { + p.totalPending -= ps.pendingCount + delete(p.slots, slot) + } + } + if p.totalPending < 0 { + p.totalPending = 0 + } + for key := range p.emitted { + if key.Slot <= finalizedSlot { + delete(p.emitted, key) + } + } + } + p.mu.Unlock() +} + +// EquivocationEvidence returns the retained evidence (copy). +func (p *CertPool) EquivocationEvidence() []EquivocationEvidence { + p.mu.Lock() + defer p.mu.Unlock() + return append([]EquivocationEvidence(nil), p.equivocation...) +} + +// Snapshot returns pool counters. +func (p *CertPool) Snapshot() CertPoolSnapshot { + p.mu.Lock() + defer p.mu.Unlock() + snap := p.snap + snap.Slots = len(p.slots) + snap.Floor = p.floor + snap.HighestSlot = p.highestSlot + snap.LiveSlot = p.liveSlot + snap.PendingTotal = p.totalPending + return snap +} + +func (p *CertPool) recordEquivocationLocked(ev EquivocationEvidence) { + p.snap.VotesEquivocated++ + if len(p.equivocation) >= p.cfg.EquivocationCap { + p.equivocation = p.equivocation[1:] + } + p.equivocation = append(p.equivocation, ev) + mlog.Log.Warnf("ALPENGLOW cert pool: equivocation evidence — rank %d cast conflicting VERIFIED %s votes at slot %d (%s vs %s)", + ev.Rank, ev.Type, ev.Slot, ev.First, ev.Second) +} + +// Votor fallback-trigger thresholds, transcribed from Agave +// votor/src/common.rs (Fraction::from_percentage): +// +// SAFE_TO_NOTAR_MIN_NOTARIZE_ONLY = 40 +// SAFE_TO_NOTAR_MIN_NOTARIZE_FOR_NOTARIZE_OR_SKIP = 20 +// SAFE_TO_NOTAR_MIN_NOTARIZE_AND_SKIP = 60 +// SAFE_TO_SKIP_THRESHOLD = 40 +// +// SafeToNotar(b): notar(b) >= 40% OR (notar(b) >= 20% AND notar(b)+skip >= 60%). +// SafeToSkip: skip + (notarTotal - topNotar) >= 40%. +// Only PLAIN notarize/skip votes feed these counters (fallback votes are +// excluded — Agave slot_stake_counters.rs "not interested in other vote types"). +var ( + safeToNotarMinNotarizeOnly = FractionFromPercentage(40) + safeToNotarMinNotarizeForMix = FractionFromPercentage(20) + safeToNotarMinNotarizeAndSkip = FractionFromPercentage(60) + safeToSkipThreshold = FractionFromPercentage(40) +) + +// votorTriggerView is one slot's trigger-relevant stake, candidate (verified + +// buffered, an over-approximation) and verified, for plain notarize/skip only. +type votorTriggerView struct { + candNotar map[solana.Hash]uint64 + verNotar map[solana.Hash]uint64 + candSkip uint64 + verSkip uint64 + candTotal uint64 // sum of candNotar + verTotal uint64 + candTop uint64 // max of candNotar + verTop uint64 +} + +func buildTriggerViewLocked(ps *poolSlot, set *ValidatorSet) votorTriggerView { + v := votorTriggerView{ + candNotar: make(map[solana.Hash]uint64), + verNotar: make(map[solana.Hash]uint64), + } + for tk, tl := range ps.tallies { + switch tk.Type { + case VoteTypeNotarize: + cand := tl.candidateStake(set) + v.candNotar[tk.Hash] = cand + v.verNotar[tk.Hash] = tl.stake + v.candTotal += cand + v.verTotal += tl.stake + if cand > v.candTop { + v.candTop = cand + } + if tl.stake > v.verTop { + v.verTop = tl.stake + } + case VoteTypeSkip: + v.candSkip = tl.candidateStake(set) + v.verSkip = tl.stake + } + } + return v +} + +func meets(f Fraction, stake, total uint64) bool { + met, err := f.Meets(stake, total) + return err == nil && met +} + +// maybeFoldTriggersLocked keeps verified stake fresh for Votor's fallback +// triggers: whenever a trigger predicate passes on CANDIDATE stake but not yet +// on VERIFIED stake, the involved tallies fold (batch-verify) immediately. +// Candidate stake over-approximates verified stake, so a predicate crossing is +// never observed late — the voting engine evaluating the same predicates on +// VerifiedVotorStakes sees them at the same time an eager-verification +// implementation (Agave) would. This is the ONLY fold policy: one fork-choice +// behavior for observer and voting nodes alike; sub-trigger tallies still +// cost nothing. +func (p *CertPool) maybeFoldTriggersLocked(slot uint64, ps *poolSlot) { + set := p.setForSlotLocked(slot) + if set == nil { + return // votes stay buffered; retried on OnValidatorSetInstalled + } + total := set.TotalStake + v := buildTriggerViewLocked(ps, set) + + foldNotar := make(map[solana.Hash]bool) + foldSkip := false + foldAllNotar := false + + for hash, cand := range v.candNotar { + ver := v.verNotar[hash] + // SafeToNotar(i): notar(b) alone. + if meets(safeToNotarMinNotarizeOnly, cand, total) && !meets(safeToNotarMinNotarizeOnly, ver, total) { + foldNotar[hash] = true + } + // SafeToNotar(ii): notar(b) >= 20% AND notar(b)+skip >= 60%. + candMix := meets(safeToNotarMinNotarizeForMix, cand, total) && + meets(safeToNotarMinNotarizeAndSkip, cand+v.candSkip, total) + verMix := meets(safeToNotarMinNotarizeForMix, ver, total) && + meets(safeToNotarMinNotarizeAndSkip, ver+v.verSkip, total) + if candMix && !verMix { + foldNotar[hash] = true + foldSkip = true + } + } + // SafeToSkip: skip + (notarTotal - topNotar) >= 40%. Involves every notarize + // tally (the total and the max), so a crossing folds them all. + candSTS := meets(safeToSkipThreshold, v.candSkip+(v.candTotal-v.candTop), total) + verSTS := meets(safeToSkipThreshold, v.verSkip+(v.verTotal-v.verTop), total) + if candSTS && !verSTS { + foldSkip = true + foldAllNotar = true + } + + if !foldSkip && !foldAllNotar && len(foldNotar) == 0 { + return + } + for tk, tl := range ps.tallies { + switch tk.Type { + case VoteTypeNotarize: + if foldAllNotar || foldNotar[tk.Hash] { + p.foldTallyLocked(slot, ps, tl, set) + } + case VoteTypeSkip: + if foldSkip { + p.foldTallyLocked(slot, ps, tl, set) + } + } + } +} + +// VotorStakes is the verified-stake observation Votor's fallback-trigger +// predicates consume (plain notarize/skip only, matching Agave's +// slot_stake_counters). The pool guarantees freshness: whenever a trigger +// predicate could pass, the involved stake has already been verified — a +// voting engine layered on top evaluates predicates on these numbers with no +// additional verification machinery. +type VotorStakes struct { + Notarize map[solana.Hash]uint64 + NotarizeTotal uint64 + TopNotarize uint64 + Skip uint64 + TotalStake uint64 +} + +// VerifiedVotorStakes returns the slot's verified trigger stakes. ok is false +// when the slot's epoch/validator set is not resolvable yet. +func (p *CertPool) VerifiedVotorStakes(slot uint64) (VotorStakes, bool) { + p.mu.Lock() + defer p.mu.Unlock() + set := p.setForSlotLocked(slot) + if set == nil { + return VotorStakes{}, false + } + out := VotorStakes{Notarize: make(map[solana.Hash]uint64), TotalStake: set.TotalStake} + ps := p.slots[slot] + if ps == nil { + return out, true + } + for tk, tl := range ps.tallies { + switch tk.Type { + case VoteTypeNotarize: + out.Notarize[tk.Hash] = tl.stake + out.NotarizeTotal += tl.stake + if tl.stake > out.TopNotarize { + out.TopNotarize = tl.stake + } + case VoteTypeSkip: + out.Skip = tl.stake + } + } + return out, true +} + +// certTarget describes one assemblable certificate: a base tally plus an +// optional fallback tally (base3 encoding). +type certTarget struct { + certType CertificateType + base tallyKey + fallback *tallyKey +} + +func targetsForSlot(ps *poolSlot) []certTarget { + var out []certTarget + // Union targets (notar-fallback, skip) must be generated whether the BASE + // tally, the FALLBACK tally, or both exist: Agave's certificate builder + // assembles a NotarizeFallback/Skip cert from fallback votes alone (base3 + // with an empty base bitmap), and a contested slot is exactly where fallback + // votes can dominate. Dedupe so a slot holding both tallies yields one target. + seen := make(map[CertificateKey]struct{}, len(ps.tallies)) + add := func(t certTarget) { + k := CertificateKey{Type: t.certType} + if t.certType.HasBlock() { + k.BlockHash = t.base.Hash + } + if _, dup := seen[k]; dup { + return + } + seen[k] = struct{}{} + out = append(out, t) + } + for tk := range ps.tallies { + switch tk.Type { + case VoteTypeNotarize: + add(certTarget{certType: CertificateNotarize, base: tk}) + add(certTarget{certType: CertificateFinalizeFast, base: tk}) + add(certTarget{certType: CertificateNotarizeFallback, base: tk, + fallback: &tallyKey{Type: VoteTypeNotarizeFallback, Hash: tk.Hash}}) + case VoteTypeNotarizeFallback: + add(certTarget{certType: CertificateNotarizeFallback, + base: tallyKey{Type: VoteTypeNotarize, Hash: tk.Hash}, + fallback: &tallyKey{Type: VoteTypeNotarizeFallback, Hash: tk.Hash}}) + case VoteTypeFinalize: + add(certTarget{certType: CertificateFinalize, base: tk}) + case VoteTypeSkip: + add(certTarget{certType: CertificateSkip, base: tk, + fallback: &tallyKey{Type: VoteTypeSkipFallback, Hash: solana.Hash{}}}) + case VoteTypeSkipFallback: + add(certTarget{certType: CertificateSkip, + base: tallyKey{Type: VoteTypeSkip}, + fallback: &tallyKey{Type: VoteTypeSkipFallback, Hash: solana.Hash{}}}) + case VoteTypeGenesis: + add(certTarget{certType: CertificateGenesis, base: tk}) + } + } + return out +} + +// maybeAssembleLocked checks every assemblable target for the slot: folds +// pending votes (batch verification) once candidate stake crosses the +// threshold, and returns any newly assembled certificates for emission. +func (p *CertPool) maybeAssembleLocked(slot uint64, ps *poolSlot) []Certificate { + set := p.setForSlotLocked(slot) + if set == nil { + return nil // validator set / epoch not resolvable yet; votes stay buffered + } + + var emits []Certificate + for _, target := range targetsForSlot(ps) { + key := CertificateKey{Type: target.certType, Slot: slot} + if target.certType.HasBlock() { + key.BlockHash = target.base.Hash + } + if _, done := p.emitted[key]; done { + continue + } + + // The base tally may be absent for union targets (fallback-only + // assembly); a nil base contributes nothing and encodes as an empty + // base group, exactly like Agave's empty bitmap0. + base := ps.tallies[target.base] + var fb *tally + if target.fallback != nil { + fb = ps.tallies[*target.fallback] + } + if base == nil && fb == nil { + continue + } + + threshold := target.certType.RequiredThreshold() + candidate := uint64(0) + if base != nil { + candidate += base.candidateStake(set) + } + if fb != nil { + candidate += fb.candidateStake(set) + } + if met, err := threshold.Meets(candidate, set.TotalStake); err != nil || !met { + continue + } + + // Candidate stake crossed: fold pending votes (one pairing per tally). + p.foldTallyLocked(slot, ps, base, set) + p.foldTallyLocked(slot, ps, fb, set) + + verifiedStake := uint64(0) + if base != nil { + verifiedStake += base.stake + } + if fb != nil { + verifiedStake += fb.stake + } + if met, err := threshold.Meets(verifiedStake, set.TotalStake); err != nil || !met { + continue // bad signatures dropped below the bar; wait for more votes + } + + cert, err := p.assembleLocked(slot, target, base, fb, set, verifiedStake) + if err != nil { + mlog.Log.Warnf("ALPENGLOW cert pool: assembly of %s@%d failed: %v", target.certType, slot, err) + continue + } + p.emitted[key] = struct{}{} + p.snap.CertsEmitted++ + emits = append(emits, cert) + } + return emits +} + +// foldTallyLocked batch-verifies a tally's pending votes: all sign the same +// payload, so verification is sum(pubkeys)+sum(sigs)+one pairing; failures +// bisect to isolate the bad votes (dropped + counted). Only AFTER a vote +// verifies does it update the durable per-slot state — the vote-budget / +// equivocation ledger (verifiedHash) and base↔fallback disjointness — so raw +// votes can never poison those. +func (p *CertPool) foldTallyLocked(slot uint64, ps *poolSlot, tl *tally, set *ValidatorSet) { + if tl == nil || len(tl.pending) == 0 { + return + } + batch := make([]VoteMessage, 0, len(tl.pending)) + for rank, msg := range tl.pending { + if int(rank) >= len(set.Validators) { + delete(tl.pending, rank) + ps.pendingCount-- + p.totalPending-- + p.snap.VotesRejected++ + continue + } + batch = append(batch, msg) + } + good := p.verifyBatch(batch, set) + for _, msg := range batch { + delete(tl.pending, msg.Rank) + ps.pendingCount-- + p.totalPending-- + } + + for _, msg := range good { + // Vote budget + equivocation, on VERIFIED votes only. A rank may cast a + // notarize-fallback for up to 3 distinct hashes; every other type is + // single-shot. A conflicting VERIFIED vote beyond budget is cryptographic + // equivocation evidence and is not counted. + dk := voteDedupKey{Rank: msg.Rank, Type: msg.Vote.Type} + seen := ps.verifiedHash[dk] + dupHash := false + for _, h := range seen { + if h == msg.Vote.BlockHash { + dupHash = true + break + } + } + if dupHash { + continue // already counted this exact verified vote + } + budget := 1 + if msg.Vote.Type == VoteTypeNotarizeFallback { + budget = 3 + } + if len(seen) >= budget { + p.recordEquivocationLocked(EquivocationEvidence{ + Slot: slot, Rank: msg.Rank, Type: msg.Vote.Type, + First: seen[0], Second: msg.Vote.BlockHash, + }) + continue + } + // Base↔fallback disjointness: a rank already counted in the paired tally + // (notarize↔notarize-fallback on the same block, skip↔skip-fallback) must + // not also count here, or the base3 bitmap would double-encode it. + if pairKey, paired := pairedTallyKey(msg.Vote); paired { + if pt := ps.tallies[pairKey]; pt != nil { + if _, in := pt.verified[msg.Rank]; in { + continue + } + } + } + + var pub bls12381.G1Affine + if _, err := pub.SetBytes(set.Validators[msg.Rank].BlsPubkeyCompressed[:]); err != nil { + continue + } + var sig bls12381.G2Affine + if _, err := sig.SetBytes(msg.Signature); err != nil { + continue + } + tl.aggPub.Add(&tl.aggPub, &pub) + tl.aggSig.Add(&tl.aggSig, &sig) + tl.verified[msg.Rank] = struct{}{} + tl.stake += set.Validators[msg.Rank].Stake + ps.verifiedHash[dk] = append(seen, msg.Vote.BlockHash) + } + p.snap.BadSignatures += uint64(len(batch) - len(good)) +} + +// verifyBatch verifies same-payload votes with one aggregate pairing check, +// bisecting on failure. Returns the subset that verified. +func (p *CertPool) verifyBatch(batch []VoteMessage, set *ValidatorSet) []VoteMessage { + if len(batch) == 0 { + return nil + } + p.snap.BatchesVerified++ + payload, err := EncodeVote(batch[0].Vote) + if err != nil { + return nil + } + + var aggPub bls12381.G1Affine + var aggSig bls12381.G2Affine + aggPub.SetInfinity() + aggSig.SetInfinity() + parsed := make([]struct { + pub bls12381.G1Affine + sig bls12381.G2Affine + ok bool + }, len(batch)) + for i, msg := range batch { + if _, err := parsed[i].pub.SetBytes(set.Validators[msg.Rank].BlsPubkeyCompressed[:]); err != nil || parsed[i].pub.IsInfinity() { + continue + } + if _, err := parsed[i].sig.SetBytes(msg.Signature); err != nil || parsed[i].sig.IsInfinity() { + continue + } + parsed[i].ok = true + aggPub.Add(&aggPub, &parsed[i].pub) + aggSig.Add(&aggSig, &parsed[i].sig) + } + + valid := make([]VoteMessage, 0, len(batch)) + structurallyOK := make([]VoteMessage, 0, len(batch)) + for i := range batch { + if parsed[i].ok { + structurallyOK = append(structurallyOK, batch[i]) + } + } + if len(structurallyOK) == 0 { + return nil + } + if len(structurallyOK) == len(batch) { + if aggregatePairingOK(aggPub, payload, aggSig) { + return batch + } + } else { + // Rebuild aggregates over the structurally-valid subset only. + aggPub.SetInfinity() + aggSig.SetInfinity() + for i := range batch { + if parsed[i].ok { + aggPub.Add(&aggPub, &parsed[i].pub) + aggSig.Add(&aggSig, &parsed[i].sig) + } + } + if aggregatePairingOK(aggPub, payload, aggSig) { + return structurallyOK + } + } + + // Aggregate failed: bisect the structurally-valid subset. + if len(structurallyOK) == 1 { + return valid + } + mid := len(structurallyOK) / 2 + valid = append(valid, p.verifyBatch(structurallyOK[:mid], set)...) + valid = append(valid, p.verifyBatch(structurallyOK[mid:], set)...) + return valid +} + +func aggregatePairingOK(aggPub bls12381.G1Affine, payload []byte, aggSig bls12381.G2Affine) bool { + if aggPub.IsInfinity() { + return false + } + return verifyBLSSignature(aggPub, payload, aggSig) == nil +} + +// assembleLocked builds the wire-shaped certificate: aggregate signature = +// G2 sum across contributing tallies; bitmap base2 (base votes only) or base3 +// (fallback group non-empty — including a fallback-only cert with an EMPTY +// base group, mirroring Agave's empty-bitmap0 base3 encoding). Disjointness is +// guaranteed by per-rank verified-time tally membership. +func (p *CertPool) assembleLocked(slot uint64, target certTarget, base, fb *tally, set *ValidatorSet, verifiedStake uint64) (Certificate, error) { + length := len(set.Validators) + baseRanks := make([]bool, length) + var aggSig bls12381.G2Affine + aggSig.SetInfinity() + if base != nil { + for rank := range base.verified { + baseRanks[int(rank)] = true + } + aggSig.Add(&aggSig, &base.aggSig) + } + var bitmap []byte + var err error + if fb != nil && len(fb.verified) > 0 { + fbRanks := make([]bool, length) + for rank := range fb.verified { + if baseRanks[int(rank)] { + // Verified-time disjointness makes this unreachable; fail closed + // rather than emit a non-disjoint base3 bitmap. + return Certificate{}, fmt.Errorf("rank %d present in both base and fallback tallies", rank) + } + fbRanks[int(rank)] = true + } + aggSig.Add(&aggSig, &fb.aggSig) + bitmap, err = EncodeSignerStoreBitmap(SignerBitmap{Encoding: SignerBitmapBase3, Length: length, Base: baseRanks, Fallback: fbRanks}) + } else { + bitmap, err = EncodeSignerStoreBitmap(SignerBitmap{Encoding: SignerBitmapBase2, Length: length, Base: baseRanks}) + } + if err != nil { + return Certificate{}, err + } + + raw := aggSig.RawBytes() + cert := Certificate{ + Type: target.certType, + Slot: slot, + Signature: raw[:], + Bitmap: bitmap, + IncludedStake: verifiedStake, + TotalStake: set.TotalStake, + StakeVerified: true, + SignatureVerified: true, + } + if target.certType.HasBlock() { + cert.BlockHash = target.base.Hash + } + return cert, nil +} + +func (p *CertPool) emitCert(cert Certificate) { + if p.emit == nil { + return + } + mlog.Log.FileOnlyf("ALPENGLOW cert pool: assembled %s certificate for slot %d (stake %d/%d)", + cert.Type, cert.Slot, cert.IncludedStake, cert.TotalStake) + p.emit(cert) +} + +// pairedTallyKey returns the tally whose votes share a union certificate with +// this vote's tally (notarize <-> notarize-fallback on the same block, skip +// <-> skip-fallback), for the verified-time disjointness guard. +func pairedTallyKey(v Vote) (tallyKey, bool) { + switch v.Type { + case VoteTypeNotarize: + return tallyKey{Type: VoteTypeNotarizeFallback, Hash: v.BlockHash}, true + case VoteTypeNotarizeFallback: + return tallyKey{Type: VoteTypeNotarize, Hash: v.BlockHash}, true + case VoteTypeSkip: + return tallyKey{Type: VoteTypeSkipFallback}, true + case VoteTypeSkipFallback: + return tallyKey{Type: VoteTypeSkip}, true + default: + return tallyKey{}, false + } +} diff --git a/pkg/alpenglow/certpool_test.go b/pkg/alpenglow/certpool_test.go new file mode 100644 index 000000000..2fcc440e5 --- /dev/null +++ b/pkg/alpenglow/certpool_test.go @@ -0,0 +1,598 @@ +package alpenglow + +import ( + "math/big" + "testing" + + bls12381 "github.com/Overclock-Validator/gnark-crypto/ecc/bls12-381" + "github.com/gagliardetto/solana-go" +) + +// signTestVote produces one rank's 192-byte uncompressed BLS signature. +func signTestVote(t *testing.T, vote Vote, key *big.Int) []byte { + t.Helper() + payload, err := EncodeVote(vote) + if err != nil { + t.Fatalf("encode vote: %v", err) + } + message, err := bls12381.HashToG2(payload, []byte(blsHashToPointDST)) + if err != nil { + t.Fatalf("hash to g2: %v", err) + } + var signed bls12381.G2Affine + signed.ScalarMultiplication(&message, key) + raw := signed.RawBytes() + return raw[:] +} + +// newTestPool builds a pool over a 5-validator set (stakes 40/30/15/10/5 of +// 100) with an emit-capture callback. +func newTestPool(t *testing.T) (*CertPool, ValidatorSet, []*big.Int, *[]Certificate) { + t.Helper() + set, keys := testBLSValidatorSet(100, 40, 30, 15, 10, 5) + verifier := NewCertificateVerifier() + if err := verifier.SetValidatorSet(set); err != nil { + t.Fatalf("set validator set: %v", err) + } + var emitted []Certificate + pool := NewCertPool(DefaultCertPoolConfig(), verifier, func(c Certificate) { emitted = append(emitted, c) }) + pool.SetEpochLookup(func(uint64) uint64 { return set.Epoch }) + // Open the trusted vote window past every slot the tests use (the window is + // anchored to replay-observed progress, never to raw vote slots). + pool.NoteLiveSlot(2000) + return pool, set, keys, &emitted +} + +func addVote(t *testing.T, pool *CertPool, vote Vote, rank uint16, key *big.Int) { + t.Helper() + pool.AddVote(VoteMessage{Vote: vote, Rank: rank, Signature: signTestVote(t, vote, key)}) +} + +// 60% of notarize stake assembles a notarize certificate that passes the full +// production verifier — the strongest pool invariant. +func TestCertPoolAssemblesNotarizeCert(t *testing.T) { + pool, set, keys, emitted := newTestPool(t) + var blockHash solana.Hash + blockHash[0] = 0xAB + vote := NewNotarizationVote(500, blockHash) + + addVote(t, pool, vote, 0, keys[0]) // 40% — below 60% + if len(*emitted) != 0 { + t.Fatalf("no cert expected at 40%%, got %d", len(*emitted)) + } + addVote(t, pool, vote, 1, keys[1]) // 70% — notarize crosses + + var notarize *Certificate + for i := range *emitted { + if (*emitted)[i].Type == CertificateNotarize { + notarize = &(*emitted)[i] + } + } + if notarize == nil { + t.Fatalf("expected a notarize certificate, emitted: %+v", *emitted) + } + if notarize.Slot != 500 || notarize.BlockHash != blockHash { + t.Fatalf("wrong cert identity: %+v", notarize) + } + if _, _, err := verifyCertificateWithSet(set, *notarize, true); err != nil { + t.Fatalf("pool-assembled cert failed the production verifier: %v", err) + } +} + +// 80% of notarize stake additionally assembles a finalize-fast certificate. +func TestCertPoolAssemblesFinalizeFastAtEightyPercent(t *testing.T) { + pool, set, keys, emitted := newTestPool(t) + var blockHash solana.Hash + blockHash[0] = 0xCD + vote := NewNotarizationVote(600, blockHash) + + addVote(t, pool, vote, 0, keys[0]) // 40 + addVote(t, pool, vote, 1, keys[1]) // 70 -> notarize emits + hasFast := func() bool { + for _, c := range *emitted { + if c.Type == CertificateFinalizeFast { + return true + } + } + return false + } + if hasFast() { + t.Fatal("finalize-fast must not emit below 80%") + } + addVote(t, pool, vote, 2, keys[2]) // 85 -> fast crosses + if !hasFast() { + t.Fatalf("expected finalize-fast at 85%%, emitted: %+v", *emitted) + } + for _, c := range *emitted { + if _, _, err := verifyCertificateWithSet(set, c, true); err != nil { + t.Fatalf("%s cert failed verification: %v", c.Type, err) + } + } +} + +// A contested slot can produce ONLY fallback votes for a block (everyone who +// cast plain notarize voted for a sibling). Agave assembles the NotarizeFallback +// cert from fallback votes alone — base3 with an empty base bitmap — and so +// must the pool. +func TestCertPoolAssemblesFallbackOnlyNotarizeFallbackCert(t *testing.T) { + pool, set, keys, emitted := newTestPool(t) + var blockHash solana.Hash + blockHash[0] = 0x44 + fb := NewNotarizationFallbackVote(650, blockHash) + + addVote(t, pool, fb, 0, keys[0]) // 40% — below 60 + if len(*emitted) != 0 { + t.Fatalf("no cert at 40%%, got %+v", *emitted) + } + addVote(t, pool, fb, 1, keys[1]) // 70% — crosses with ZERO plain notarize votes + + var cert *Certificate + for i := range *emitted { + if (*emitted)[i].Type == CertificateNotarizeFallback { + cert = &(*emitted)[i] + } + } + if cert == nil { + t.Fatalf("expected a fallback-only notarize-fallback certificate, emitted: %+v", *emitted) + } + bitmap, err := DecodeSignerStoreBitmap(cert.Bitmap, len(set.Validators)) + if err != nil { + t.Fatalf("decode bitmap: %v", err) + } + if bitmap.Encoding != SignerBitmapBase3 { + t.Fatalf("fallback-only cert must use base3 (empty base group), got %s", bitmap.Encoding) + } + for i, b := range bitmap.Base { + if b { + t.Fatalf("base group must be empty for a fallback-only cert (rank %d set)", i) + } + } + if _, _, err := verifyCertificateWithSet(set, *cert, true); err != nil { + t.Fatalf("fallback-only cert failed the production verifier: %v", err) + } +} + +// Same for skip: skip-fallback votes alone assemble the skip certificate. +func TestCertPoolAssemblesFallbackOnlySkipCert(t *testing.T) { + pool, set, keys, emitted := newTestPool(t) + skipFB := NewSkipFallbackVote(660) + + addVote(t, pool, skipFB, 0, keys[0]) // 40 + addVote(t, pool, skipFB, 1, keys[1]) // 70 — crosses with ZERO plain skip votes + + var cert *Certificate + for i := range *emitted { + if (*emitted)[i].Type == CertificateSkip { + cert = &(*emitted)[i] + } + } + if cert == nil { + t.Fatalf("expected a fallback-only skip certificate, emitted: %+v", *emitted) + } + if _, _, err := verifyCertificateWithSet(set, *cert, true); err != nil { + t.Fatalf("fallback-only skip cert failed the production verifier: %v", err) + } +} + +// Skip + skip-fallback assemble a base3 union certificate. +func TestCertPoolAssemblesBase3SkipCert(t *testing.T) { + pool, set, keys, emitted := newTestPool(t) + skip := NewSkipVote(700) + skipFB := NewSkipFallbackVote(700) + + addVote(t, pool, skip, 0, keys[0]) // 40 base + addVote(t, pool, skipFB, 1, keys[1]) // +30 fallback = 70 union + + var cert *Certificate + for i := range *emitted { + if (*emitted)[i].Type == CertificateSkip { + cert = &(*emitted)[i] + } + } + if cert == nil { + t.Fatalf("expected a skip certificate, emitted: %+v", *emitted) + } + bitmap, err := DecodeSignerStoreBitmap(cert.Bitmap, len(set.Validators)) + if err != nil { + t.Fatalf("decode bitmap: %v", err) + } + if bitmap.Encoding != SignerBitmapBase3 { + t.Fatalf("expected base3 bitmap, got %s", bitmap.Encoding) + } + if err := bitmap.CheckDisjoint(); err != nil { + t.Fatalf("bitmap not disjoint: %v", err) + } + if _, _, err := verifyCertificateWithSet(set, *cert, true); err != nil { + t.Fatalf("pool-assembled base3 cert failed verification: %v", err) + } +} + +// A corrupted signature in the batch is bisected out; the cert still emits +// once enough honest stake arrives. +func TestCertPoolBisectsBadSignature(t *testing.T) { + pool, set, keys, emitted := newTestPool(t) + var blockHash solana.Hash + blockHash[0] = 0xEE + vote := NewNotarizationVote(800, blockHash) + + // Rank 2 (15%) signs the WRONG vote (valid point, wrong payload). + wrong := NewNotarizationVote(801, blockHash) + pool.AddVote(VoteMessage{Vote: vote, Rank: 2, Signature: signTestVote(t, wrong, keys[2])}) + addVote(t, pool, vote, 0, keys[0]) // 40 good + addVote(t, pool, vote, 3, keys[3]) // +10 good; candidate 65 crosses, verified only 50 + + if len(*emitted) != 0 { + t.Fatalf("cert must not emit on 50%% verified stake, got %+v", *emitted) + } + if pool.Snapshot().BadSignatures == 0 { + t.Fatal("bad signature must be detected and counted") + } + + addVote(t, pool, vote, 1, keys[1]) // +30 good -> verified 80 + var notarize *Certificate + for i := range *emitted { + if (*emitted)[i].Type == CertificateNotarize { + notarize = &(*emitted)[i] + } + } + if notarize == nil { + t.Fatalf("expected notarize cert after honest quorum, emitted: %+v", *emitted) + } + if _, _, err := verifyCertificateWithSet(set, *notarize, true); err != nil { + t.Fatalf("cert with bisected-out bad vote failed verification: %v", err) + } + // The bad rank must not be in the bitmap. + bitmap, err := DecodeSignerStoreBitmap(notarize.Bitmap, len(set.Validators)) + if err != nil { + t.Fatalf("decode bitmap: %v", err) + } + if bitmap.Base[2] { + t.Fatal("rank with bad signature must be excluded from the certificate") + } +} + +// Conflicting same-type votes are equivocation evidence recorded at VERIFIED +// time (both signatures check out), and the second block is not counted. The +// evidence is detected once both blocks' tallies fold — the security-relevant +// case where an equivocator's stake actually matters. +func TestCertPoolEquivocationEvidence(t *testing.T) { + pool, _, keys, _ := newTestPool(t) + var h1, h2 solana.Hash + h1[0], h2[0] = 1, 2 + + // Rank 0 (40%) validly signs BOTH blocks. Both tallies must fold for the + // equivocation to be observed: h1 via ranks 0+1 (70%), h2 via ranks 0+2+3. + addVote(t, pool, NewNotarizationVote(900, h1), 0, keys[0]) // h1: 40 pending + addVote(t, pool, NewNotarizationVote(900, h2), 0, keys[0]) // h2: 40 pending + addVote(t, pool, NewNotarizationVote(900, h1), 1, keys[1]) // h1: 70 -> folds; rank0 verified for h1 + addVote(t, pool, NewNotarizationVote(900, h2), 2, keys[2]) // h2: 55 pending + addVote(t, pool, NewNotarizationVote(900, h2), 3, keys[3]) // h2: 65 -> folds; rank0 equivocation + + ev := pool.EquivocationEvidence() + if len(ev) != 1 { + t.Fatalf("expected 1 evidence entry, got %d: %+v", len(ev), ev) + } + if ev[0].Rank != 0 || ev[0].Slot != 900 || ev[0].First != h1 || ev[0].Second != h2 { + t.Fatalf("wrong evidence: %+v", ev[0]) + } + if pool.Snapshot().VotesEquivocated != 1 { + t.Fatal("equivocation counter must increment") + } +} + +// A bogus vote for a victim rank must NOT suppress that validator's real vote +// (dedupe-poisoning). The bogus vote never verifies, so it never touches the +// equivocation/dedupe ledger; the real vote still assembles its certificate. +func TestCertPoolBogusVoteDoesNotPoisonRealVote(t *testing.T) { + pool, set, keys, emitted := newTestPool(t) + var real, bogus solana.Hash + real[0], bogus[0] = 0xA0, 0xB0 + + // Attacker forges a vote for rank 0 on a bogus block, signed with the WRONG + // key (rank 4's key) — a bad signature for rank 0. + pool.AddVote(VoteMessage{Vote: NewNotarizationVote(950, bogus), Rank: 0, Signature: signTestVote(t, NewNotarizationVote(950, bogus), keys[4])}) + + // Rank 0's REAL vote for the real block, then rank 1 — 70% crosses. + addVote(t, pool, NewNotarizationVote(950, real), 0, keys[0]) + addVote(t, pool, NewNotarizationVote(950, real), 1, keys[1]) + + var notarize *Certificate + for i := range *emitted { + if (*emitted)[i].Type == CertificateNotarize && (*emitted)[i].BlockHash == real { + notarize = &(*emitted)[i] + } + } + if notarize == nil { + t.Fatalf("real vote must still assemble despite the bogus vote; emitted: %+v", *emitted) + } + if _, _, err := verifyCertificateWithSet(set, *notarize, true); err != nil { + t.Fatalf("assembled cert failed verification: %v", err) + } + // No equivocation was recorded against the honest rank 0. + if len(pool.EquivocationEvidence()) != 0 { + t.Fatalf("bogus vote must not forge equivocation, got %+v", pool.EquivocationEvidence()) + } + // Rank 0 is in the certificate (its real vote counted). + bitmap, err := DecodeSignerStoreBitmap(notarize.Bitmap, len(set.Validators)) + if err != nil { + t.Fatal(err) + } + if !bitmap.Base[0] { + t.Fatal("honest rank 0's real vote must be counted") + } +} + +// DoS bounds: votes outside the slot window and past the pending cap reject. +func TestCertPoolIngestBounds(t *testing.T) { + set, keys := testBLSValidatorSet(100, 40, 30, 15, 10, 5) + verifier := NewCertificateVerifier() + if err := verifier.SetValidatorSet(set); err != nil { + t.Fatal(err) + } + pool := NewCertPool(CertPoolConfig{MaxSlotsAhead: 10, MaxPendingVotesPerSlot: 2, EquivocationCap: 4}, verifier, nil) + pool.SetEpochLookup(func(uint64) uint64 { return set.Epoch }) + pool.NoteLiveSlot(100) // trusted window anchor at slot 100 + + // Below-threshold votes stay PENDING (nothing folds them), so the cap is + // observable: ranks 2+3 hold 25%% of stake, well under any threshold. + addVote(t, pool, NewFinalizationVote(100), 2, keys[2]) + pool.AddVote(VoteMessage{Vote: NewSkipVote(500), Rank: 1, Signature: signTestVote(t, NewSkipVote(500), keys[1])}) // beyond anchor(100)+10 + if pool.Snapshot().VotesRejected == 0 { + t.Fatal("far-future vote must reject") + } + + // Pending cap: third distinct pending vote on the slot rejects. + addVote(t, pool, NewSkipVote(100), 3, keys[3]) + before := pool.Snapshot().VotesRejected + pool.AddVote(VoteMessage{Vote: NewSkipFallbackVote(100), Rank: 4, Signature: signTestVote(t, NewSkipFallbackVote(100), keys[4])}) + if pool.Snapshot().VotesRejected != before+1 { + t.Fatal("pending cap must reject the overflow vote") + } + + // Floor: pruned slots reject. + pool.ObserveFloor(150) + before = pool.Snapshot().VotesRejected + addVote(t, pool, NewSkipVote(120), 0, keys[0]) + if pool.Snapshot().VotesRejected != before+1 { + t.Fatal("vote at or below the floor must reject") + } + if pool.Snapshot().Slots != 0 { + t.Fatal("floor must prune retained slots") + } +} + +// Votes buffered before the epoch's validator set installs assemble +// retroactively when it lands. +func TestCertPoolDeferredEpochAssembly(t *testing.T) { + set, keys := testBLSValidatorSet(100, 40, 30, 15, 10, 5) + verifier := NewCertificateVerifier() + var emitted []Certificate + pool := NewCertPool(DefaultCertPoolConfig(), verifier, func(c Certificate) { emitted = append(emitted, c) }) + pool.SetEpochLookup(func(uint64) uint64 { return set.Epoch }) + pool.NoteLiveSlot(1000) + + var blockHash solana.Hash + blockHash[0] = 0x77 + vote := NewNotarizationVote(1000, blockHash) + addVote(t, pool, vote, 0, keys[0]) + addVote(t, pool, vote, 1, keys[1]) + if len(emitted) != 0 { + t.Fatal("nothing can assemble before the validator set installs") + } + + if err := verifier.SetValidatorSet(set); err != nil { + t.Fatal(err) + } + pool.OnValidatorSetInstalled(set.Epoch) + if len(emitted) == 0 { + t.Fatal("buffered votes must assemble once the set installs") + } +} + +// Without a real slot→epoch lookup the pool must NOT assemble (it must never +// guess an epoch / validator set), but it must assemble retroactively once the +// lookup is wired — fail-safe, not fail-broken. +func TestCertPoolMissingEpochLookupDoesNotAssemble(t *testing.T) { + set, keys := testBLSValidatorSet(100, 40, 30, 15, 10, 5) + verifier := NewCertificateVerifier() + if err := verifier.SetValidatorSet(set); err != nil { + t.Fatal(err) + } + var emitted []Certificate + pool := NewCertPool(DefaultCertPoolConfig(), verifier, func(c Certificate) { emitted = append(emitted, c) }) + // No SetEpochLookup, but seed the window directly. + pool.NoteLiveSlot(1200) + + var blockHash solana.Hash + blockHash[0] = 0x33 + vote := NewNotarizationVote(1200, blockHash) + addVote(t, pool, vote, 0, keys[0]) + addVote(t, pool, vote, 1, keys[1]) // 70% — would cross, but no epoch map + if len(emitted) != 0 { + t.Fatalf("must not assemble without a slot→epoch lookup, got %+v", emitted) + } + // A retry with the epoch still unresolved also assembles nothing. + pool.OnValidatorSetInstalled(set.Epoch) + if len(emitted) != 0 { + t.Fatal("OnValidatorSetInstalled must not guess slot epochs") + } + // Wire the lookup and retry: the buffered quorum now assembles. + pool.SetEpochLookup(func(uint64) uint64 { return set.Epoch }) + pool.OnValidatorSetInstalled(set.Epoch) + if len(emitted) == 0 { + t.Fatal("buffered quorum must assemble once the epoch lookup is installed") + } +} + +// Future-slot spam cannot grow the pool without bound: distinct future slots +// are capped by MaxLiveSlots and total buffered votes by MaxPendingVotesTotal. +func TestCertPoolGlobalMemoryBounds(t *testing.T) { + set, keys := testBLSValidatorSet(100, 40, 30, 15, 10, 5) + verifier := NewCertificateVerifier() + if err := verifier.SetValidatorSet(set); err != nil { + t.Fatal(err) + } + pool := NewCertPool(CertPoolConfig{ + MaxSlotsAhead: 1000, MaxPendingVotesPerSlot: 10, MaxLiveSlots: 3, MaxPendingVotesTotal: 4, + }, verifier, nil) + pool.SetEpochLookup(func(uint64) uint64 { return set.Epoch }) + pool.NoteLiveSlot(2000) + + // Spam distinct future slots: only MaxLiveSlots (3) may be retained, and the + // global pending cap (4) stops buffering regardless of the per-slot cap. + for slot := uint64(2001); slot <= 2010; slot++ { + addVote(t, pool, NewSkipVote(slot), 0, keys[0]) + } + snap := pool.Snapshot() + if snap.Slots > 3 { + t.Fatalf("MaxLiveSlots must cap retained slots, got %d", snap.Slots) + } + if snap.PendingTotal > 4 { + t.Fatalf("MaxPendingVotesTotal must cap buffered votes, got %d", snap.PendingTotal) + } + if snap.VotesRejected == 0 { + t.Fatal("spam beyond the bounds must be rejected") + } +} + +// Votor trigger freshness: SafeToNotar's MIXED condition (notar(b) >= 20% AND +// notar(b)+skip >= 60%) crosses on CANDIDATE stake -> both involved tallies +// fold immediately, so a voting engine reading VerifiedVotorStakes sees the +// predicate pass at the same moment an eager-verification node would — even +// though NO certificate threshold was reached. This is the cross-tally case a +// per-tally fold floor would miss. +func TestCertPoolFoldsForSafeToNotarMixedCondition(t *testing.T) { + pool, _, keys, emitted := newTestPool(t) + var b solana.Hash + b[0] = 0x21 + // notar(b) = ranks 2+3 = 25% (>=20, <40); skip = rank 0 = 40% (<60 alone). + addVote(t, pool, NewNotarizationVote(300, b), 2, keys[2]) + addVote(t, pool, NewNotarizationVote(300, b), 3, keys[3]) + + stakes, ok := pool.VerifiedVotorStakes(300) + if !ok { + t.Fatal("validator set must be resolvable") + } + if stakes.Notarize[b] != 0 { + t.Fatalf("below every trigger, nothing should be verified yet (lazy), got %d", stakes.Notarize[b]) + } + + // The skip arrival makes the mixed condition cross: 25+40 = 65 >= 60. + addVote(t, pool, NewSkipVote(300), 0, keys[0]) + + stakes, ok = pool.VerifiedVotorStakes(300) + if !ok { + t.Fatal("validator set must be resolvable") + } + if stakes.Notarize[b] != 25 || stakes.Skip != 40 { + t.Fatalf("mixed-condition crossing must fold both tallies: notar=%d skip=%d", stakes.Notarize[b], stakes.Skip) + } + if len(*emitted) != 0 { + t.Fatalf("no certificate threshold was reached; emitted %+v", *emitted) + } +} + +// SafeToSkip (skip + notarTotal - topNotar >= 40%) involves EVERY notarize +// tally; its crossing folds them all — including a sibling too small to +// trigger anything on its own. +func TestCertPoolFoldsForSafeToSkip(t *testing.T) { + pool, _, keys, _ := newTestPool(t) + var a, b solana.Hash + a[0], b[0] = 0xA1, 0xB1 + addVote(t, pool, NewNotarizationVote(310, a), 1, keys[1]) // 30% + addVote(t, pool, NewNotarizationVote(310, b), 2, keys[2]) // 15% — fails every per-block trigger + // skip 40%: SafeToSkip = 40 + (45-30) = 55 >= 40 -> fold skip + ALL notar. + addVote(t, pool, NewSkipVote(310), 0, keys[0]) + + stakes, ok := pool.VerifiedVotorStakes(310) + if !ok { + t.Fatal("validator set must be resolvable") + } + if stakes.Notarize[a] != 30 || stakes.Notarize[b] != 15 || stakes.Skip != 40 { + t.Fatalf("SafeToSkip crossing must fold all trigger tallies: a=%d b=%d skip=%d", + stakes.Notarize[a], stakes.Notarize[b], stakes.Skip) + } + if stakes.NotarizeTotal != 45 || stakes.TopNotarize != 30 { + t.Fatalf("aggregates wrong: total=%d top=%d", stakes.NotarizeTotal, stakes.TopNotarize) + } +} + +// Below every trigger, verification stays lazy: nothing folds, votes buffer as +// candidate stake only. +func TestCertPoolStaysLazyBelowTriggers(t *testing.T) { + pool, _, keys, _ := newTestPool(t) + var c solana.Hash + c[0] = 0xC1 + addVote(t, pool, NewNotarizationVote(320, c), 4, keys[4]) // 5% + addVote(t, pool, NewSkipVote(320), 3, keys[3]) // 10%; SafeToSkip = 10+0 < 40 + + stakes, ok := pool.VerifiedVotorStakes(320) + if !ok { + t.Fatal("validator set must be resolvable") + } + if stakes.Notarize[c] != 0 || stakes.Skip != 0 { + t.Fatalf("sub-trigger tallies must stay unverified (lazy): %+v", stakes) + } + if pool.Snapshot().PendingTotal != 2 { + t.Fatalf("votes must remain buffered as candidates, pending=%d", pool.Snapshot().PendingTotal) + } +} + +// Bitmap encode/decode round-trips exactly for both encodings. +func TestEncodeSignerStoreBitmapRoundTrip(t *testing.T) { + b2 := SignerBitmap{Encoding: SignerBitmapBase2, Length: 11, Base: make([]bool, 11)} + b2.Base[0], b2.Base[3], b2.Base[10] = true, true, true + enc, err := EncodeSignerStoreBitmap(b2) + if err != nil { + t.Fatal(err) + } + dec, err := DecodeSignerStoreBitmap(enc, 11) + if err != nil { + t.Fatal(err) + } + for i := range b2.Base { + if dec.Base[i] != b2.Base[i] { + t.Fatalf("base2 bit %d mismatch", i) + } + } + + b3 := SignerBitmap{Encoding: SignerBitmapBase3, Length: 7, Base: make([]bool, 7), Fallback: make([]bool, 7)} + b3.Base[1], b3.Base[6] = true, true + b3.Fallback[0], b3.Fallback[4] = true, true + enc, err = EncodeSignerStoreBitmap(b3) + if err != nil { + t.Fatal(err) + } + dec, err = DecodeSignerStoreBitmap(enc, 7) + if err != nil { + t.Fatal(err) + } + for i := 0; i < 7; i++ { + if dec.Base[i] != b3.Base[i] || dec.Fallback[i] != b3.Fallback[i] { + t.Fatalf("base3 symbol %d mismatch", i) + } + } + + // Non-disjoint base3 must refuse to encode. + bad := SignerBitmap{Encoding: SignerBitmapBase3, Length: 2, Base: []bool{true, false}, Fallback: []bool{true, false}} + if _, err := EncodeSignerStoreBitmap(bad); err == nil { + t.Fatal("non-disjoint base3 must fail to encode") + } +} + +// Duplicate certs are emitted once; identical re-votes are deduped. +func TestCertPoolEmitsOnce(t *testing.T) { + pool, _, keys, emitted := newTestPool(t) + var blockHash solana.Hash + blockHash[0] = 0x55 + vote := NewNotarizationVote(1100, blockHash) + + addVote(t, pool, vote, 0, keys[0]) + addVote(t, pool, vote, 1, keys[1]) + n := len(*emitted) + addVote(t, pool, vote, 1, keys[1]) // exact duplicate + addVote(t, pool, vote, 4, keys[4]) // more stake, same certs already out (fast needs 80: 40+30+5=75, no) + if len(*emitted) != n { + t.Fatalf("no new certs expected, went from %d to %d", n, len(*emitted)) + } +} diff --git a/pkg/alpenglow/chain.go b/pkg/alpenglow/chain.go index 881ee0af7..6b2f9417d 100644 --- a/pkg/alpenglow/chain.go +++ b/pkg/alpenglow/chain.go @@ -1,7 +1,9 @@ package alpenglow import ( + "bytes" "fmt" + "sort" "sync" "time" @@ -103,6 +105,14 @@ type ChainTracker struct { finalizeCerts map[uint64]Certificate directFinalized map[BlockID]CertificateType chainFinalized map[BlockID]struct{} // finalized by ancestry of a finalized block + // finalizedBySlot indexes the finalized block PER SLOT (direct or by + // ancestry) so slot-keyed decision queries (CertifiedBlockAt, WantedBlocks) + // can surface a finalized block even when it never received a certificate + // of its own — blockSlots indexes certified blocks only, and a cert-less + // ancestry-finalized parent would otherwise be invisible to the switch + // sweep. First finalized block wins; a DIFFERENT finalized block at the + // same slot is Byzantine and belongs to the conflict machinery. + finalizedBySlot map[uint64]BlockID indirectSkips map[uint64]chainIndirectSkip conflicts map[uint64]chainConflict @@ -113,6 +123,23 @@ type ChainTracker struct { latestCertificateSlot uint64 latestObservedBlock BlockID latestDirectFinalizedBlock BlockID + + // decisionVersion increments whenever the tracker becomes more decisive in a + // way that could contradict an already-executed slot — not just on cert + // acceptance but also on replay-derived parent links, finalized ancestry, + // indirect skips, and conflicts. The execute-on-receipt switch sweep gates on + // it, so it never misses a contradiction that arose without a new certificate. + decisionVersion uint64 +} + +// bumpDecisionLocked marks that a decision-relevant change occurred. +func (t *ChainTracker) bumpDecisionLocked() { t.decisionVersion++ } + +// DecisionVersion returns the monotonic decision-change counter. +func (t *ChainTracker) DecisionVersion() uint64 { + t.mu.RLock() + defer t.mu.RUnlock() + return t.decisionVersion } type chainBlockState struct { @@ -151,6 +178,7 @@ func NewChainTrackerWithConfig(cfg ChainConfig) *ChainTracker { directFinalized: make(map[BlockID]CertificateType), chainFinalized: make(map[BlockID]struct{}), indirectSkips: make(map[uint64]chainIndirectSkip), + finalizedBySlot: make(map[uint64]BlockID), conflicts: make(map[uint64]chainConflict), } } @@ -215,6 +243,7 @@ func (t *ChainTracker) ObserveReplayBlock(obs ReplayBlockObservation) ChainRepla state := t.ensureBlockStateLocked(obs.Block) wasObserved := state.observed state.observed = true + prevParentSlot, prevParentHash := state.parentSlot, state.parentHash // Parent slot 0 means unknown — never clobber a known parent link with it, and // never replace a known parent hash with zero (indirect-skip and ancestry // derivation depend on the link surviving). @@ -229,14 +258,27 @@ func (t *ChainTracker) ObserveReplayBlock(obs ReplayBlockObservation) ChainRepla state.parentHash = obs.ParentHash } } + parentChanged := state.parentSlot != prevParentSlot || state.parentHash != prevParentHash + derived := false if certType, finalized := t.directFinalized[obs.Block]; finalized { t.deriveIndirectSkipsLocked(obs.Block, certType) // The cert may have arrived before this observation supplied the parent // link — ancestry marking needs the link, so re-run it now. t.markChainFinalizedAncestorsLocked(obs.Block) + derived = true } else if _, chainFin := t.chainFinalized[obs.Block]; chainFin { t.markChainFinalizedAncestorsLocked(obs.Block) + derived = true + } + + // A new parent link or a freshly-derived finalized-ancestry / indirect-skip + // can contradict an executed slot without any new certificate — advance the + // decision version so the switch sweep re-runs. A bare observation with no + // link and no derivation changes nothing the sweep reads (it consults + // certificates and derived skips), so it does not bump. + if parentChanged || derived { + t.bumpDecisionLocked() } return ChainReplayBlockUpdate{New: !wasObserved, Snapshot: t.snapshotLocked()} @@ -311,6 +353,9 @@ func (t *ChainTracker) applyTrustedCertificateLocked(cert Certificate) { t.skipCerts[cert.Slot] = cert t.refreshConflictLocked(cert.Slot) } + // A trusted cert (and everything it just derived) can newly contradict an + // executed slot — advance the decision version so the switch sweep re-runs. + t.bumpDecisionLocked() } func (t *ChainTracker) applyTrustedBlockCertificateLocked(cert Certificate) { @@ -370,6 +415,9 @@ func (t *ChainTracker) markDirectFinalizedLocked(block BlockID, certType Certifi return } t.directFinalized[block] = certType + if _, taken := t.finalizedBySlot[block.Slot]; !taken { + t.finalizedBySlot[block.Slot] = block + } if block.Slot >= t.latestDirectFinalizedBlock.Slot { t.latestDirectFinalizedBlock = block // Bound memory on long runs: finalized slots well behind the watermark are @@ -393,9 +441,9 @@ func (t *ChainTracker) markChainFinalizedAncestorsLocked(block BlockID) { return } parent := BlockID{Slot: state.parentSlot, Hash: state.parentHash} - if _, known := t.blocks[parent]; !known || state.parentHash.IsZero() { - // Parent hash unknown to the cert index. Fall back to the parent slot's - // single certified block ONLY if it carries a unique-strength cert + if state.parentHash.IsZero() { + // No parent hash at all. Fall back to the parent slot's single + // certified block ONLY if it carries a unique-strength cert // (notarize/fast-finalize/genesis — provably the slot's one block, // Lemmas 21(i)/24). A fallback-only cert could be an equivocation twin. slotBlocks := t.blockSlots[state.parentSlot] @@ -405,11 +453,6 @@ func (t *ChainTracker) markChainFinalizedAncestorsLocked(block BlockID) { for _, id := range slotBlocks { parent = id } - // A known parent hash that simply isn't cert-indexed still binds: never - // mark a different block than the one the child actually chains to. - if !state.parentHash.IsZero() && parent.Hash != state.parentHash { - return - } st := t.blocks[parent] if st == nil { return @@ -419,11 +462,23 @@ func (t *ChainTracker) markChainFinalizedAncestorsLocked(block BlockID) { default: return } + } else if _, known := t.blocks[parent]; !known { + // The finalized child's header names its parent hash EXACTLY, but no + // cert or replay observation tracks that block yet (e.g. replay + // executed an equivocation twin, or the block was never fetched). + // The hash binding is protocol-final — mint a stub so the finalized + // identity is queryable (CertifiedBlockAt) and repairable + // (WantedBlocks). The walk stops at the stub (not observed, no + // parent link of its own) on the next iteration. + t.ensureBlockStateLocked(parent) } if _, done := t.chainFinalized[parent]; done { return } t.chainFinalized[parent] = struct{}{} + if _, taken := t.finalizedBySlot[parent.Slot]; !taken { + t.finalizedBySlot[parent.Slot] = parent + } // Ancestry finalization creates the same exclusivity as direct finalization. t.refreshConflictLocked(parent.Slot) block = parent @@ -478,6 +533,181 @@ func (t *ChainTracker) FinalityConflictAt(slot uint64) bool { // PruneBeforeSlot drops all tracker state for slots strictly below slot, bounding // memory on a long-running node. Pruning runs automatically behind finality; this // exported form lets a caller prune explicitly (e.g. behind the rooted watermark). +// CertifiedBlockAt returns the slot's DECISIVELY certified block: one backed +// by a unique-strength certificate (notarize / finalize-fast / genesis — at +// most one per slot by protocol, Lemma 21(i)/24) or finalized directly or by +// ancestry. Fallback-only candidates are ambiguous (up to 7 can legally +// coexist) and never returned. This is the execute-on-receipt switch signal: +// an executed block contradicting the decisive block must be unwound. +func (t *ChainTracker) CertifiedBlockAt(slot uint64) (BlockID, CertificateType, bool) { + t.mu.RLock() + defer t.mu.RUnlock() + var winner BlockID + var winnerType CertificateType + found := false + // A finalized block (direct or by ancestry) is decisive even when it never + // received a certificate of its own — the cert-less ancestry-finalized + // parent case, which the cert-only blockSlots scan below cannot see. + if fin, ok := t.finalizedBySlot[slot]; ok { + winner, found = fin, true + if state := t.blocks[fin]; state != nil { + winnerType = strongestBlockCertificateType(state.certificates) + } + } + // Iterate the slot's blocks directly (no candidate-slice allocation): the + // switch sweep calls this for every executed-unfolded slot whenever the + // tracker's decision version advances — which on a healthy cluster is + // nearly every block. + for _, block := range t.blockSlots[slot] { + state := t.blocks[block] + if state == nil { + continue + } + certType := strongestBlockCertificateType(state.certificates) + decisive := false + switch certType { + case CertificateFinalizeFast, CertificateNotarize, CertificateGenesis: + decisive = true + } + if !decisive { + if _, fin := t.directFinalized[block]; fin { + decisive = true + } else if _, fin := t.chainFinalized[block]; fin { + decisive = true + } + } + if !decisive { + continue + } + if found && winner != block { + // Two decisive blocks in one slot is Byzantine evidence; the + // conflict machinery owns it — report no decisive block here. + return BlockID{}, "", false + } + winner, winnerType, found = block, certType, true + } + return winner, winnerType, found +} + +// SkipCertifiedAt reports whether the slot is certified skipped, explicitly +// (skip cert) or indirectly (omitted between finalized ancestors). +func (t *ChainTracker) SkipCertifiedAt(slot uint64) bool { + t.mu.RLock() + defer t.mu.RUnlock() + if _, ok := t.skipCerts[slot]; ok { + return true + } + _, ok := t.indirectSkips[slot] + return ok +} + +// WantedBlock names a certified block whose data replay has not observed yet — +// the target of cert-driven repair. +type WantedBlock struct { + Block BlockID + Strongest CertificateType + Finalized bool +} + +// wantedPriority ranks a slot's candidates for repair: a finalized block wins, +// then a unique-strength certificate (notarize / fast-finalize / genesis), then +// a fallback. -1 means it is not a repair target. Picking the highest-priority +// candidate per slot (rather than the lowest hash) keeps the repair loop — which +// nudges at most once per slot — aimed at the DECISIVE block, not a fallback +// sibling that merely happens to sort first. +func wantedPriority(ct CertificateType, finalized bool) int { + if finalized { + return 3 + } + switch ct { + case CertificateFinalizeFast, CertificateNotarize, CertificateGenesis: + return 2 + case CertificateNotarizeFallback: + return 1 + default: + return -1 + } +} + +// WantedBlocks returns ONE certified-but-unobserved repair target per slot +// strictly above afterSlot, ascending by slot, capped at max. Within a slot the +// most decisive candidate is chosen (finalized > unique-strength > fallback), +// tie-broken by lowest hash for determinism — so a fallback is targeted only +// when no decisive candidate exists. Skip-certified slots are excluded unless +// the block is finalized (finality outranks a skip; the illegal coexistence is +// the conflict machinery's to flag). The scan is bounded by the tracker's +// retention window and the <= 7 certified candidates per slot protocol bound. +func (t *ChainTracker) WantedBlocks(afterSlot uint64, max int) []WantedBlock { + if max <= 0 { + return nil + } + t.mu.RLock() + defer t.mu.RUnlock() + + slots := make([]uint64, 0, len(t.blockSlots)) + seenSlot := make(map[uint64]struct{}, len(t.blockSlots)) + for slot := range t.blockSlots { + if slot > afterSlot { + slots = append(slots, slot) + seenSlot[slot] = struct{}{} + } + } + // A cert-less ancestry-finalized block's slot may have NO certified blocks + // at all — it must still be repairable (it is the decisive block). + for slot := range t.finalizedBySlot { + if slot > afterSlot { + if _, dup := seenSlot[slot]; !dup { + slots = append(slots, slot) + } + } + } + sort.Slice(slots, func(i, j int) bool { return slots[i] < slots[j] }) + + out := make([]WantedBlock, 0, min(max, len(slots))) + for _, slot := range slots { + if len(out) >= max { + break + } + _, skipped := t.skipCerts[slot] + if !skipped { + _, skipped = t.indirectSkips[slot] + } + // Pick the single most decisive unobserved candidate for the slot. + var best *WantedBlock + bestPri := -1 + // The finalized block first (may be cert-less — absent from the + // certified-candidates scan below). + if fin, ok := t.finalizedBySlot[slot]; ok { + if state := t.blocks[fin]; state != nil && !state.observed { + w := WantedBlock{Block: fin, Strongest: strongestBlockCertificateType(state.certificates), Finalized: true} + best, bestPri = &w, wantedPriority(w.Strongest, true) + } + } + for _, cand := range t.blockCandidatesLocked(slot) { + if cand.Observed { + continue + } + finalized := t.finalizedLocked(cand.Block) + pri := wantedPriority(cand.CertificateType, finalized) + if pri < 0 { + continue // tracked but uncertified (e.g. replay-observed sibling) + } + if skipped && !finalized { + continue // skip-certified slot: only a finalized block overrides + } + if best == nil || pri > bestPri || + (pri == bestPri && bytes.Compare(cand.Block.Hash[:], best.Block.Hash[:]) < 0) { + w := WantedBlock{Block: cand.Block, Strongest: cand.CertificateType, Finalized: finalized} + best, bestPri = &w, pri + } + } + if best != nil { + out = append(out, *best) + } + } + return out +} + func (t *ChainTracker) PruneBeforeSlot(slot uint64) { t.mu.Lock() defer t.mu.Unlock() @@ -514,6 +744,11 @@ func (t *ChainTracker) pruneBeforeSlotLocked(slot uint64) { delete(t.chainFinalized, id) } } + for s := range t.finalizedBySlot { + if s < slot { + delete(t.finalizedBySlot, s) + } + } for s := range t.blockSlots { if s < slot { delete(t.blockSlots, s) diff --git a/pkg/alpenglow/chain_query_test.go b/pkg/alpenglow/chain_query_test.go new file mode 100644 index 000000000..5eff45cad --- /dev/null +++ b/pkg/alpenglow/chain_query_test.go @@ -0,0 +1,183 @@ +package alpenglow + +import "testing" + +// CertifiedBlockAt / SkipCertifiedAt are the execute-on-receipt switch +// sweep's decision oracles: the sweep unwinds an executed block exactly when +// CertifiedBlockAt names a different sibling, and marks a slot skipped exactly +// when SkipCertifiedAt says so. These tests pin the scenario matrix the sweep +// depends on: certified sibling, fallback-only ambiguity, explicit skip, +// indirect (derived) skip, finalized-by-ancestry, and the two-decisive-blocks +// conflict handoff. + +// A notarize cert makes its block THE decisive block for the slot — the +// certified-sibling-switch signal. +func TestCertifiedBlockAtDecisiveNotarize(t *testing.T) { + tracker := NewChainTracker() + sibling := BlockID{Slot: 40, Hash: chainTestHash(2)} + specObserve(t, tracker, Certificate{Type: CertificateNotarize, Slot: 40, BlockHash: sibling.Hash}) + + got, certType, ok := tracker.CertifiedBlockAt(40) + if !ok || got != sibling || certType != CertificateNotarize { + t.Fatalf("notarize cert must be decisive: got %+v %s ok=%v", got, certType, ok) + } +} + +// Fallback certs are ambiguous (up to 7 can legally coexist) — never a switch +// signal. Codex item: "fallback-only = ambiguous, do not choose". +func TestCertifiedBlockAtIgnoresFallbackOnly(t *testing.T) { + tracker := NewChainTracker() + specObserve(t, tracker, Certificate{Type: CertificateNotarizeFallback, Slot: 41, BlockHash: chainTestHash(1)}) + specObserve(t, tracker, Certificate{Type: CertificateNotarizeFallback, Slot: 41, BlockHash: chainTestHash(2)}) + + if _, _, ok := tracker.CertifiedBlockAt(41); ok { + t.Fatalf("fallback-only slot must have NO decisive block") + } + if tracker.SkipCertifiedAt(41) { + t.Fatalf("fallback certs are not skip evidence") + } +} + +// An explicit skip cert answers SkipCertifiedAt; the slot has no decisive block. +func TestSkipCertifiedAtExplicit(t *testing.T) { + tracker := NewChainTracker() + specObserve(t, tracker, Certificate{Type: CertificateSkip, Slot: 42}) + + if !tracker.SkipCertifiedAt(42) { + t.Fatalf("explicit skip cert must report skip-certified") + } + if _, _, ok := tracker.CertifiedBlockAt(42); ok { + t.Fatalf("a skip-certified slot has no decisive block") + } +} + +// Indirect skips: a finalized block whose parent link jumps slots derives the +// omitted slots as skipped — with no skip certificate anywhere. This is how +// the sweep learns to mark leader-skipped slots during catchup and after +// fork switches. +func TestSkipCertifiedAtIndirect(t *testing.T) { + tracker := NewChainTracker() + finalized := BlockID{Slot: 15, Hash: chainTestHash(15)} + specObserve(t, tracker, Certificate{Type: CertificateFinalizeFast, Slot: 15, BlockHash: finalized.Hash}) + tracker.ObserveReplayBlock(ReplayBlockObservation{Block: finalized, ParentSlot: 12, ParentHash: chainTestHash(12)}) + + for _, slot := range []uint64{13, 14} { + if !tracker.SkipCertifiedAt(slot) { + t.Fatalf("slot %d omitted between finalized ancestors must be indirectly skip-certified", slot) + } + } + if tracker.SkipCertifiedAt(12) { + t.Fatalf("the finalized parent slot itself is not skipped") + } + // And the finalized block is decisive at its own slot. + if got, _, ok := tracker.CertifiedBlockAt(15); !ok || got != finalized { + t.Fatalf("finalized block must be decisive at its slot: %+v ok=%v", got, ok) + } +} + +// Finalization by ancestry upgrades a FALLBACK-cert'd parent to decisive: a +// notar-fallback cert alone is ambiguous (never a switch signal), but once a +// finalized descendant chains to it the ambiguity is resolved and the sweep +// may act on it. +func TestCertifiedBlockAtFinalizedByAncestry(t *testing.T) { + tracker := NewChainTracker() + parent := BlockID{Slot: 12, Hash: chainTestHash(12)} + child := BlockID{Slot: 15, Hash: chainTestHash(15)} + + // Replay executed both blocks (execute-on-receipt), and the parent picked + // up only a notar-fallback cert — ambiguous by itself. + tracker.ObserveReplayBlock(ReplayBlockObservation{Block: parent, ParentSlot: 11, ParentHash: chainTestHash(11)}) + tracker.ObserveReplayBlock(ReplayBlockObservation{Block: child, ParentSlot: parent.Slot, ParentHash: parent.Hash}) + specObserve(t, tracker, Certificate{Type: CertificateNotarizeFallback, Slot: 12, BlockHash: parent.Hash}) + if _, _, ok := tracker.CertifiedBlockAt(12); ok { + t.Fatalf("a fallback-only parent must not be decisive before ancestry finalization") + } + + specObserve(t, tracker, Certificate{Type: CertificateFinalizeFast, Slot: 15, BlockHash: child.Hash}) + + got, _, ok := tracker.CertifiedBlockAt(12) + if !ok || got != parent { + t.Fatalf("ancestry-finalized fallback parent must be decisive at slot 12: %+v ok=%v", got, ok) + } +} + +// Two decisive blocks in one slot is Byzantine evidence: CertifiedBlockAt +// reports NO decisive block (the conflict machinery owns the halt) rather +// than arbitrarily picking one. Codex item: "multiple decisive blocks = +// conflict/evidence, fail closed". +func TestCertifiedBlockAtTwoDecisiveIsNoDecision(t *testing.T) { + tracker := NewChainTracker() + a := BlockID{Slot: 44, Hash: chainTestHash(1)} + b := BlockID{Slot: 44, Hash: chainTestHash(2)} + // Byzantine: two "unique-strength" certs for one slot (impossible under + // honest-majority thresholds, exactly what evidence must catch). + specObserve(t, tracker, Certificate{Type: CertificateNotarize, Slot: 44, BlockHash: a.Hash}) + specObserve(t, tracker, Certificate{Type: CertificateFinalizeFast, Slot: 44, BlockHash: b.Hash}) + + if _, _, ok := tracker.CertifiedBlockAt(44); ok { + t.Fatalf("two decisive blocks must yield NO switch decision — the conflict path owns it") + } + if decision, ok := tracker.NextDecision(43); !ok || decision.Kind != ChainDecisionKindConflict { + t.Fatalf("conflict machinery must report the Byzantine slot, got %+v ok=%v", decision, ok) + } +} + +// The cert-less variant: an ancestry-finalized parent that never received ANY +// certificate is still decisive — surfaced via the per-slot finalized index, +// since the cert-only blockSlots scan cannot see it. This is the adversarial +// corner where the fix matters: replay executed an equivocation twin at the +// parent slot, no cert for the true parent ever arrived, and the finalized +// descendant is the only evidence. Without the index the sweep would take the +// expensive rooted re-replay; with it, the cheap in-RAM switch fires. +func TestCertifiedBlockAtCertlessAncestryFinalized(t *testing.T) { + tracker := NewChainTracker() + parent := BlockID{Slot: 12, Hash: chainTestHash(12)} + child := BlockID{Slot: 15, Hash: chainTestHash(15)} + + // The true parent was replay-observed (or hash-linked) but NEVER certified. + tracker.ObserveReplayBlock(ReplayBlockObservation{Block: parent, ParentSlot: 11, ParentHash: chainTestHash(11)}) + tracker.ObserveReplayBlock(ReplayBlockObservation{Block: child, ParentSlot: parent.Slot, ParentHash: parent.Hash}) + if _, _, ok := tracker.CertifiedBlockAt(12); ok { + t.Fatalf("nothing decisive before finality") + } + + specObserve(t, tracker, Certificate{Type: CertificateFinalizeFast, Slot: 15, BlockHash: child.Hash}) + + got, _, ok := tracker.CertifiedBlockAt(12) + if !ok || got != parent { + t.Fatalf("cert-less ancestry-finalized parent must be decisive: %+v ok=%v", got, ok) + } + + // Two-decisive still fails closed: a decisive cert for a DIFFERENT sibling + // at the finalized slot is Byzantine — no switch decision, conflict owns it. + specObserve(t, tracker, Certificate{Type: CertificateNotarize, Slot: 12, BlockHash: chainTestHash(99)}) + if _, _, ok := tracker.CertifiedBlockAt(12); ok { + t.Fatalf("finalized block + different decisive cert must yield NO switch decision") + } +} + +// A cert-less finalized block replay has NOT observed is a repair target: the +// wanted-blocks feed must name it (Finalized=true) even though its slot has no +// certified blocks at all — otherwise cert-driven repair could never fetch the +// one block the chain provably needs. +func TestWantedBlocksIncludesCertlessFinalized(t *testing.T) { + tracker := NewChainTracker() + parent := BlockID{Slot: 12, Hash: chainTestHash(12)} + child := BlockID{Slot: 15, Hash: chainTestHash(15)} + + // Only the CHILD was observed by replay; the parent is known solely via + // the child's parent link + ancestry finalization. + tracker.ObserveReplayBlock(ReplayBlockObservation{Block: child, ParentSlot: parent.Slot, ParentHash: parent.Hash}) + specObserve(t, tracker, Certificate{Type: CertificateFinalizeFast, Slot: 15, BlockHash: child.Hash}) + + wanted := tracker.WantedBlocks(0, 16) + for _, w := range wanted { + if w.Block == parent { + if !w.Finalized { + t.Fatalf("cert-less finalized target must carry Finalized=true: %+v", w) + } + return + } + } + t.Fatalf("unobserved cert-less finalized parent must be a wanted block, got %+v", wanted) +} diff --git a/pkg/alpenglow/chain_test.go b/pkg/alpenglow/chain_test.go index 704109ebf..989a1913c 100644 --- a/pkg/alpenglow/chain_test.go +++ b/pkg/alpenglow/chain_test.go @@ -212,6 +212,34 @@ func TestChainTrackerFastFinalizationDerivesOmittedSkips(t *testing.T) { } } +// The decision version advances on cert acceptance AND on a replay observation +// that derives new decisiveness (here: the parent link that produces the +// omitted indirect skips) — the exact case the switch sweep would otherwise +// miss when gated on certificate count alone. +func TestChainTrackerDecisionVersionAdvancesOnReplayDerivation(t *testing.T) { + tracker := NewChainTracker() + blockID := BlockID{Slot: 15, Hash: chainTestHash(15)} + + v0 := tracker.DecisionVersion() + if _, err := tracker.ObserveCertificate(Certificate{ + Type: CertificateFinalizeFast, Slot: blockID.Slot, BlockHash: blockID.Hash, SignatureVerified: true, + }); err != nil { + t.Fatalf("observe cert: %v", err) + } + v1 := tracker.DecisionVersion() + if v1 <= v0 { + t.Fatalf("cert acceptance must advance the decision version (%d -> %d)", v0, v1) + } + + // The replay observation supplies the parent link that derives the omitted + // skips (13, 14) — a decisiveness change with NO new certificate. + tracker.ObserveReplayBlock(ReplayBlockObservation{Block: blockID, ParentSlot: 12, ParentHash: chainTestHash(12)}) + v2 := tracker.DecisionVersion() + if v2 <= v1 { + t.Fatalf("replay-derived indirect skips must advance the decision version (%d -> %d)", v1, v2) + } +} + func TestChainTrackerSlowFinalizationRequiresNotarizationCertificate(t *testing.T) { tracker := NewChainTracker() blockID := BlockID{Slot: 15, Hash: chainTestHash(15)} diff --git a/pkg/alpenglow/verify.go b/pkg/alpenglow/verify.go index 274c0fd46..a589f104c 100644 --- a/pkg/alpenglow/verify.go +++ b/pkg/alpenglow/verify.go @@ -209,6 +209,15 @@ func BuildValidatorSet(epoch uint64, stakes map[solana.PublicKey]uint64, voteAcc return ValidatorSet{Epoch: epoch, Validators: entries, TotalStake: totalStake}, nil } +// ValidatorSetForEpoch returns the installed validator set for epoch (copy of +// the header; the validator slice is shared read-only). +func (v *CertificateVerifier) ValidatorSetForEpoch(epoch uint64) (ValidatorSet, bool) { + v.mu.RLock() + defer v.mu.RUnlock() + set, ok := v.sets[epoch] + return set, ok +} + // LatestEpoch returns the newest epoch with an installed validator set (0 if none). func (v *CertificateVerifier) LatestEpoch() uint64 { v.mu.RLock() @@ -869,6 +878,53 @@ func DecodeSignerStoreBitmap(data []byte, maxLen int) (SignerBitmap, error) { } } +// EncodeSignerStoreBitmap is the exact inverse of DecodeSignerStoreBitmap: +// header [version][u16 len LE], then base2 bit-packed bools (LSB-first) or +// base3 symbols (0/1/2 = none/base/fallback, 5 per byte). Base3 requires +// disjoint base/fallback sets. +func EncodeSignerStoreBitmap(b SignerBitmap) ([]byte, error) { + if b.Length < 0 || b.Length > MaximumValidators { + return nil, fmt.Errorf("alpenglow verifier: invalid bitmap length %d", b.Length) + } + out := []byte{0, byte(b.Length), byte(b.Length >> 8)} + switch b.Encoding { + case SignerBitmapBase2: + out[0] = signerStoreVersionBase2 + payload := make([]byte, (b.Length+7)/8) + for i := 0; i < b.Length; i++ { + if i < len(b.Base) && b.Base[i] { + payload[i/8] |= 1 << uint(i%8) + } + } + return append(out, payload...), nil + case SignerBitmapBase3: + out[0] = signerStoreVersionBase3 + if err := b.CheckDisjoint(); err != nil { + return nil, err + } + payload := make([]byte, (b.Length+base3SymbolsPerByte-1)/base3SymbolsPerByte) + for chunk := range payload { + start := chunk * base3SymbolsPerByte + end := min(start+base3SymbolsPerByte, b.Length) + var blockNum, mult byte = 0, 1 + for bit := start; bit < end; bit++ { + var sym byte + if bit < len(b.Base) && b.Base[bit] { + sym = 1 + } else if bit < len(b.Fallback) && b.Fallback[bit] { + sym = 2 + } + blockNum += sym * mult + mult *= 3 + } + payload[chunk] = blockNum + } + return append(out, payload...), nil + default: + return nil, fmt.Errorf("alpenglow verifier: unsupported bitmap encoding %q", b.Encoding) + } +} + func decodeSignerStoreBase2(payload []byte, totalBits int) (SignerBitmap, error) { expectedLen := (totalBits + 7) / 8 if len(payload) != expectedLen { diff --git a/pkg/alpenglow/wanted_blocks_test.go b/pkg/alpenglow/wanted_blocks_test.go new file mode 100644 index 000000000..e90153eae --- /dev/null +++ b/pkg/alpenglow/wanted_blocks_test.go @@ -0,0 +1,116 @@ +package alpenglow + +import ( + "testing" + + "github.com/gagliardetto/solana-go" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func wbHash(b byte) solana.Hash { var h solana.Hash; h[0] = b; return h } + +func wbCert(t *testing.T, tr *ChainTracker, ct CertificateType, slot uint64, hash solana.Hash) { + t.Helper() + _, err := tr.ObserveCertificate(Certificate{ + Type: ct, Slot: slot, BlockHash: hash, + SignatureVerified: true, StakeVerified: true, + }) + require.NoError(t, err) +} + +// WantedBlocks lists certified-but-unobserved blocks ascending: observed +// blocks drop out, skip-certified slots are excluded unless finalized, and +// afterSlot/max bound the scan. +func TestWantedBlocksSelection(t *testing.T) { + tr := NewChainTracker() + + // 101: notarized, unobserved -> wanted. + wbCert(t, tr, CertificateNotarize, 101, wbHash(1)) + // 102: notarized but replay observed the data -> not wanted. + wbCert(t, tr, CertificateNotarize, 102, wbHash(2)) + tr.ObserveReplayBlock(ReplayBlockObservation{Block: BlockID{Slot: 102, Hash: wbHash(2)}, ParentSlot: 101, ParentHash: wbHash(1)}) + // 103: skip-certified with only a fallback candidate -> excluded (a skip + // outranks an ambiguous fallback; repair must not chase discarded data). + wbCert(t, tr, CertificateSkip, 103, solana.Hash{}) + wbCert(t, tr, CertificateNotarizeFallback, 103, wbHash(3)) + // 104: fast-finalized, unobserved -> wanted with Finalized set. + wbCert(t, tr, CertificateFinalizeFast, 104, wbHash(4)) + + wanted := tr.WantedBlocks(100, 10) + require.Len(t, wanted, 2) + assert.Equal(t, BlockID{Slot: 101, Hash: wbHash(1)}, wanted[0].Block) + assert.Equal(t, CertificateNotarize, wanted[0].Strongest) + assert.False(t, wanted[0].Finalized) + assert.Equal(t, BlockID{Slot: 104, Hash: wbHash(4)}, wanted[1].Block) + assert.Equal(t, CertificateFinalizeFast, wanted[1].Strongest) + assert.True(t, wanted[1].Finalized) + + // afterSlot is exclusive; max caps the result. + wanted = tr.WantedBlocks(101, 10) + require.Len(t, wanted, 1) + assert.Equal(t, uint64(104), wanted[0].Block.Slot) + wanted = tr.WantedBlocks(100, 1) + require.Len(t, wanted, 1) + assert.Equal(t, uint64(101), wanted[0].Block.Slot) + + // Observing the data satisfies the want. + tr.ObserveReplayBlock(ReplayBlockObservation{Block: BlockID{Slot: 101, Hash: wbHash(1)}, ParentSlot: 100}) + wanted = tr.WantedBlocks(100, 10) + require.Len(t, wanted, 1) + assert.Equal(t, uint64(104), wanted[0].Block.Slot) + + // Pruning the retention window drops the remaining want. + tr.PruneBeforeSlot(105) + assert.Empty(t, tr.WantedBlocks(100, 10)) +} + +// Repair targets the DECISIVE block per slot, not a fallback sibling that +// merely sorts first by hash. A single target is returned per slot. +func TestWantedBlocksPrefersDecisiveOverLowerHashFallback(t *testing.T) { + tr := NewChainTracker() + notarizeHi := wbHash(0x09) // decisive, HIGH hash + fallbackLo := wbHash(0x01) // fallback, LOW hash — would sort first + + wbCert(t, tr, CertificateNotarizeFallback, 200, fallbackLo) + wbCert(t, tr, CertificateNotarize, 200, notarizeHi) + + wanted := tr.WantedBlocks(199, 10) + require.Len(t, wanted, 1, "exactly one repair target per slot") + assert.Equal(t, notarizeHi, wanted[0].Block.Hash, "must target the decisive notarized block, not the lower-hash fallback") + assert.Equal(t, CertificateNotarize, wanted[0].Strongest) + + // A finalized block outranks a unique-strength cert on another sibling. + wbCert(t, tr, CertificateFinalizeFast, 201, wbHash(0xF0)) + wbCert(t, tr, CertificateNotarize, 201, wbHash(0x02)) + wanted = tr.WantedBlocks(200, 10) + require.Len(t, wanted, 1) + assert.Equal(t, wbHash(0xF0), wanted[0].Block.Hash, "finalized block wins") + assert.True(t, wanted[0].Finalized) + + // A fallback-only slot still yields the fallback (no decisive candidate). + tr2 := NewChainTracker() + wbCert(t, tr2, CertificateNotarizeFallback, 202, wbHash(0x05)) + only := tr2.WantedBlocks(201, 10) + require.Len(t, only, 1) + assert.Equal(t, CertificateNotarizeFallback, only[0].Strongest, "fallback repaired when nothing decisive exists") +} + +// A certified sibling stays wanted while a DIFFERENT (uncertified) sibling was +// observed — the exact post-switch repair case: replay ran the wrong block, +// certs name the right one, and its data still has to be fetched. +func TestWantedBlocksCertifiedSiblingOfObservedBlock(t *testing.T) { + tr := NewChainTracker() + + // Replay observed sibling A (no certificate); certs then name sibling B. + tr.ObserveReplayBlock(ReplayBlockObservation{Block: BlockID{Slot: 150, Hash: wbHash(0xA)}, ParentSlot: 149}) + wbCert(t, tr, CertificateNotarize, 150, wbHash(0xB)) + + wanted := tr.WantedBlocks(149, 10) + require.Len(t, wanted, 1, "uncertified observed sibling must not satisfy the want") + assert.Equal(t, BlockID{Slot: 150, Hash: wbHash(0xB)}, wanted[0].Block) + + // Once the certified sibling's data is observed, the want clears. + tr.ObserveReplayBlock(ReplayBlockObservation{Block: BlockID{Slot: 150, Hash: wbHash(0xB)}, ParentSlot: 149}) + assert.Empty(t, tr.WantedBlocks(149, 10)) +} diff --git a/pkg/block/block.go b/pkg/block/block.go index 3d4ae081d..f93b405bd 100644 --- a/pkg/block/block.go +++ b/pkg/block/block.go @@ -52,6 +52,14 @@ type Block struct { FeeRateGovernor *sealevel.FeeRateGovernor FromLightbringer bool IsSkipped bool // True for slots that were skipped by the leader + + // Shred-path observability (zero when the block did not come from shreds — + // RPC/file blocks must not fabricate these). "Full" follows Agave's + // SlotMeta/is_full language: all data shreds present, block reconstructable + // — NOT finalized/consensus-safe. + ShredFirstNanos int64 // wall clock (unix nanos) of the first accepted shred for the slot + ShredFullNanos int64 // wall clock (unix nanos) when the slot became full + RepairedShreds int // data shreds obtained via repair rather than turbine } func (b *Block) FixupTxVersions() { diff --git a/pkg/blockstream/alpenglow_repair_test.go b/pkg/blockstream/alpenglow_repair_test.go new file mode 100644 index 000000000..dc4b83b30 --- /dev/null +++ b/pkg/blockstream/alpenglow_repair_test.go @@ -0,0 +1,146 @@ +package blockstream + +import ( + "testing" + "time" + + "github.com/Overclock-Validator/mithril/pkg/alpenglow" + b "github.com/Overclock-Validator/mithril/pkg/block" + "github.com/gagliardetto/solana-go" +) + +func newRepairTestSource(t *testing.T, wanted func(uint64, int) []alpenglow.WantedBlock, skip func(uint64) bool) *BlockSource { + t.Helper() + bs := NewBlockSource(&BlockSourceOpts{ + SourceType: BlockSourceTurbine, + TurbineBindAddr: "127.0.0.1:0", + TurbineAlpenglowBlockIDHints: true, + StartSlot: 151, + EndSlot: 200, + AlpenglowWantedBlocks: wanted, + AlpenglowSkipCertified: skip, + }) + bs.isNearTip.Store(true) + return bs +} + +func wantedOne(slot uint64, hash solana.Hash) func(uint64, int) []alpenglow.WantedBlock { + return func(afterSlot uint64, max int) []alpenglow.WantedBlock { + if slot <= afterSlot || max < 1 { + return nil + } + return []alpenglow.WantedBlock{{Block: alpenglow.BlockID{Slot: slot, Hash: hash}, Strongest: alpenglow.CertificateNotarize}} + } +} + +// A certified-but-unobserved block pins the assembler to the certified id; +// re-nudges are rate limited to one per slot per second. +func TestRepairLoopNudgesAndRateLimits(t *testing.T) { + certified := solana.Hash{0xC1} + bs := newRepairTestSource(t, wantedOne(152, certified), nil) + + nudged := make(map[uint64]time.Time) + t0 := time.Now() + bs.serviceAlpenglowWantedBlocks(nudged, t0) + if got := bs.knownAlpenglowBlockIDs[152]; got != certified { + t.Fatalf("known block id = %s, want %s", got, certified) + } + + // Within the pause the slot is not re-nudged (hint removed to observe it)... + delete(bs.knownAlpenglowBlockIDs, 152) + bs.serviceAlpenglowWantedBlocks(nudged, t0.Add(200*time.Millisecond)) + if _, ok := bs.knownAlpenglowBlockIDs[152]; ok { + t.Fatalf("expected no re-nudge within the rate-limit pause") + } + // ...and after the pause it is. + bs.serviceAlpenglowWantedBlocks(nudged, t0.Add(1100*time.Millisecond)) + if got := bs.knownAlpenglowBlockIDs[152]; got != certified { + t.Fatalf("expected re-nudge after the pause, known = %s", got) + } +} + +// A buffered pre-emission candidate with a different block id is provably +// non-canonical: discarded, slot state cleared for re-fetch, assembler pinned +// to the certified id — and no RPC retry is enqueued. +func TestRepairLoopDiscardsMismatchedBufferedCandidate(t *testing.T) { + certified := solana.Hash{0xC1} + other := solana.Hash{0xC2} + bs := newRepairTestSource(t, wantedOne(152, certified), nil) + bs.reorderBuffer[152] = &b.Block{ + Slot: 152, + HasAlpenglowBlockID: true, + AlpenglowBlockID: [32]byte(other), + } + bs.slotState[152] = slotDone + + bs.serviceAlpenglowWantedBlocks(make(map[uint64]time.Time), time.Now()) + + if bs.reorderBuffer[152] != nil { + t.Fatalf("expected mismatched candidate to be discarded") + } + if _, exists := bs.slotState[152]; exists { + t.Fatalf("expected slot state cleared so the slot re-fetches") + } + if got := bs.knownAlpenglowBlockIDs[152]; got != certified { + t.Fatalf("known block id = %s, want %s", got, certified) + } + if len(bs.retrySlots) != 0 { + t.Fatalf("cert-driven repair must not enqueue RPC retries, got %+v", bs.retrySlots) + } +} + +// A buffered candidate that already carries the certified id needs no repair: +// no nudge, no hint, no discard. +func TestRepairLoopLeavesMatchingBufferedCandidate(t *testing.T) { + certified := solana.Hash{0xC1} + bs := newRepairTestSource(t, wantedOne(152, certified), nil) + bs.reorderBuffer[152] = &b.Block{ + Slot: 152, + HasAlpenglowBlockID: true, + AlpenglowBlockID: [32]byte(certified), + } + + nudged := make(map[uint64]time.Time) + bs.serviceAlpenglowWantedBlocks(nudged, time.Now()) + + if bs.reorderBuffer[152] == nil { + t.Fatalf("matching candidate must stay buffered") + } + if len(nudged) != 0 { + t.Fatalf("matching candidate must not consume a nudge, got %+v", nudged) + } + if _, ok := bs.knownAlpenglowBlockIDs[152]; ok { + t.Fatalf("matching candidate needs no hint") + } +} + +// Skip-cancel marks certificate-skipped slots (bounded window, rate limited) +// and survives having no active receiver; the limiter is GC'd as the emission +// frontier advances past entries. +func TestRepairLoopSkipCancelAndLimiterGC(t *testing.T) { + skipCertified := func(slot uint64) bool { return slot == 153 } + bs := newRepairTestSource(t, func(uint64, int) []alpenglow.WantedBlock { return nil }, skipCertified) + + nudged := map[uint64]time.Time{149: time.Now().Add(-time.Hour)} // below the frontier: must be GC'd + bs.serviceAlpenglowWantedBlocks(nudged, time.Now()) + + if _, ok := nudged[149]; ok { + t.Fatalf("limiter entries below the frontier must be pruned") + } + if _, ok := nudged[153]; !ok { + t.Fatalf("skip-certified slot must be skip-cancelled (limiter records the reset)") + } +} + +// Outside near-tip the loop is inert: catch-up repairs arrive via backfill. +func TestRepairLoopGatedOnNearTip(t *testing.T) { + bs := newRepairTestSource(t, wantedOne(152, solana.Hash{0xC1}), nil) + bs.isNearTip.Store(false) + + nudged := make(map[uint64]time.Time) + bs.serviceAlpenglowWantedBlocks(nudged, time.Now()) + + if len(nudged) != 0 || len(bs.knownAlpenglowBlockIDs) != 0 { + t.Fatalf("repair loop must be inert outside near-tip") + } +} diff --git a/pkg/blockstream/block_source.go b/pkg/blockstream/block_source.go index 3e7ce8cc6..c09ceb142 100644 --- a/pkg/blockstream/block_source.go +++ b/pkg/blockstream/block_source.go @@ -57,13 +57,17 @@ type BlockSourceOpts struct { LeaderForSlot func(slot uint64) (solana.PublicKey, bool) AlpenglowDecisionSource func(anchorSlot uint64) (alpenglow.ChainDecision, bool) AlpenglowCandidateBlockSink func(alpenglow.ReplayBlockObservation) - StartSlot uint64 - EndSlot uint64 - BlockDir string + // Cert-driven repair feed: certified-but-unobserved blocks the repair loop + // steers turbine toward, and the skip oracle that cancels shred state for + // certificate-skipped slots. + AlpenglowWantedBlocks func(afterSlot uint64, max int) []alpenglow.WantedBlock + AlpenglowSkipCertified func(slot uint64) bool + StartSlot uint64 + EndSlot uint64 + BlockDir string // When enabled, an active near-tip Lightbringer stream is delivered to replay // as an observation feed for consensus buffering instead of requiring the // block source to resolve every local gap before delivery. - ConsensusManagedLightbringer bool // Backup RPC endpoints for failover (optional) // These are tried in order if the primary fails with hard connectivity errors @@ -322,13 +326,14 @@ type BlockSource struct { lightbringerBufferMu sync.Mutex lightbringerBuffer map[uint64]*b.Block lightbringerBufferOrder []uint64 - consensusManagedLightbringer bool alpenglowMu sync.Mutex knownAlpenglowBlockIDs map[uint64]solana.Hash knownAlpenglowBlockIDOrder []uint64 activeTurbineReceiver *turbine.UDPReceiver alpenglowDecisionSource func(anchorSlot uint64) (alpenglow.ChainDecision, bool) alpenglowCandidateBlockSink func(alpenglow.ReplayBlockObservation) + alpenglowWantedBlocksFn func(afterSlot uint64, max int) []alpenglow.WantedBlock + alpenglowSkipCertifiedFn func(slot uint64) bool // Stats tracking stats BlockSourceStats @@ -501,8 +506,9 @@ func NewBlockSource(opts *BlockSourceOpts) *BlockSource { leaderForSlot: opts.LeaderForSlot, alpenglowDecisionSource: opts.AlpenglowDecisionSource, alpenglowCandidateBlockSink: opts.AlpenglowCandidateBlockSink, + alpenglowWantedBlocksFn: opts.AlpenglowWantedBlocks, + alpenglowSkipCertifiedFn: opts.AlpenglowSkipCertified, lightbringerBuffer: make(map[uint64]*b.Block), - consensusManagedLightbringer: opts.ConsensusManagedLightbringer, knownAlpenglowBlockIDs: make(map[uint64]solana.Hash), // Configurable mode thresholds @@ -592,6 +598,38 @@ func (bs *BlockSource) resetTurbineSlotForAlpenglowBlock(slot uint64, blockID so } } +// TurbineShredEdges reports the monotonic shred frontier (latest shred slot, +// highest full slot) from the active turbine receiver. ok is false when no +// receiver is active (RPC-only / pre-handoff) — callers must not fabricate +// shred stats then. +func (bs *BlockSource) TurbineShredEdges() (latestShredSlot, highestFullSlot uint64, ok bool) { + bs.alpenglowMu.Lock() + receiver := bs.activeTurbineReceiver + bs.alpenglowMu.Unlock() + if receiver == nil { + return 0, 0, false + } + latest, full := receiver.ShredEdges() + return latest, full, true +} + +// TurbineShredObservation reports partial shred arrivals for a slot that never +// became full — "the leader sent SOMETHING" skip observability. ok is false +// when no receiver is active or no shred was ever accepted for the slot. +func (bs *BlockSource) TurbineShredObservation(slot uint64) (dataShreds, repairedShreds int, firstNanos int64, ok bool) { + bs.alpenglowMu.Lock() + receiver := bs.activeTurbineReceiver + bs.alpenglowMu.Unlock() + if receiver == nil { + return 0, 0, 0, false + } + obs, found := receiver.ShredObservation(slot) + if !found { + return 0, 0, 0, false + } + return obs.DataShreds, obs.RepairedShreds, obs.FirstNanos, true +} + func (bs *BlockSource) prioritizeTurbineRepairRange(start, end uint64) { if bs.sourceType != BlockSourceTurbine || !bs.turbineAlpenglowBlockIDHints || start == 0 { return @@ -616,6 +654,126 @@ func (bs *BlockSource) prioritizeTurbineRepairForLiveGap(waitingSlot, firstBuffe bs.prioritizeTurbineRepairRange(waitingSlot, end) } +// Cert-driven repair. Certificates prove which block data the cluster voted +// real BEFORE turbine finishes delivering it here: a certified-but-unobserved +// block means we are missing (or mis-assembled) data the chain has already +// settled on. The repair loop closes that gap continuously instead of waiting +// for the emission frontier to stall on it: +// +// - certified block not yet observed -> pin the assembler to the certified +// block id and pull repair for the slot +// - buffered pre-emission candidate carrying a DIFFERENT id -> provably +// non-canonical; discard it and re-arm the slot for the certified version +// (the post-emission case is the replay switch sweep's job) +// - certificate-skipped slot -> drop its partial shred state so the +// receiver stops assembling and repairing data the chain discarded +// +// This also services the switch sweep: after a wrong-sibling unwind the +// certified sibling stays "wanted" until observed, so the hints re-fire every +// nudge interval until the data lands. +const ( + alpenglowRepairTick = 250 * time.Millisecond + alpenglowRepairMaxWanted = 32 + alpenglowRepairNudgePause = time.Second // at most one nudge per slot per second +) + +func (bs *BlockSource) alpenglowRepairLoop() { + ticker := time.NewTicker(alpenglowRepairTick) + defer ticker.Stop() + nudged := make(map[uint64]time.Time) // loop-local: single-goroutine rate limiter + for { + select { + case <-bs.stopChan: + return + case <-ticker.C: + bs.serviceAlpenglowWantedBlocks(nudged, time.Now()) + } + } +} + +// serviceAlpenglowWantedBlocks runs one repair pass. nudged is the per-slot +// rate limiter (owned by the calling goroutine); entries at or below the +// emission frontier are pruned as it advances. +func (bs *BlockSource) serviceAlpenglowWantedBlocks(nudged map[uint64]time.Time, now time.Time) { + if bs.alpenglowWantedBlocksFn == nil || bs.sourceType != BlockSourceTurbine || !bs.turbineAlpenglowBlockIDHints { + return + } + if !bs.isNearTip.Load() { + return // catch-up fills gaps via ordinary backfill; hints would be noise + } + + bs.reorderMu.Lock() + waiting := bs.nextSlotToSend + bs.reorderMu.Unlock() + if waiting == 0 { + return + } + after := waiting - 1 + + for slot := range nudged { + if slot <= after { + delete(nudged, slot) + } + } + + for _, w := range bs.alpenglowWantedBlocksFn(after, alpenglowRepairMaxWanted) { + slot := w.Block.Slot + if last, ok := nudged[slot]; ok && now.Sub(last) < alpenglowRepairNudgePause { + continue + } + + bs.reorderMu.Lock() + blk := bs.reorderBuffer[slot] + haveCertified := blk != nil && blk.HasAlpenglowBlockID && solana.Hash(blk.AlpenglowBlockID) == w.Block.Hash + mismatch := blk != nil && blk.HasAlpenglowBlockID && !haveCertified + if mismatch { + delete(bs.reorderBuffer, slot) + bs.slotStateMu.Lock() + delete(bs.slotState, slot) + delete(bs.inflightStart, slot) + bs.slotStateMu.Unlock() + } + bs.reorderMu.Unlock() + + if haveCertified { + continue // assembled and waiting its emission turn; nothing to repair + } + nudged[slot] = now + if mismatch { + bs.clearSlotErrors(slot) + bs.resetTurbineSlotForAlpenglowBlock(slot, w.Block.Hash) + mlog.Log.Warnf("ALPENGLOW repair: discarded buffered non-certified candidate at slot %d; repairing toward certified block %s", + slot, w.Block.Hash) + continue + } + bs.SetKnownAlpenglowBlockID(slot, w.Block.Hash) + bs.prioritizeTurbineRepairRange(slot, slot) + } + + // Skip-cancel: certificate-skipped slots ahead of the frontier stop + // accumulating shred state and stop generating repair requests. Slots with + // a finalized block over a skip never reach here — the wanted-block nudge + // above refreshes their limiter entry first (finality outranks a skip). + if bs.alpenglowSkipCertifiedFn == nil { + return + } + for slot := after + 1; slot <= after+alpenglowRepairMaxWanted; slot++ { + if last, ok := nudged[slot]; ok && now.Sub(last) < alpenglowRepairNudgePause { + continue + } + if !bs.alpenglowSkipCertifiedFn(slot) { + continue + } + nudged[slot] = now + bs.alpenglowMu.Lock() + receiver := bs.activeTurbineReceiver + bs.alpenglowMu.Unlock() + if receiver != nil { + receiver.ResetSlot(slot) + } + } +} + func (bs *BlockSource) attachAlpenglowBlockIDHintsToReceiver(receiver *turbine.UDPReceiver) { if !bs.turbineAlpenglowBlockIDHints || receiver == nil { return @@ -725,9 +883,6 @@ func (bs *BlockSource) updateMode() { if wasNearTip { // Currently in near-tip mode - switch to catchup if gap exceeds threshold if gap >= bs.catchupThreshold { - if bs.shouldDeferCatchupForConsensusBufferedLightbringer(gap, lastExecuted, tip) { - return - } bs.isNearTip.Store(false) mlog.Log.Infof("MODE SWITCH: near-tip → CATCHUP | gap=%d (threshold=%d) | exec_slot=%d | tip=%d", gap, bs.catchupThreshold, lastExecuted, tip) @@ -751,60 +906,6 @@ func (bs *BlockSource) updateMode() { } } -func (bs *BlockSource) consensusBufferedLightbringerMaxReplayGap() uint64 { - if bs.catchupThreshold == 0 { - return 0 - } - if bs.catchupThreshold > math.MaxUint64/2 { - return math.MaxUint64 - } - return bs.catchupThreshold * 2 -} - -func (bs *BlockSource) consensusBufferedLightbringerMaxSourceGap() uint64 { - maxGap := bs.nearTipThreshold - if maxGap == 0 || (bs.catchupThreshold > 0 && maxGap > bs.catchupThreshold) { - maxGap = bs.catchupThreshold - } - return maxGap -} - -func (bs *BlockSource) shouldDeferCatchupForConsensusBufferedLightbringer(gap uint64, lastExecuted uint64, tip uint64) bool { - if !bs.usesLiveShredStream() { - return false - } - if !bs.consensusManagedLightbringer || !bs.lightbringerActive.Load() || !bs.lightbringerConnected.Load() { - return false - } - if maxReplayGap := bs.consensusBufferedLightbringerMaxReplayGap(); maxReplayGap != 0 && gap > maxReplayGap { - return false - } - - latestStreamed := bs.lightbringerLastStreamSlot.Load() - if latestStreamed <= lastExecuted { - return false - } - if tip > latestStreamed { - sourceGap := tip - latestStreamed - if maxSourceGap := bs.consensusBufferedLightbringerMaxSourceGap(); maxSourceGap != 0 && sourceGap > maxSourceGap { - return false - } - } - - lastRecvUnix := bs.lightbringerLastRecvUnix.Load() - if lastRecvUnix == 0 || time.Since(time.Unix(lastRecvUnix, 0)) >= lightbringerIdleReconnect { - return false - } - lastProgressUnix := bs.lastProgress.Load() - if lastProgressUnix != 0 && time.Since(time.Unix(lastProgressUnix, 0)) >= lightbringerNoEmitReconnect { - return false - } - - return true -} - -// effectiveTipSafetyMargin returns the tip safety margin for the current mode. -// In near-tip mode, we return 0 (no margin) - we rely on fast retries instead. func (bs *BlockSource) effectiveTipSafetyMargin() uint64 { if bs.isNearTip.Load() { return 0 // Near-tip mode: no safety margin, rely on retries @@ -894,7 +995,7 @@ func (bs *BlockSource) forceRPCForCatchupWithReason(gap uint64, reason string) { } bs.slotStateMu.Unlock() } - if bs.consensusManagedLightbringer || rewoundEmissionFrontier { + if rewoundEmissionFrontier { bs.slotStateMu.Lock() for slot := range bs.slotState { if slot >= waitingSlot { @@ -1088,9 +1189,6 @@ func (bs *BlockSource) shouldPreferIncomingLightbringerBlockLocked(existing, inc } func (bs *BlockSource) waitingLightbringerParentMismatchLocked() (waitingSlot uint64, observedParentSlot uint64, expectedParentSlot uint64, mismatch bool) { - if bs.consensusManagedLightbringer && bs.lightbringerActive.Load() { - return 0, 0, 0, false - } blk := bs.reorderBuffer[bs.nextSlotToSend] if blk == nil || !blk.FromLightbringer { return 0, 0, 0, false @@ -1490,7 +1588,7 @@ func (bs *BlockSource) lightbringerLiveEdgeHandoffMaxLag() uint64 { } func (bs *BlockSource) allowsLiveEdgeHandoff() bool { - return bs.consensusManagedLightbringer || bs.sourceType == BlockSourceTurbine + return bs.sourceType == BlockSourceTurbine } func (bs *BlockSource) lightbringerHandoffRequiredLastSlot(waitingSlot uint64) uint64 { @@ -1826,9 +1924,6 @@ func (bs *BlockSource) shouldDiscardLiveStreamResult(slot uint64, generation uin if !bs.isNearTip.Load() { return true } - if bs.consensusManagedLightbringer && bs.lightbringerActive.Load() { - return false - } handoffSlot := bs.lightbringerHandoffSlot.Load() return handoffSlot == 0 || slot < handoffSlot @@ -1860,10 +1955,51 @@ func (bs *BlockSource) inspectLaterLightbringerBlocksLocked(waitingSlot uint64) return firstBufferedSlot, firstBufferedParentSlot, bufferedCount, firstConnectedSlot, firstConnectedParentSlot, foundConnected } +// applyAlpenglowCertifiedSkipLocked marks the waiting slot skipped when the +// consensus decision source certifies it skipped — in ANY block-source mode +// (RPC catchup / pre-handoff included). A certified skip is a consensus fact, +// so applying it early is always safe; it keeps a certified-skipped slot from +// being re-fetched/re-run and makes the skip decision survive block-source +// recreation on a post-switch re-replay. The emit loop then advances the +// frontier for the marked skip mode-independently. Returns true if it newly +// marked the slot. +func (bs *BlockSource) applyAlpenglowCertifiedSkipLocked() bool { + if bs.alpenglowDecisionSource == nil { + return false + } + waitingSlot := bs.nextSlotToSend + if waitingSlot == 0 || bs.skippedSlots[waitingSlot] { + return false + } + decision, ok := bs.alpenglowDecisionSource(waitingSlot - 1) + if !ok || decision.Slot != waitingSlot || decision.Kind != alpenglow.ChainDecisionKindSkip { + return false + } + delete(bs.reorderBuffer, waitingSlot) + bs.skippedSlots[waitingSlot] = true + bs.alpenglowCertifiedSkips[waitingSlot] = true + delete(bs.lightbringerSynthesizedSkips, waitingSlot) + bs.slotStateMu.Lock() + bs.slotState[waitingSlot] = slotDone + delete(bs.inflightStart, waitingSlot) + bs.slotStateMu.Unlock() + bs.clearSlotErrors(waitingSlot) + bs.stats.FetchSkipped.Add(1) + mlog.Log.FileOnlyf("ALPENGLOW consensus decision: slot %d certified-skipped (mode-independent)", waitingSlot) + return true +} + func (bs *BlockSource) applyAlpenglowDecisionLocked() bool { if bs.alpenglowDecisionSource == nil || bs.sourceType != BlockSourceTurbine || !bs.turbineAlpenglowBlockIDHints { return false } + // Certified skips are consensus facts — apply them regardless of mode so a + // certified-skipped slot is never re-run and the decision survives source + // recreation. The marked skip advances via the normal emit path. + if bs.applyAlpenglowCertifiedSkipLocked() { + return false + } + // Buffered-candidate block steering needs an active near-tip Turbine stream. if !bs.lightbringerActive.Load() || !bs.isNearTip.Load() { return false } @@ -2523,11 +2659,6 @@ func (bs *BlockSource) detectLightbringerGapLocked() (waitingSlot uint64, firstB bs.clearLightbringerGapWatch() return 0, 0, 0, 0, false } - if bs.consensusManagedLightbringer && lightbringerActive { - bs.clearLightbringerGapWatch() - return 0, 0, 0, 0, false - } - waitingSlot = bs.nextSlotToSend if !lightbringerActive && handoffSlot != 0 && waitingSlot < handoffSlot { // RPC still owns slots before the pending handoff boundary. Buffered @@ -2584,26 +2715,6 @@ func (bs *BlockSource) detectLightbringerGapLocked() (waitingSlot uint64, firstB return waitingSlot, firstBufferedSlot, firstBufferedParentSlot, bufferedCount, true } -func (bs *BlockSource) shouldDeliverLightbringerObservationDirectLocked(slot uint64, blk *b.Block) bool { - if !bs.consensusManagedLightbringer || !bs.usesLiveShredStream() { - return false - } - if blk == nil || !blk.FromLightbringer { - return false - } - if !bs.isNearTip.Load() || !bs.lightbringerActive.Load() { - return false - } - if bs.lightbringerForceRPCUntil.Load() != 0 { - return false - } - if slot < bs.nextSlotToSend { - return false - } - return true -} - -// classifyError returns a string classification for an error func classifyError(err error) string { if err == nil { return "success" @@ -3112,6 +3223,69 @@ func (bs *BlockSource) pollTip() { // In near-tip mode, this also: // - Schedules N+2 (prefetch while N+1 executes) // - Immediately retries N+1 if it failed (don't wait for 200ms ticker) +// RewindForAlpenglowSwitch rewinds the emission frontier to re-serve `slot` +// after certificates named a different outcome than the executed one (wrong +// sibling or certificate-skipped). Buffered and in-flight state at or above +// the slot is dropped, live-stream results are invalidated, and the certified +// block id (zero for a skip) narrows the turbine assembler + prioritizes +// repair so the certified version arrives fast. +func (bs *BlockSource) RewindForAlpenglowSwitch(slot uint64, certified solana.Hash) { + if slot == 0 { + return + } + bs.reorderMu.Lock() + if bs.nextSlotToSend > slot { + bs.nextSlotToSend = slot + } + if bs.lastEmittedBlockSlot >= slot { + bs.lastEmittedBlockSlot = slot - 1 + } + for bufferedSlot := range bs.reorderBuffer { + if bufferedSlot >= slot { + delete(bs.reorderBuffer, bufferedSlot) + } + } + for skippedSlot := range bs.skippedSlots { + if skippedSlot >= slot { + delete(bs.skippedSlots, skippedSlot) + delete(bs.lightbringerSynthesizedSkips, skippedSlot) + delete(bs.alpenglowCertifiedSkips, skippedSlot) + } + } + bs.reorderMu.Unlock() + + bs.slotStateMu.Lock() + for trackedSlot := range bs.slotState { + if trackedSlot >= slot { + delete(bs.slotState, trackedSlot) + delete(bs.inflightStart, trackedSlot) + } + } + bs.slotStateMu.Unlock() + + bs.retryMu.Lock() + if len(bs.retrySlots) > 0 { + filtered := bs.retrySlots[:0] + for _, retrySlot := range bs.retrySlots { + if retrySlot < slot { + filtered = append(filtered, retrySlot) + } + } + bs.retrySlots = filtered + } + bs.retryMu.Unlock() + + // Drop prefetched live-stream blocks for the rewound range and invalidate + // in-flight results so stale emissions can't race the re-serve. + bs.invalidateLightbringerResults() + + if certified != (solana.Hash{}) { + bs.SetKnownAlpenglowBlockID(slot, certified) + bs.resetTurbineSlotForAlpenglowBlock(slot, certified) + } + mlog.Log.Warnf("BLOCK SOURCE REWIND: re-serving slot %d after certificate switch (certified=%s)", slot, certified) +} + func (bs *BlockSource) SetLastExecutedSlot(slot uint64) { bs.lastExecutedSlot.Store(slot) @@ -3479,7 +3653,6 @@ func (bs *BlockSource) emitOrderedBlocks() { var gapFirstBufferedParentSlot uint64 var gapBufferedCount int var shouldFallbackToRPC bool - var emitObservationDirect bool if result.slot < bs.nextSlotToSend { bs.slotStateMu.Lock() @@ -3626,10 +3799,7 @@ func (bs *BlockSource) emitOrderedBlocks() { if !result.block.FromLightbringer { bs.stats.FetchSuccesses.Add(1) } - emitObservationDirect = bs.shouldDeliverLightbringerObservationDirectLocked(result.slot, result.block) - if !emitObservationDirect { - bs.reorderBuffer[result.slot] = result.block - } + bs.reorderBuffer[result.slot] = result.block bs.hardErrCount.Store(0) // Reset error count on success bs.clearSlotErrors(result.slot) // Clear stall diagnostics for this slot // Track max buffered slot @@ -3668,14 +3838,6 @@ func (bs *BlockSource) emitOrderedBlocks() { bs.slotStateMu.Unlock() } - if emitObservationDirect { - blk := result.block - bs.reorderMu.Unlock() - bs.streamChan <- blk - bs.lastProgress.Store(time.Now().Unix()) - bs.reorderMu.Lock() - } - // Emit consecutive blocks for { if bs.applyAlpenglowDecisionLocked() { @@ -4208,6 +4370,11 @@ func (bs *BlockSource) Start() { // Start tip poller go bs.pollTip() + // Cert-driven repair: steer turbine toward certified-but-unobserved blocks. + if bs.sourceType == BlockSourceTurbine && bs.turbineAlpenglowBlockIDHints && bs.alpenglowWantedBlocksFn != nil { + go bs.alpenglowRepairLoop() + } + // Wait for initial tip time.Sleep(100 * time.Millisecond) diff --git a/pkg/blockstream/block_source_test.go b/pkg/blockstream/block_source_test.go index a31fd3628..d96484f28 100644 --- a/pkg/blockstream/block_source_test.go +++ b/pkg/blockstream/block_source_test.go @@ -125,6 +125,42 @@ func TestApplyAlpenglowDecisionLockedMarksCertifiedSkip(t *testing.T) { } } +// A certified skip applies even OUTSIDE active near-tip Turbine (RPC catchup / +// pre-handoff), so a certified-skipped slot is not re-run after block-source +// recreation on the post-switch re-replay. +func TestApplyAlpenglowCertifiedSkipModeIndependent(t *testing.T) { + bs := NewBlockSource(&BlockSourceOpts{ + SourceType: BlockSourceTurbine, + TurbineBindAddr: "127.0.0.1:0", + TurbineAlpenglowBlockIDHints: true, + StartSlot: 151, + EndSlot: 200, + AlpenglowDecisionSource: func(anchorSlot uint64) (alpenglow.ChainDecision, bool) { + return alpenglow.ChainDecision{Slot: 151, Kind: alpenglow.ChainDecisionKindSkip}, true + }, + }) + // NOT near-tip and NOT lightbringer-active: the pre-handoff / RPC-catchup + // case that the near-tip gate would otherwise skip. + bs.isNearTip.Store(false) + bs.lightbringerActive.Store(false) + + bs.reorderMu.Lock() + changed := bs.applyAlpenglowDecisionLocked() + skipped := bs.skippedSlots[151] + certSkip := bs.alpenglowCertifiedSkips[151] + bs.reorderMu.Unlock() + + if changed { + t.Fatal("skip marking returns false; the frontier advances via the normal skip path") + } + if !skipped || !certSkip { + t.Fatalf("certified skip must be marked outside near-tip: skipped=%v cert=%v", skipped, certSkip) + } + if got := bs.stats.FetchSkipped.Load(); got != 1 { + t.Fatalf("FetchSkipped = %d, want 1", got) + } +} + func TestApplyAlpenglowDecisionLockedLeavesMatchingCertifiedBlock(t *testing.T) { blockID := solana.Hash{1} bs := NewBlockSource(&BlockSourceOpts{ @@ -492,41 +528,6 @@ func TestPrepareLightbringerHandoffRequiresMinimumRunway(t *testing.T) { } } -func TestPrepareLightbringerHandoffAllowsLiveEdgeRunwayAtTip(t *testing.T) { - bs := NewBlockSource(&BlockSourceOpts{ - SourceType: BlockSourceLightbringer, - LightbringerEndpoint: "127.0.0.1:50051", - StartSlot: 100, - EndSlot: 200, - ConsensusManagedLightbringer: true, - }) - - bs.isNearTip.Store(true) - bs.lightbringerConnected.Store(true) - bs.lastExecutedSlot.Store(150) - bs.confirmedTip.Store(151) - bs.lightbringerLastStreamSlot.Store(151) - bs.lastEmittedBlockSlot = 150 - bs.lightbringerBuffer[151] = &b.Block{Slot: 151, FromLightbringer: true, SourceParentSlot: 150} - bs.lightbringerBufferOrder = append(bs.lightbringerBufferOrder, 151) - - reason := bs.lightbringerHandoffWaitReason(151, 150) - if !strings.Contains(reason, "handoff-ready runway buffered through slot 151") { - t.Fatalf("expected live-edge runway to be handoff-ready, got %q", reason) - } - - blocks, handoffSlot, prepared := bs.prepareLightbringerHandoff(151, 150) - if !prepared { - t.Fatalf("expected consensus-managed handoff to prepare at the live edge") - } - if handoffSlot != 151 { - t.Fatalf("expected handoff slot 151, got %d", handoffSlot) - } - if len(blocks) != 1 || blocks[0].Slot != 151 { - t.Fatalf("expected single live-edge Lightbringer block to be enqueued, got %+v", blocks) - } -} - func TestPrepareTurbineHandoffAllowsLiveEdgeRunwayAtTipWithoutConsensusBuffering(t *testing.T) { bs := NewBlockSource(&BlockSourceOpts{ SourceType: BlockSourceTurbine, @@ -563,11 +564,10 @@ func TestPrepareTurbineHandoffAllowsLiveEdgeRunwayAtTipWithoutConsensusBuffering func TestPrepareLightbringerHandoffKeepsMinimumRunwayWhenLightbringerLagsTip(t *testing.T) { bs := NewBlockSource(&BlockSourceOpts{ - SourceType: BlockSourceLightbringer, - LightbringerEndpoint: "127.0.0.1:50051", - StartSlot: 100, - EndSlot: 200, - ConsensusManagedLightbringer: true, + SourceType: BlockSourceLightbringer, + LightbringerEndpoint: "127.0.0.1:50051", + StartSlot: 100, + EndSlot: 200, }) bs.isNearTip.Store(true) @@ -810,77 +810,6 @@ func TestShouldDecodeLightbringerSlotDoesNotStageWhenReplayGapTooLarge(t *testin } } -func TestUpdateModeDefersCatchupWhileConsensusManagedLightbringerIsLive(t *testing.T) { - bs := NewBlockSource(&BlockSourceOpts{ - SourceType: BlockSourceLightbringer, - LightbringerEndpoint: "127.0.0.1:50051", - StartSlot: 100, - EndSlot: 300, - ConsensusManagedLightbringer: true, - }) - - bs.lightbringerStarted.Store(true) - bs.isNearTip.Store(true) - bs.lightbringerActive.Store(true) - bs.lightbringerConnected.Store(true) - bs.lightbringerHandoffSlot.Store(101) - bs.lastExecutedSlot.Store(100) - bs.confirmedTip.Store(165) - bs.lightbringerLastStreamSlot.Store(164) - bs.lightbringerLastRecvUnix.Store(time.Now().Unix()) - bs.lastProgress.Store(time.Now().Unix()) - bs.nextSlotToSend = 101 - - bs.updateMode() - - if !bs.isNearTip.Load() { - t.Fatalf("expected near-tip mode to remain active while Lightbringer observations are fresh") - } - if !bs.lightbringerActive.Load() { - t.Fatalf("expected Lightbringer to stay active during consensus buffering") - } - if bs.lightbringerNeedRPCResume.Load() { - t.Fatalf("expected RPC resume flag to stay clear while deferring catchup") - } -} - -func TestUpdateModeFallsBackWhenConsensusManagedLightbringerReplayGapExceedsGrace(t *testing.T) { - bs := NewBlockSource(&BlockSourceOpts{ - SourceType: BlockSourceLightbringer, - LightbringerEndpoint: "127.0.0.1:50051", - StartSlot: 100, - EndSlot: 300, - ConsensusManagedLightbringer: true, - }) - - bs.lightbringerStarted.Store(true) - bs.isNearTip.Store(true) - bs.lightbringerActive.Store(true) - bs.lightbringerConnected.Store(true) - bs.lightbringerHandoffSlot.Store(101) - bs.lastExecutedSlot.Store(100) - bs.confirmedTip.Store(229) - bs.lightbringerLastStreamSlot.Store(229) - bs.lightbringerLastRecvUnix.Store(time.Now().Unix()) - bs.lastProgress.Store(time.Now().Unix()) - bs.nextSlotToSend = 150 - - bs.updateMode() - - if bs.isNearTip.Load() { - t.Fatalf("expected near-tip mode to fall back once replay gap exceeds consensus buffering grace") - } - if bs.lightbringerActive.Load() { - t.Fatalf("expected Lightbringer to be marked inactive after fallback") - } - if !bs.lightbringerNeedRPCResume.Load() { - t.Fatalf("expected RPC resume flag to be raised after fallback") - } - if got := bs.nextSlotToSend; got != 101 { - t.Fatalf("expected consensus-managed fallback to rewind emission frontier to replay next slot 101, got %d", got) - } -} - func TestShouldPreferIncomingLightbringerBlockLockedPrefersConnectedSameSlotBlock(t *testing.T) { bs := NewBlockSource(&BlockSourceOpts{ SourceType: BlockSourceLightbringer, @@ -927,30 +856,6 @@ func TestWaitingLightbringerParentMismatchLockedDetectsDisconnectedBufferedSlot( } } -func TestWaitingLightbringerParentMismatchLockedDefersWhenConsensusManaged(t *testing.T) { - bs := NewBlockSource(&BlockSourceOpts{ - SourceType: BlockSourceLightbringer, - LightbringerEndpoint: "127.0.0.1:50051", - StartSlot: 100, - EndSlot: 200, - ConsensusManagedLightbringer: true, - }) - - bs.lightbringerActive.Store(true) - bs.lastEmittedBlockSlot = 150 - bs.nextSlotToSend = 151 - bs.reorderBuffer[151] = &b.Block{Slot: 151, FromLightbringer: true, SourceParentSlot: 149} - - bs.reorderMu.Lock() - waitingSlot, observedParent, expectedParent, mismatch := bs.waitingLightbringerParentMismatchLocked() - bs.reorderMu.Unlock() - - if mismatch || waitingSlot != 0 || observedParent != 0 || expectedParent != 0 { - t.Fatalf("expected consensus-managed Lightbringer to defer parent mismatch handling, got mismatch=%v slot=%d observed=%d expected=%d", - mismatch, waitingSlot, observedParent, expectedParent) - } -} - func TestShouldDiscardSkippedSlotAfterHandoffDropsRPCSkipMarker(t *testing.T) { bs := NewBlockSource(&BlockSourceOpts{ SourceType: BlockSourceLightbringer, @@ -1157,32 +1062,6 @@ func TestDetectLightbringerGapWaitsForConfiguredFallbackDelay(t *testing.T) { } } -func TestDetectLightbringerGapDefersWhenConsensusManaged(t *testing.T) { - bs := NewBlockSource(&BlockSourceOpts{ - SourceType: BlockSourceLightbringer, - LightbringerEndpoint: "127.0.0.1:50051", - StartSlot: 100, - EndSlot: 200, - ConsensusManagedLightbringer: true, - }) - - bs.lightbringerActive.Store(true) - bs.nextSlotToSend = 120 - bs.reorderBuffer[121] = &b.Block{Slot: 121, FromLightbringer: true, SourceParentSlot: 120} - bs.reorderBuffer[122] = &b.Block{Slot: 122, FromLightbringer: true, SourceParentSlot: 121} - bs.lightbringerGapSlot.Store(120) - bs.lightbringerGapSinceUnix.Store(time.Now().Add(-time.Minute).UnixNano()) - - waitingSlot, firstBufferedSlot, firstBufferedParentSlot, bufferedCount, shouldFallback := bs.detectLightbringerGapLocked() - if waitingSlot != 0 || firstBufferedSlot != 0 || firstBufferedParentSlot != 0 || bufferedCount != 0 || shouldFallback { - t.Fatalf("expected consensus-managed Lightbringer to defer gap fallback, got waiting=%d first=%d parent=%d buffered=%d fallback=%v", - waitingSlot, firstBufferedSlot, firstBufferedParentSlot, bufferedCount, shouldFallback) - } - if got := bs.lightbringerGapSlot.Load(); got != 0 { - t.Fatalf("expected deferred gap tracking to clear the active gap watch, got slot %d", got) - } -} - func TestSetLastExecutedSlotClearsRecoveryWindowImmediatelyWhenDisabled(t *testing.T) { bs := NewBlockSource(&BlockSourceOpts{ SourceType: BlockSourceLightbringer, @@ -1207,11 +1086,10 @@ func TestSetLastExecutedSlotClearsRecoveryWindowImmediatelyWhenDisabled(t *testi func TestSetLastExecutedSlotAdvancesDeferredLightbringerFrontier(t *testing.T) { bs := NewBlockSource(&BlockSourceOpts{ - SourceType: BlockSourceLightbringer, - LightbringerEndpoint: "127.0.0.1:50051", - StartSlot: 100, - EndSlot: 200, - ConsensusManagedLightbringer: true, + SourceType: BlockSourceLightbringer, + LightbringerEndpoint: "127.0.0.1:50051", + StartSlot: 100, + EndSlot: 200, }) bs.nextSlotToSend = 151 @@ -1257,11 +1135,10 @@ func TestSetLastExecutedSlotAdvancesDeferredLightbringerFrontier(t *testing.T) { func TestForceRPCForCatchupRewindsConsensusManagedFrontier(t *testing.T) { bs := NewBlockSource(&BlockSourceOpts{ - SourceType: BlockSourceLightbringer, - LightbringerEndpoint: "127.0.0.1:50051", - StartSlot: 100, - EndSlot: 200, - ConsensusManagedLightbringer: true, + SourceType: BlockSourceLightbringer, + LightbringerEndpoint: "127.0.0.1:50051", + StartSlot: 100, + EndSlot: 200, }) bs.lightbringerActive.Store(true) @@ -1311,11 +1188,10 @@ func TestForceRPCForCatchupRewindsConsensusManagedFrontier(t *testing.T) { func TestForceRPCFallbackRewindsConsensusManagedTurbineFrontier(t *testing.T) { bs := NewBlockSource(&BlockSourceOpts{ - SourceType: BlockSourceTurbine, - TurbineBindAddr: "127.0.0.1:8001", - StartSlot: 100, - EndSlot: 200, - ConsensusManagedLightbringer: true, + SourceType: BlockSourceTurbine, + TurbineBindAddr: "127.0.0.1:8001", + StartSlot: 100, + EndSlot: 200, }) bs.lightbringerActive.Store(true) @@ -1413,11 +1289,10 @@ func TestForceRPCFallbackRewindsActiveTurbineFrontierToReplayProgress(t *testing func TestForceRPCForCatchupKeepsPendingHandoffEmissionFrontier(t *testing.T) { bs := NewBlockSource(&BlockSourceOpts{ - SourceType: BlockSourceLightbringer, - LightbringerEndpoint: "127.0.0.1:50051", - StartSlot: 100, - EndSlot: 200, - ConsensusManagedLightbringer: true, + SourceType: BlockSourceLightbringer, + LightbringerEndpoint: "127.0.0.1:50051", + StartSlot: 100, + EndSlot: 200, }) bs.lightbringerHandoffSlot.Store(121) @@ -1447,45 +1322,6 @@ func TestForceRPCForCatchupKeepsPendingHandoffEmissionFrontier(t *testing.T) { } } -func TestEmitOrderedBlocksDirectlyStreamsConsensusManagedLightbringerObservations(t *testing.T) { - bs := NewBlockSource(&BlockSourceOpts{ - SourceType: BlockSourceLightbringer, - LightbringerEndpoint: "127.0.0.1:50051", - StartSlot: 100, - EndSlot: 200, - ConsensusManagedLightbringer: true, - }) - - bs.isNearTip.Store(true) - bs.lightbringerActive.Store(true) - bs.nextSlotToSend = 101 - - done := make(chan struct{}) - go func() { - bs.emitOrderedBlocks() - close(done) - }() - - bs.resultQueue <- fetchResult{ - slot: 105, - block: &b.Block{Slot: 105, FromLightbringer: true, SourceParentSlot: 104}, - } - close(bs.resultQueue) - - blk := bs.NextBlock() - <-done - - if blk == nil || blk.Slot != 105 || !blk.FromLightbringer { - t.Fatalf("expected direct Lightbringer observation for slot 105, got %+v", blk) - } - if _, exists := bs.reorderBuffer[105]; exists { - t.Fatalf("expected direct observation to bypass the reorder buffer") - } - if got := bs.nextSlotToSend; got != 101 { - t.Fatalf("expected direct observation to leave nextSlotToSend at 101 until replay resolves it, got %d", got) - } -} - func TestEmitOrderedBlocksDropsStaleLiveStreamGeneration(t *testing.T) { bs := NewBlockSource(&BlockSourceOpts{ SourceType: BlockSourceTurbine, diff --git a/pkg/config/config.go b/pkg/config/config.go index 8adbcd7c0..78c7f94cd 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -12,7 +12,10 @@ const LightbringerQuietDefault = true func ApplyDefaults(v *viper.Viper) { v.SetDefault("lightbringer.quiet", LightbringerQuietDefault) - v.SetDefault("consensus.mode", "classic") + // network.cluster and block.source default in the run command itself + // (alpenglow / turbine) — NOT here, because the lightbringer auto-switch + // needs to distinguish "operator chose a source" from "defaulted". + v.SetDefault("consensus.mode", "verifying") v.SetDefault("consensus.alpenglow_observer_bind_addr", "") v.SetDefault("consensus.alpenglow_bls_dst", "") v.SetDefault("validator.identity_keypair", "") @@ -197,15 +200,12 @@ type LogConfig struct { MaxBackups int `toml:"max_backups" mapstructure:"max_backups"` // Keep up to N old log files } -// ConsensusConfig holds vote-anchored consensus configuration +// ConsensusConfig holds Alpenglow consensus configuration. type ConsensusConfig struct { - Mode string `toml:"mode" mapstructure:"mode"` // "classic", "alpenglow-observer", or "alpenglow" (default: "classic") + Mode string `toml:"mode" mapstructure:"mode"` // "verifying" (default, non-voting) or "validator" (enforces keypair/socket requirements; voting engine not yet active) AlpenglowObserverBindAddr string `toml:"alpenglow_observer_bind_addr" mapstructure:"alpenglow_observer_bind_addr"` // Optional passive Alpenglow Votor QUIC listener AlpenglowMaxMessageBytes int64 `toml:"alpenglow_max_message_bytes" mapstructure:"alpenglow_max_message_bytes"` // Max Votor QUIC stream payload size AlpenglowBLSDST string `toml:"alpenglow_bls_dst" mapstructure:"alpenglow_bls_dst"` // BLS hash-to-curve DST; empty = default (must match cluster's solana-bls version) - SkipPathMaxDepth int `toml:"skip_path_max_depth" mapstructure:"skip_path_max_depth"` // Max slots the skip-path solver explores (default: 64) - UnresolvedPolicy string `toml:"unresolved_policy" mapstructure:"unresolved_policy"` // "halt" or "warn" (default: "halt") - EnforceOnSource string `toml:"enforce_on_source" mapstructure:"enforce_on_source"` // "lightbringer", "turbine", "stream", or "all" (default: "stream") } // ValidatorConfig holds optional validator identity material for gossip and future voting modes. diff --git a/pkg/consensus/engine.go b/pkg/consensus/engine.go index 249c75001..f00b8851b 100644 --- a/pkg/consensus/engine.go +++ b/pkg/consensus/engine.go @@ -2,7 +2,6 @@ package consensus import ( "context" - "errors" "fmt" "strings" "sync" @@ -15,16 +14,6 @@ import ( "github.com/gagliardetto/solana-go" ) -type Mode string - -const ( - ModeClassic Mode = "classic" - ModeAlpenglowObserver Mode = "alpenglow-observer" - ModeAlpenglow Mode = "alpenglow" -) - -var ErrAlpenglowVotingNotImplemented = errors.New("alpenglow voting mode is not implemented yet; use consensus.mode=\"alpenglow-observer\"") - const ( maxRecentAlpenglowBlockIDs = 8192 alpenglowVoteVerifySamplesPerWindow = 16 @@ -80,8 +69,33 @@ type AlpenglowEpochLookupSink interface { SetAlpenglowEpochLookup(fn func(slot uint64) uint64) } +// AlpenglowPruneSink lets replay prune consensus bookkeeping as slots fold. +type AlpenglowPruneSink interface { + PruneAlpenglowBefore(slot uint64) +} + +// AlpenglowChainQuery answers the switch sweep's decisive-certificate +// questions (unique-strength or finalized block per slot; certified skips). +type AlpenglowChainQuery interface { + CertifiedBlockAt(slot uint64) (alpenglow.BlockID, alpenglow.CertificateType, bool) + SkipCertifiedAt(slot uint64) bool + // ChainDecisionVersion gates the sweep: it advances on ANY decision-relevant + // change (cert acceptance, replay-derived parent links, finalized ancestry, + // indirect skips, conflicts), not only on new certificates — so a + // contradiction that arises without a new cert is never missed. + ChainDecisionVersion() uint64 +} + +// AlpenglowWantedBlocksSource surfaces certified-but-unobserved blocks so the +// block source can steer turbine/repair toward data the cluster has already +// voted real (cert-driven repair). +type AlpenglowWantedBlocksSource interface { + AlpenglowWantedBlocks(afterSlot uint64, max int) []alpenglow.WantedBlock + SkipCertifiedAt(slot uint64) bool +} + type Snapshot struct { - Mode Mode `json:"mode"` + Mode string `json:"mode"` ObservedBlocks uint64 `json:"observed_blocks"` ReplayedSlots uint64 `json:"replayed_slots"` Alpenglow *alpenglow.Snapshot `json:"alpenglow,omitempty"` @@ -104,88 +118,36 @@ type Config struct { AlpenglowBLSDST string // BLS hash-to-curve DST; empty keeps the default (must match cluster's solana-bls version) } -func NormalizeMode(raw string) (Mode, error) { - mode := Mode(strings.ToLower(strings.TrimSpace(raw))) - if mode == "" { - return ModeClassic, nil - } - if mode == "legacy" { - return ModeClassic, nil - } - - switch mode { - case ModeClassic, ModeAlpenglowObserver, ModeAlpenglow: - return mode, nil - default: - return "", fmt.Errorf("invalid consensus.mode %q (must be \"classic\", \"alpenglow-observer\", or \"alpenglow\")", raw) - } -} - -func NewEngine(mode Mode) (Engine, error) { - return NewEngineWithConfig(mode, Config{}) -} - -func NewEngineWithConfig(mode Mode, cfg Config) (Engine, error) { +// NewEngine constructs the Alpenglow observer engine — the only consensus +// engine in this Alpenglow-only build. +func NewEngine(cfg Config) (*AlpenglowObserverEngine, error) { alpenglow.SetHashToPointDST(strings.TrimSpace(cfg.AlpenglowBLSDST)) - switch mode { - case ModeClassic: - return &ClassicEngine{}, nil - case ModeAlpenglowObserver: - return &AlpenglowObserverEngine{ - observer: alpenglow.NewObserver(), - chain: newAlpenglowObserverChainTracker(), - verifier: alpenglow.NewCertificateVerifier(), - receiverBindAddr: strings.TrimSpace(cfg.AlpenglowObserverBindAddr), - receiverMaxMessageBytes: cfg.AlpenglowMaxMessageBytes, - recentBlockIDs: make(map[uint64]solana.Hash), - }, nil - case ModeAlpenglow: - return &AlpenglowEngine{}, nil - default: - return nil, fmt.Errorf("unsupported consensus mode %q", mode) - } -} - -type ClassicEngine struct { - observedBlocks atomic.Uint64 - replayedSlots atomic.Uint64 -} - -func (e *ClassicEngine) Name() string { return string(ModeClassic) } - -func (e *ClassicEngine) Start(context.Context) error { - mlog.Log.Infof("Consensus engine started: %s", e.Name()) - return nil -} - -func (e *ClassicEngine) ObserveBlock(_ context.Context, obs BlockObservation) error { - if obs.Block != nil { - e.observedBlocks.Add(1) - } - return nil -} - -func (e *ClassicEngine) OnReplayResult(_ context.Context, result SlotReplayResult) error { - if result.Slot != 0 { - e.replayedSlots.Add(1) - } - return nil -} - -func (e *ClassicEngine) Snapshot() Snapshot { - return Snapshot{ - Mode: ModeClassic, - ObservedBlocks: e.observedBlocks.Load(), - ReplayedSlots: e.replayedSlots.Load(), - } + e := &AlpenglowObserverEngine{ + observer: alpenglow.NewObserver(), + chain: newAlpenglowObserverChainTracker(), + verifier: alpenglow.NewCertificateVerifier(), + receiverBindAddr: strings.TrimSpace(cfg.AlpenglowObserverBindAddr), + receiverMaxMessageBytes: cfg.AlpenglowMaxMessageBytes, + recentBlockIDs: make(map[uint64]solana.Hash), + } + // The cert pool assembles certificates locally from raw Votor votes; + // pool-assembled certs are fully stake+signature verified and enter the + // chain tracker exactly like verified wire certs. + e.certPool = alpenglow.NewCertPool(alpenglow.DefaultCertPoolConfig(), e.verifier, func(cert alpenglow.Certificate) { + if _, err := e.ensureChain().ObserveCertificate(cert); err != nil { + mlog.Log.FileOnlyf("ALPENGLOW observer: pool-assembled certificate rejected by tracker: %v", err) + return + } + e.observeVotorBlockID(alpenglow.Message{Certificate: &cert}) + }) + return e, nil } -func (e *ClassicEngine) Close() error { return nil } - type AlpenglowObserverEngine struct { observedBlocks atomic.Uint64 replayedSlots atomic.Uint64 observer *alpenglow.Observer + certPool *alpenglow.CertPool chain *alpenglow.ChainTracker verifier *alpenglow.CertificateVerifier receiverBindAddr string @@ -214,7 +176,7 @@ type AlpenglowObserverEngine struct { lastVoteVerifyErr string } -func (e *AlpenglowObserverEngine) Name() string { return string(ModeAlpenglowObserver) } +func (e *AlpenglowObserverEngine) Name() string { return "alpenglow-observer" } func (e *AlpenglowObserverEngine) Start(ctx context.Context) error { observer := e.ensureObserver() @@ -281,13 +243,18 @@ func (e *AlpenglowObserverEngine) OnReplayResult(_ context.Context, result SlotR Source: result.Source, At: result.At, }) + // Anchor the cert pool's vote window to execution-proven progress — a + // trusted signal an attacker cannot advance (unlike raw vote slots). + if e.certPool != nil { + e.certPool.NoteLiveSlot(result.Slot) + } } return nil } func (e *AlpenglowObserverEngine) Snapshot() Snapshot { snapshot := Snapshot{ - Mode: ModeAlpenglowObserver, + Mode: "alpenglow-observer", ObservedBlocks: e.observedBlocks.Load(), ReplayedSlots: e.replayedSlots.Load(), } @@ -330,7 +297,9 @@ func (e *AlpenglowObserverEngine) SetAlpenglowBlockIDSink(sink AlpenglowBlockIDS func (e *AlpenglowObserverEngine) observeVotorMessage(msg alpenglow.Message) { if msg.Vote != nil { - e.sampleVoteVerification(*msg.Vote) + // The cert pool owns vote verification now (lazy, batched); the old + // per-window sampling verifier is redundant with it. + e.certPool.AddVote(*msg.Vote) } if msg.Certificate != nil { verified, result, err := e.verifyCertificate(*msg.Certificate) @@ -518,13 +487,55 @@ func (e *AlpenglowObserverEngine) SetAlpenglowValidatorSet(set alpenglow.Validat } mlog.Log.FileOnlyf("ALPENGLOW observer: installed validator set for epoch %d (validators=%d total_stake=%d)", set.Epoch, len(set.Validators), set.TotalStake) e.replayPendingCertsForEpoch(set.Epoch) + if e.certPool != nil { + e.certPool.OnValidatorSetInstalled(set.Epoch) + } return nil } +// CertifiedBlockAt exposes the tracker's decisive block for the switch sweep. +func (e *AlpenglowObserverEngine) CertifiedBlockAt(slot uint64) (alpenglow.BlockID, alpenglow.CertificateType, bool) { + return e.ensureChain().CertifiedBlockAt(slot) +} + +// SkipCertifiedAt exposes certified skips for the switch sweep. +func (e *AlpenglowObserverEngine) SkipCertifiedAt(slot uint64) bool { + return e.ensureChain().SkipCertifiedAt(slot) +} + +// ChainDecisionVersion gates the sweep: it re-walks executed slots whenever the +// tracker became more decisive, including replay-derived changes that land +// without a new certificate. +func (e *AlpenglowObserverEngine) ChainDecisionVersion() uint64 { + return e.ensureChain().DecisionVersion() +} + +// AlpenglowWantedBlocks lists certified-but-unobserved blocks for cert-driven +// repair (ascending, capped). +func (e *AlpenglowObserverEngine) AlpenglowWantedBlocks(afterSlot uint64, max int) []alpenglow.WantedBlock { + return e.ensureChain().WantedBlocks(afterSlot, max) +} + func (e *AlpenglowObserverEngine) SetAlpenglowEpochLookup(fn func(slot uint64) uint64) { e.epochLookupMu.Lock() e.epochForSlot = fn e.epochLookupMu.Unlock() + if e.certPool != nil { + e.certPool.SetEpochLookup(fn) + } +} + +// PruneAlpenglowBefore drops consensus bookkeeping for slots at or below the +// durably-folded watermark: chain-tracker state and cert-pool tallies. +// Equivocation and conflict evidence survive pruning by design. +func (e *AlpenglowObserverEngine) PruneAlpenglowBefore(slot uint64) { + if slot == 0 { + return + } + e.ensureChain().PruneBeforeSlot(slot) + if e.certPool != nil { + e.certPool.ObserveFloor(slot) + } } func (e *AlpenglowObserverEngine) Close() error { @@ -778,21 +789,3 @@ func parentBlockIDOrZero(block *block.Block) solana.Hash { } return solana.Hash(block.AlpenglowParentBlockID) } - -type AlpenglowEngine struct{} - -func (e *AlpenglowEngine) Name() string { return string(ModeAlpenglow) } - -func (e *AlpenglowEngine) Start(context.Context) error { - return ErrAlpenglowVotingNotImplemented -} - -func (e *AlpenglowEngine) ObserveBlock(context.Context, BlockObservation) error { return nil } - -func (e *AlpenglowEngine) OnReplayResult(context.Context, SlotReplayResult) error { return nil } - -func (e *AlpenglowEngine) Snapshot() Snapshot { - return Snapshot{Mode: ModeAlpenglow} -} - -func (e *AlpenglowEngine) Close() error { return nil } diff --git a/pkg/consensus/engine_test.go b/pkg/consensus/engine_test.go index 52896aa97..9e9a474c5 100644 --- a/pkg/consensus/engine_test.go +++ b/pkg/consensus/engine_test.go @@ -2,7 +2,6 @@ package consensus import ( "context" - "errors" "fmt" "math/big" "testing" @@ -13,51 +12,8 @@ import ( "github.com/gagliardetto/solana-go" ) -func TestNormalizeMode(t *testing.T) { - tests := []struct { - name string - raw string - want Mode - }{ - {name: "empty defaults classic", raw: "", want: ModeClassic}, - {name: "classic", raw: "classic", want: ModeClassic}, - {name: "legacy alias", raw: "legacy", want: ModeClassic}, - {name: "observer", raw: "alpenglow-observer", want: ModeAlpenglowObserver}, - {name: "alpenglow", raw: "alpenglow", want: ModeAlpenglow}, - {name: "trim lowercase", raw: " CLASSIC ", want: ModeClassic}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got, err := NormalizeMode(tt.raw) - if err != nil { - t.Fatalf("NormalizeMode(%q) returned error: %v", tt.raw, err) - } - if got != tt.want { - t.Fatalf("NormalizeMode(%q)=%q, want %q", tt.raw, got, tt.want) - } - }) - } -} - -func TestNormalizeModeRejectsUnknown(t *testing.T) { - if _, err := NormalizeMode("tower"); err == nil { - t.Fatalf("expected invalid mode error") - } -} - -func TestAlpenglowVotingModeFailsFast(t *testing.T) { - engine, err := NewEngine(ModeAlpenglow) - if err != nil { - t.Fatalf("NewEngine returned error: %v", err) - } - if err := engine.Start(context.Background()); !errors.Is(err, ErrAlpenglowVotingNotImplemented) { - t.Fatalf("Start error = %v, want %v", err, ErrAlpenglowVotingNotImplemented) - } -} - func TestAlpenglowObserverTracksReplayInSnapshot(t *testing.T) { - engine, err := NewEngine(ModeAlpenglowObserver) + engine, err := NewEngine(Config{}) if err != nil { t.Fatalf("NewEngine returned error: %v", err) } @@ -107,11 +63,11 @@ func TestAlpenglowObserverTracksReplayInSnapshot(t *testing.T) { // deferred and replayed once the stakes land — otherwise the cert is lost and that // slot's decision stalls. func TestAlpenglowDefersCertUntilStakesInstall(t *testing.T) { - engine, err := NewEngine(ModeAlpenglowObserver) + engine, err := NewEngine(Config{}) if err != nil { t.Fatalf("NewEngine returned error: %v", err) } - observer := engine.(*AlpenglowObserverEngine) + observer := engine set := testAlpenglowValidatorSet() observer.SetAlpenglowEpochLookup(func(slot uint64) uint64 { return set.Epoch }) @@ -141,11 +97,11 @@ func TestAlpenglowDefersCertUntilStakesInstall(t *testing.T) { // Deferred certs carry network-controlled slots, so the pending buffer must bound the // number of distinct epoch buckets (an attacker could otherwise feed far-future slots). func TestAlpenglowPendingCertEpochCap(t *testing.T) { - engine, err := NewEngine(ModeAlpenglowObserver) + engine, err := NewEngine(Config{}) if err != nil { t.Fatalf("NewEngine: %v", err) } - observer := engine.(*AlpenglowObserverEngine) + observer := engine observer.SetAlpenglowEpochLookup(func(slot uint64) uint64 { return slot }) // slot == epoch noSet := fmt.Errorf("alpenglow verifier: no validator set for epoch") @@ -170,11 +126,11 @@ func TestAlpenglowPendingCertEpochCap(t *testing.T) { // Finalize cert on the slow path — the replay loop captures these for the // promotion gate. func TestObserveFooterCertificatesReturnsFinalizedBlocks(t *testing.T) { - engine, err := NewEngine(ModeAlpenglowObserver) + engine, err := NewEngine(Config{}) if err != nil { t.Fatalf("NewEngine: %v", err) } - observer := engine.(*AlpenglowObserverEngine) + observer := engine if err := observer.SetAlpenglowValidatorSet(testAlpenglowValidatorSet()); err != nil { t.Fatalf("SetAlpenglowValidatorSet: %v", err) } @@ -210,11 +166,11 @@ func TestObserveFooterCertificatesReturnsFinalizedBlocks(t *testing.T) { // Once a validator set is installed, deferral only accepts epochs near it — a cert // with a far-off epoch can never verify soon and must not occupy buckets. func TestAlpenglowDeferRejectsFarOffEpochs(t *testing.T) { - engine, err := NewEngine(ModeAlpenglowObserver) + engine, err := NewEngine(Config{}) if err != nil { t.Fatalf("NewEngine: %v", err) } - observer := engine.(*AlpenglowObserverEngine) + observer := engine if err := observer.SetAlpenglowValidatorSet(testAlpenglowValidatorSet()); err != nil { // epoch 1 t.Fatalf("SetAlpenglowValidatorSet: %v", err) } @@ -235,11 +191,11 @@ func TestAlpenglowDeferRejectsFarOffEpochs(t *testing.T) { } func TestAlpenglowObserverFeedsCertifiedDecisionResolver(t *testing.T) { - engine, err := NewEngine(ModeAlpenglowObserver) + engine, err := NewEngine(Config{}) if err != nil { t.Fatalf("NewEngine returned error: %v", err) } - observer := engine.(*AlpenglowObserverEngine) + observer := engine if err := observer.SetAlpenglowValidatorSet(testAlpenglowValidatorSet()); err != nil { t.Fatalf("SetAlpenglowValidatorSet returned error: %v", err) } @@ -269,11 +225,11 @@ func TestAlpenglowObserverFeedsCertifiedDecisionResolver(t *testing.T) { } func TestAlpenglowObserverCandidateBlockEnablesIndirectSkipDecision(t *testing.T) { - engine, err := NewEngine(ModeAlpenglowObserver) + engine, err := NewEngine(Config{}) if err != nil { t.Fatalf("NewEngine returned error: %v", err) } - observer := engine.(*AlpenglowObserverEngine) + observer := engine if err := observer.SetAlpenglowValidatorSet(testAlpenglowValidatorSet()); err != nil { t.Fatalf("SetAlpenglowValidatorSet returned error: %v", err) } @@ -392,3 +348,18 @@ func testAlpenglowCertificateVote(t *testing.T, cert alpenglow.Certificate) alpe return alpenglow.Vote{} } } + +// The Alpenglow-only build has exactly one engine: NewEngine must always +// yield the observer engine regardless of configuration. +func TestNewEngineYieldsObserver(t *testing.T) { + engine, err := NewEngine(Config{}) + if err != nil { + t.Fatalf("NewEngine returned error: %v", err) + } + if engine == nil { + t.Fatal("NewEngine returned nil engine") + } + if got := engine.Name(); got != "alpenglow-observer" { + t.Fatalf("engine.Name()=%q, want %q", got, "alpenglow-observer") + } +} diff --git a/pkg/epochstakes/epoch_authorized_voters.go b/pkg/epochstakes/epoch_authorized_voters.go deleted file mode 100644 index 106c9e574..000000000 --- a/pkg/epochstakes/epoch_authorized_voters.go +++ /dev/null @@ -1,36 +0,0 @@ -package epochstakes - -import ( - "github.com/gagliardetto/solana-go" -) - -type EpochAuthorizedVotersCache struct { - authorizedVoters map[solana.PublicKey][]solana.PublicKey -} - -func NewEpochAuthorizedVotersCache() *EpochAuthorizedVotersCache { - return &EpochAuthorizedVotersCache{authorizedVoters: make(map[solana.PublicKey][]solana.PublicKey)} -} - -func (cache *EpochAuthorizedVotersCache) PutEntry(voteAcct solana.PublicKey, authorizedVoter solana.PublicKey) { - cache.authorizedVoters[voteAcct] = append(cache.authorizedVoters[voteAcct], authorizedVoter) -} - -func (cache *EpochAuthorizedVotersCache) IsAuthorizedVoter(voteAcct solana.PublicKey, pubkey solana.PublicKey) bool { - for _, a := range cache.authorizedVoters[voteAcct] { - if a == pubkey { - return true - } - } - return false -} - -// Entries returns the underlying map for serialization/persistence. -func (cache *EpochAuthorizedVotersCache) Entries() map[solana.PublicKey][]solana.PublicKey { - return cache.authorizedVoters -} - -// Len returns the number of vote accounts in the cache. -func (cache *EpochAuthorizedVotersCache) Len() int { - return len(cache.authorizedVoters) -} diff --git a/pkg/forkchoice/consensus_coordinator.go b/pkg/forkchoice/consensus_coordinator.go deleted file mode 100644 index 263abce62..000000000 --- a/pkg/forkchoice/consensus_coordinator.go +++ /dev/null @@ -1,122 +0,0 @@ -package forkchoice - -import ( - "errors" - - "github.com/gagliardetto/solana-go" -) - -var ( - ErrNeedWait = errors.New("consensus: vote landing window not reached, need to wait") - ErrNoSupermajority = errors.New("consensus: no hash reached supermajority for target slot") -) - -// SlotDecision represents the resolved action for a single slot. -type SlotDecision struct { - Slot uint64 - UseBlock bool // true = use the block, false = slot is empty/skipped -} - -// ResolvedPath is a confirmed execution path from the current anchor to a -// vote-confirmed leaf. -type ResolvedPath struct { - LeafSlot uint64 - LeafBankhash solana.Hash - SlotDecisions []SlotDecision -} - -// ConsensusCoordinator bridges the ForkChoiceService (vote accumulation) and -// the PoH path resolver. It finds a confirmed leaf ahead of the current anchor, -// then reconstructs which slots should use blocks versus be treated as skipped. -type ConsensusCoordinator struct { - forkChoice *ForkChoiceService - maxDepth int - policy string // "halt" = return error on unresolved, "warn" = log and continue -} - -// NewConsensusCoordinator creates a coordinator with the given forkchoice service, -// maximum path depth, and unresolved policy. -func NewConsensusCoordinator(fc *ForkChoiceService, maxDepth int, policy string) *ConsensusCoordinator { - return &ConsensusCoordinator{ - forkChoice: fc, - maxDepth: maxDepth, - policy: policy, - } -} - -// ResolveFromAnchor finds the highest confirmed leaf reachable within maxDepth -// from the current execution anchor, then reconstructs the block/skip path to it. -func (cc *ConsensusCoordinator) ResolveFromAnchor(anchorSlot uint64) (*ResolvedPath, error) { - latestObserved := cc.forkChoice.LatestObservedSlot() - if latestObserved <= anchorSlot { - return nil, ErrNeedWait - } - - latestSearchSlot := latestObserved - sawConfirmedLeafBeyondDepth := false - if cc.maxDepth > 0 { - maxLeafSlot := anchorSlot + uint64(cc.maxDepth) - if latestSearchSlot > maxLeafSlot { - for slot := latestObserved; slot > maxLeafSlot; slot-- { - _, status := cc.forkChoice.GetSupermajorityHash(slot) - if status == BankhashHasSupermajority { - sawConfirmedLeafBeyondDepth = true - break - } - } - latestSearchSlot = maxLeafSlot - } - } - - sawPathIncomplete := false - - for slot := latestSearchSlot; slot > anchorSlot; slot-- { - winningHash, status := cc.forkChoice.GetSupermajorityHash(slot) - if status != BankhashHasSupermajority { - continue - } - - result, err := cc.forkChoice.ResolvePathToLeaf(anchorSlot, slot, cc.maxDepth) - if err != nil { - switch { - case errors.Is(err, ErrPathIncomplete): - sawPathIncomplete = true - continue - case errors.Is(err, ErrNoPath): - // The newest confirmed leaf may belong to a branch that does not - // connect to the current execution anchor. Keep scanning older - // confirmed leaves before declaring failure. - continue - default: - return nil, err - } - } - - decisions := make([]SlotDecision, len(result.Path)) - for i, useBlock := range result.Path { - decisions[i] = SlotDecision{ - Slot: anchorSlot + uint64(i) + 1, - UseBlock: useBlock, - } - } - - return &ResolvedPath{ - LeafSlot: slot, - LeafBankhash: winningHash, - SlotDecisions: decisions, - }, nil - } - - if sawConfirmedLeafBeyondDepth { - return nil, ErrDepthExceeded - } - if sawPathIncomplete { - return nil, ErrPathIncomplete - } - return nil, ErrNeedWait -} - -// Policy returns the coordinator's unresolved policy ("halt" or "warn"). -func (cc *ConsensusCoordinator) Policy() string { - return cc.policy -} diff --git a/pkg/forkchoice/consensus_coordinator_test.go b/pkg/forkchoice/consensus_coordinator_test.go deleted file mode 100644 index be4628eb5..000000000 --- a/pkg/forkchoice/consensus_coordinator_test.go +++ /dev/null @@ -1,132 +0,0 @@ -package forkchoice - -import ( - "testing" - - "github.com/gagliardetto/solana-go" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func newTestForkChoiceState(totalStake uint64) *ForkChoiceService { - state := &forkChoiceState{ - voteStakeTotals: make(map[uint64]*slotVoteAccumulator), - observedBlocks: make(map[uint64]*ObservedBlockMeta), - blockhashToSlot: make(map[solana.Hash]uint64), - pendingParentByHash: make(map[solana.Hash][]uint64), - equivocatedSlots: make(map[uint64]struct{}), - totalEpochStake: totalStake, - } - return &ForkChoiceService{state: state} -} - -func injectSupermajority(fc *ForkChoiceService, slot uint64, winningHash solana.Hash, stake uint64) { - acc := newSlotVoteAccumulator(fc.state.totalEpochStake, slot) - tracker := &voteStakeTracker{ - voted: make(map[solana.PublicKey]struct{}), - stake: stake, - } - acc.trackers[winningHash] = tracker - acc.confirmed = true - acc.confirmedHash = winningHash - fc.state.voteStakeTotals[slot] = acc -} - -func TestResolveFromAnchorSuccess(t *testing.T) { - fc := newTestForkChoiceState(100) - - injectSupermajority(fc, 13, testHash(0xAA), 70) - fc.state.observedBlocks[11] = &ObservedBlockMeta{Slot: 11, ParentSlot: 9, ParentSlotKnown: true, Blockhash: testHash(0x11)} - fc.state.observedBlocks[13] = &ObservedBlockMeta{Slot: 13, ParentSlot: 11, ParentSlotKnown: true, Blockhash: testHash(0x13)} - fc.state.latestObservedSlot = 13 - - cc := NewConsensusCoordinator(fc, 64, "halt") - resolved, err := cc.ResolveFromAnchor(9) - require.NoError(t, err) - require.NotNil(t, resolved) - assert.Equal(t, uint64(13), resolved.LeafSlot) - assert.Equal(t, testHash(0xAA), resolved.LeafBankhash) - assert.Equal(t, []SlotDecision{ - {Slot: 10, UseBlock: false}, - {Slot: 11, UseBlock: true}, - {Slot: 12, UseBlock: false}, - {Slot: 13, UseBlock: true}, - }, resolved.SlotDecisions) -} - -func TestResolveFromAnchorNeedWait(t *testing.T) { - fc := newTestForkChoiceState(100) - - cc := NewConsensusCoordinator(fc, 64, "halt") - _, err := cc.ResolveFromAnchor(10) - assert.ErrorIs(t, err, ErrNeedWait) -} - -func TestResolveFromAnchorDepthExceeded(t *testing.T) { - fc := newTestForkChoiceState(100) - - fc.state.latestObservedSlot = 100 - injectSupermajority(fc, 100, testHash(0xEE), 70) - - cc := NewConsensusCoordinator(fc, 16, "halt") - _, err := cc.ResolveFromAnchor(0) - assert.ErrorIs(t, err, ErrDepthExceeded) -} - -func TestResolveFromAnchorWaitsWhenObservedDepthExceedsLimitWithoutConfirmedLeaf(t *testing.T) { - fc := newTestForkChoiceState(100) - - fc.state.latestObservedSlot = 100 - - cc := NewConsensusCoordinator(fc, 16, "halt") - _, err := cc.ResolveFromAnchor(0) - assert.ErrorIs(t, err, ErrNeedWait) -} - -func TestResolveFromAnchorPathIncomplete(t *testing.T) { - fc := newTestForkChoiceState(100) - - injectSupermajority(fc, 15, testHash(0xCC), 70) - fc.state.observedBlocks[15] = &ObservedBlockMeta{Slot: 15, ParentSlotKnown: false, ParentBlockhash: testHash(0x02)} - fc.state.latestObservedSlot = 15 - - cc := NewConsensusCoordinator(fc, 64, "halt") - _, err := cc.ResolveFromAnchor(10) - assert.ErrorIs(t, err, ErrPathIncomplete) -} - -func TestResolveFromAnchorSkipsDisconnectedConfirmedLeaf(t *testing.T) { - fc := newTestForkChoiceState(100) - - // Newest confirmed leaf does not connect back to the anchor. - injectSupermajority(fc, 14, testHash(0xDD), 70) - fc.state.observedBlocks[14] = &ObservedBlockMeta{Slot: 14, ParentSlot: 8, ParentSlotKnown: true, Blockhash: testHash(0x14)} - - // Slightly older confirmed leaf is valid and should be selected instead. - injectSupermajority(fc, 13, testHash(0xCC), 70) - fc.state.observedBlocks[11] = &ObservedBlockMeta{Slot: 11, ParentSlot: 9, ParentSlotKnown: true, Blockhash: testHash(0x11)} - fc.state.observedBlocks[13] = &ObservedBlockMeta{Slot: 13, ParentSlot: 11, ParentSlotKnown: true, Blockhash: testHash(0x13)} - fc.state.latestObservedSlot = 14 - - cc := NewConsensusCoordinator(fc, 64, "halt") - resolved, err := cc.ResolveFromAnchor(9) - require.NoError(t, err) - require.NotNil(t, resolved) - assert.Equal(t, uint64(13), resolved.LeafSlot) - assert.Equal(t, testHash(0xCC), resolved.LeafBankhash) - assert.Equal(t, []SlotDecision{ - {Slot: 10, UseBlock: false}, - {Slot: 11, UseBlock: true}, - {Slot: 12, UseBlock: false}, - {Slot: 13, UseBlock: true}, - }, resolved.SlotDecisions) -} - -func TestCoordinatorPolicy(t *testing.T) { - fc := newTestForkChoiceState(100) - cc := NewConsensusCoordinator(fc, 64, "halt") - assert.Equal(t, "halt", cc.Policy()) - - cc2 := NewConsensusCoordinator(fc, 64, "warn") - assert.Equal(t, "warn", cc2.Policy()) -} diff --git a/pkg/forkchoice/forkchoice.go b/pkg/forkchoice/forkchoice.go deleted file mode 100644 index 3f1e742e4..000000000 --- a/pkg/forkchoice/forkchoice.go +++ /dev/null @@ -1,760 +0,0 @@ -package forkchoice - -import ( - "fmt" - "sort" - "sync" - - "github.com/Overclock-Validator/mithril/pkg/base58" - "github.com/Overclock-Validator/mithril/pkg/epochstakes" - "github.com/Overclock-Validator/mithril/pkg/mlog" - "github.com/gagliardetto/solana-go" -) - -// BankhashStatus represents the confirmation status of a slot's bankhash. -type BankhashStatus int - -const ( - BankhashHasSupermajority BankhashStatus = iota - BankhashNoSupermajority - BankhashNeedWait -) - -func (s BankhashStatus) String() string { - switch s { - case BankhashHasSupermajority: - return "has_supermajority" - case BankhashNoSupermajority: - return "no_supermajority" - case BankhashNeedWait: - return "need_wait" - default: - return "unknown" - } -} - -// BankhashResult provides detailed fork choice query results. -type BankhashResult struct { - Status BankhashStatus - WinningHash solana.Hash - StakeForHash uint64 // stake accumulated for the queried hash - WinningStake uint64 // stake accumulated for the winning hash (may differ from StakeForHash) - TotalEpochStake uint64 - ThresholdStake uint64 -} - -// VoteHashDiagnostic is a JSON-friendly snapshot of votes accumulated for one -// bankhash within a target slot. -type VoteHashDiagnostic struct { - Bankhash string `json:"bankhash"` - Stake uint64 `json:"stake"` - VoterCount int `json:"voter_count"` - Confirmed bool `json:"confirmed"` -} - -// SlotVoteDiagnostic is a JSON-friendly snapshot of forkchoice's accumulated -// vote state for a target slot. -type SlotVoteDiagnostic struct { - Slot uint64 `json:"slot"` - Status string `json:"status"` - WinningHash string `json:"winning_hash,omitempty"` - LatestObservedSlot uint64 `json:"latest_observed_slot"` - TotalEpochStake uint64 `json:"total_epoch_stake"` - ThresholdStake uint64 `json:"threshold_stake"` - Hashes []VoteHashDiagnostic `json:"hashes,omitempty"` -} - -// ConfirmedLeaf is a vote-confirmed bankhash winner paired with the observed -// block slot it belongs to. -type ConfirmedLeaf struct { - Slot uint64 - Bankhash solana.Hash -} - -type blockJob struct { - slot uint64 - txs []*solana.Transaction - - // Epoch data captured at submission time so that each block is processed - // with the epoch view that was current when it was submitted, not when - // it happens to be dequeued. This prevents post-boundary epoch data from - // being applied to pre-boundary blocks sitting in the queue. - epochStakes map[solana.PublicKey]uint64 - epochAuthorizedVoters *epochstakes.EpochAuthorizedVotersCache - totalEpochStake uint64 -} - -type voteUpdate struct { - voteInfo *voteInfo - stake uint64 -} - -type forkChoiceState struct { - voteStakeTotals map[uint64]*slotVoteAccumulator - observedBlocks map[uint64]*ObservedBlockMeta - blockhashToSlot map[solana.Hash]uint64 - pendingParentByHash map[solana.Hash][]uint64 - equivocatedSlots map[uint64]struct{} - validatorRoots map[solana.PublicKey]uint64 // voter -> latest explicit tower root (the finality signal) - epoch uint64 - epochStakes map[solana.PublicKey]uint64 - epochAuthorizedVoters *epochstakes.EpochAuthorizedVotersCache - totalEpochStake uint64 - latestObservedSlot uint64 - mu sync.Mutex -} - -type ForkChoiceService struct { - state *forkChoiceState - jobChan chan *blockJob - wg sync.WaitGroup - shutdown chan struct{} -} - -func NewForkChoiceService( - epoch uint64, - epochStakes map[solana.PublicKey]uint64, - totalEpochStake uint64, - epochAuthorizedVoters *epochstakes.EpochAuthorizedVotersCache, -) *ForkChoiceService { - - state := &forkChoiceState{ - voteStakeTotals: make(map[uint64]*slotVoteAccumulator), - observedBlocks: make(map[uint64]*ObservedBlockMeta), - blockhashToSlot: make(map[solana.Hash]uint64), - pendingParentByHash: make(map[solana.Hash][]uint64), - equivocatedSlots: make(map[uint64]struct{}), - validatorRoots: make(map[solana.PublicKey]uint64), - epoch: epoch, - epochStakes: epochStakes, - epochAuthorizedVoters: epochAuthorizedVoters, - totalEpochStake: totalEpochStake, - } - - return &ForkChoiceService{ - state: state, - jobChan: make(chan *blockJob, 32), - shutdown: make(chan struct{}), - } -} - -func (s *ForkChoiceService) Start() { - s.wg.Add(1) - go s.run() -} - -func (s *ForkChoiceService) Stop() { - close(s.shutdown) - s.wg.Wait() - close(s.jobChan) -} - -func (s *ForkChoiceService) run() { - defer s.wg.Done() - for { - select { - case job, ok := <-s.jobChan: - if !ok { - return - } - s.processBlock(job) - case <-s.shutdown: - for { - select { - case job, ok := <-s.jobChan: - if !ok { - return - } - s.processBlock(job) - default: - return - } - } - } - } -} - -func (s *ForkChoiceService) SubmitBlock(slot uint64, txs []*solana.Transaction) { - s.state.mu.Lock() - job := &blockJob{ - slot: slot, - txs: txs, - epochStakes: s.state.epochStakes, - epochAuthorizedVoters: s.state.epochAuthorizedVoters, - totalEpochStake: s.state.totalEpochStake, - } - s.state.mu.Unlock() - - select { - case s.jobChan <- job: - case <-s.shutdown: - fmt.Printf("fork choice service shutting down, discarding job for slot %d\n", slot) - } -} - -// ObserveExecutionAnchor seeds the blockhash->slot index with the last known -// confirmed slot. This lets RPC-fetched children that only carry a parent -// blockhash recover their parent slot without extra RPC lookups. -func (s *ForkChoiceService) ObserveExecutionAnchor(slot uint64, blockhash solana.Hash) { - if blockhash == (solana.Hash{}) { - return - } - - s.state.mu.Lock() - defer s.state.mu.Unlock() - - s.state.blockhashToSlot[blockhash] = slot - s.resolvePendingParentsLocked(blockhash, slot) - s.pruneBeforeSlotLocked(slot) -} - -// PruneBeforeSlot drops forkchoice state older than the given slot. This is -// useful for post-execution verification paths that need to retain a small -// trailing window of recent slots without advancing the full execution anchor. -func (s *ForkChoiceService) PruneBeforeSlot(slot uint64) { - s.state.mu.Lock() - defer s.state.mu.Unlock() - - s.pruneBeforeSlotLocked(slot) -} - -// ObserveSkippedSlot advances the observed watermark when the source tells us a -// slot was skipped. This keeps confirmed-leaf search moving even when no block -// exists for the slot. -func (s *ForkChoiceService) ObserveSkippedSlot(slot uint64) { - s.state.mu.Lock() - defer s.state.mu.Unlock() - - if s.state.latestObservedSlot < slot { - s.state.latestObservedSlot = slot - } -} - -// ObserveBlock ingests pre-execution block metadata and vote transactions. -func (s *ForkChoiceService) ObserveBlock(meta ObservedBlockMeta, txs []*solana.Transaction) error { - s.state.mu.Lock() - epochStakes := s.state.epochStakes - epochAuthorizedVoters := s.state.epochAuthorizedVoters - totalEpochStake := s.state.totalEpochStake - s.state.mu.Unlock() - - updatesToApply := collectVoteUpdates(txs, epochStakes, epochAuthorizedVoters) - - s.state.mu.Lock() - defer s.state.mu.Unlock() - - if err := s.ingestObservedBlockLocked(meta); err != nil { - return err - } - - s.applyVoteUpdatesLocked(updatesToApply, totalEpochStake) - - if s.state.latestObservedSlot < meta.Slot { - s.state.latestObservedSlot = meta.Slot - } - - return nil -} - -// ObserveVotesOnly applies a block's votes (advancing lockouts and the -// explicit-root finality watermark) WITHOUT registering the block for path -// resolution. Used during live catchup, where blocks are sequential -// cluster-confirmed data and buffered execution is suspended. -func (s *ForkChoiceService) ObserveVotesOnly(slot uint64, txs []*solana.Transaction) { - s.state.mu.Lock() - epochStakes := s.state.epochStakes - epochAuthorizedVoters := s.state.epochAuthorizedVoters - totalEpochStake := s.state.totalEpochStake - s.state.mu.Unlock() - - updatesToApply := collectVoteUpdates(txs, epochStakes, epochAuthorizedVoters) - - s.state.mu.Lock() - defer s.state.mu.Unlock() - - s.applyVoteUpdatesLocked(updatesToApply, totalEpochStake) - - if s.state.latestObservedSlot < slot { - s.state.latestObservedSlot = slot - } - - if slot%1000 == 0 { - mlog.Log.Infof("forkchoice: catchup vote observation at slot %d (%d validator roots tracked)", - slot, len(s.state.validatorRoots)) - } -} - -func (s *ForkChoiceService) processBlock(job *blockJob) { - updatesToApply := collectVoteUpdates(job.txs, job.epochStakes, job.epochAuthorizedVoters) - - s.state.mu.Lock() - defer s.state.mu.Unlock() - - s.applyVoteUpdatesLocked(updatesToApply, job.totalEpochStake) - - if s.state.latestObservedSlot < job.slot { - s.state.latestObservedSlot = job.slot - } -} - -// UpdateEpoch swaps in new epoch stake data. Called at epoch boundaries so that -// vote stake weights and authorized voter lookups use current data. -func (s *ForkChoiceService) UpdateEpoch( - epoch uint64, - epochStakes map[solana.PublicKey]uint64, - totalEpochStake uint64, - epochAuthorizedVoters *epochstakes.EpochAuthorizedVotersCache, -) { - s.state.mu.Lock() - defer s.state.mu.Unlock() - - s.state.epoch = epoch - s.state.epochStakes = epochStakes - s.state.totalEpochStake = totalEpochStake - s.state.epochAuthorizedVoters = epochAuthorizedVoters - - mlog.Log.Infof("forkchoice: updated epoch stakes for epoch %d (total_stake=%d, validators=%d)", - epoch, totalEpochStake, len(epochStakes)) -} - -func collectVoteUpdates( - txs []*solana.Transaction, - epochStakes map[solana.PublicKey]uint64, - epochAuthorizedVoters *epochstakes.EpochAuthorizedVotersCache, -) []voteUpdate { - var updatesToApply []voteUpdate - - for _, tx := range txs { - if !tx.IsVote() { - continue - } - - voteInfo, ok := parseAndValidateVoteTx(tx, epochAuthorizedVoters) - if !ok { - continue - } - - stakeForVoteAcct, ok := epochStakes[voteInfo.votePubkey] - if !ok { - continue - } - - updatesToApply = append(updatesToApply, voteUpdate{ - voteInfo: voteInfo, - stake: stakeForVoteAcct, - }) - } - - return updatesToApply -} - -func (s *ForkChoiceService) applyVoteUpdatesLocked(updatesToApply []voteUpdate, totalEpochStake uint64) { - for _, update := range updatesToApply { - accumulator, exists := s.state.voteStakeTotals[update.voteInfo.slot] - if !exists { - accumulator = newSlotVoteAccumulator(totalEpochStake, update.voteInfo.slot) - s.state.voteStakeTotals[update.voteInfo.slot] = accumulator - } - - _, _ = accumulator.addVote( - update.voteInfo.bankHash, - update.voteInfo.votePubkey, - update.stake, - ) - - // Record the validator's explicit tower root (monotonic; never regress). - if r := update.voteInfo.rootSlot; r != nil { - if prev, ok := s.state.validatorRoots[update.voteInfo.votePubkey]; !ok || *r > prev { - s.state.validatorRoots[update.voteInfo.votePubkey] = *r - } - } - } -} - -func (s *ForkChoiceService) ingestObservedBlockLocked(meta ObservedBlockMeta) error { - existing, exists := s.state.observedBlocks[meta.Slot] - if exists { - if existing.Blockhash != meta.Blockhash { - s.state.equivocatedSlots[meta.Slot] = struct{}{} - return ErrEquivocation - } - if !existing.ParentSlotKnown && meta.ParentSlotKnown { - existing.ParentSlot = meta.ParentSlot - existing.ParentSlotKnown = true - } - if existing.ParentBlockhash == (solana.Hash{}) && meta.ParentBlockhash != (solana.Hash{}) { - existing.ParentBlockhash = meta.ParentBlockhash - } - meta = *existing - } else { - copyMeta := meta - s.state.observedBlocks[meta.Slot] = ©Meta - existing = ©Meta - } - - s.state.blockhashToSlot[existing.Blockhash] = existing.Slot - s.resolvePendingParentsLocked(existing.Blockhash, existing.Slot) - - if !existing.ParentSlotKnown && existing.ParentBlockhash != (solana.Hash{}) { - if parentSlot, hasParent := s.state.blockhashToSlot[existing.ParentBlockhash]; hasParent { - existing.ParentSlot = parentSlot - existing.ParentSlotKnown = true - } else { - s.state.pendingParentByHash[existing.ParentBlockhash] = append(s.state.pendingParentByHash[existing.ParentBlockhash], existing.Slot) - } - } - - return nil -} - -func (s *ForkChoiceService) resolvePendingParentsLocked(parentBlockhash solana.Hash, parentSlot uint64) { - waiting := s.state.pendingParentByHash[parentBlockhash] - if len(waiting) == 0 { - return - } - for _, childSlot := range waiting { - if child, exists := s.state.observedBlocks[childSlot]; exists && !child.ParentSlotKnown { - child.ParentSlot = parentSlot - child.ParentSlotKnown = true - } - } - delete(s.state.pendingParentByHash, parentBlockhash) -} - -func (s *ForkChoiceService) pruneBeforeSlotLocked(anchorSlot uint64) { - if anchorSlot == 0 { - return - } - - for slot := range s.state.voteStakeTotals { - if slot < anchorSlot { - delete(s.state.voteStakeTotals, slot) - } - } - - for slot := range s.state.observedBlocks { - if slot < anchorSlot { - delete(s.state.observedBlocks, slot) - } - } - - for slot := range s.state.equivocatedSlots { - if slot < anchorSlot { - delete(s.state.equivocatedSlots, slot) - } - } - - for blockhash, slot := range s.state.blockhashToSlot { - if slot < anchorSlot { - delete(s.state.blockhashToSlot, blockhash) - } - } - - for parentHash, waiting := range s.state.pendingParentByHash { - filtered := waiting[:0] - for _, childSlot := range waiting { - if childSlot >= anchorSlot { - filtered = append(filtered, childSlot) - } - } - if len(filtered) == 0 { - delete(s.state.pendingParentByHash, parentHash) - continue - } - s.state.pendingParentByHash[parentHash] = filtered - } -} - -func (s *ForkChoiceService) LatestObservedSlot() uint64 { - s.state.mu.Lock() - defer s.state.mu.Unlock() - return s.state.latestObservedSlot -} - -// FindConfirmedLeaf returns the highest slot above the current anchor that has -// both an observed block and a vote-confirmed bankhash winner. -func (s *ForkChoiceService) FindConfirmedLeaf(anchorSlot uint64, maxDepth int) (ConfirmedLeaf, error) { - s.state.mu.Lock() - defer s.state.mu.Unlock() - return s.findConfirmedLeafLocked(anchorSlot, maxDepth) -} - -// findConfirmedLeafLocked is the body of FindConfirmedLeaf; the caller must hold s.state.mu. -func (s *ForkChoiceService) findConfirmedLeafLocked(anchorSlot uint64, maxDepth int) (ConfirmedLeaf, error) { - if s.state.latestObservedSlot <= anchorSlot { - return ConfirmedLeaf{}, ErrNeedWait - } - - latestSearchSlot := s.state.latestObservedSlot - if maxDepth > 0 { - maxLeafSlot := anchorSlot + uint64(maxDepth) - if latestSearchSlot > maxLeafSlot { - latestSearchSlot = maxLeafSlot - } - } - - for slot := latestSearchSlot; slot > anchorSlot; slot-- { - if _, equivocated := s.state.equivocatedSlots[slot]; equivocated { - return ConfirmedLeaf{}, ErrEquivocation - } - - accumulator, exists := s.state.voteStakeTotals[slot] - if !exists { - continue - } - - winningHash, hasWinner := accumulator.winningHash() - if !hasWinner { - continue - } - - if _, observed := s.state.observedBlocks[slot]; !observed { - continue - } - - return ConfirmedLeaf{ - Slot: slot, - Bankhash: winningHash, - }, nil - } - - if maxDepth > 0 && s.state.latestObservedSlot > anchorSlot+uint64(maxDepth) { - return ConfirmedLeaf{}, ErrDepthExceeded - } - - return ConfirmedLeaf{}, ErrNeedWait -} - -// HighestRootedSlot reports the raw explicit-root finality watermark: the highest -// slot a >2/3 stake supermajority has explicitly rooted past, with no anchor or -// path checks. It is the input to FindRootedSlot's gate and a diagnostic of -// whether rooting is advancing. Returns (0,false) until a supermajority roots. -func (s *ForkChoiceService) HighestRootedSlot() (uint64, bool) { - s.state.mu.Lock() - defer s.state.mu.Unlock() - return highestRootedSlot(s.state.validatorRoots, s.state.epochStakes, s.state.totalEpochStake) -} - -// FindRootedSlot returns the deepest slot a >2/3 supermajority has explicitly rooted -// past that is also observed, carries a confirmed bankhash, and resolves a same-fork -// path from the anchor — the fail-closed durable-promotion gate (rooted, on-fork only). -// Returns ErrNeedWait if none yet, ErrEquivocation on an equivocated slot, else path errors. -// Not yet wired: promotion currently gates on HighestRootedSlot; this goes live with #14. -func (s *ForkChoiceService) FindRootedSlot(anchorSlot uint64, maxDepth int) (ConfirmedLeaf, error) { - s.state.mu.Lock() - defer s.state.mu.Unlock() - - rooted, ok := highestRootedSlot(s.state.validatorRoots, s.state.epochStakes, s.state.totalEpochStake) - if !ok || rooted <= anchorSlot { - return ConfirmedLeaf{}, ErrNeedWait - } - - // Bound the explicit-root watermark to the search window above the anchor. - ceiling := rooted - if maxDepth > 0 && ceiling > anchorSlot+uint64(maxDepth) { - ceiling = anchorSlot + uint64(maxDepth) - } - - // Highest observed, supermajority-confirmed slot at/below the rooted - // watermark whose path back to the anchor resolves on a single fork. - for slot := ceiling; slot > anchorSlot; slot-- { - if _, equivocated := s.state.equivocatedSlots[slot]; equivocated { - return ConfirmedLeaf{}, ErrEquivocation - } - - accumulator, exists := s.state.voteStakeTotals[slot] - if !exists { - continue - } - winningHash, hasWinner := accumulator.winningHash() - if !hasWinner { - continue - } - if _, observed := s.state.observedBlocks[slot]; !observed { - continue - } - - // Mandatory same-fork ancestry — fail closed if the rooted slot does not - // resolve a clean path from the anchor. - if _, err := s.resolvePathToLeafLocked(anchorSlot, slot, maxDepth); err != nil { - return ConfirmedLeaf{}, err - } - return ConfirmedLeaf{Slot: slot, Bankhash: winningHash}, nil - } - - return ConfirmedLeaf{}, ErrNeedWait -} - -// ResolvePathToLeaf reconstructs the block/skip decisions from anchorSlot to -// leafSlot using the observed pre-execution block metadata. -func (s *ForkChoiceService) ResolvePathToLeaf(anchorSlot uint64, leafSlot uint64, maxDepth int) (*SolveResult, error) { - s.state.mu.Lock() - defer s.state.mu.Unlock() - return s.resolvePathToLeafLocked(anchorSlot, leafSlot, maxDepth) -} - -// resolvePathToLeafLocked is the body of ResolvePathToLeaf; caller holds s.state.mu. -func (s *ForkChoiceService) resolvePathToLeafLocked(anchorSlot uint64, leafSlot uint64, maxDepth int) (*SolveResult, error) { - observedSnapshot := make(map[uint64]*ObservedBlockMeta, len(s.state.observedBlocks)) - for slot, meta := range s.state.observedBlocks { - copyMeta := *meta - observedSnapshot[slot] = ©Meta - } - - equivocatedSnapshot := make(map[uint64]struct{}, len(s.state.equivocatedSlots)) - for slot := range s.state.equivocatedSlots { - equivocatedSnapshot[slot] = struct{}{} - } - - return ResolvePohPath(anchorSlot, leafSlot, observedSnapshot, equivocatedSnapshot, maxDepth) -} - -// VoteConfirmationTimeoutSlots is the grace window (in slots) before an -// unresolved slot (no supermajority winner) transitions from NeedWait to -// NoSupermajority. This is NOT a mandatory delay before confirmation — a slot -// with observed supermajority is confirmed immediately regardless of this window. -// abcd. -const VoteConfirmationTimeoutSlots = 32 - -// IsBankhashCorrect queries the confirmation status of a slot's bankhash. -// Returns a BankhashResult with status, winning hash, stake details, and threshold. -// -// A slot is confirmed immediately when a winner is observed — no mandatory delay. -// The timeout window (VoteConfirmationTimeoutSlots) only governs how long an -// unresolved slot (no winner) stays in NeedWait before becoming NoSupermajority. -func (s *ForkChoiceService) IsBankhashCorrect(slot uint64, bankHash solana.Hash) BankhashResult { - s.state.mu.Lock() - defer s.state.mu.Unlock() - - accumulator, exists := s.state.voteStakeTotals[slot] - - if exists { - winningHash, hasWinner := accumulator.winningHash() - if hasWinner { - stakeForHash := accumulator.stakeForHash(bankHash) - winningStake := accumulator.stakeForHash(winningHash) - - if winningHash == bankHash { - return BankhashResult{ - Status: BankhashHasSupermajority, - WinningHash: winningHash, - StakeForHash: stakeForHash, - WinningStake: winningStake, - TotalEpochStake: accumulator.totalEpochStake, - ThresholdStake: accumulator.thresholdStake, - } - } - - mlog.Log.Warnf("forkchoice: slot %d bankhash mismatch! our=%s winning=%s (our_stake=%d winning_stake=%d/%d)", - slot, - base58.Encode(bankHash[:]), - base58.Encode(winningHash[:]), - stakeForHash, - winningStake, - accumulator.totalEpochStake, - ) - return BankhashResult{ - Status: BankhashNoSupermajority, - WinningHash: winningHash, - StakeForHash: stakeForHash, - WinningStake: winningStake, - TotalEpochStake: accumulator.totalEpochStake, - ThresholdStake: accumulator.thresholdStake, - } - } - } - - if s.state.latestObservedSlot < (slot + VoteConfirmationTimeoutSlots) { - totalStake := s.state.totalEpochStake - if exists { - totalStake = accumulator.totalEpochStake - } - return BankhashResult{ - Status: BankhashNeedWait, - TotalEpochStake: totalStake, - } - } - - if exists { - stakeForHash := accumulator.stakeForHash(bankHash) - return BankhashResult{ - Status: BankhashNoSupermajority, - StakeForHash: stakeForHash, - TotalEpochStake: accumulator.totalEpochStake, - ThresholdStake: accumulator.thresholdStake, - } - } - - return BankhashResult{ - Status: BankhashNoSupermajority, - TotalEpochStake: s.state.totalEpochStake, - } -} - -// GetSupermajorityHash returns the vote-confirmed hash for a slot, if any hash -// has crossed the 2/3 supermajority threshold. -func (s *ForkChoiceService) GetSupermajorityHash(slot uint64) (solana.Hash, BankhashStatus) { - s.state.mu.Lock() - defer s.state.mu.Unlock() - - if accumulator, exists := s.state.voteStakeTotals[slot]; exists { - if winningHash, ok := accumulator.winningHash(); ok { - return winningHash, BankhashHasSupermajority - } - } - - if s.state.latestObservedSlot < (slot + VoteConfirmationTimeoutSlots) { - return solana.Hash{}, BankhashNeedWait - } - - return solana.Hash{}, BankhashNoSupermajority -} - -// SlotVoteDiagnostics returns a compact snapshot of forkchoice vote totals for -// a target slot. It is intended for rare consensus mismatch artifacts rather -// than hot-path logging. -func (s *ForkChoiceService) SlotVoteDiagnostics(slot uint64) SlotVoteDiagnostic { - s.state.mu.Lock() - defer s.state.mu.Unlock() - - out := SlotVoteDiagnostic{ - Slot: slot, - Status: BankhashNoSupermajority.String(), - LatestObservedSlot: s.state.latestObservedSlot, - TotalEpochStake: s.state.totalEpochStake, - } - - accumulator, exists := s.state.voteStakeTotals[slot] - if !exists { - if s.state.latestObservedSlot < slot+VoteConfirmationTimeoutSlots { - out.Status = BankhashNeedWait.String() - } - return out - } - - out.TotalEpochStake = accumulator.totalEpochStake - out.ThresholdStake = accumulator.thresholdStake - if winningHash, ok := accumulator.winningHash(); ok { - out.Status = BankhashHasSupermajority.String() - out.WinningHash = base58.Encode(winningHash[:]) - } else if s.state.latestObservedSlot < slot+VoteConfirmationTimeoutSlots { - out.Status = BankhashNeedWait.String() - } - - out.Hashes = make([]VoteHashDiagnostic, 0, len(accumulator.trackers)) - for bankhash, tracker := range accumulator.trackers { - out.Hashes = append(out.Hashes, VoteHashDiagnostic{ - Bankhash: base58.Encode(bankhash[:]), - Stake: tracker.stake, - VoterCount: len(tracker.voted), - Confirmed: accumulator.hashHasSupermajority(bankhash), - }) - } - sort.Slice(out.Hashes, func(i, j int) bool { - if out.Hashes[i].Stake == out.Hashes[j].Stake { - return out.Hashes[i].Bankhash < out.Hashes[j].Bankhash - } - return out.Hashes[i].Stake > out.Hashes[j].Stake - }) - return out -} diff --git a/pkg/forkchoice/forkchoice_rooting_test.go b/pkg/forkchoice/forkchoice_rooting_test.go deleted file mode 100644 index f32ebc4b5..000000000 --- a/pkg/forkchoice/forkchoice_rooting_test.go +++ /dev/null @@ -1,191 +0,0 @@ -package forkchoice - -import ( - "testing" - - "github.com/Overclock-Validator/mithril/pkg/epochstakes" - "github.com/gagliardetto/solana-go" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -// newRootingService builds a service and injects confirmed+observed slots. -func newRootingService() *ForkChoiceService { - return NewForkChoiceService(0, map[solana.PublicKey]uint64{}, 100, epochstakes.NewEpochAuthorizedVotersCache()) -} - -// injectConfirmedSlot marks slot as both observed and supermajority-confirmed. -func injectConfirmedSlot(s *ForkChoiceService, slot, parentSlot uint64, winningHash solana.Hash) { - acc := newSlotVoteAccumulator(100, slot) - acc.trackers[winningHash] = &voteStakeTracker{voted: make(map[solana.PublicKey]struct{}), stake: 70} - acc.confirmed = true - acc.confirmedHash = winningHash - s.state.voteStakeTotals[slot] = acc - s.state.observedBlocks[slot] = &ObservedBlockMeta{Slot: slot, ParentSlot: parentSlot, ParentSlotKnown: true, Blockhash: winningHash} -} - -// Applying votes records each validator's latest explicit root -// (monotonic; nil roots ignored). -func TestApplyVoteUpdatesRecordsRoots(t *testing.T) { - s := newRootingService() - rp := func(v uint64) *uint64 { return &v } - s.applyVoteUpdatesLocked([]voteUpdate{ - {voteInfo: &voteInfo{slot: 110, bankHash: testHash(110), votePubkey: votePubkey(1), rootSlot: rp(78)}, stake: 50}, - {voteInfo: &voteInfo{slot: 111, bankHash: testHash(111), votePubkey: votePubkey(2), rootSlot: rp(80)}, stake: 30}, - {voteInfo: &voteInfo{slot: 109, bankHash: testHash(109), votePubkey: votePubkey(1), rootSlot: rp(70)}, stake: 50}, // older root -> ignored - {voteInfo: &voteInfo{slot: 112, bankHash: testHash(112), votePubkey: votePubkey(3), rootSlot: nil}, stake: 20}, // no root - }, 100) - - assert.Equal(t, uint64(78), s.state.validatorRoots[votePubkey(1)], "keeps the max root, not the later-but-lower one") - assert.Equal(t, uint64(80), s.state.validatorRoots[votePubkey(2)]) - _, ok := s.state.validatorRoots[votePubkey(3)] - assert.False(t, ok, "nil root not recorded") -} - -func votePubkey(b byte) solana.PublicKey { return solana.PublicKey{b} } - -// highestRootedSlot: the highest slot a >2/3 supermajority has rooted past, exact arithmetic. -func TestHighestRootedSlot(t *testing.T) { - // total 120, threshold = floor(120*2/3) = 80, need stake STRICTLY > 80. - stakes := map[solana.PublicKey]uint64{votePubkey(1): 40, votePubkey(2): 40, votePubkey(3): 40} - - // roots 100/90/80: rooted >=90 is 80 (not >80); >=80 is 120 (>80) -> 80. - got, ok := highestRootedSlot(map[solana.PublicKey]uint64{votePubkey(1): 100, votePubkey(2): 90, votePubkey(3): 80}, stakes, 120) - assert.True(t, ok) - assert.Equal(t, uint64(80), got) - - // Skewed stake 50/30/40: >=90 is 80 (not >80); >=80 is 120 -> still 80. - got, ok = highestRootedSlot(map[solana.PublicKey]uint64{votePubkey(1): 100, votePubkey(2): 90, votePubkey(3): 80}, - map[solana.PublicKey]uint64{votePubkey(1): 50, votePubkey(2): 30, votePubkey(3): 40}, 120) - assert.True(t, ok) - assert.Equal(t, uint64(80), got) - - // One validator with >2/3 alone: its own root is the watermark. - got, ok = highestRootedSlot(map[solana.PublicKey]uint64{votePubkey(1): 200, votePubkey(2): 50}, - map[solana.PublicKey]uint64{votePubkey(1): 90, votePubkey(2): 30}, 120) - assert.True(t, ok) - assert.Equal(t, uint64(200), got) -} - -func TestHighestRootedSlotNoQuorum(t *testing.T) { - // Only 80 of 120 stake has any root; 80 is NOT > threshold(80). - _, ok := highestRootedSlot(map[solana.PublicKey]uint64{votePubkey(1): 100, votePubkey(2): 100}, - map[solana.PublicKey]uint64{votePubkey(1): 40, votePubkey(2): 40, votePubkey(3): 40}, 120) - assert.False(t, ok, "only 2/3 (not >2/3) rooted -> not final") -} - -func TestHighestRootedSlotIgnoresUnstaked(t *testing.T) { - // votePubkey(9) has a root but no stake entry -> ignored. votePubkey(1)=100 > threshold 80. - got, ok := highestRootedSlot(map[solana.PublicKey]uint64{votePubkey(1): 500, votePubkey(9): 999}, - map[solana.PublicKey]uint64{votePubkey(1): 100, votePubkey(2): 20}, 120) - assert.True(t, ok) - assert.Equal(t, uint64(500), got) -} - -func TestHighestRootedSlotEmpty(t *testing.T) { - _, ok := highestRootedSlot(nil, nil, 120) - assert.False(t, ok) -} - -// makeRooted makes 3 validators (40 each, total 120) all explicitly root at rootSlot. -func makeRooted(s *ForkChoiceService, rootSlot uint64) { - s.state.epochStakes = map[solana.PublicKey]uint64{votePubkey(1): 40, votePubkey(2): 40, votePubkey(3): 40} - s.state.totalEpochStake = 120 - s.state.validatorRoots = map[solana.PublicKey]uint64{votePubkey(1): rootSlot, votePubkey(2): rootSlot, votePubkey(3): rootSlot} -} - -// rootedChain observes+confirms slots (anchor, top] linked by parent, so a path -// resolves from anchor to any slot in the chain. -func rootedChain(s *ForkChoiceService, anchor, top uint64) { - for slot := anchor + 1; slot <= top; slot++ { - injectConfirmedSlot(s, slot, slot-1, testHash(byte(slot))) - } - s.state.latestObservedSlot = top -} - -// Returns the slot a >2/3 supermajority has explicitly rooted past, when it is -// observed, confirmed, and path-resolves to the anchor. -func TestFindRootedSlotReturnsExplicitRoot(t *testing.T) { - s := newRootingService() - rootedChain(s, 100, 110) - makeRooted(s, 110) - - r, err := s.FindRootedSlot(100, 32) - require.NoError(t, err) - assert.Equal(t, uint64(110), r.Slot) - assert.Equal(t, testHash(byte(110)), r.Bankhash) -} - -// No durable root until a >2/3 supermajority has rooted (not just confirmed). -func TestFindRootedSlotNeedWaitWhenNoSupermajorityRoot(t *testing.T) { - s := newRootingService() - rootedChain(s, 100, 110) - s.state.epochStakes = map[solana.PublicKey]uint64{votePubkey(1): 40, votePubkey(2): 40, votePubkey(3): 40} - s.state.totalEpochStake = 120 - s.state.validatorRoots = map[solana.PublicKey]uint64{votePubkey(1): 110} // only 40/120 rooted - - _, err := s.FindRootedSlot(100, 32) - assert.ErrorIs(t, err, ErrNeedWait) -} - -// With no explicit roots at all, nothing is rooted. -func TestFindRootedSlotNeedWaitWhenNoRoots(t *testing.T) { - s := newRootingService() - rootedChain(s, 100, 110) // confirmed, but no roots recorded - - _, err := s.FindRootedSlot(100, 32) - assert.ErrorIs(t, err, ErrNeedWait) -} - -// The rooted watermark slot being unobserved falls back to the highest observed -// confirmed slot below it. -func TestFindRootedSlotSkipsUnobserved(t *testing.T) { - s := newRootingService() - rootedChain(s, 100, 109) // observe+confirm 101..109 - // slot 110 confirmed but NOT observed - acc := newSlotVoteAccumulator(100, 110) - acc.confirmed = true - acc.confirmedHash = testHash(byte(110)) - s.state.voteStakeTotals[110] = acc - s.state.latestObservedSlot = 110 - makeRooted(s, 110) // watermark 110, but 110 unobserved - - r, err := s.FindRootedSlot(100, 32) - require.NoError(t, err) - assert.Equal(t, uint64(109), r.Slot) -} - -// Equivocation at the rooted slot fails the gate. -func TestFindRootedSlotEquivocation(t *testing.T) { - s := newRootingService() - rootedChain(s, 100, 110) - makeRooted(s, 110) - s.state.equivocatedSlots[110] = struct{}{} - - _, err := s.FindRootedSlot(100, 32) - assert.ErrorIs(t, err, ErrEquivocation) -} - -// A broken (unresolvable) path from anchor to the rooted slot fails CLOSED: -// a slot that can't be tied to the canonical fork is never durably committed. -func TestFindRootedSlotFailsClosedOnBrokenPath(t *testing.T) { - s := newRootingService() - rootedChain(s, 100, 110) - makeRooted(s, 110) - delete(s.state.observedBlocks, 105) // sever the ancestry chain mid-way - - _, err := s.FindRootedSlot(100, 32) - require.Error(t, err, "must not return a rooted slot whose path is unresolvable") -} - -// ObserveVotesOnly (catchup mode) must advance observation state WITHOUT -// registering the block for path resolution (no observedBlocks entry). -func TestObserveVotesOnlyDoesNotRegisterBlocks(t *testing.T) { - s := newRootingService() - s.ObserveVotesOnly(500, nil) - assert.Equal(t, uint64(500), s.state.latestObservedSlot) - _, registered := s.state.observedBlocks[500] - assert.False(t, registered, "votes-only observation must not register the block") - // vote application path is shared with ObserveBlock (covered by - // TestApplyVoteUpdatesRecordsRoots); this pins the no-registration contract. -} diff --git a/pkg/forkchoice/forkchoice_test.go b/pkg/forkchoice/forkchoice_test.go deleted file mode 100644 index 845183dd4..000000000 --- a/pkg/forkchoice/forkchoice_test.go +++ /dev/null @@ -1,747 +0,0 @@ -package forkchoice - -import ( - "encoding/binary" - "encoding/json" - "testing" - - "github.com/Overclock-Validator/mithril/pkg/epochstakes" - "github.com/gagliardetto/solana-go" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestNeedWaitWhenNoWinnerBeforeTimeout(t *testing.T) { - epochAuth := epochstakes.NewEpochAuthorizedVotersCache() - epochStakes := map[solana.PublicKey]uint64{} - service := NewForkChoiceService(0, epochStakes, 100, epochAuth) - - // No blocks ingested yet, no winner, within timeout → NeedWait. - result := service.IsBankhashCorrect(10, solana.Hash{1}) - assert.Equal(t, BankhashNeedWait, result.Status) - assert.Equal(t, uint64(100), result.TotalEpochStake) -} - -func TestHasSupermajorityAfterEnoughVotes(t *testing.T) { - epochAuth := epochstakes.NewEpochAuthorizedVotersCache() - epochStakes := map[solana.PublicKey]uint64{} - totalStake := uint64(100) - service := NewForkChoiceService(0, epochStakes, totalStake, epochAuth) - - slot := uint64(10) - hash := solana.Hash{0xAA} - - // Manually populate state to test query path - service.state.mu.Lock() - acc := newSlotVoteAccumulator(totalStake, slot) - for i := 0; i < 67; i++ { - var pk [32]byte - pk[0] = byte(i + 1) - pk[1] = byte((i + 1) >> 8) - acc.addVote(hash, solana.PublicKeyFromBytes(pk[:]), 1) - } - service.state.voteStakeTotals[slot] = acc - service.state.latestObservedSlot = slot + VoteConfirmationTimeoutSlots - service.state.mu.Unlock() - - result := service.IsBankhashCorrect(slot, hash) - assert.Equal(t, BankhashHasSupermajority, result.Status) - assert.Equal(t, hash, result.WinningHash) - assert.Equal(t, uint64(67), result.StakeForHash) - assert.Equal(t, totalStake, result.TotalEpochStake) - assert.Equal(t, computeThresholdStake(totalStake), result.ThresholdStake) -} - -func TestNoSupermajorityAfterLandingWindow(t *testing.T) { - epochAuth := epochstakes.NewEpochAuthorizedVotersCache() - epochStakes := map[solana.PublicKey]uint64{} - totalStake := uint64(100) - service := NewForkChoiceService(0, epochStakes, totalStake, epochAuth) - - slot := uint64(10) - hash := solana.Hash{0xAA} - - service.state.mu.Lock() - acc := newSlotVoteAccumulator(totalStake, slot) - // Only 30 stake — not enough for threshold of 66 - for i := 0; i < 30; i++ { - var pk [32]byte - pk[0] = byte(i + 1) - acc.addVote(hash, solana.PublicKeyFromBytes(pk[:]), 1) - } - service.state.voteStakeTotals[slot] = acc - service.state.latestObservedSlot = slot + VoteConfirmationTimeoutSlots - service.state.mu.Unlock() - - result := service.IsBankhashCorrect(slot, hash) - assert.Equal(t, BankhashNoSupermajority, result.Status) - assert.Equal(t, uint64(30), result.StakeForHash) - assert.Equal(t, totalStake, result.TotalEpochStake) - assert.Equal(t, computeThresholdStake(totalStake), result.ThresholdStake) -} - -func TestNoVotesSeenAfterLandingWindow(t *testing.T) { - epochAuth := epochstakes.NewEpochAuthorizedVotersCache() - epochStakes := map[solana.PublicKey]uint64{} - totalStake := uint64(100) - service := NewForkChoiceService(0, epochStakes, totalStake, epochAuth) - - // Landing window passed but no accumulator for this slot - service.state.mu.Lock() - service.state.latestObservedSlot = 50 + VoteConfirmationTimeoutSlots - service.state.mu.Unlock() - - result := service.IsBankhashCorrect(50, solana.Hash{0xAA}) - assert.Equal(t, BankhashNoSupermajority, result.Status) -} - -// TestEarlyConfirmationBeforeTimeout verifies that a slot with observed -// supermajority is confirmed immediately, even before the timeout window. -func TestEarlyConfirmationBeforeTimeout(t *testing.T) { - epochAuth := epochstakes.NewEpochAuthorizedVotersCache() - epochStakes := map[solana.PublicKey]uint64{} - totalStake := uint64(100) - service := NewForkChoiceService(0, epochStakes, totalStake, epochAuth) - - slot := uint64(10) - hash := solana.Hash{0xAA} - - // Inject supermajority but keep latestObservedSlot BELOW timeout. - service.state.mu.Lock() - acc := newSlotVoteAccumulator(totalStake, slot) - for i := 0; i < 67; i++ { - var pk [32]byte - pk[0] = byte(i + 1) - pk[1] = byte((i + 1) >> 8) - acc.addVote(hash, solana.PublicKeyFromBytes(pk[:]), 1) - } - service.state.voteStakeTotals[slot] = acc - service.state.latestObservedSlot = slot + 5 // Well below slot + 32 - service.state.mu.Unlock() - - result := service.IsBankhashCorrect(slot, hash) - assert.Equal(t, BankhashHasSupermajority, result.Status, "should confirm immediately when winner exists") - assert.Equal(t, hash, result.WinningHash) - assert.Equal(t, uint64(67), result.StakeForHash) - assert.Equal(t, totalStake, result.TotalEpochStake) - assert.Equal(t, computeThresholdStake(totalStake), result.ThresholdStake) -} - -// TestNeedWaitPartialVotesBeforeTimeout verifies NeedWait when there are -// partial votes (below threshold) and the timeout hasn't expired. -func TestNeedWaitPartialVotesBeforeTimeout(t *testing.T) { - epochAuth := epochstakes.NewEpochAuthorizedVotersCache() - epochStakes := map[solana.PublicKey]uint64{} - totalStake := uint64(100) - service := NewForkChoiceService(0, epochStakes, totalStake, epochAuth) - - slot := uint64(10) - hash := solana.Hash{0xAA} - - service.state.mu.Lock() - acc := newSlotVoteAccumulator(totalStake, slot) - // Only 30 stake — not enough for threshold of 66 - for i := 0; i < 30; i++ { - var pk [32]byte - pk[0] = byte(i + 1) - acc.addVote(hash, solana.PublicKeyFromBytes(pk[:]), 1) - } - service.state.voteStakeTotals[slot] = acc - service.state.latestObservedSlot = slot + 10 // Below timeout - service.state.mu.Unlock() - - result := service.IsBankhashCorrect(slot, hash) - assert.Equal(t, BankhashNeedWait, result.Status, "no winner + before timeout = NeedWait") -} - -// TestNoSupermajorityPartialVotesAfterTimeout verifies NoSupermajority when -// partial votes exist but the timeout has expired without a winner. -func TestNoSupermajorityPartialVotesAfterTimeout(t *testing.T) { - epochAuth := epochstakes.NewEpochAuthorizedVotersCache() - epochStakes := map[solana.PublicKey]uint64{} - totalStake := uint64(100) - service := NewForkChoiceService(0, epochStakes, totalStake, epochAuth) - - slot := uint64(10) - hash := solana.Hash{0xAA} - - service.state.mu.Lock() - acc := newSlotVoteAccumulator(totalStake, slot) - for i := 0; i < 30; i++ { - var pk [32]byte - pk[0] = byte(i + 1) - acc.addVote(hash, solana.PublicKeyFromBytes(pk[:]), 1) - } - service.state.voteStakeTotals[slot] = acc - service.state.latestObservedSlot = slot + VoteConfirmationTimeoutSlots - service.state.mu.Unlock() - - result := service.IsBankhashCorrect(slot, hash) - assert.Equal(t, BankhashNoSupermajority, result.Status, "no winner + after timeout = NoSupermajority") - assert.Equal(t, uint64(30), result.StakeForHash) - assert.Equal(t, computeThresholdStake(totalStake), result.ThresholdStake) -} - -// TestEarlyMismatchBeforeTimeout verifies that a bankhash mismatch is surfaced -// immediately when a different hash wins supermajority, even before timeout. -func TestEarlyMismatchBeforeTimeout(t *testing.T) { - epochAuth := epochstakes.NewEpochAuthorizedVotersCache() - epochStakes := map[solana.PublicKey]uint64{} - totalStake := uint64(100) - service := NewForkChoiceService(0, epochStakes, totalStake, epochAuth) - - slot := uint64(10) - winnerHash := solana.Hash{0xBB} - ourHash := solana.Hash{0xAA} - - // Inject supermajority for winnerHash, also add some stake for ourHash. - service.state.mu.Lock() - acc := newSlotVoteAccumulator(totalStake, slot) - for i := 0; i < 67; i++ { - var pk [32]byte - pk[0] = byte(i + 1) - pk[1] = byte((i + 1) >> 8) - acc.addVote(winnerHash, solana.PublicKeyFromBytes(pk[:]), 1) - } - // Add 10 stake for our hash (below threshold). - for i := 0; i < 10; i++ { - var pk [32]byte - pk[0] = byte(i + 100) - acc.addVote(ourHash, solana.PublicKeyFromBytes(pk[:]), 1) - } - service.state.voteStakeTotals[slot] = acc - service.state.latestObservedSlot = slot + 5 // Well below timeout - service.state.mu.Unlock() - - result := service.IsBankhashCorrect(slot, ourHash) - assert.Equal(t, BankhashNoSupermajority, result.Status, "mismatch surfaced immediately") - assert.Equal(t, winnerHash, result.WinningHash, "winning hash should be the other hash") - assert.Equal(t, uint64(10), result.StakeForHash, "our hash stake") - assert.Equal(t, uint64(67), result.WinningStake, "winner stake") -} - -// TestGetSupermajorityHashEarlyConfirmation verifies that GetSupermajorityHash -// returns the winner immediately, before the timeout window. -func TestGetSupermajorityHashEarlyConfirmation(t *testing.T) { - epochAuth := epochstakes.NewEpochAuthorizedVotersCache() - epochStakes := map[solana.PublicKey]uint64{} - totalStake := uint64(100) - service := NewForkChoiceService(0, epochStakes, totalStake, epochAuth) - - slot := uint64(10) - winnerHash := solana.Hash{0xAA} - - service.state.mu.Lock() - acc := newSlotVoteAccumulator(totalStake, slot) - for i := 0; i < 67; i++ { - var pk [32]byte - pk[0] = byte(i + 1) - pk[1] = byte((i + 1) >> 8) - acc.addVote(winnerHash, solana.PublicKeyFromBytes(pk[:]), 1) - } - service.state.voteStakeTotals[slot] = acc - service.state.latestObservedSlot = slot + 5 // Below timeout - service.state.mu.Unlock() - - hash, status := service.GetSupermajorityHash(slot) - assert.Equal(t, BankhashHasSupermajority, status, "should return winner immediately") - assert.Equal(t, winnerHash, hash) -} - -// TestGetSupermajorityHashNeedWaitBeforeTimeout verifies that GetSupermajorityHash -// returns NeedWait when no winner exists and the timeout hasn't expired. -func TestGetSupermajorityHashNeedWaitBeforeTimeout(t *testing.T) { - epochAuth := epochstakes.NewEpochAuthorizedVotersCache() - epochStakes := map[solana.PublicKey]uint64{} - totalStake := uint64(100) - service := NewForkChoiceService(0, epochStakes, totalStake, epochAuth) - - slot := uint64(10) - - // Partial votes, no winner. - service.state.mu.Lock() - acc := newSlotVoteAccumulator(totalStake, slot) - var pk [32]byte - pk[0] = 1 - acc.addVote(solana.Hash{0xAA}, solana.PublicKeyFromBytes(pk[:]), 30) - service.state.voteStakeTotals[slot] = acc - service.state.latestObservedSlot = slot + 10 // Below timeout - service.state.mu.Unlock() - - hash, status := service.GetSupermajorityHash(slot) - assert.Equal(t, BankhashNeedWait, status, "no winner + before timeout = NeedWait") - assert.Equal(t, solana.Hash{}, hash) -} - -// TestGetSupermajorityHashNoSupermajorityAfterTimeout verifies that -// GetSupermajorityHash returns NoSupermajority when partial votes exist -// but the timeout has expired without a winner. -func TestGetSupermajorityHashNoSupermajorityAfterTimeout(t *testing.T) { - epochAuth := epochstakes.NewEpochAuthorizedVotersCache() - epochStakes := map[solana.PublicKey]uint64{} - totalStake := uint64(100) - service := NewForkChoiceService(0, epochStakes, totalStake, epochAuth) - - slot := uint64(10) - - // Partial votes, no winner, timeout expired. - service.state.mu.Lock() - acc := newSlotVoteAccumulator(totalStake, slot) - var pk [32]byte - pk[0] = 1 - acc.addVote(solana.Hash{0xAA}, solana.PublicKeyFromBytes(pk[:]), 30) - service.state.voteStakeTotals[slot] = acc - service.state.latestObservedSlot = slot + VoteConfirmationTimeoutSlots - service.state.mu.Unlock() - - hash, status := service.GetSupermajorityHash(slot) - assert.Equal(t, BankhashNoSupermajority, status, "no winner + after timeout = NoSupermajority") - assert.Equal(t, solana.Hash{}, hash) -} - -// TestSubmitBlockCapturesEpochDataBeforeUpdateEpoch verifies that a block -// submitted before UpdateEpoch is processed with the epoch data that was -// current at submission time, not the post-update data. This prevents -// pre-boundary blocks queued before an epoch transition from being parsed -// and weighted with post-boundary stakes/voters. -func TestSubmitBlockCapturesEpochDataBeforeUpdateEpoch(t *testing.T) { - voterKey := solana.PublicKey{1} - voteAcct := solana.PublicKey{2} - - // Epoch 0: voterKey authorized for voteAcct, stake=50, total=100. - epoch0Auth := epochstakes.NewEpochAuthorizedVotersCache() - epoch0Auth.PutEntry(voteAcct, voterKey) - epoch0Stakes := map[solana.PublicKey]uint64{voteAcct: 50} - epoch0Total := uint64(100) - - // Epoch 1: voterKey NOT authorized, different total. - epoch1Auth := epochstakes.NewEpochAuthorizedVotersCache() - epoch1Stakes := map[solana.PublicKey]uint64{voteAcct: 75} - epoch1Total := uint64(200) - - service := NewForkChoiceService(0, epoch0Stakes, epoch0Total, epoch0Auth) - - // Build a vote tx: voterKey votes for slot 50 with hash {0xBB}. - votedSlot := uint64(50) - votedHash := solana.Hash{0xBB} - voteTx := buildTestVoteTx(voteAcct, voterKey, votedSlot, votedHash) - - // Submit the block — captures epoch 0 data in the job. - // Service is NOT started, so the job sits in the channel. - service.SubmitBlock(100, []*solana.Transaction{voteTx}) - - // Update to epoch 1 BEFORE the job is processed. - service.UpdateEpoch(1, epoch1Stakes, epoch1Total, epoch1Auth) - - // Drain the job and verify it carries epoch 0 data. - job := <-service.jobChan - assert.Equal(t, epoch0Total, job.totalEpochStake, "job should carry epoch 0 total stake") - - // Process the job — should use epoch 0 data from the job. - service.processBlock(job) - - // The vote should have been accepted (voterKey authorized in epoch 0), - // and the accumulator should use epoch 0's total stake for threshold. - service.state.mu.Lock() - acc, exists := service.state.voteStakeTotals[votedSlot] - service.state.mu.Unlock() - - assert.True(t, exists, "accumulator should exist — vote authorized in epoch 0") - assert.Equal(t, epoch0Total, acc.totalEpochStake, "accumulator threshold should use epoch 0 total") - assert.Equal(t, computeThresholdStake(epoch0Total), acc.thresholdStake) - assert.Equal(t, uint64(50), acc.stakeForHash(votedHash), "vote weighted with epoch 0 stake") - - // Verify the converse: if the job had used epoch 1 data, the vote would - // have been rejected (voterKey not in epoch1Auth) and no accumulator created. - // The existence of the accumulator with epoch 0 weights proves the snapshot. -} - -// TestSequentialProcessingDeterminesWinner verifies that block processing order -// determines winner selection when two competing hashes can both independently -// cross the 2/3 threshold. This is a regression test for the ordering fix that -// replaced the concurrent ants pool with sequential inline processing. -// -// The test exercises the full service path: Start() → SubmitBlock() → run() → -// processBlock() → Stop(). Channel FIFO ordering guarantees block 100 is -// processed before block 101, so hashX always crosses the threshold first. -// -// With the old ants pool, thread scheduling determined which block's votes -// applied first, making the winner non-deterministic. -func TestSequentialProcessingDeterminesWinner(t *testing.T) { - // Two voters, each with enough stake to independently cross the threshold. - voterA := solana.PublicKey{1} - voteAcctA := solana.PublicKey{2} - voterB := solana.PublicKey{3} - voteAcctB := solana.PublicKey{4} - - totalStake := uint64(100) // threshold = uint64(100 * 2/3) = 66 - - epochAuth := epochstakes.NewEpochAuthorizedVotersCache() - epochAuth.PutEntry(voteAcctA, voterA) - epochAuth.PutEntry(voteAcctB, voterB) - - epochStakes := map[solana.PublicKey]uint64{ - voteAcctA: 67, // > threshold of 66 - voteAcctB: 67, - } - - service := NewForkChoiceService(0, epochStakes, totalStake, epochAuth) - service.Start() - - // Both voters vote for the same slot but different hashes. - votedSlot := uint64(50) - hashX := solana.Hash{0xAA} - hashY := solana.Hash{0xBB} - - // Block at slot 100: voterA votes for (slot 50, hashX) — submitted first. - txA := buildTestVoteTx(voteAcctA, voterA, votedSlot, hashX) - service.SubmitBlock(100, []*solana.Transaction{txA}) - - // Block at slot 101: voterB votes for (slot 50, hashY) — submitted second. - txB := buildTestVoteTx(voteAcctB, voterB, votedSlot, hashY) - service.SubmitBlock(101, []*solana.Transaction{txB}) - - // Stop drains all queued jobs before returning. - service.Stop() - - // latestObservedSlot should have been advanced by processBlock (not manual). - service.state.mu.Lock() - latestIngested := service.state.latestObservedSlot - service.state.mu.Unlock() - assert.Equal(t, uint64(101), latestIngested, "watermark should advance to 101") - - // hashX must win because block 100 was processed first (channel FIFO). - resultX := service.IsBankhashCorrect(votedSlot, hashX) - assert.Equal(t, BankhashHasSupermajority, resultX.Status, "hashX should have supermajority") - assert.Equal(t, hashX, resultX.WinningHash) - - // hashY loses — a different hash already won supermajority. - resultY := service.IsBankhashCorrect(votedSlot, hashY) - assert.Equal(t, BankhashNoSupermajority, resultY.Status, "hashY should be NoSupermajority (mismatch)") - assert.Equal(t, hashX, resultY.WinningHash, "winning hash should still be hashX") -} - -func TestParseAndValidateVoteTxFindsVoteInstructionAfterComputeBudget(t *testing.T) { - voterKey := solana.PublicKey{1} - voteAcct := solana.PublicKey{2} - votedSlot := uint64(50) - votedHash := solana.Hash{0xBB} - - epochAuth := epochstakes.NewEpochAuthorizedVotersCache() - epochAuth.PutEntry(voteAcct, voterKey) - - tx := buildTestVoteTx(voteAcct, voterKey, votedSlot, votedHash) - voteInstr := tx.Message.Instructions[0] - tx.Message.AccountKeys = []solana.PublicKey{ - voterKey, - voteAcct, - solana.SysVarSlotHashesPubkey, - solana.SysVarClockPubkey, - solana.ComputeBudget, - solana.VoteProgramID, - } - tx.Message.Instructions = []solana.CompiledInstruction{ - { - ProgramIDIndex: 4, - Data: solana.Base58([]byte{2, 0, 0, 0, 200, 0, 0, 0, 0}), - }, - { - ProgramIDIndex: 5, - Accounts: voteInstr.Accounts, - Data: voteInstr.Data, - }, - } - - require.True(t, tx.IsVote(), "fixture should still be recognized as a vote transaction") - - info, ok := parseAndValidateVoteTx(tx, epochAuth) - require.True(t, ok) - assert.Equal(t, votedSlot, info.slot) - assert.Equal(t, votedHash, solana.Hash(info.bankHash)) - assert.Equal(t, voteAcct, info.votePubkey) -} - -func TestParseAndValidateVoteTxUsesSignerForVoteAuthority(t *testing.T) { - voterKey := solana.PublicKey{1} - voteAcct := solana.PublicKey{2} - votedSlot := uint64(50) - votedHash := solana.Hash{0xBB} - - epochAuth := epochstakes.NewEpochAuthorizedVotersCache() - epochAuth.PutEntry(voteAcct, voterKey) - - tx := buildTestVoteTx(voteAcct, voterKey, votedSlot, votedHash) - require.True(t, tx.IsVote(), "fixture should be recognized as a vote transaction") - require.Equal(t, solana.SysVarSlotHashesPubkey, tx.Message.AccountKeys[tx.Message.Instructions[0].Accounts[1]], - "legacy vote account 1 is the slot-hashes sysvar, not the vote authority") - - info, ok := parseAndValidateVoteTx(tx, epochAuth) - require.True(t, ok) - assert.Equal(t, votedSlot, info.slot) - assert.Equal(t, votedHash, solana.Hash(info.bankHash)) - assert.Equal(t, voteAcct, info.votePubkey) -} - -func TestParseAndValidateVoteTxRejectsAuthorityOutsideInstruction(t *testing.T) { - voterKey := solana.PublicKey{1} - voteAcct := solana.PublicKey{2} - - epochAuth := epochstakes.NewEpochAuthorizedVotersCache() - epochAuth.PutEntry(voteAcct, voterKey) - - tx := buildTestVoteTx(voteAcct, voterKey, 50, solana.Hash{0xBB}) - tx.Message.Instructions[0].Accounts = []uint16{1, 2, 3} - - _, ok := parseAndValidateVoteTx(tx, epochAuth) - require.False(t, ok) -} - -func TestParseAndValidateVoteTxAcceptsLiveTowerSyncShape(t *testing.T) { - voteAuthority := solana.MustPublicKeyFromBase58("DRpbCBMxVnDK7maPM5tGv6MvB3v1sRMC86PZ8okm21hy") - voteAcct := solana.MustPublicKeyFromBase58("3N7s9zXMZ4QqvHQR15t5GNHyqc89KduzMP7423eWiD5g") - votedHash := solana.MustHashFromBase58("F4GcS4MtttPknSkbGW3KCXWJd6mWvzaXDnHHyM87Gd2A") - - var data solana.Base58 - err := json.Unmarshal([]byte(`"67MGmzm8yEnRh15X2h4HuP1ZCWg1Ld1zPNgZqhBGEySYPXuCReZ8tSvvhrKA1j7q6ky81hjNVPUp6WvdLbnfVYTmFvK2C2QCSBbGAoibsseTrrczvs6Xk47BPdpcN6PB9bYaFnu8wtykuo4WLhELbCuYYwwUyA6zNqZfDHLePABFKUDJLbyE9DsqoiATDtoznG7Bevvfra"`), &data) - require.NoError(t, err) - - tx := &solana.Transaction{ - Message: solana.Message{ - Header: solana.MessageHeader{ - NumRequiredSignatures: 1, - }, - AccountKeys: []solana.PublicKey{ - voteAuthority, - voteAcct, - solana.VoteProgramID, - }, - Instructions: []solana.CompiledInstruction{ - { - ProgramIDIndex: 2, - Accounts: []uint16{1, 0}, - Data: data, - }, - }, - }, - Signatures: []solana.Signature{{}}, - } - - epochAuth := epochstakes.NewEpochAuthorizedVotersCache() - epochAuth.PutEntry(voteAcct, voteAuthority) - - info, ok := parseAndValidateVoteTx(tx, epochAuth) - require.True(t, ok) - assert.Equal(t, uint64(420404777), info.slot) - assert.Equal(t, votedHash, solana.Hash(info.bankHash)) - assert.Equal(t, voteAcct, info.votePubkey) - // The explicit tower root must be extracted. A real TowerSync carries a root, - // and it is always below the tower tip. - require.NotNil(t, info.rootSlot, "TowerSync carries an explicit root") - assert.Less(t, *info.rootSlot, info.slot, "root is below the tower tip") -} - -// A legacy Vote instruction carries no explicit root -> rootSlot is nil. -func TestParseVoteTxLegacyHasNoRoot(t *testing.T) { - voteAcct := solana.PublicKey{0x11} - voteAuthority := solana.PublicKey{0x22} - epochAuth := epochstakes.NewEpochAuthorizedVotersCache() - epochAuth.PutEntry(voteAcct, voteAuthority) - - tx := buildTestVoteTx(voteAcct, voteAuthority, 1000, testHash(0xAB)) - info, ok := parseAndValidateVoteTx(tx, epochAuth) - require.True(t, ok) - assert.Equal(t, uint64(1000), info.slot) - assert.Nil(t, info.rootSlot, "legacy Vote has no explicit root") -} - -func TestParseAndValidateVoteTxFallsBackToSignatureCountWhenHeaderSignerCountMissing(t *testing.T) { - voteAuthority := solana.MustPublicKeyFromBase58("GmCxjmjKZoaKN1DKunbYq8RCYib94Nm3sHyncFfofaF5") - voteAcct := solana.MustPublicKeyFromBase58("7S9dHgoeMYvtShTjEC3x5D3THRDQz123WVGPseZsm3hm") - - var data solana.Base58 - err := json.Unmarshal([]byte(`"67MGn8HzmNzWfjLAq5WGPoC4LktJMeSH2UUTqWmWd2VXRbURnRQM4hTJvxGRcSbKb6CYLd3x42wvAjAyYsY19ajzUtqxDcE4XZP4eHV47zTUEkudvy7R2a7sJAaJtS9nk9D2NtMP3du8S8BFSUhjLPmVW9pmh4CgnBS5Jh7B8XNkQmGLS8sCGSWY9UbZrYipFy7rEVjirv"`), &data) - require.NoError(t, err) - - tx := &solana.Transaction{ - Message: solana.Message{ - Header: solana.MessageHeader{}, - AccountKeys: []solana.PublicKey{ - voteAuthority, - voteAcct, - solana.VoteProgramID, - }, - Instructions: []solana.CompiledInstruction{ - { - ProgramIDIndex: 2, - Accounts: []uint16{1, 0}, - Data: data, - }, - }, - }, - Signatures: []solana.Signature{{}}, - } - - epochAuth := epochstakes.NewEpochAuthorizedVotersCache() - epochAuth.PutEntry(voteAcct, voteAuthority) - - info, ok := parseAndValidateVoteTx(tx, epochAuth) - require.True(t, ok) - assert.Equal(t, uint64(420407984), info.slot) - assert.Equal(t, voteAcct, info.votePubkey) -} - -func TestObserveBlockResolvesParentSlotFromParentBlockhash(t *testing.T) { - epochAuth := epochstakes.NewEpochAuthorizedVotersCache() - service := NewForkChoiceService(0, map[solana.PublicKey]uint64{}, 100, epochAuth) - - anchorHash := solana.Hash{0x01} - childHash := solana.Hash{0x02} - - service.ObserveExecutionAnchor(99, anchorHash) - - err := service.ObserveBlock(ObservedBlockMeta{ - Slot: 101, - Blockhash: childHash, - ParentBlockhash: anchorHash, - }, nil) - require.NoError(t, err) - - service.state.mu.Lock() - defer service.state.mu.Unlock() - - meta := service.state.observedBlocks[101] - require.NotNil(t, meta) - assert.True(t, meta.ParentSlotKnown) - assert.Equal(t, uint64(99), meta.ParentSlot) -} - -func TestObserveExecutionAnchorPrunesOldForkchoiceState(t *testing.T) { - epochAuth := epochstakes.NewEpochAuthorizedVotersCache() - service := NewForkChoiceService(0, map[solana.PublicKey]uint64{}, 100, epochAuth) - - oldParentHash := testHash(0x10) - newParentHash := testHash(0x20) - oldBlockHash := testHash(0x11) - newBlockHash := testHash(0x21) - anchorHash := testHash(0x30) - - oldAcc := newSlotVoteAccumulator(100, 90) - oldAcc.trackers[testHash(0x91)] = &voteStakeTracker{voted: map[solana.PublicKey]struct{}{}, stake: 70} - newAcc := newSlotVoteAccumulator(100, 101) - newAcc.trackers[testHash(0xA1)] = &voteStakeTracker{voted: map[solana.PublicKey]struct{}{}, stake: 70} - - service.state.mu.Lock() - service.state.voteStakeTotals[90] = oldAcc - service.state.voteStakeTotals[101] = newAcc - service.state.observedBlocks[90] = &ObservedBlockMeta{Slot: 90, Blockhash: oldBlockHash, ParentSlot: 89, ParentSlotKnown: true} - service.state.observedBlocks[101] = &ObservedBlockMeta{Slot: 101, Blockhash: newBlockHash, ParentSlot: 100, ParentSlotKnown: true} - service.state.blockhashToSlot[oldParentHash] = 89 - service.state.blockhashToSlot[oldBlockHash] = 90 - service.state.blockhashToSlot[newParentHash] = 100 - service.state.blockhashToSlot[newBlockHash] = 101 - service.state.pendingParentByHash[oldParentHash] = []uint64{90, 95} - service.state.pendingParentByHash[newParentHash] = []uint64{101, 103} - service.state.equivocatedSlots[90] = struct{}{} - service.state.equivocatedSlots[101] = struct{}{} - service.state.mu.Unlock() - - service.ObserveExecutionAnchor(100, anchorHash) - - service.state.mu.Lock() - defer service.state.mu.Unlock() - - _, ok := service.state.voteStakeTotals[90] - assert.False(t, ok) - _, ok = service.state.voteStakeTotals[101] - assert.True(t, ok) - - _, ok = service.state.observedBlocks[90] - assert.False(t, ok) - _, ok = service.state.observedBlocks[101] - assert.True(t, ok) - - _, ok = service.state.blockhashToSlot[oldParentHash] - assert.False(t, ok) - _, ok = service.state.blockhashToSlot[oldBlockHash] - assert.False(t, ok) - _, ok = service.state.blockhashToSlot[newParentHash] - assert.True(t, ok) - _, ok = service.state.blockhashToSlot[newBlockHash] - assert.True(t, ok) - assert.Equal(t, uint64(100), service.state.blockhashToSlot[anchorHash]) - - waitingOld := service.state.pendingParentByHash[oldParentHash] - assert.Empty(t, waitingOld) - waitingNew := service.state.pendingParentByHash[newParentHash] - assert.Equal(t, []uint64{101, 103}, waitingNew) - - _, ok = service.state.equivocatedSlots[90] - assert.False(t, ok) - _, ok = service.state.equivocatedSlots[101] - assert.True(t, ok) -} - -func TestFindConfirmedLeafReturnsHighestObservedWinner(t *testing.T) { - epochAuth := epochstakes.NewEpochAuthorizedVotersCache() - service := NewForkChoiceService(0, map[solana.PublicKey]uint64{}, 100, epochAuth) - - injectWinner := func(slot uint64, winningHash solana.Hash) { - acc := newSlotVoteAccumulator(100, slot) - tracker := &voteStakeTracker{ - voted: make(map[solana.PublicKey]struct{}), - stake: 70, - } - acc.trackers[winningHash] = tracker - acc.confirmed = true - acc.confirmedHash = winningHash - service.state.voteStakeTotals[slot] = acc - } - - service.state.mu.Lock() - service.state.observedBlocks[105] = &ObservedBlockMeta{Slot: 105, ParentSlot: 100, ParentSlotKnown: true, Blockhash: testHash(0x05)} - service.state.observedBlocks[107] = &ObservedBlockMeta{Slot: 107, ParentSlot: 105, ParentSlotKnown: true, Blockhash: testHash(0x07)} - injectWinner(105, testHash(0xA5)) - injectWinner(107, testHash(0xA7)) - service.state.latestObservedSlot = 107 - service.state.mu.Unlock() - - leaf, err := service.FindConfirmedLeaf(100, 16) - require.NoError(t, err) - assert.Equal(t, uint64(107), leaf.Slot) - assert.Equal(t, testHash(0xA7), leaf.Bankhash) -} - -// buildTestVoteTx constructs a minimal legacy Vote instruction for testing. -func buildTestVoteTx(voteAcct, voteAuthority solana.PublicKey, slot uint64, hash solana.Hash) *solana.Transaction { - // Encode VoteProgramInstrTypeVote (type=2): - // [type:4][num_slots:8][slot:8][hash:32][timestamp_opt:1] = 53 bytes - data := make([]byte, 53) - binary.LittleEndian.PutUint32(data[0:4], 2) // VoteProgramInstrTypeVote - binary.LittleEndian.PutUint64(data[4:12], 1) // 1 slot (Rust Vec len is u64) - binary.LittleEndian.PutUint64(data[12:20], slot) - copy(data[20:52], hash[:]) - data[52] = 0 // No timestamp - - return &solana.Transaction{ - Message: solana.Message{ - Header: solana.MessageHeader{ - NumRequiredSignatures: 1, - }, - AccountKeys: []solana.PublicKey{ - voteAuthority, - voteAcct, - solana.SysVarSlotHashesPubkey, - solana.SysVarClockPubkey, - solana.VoteProgramID, - }, - Instructions: []solana.CompiledInstruction{ - { - ProgramIDIndex: 4, - Accounts: []uint16{1, 2, 3, 0}, - Data: solana.Base58(data), - }, - }, - }, - Signatures: []solana.Signature{{}}, - } -} diff --git a/pkg/forkchoice/heaviest_subtree.go b/pkg/forkchoice/heaviest_subtree.go deleted file mode 100644 index cc23e2644..000000000 --- a/pkg/forkchoice/heaviest_subtree.go +++ /dev/null @@ -1,544 +0,0 @@ -package forkchoice - -import ( - "bytes" - "sort" - - "github.com/Overclock-Validator/mithril/pkg/mlog" - "github.com/gagliardetto/solana-go" -) - -// SlotHashKey identifies one block version: (slot, hash). Ordering is slot, then -// hash bytes — matching Agave's (Slot, Hash) tuple ordering. -type SlotHashKey struct { - Slot uint64 - Hash [32]byte -} - -func (k SlotHashKey) less(o SlotHashKey) bool { - if k.Slot != o.Slot { - return k.Slot < o.Slot - } - return bytes.Compare(k.Hash[:], o.Hash[:]) < 0 -} - -// hsfForkInfo is the per-node state (Agave ForkInfo). -type hsfForkInfo struct { - stakeVotedAt uint64 - stakeVotedSubtree uint64 - height int - bestSlot SlotHashKey // heaviest descendant (candidates only) - deepestSlot SlotHashKey // deepest descendant (ignores validity) - parent *SlotHashKey - children []SlotHashKey // sorted ascending (BTreeSet semantics) - // Latest ancestor slot marked invalid (== own slot when self is the duplicate); - // nil = candidate. - latestInvalidAncestor *uint64 - isDuplicateConfirmed bool -} - -func (fi *hsfForkInfo) isCandidate() bool { return fi.latestInvalidAncestor == nil } - -func (fi *hsfForkInfo) setDuplicateConfirmed() { - fi.isDuplicateConfirmed = true - fi.latestInvalidAncestor = nil -} - -func (fi *hsfForkInfo) updateWithNewlyValidAncestor(validSlot uint64) { - if fi.latestInvalidAncestor != nil && *fi.latestInvalidAncestor <= validSlot { - fi.latestInvalidAncestor = nil - } -} - -func (fi *hsfForkInfo) updateWithNewlyInvalidAncestor(invalidSlot uint64) { - if fi.isDuplicateConfirmed { - panic("heaviest-subtree: cannot mark a duplicate-confirmed node invalid") - } - if fi.latestInvalidAncestor == nil || invalidSlot > *fi.latestInvalidAncestor { - s := invalidSlot - fi.latestInvalidAncestor = &s - } -} - -// StakeFn returns a validator's stake for votes at the given slot (epoch-aware -// lookups plug in here; a flat epoch-stakes map ignores the slot). -type StakeFn func(pk solana.PublicKey, slot uint64) uint64 - -// HeaviestSubtreeForkChoice is a faithful port of Agave's stake-weighted fork -// choice (core/src/consensus/heaviest_subtree_fork_choice.rs): per-node -// stake_voted_at/subtree, latest-vote-per-validator, upward aggregation, best -// child by subtree stake (tie-break lower key), duplicate marking via -// invalid/valid candidates. Not goroutine-safe; callers serialize. -type HeaviestSubtreeForkChoice struct { - treeRoot SlotHashKey - forkInfos map[SlotHashKey]*hsfForkInfo - latestVotes map[solana.PublicKey]SlotHashKey -} - -func NewHeaviestSubtreeForkChoice(root SlotHashKey) *HeaviestSubtreeForkChoice { - h := &HeaviestSubtreeForkChoice{ - treeRoot: root, - forkInfos: make(map[SlotHashKey]*hsfForkInfo), - latestVotes: make(map[solana.PublicKey]SlotHashKey), - } - h.AddNewLeafSlot(root, nil) - return h -} - -func (h *HeaviestSubtreeForkChoice) TreeRoot() SlotHashKey { return h.treeRoot } - -func (h *HeaviestSubtreeForkChoice) ContainsBlock(k SlotHashKey) bool { - _, ok := h.forkInfos[k] - return ok -} - -// BestOverallSlot is the heaviest valid leaf: the fork to follow. -func (h *HeaviestSubtreeForkChoice) BestOverallSlot() SlotHashKey { - return h.forkInfos[h.treeRoot].bestSlot -} - -// DeepestOverallSlot is the deepest leaf regardless of validity. -func (h *HeaviestSubtreeForkChoice) DeepestOverallSlot() SlotHashKey { - return h.forkInfos[h.treeRoot].deepestSlot -} - -func (h *HeaviestSubtreeForkChoice) BestSlot(k SlotHashKey) (SlotHashKey, bool) { - fi, ok := h.forkInfos[k] - if !ok { - return SlotHashKey{}, false - } - return fi.bestSlot, true -} - -func (h *HeaviestSubtreeForkChoice) StakeVotedSubtree(k SlotHashKey) (uint64, bool) { - fi, ok := h.forkInfos[k] - if !ok { - return 0, false - } - return fi.stakeVotedSubtree, true -} - -func (h *HeaviestSubtreeForkChoice) StakeVotedAt(k SlotHashKey) (uint64, bool) { - fi, ok := h.forkInfos[k] - if !ok { - return 0, false - } - return fi.stakeVotedAt, true -} - -func (h *HeaviestSubtreeForkChoice) IsCandidate(k SlotHashKey) (bool, bool) { - fi, ok := h.forkInfos[k] - if !ok { - return false, false - } - return fi.isCandidate(), true -} - -func (h *HeaviestSubtreeForkChoice) IsDuplicateConfirmed(k SlotHashKey) (bool, bool) { - fi, ok := h.forkInfos[k] - if !ok { - return false, false - } - return fi.isDuplicateConfirmed, true -} - -func (h *HeaviestSubtreeForkChoice) LatestInvalidAncestor(k SlotHashKey) (uint64, bool) { - fi, ok := h.forkInfos[k] - if !ok || fi.latestInvalidAncestor == nil { - return 0, false - } - return *fi.latestInvalidAncestor, true -} - -// AddNewLeafSlot inserts a frozen block under parent (nil = the tree root itself) -// and propagates best/deepest up the tree. Re-adding an existing key is a no-op -// (repair of the same version after a dump). -func (h *HeaviestSubtreeForkChoice) AddNewLeafSlot(key SlotHashKey, parent *SlotHashKey) { - if _, exists := h.forkInfos[key]; exists { - return - } - var inheritedInvalid *uint64 - if parent != nil { - if pfi, ok := h.forkInfos[*parent]; ok && pfi.latestInvalidAncestor != nil { - s := *pfi.latestInvalidAncestor - inheritedInvalid = &s - } - } - h.forkInfos[key] = &hsfForkInfo{ - height: 1, - bestSlot: key, // a leaf's best/deepest is itself - deepestSlot: key, - parent: parent, - latestInvalidAncestor: inheritedInvalid, - // A parentless insert is the root, which is duplicate-confirmed by definition. - isDuplicateConfirmed: parent == nil, - } - if parent == nil { - return - } - pfi, ok := h.forkInfos[*parent] - if !ok { - panic("heaviest-subtree: parent must exist before its child is added") - } - pfi.children = insertSortedKey(pfi.children, key) - h.propagateNewLeaf(key, *parent) -} - -// AddVotes applies the latest votes (one per validator per batch), subtracting each -// validator's stake from its previous fork and adding it to the new one, then -// re-aggregates. Returns the new best overall slot. -func (h *HeaviestSubtreeForkChoice) AddVotes(votes []VoteKey, stakeAt StakeFn) SlotHashKey { - ops := h.generateUpdateOperations(votes, stakeAt) - h.processUpdateOperations(ops) - return h.BestOverallSlot() -} - -// VoteKey is one validator's latest vote target. -type VoteKey struct { - Pubkey solana.PublicKey - Key SlotHashKey -} - -// update-operation machinery (Agave UpdateLabel/UpdateOperation over a BTreeMap, -// processed greatest→smallest so descendants aggregate before ancestors). -const ( - labelAggregate = iota - labelAdd - labelMarkValid - labelMarkInvalid - labelSubtract -) - -type opKey struct { - key SlotHashKey - label int - labelSlot uint64 // MarkValid/MarkInvalid payload (part of ordering, like Rust) -} - -type updateOps map[opKey]uint64 // value = stake for Add/Subtract, unused otherwise - -func (h *HeaviestSubtreeForkChoice) generateUpdateOperations(votes []VoteKey, stakeAt StakeFn) updateOps { - ops := make(updateOps) - observed := make(map[solana.PublicKey]bool, len(votes)) - for _, v := range votes { - if v.Key.Slot < h.treeRoot.Slot { - continue // below root: provably a no-op for fork choice - } - if observed[v.Pubkey] { - panic("heaviest-subtree: multiple votes for the same pubkey in one batch") - } - observed[v.Pubkey] = true - - if prev, ok := h.latestVotes[v.Pubkey]; ok { - // Only newer votes count; equal slot only for a SMALLER hash (a duplicate - // version of the same slot). - if v.Key.Slot < prev.Slot || - (v.Key.Slot == prev.Slot && bytes.Compare(v.Key.Hash[:], prev.Hash[:]) >= 0) { - continue - } - // Remove this validator's stake from the previous fork. - if st := stakeAt(v.Pubkey, prev.Slot); st > 0 { - ops[opKey{key: prev, label: labelSubtract}] += st - h.insertAggregateOperations(ops, prev) - } - } - h.latestVotes[v.Pubkey] = v.Key - - // Insert the Add and its aggregates even for zero stake, so op ordering is identical regardless of stake. - ops[opKey{key: v.Key, label: labelAdd}] += stakeAt(v.Pubkey, v.Key.Slot) - h.insertAggregateOperations(ops, v.Key) - } - return ops -} - -func (h *HeaviestSubtreeForkChoice) insertAggregateOperations(ops updateOps, key SlotHashKey) { - h.insertAggregateAcrossAncestors(ops, key, 0, 0) -} - -// insertAggregateAcrossAncestors marks every ancestor for re-aggregation (stopping -// at the first already-marked one), optionally attaching a MarkValid/MarkInvalid. -func (h *HeaviestSubtreeForkChoice) insertAggregateAcrossAncestors(ops updateOps, key SlotHashKey, markLabel int, markSlot uint64) { - for p := h.parentOf(key); p != nil; p = h.parentOf(*p) { - if !h.insertOneAggregate(ops, *p, markLabel, markSlot) { - break - } - } -} - -func (h *HeaviestSubtreeForkChoice) insertOneAggregate(ops updateOps, key SlotHashKey, markLabel int, markSlot uint64) bool { - agg := opKey{key: key, label: labelAggregate} - if _, exists := ops[agg]; exists { - return false - } - if markLabel == labelMarkValid || markLabel == labelMarkInvalid { - ops[opKey{key: key, label: markLabel, labelSlot: markSlot}] = 0 - } - ops[agg] = 0 - return true -} - -// processUpdateOperations applies ops greatest→smallest (descendants before -// ancestors; per node: Subtract/MarkInvalid/MarkValid/Add before Aggregate). -func (h *HeaviestSubtreeForkChoice) processUpdateOperations(ops updateOps) { - keys := make([]opKey, 0, len(ops)) - for k := range ops { - keys = append(keys, k) - } - sort.Slice(keys, func(i, j int) bool { // descending (Rust .rev() over BTreeMap) - a, b := keys[i], keys[j] - if a.key != b.key { - return b.key.less(a.key) - } - if a.label != b.label { - return a.label > b.label - } - return a.labelSlot > b.labelSlot - }) - for _, k := range keys { - switch k.label { - case labelMarkValid: - h.markForkValid(k.key, k.labelSlot) - case labelMarkInvalid: - h.markForkInvalid(k.key, k.labelSlot) - case labelAggregate: - h.aggregateSlot(k.key) - case labelAdd: - if fi, ok := h.forkInfos[k.key]; ok { - fi.stakeVotedAt += ops[k] - fi.stakeVotedSubtree += ops[k] - } - case labelSubtract: - if fi, ok := h.forkInfos[k.key]; ok { - fi.stakeVotedAt -= ops[k] - fi.stakeVotedSubtree -= ops[k] - } - } - } -} - -// aggregateSlot recomputes one node from its children: subtree stake counts ALL -// children (even non-candidates, so their weight still backs shared ancestors); -// bestSlot considers candidates only, by subtree stake, tie-break lower key; -// deepest by height, then stake, then lower key; duplicate-confirmed bubbles up. -func (h *HeaviestSubtreeForkChoice) aggregateSlot(key SlotHashKey) { - fi, ok := h.forkInfos[key] - if !ok { - return - } - stakeVotedSubtree := fi.stakeVotedAt - deepestChildHeight := 0 - bestSlot := key - deepestSlot := key - isDupConfirmed := false - bestChildStake := uint64(0) - bestChildKey := key - deepestChildStake := uint64(0) - deepestChildKey := key - for _, ck := range fi.children { - childInfo := h.forkInfos[ck] - isDupConfirmed = isDupConfirmed || childInfo.isDuplicateConfirmed - stakeVotedSubtree += childInfo.stakeVotedSubtree - if childInfo.isCandidate() && - (bestChildKey == key || - childInfo.stakeVotedSubtree > bestChildStake || - (childInfo.stakeVotedSubtree == bestChildStake && ck.less(bestChildKey))) { - bestChildStake = childInfo.stakeVotedSubtree - bestChildKey = ck - bestSlot = childInfo.bestSlot - } - if deepestChildKey == key || - childInfo.height > deepestChildHeight || - (childInfo.height == deepestChildHeight && childInfo.stakeVotedSubtree > deepestChildStake) || - (childInfo.height == deepestChildHeight && childInfo.stakeVotedSubtree == deepestChildStake && ck.less(deepestChildKey)) { - deepestChildHeight = childInfo.height - deepestChildStake = childInfo.stakeVotedSubtree - deepestChildKey = ck - deepestSlot = childInfo.deepestSlot - } - } - if isDupConfirmed && !fi.isDuplicateConfirmed { - mlog.Log.Infof("fork choice: setting (%d) to duplicate confirmed", key.Slot) - fi.setDuplicateConfirmed() - } - fi.stakeVotedSubtree = stakeVotedSubtree - fi.height = deepestChildHeight + 1 - fi.bestSlot = bestSlot - fi.deepestSlot = deepestSlot -} - -func (h *HeaviestSubtreeForkChoice) markForkValid(key SlotHashKey, validSlot uint64) { - if fi, ok := h.forkInfos[key]; ok { - fi.updateWithNewlyValidAncestor(validSlot) - if key.Slot == validSlot { - fi.isDuplicateConfirmed = true - } - } -} - -func (h *HeaviestSubtreeForkChoice) markForkInvalid(key SlotHashKey, invalidSlot uint64) { - if fi, ok := h.forkInfos[key]; ok { - fi.updateWithNewlyInvalidAncestor(invalidSlot) - } -} - -// MarkForkInvalidCandidate excludes the subtree rooted at key from best-slot -// selection (unconfirmed duplicate); its stake still backs shared ancestors. -func (h *HeaviestSubtreeForkChoice) MarkForkInvalidCandidate(key SlotHashKey) { - fi, ok := h.forkInfos[key] - if !ok { - return - } - if fi.isDuplicateConfirmed { - panic("heaviest-subtree: cannot mark a duplicate-confirmed fork invalid") - } - ops := make(updateOps) - // The whole subtree INCLUDING key gets the mark (Agave subtree_diff includes - // its root; the key itself becomes latest_invalid_ancestor == own slot). - for _, node := range append([]SlotHashKey{key}, h.subtreeKeys(key)...) { - h.insertOneAggregate(ops, node, labelMarkInvalid, key.Slot) - } - h.insertAggregateOperations(ops, key) - h.processUpdateOperations(ops) -} - -// MarkForkValidCandidate re-admits a fork after its version is duplicate-confirmed; -// returns the newly duplicate-confirmed ancestors. -func (h *HeaviestSubtreeForkChoice) MarkForkValidCandidate(key SlotHashKey) []SlotHashKey { - var newlyConfirmed []SlotHashKey - for cur := &key; cur != nil; cur = h.parentOf(*cur) { - if fi, ok := h.forkInfos[*cur]; ok && !fi.isDuplicateConfirmed { - newlyConfirmed = append(newlyConfirmed, *cur) - } - } - ops := make(updateOps) - for _, node := range append([]SlotHashKey{key}, h.subtreeKeys(key)...) { - h.insertOneAggregate(ops, node, labelMarkValid, key.Slot) - } - h.insertAggregateOperations(ops, key) - h.processUpdateOperations(ops) - return newlyConfirmed -} - -// SetTreeRoot advances the root, dropping everything not reachable from newRoot. -func (h *HeaviestSubtreeForkChoice) SetTreeRoot(newRoot SlotHashKey) { - nfi, ok := h.forkInfos[newRoot] - if !ok { - panic("heaviest-subtree: new root does not exist in fork choice") - } - keep := make(map[SlotHashKey]bool) - for _, k := range h.subtreeKeys(newRoot) { - keep[k] = true - } - keep[newRoot] = true - for k := range h.forkInfos { - if !keep[k] { - delete(h.forkInfos, k) - } - } - nfi.parent = nil - h.treeRoot = newRoot -} - -// propagateNewLeaf pushes a just-added leaf's best/deepest status up the tree -// without a full re-aggregation. -func (h *HeaviestSubtreeForkChoice) propagateNewLeaf(key, parent SlotHashKey) { - parentBest := h.forkInfos[parent].bestSlot - if h.isBestChild(key) { - ancestor := &parent - for ancestor != nil { - ancestorInfo := h.forkInfos[*ancestor] - if ancestorInfo.bestSlot == parentBest { - ancestorInfo.bestSlot = key - } else { - break - } - ancestor = ancestorInfo.parent - } - } - ancestor := &parent - currentChild := key - currentHeight := 1 - for ancestor != nil { - if !h.isDeepestChild(currentChild) { - break - } - ancestorInfo := h.forkInfos[*ancestor] - ancestorInfo.deepestSlot = key - ancestorInfo.height = currentHeight + 1 - currentChild = *ancestor - currentHeight = ancestorInfo.height - ancestor = ancestorInfo.parent - } -} - -// isBestChild: heaviest among its parent's CANDIDATE children, ties to lower key -// (only siblings are candidacy-filtered; the node itself is not). -func (h *HeaviestSubtreeForkChoice) isBestChild(key SlotHashKey) bool { - fi := h.forkInfos[key] - if fi.parent == nil { - return true - } - myStake := fi.stakeVotedSubtree - for _, sibling := range h.forkInfos[*fi.parent].children { - if sibling == key { - continue - } - siblingInfo := h.forkInfos[sibling] - if !siblingInfo.isCandidate() { - continue - } - if siblingInfo.stakeVotedSubtree > myStake || - (siblingInfo.stakeVotedSubtree == myStake && sibling.less(key)) { - return false - } - } - return true -} - -func (h *HeaviestSubtreeForkChoice) isDeepestChild(key SlotHashKey) bool { - fi := h.forkInfos[key] - if fi.parent == nil { - return true - } - myHeight, myStake := fi.height, fi.stakeVotedSubtree - for _, sibling := range h.forkInfos[*fi.parent].children { - if sibling == key { - continue - } - siblingInfo := h.forkInfos[sibling] - if siblingInfo.height > myHeight || - (siblingInfo.height == myHeight && siblingInfo.stakeVotedSubtree > myStake) || - (siblingInfo.height == myHeight && siblingInfo.stakeVotedSubtree == myStake && sibling.less(key)) { - return false - } - } - return true -} - -func (h *HeaviestSubtreeForkChoice) parentOf(key SlotHashKey) *SlotHashKey { - if fi, ok := h.forkInfos[key]; ok { - return fi.parent - } - return nil -} - -// subtreeKeys returns all descendants of key (excluding key), BFS order. -func (h *HeaviestSubtreeForkChoice) subtreeKeys(key SlotHashKey) []SlotHashKey { - var out []SlotHashKey - queue := append([]SlotHashKey(nil), h.forkInfos[key].children...) - for len(queue) > 0 { - k := queue[0] - queue = queue[1:] - out = append(out, k) - queue = append(queue, h.forkInfos[k].children...) - } - return out -} - -func insertSortedKey(keys []SlotHashKey, k SlotHashKey) []SlotHashKey { - i := sort.Search(len(keys), func(i int) bool { return !keys[i].less(k) }) - keys = append(keys, SlotHashKey{}) - copy(keys[i+1:], keys[i:]) - keys[i] = k - return keys -} diff --git a/pkg/forkchoice/heaviest_subtree_test.go b/pkg/forkchoice/heaviest_subtree_test.go deleted file mode 100644 index 60d4ad4e5..000000000 --- a/pkg/forkchoice/heaviest_subtree_test.go +++ /dev/null @@ -1,292 +0,0 @@ -package forkchoice - -import ( - "testing" - - "github.com/gagliardetto/solana-go" -) - -// Scenarios ported from Agave core/src/consensus/heaviest_subtree_fork_choice.rs -// tests (the only official fork-choice test corpus). Canonical setup_forks tree: -// -// slot 0 -// | -// slot 1 -// / \ -// slot 2 | -// | slot 3 -// slot 4 | -// slot 5 -// | -// slot 6 -func hsKey(slot uint64) SlotHashKey { return SlotHashKey{Slot: slot} } - -func hsKeyH(slot uint64, choice byte) SlotHashKey { - k := SlotHashKey{Slot: slot} - k.Hash[0] = choice - return k -} - -func setupForks() *HeaviestSubtreeForkChoice { - choice := NewHeaviestSubtreeForkChoice(hsKey(0)) - add := func(child, parent uint64) { - p := hsKey(parent) - choice.AddNewLeafSlot(hsKey(child), &p) - } - add(1, 0) - add(2, 1) - add(4, 2) - add(3, 1) - add(5, 3) - add(6, 5) - return choice -} - -func hsVoter(b byte) solana.PublicKey { return solana.PublicKey{0xF0, b} } - -func flatStake(stake uint64) StakeFn { - return func(solana.PublicKey, uint64) uint64 { return stake } -} - -// Ported test_add_votes: v0->3, v1->2, v2->1 (stake 100 each). Subtrees of 1's -// children tie at 100 each; tie-break picks the lower slot (2), whose best leaf -// is 4 -> best overall = 4. -func TestHSFCAddVotes(t *testing.T) { - choice := setupForks() - best := choice.AddVotes([]VoteKey{ - {Pubkey: hsVoter(0), Key: hsKey(3)}, - {Pubkey: hsVoter(1), Key: hsKey(2)}, - {Pubkey: hsVoter(2), Key: hsKey(1)}, - }, flatStake(100)) - if best.Slot != 4 { - t.Fatalf("best overall = %d, want 4 (official expectation)", best.Slot) - } - // weight aggregation: subtree(1) = 300, subtree(2) = 100, at(1) = 100 - if st, _ := choice.StakeVotedSubtree(hsKey(1)); st != 300 { - t.Fatalf("subtree(1) = %d, want 300", st) - } - if st, _ := choice.StakeVotedSubtree(hsKey(2)); st != 100 { - t.Fatalf("subtree(2) = %d, want 100", st) - } - if st, _ := choice.StakeVotedAt(hsKey(1)); st != 100 { - t.Fatalf("at(1) = %d, want 100", st) - } -} - -// With zero votes, best descends by tie-break (lower slot) to leaf 4. -func TestHSFCBestOverallNoVotes(t *testing.T) { - choice := setupForks() - if best := choice.BestOverallSlot(); best.Slot != 4 { - t.Fatalf("no-vote best = %d, want 4", best.Slot) - } - // deepest ignores stake: the 3->5->6 branch is taller - if deepest := choice.DeepestOverallSlot(); deepest.Slot != 6 { - t.Fatalf("deepest = %d, want 6", deepest.Slot) - } -} - -// A validator's later vote moves its stake: subtract from the old fork, add to -// the new one (latest-vote-per-validator semantics). -func TestHSFCVoteSwitchMovesStake(t *testing.T) { - choice := setupForks() - choice.AddVotes([]VoteKey{{Pubkey: hsVoter(0), Key: hsKey(4)}}, flatStake(100)) - if best := choice.BestOverallSlot(); best.Slot != 4 { - t.Fatalf("after vote on 4: best = %d, want 4", best.Slot) - } - best := choice.AddVotes([]VoteKey{{Pubkey: hsVoter(0), Key: hsKey(6)}}, flatStake(100)) - if best.Slot != 6 { - t.Fatalf("after switch to 6: best = %d, want 6", best.Slot) - } - if st, _ := choice.StakeVotedSubtree(hsKey(2)); st != 0 { - t.Fatalf("old fork subtree(2) must drop to 0, got %d", st) - } - if st, _ := choice.StakeVotedAt(hsKey(6)); st != 100 { - t.Fatalf("at(6) = %d, want 100", st) - } - // stale (older-slot) vote from the same validator must be ignored - choice.AddVotes([]VoteKey{{Pubkey: hsVoter(0), Key: hsKey(4)}}, flatStake(100)) - if best := choice.BestOverallSlot(); best.Slot != 6 { - t.Fatalf("stale vote must not move stake back: best = %d, want 6", best.Slot) - } -} - -// Ported from the aggregate_slot doc scenario: marking a heavy fork invalid -// excludes it from best-slot selection, but its stake still counts toward shared -// ancestors' subtree weight. -func TestHSFCMarkInvalidExcludesButKeepsWeight(t *testing.T) { - choice := setupForks() - choice.AddVotes([]VoteKey{ - {Pubkey: hsVoter(0), Key: hsKey(4)}, // 66-stake fork - {Pubkey: hsVoter(1), Key: hsKey(4)}, - {Pubkey: hsVoter(2), Key: hsKey(3)}, // 34-stake fork - }, flatStake(33)) - - if best := choice.BestOverallSlot(); best.Slot != 4 { - t.Fatalf("pre-mark best = %d, want 4", best.Slot) - } - choice.MarkForkInvalidCandidate(hsKey(4)) // 4 is an unconfirmed duplicate - // Agave-documented behavior: stay on the HEAVIEST fork, halting at the last - // valid ancestor of the duplicate (2) — do NOT jump to the lighter fork (3). - if best := choice.BestOverallSlot(); best.Slot != 2 { - t.Fatalf("post-mark best = %d, want 2 (heaviest fork's last valid ancestor)", best.Slot) - } - // weight of 4 still backs its ancestors (the doc's slot-2-vs-slot-3 argument) - if st, _ := choice.StakeVotedSubtree(hsKey(2)); st != 66 { - t.Fatalf("subtree(2) must keep the invalid fork's weight, got %d", st) - } - if cand, _ := choice.IsCandidate(hsKey(4)); cand { - t.Fatal("4 must not be a candidate while an unconfirmed duplicate") - } - - // duplicate-confirmation re-admits the fork - choice.MarkForkValidCandidate(hsKey(4)) - if best := choice.BestOverallSlot(); best.Slot != 4 { - t.Fatalf("post-confirm best = %d, want 4", best.Slot) - } - if dup, _ := choice.IsDuplicateConfirmed(hsKey(4)); !dup { - t.Fatal("4 must be duplicate-confirmed after valid marking") - } -} - -// Invalid marking propagates to descendants (latest_invalid_ancestor), and the -// mark reaches the marked node itself (== own slot). -func TestHSFCInvalidAncestorPropagation(t *testing.T) { - choice := setupForks() - choice.MarkForkInvalidCandidate(hsKey(3)) - for _, s := range []uint64{3, 5, 6} { - if inv, ok := choice.LatestInvalidAncestor(hsKey(s)); !ok || inv != 3 { - t.Fatalf("slot %d latest_invalid_ancestor = (%d,%v), want (3,true)", s, inv, ok) - } - } - if _, ok := choice.LatestInvalidAncestor(hsKey(2)); ok { - t.Fatal("other fork must not inherit the invalid mark") - } - // leaves added under an invalid fork inherit the mark - p := hsKey(6) - choice.AddNewLeafSlot(hsKey(7), &p) - if inv, ok := choice.LatestInvalidAncestor(hsKey(7)); !ok || inv != 3 { - t.Fatalf("new leaf under invalid fork must inherit: (%d,%v)", inv, ok) - } -} - -// Ported test_add_votes_duplicate_tie: two versions of the same slot with equal -// stake — fork choice picks the smaller (slot, hash) key. -func TestHSFCDuplicateTieBreak(t *testing.T) { - choice := setupForks() - p := hsKey(4) - dupA := hsKeyH(10, 0x0A) // duplicate versions of slot 10 under 4 - dupB := hsKeyH(10, 0x0B) - choice.AddNewLeafSlot(dupA, &p) - choice.AddNewLeafSlot(dupB, &p) - - best := choice.AddVotes([]VoteKey{ - {Pubkey: hsVoter(0), Key: dupA}, - {Pubkey: hsVoter(1), Key: dupB}, - }, flatStake(10)) - if best != dupA { - t.Fatalf("tie between duplicate versions must pick the smaller hash: got %+v", best) - } -} - -// Same-slot revote: only a SMALLER hash replaces a validator's existing vote -// (duplicate-version correction); a larger hash is ignored. Ported from -// test_add_votes_duplicate_greater_hash_ignored / smaller_hash_prioritized. -func TestHSFCDuplicateSameSlotRevote(t *testing.T) { - choice := setupForks() - p := hsKey(4) - dupA := hsKeyH(10, 0x0A) - dupB := hsKeyH(10, 0x0B) - choice.AddNewLeafSlot(dupA, &p) - choice.AddNewLeafSlot(dupB, &p) - - choice.AddVotes([]VoteKey{{Pubkey: hsVoter(0), Key: dupB}}, flatStake(10)) - if st, _ := choice.StakeVotedAt(dupB); st != 10 { - t.Fatalf("at(dupB) = %d, want 10", st) - } - // same slot, smaller hash: replaces - choice.AddVotes([]VoteKey{{Pubkey: hsVoter(0), Key: dupA}}, flatStake(10)) - if st, _ := choice.StakeVotedAt(dupA); st != 10 { - t.Fatalf("smaller-hash revote must land: at(dupA) = %d, want 10", st) - } - if st, _ := choice.StakeVotedAt(dupB); st != 0 { - t.Fatalf("smaller-hash revote must subtract old: at(dupB) = %d, want 0", st) - } - // same slot, larger hash again: ignored - choice.AddVotes([]VoteKey{{Pubkey: hsVoter(0), Key: dupB}}, flatStake(10)) - if st, _ := choice.StakeVotedAt(dupA); st != 10 { - t.Fatalf("larger-hash revote must be ignored: at(dupA) = %d, want 10", st) - } -} - -// Ported test_set_root behavior: everything not descending from the new root is -// dropped; votes below the root are ignored. -func TestHSFCSetTreeRoot(t *testing.T) { - choice := setupForks() - choice.AddVotes([]VoteKey{{Pubkey: hsVoter(0), Key: hsKey(5)}}, flatStake(100)) - choice.SetTreeRoot(hsKey(3)) - if choice.ContainsBlock(hsKey(0)) || choice.ContainsBlock(hsKey(2)) || choice.ContainsBlock(hsKey(4)) { - t.Fatal("non-descendants of the new root must be dropped") - } - if !choice.ContainsBlock(hsKey(3)) || !choice.ContainsBlock(hsKey(6)) { - t.Fatal("the new root's subtree must survive") - } - if choice.TreeRoot() != hsKey(3) { - t.Fatalf("tree root = %+v, want slot 3", choice.TreeRoot()) - } - // a vote below the root is a no-op - before, _ := choice.StakeVotedSubtree(hsKey(3)) - choice.AddVotes([]VoteKey{{Pubkey: hsVoter(9), Key: hsKey(1)}}, flatStake(100)) - after, _ := choice.StakeVotedSubtree(hsKey(3)) - if before != after { - t.Fatal("votes below the tree root must be ignored") - } - if best := choice.BestOverallSlot(); best.Slot != 6 { - t.Fatalf("best after re-root = %d, want 6", best.Slot) - } -} - -// Ported propagate_new_leaf: a new leaf on the best fork becomes the new best. -func TestHSFCPropagateNewLeaf(t *testing.T) { - choice := setupForks() - choice.AddVotes([]VoteKey{{Pubkey: hsVoter(0), Key: hsKey(4)}}, flatStake(100)) - p := hsKey(4) - choice.AddNewLeafSlot(hsKey(8), &p) - if best := choice.BestOverallSlot(); best.Slot != 8 { - t.Fatalf("new leaf on best fork must become best: %d, want 8", best.Slot) - } - // a leaf on the lighter fork must NOT displace the best - p6 := hsKey(6) - choice.AddNewLeafSlot(hsKey(9), &p6) - if best := choice.BestOverallSlot(); best.Slot != 8 { - t.Fatalf("leaf on lighter fork must not become best: %d, want 8", best.Slot) - } - // deepest DOES follow the taller fork regardless of stake - if deepest := choice.DeepestOverallSlot(); deepest.Slot != 9 { - t.Fatalf("deepest = %d, want 9", deepest.Slot) - } -} - -// Duplicate-confirming a node confirms its ancestors (returned newly-confirmed -// set) and clears their invalid marks. -func TestHSFCValidCandidateConfirmsAncestors(t *testing.T) { - choice := setupForks() - newly := choice.MarkForkValidCandidate(hsKey(5)) - // 5, 3, 1 were unconfirmed (0 is confirmed as root) - want := map[uint64]bool{5: true, 3: true, 1: true} - if len(newly) != 3 { - t.Fatalf("newly confirmed = %v, want slots 5,3,1", newly) - } - for _, k := range newly { - if !want[k.Slot] { - t.Fatalf("unexpected newly-confirmed slot %d", k.Slot) - } - } - for s := range want { - if dup, _ := choice.IsDuplicateConfirmed(hsKey(s)); !dup { - t.Fatalf("slot %d must be duplicate-confirmed", s) - } - } - if dup, _ := choice.IsDuplicateConfirmed(hsKey(6)); dup { - t.Fatal("descendant 6 must NOT be confirmed by ancestor confirmation") - } -} diff --git a/pkg/forkchoice/skip_path.go b/pkg/forkchoice/skip_path.go deleted file mode 100644 index f0944483e..000000000 --- a/pkg/forkchoice/skip_path.go +++ /dev/null @@ -1,93 +0,0 @@ -package forkchoice - -import ( - "errors" - "fmt" - - "github.com/gagliardetto/solana-go" -) - -var ( - ErrDepthExceeded = errors.New("skip path: depth exceeded max allowed") - ErrNoPath = errors.New("skip path: no valid path found to target slot") - ErrPathIncomplete = errors.New("skip path: path is incomplete and needs more observations") - ErrEquivocation = errors.New("skip path: observed conflicting blocks for the same slot") -) - -// ObservedBlockMeta is the pre-execution PoH metadata we know about a block. -type ObservedBlockMeta struct { - Slot uint64 - Blockhash solana.Hash - ParentSlot uint64 - ParentSlotKnown bool - ParentBlockhash solana.Hash -} - -// SolveResult contains the resolved block/skip path. -// Path[i] corresponds to slot (anchorSlot + 1 + i): -// -// true -> use the block at that slot -// false -> slot is empty/skipped -type SolveResult struct { - Path []bool - MatchedSlot uint64 -} - -// ResolvePohPath walks backwards from a confirmed leaf slot to a known anchor -// slot using observed PoH parent links. Slots not present on the leaf's ancestry -// are treated as skipped. -func ResolvePohPath( - anchorSlot uint64, - leafSlot uint64, - observed map[uint64]*ObservedBlockMeta, - equivocatedSlots map[uint64]struct{}, - maxDepth int, -) (*SolveResult, error) { - if leafSlot < anchorSlot { - return nil, fmt.Errorf("skip path: leafSlot %d < anchorSlot %d", leafSlot, anchorSlot) - } - - depth := leafSlot - anchorSlot - if int(depth) > maxDepth { - return nil, ErrDepthExceeded - } - if depth == 0 { - return &SolveResult{MatchedSlot: anchorSlot}, nil - } - - path := make([]bool, depth) - current := leafSlot - visited := make(map[uint64]struct{}) - - for current > anchorSlot { - if _, exists := equivocatedSlots[current]; exists { - return nil, ErrEquivocation - } - if _, seen := visited[current]; seen { - return nil, ErrNoPath - } - visited[current] = struct{}{} - - meta, exists := observed[current] - if !exists { - return nil, ErrPathIncomplete - } - if !meta.ParentSlotKnown { - return nil, ErrPathIncomplete - } - if meta.ParentSlot >= current { - return nil, ErrNoPath - } - if meta.ParentSlot < anchorSlot { - return nil, ErrNoPath - } - - path[current-anchorSlot-1] = true - current = meta.ParentSlot - } - - return &SolveResult{ - Path: path, - MatchedSlot: leafSlot, - }, nil -} diff --git a/pkg/forkchoice/skip_path_test.go b/pkg/forkchoice/skip_path_test.go deleted file mode 100644 index 74b521e7b..000000000 --- a/pkg/forkchoice/skip_path_test.go +++ /dev/null @@ -1,71 +0,0 @@ -package forkchoice - -import ( - "testing" - - "github.com/gagliardetto/solana-go" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func testHash(b byte) solana.Hash { - return solana.Hash{b} -} - -func TestResolvePohPathSingleBlock(t *testing.T) { - observed := map[uint64]*ObservedBlockMeta{ - 10: {Slot: 10, ParentSlot: 9, ParentSlotKnown: true, Blockhash: testHash(0x10)}, - } - - result, err := ResolvePohPath(9, 10, observed, nil, 64) - require.NoError(t, err) - assert.Equal(t, []bool{true}, result.Path) - assert.Equal(t, uint64(10), result.MatchedSlot) -} - -func TestResolvePohPathMultipleBlocksWithSkips(t *testing.T) { - observed := map[uint64]*ObservedBlockMeta{ - 11: {Slot: 11, ParentSlot: 9, ParentSlotKnown: true, Blockhash: testHash(0x11)}, - 13: {Slot: 13, ParentSlot: 11, ParentSlotKnown: true, Blockhash: testHash(0x13)}, - } - - result, err := ResolvePohPath(9, 13, observed, nil, 64) - require.NoError(t, err) - assert.Equal(t, []bool{false, true, false, true}, result.Path) - assert.Equal(t, uint64(13), result.MatchedSlot) -} - -func TestResolvePohPathMissingObservation(t *testing.T) { - _, err := ResolvePohPath(9, 13, map[uint64]*ObservedBlockMeta{}, nil, 64) - assert.ErrorIs(t, err, ErrPathIncomplete) -} - -func TestResolvePohPathUnknownParent(t *testing.T) { - observed := map[uint64]*ObservedBlockMeta{ - 13: {Slot: 13, ParentSlotKnown: false, ParentBlockhash: testHash(0x11), Blockhash: testHash(0x13)}, - } - - _, err := ResolvePohPath(9, 13, observed, nil, 64) - assert.ErrorIs(t, err, ErrPathIncomplete) -} - -func TestResolvePohPathDepthExceeded(t *testing.T) { - _, err := ResolvePohPath(0, 100, nil, nil, 64) - assert.ErrorIs(t, err, ErrDepthExceeded) -} - -func TestResolvePohPathEquivocation(t *testing.T) { - observed := map[uint64]*ObservedBlockMeta{ - 10: {Slot: 10, ParentSlot: 9, ParentSlotKnown: true, Blockhash: testHash(0x10)}, - } - equivocated := map[uint64]struct{}{10: {}} - - _, err := ResolvePohPath(9, 10, observed, equivocated, 64) - assert.ErrorIs(t, err, ErrEquivocation) -} - -func TestResolvePohPathEndBeforeAnchor(t *testing.T) { - _, err := ResolvePohPath(10, 5, nil, nil, 64) - assert.Error(t, err) - assert.Contains(t, err.Error(), "leafSlot 5 < anchorSlot 10") -} diff --git a/pkg/forkchoice/vote_parser.go b/pkg/forkchoice/vote_parser.go deleted file mode 100644 index a0a93abb0..000000000 --- a/pkg/forkchoice/vote_parser.go +++ /dev/null @@ -1,283 +0,0 @@ -package forkchoice - -import ( - "github.com/Overclock-Validator/mithril/pkg/epochstakes" - "github.com/Overclock-Validator/mithril/pkg/sealevel" - bin "github.com/gagliardetto/binary" - "github.com/gagliardetto/solana-go" - "github.com/gammazero/deque" -) - -type voteInfo struct { - slot uint64 - bankHash [32]byte - votePubkey solana.PublicKey - rootSlot *uint64 // validator's explicit tower root (nil for legacy votes); the cluster-finality signal -} - -// parseAndValidateVoteTx validates a vote transaction against the given authorized -// voters cache. Accepts the cache as a parameter to avoid racing with epoch updates. -func parseAndValidateVoteTx(tx *solana.Transaction, authorizedVoters *epochstakes.EpochAuthorizedVotersCache) (*voteInfo, bool) { - if len(tx.Message.Instructions) == 0 { - return nil, false - } - - for _, instr := range tx.Message.Instructions { - programID, err := tx.ResolveProgramIDIndex(instr.ProgramIDIndex) - if err != nil || !programID.Equals(solana.VoteProgramID) { - continue - } - return parseAndValidateVoteInstruction(tx, instr, authorizedVoters) - } - - return nil, false -} - -func parseAndValidateVoteInstruction(tx *solana.Transaction, instr solana.CompiledInstruction, authorizedVoters *epochstakes.EpochAuthorizedVotersCache) (info *voteInfo, ok bool) { - defer func() { - if recover() != nil { - info = nil - ok = false - } - }() - - if len(instr.Accounts) < 1 { - return nil, false - } - votePubkey, err := tx.Message.Account(instr.Accounts[0]) - if err != nil { - return nil, false - } - - if !hasAuthorizedVoteSigner(tx, instr, votePubkey, authorizedVoters) { - return nil, false - } - - instrData := instr.Data - decoder := bin.NewBinDecoder(instrData) - instructionType, err := decoder.ReadUint32(bin.LE) - if err != nil { - return nil, false - } - - switch instructionType { - case sealevel.VoteProgramInstrTypeTowerSync: - { - var vote sealevel.VoteInstrTowerSync - err = vote.UnmarshalWithDecoder(decoder) - if err != nil { - return nil, false - } - lockout, ok := getLastLockout(&vote.Lockouts) - if !ok { - return nil, false - } - - return &voteInfo{slot: lockout.Slot, - bankHash: vote.Hash, - votePubkey: votePubkey, - rootSlot: vote.Root}, true - } - - case sealevel.VoteProgramInstrTypeTowerSyncSwitch: - { - var vote sealevel.VoteInstrTowerSyncSwitch - err = vote.UnmarshalWithDecoder(decoder) - if err != nil { - return nil, false - } - lockout, ok := getLastLockout(&vote.TowerSync.Lockouts) - if !ok { - return nil, false - } - return &voteInfo{slot: lockout.Slot, - bankHash: vote.TowerSync.Hash, - votePubkey: votePubkey, - rootSlot: vote.TowerSync.Root}, true - } - - case sealevel.VoteProgramInstrTypeVote: - { - if !hasLegacyVoteSysvarAccounts(tx, instr) { - return nil, false - } - var vote sealevel.VoteInstrVote - err = vote.UnmarshalWithDecoder(decoder) - if err != nil { - return nil, false - } - - slot, ok := getSlot(vote.Slots) - if !ok { - return nil, false - } - - return &voteInfo{slot: slot, - bankHash: vote.Hash, - votePubkey: votePubkey}, true - } - - case sealevel.VoteProgramInstrTypeVoteSwitch: - { - if !hasLegacyVoteSysvarAccounts(tx, instr) { - return nil, false - } - var vote sealevel.VoteInstrVoteSwitch - err = vote.UnmarshalWithDecoder(decoder) - if err != nil { - return nil, false - } - - slot, ok := getSlot(vote.Vote.Slots) - if !ok { - return nil, false - } - - return &voteInfo{slot: slot, - bankHash: vote.Vote.Hash, - votePubkey: votePubkey}, true - } - - case sealevel.VoteProgramInstrTypeUpdateVoteState: - { - var vote sealevel.VoteInstrUpdateVoteState - err = vote.UnmarshalWithDecoder(decoder) - if err != nil { - return nil, false - } - - lockout, ok := getLastLockout(&vote.Lockouts) - if !ok { - return nil, false - } - return &voteInfo{slot: lockout.Slot, - bankHash: vote.Hash, - votePubkey: votePubkey, - rootSlot: vote.Root}, true - } - - case sealevel.VoteProgramInstrTypeUpdateVoteStateSwitch: - { - var vote sealevel.VoteInstrUpdateVoteStateSwitch - err = vote.UnmarshalWithDecoder(decoder) - if err != nil { - return nil, false - } - - lockout, ok := getLastLockout(&vote.UpdateVoteState.Lockouts) - if !ok { - return nil, false - } - return &voteInfo{slot: lockout.Slot, - bankHash: vote.UpdateVoteState.Hash, - votePubkey: votePubkey, - rootSlot: vote.UpdateVoteState.Root}, true - } - - case sealevel.VoteProgramInstrTypeCompactUpdateVoteState: - { - var vote sealevel.VoteInstrCompactUpdateVoteState - err = vote.UnmarshalWithDecoder(decoder) - if err != nil { - return nil, false - } - - lockout, ok := getLastLockout(&vote.UpdateVoteState.Lockouts) - if !ok { - return nil, false - } - return &voteInfo{slot: lockout.Slot, - bankHash: vote.UpdateVoteState.Hash, - votePubkey: votePubkey, - rootSlot: vote.UpdateVoteState.Root}, true - } - - case sealevel.VoteProgramInstrTypeCompactUpdateVoteStateSwitch: - { - var vote sealevel.VoteInstrCompactUpdateVoteStateSwitch - err = vote.UnmarshalWithDecoder(decoder) - if err != nil { - return nil, false - } - - lockout, ok := getLastLockout(&vote.UpdateVoteState.Lockouts) - if !ok { - return nil, false - } - return &voteInfo{slot: lockout.Slot, - bankHash: vote.UpdateVoteState.Hash, - votePubkey: votePubkey, - rootSlot: vote.UpdateVoteState.Root}, true - } - - default: - { - return nil, false - } - } -} - -func hasAuthorizedVoteSigner(tx *solana.Transaction, instr solana.CompiledInstruction, votePubkey solana.PublicKey, authorizedVoters *epochstakes.EpochAuthorizedVotersCache) bool { - if authorizedVoters == nil { - return false - } - - // The Vote program validates authority from the instruction's signer set; - // for common vote instructions, account 1 is a sysvar rather than the voter. - numSigners := voteTransactionSignerCount(tx) - if numSigners > len(tx.Message.AccountKeys) { - numSigners = len(tx.Message.AccountKeys) - } - for _, accountIndex := range instr.Accounts { - if int(accountIndex) >= numSigners { - continue - } - if authorizedVoters.IsAuthorizedVoter(votePubkey, tx.Message.AccountKeys[accountIndex]) { - return true - } - } - return false -} - -func voteTransactionSignerCount(tx *solana.Transaction) int { - numSigners := int(tx.Message.Header.NumRequiredSignatures) - if numSigners == 0 && len(tx.Signatures) > 0 { - numSigners = len(tx.Signatures) - } - return numSigners -} - -func hasLegacyVoteSysvarAccounts(tx *solana.Transaction, instr solana.CompiledInstruction) bool { - if len(instr.Accounts) < 3 { - return false - } - slotHashes, err := tx.Message.Account(instr.Accounts[1]) - if err != nil || slotHashes != solana.SysVarSlotHashesPubkey { - return false - } - clock, err := tx.Message.Account(instr.Accounts[2]) - return err == nil && clock == solana.SysVarClockPubkey -} - -func getLastLockout(lockouts *deque.Deque[sealevel.VoteLockout]) (*sealevel.VoteLockout, bool) { - lockoutsLen := lockouts.Len() - if lockoutsLen == 0 { - return nil, false - } - - lockout := lockouts.PopBack() - return &lockout, true -} - -func getSlot(slots []uint64) (uint64, bool) { - if len(slots) == 0 { - return 0, false - } - maxSlot := slots[0] - for _, slot := range slots { - if slot > maxSlot { - maxSlot = slot - } - } - return maxSlot, true -} diff --git a/pkg/forkchoice/vote_stake_accumulator.go b/pkg/forkchoice/vote_stake_accumulator.go deleted file mode 100644 index 8ab285182..000000000 --- a/pkg/forkchoice/vote_stake_accumulator.go +++ /dev/null @@ -1,154 +0,0 @@ -package forkchoice - -import ( - "sort" - - "github.com/gagliardetto/solana-go" -) - -// VoteThresholdSize matches Agave's VOTE_THRESHOLD_SIZE = 2f64 / 3f64. -// See: agave/runtime/src/commitment.rs:9 -const VoteThresholdSize = 2.0 / 3.0 - -// computeThresholdStake computes the threshold using Agave's exact formula: -// -// threshold_stake = (total_stake as f64 * threshold) as u64 -// -// Supermajority is reached when accumulated stake exceeds this value. -// See: agave/core/src/consensus/vote_stake_tracker.rs:30 -func computeThresholdStake(totalStake uint64) uint64 { - return uint64(float64(totalStake) * VoteThresholdSize) -} - -// voteStakeTracker tracks per-pubkey vote stake for a single (slot, hash) pair. -// Equivalent to Agave's VoteStakeTracker. -// See: agave/core/src/consensus/vote_stake_tracker.rs -type voteStakeTracker struct { - voted map[solana.PublicKey]struct{} - stake uint64 -} - -// slotVoteAccumulator tracks all hash trackers for a single slot. -// Equivalent to Agave's SlotVoteTracker.optimistic_votes_tracker. -// See: agave/core/src/cluster_info_vote_listener.rs:75 -type slotVoteAccumulator struct { - trackers map[solana.Hash]*voteStakeTracker - voterToHash map[solana.PublicKey]solana.Hash - totalEpochStake uint64 - thresholdStake uint64 - slot uint64 - confirmed bool - confirmedHash solana.Hash -} - -func newSlotVoteAccumulator(totalEpochStake uint64, slot uint64) *slotVoteAccumulator { - return &slotVoteAccumulator{ - trackers: make(map[solana.Hash]*voteStakeTracker), - voterToHash: make(map[solana.PublicKey]solana.Hash), - totalEpochStake: totalEpochStake, - thresholdStake: computeThresholdStake(totalEpochStake), - slot: slot, - } -} - -// addVote records a vote for the given hash by votePubkey with the given stake. -// Returns (thresholdCrossed, isNew). -// -// thresholdCrossed is true only on the exact vote that causes the hash to cross -// the 2/3 threshold. Uses Agave crossing semantics: - -// See: agave/core/src/consensus/vote_stake_tracker.rs:14-37 -func (acc *slotVoteAccumulator) addVote(hash solana.Hash, votePubkey solana.PublicKey, stake uint64) (thresholdCrossed bool, isNew bool) { - if _, exists := acc.voterToHash[votePubkey]; exists { - return false, false - } - - tracker, exists := acc.trackers[hash] - if !exists { - tracker = &voteStakeTracker{ - voted: make(map[solana.PublicKey]struct{}), - } - acc.trackers[hash] = tracker - } - - if _, alreadyVoted := tracker.voted[votePubkey]; alreadyVoted { - return false, false - } - - acc.voterToHash[votePubkey] = hash - tracker.voted[votePubkey] = struct{}{} - oldStake := tracker.stake - newStake := oldStake + stake - tracker.stake = newStake - - crossed := oldStake <= acc.thresholdStake && acc.thresholdStake < newStake - if crossed && !acc.confirmed { - acc.confirmed = true - acc.confirmedHash = hash - } - - return crossed, true -} - -// hasSupermajority returns true if any hash for this slot has reached 2/3 supermajority. -func (acc *slotVoteAccumulator) hasSupermajority() bool { - return acc.confirmed -} - -// hashHasSupermajority returns true if the given specific hash has crossed the 2/3 threshold. -func (acc *slotVoteAccumulator) hashHasSupermajority(hash solana.Hash) bool { - tracker, exists := acc.trackers[hash] - if !exists { - return false - } - return tracker.stake > acc.thresholdStake -} - -// winningHash returns the hash that crossed the threshold, if any. -func (acc *slotVoteAccumulator) winningHash() (solana.Hash, bool) { - if !acc.confirmed { - return solana.Hash{}, false - } - return acc.confirmedHash, true -} - -// stakeForHash returns the accumulated stake for a specific hash. -func (acc *slotVoteAccumulator) stakeForHash(hash solana.Hash) uint64 { - tracker, exists := acc.trackers[hash] - if !exists { - return 0 - } - return tracker.stake -} - -// highestRootedSlot returns the highest slot with >2/3 of epoch stake explicitly -// rooted past it (0, false if none). roots: voter→latest root; stakes: voter→stake. -func highestRootedSlot(roots map[solana.PublicKey]uint64, stakes map[solana.PublicKey]uint64, totalStake uint64) (uint64, bool) { - threshold := computeThresholdStake(totalStake) // reuse Agave's 2/3 rule - - // A validator with root r counts toward every S <= r. Sorting roots descending - // and accumulating stake, the first root whose running total exceeds the - // threshold is the highest S a supermajority has rooted past. - type rootStake struct { - root uint64 - stake uint64 - } - entries := make([]rootStake, 0, len(roots)) - for voter, r := range roots { - s, ok := stakes[voter] - if !ok || s == 0 { - continue - } - entries = append(entries, rootStake{root: r, stake: s}) - } - sort.Slice(entries, func(i, j int) bool { return entries[i].root > entries[j].root }) - - var cumulative uint64 - for _, e := range entries { - cumulative += e.stake - if cumulative > threshold { - return e.root, true - } - } - return 0, false -} diff --git a/pkg/forkchoice/vote_stake_accumulator_test.go b/pkg/forkchoice/vote_stake_accumulator_test.go deleted file mode 100644 index 70d8c29bc..000000000 --- a/pkg/forkchoice/vote_stake_accumulator_test.go +++ /dev/null @@ -1,129 +0,0 @@ -package forkchoice - -import ( - "testing" - - "github.com/gagliardetto/solana-go" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func makePubkey(id byte) solana.PublicKey { - var pk [32]byte - pk[0] = id - return solana.PublicKeyFromBytes(pk[:]) -} - -func TestComputeThresholdStake(t *testing.T) { - // Match Agave's formula: uint64(float64(total) * 2.0/3.0) - assert.Equal(t, uint64(6), computeThresholdStake(10)) - assert.Equal(t, uint64(66), computeThresholdStake(100)) - assert.Equal(t, uint64(0), computeThresholdStake(0)) - assert.Equal(t, uint64(0), computeThresholdStake(1)) - // total=3: int(3 * 0.666...) = int(2.0) = 2 - assert.Equal(t, uint64(2), computeThresholdStake(3)) -} - -// TestThresholdCrossingSmallTotal mirrors Agave's test_add_vote_pubkey: -// total=10, 10 voters each with stake=1, threshold crosses at i=6 (stake=7 > threshold=6). -func TestThresholdCrossingSmallTotal(t *testing.T) { - acc := newSlotVoteAccumulator(10, 42) - hash := solana.Hash{1} - - for i := 0; i < 10; i++ { - pubkey := makePubkey(byte(i + 1)) - crossed, isNew := acc.addVote(hash, pubkey, 1) - assert.True(t, isNew, "vote %d should be new", i) - - // Agave: at i=6, stake goes from 6 to 7, crossing threshold of 6 - if i == 6 { - assert.True(t, crossed, "threshold should cross at i=6 (stake=7)") - } else { - assert.False(t, crossed, "threshold should NOT cross at i=%d", i) - } - } - - assert.True(t, acc.hasSupermajority()) - assert.True(t, acc.hashHasSupermajority(hash)) - assert.Equal(t, uint64(10), acc.stakeForHash(hash)) -} - -func TestDuplicateVotePubkeyDoesNotIncreaseStake(t *testing.T) { - acc := newSlotVoteAccumulator(10, 42) - hash := solana.Hash{1} - pubkey := makePubkey(1) - - _, isNew1 := acc.addVote(hash, pubkey, 5) - assert.True(t, isNew1) - assert.Equal(t, uint64(5), acc.stakeForHash(hash)) - - crossed2, isNew2 := acc.addVote(hash, pubkey, 5) - assert.False(t, isNew2, "duplicate vote should not be new") - assert.False(t, crossed2, "duplicate vote should not cross threshold") - assert.Equal(t, uint64(5), acc.stakeForHash(hash), "stake should not increase on duplicate") -} - -func TestTwoHashesSameSlot(t *testing.T) { - acc := newSlotVoteAccumulator(10, 42) - hashA := solana.Hash{0xAA} - hashB := solana.Hash{0xBB} - - // 7 voters for hashA - for i := 0; i < 7; i++ { - acc.addVote(hashA, makePubkey(byte(i+1)), 1) - } - // 3 voters for hashB - for i := 0; i < 3; i++ { - acc.addVote(hashB, makePubkey(byte(i+100)), 1) - } - - assert.True(t, acc.hashHasSupermajority(hashA), "hashA should have supermajority") - assert.False(t, acc.hashHasSupermajority(hashB), "hashB should NOT have supermajority") - - winning, ok := acc.winningHash() - require.True(t, ok) - assert.Equal(t, hashA, winning) -} - -func TestNoThresholdReached(t *testing.T) { - acc := newSlotVoteAccumulator(10, 42) - hash := solana.Hash{1} - - // Only 3 stake — not enough for threshold of 6 - for i := 0; i < 3; i++ { - acc.addVote(hash, makePubkey(byte(i+1)), 1) - } - - assert.False(t, acc.hasSupermajority()) - assert.False(t, acc.hashHasSupermajority(hash)) - _, ok := acc.winningHash() - assert.False(t, ok) -} - -// TestDedupAcrossDifferentHashes verifies the lightweight behavior used by Mithril's -// forkchoice: once a vote account has contributed stake to a slot, conflicting -// hashes from the same vote account for that slot are ignored. -func TestDedupAcrossDifferentHashes(t *testing.T) { - acc := newSlotVoteAccumulator(10, 42) - hashA := solana.Hash{0xAA} - hashB := solana.Hash{0xBB} - pubkey := makePubkey(1) - - _, isNew1 := acc.addVote(hashA, pubkey, 5) - _, isNew2 := acc.addVote(hashB, pubkey, 5) - - assert.True(t, isNew1) - assert.False(t, isNew2, "same pubkey voting for different hash should be ignored") - assert.Equal(t, uint64(5), acc.stakeForHash(hashA)) - assert.Equal(t, uint64(0), acc.stakeForHash(hashB)) -} - -func TestStakeForNonexistentHash(t *testing.T) { - acc := newSlotVoteAccumulator(10, 42) - assert.Equal(t, uint64(0), acc.stakeForHash(solana.Hash{0xFF})) -} - -func TestHashHasSupermajorityNonexistentHash(t *testing.T) { - acc := newSlotVoteAccumulator(10, 42) - assert.False(t, acc.hashHasSupermajority(solana.Hash{0xFF})) -} diff --git a/pkg/global/global_ctx.go b/pkg/global/global_ctx.go index 0bb03d0ab..78ce280dd 100644 --- a/pkg/global/global_ctx.go +++ b/pkg/global/global_ctx.go @@ -5,6 +5,7 @@ package global import ( "encoding/binary" "fmt" + "math" "os" "path/filepath" "runtime" @@ -14,7 +15,6 @@ import ( "github.com/Overclock-Validator/mithril/pkg/accountsdb" "github.com/Overclock-Validator/mithril/pkg/epochstakes" - "github.com/Overclock-Validator/mithril/pkg/forkchoice" "github.com/Overclock-Validator/mithril/pkg/leaderschedule" "github.com/Overclock-Validator/mithril/pkg/mlog" "github.com/Overclock-Validator/mithril/pkg/sealevel" @@ -31,14 +31,12 @@ type GlobalCtx struct { slot uint64 epoch uint64 transactionCount uint64 - pendingNewStakePubkeys []accountsdb.StakeIndexEntry // New stake entries to append to index after block commit - cachedStakeEntries []accountsdb.StakeIndexEntry // Parsed+sorted index, populated on first load - entriesFlushedSinceCompact int // Appended entries since last compaction + pendingStakeBySlot map[uint64][]accountsdb.StakeIndexEntry // New stake entries keyed by the slot that created them; flushed to the index file only when that slot FOLDS (branch-safe: unwound slots drop their entries), merged into stake scans from RAM meanwhile + cachedStakeEntries []accountsdb.StakeIndexEntry // Parsed+sorted index, populated on first load + entriesFlushedSinceCompact int // Appended entries since last compaction voteCache map[solana.PublicKey]*sealevel.VoteStateVersions epochVoteStateSnapshots map[uint64]map[solana.PublicKey]*sealevel.VoteStateVersions epochStakes *epochstakes.EpochStakesCache - epochAuthorizedVoters *epochstakes.EpochAuthorizedVotersCache - forkChoice *forkchoice.ForkChoiceService slotsConfirmed map[uint64]struct{} leaderSchedule *leaderschedule.LeaderSchedule calcUnixTimeForClockSysvar bool @@ -67,56 +65,56 @@ func SetEpoch(epoch uint64) { instance.SetEpoch(epoch) } -func SetForkChoice(forkChoice *forkchoice.ForkChoiceService) { - instance.forkChoice = forkChoice +func IncrTransactionCount(num uint64) { + instance.IncrTransactionCount(num) } -func HasForkChoice() bool { - return instance.forkChoice != nil +func SetTransactionCount(num uint64) { + instance.SetTransactionCount(num) } -func SubmitBlockToForkChoiceService(slot uint64, txs []*solana.Transaction) { - if instance.forkChoice == nil { - return +// EnqueuePendingStakePubkey records a stake pubkey created/modified while +// executing `slot`, for later append to the index file. Entries stay in RAM, +// keyed by slot, until that slot FOLDS to durable storage — so a wrong-fork +// block's entries are dropped by the unwind instead of leaking into the file +// — and are merged into stake scans from RAM in the meantime. Deduplication +// happens at scan/load time (last occurrence wins). +func EnqueuePendingStakePubkey(slot uint64, pubkey solana.PublicKey) { + instance.pendingStakeMutex.Lock() + defer instance.pendingStakeMutex.Unlock() + if instance.pendingStakeBySlot == nil { + instance.pendingStakeBySlot = make(map[uint64][]accountsdb.StakeIndexEntry) } - instance.forkChoice.SubmitBlock(slot, txs) + instance.pendingStakeBySlot[slot] = append(instance.pendingStakeBySlot[slot], accountsdb.StakeIndexEntry{Pubkey: pubkey}) } -func BankhashConfirmedForSlot(slot uint64, bankHash solana.Hash) int { - if instance.forkChoice == nil { - return 0 +// PendingStakeEntriesSnapshot returns a copy of all not-yet-flushed stake +// entries (any slot). Stake scans merge these with the file-backed index so +// the index file itself only ever needs entries for FOLDED slots. +func PendingStakeEntriesSnapshot() []accountsdb.StakeIndexEntry { + instance.pendingStakeMutex.Lock() + defer instance.pendingStakeMutex.Unlock() + var out []accountsdb.StakeIndexEntry + for _, entries := range instance.pendingStakeBySlot { + out = append(out, entries...) } - return int(instance.forkChoice.IsBankhashCorrect(slot, bankHash).Status) + return out } -func IncrTransactionCount(num uint64) { - instance.IncrTransactionCount(num) -} - -// EnqueuePendingStakePubkey records a stake pubkey for later append to the index file. -// Called during tx processing when a stake account is created or modified. -// Deduplication happens at index load time (LoadStakePubkeyIndex keeps last occurrence). -func EnqueuePendingStakePubkey(pubkey solana.PublicKey) { +// DropPendingStakePubkeysFrom discards pending entries for slots >= fromSlot. +// Called by the fork-switch unwind so wrong-fork stake entries never reach the +// durable index. Returns the number of entries dropped. +func DropPendingStakePubkeysFrom(fromSlot uint64) int { instance.pendingStakeMutex.Lock() defer instance.pendingStakeMutex.Unlock() - instance.pendingNewStakePubkeys = append(instance.pendingNewStakePubkeys, accountsdb.StakeIndexEntry{Pubkey: pubkey}) -} - -func PutEpochAuthorizedVoter(voteAcct solana.PublicKey, authorizedVoter solana.PublicKey) { - if instance.epochAuthorizedVoters == nil { - instance.epochAuthorizedVoters = epochstakes.NewEpochAuthorizedVotersCache() + dropped := 0 + for slot, entries := range instance.pendingStakeBySlot { + if slot >= fromSlot { + dropped += len(entries) + delete(instance.pendingStakeBySlot, slot) + } } - instance.epochAuthorizedVoters.PutEntry(voteAcct, authorizedVoter) -} - -func EpochAuthorizedVoters() *epochstakes.EpochAuthorizedVotersCache { - return instance.epochAuthorizedVoters -} - -// SetEpochAuthorizedVoters replaces the entire authorized voters cache. -// Called at epoch boundaries after rebuilding from vote accounts. -func SetEpochAuthorizedVoters(cache *epochstakes.EpochAuthorizedVotersCache) { - instance.epochAuthorizedVoters = cache + return dropped } func PutSlotConfirmed(slot uint64) { @@ -364,6 +362,15 @@ func (globctx *GlobalCtx) IncrTransactionCount(num uint64) { globctx.transactionCount += num } +// SetTransactionCount overwrites the running transaction count — used to seed it +// from a resume checkpoint and to restore it after an in-loop fork-switch unwind +// (so a discarded fork's transactions do not linger in the count). +func (globctx *GlobalCtx) SetTransactionCount(num uint64) { + globctx.mu.Lock() + defer globctx.mu.Unlock() + globctx.transactionCount = num +} + func (globctx *GlobalCtx) LatestBlockhash() [32]byte { globctx.mu.Lock() defer globctx.mu.Unlock() @@ -394,20 +401,35 @@ func (globctx *GlobalCtx) TransactionCount() uint64 { return globctx.transactionCount } -// FlushPendingStakePubkeys appends any new stake entries discovered during replay -// to the stake pubkey index file. Called after each block commit. -// Writes 48-byte records to match the header written at snapshot time. -// Returns the number of entries flushed. +// FlushPendingStakePubkeys appends ALL pending stake entries to the index +// file regardless of slot. Only correct where forks are impossible (legacy / +// verify replay modes); the rooted-durable live path must use +// FlushPendingStakePubkeysThrough at fold time instead. func FlushPendingStakePubkeys(accountsDbDir string) (int, error) { + return FlushPendingStakePubkeysThrough(accountsDbDir, math.MaxUint64) +} + +// FlushPendingStakePubkeysThrough appends pending stake entries for slots <= +// through to the index file and fsyncs. Called at FOLD time, BEFORE the batch +// commit that makes those slots durable: on the index side a crash can then +// only leave a harmless SUPERSET (re-executed slots re-enqueue; dedup at scan +// time), never a subset — the index feeding epoch-stakes enumeration must +// never miss a folded slot's stake accounts. Entries for slots > through stay +// in RAM (merged into scans) until their own fold or unwind decides them. +// Returns the number of entries flushed. +func FlushPendingStakePubkeysThrough(accountsDbDir string, through uint64) (int, error) { instance.pendingStakeMutex.Lock() - if len(instance.pendingNewStakePubkeys) == 0 { + var pending []accountsdb.StakeIndexEntry + for slot, entries := range instance.pendingStakeBySlot { + if slot <= through { + pending = append(pending, entries...) + delete(instance.pendingStakeBySlot, slot) + } + } + if len(pending) == 0 { instance.pendingStakeMutex.Unlock() return 0, nil } - // Copy pending slice, clear it, and invalidate cache while holding lock - pending := make([]accountsdb.StakeIndexEntry, len(instance.pendingNewStakePubkeys)) - copy(pending, instance.pendingNewStakePubkeys) - instance.pendingNewStakePubkeys = nil instance.cachedStakeEntries = nil // file is changing, invalidate cache instance.pendingStakeMutex.Unlock() @@ -461,7 +483,7 @@ func FlushPendingStakePubkeys(accountsDbDir string) (int, error) { func ClearPendingStakePubkeys() { instance.pendingStakeMutex.Lock() defer instance.pendingStakeMutex.Unlock() - instance.pendingNewStakePubkeys = nil + instance.pendingStakeBySlot = nil } // compactThreshold is the minimum number of appended entries before compaction triggers. @@ -621,6 +643,33 @@ func StreamStakeAccounts( if err != nil { return 0, fmt.Errorf("loading stake pubkey index: %w", err) } + // Merge stake accounts created by not-yet-folded slots: their entries live + // only in RAM (flushed to the file at fold time; dropped on unwind), so the + // scan must union them in for completeness at epoch boundaries. File + // entries win on duplicates — they carry location hints; the entry is just + // a pointer, the delegation itself is read from the account. + if pending := PendingStakeEntriesSnapshot(); len(pending) > 0 { + inFile := make(map[solana.PublicKey]struct{}, len(stakeEntries)) + for _, e := range stakeEntries { + inFile[e.Pubkey] = struct{}{} + } + merged := stakeEntries + appended := false + for _, e := range pending { + if _, dup := inFile[e.Pubkey]; dup { + continue + } + if !appended { + // Copy-on-write: LoadStakePubkeyIndex's slice is shared/cached. + merged = append(append([]accountsdb.StakeIndexEntry(nil), stakeEntries...), e) + appended = true + } else { + merged = append(merged, e) + } + inFile[e.Pubkey] = struct{}{} + } + stakeEntries = merged + } var processedCount atomic.Int64 var getAccountErrors atomic.Int64 diff --git a/pkg/replay/alpenglow_engine.go b/pkg/replay/alpenglow_engine.go index df9c4e924..0f5ab5f96 100644 --- a/pkg/replay/alpenglow_engine.go +++ b/pkg/replay/alpenglow_engine.go @@ -18,19 +18,6 @@ import ( "github.com/gagliardetto/solana-go" ) -// isAlpenglowReplayMode reports whether the consensus engine runs Alpenglow -// (observer or full), which switches replay to Alpenglow clock + finality semantics. -func isAlpenglowReplayMode(consensusOpts *ConsensusOpts) bool { - if consensusOpts == nil || consensusOpts.Mode == "" { - return false - } - mode, err := consensusengine.NormalizeMode(consensusOpts.Mode) - if err != nil { - return false - } - return mode == consensusengine.ModeAlpenglowObserver || mode == consensusengine.ModeAlpenglow -} - // alpenglowRootedSlot returns the highest slot the engine has seen a finalization // certificate for — the Alpenglow finality watermark that drives promotion, the // certificate-based counterpart to TowerBFT's HighestRootedSlot. diff --git a/pkg/replay/alpenglow_engine_test.go b/pkg/replay/alpenglow_engine_test.go index 7572af75f..602948912 100644 --- a/pkg/replay/alpenglow_engine_test.go +++ b/pkg/replay/alpenglow_engine_test.go @@ -35,7 +35,7 @@ func TestAlpenglowRootedSlot(t *testing.T) { } // engine with no alpenglow chain yet - noChain := &fakeEngine{snap: consensusengine.Snapshot{Mode: consensusengine.ModeAlpenglowObserver}} + noChain := &fakeEngine{snap: consensusengine.Snapshot{Mode: "alpenglow-observer"}} if slot, ok := alpenglowRootedSlot(noChain); ok || slot != 0 { t.Fatalf("nil chain: got (%d,%v), want (0,false)", slot, ok) } @@ -56,37 +56,3 @@ func TestAlpenglowRootedSlot(t *testing.T) { t.Fatalf("finalized: got (%d,%v), want (430276100,true)", slot, ok) } } - -// isAlpenglowReplayMode gates ALL alpenglow replay behavior (clock, feature -// overrides, watermark source). Getting this wrong = mainnet bankhash divergence, -// so pin every case. -func TestIsAlpenglowReplayMode(t *testing.T) { - cases := []struct { - name string - opts *ConsensusOpts - want bool - }{ - {"nil opts", nil, false}, - {"empty mode", &ConsensusOpts{}, false}, - {"classic", &ConsensusOpts{Mode: "classic"}, false}, - {"observer", &ConsensusOpts{Mode: "alpenglow-observer"}, true}, - {"full alpenglow", &ConsensusOpts{Mode: "alpenglow"}, true}, - {"invalid mode", &ConsensusOpts{Mode: "nonsense"}, false}, - } - for _, c := range cases { - if got := isAlpenglowReplayMode(c.opts); got != c.want { - t.Errorf("%s: isAlpenglowReplayMode=%v, want %v", c.name, got, c.want) - } - } -} - -// The watermark must be classic (TowerBFT) whenever mode is not alpenglow — the -// mode-switch must never accidentally consult the alpenglow chain in classic mode. -func TestModeGatesWatermarkSource(t *testing.T) { - if isAlpenglowReplayMode(&ConsensusOpts{Mode: "classic"}) { - t.Fatal("classic mode must not select the alpenglow watermark") - } - if !isAlpenglowReplayMode(&ConsensusOpts{Mode: "alpenglow-observer"}) { - t.Fatal("observer mode must select the alpenglow watermark") - } -} diff --git a/pkg/replay/alpenglow_features.go b/pkg/replay/alpenglow_features.go index cb2dbf6fc..9b184720d 100644 --- a/pkg/replay/alpenglow_features.go +++ b/pkg/replay/alpenglow_features.go @@ -32,7 +32,3 @@ func alpenglowClockFeatureActive(f *features.Features) bool { } return f.IsActive(features.Alpenglow) || f.IsActive(features.AlpenglowDevContext) } - -func useAlpenglowClockSemantics(alpenglowReplayMode bool, f *features.Features) bool { - return alpenglowReplayMode || alpenglowClockFeatureActive(f) -} diff --git a/pkg/replay/alpenglow_features_test.go b/pkg/replay/alpenglow_features_test.go index 4370707dc..4fb69e89a 100644 --- a/pkg/replay/alpenglow_features_test.go +++ b/pkg/replay/alpenglow_features_test.go @@ -34,27 +34,6 @@ func TestAlpenglowClockFeatureRequiresAgaveFeatureGate(t *testing.T) { require.True(t, alpenglowClockFeatureActive(ft)) } -func TestAlpenglowReplayModeForcesAlpenglowClockSemantics(t *testing.T) { - ft := features.NewFeaturesDefault() - - require.False(t, alpenglowClockFeatureActive(ft)) - require.False(t, useAlpenglowClockSemantics(false, ft)) - require.True(t, useAlpenglowClockSemantics(true, ft)) -} - -// nil features must be treated as "gate inactive", never panic. func TestAlpenglowClockFeatureActiveNilFeaturesIsFalse(t *testing.T) { require.False(t, alpenglowClockFeatureActive(nil)) - require.False(t, useAlpenglowClockSemantics(false, nil)) - require.True(t, useAlpenglowClockSemantics(true, nil)) -} - -// Without replay mode, the Agave feature gate alone must switch on Alpenglow -// clock semantics. -func TestUseAlpenglowClockSemanticsFromFeatureGateWithoutReplayMode(t *testing.T) { - ft := features.NewFeaturesDefault() - - require.False(t, useAlpenglowClockSemantics(false, ft)) - ft.EnableFeature(features.Alpenglow, 42) - require.True(t, useAlpenglowClockSemantics(false, ft)) } diff --git a/pkg/replay/alpenglow_fork.go b/pkg/replay/alpenglow_fork.go deleted file mode 100644 index 58d887482..000000000 --- a/pkg/replay/alpenglow_fork.go +++ /dev/null @@ -1,35 +0,0 @@ -package replay - -import ( - "fmt" - - "github.com/Overclock-Validator/mithril/pkg/alpenglow" - "github.com/Overclock-Validator/mithril/pkg/forkchoice" -) - -// applyAlpenglowForkDecision drives the multi-branch fork engine from an Alpenglow -// chain decision. Unlike TowerBFT (which picks the heaviest subtree from vote -// weight), Alpenglow's certificate NAMES the winner: a certified block confirms -// that version — evicting any competing block executed for the same slot — while a -// conflict (two certified blocks / block+skip) halts for safety. Skip decisions -// promote nothing. Callers finalize the confirmed chain separately via OnFinalized. -func applyAlpenglowForkDecision(d *forkDriver, dec alpenglow.ChainDecision, competing []forkchoice.SlotHashKey) error { - switch dec.Kind { - case alpenglow.ChainDecisionKindBlock: - var winner forkchoice.SlotHashKey - winner.Slot = dec.Slot - copy(winner.Hash[:], dec.Block.Hash[:]) - for _, c := range competing { - if c != winner { - d.OnDuplicate(c) - } - } - d.OnDuplicateConfirmed(winner) - return nil - case alpenglow.ChainDecisionKindConflict: - return fmt.Errorf("alpenglow: unresolved conflict at slot %d (competing certified branches)", dec.Slot) - default: - // skip / unknown: no branch to promote for this slot. - return nil - } -} diff --git a/pkg/replay/alpenglow_fork_test.go b/pkg/replay/alpenglow_fork_test.go deleted file mode 100644 index 87fde720b..000000000 --- a/pkg/replay/alpenglow_fork_test.go +++ /dev/null @@ -1,115 +0,0 @@ -package replay - -import ( - "testing" - - "github.com/Overclock-Validator/mithril/pkg/alpenglow" - "github.com/Overclock-Validator/mithril/pkg/forkchoice" - "github.com/gagliardetto/solana-go" -) - -// The full Alpenglow fork-choice loop, end to end: two competing blocks for a slot -// both execute in memory (multi-branch MVCC), a real Alpenglow certificate decides -// the winner, the fork engine promotes the certified branch to durable state and -// evicts the loser — WITHOUT the loser's state ever reaching disk. This is the -// TowerBFT-equivalent "choose the correct branch" proof, driven by certs. -func TestAlpenglowForkChoiceSelectsCertifiedBranch(t *testing.T) { - driver, committer := newTestDriver() - tracker := alpenglow.NewChainTracker() - root := forkchoice.SlotHashKey{} - - // slot 1: single block off the durable root. - k1 := fdKey(1, 0xA1) - if err := driver.OnBlock(k1, root, fdExec(k1)); err != nil { - t.Fatalf("slot1: %v", err) - } - // slot 2: a FORK — two competing blocks, both executed speculatively in memory. - kWin := fdKey(2, 0xB) // the branch the certificate will name - kLose := fdKey(2, 0xA) // the losing branch - if err := driver.OnBlock(kWin, k1, fdExec(kWin)); err != nil { - t.Fatalf("slot2 winner: %v", err) - } - if err := driver.OnBlock(kLose, k1, fdExec(kLose)); err != nil { - t.Fatalf("slot2 loser: %v", err) - } - - // Real cert stream: notarize slot 1, then notarize + fast-finalize the WINNER - // block for slot 2. The certs — not any weight computation — decide the branch. - h1 := solana.Hash{0xA1} - hWin := solana.Hash{0xB} - feed := func(c alpenglow.Certificate) { - c.SignatureVerified, c.StakeVerified = true, true - if _, err := tracker.ObserveCertificate(c); err != nil { - t.Fatalf("observe cert %s slot %d: %v", c.Type, c.Slot, err) - } - } - feed(alpenglow.Certificate{Type: alpenglow.CertificateNotarize, Slot: 1, BlockHash: h1}) - feed(alpenglow.Certificate{Type: alpenglow.CertificateNotarize, Slot: 2, BlockHash: hWin}) - feed(alpenglow.Certificate{Type: alpenglow.CertificateFinalizeFast, Slot: 2, BlockHash: hWin}) - - // Drive the fork engine from the certificate decisions. - dec1, ok := tracker.NextDecision(0) - if !ok || dec1.Kind != alpenglow.ChainDecisionKindBlock || dec1.Slot != 1 { - t.Fatalf("slot1 decision = %+v (ok=%v), want block@1", dec1, ok) - } - if err := applyAlpenglowForkDecision(driver, dec1, []forkchoice.SlotHashKey{k1}); err != nil { - t.Fatalf("apply slot1: %v", err) - } - dec2, ok := tracker.NextDecision(1) - if !ok || dec2.Kind != alpenglow.ChainDecisionKindBlock || dec2.Slot != 2 { - t.Fatalf("slot2 decision = %+v (ok=%v), want block@2 (cert must name a branch, not conflict)", dec2, ok) - } - if err := applyAlpenglowForkDecision(driver, dec2, []forkchoice.SlotHashKey{kWin, kLose}); err != nil { - t.Fatalf("apply slot2: %v", err) - } - - t.Logf("slot 2 FORK: executed 2 competing branches in memory — winner=0xB, loser=0xA") - t.Logf("certificate named winner block 0xB (FinalizeFast) → fork engine selects it") - - // Finalize the certified chain through slot 2. - through, _, err := driver.OnFinalized(kWin) - if err != nil || through != 2 { - t.Fatalf("finalize: through=%d err=%v, want 2", through, err) - } - t.Logf("promoted certified branch through slot %d", through) - - // PROOF: durable slot-2 state is the WINNER's write (0xB), and the loser's - // write (0xA) never reached disk. fdExec writes lamports=version to pubkey=slot, - // so the value at pubkey{2} tells us which branch was promoted. - acct, _ := committer.durable.GetAccountWithoutLock(solana.PublicKey{2}) - if acct == nil { - t.Fatal("slot-2 state missing from durable — certified branch was not promoted") - } - if acct.Lamports != 0xB { - t.Fatalf("durable slot-2 = 0x%X, want 0xB — fork engine promoted the WRONG branch (loser=0xA)", acct.Lamports) - } - // slot 1 must also be durable (certified linear prefix). - if a, _ := committer.durable.GetAccountWithoutLock(solana.PublicKey{1}); a == nil || a.Lamports != 0xA1 { - t.Fatalf("durable slot-1 = %v, want the certified block 0xA1", a) - } - t.Logf("PROOF: durable slot-2 state = winner 0x%X; loser 0xA never reached disk ✓", acct.Lamports) -} - -// Safety: if two DIFFERENT blocks are both certified for one slot (a protocol -// safety violation), the fork engine must NOT silently promote one — it halts. -func TestAlpenglowForkChoiceHaltsOnConflictingCerts(t *testing.T) { - driver, _ := newTestDriver() - tracker := alpenglow.NewChainTracker() - - feed := func(c alpenglow.Certificate) { - c.SignatureVerified, c.StakeVerified = true, true - _, _ = tracker.ObserveCertificate(c) - } - // Two notarize certs, same slot, DIFFERENT blocks → conflict. - feed(alpenglow.Certificate{Type: alpenglow.CertificateNotarize, Slot: 5, BlockHash: solana.Hash{0xA}}) - feed(alpenglow.Certificate{Type: alpenglow.CertificateNotarize, Slot: 5, BlockHash: solana.Hash{0xB}}) - - dec, ok := tracker.NextDecision(4) - if !ok || dec.Kind != alpenglow.ChainDecisionKindConflict { - t.Fatalf("two certified blocks for slot 5 must be a conflict, got %+v (ok=%v)", dec, ok) - } - competing := []forkchoice.SlotHashKey{fdKey(5, 0xA), fdKey(5, 0xB)} - if err := applyAlpenglowForkDecision(driver, dec, competing); err == nil { - t.Fatal("conflicting certs must halt (return error), not silently promote a branch") - } -} diff --git a/pkg/replay/alpenglow_switch.go b/pkg/replay/alpenglow_switch.go new file mode 100644 index 000000000..213e170f8 --- /dev/null +++ b/pkg/replay/alpenglow_switch.go @@ -0,0 +1,189 @@ +package replay + +import ( + "fmt" + "sync/atomic" + + consensusengine "github.com/Overclock-Validator/mithril/pkg/consensus" + "github.com/Overclock-Validator/mithril/pkg/mlog" + "github.com/Overclock-Validator/mithril/pkg/rewards" + "github.com/Overclock-Validator/mithril/pkg/sealevel" + "github.com/Overclock-Validator/mithril/pkg/state" + "github.com/gagliardetto/solana-go" +) + +// voteStakeDirtySlot is the highest slot whose transaction execution mutated the +// GLOBAL vote or stake caches (see recordStakeAndVoteAccounts). The in-loop +// unwind rolls back account state (the WorkingSet undo journal) but NOT those +// process-global caches, so it is only safe when the unwound suffix did not +// touch them. When it did, the switch falls back to the rooted-checkpoint +// re-replay, which reconstructs every cache from the resumed state. Reset per +// ReplayBlocks run; a stale value only ever causes an extra (safe) fallback. +var voteStakeDirtySlot atomic.Uint64 + +// markVoteStakeDirty records that slot's execution mutated a global vote/stake +// cache (monotonic max; safe under parallel transaction execution). +func markVoteStakeDirty(slot uint64) { + for { + cur := voteStakeDirtySlot.Load() + if slot <= cur { + return + } + if voteStakeDirtySlot.CompareAndSwap(cur, slot) { + return + } + } +} + +// resetVoteStakeDirty clears the watermark at the start of a replay run. +func resetVoteStakeDirty() { voteStakeDirtySlot.Store(0) } + +// Execute-on-receipt runs blocks the moment they're assembled, which means a +// certificate can land AFTER the slot already executed and name a different +// outcome: a sibling block we lost the shred race on, or a skip over a block +// we ran. The switch sweep walks the executed-but-unfolded window whenever +// new certificates arrive and reports the FIRST contradiction between an +// executed identity and a decisive certificate. +// +// Until the WorkingSet unwind engine lands, the contradiction surfaces as a +// typed error handled by the node-level recovery loop (re-replay from the +// rooted checkpoint; repair re-fetches the certified version via the +// block-id hints). The in-loop unwind replaces that coarse path. + +// CertifiedSwitch reports an executed slot contradicted by a decisive +// certificate. +type CertifiedSwitch struct { + Slot uint64 + Executed solana.Hash // zero when Skip + Certified solana.Hash // zero when Skip + Skip bool // a skip cert contradicts an executed block +} + +func (e *CertifiedSwitch) Error() string { + if e.Skip { + return fmt.Sprintf("alpenglow switch: slot %d executed locally but is certificate-skipped", e.Slot) + } + return fmt.Sprintf("alpenglow switch: slot %d executed block %s but certificates name %s", e.Slot, e.Executed, e.Certified) +} + +// alpenglowSwitchSweeper rate-gates the sweep on decision-version changes (cert +// arrivals AND replay-derived decisiveness — parent links, finalized ancestry, +// indirect skips, conflicts), so a contradiction that arises without a new +// certificate is not skipped. +type alpenglowSwitchSweeper struct { + query consensusengine.AlpenglowChainQuery + lastDecisionSeen uint64 +} + +func newAlpenglowSwitchSweeper(engine consensusengine.Engine) *alpenglowSwitchSweeper { + q, ok := engine.(consensusengine.AlpenglowChainQuery) + if !ok { + return nil + } + return &alpenglowSwitchSweeper{query: q} +} + +// sweep walks executed identities in (lastRooted, tip] and returns the first +// contradiction with a decisive certificate. Cheap: no-ops unless the +// tracker accepted new certificates since the last sweep. +func (s *alpenglowSwitchSweeper) sweep(executed map[uint64]solana.Hash, lastRooted, tip uint64) *CertifiedSwitch { + if s == nil || len(executed) == 0 || tip <= lastRooted { + return nil + } + version := s.query.ChainDecisionVersion() + if version == s.lastDecisionSeen { + return nil + } + s.lastDecisionSeen = version + + for slot := lastRooted + 1; slot <= tip; slot++ { + executedID, ran := executed[slot] + if !ran { + continue + } + if s.query.SkipCertifiedAt(slot) { + return &CertifiedSwitch{Slot: slot, Skip: true} + } + if certified, _, ok := s.query.CertifiedBlockAt(slot); ok { + if solana.Hash(certified.Hash) != executedID { + return &CertifiedSwitch{Slot: slot, Executed: executedID, Certified: solana.Hash(certified.Hash)} + } + } + } + return nil +} + +// tryInLoopUnwind attempts the in-RAM fork switch: evict the wrong suffix +// from the working set and rebuild replay's resume state from the retained +// parent context. Returns nil (caller falls back to the rooted-checkpoint +// re-replay) when the switch cannot be handled safely in-loop: +// - the unwind span crosses an epoch boundary (epoch-scoped caches would +// hold post-boundary state) +// - the slot is inside the partitioned-rewards distribution window +// (re-execution would double-apply distribution bookkeeping) +// - the parent slot's context is no longer retained in RAM +// +// Fallback reasons reported by tryInLoopUnwind; surfaced in the fork-switch +// instrumentation (100-slot summary + logs) so operators can see WHY switches +// fell back to the rooted-checkpoint re-replay — the signal that decides +// whether the in-RAM engine suffices or a branch-aware state engine is needed. +const ( + unwindFallbackNilTail = "nil-tail" + unwindFallbackCrossEpoch = "cross-epoch" + unwindFallbackRewardsWindow = "rewards-window" + unwindFallbackVoteStakeDirty = "vote-stake-dirty" + unwindFallbackMissingContext = "missing-context" + unwindFallbackContextRebuild = "context-rebuild" +) + +// tryInLoopUnwind attempts the in-RAM fork switch. On success it returns the +// rebuilt resume state and "". Otherwise it returns nil and the guard reason +// that forced the rooted-checkpoint fallback. +func tryInLoopUnwind( + sw *CertifiedSwitch, + tail *unrootedTail, + mithrilState *state.MithrilState, + epochSchedule *sealevel.SysvarEpochSchedule, + currentEpoch uint64, + partitionedRewardsInfo *rewards.PartitionedRewardDistributionInfo, +) (*ResumeState, string) { + if tail == nil || sw.Slot == 0 { + return nil, unwindFallbackNilTail + } + if epochSchedule.GetEpoch(sw.Slot-1) != currentEpoch || epochSchedule.GetEpoch(sw.Slot) != currentEpoch { + return nil, unwindFallbackCrossEpoch + } + if partitionedRewardsInfo != nil && partitionedRewardsInfo.NumRewardPartitionsRemaining > 0 { + return nil, unwindFallbackRewardsWindow + } + // Vote/stake cache safety, BOTH directions. The unwind cannot roll the + // global vote/stake caches back (a write in the UNWOUND suffix >= sw.Slot + // would leave them describing the wrong sibling), and the resume path's + // cache reload reads durable AccountsDB, which cannot see writes in the + // RETAINED suffix (rooted < slot < sw.Slot) — a reload would regress the + // cache below live account state. Either way the caches and account state + // disagree, and the vote cache feeds the timestamp oracle -> clock -> + // bankhash. So: any vote/stake write anywhere ABOVE the rooted watermark + // forces the rooted-checkpoint fallback, where reload-from-durable is + // exact by construction. Vote-program writes are rare in Alpenglow blocks + // (vote transactions are off-chain), so the fast path still dominates. + if voteStakeDirtySlot.Load() > mithrilState.LastRootedSlot { + return nil, unwindFallbackVoteStakeDirty + } + + ctx := tail.unwind(sw.Slot) + if ctx == nil && sw.Slot-1 == mithrilState.LastRootedSlot && mithrilState.LastRootedContext != nil { + // Parent is exactly the durable fold boundary: its context lives in + // the state file rather than the tail. + ctx = mithrilState.LastRootedContext + } + if ctx == nil { + return nil, unwindFallbackMissingContext + } + rs, err := ResumeStateFromRootedContext(ctx, nil) + if err != nil { + mlog.Log.Warnf("alpenglow switch: cannot rebuild resume state from retained context at slot %d: %v", ctx.Slot, err) + return nil, unwindFallbackContextRebuild + } + return rs, "" +} diff --git a/pkg/replay/alpenglow_switch_test.go b/pkg/replay/alpenglow_switch_test.go new file mode 100644 index 000000000..794e54258 --- /dev/null +++ b/pkg/replay/alpenglow_switch_test.go @@ -0,0 +1,124 @@ +package replay + +import ( + "testing" + + "github.com/Overclock-Validator/mithril/pkg/alpenglow" + "github.com/gagliardetto/solana-go" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// fakeChainQuery is a canned AlpenglowChainQuery. +type fakeChainQuery struct { + certified map[uint64]alpenglow.BlockID + skipped map[uint64]bool + version uint64 +} + +func (f *fakeChainQuery) CertifiedBlockAt(slot uint64) (alpenglow.BlockID, alpenglow.CertificateType, bool) { + if b, ok := f.certified[slot]; ok { + return b, alpenglow.CertificateNotarize, true + } + return alpenglow.BlockID{}, "", false +} +func (f *fakeChainQuery) SkipCertifiedAt(slot uint64) bool { return f.skipped[slot] } +func (f *fakeChainQuery) ChainDecisionVersion() uint64 { return f.version } + +func swHash(b byte) solana.Hash { var h solana.Hash; h[0] = b; return h } + +func newTestSweeper(q *fakeChainQuery) *alpenglowSwitchSweeper { + return &alpenglowSwitchSweeper{query: q} +} + +// Matching certificates produce no switch. +func TestSweepNoContradiction(t *testing.T) { + q := &fakeChainQuery{ + certified: map[uint64]alpenglow.BlockID{101: {Slot: 101, Hash: swHash(1)}}, + skipped: map[uint64]bool{}, + version: 1, + } + s := newTestSweeper(q) + executed := map[uint64]solana.Hash{101: swHash(1), 102: swHash(2)} + assert.Nil(t, s.sweep(executed, 100, 102)) +} + +// A decisive cert naming a different sibling switches at that slot. +func TestSweepSiblingMismatch(t *testing.T) { + q := &fakeChainQuery{ + certified: map[uint64]alpenglow.BlockID{101: {Slot: 101, Hash: swHash(9)}}, + skipped: map[uint64]bool{}, + version: 1, + } + s := newTestSweeper(q) + executed := map[uint64]solana.Hash{101: swHash(1)} + sw := s.sweep(executed, 100, 101) + require.NotNil(t, sw) + assert.Equal(t, uint64(101), sw.Slot) + assert.Equal(t, swHash(1), sw.Executed) + assert.Equal(t, swHash(9), sw.Certified) + assert.False(t, sw.Skip) +} + +// A skip cert over an executed slot switches with Skip=true. +func TestSweepSkipOverExecuted(t *testing.T) { + q := &fakeChainQuery{certified: map[uint64]alpenglow.BlockID{}, skipped: map[uint64]bool{101: true}, version: 1} + s := newTestSweeper(q) + executed := map[uint64]solana.Hash{101: swHash(1)} + sw := s.sweep(executed, 100, 101) + require.NotNil(t, sw) + assert.True(t, sw.Skip) + assert.Equal(t, uint64(101), sw.Slot) +} + +// The FIRST contradiction wins (ancestors before descendants). +func TestSweepReportsFirstContradiction(t *testing.T) { + q := &fakeChainQuery{ + certified: map[uint64]alpenglow.BlockID{ + 101: {Slot: 101, Hash: swHash(9)}, + 103: {Slot: 103, Hash: swHash(8)}, + }, + skipped: map[uint64]bool{102: true}, + version: 1, + } + s := newTestSweeper(q) + executed := map[uint64]solana.Hash{101: swHash(1), 102: swHash(2), 103: swHash(3)} + sw := s.sweep(executed, 100, 103) + require.NotNil(t, sw) + assert.Equal(t, uint64(101), sw.Slot, "lowest contradicted slot first") +} + +// The sweep is gated on the decision version and bounded by the window. The +// decision version advances on ANY decisive change — not only certificates — +// so a contradiction derived from replay observations (parent links, finalized +// ancestry, indirect skips) still re-arms the sweep. +func TestSweepGatingAndBounds(t *testing.T) { + q := &fakeChainQuery{ + certified: map[uint64]alpenglow.BlockID{101: {Slot: 101, Hash: swHash(9)}}, + skipped: map[uint64]bool{}, + version: 1, + } + s := newTestSweeper(q) + executed := map[uint64]solana.Hash{101: swHash(1)} + + // Below the rooted floor: never inspected. + assert.Nil(t, s.sweep(executed, 101, 101), "slot at/below lastRooted is out of window") + + // First sweep at version=1 fires... + sw := s.sweep(executed, 100, 101) + require.NotNil(t, sw) + // ...but with no decision change the next sweep is a no-op even though the + // contradiction persists (the caller acts on the first report). + assert.Nil(t, s.sweep(executed, 100, 101), "no decision change -> no re-sweep") + + // A decision-version bump WITHOUT a new certificate (e.g. replay-derived + // finalized ancestry or an indirect skip) re-arms the sweep. + q.version = 2 + require.NotNil(t, s.sweep(executed, 100, 101), "decision-version bump re-arms the sweep") +} + +// A nil sweeper (engine without chain query) is inert. +func TestSweepNilSweeper(t *testing.T) { + var s *alpenglowSwitchSweeper + assert.Nil(t, s.sweep(map[uint64]solana.Hash{1: swHash(1)}, 0, 1)) +} diff --git a/pkg/replay/alpenglow_unwind_test.go b/pkg/replay/alpenglow_unwind_test.go new file mode 100644 index 000000000..3277e3cc7 --- /dev/null +++ b/pkg/replay/alpenglow_unwind_test.go @@ -0,0 +1,296 @@ +package replay + +import ( + "encoding/base64" + "reflect" + "testing" + + "github.com/Overclock-Validator/mithril/pkg/accounts" + "github.com/Overclock-Validator/mithril/pkg/rewards" + "github.com/Overclock-Validator/mithril/pkg/sealevel" + "github.com/Overclock-Validator/mithril/pkg/state" + "github.com/mr-tron/base58" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// Tripwire: the fork-switch unwind and checkpoint resume are only correct if +// EVERY runtime side effect a slot produces is carried in ResumeContext and +// restored. If you add a field to state.ResumeContext, this test forces you to +// (1) map it in ResumeStateFromRootedContext, (2) restore it on resume +// (configureInitialBlockFromResume or the ReplayBlocks seed path), (3) restore +// it on the in-loop unwind, and (4) bump the count below. +func TestResumeContextFieldTripwire(t *testing.T) { + const wired = 21 // fields consciously wired through resume + unwind + if n := reflect.TypeOf(state.ResumeContext{}).NumField(); n != wired { + t.Fatalf("state.ResumeContext has %d fields but %d are wired through the resume/unwind restoration path — wire the new field(s) end-to-end, then update this count", n, wired) + } +} + +// Full-fidelity mapping: every restorable ResumeContext field survives into +// ResumeState exactly (the input to both checkpoint resume and the in-loop +// fork-switch unwind). +func TestResumeStateFromRootedContextRoundTrip(t *testing.T) { + bankhash := make([]byte, 32) + bankhash[0] = 0xAA + lt := make([]byte, 2048) + lt[7] = 3 + evicted := make([]byte, 32) + evicted[1] = 0xBB + lastBH := make([]byte, 32) + lastBH[2] = 0xCC + entryBH := make([]byte, 32) + entryBH[3] = 0xDD + clock := []byte{9, 8, 7, 6} + txc := uint64(123456) + + rc := &state.ResumeContext{ + Slot: 900, + Bankhash: base58.Encode(bankhash), + BlockHeight: 880, + Epoch: 2, + AcctsLtHash: base64.StdEncoding.EncodeToString(lt), + LamportsPerSignature: 5000, + PrevLamportsPerSig: 4500, + NumSignatures: 777, + RecentBlockhashes: []state.BlockhashEntry{{Blockhash: base58.Encode(entryBH), LamportsPerSignature: 5000}}, + EvictedBlockhash: base58.Encode(evicted), + Blockhash: base58.Encode(lastBH), + SlotHashes: []state.SlotHashEntry{{Slot: 899, Hash: base58.Encode(entryBH)}}, + Clock: base64.StdEncoding.EncodeToString(clock), + Capitalization: 42_000_000, + SlotsPerYear: 78840000, + InflationInitial: 0.08, + InflationTerminal: 0.015, + InflationTaper: 0.15, + InflationFoundation: 0.05, + InflationFoundationTerm: 7, + TransactionCount: &txc, + } + + rs, err := ResumeStateFromRootedContext(rc, map[uint64]string{2: "c3Rha2Vz"}) + require.NoError(t, err) + + assert.Equal(t, uint64(900), rs.ParentSlot) + assert.Equal(t, uint64(880), rs.ParentBlockHeight) + assert.Equal(t, bankhash, rs.ParentBankhash) + require.NotNil(t, rs.AcctsLtHash) + assert.Equal(t, lt, rs.AcctsLtHash.Hash(), "lt-hash restored byte-exact") + assert.Equal(t, uint64(5000), rs.LamportsPerSignature) + assert.Equal(t, uint64(4500), rs.PrevLamportsPerSignature) + assert.Equal(t, uint64(777), rs.NumSignatures) + require.NotNil(t, rs.RecentBlockhashes) + require.Len(t, *rs.RecentBlockhashes, 1) + assert.Equal(t, entryBH, (*rs.RecentBlockhashes)[0].Blockhash[:]) + assert.Equal(t, evicted, rs.EvictedBlockhash[:]) + assert.Equal(t, lastBH, rs.LastBlockhash[:]) + require.NotNil(t, rs.SlotHashes) + require.Len(t, *rs.SlotHashes, 1) + assert.Equal(t, uint64(899), (*rs.SlotHashes)[0].Slot) + assert.Equal(t, clock, rs.Clock) + assert.Equal(t, uint64(42_000_000), rs.Capitalization) + assert.Equal(t, float64(78840000), rs.SlotsPerYear) + assert.Equal(t, 0.08, rs.InflationInitial) + assert.Equal(t, 0.015, rs.InflationTerminal) + assert.Equal(t, 0.15, rs.InflationTaper) + assert.Equal(t, 0.05, rs.InflationFoundation) + assert.Equal(t, float64(7), rs.InflationFoundationTerm) + require.NotNil(t, rs.TransactionCount) + assert.Equal(t, uint64(123456), *rs.TransactionCount) + assert.Equal(t, []byte("c3Rha2Vz"), rs.ComputedEpochStakes[2]) +} + +// unwind returns the ACTUAL executed parent context, which need not be +// numerically fromSlot-1 when the slots in between were skipped (P2). +func TestUnwindReturnsExecutedParentAcrossSkips(t *testing.T) { + tail := newUnrootedTail(&fakeDurable{}, &fakeCommitter{durable: accounts.NewMemAccounts()}, 512, 1, "") + // Executed slots 5 and 8 (6, 7 skipped -> no context), then 9. + tail.Add(5, []*accounts.Account{testAccount(1, 51)}, testHashBytes(5)) + tail.SetContext(5, &state.ResumeContext{Slot: 5, Bankhash: "bh5"}) + tail.Add(8, []*accounts.Account{testAccount(2, 82)}, testHashBytes(8)) + tail.SetContext(8, &state.ResumeContext{Slot: 8, Bankhash: "bh8"}) + tail.Add(9, []*accounts.Account{testAccount(3, 93)}, testHashBytes(9)) + tail.SetContext(9, &state.ResumeContext{Slot: 9, Bankhash: "bh9"}) + + // Switch at slot 9: the parent is the executed slot 8. + ctx := tail.unwind(9) + require.NotNil(t, ctx) + assert.Equal(t, uint64(8), ctx.Slot) + + // Switch at slot 8: slots 6,7 were skipped, so the executed parent is slot 5 + // — returned even though it is not numerically 8-1=7 (the old code rejected + // this and forced a rooted re-replay). + ctx = tail.unwind(8) + require.NotNil(t, ctx, "parent across skipped slots must be returned") + assert.Equal(t, uint64(5), ctx.Slot) +} + +// The seed for the running transaction count: exact from a checkpoint that +// recorded one (including a genuine zero — dev genesis), approximate from the +// snapshot manifest when the checkpoint predates the field (nil pointer). +func TestResolveInitialTransactionCount(t *testing.T) { + // Pre-field checkpoint (nil pointer): manifest fallback, flagged approximate. + count, exact := resolveInitialTransactionCount(&ResumeState{}, 5000) + assert.Equal(t, uint64(5000), count) + assert.False(t, exact, "nil TransactionCount must be treated as absent, not zero") + + // Fresh start (no resume state at all): manifest is exact enough by definition. + count, exact = resolveInitialTransactionCount(nil, 5000) + assert.Equal(t, uint64(5000), count) + assert.False(t, exact) + + // Checkpoint with a recorded count: exact, overrides the manifest. + txc := uint64(777777) + count, exact = resolveInitialTransactionCount(&ResumeState{TransactionCount: &txc}, 5000) + assert.Equal(t, uint64(777777), count) + assert.True(t, exact) + + // Present-but-zero is EXACT zero (dev genesis), not "unset". + zero := uint64(0) + count, exact = resolveInitialTransactionCount(&ResumeState{TransactionCount: &zero}, 5000) + assert.Equal(t, uint64(0), count) + assert.True(t, exact, "explicit zero must not fall back to the manifest") +} + +// The vote/stake dirty watermark is a monotonic max, reset per run. +func TestVoteStakeDirtyWatermark(t *testing.T) { + resetVoteStakeDirty() + assert.Equal(t, uint64(0), voteStakeDirtySlot.Load()) + markVoteStakeDirty(100) + markVoteStakeDirty(50) // lower — monotonic max holds + assert.Equal(t, uint64(100), voteStakeDirtySlot.Load()) + markVoteStakeDirty(150) + assert.Equal(t, uint64(150), voteStakeDirtySlot.Load()) + resetVoteStakeDirty() + assert.Equal(t, uint64(0), voteStakeDirtySlot.Load()) +} + +// The in-loop unwind proceeds when the unwound suffix left the global vote/stake +// caches untouched, but falls back to the rooted-checkpoint re-replay when a +// slot in that suffix mutated them (P1) — the unwind can only roll back account +// state, not those process-global caches. +func TestTryInLoopUnwindFallsBackWhenVoteStakeDirty(t *testing.T) { + // A resume context the rebuild accepts: base58 bankhash + base64 lt-hash + // (1024 uint16 elements = 2048 bytes). + ctxTxCount := uint64(999) + validCtx := &state.ResumeContext{ + Slot: 7, + Bankhash: base58.Encode(make([]byte, 32)), + AcctsLtHash: base64.StdEncoding.EncodeToString(make([]byte, 2048)), + TransactionCount: &ctxTxCount, + } + newTail := func() *unrootedTail { + tail := newUnrootedTail(&fakeDurable{}, &fakeCommitter{durable: accounts.NewMemAccounts()}, 512, 1, "") + tail.Add(7, []*accounts.Account{testAccount(1, 71)}, testHashBytes(7)) + tail.SetContext(7, validCtx) + return tail + } + sched := &sealevel.SysvarEpochSchedule{SlotsPerEpoch: 432000, LeaderScheduleSlotOffset: 432000} + sw := &CertifiedSwitch{Slot: 8, Executed: swHash(1), Certified: swHash(2)} + epoch := sched.GetEpoch(8) // 0 with these params + mithrilState := &state.MithrilState{} + + // Clean: the in-loop unwind succeeds (evict slot 8+, resume from slot 7) and + // carries the parent's transaction count so the discarded fork's txs can be + // dropped from the running count. + resetVoteStakeDirty() + rs, _ := tryInLoopUnwind(sw, newTail(), mithrilState, sched, epoch, nil) + require.NotNil(t, rs, "clean unwind should succeed in-loop") + require.NotNil(t, rs.TransactionCount, "unwind carries the parent's tx count for restore") + assert.Equal(t, uint64(999), *rs.TransactionCount) + + // A global cache was mutated in the UNWOUND suffix (at the switch slot): + // the unwind cannot roll the caches back -> must fall back (nil). + markVoteStakeDirty(8) + rs, reason := tryInLoopUnwind(sw, newTail(), mithrilState, sched, epoch, nil) + assert.Nil(t, rs, "dirty cache in the unwound suffix must force the rooted re-replay fallback") + assert.Equal(t, unwindFallbackVoteStakeDirty, reason) + resetVoteStakeDirty() + + // A cache write in the RETAINED suffix (below the switch slot but above the + // rooted watermark) is just as unsafe: the resume path reloads the vote + // cache from durable, which cannot see the retained suffix's writes — the + // reload would REGRESS the cache below live account state. + markVoteStakeDirty(7) // retained slot; rooted watermark is 0 + rs, reason = tryInLoopUnwind(sw, newTail(), mithrilState, sched, epoch, nil) + assert.Nil(t, rs, "dirty cache in the retained suffix must also force the fallback") + assert.Equal(t, unwindFallbackVoteStakeDirty, reason) + resetVoteStakeDirty() + + // Once the rooted watermark passes the dirty slot, the write is folded into + // durable and reload-from-durable is exact again -> fast path allowed. + markVoteStakeDirty(7) + rootedPast := &state.MithrilState{LastRootedSlot: 7} + rs, _ = tryInLoopUnwind(sw, newTail(), rootedPast, sched, epoch, nil) + require.NotNil(t, rs, "dirtiness at/below the rooted watermark is durably folded — fast path is safe") + resetVoteStakeDirty() +} + +// Every unsafe-unwind guard falls back to the rooted-checkpoint re-replay +// (returns nil) instead of proceeding: cross-epoch spans, the partitioned- +// rewards window, and a missing parent context. +func TestTryInLoopUnwindGuardMatrix(t *testing.T) { + validCtx := &state.ResumeContext{ + Slot: 7, + Bankhash: base58.Encode(make([]byte, 32)), + AcctsLtHash: base64.StdEncoding.EncodeToString(make([]byte, 2048)), + } + newTail := func(withCtx bool) *unrootedTail { + tail := newUnrootedTail(&fakeDurable{}, &fakeCommitter{durable: accounts.NewMemAccounts()}, 512, 1, "") + tail.Add(7, []*accounts.Account{testAccount(1, 71)}, testHashBytes(7)) + if withCtx { + tail.SetContext(7, validCtx) + } + return tail + } + sched := &sealevel.SysvarEpochSchedule{SlotsPerEpoch: 432000, LeaderScheduleSlotOffset: 432000} + mithrilState := &state.MithrilState{} + resetVoteStakeDirty() + + // Nil tail / zero slot are inert. + assertUnwindFallback(t, &CertifiedSwitch{Slot: 8}, nil, mithrilState, sched, 0, nil) + assertUnwindFallback(t, &CertifiedSwitch{Slot: 0}, newTail(true), mithrilState, sched, 0, nil) + + // Cross-epoch span: the switch slot's parent sits in the previous epoch — + // epoch-scoped caches (stakes, leader schedule, stake history) would be + // stale for the re-execution. Slot 432000 is the first slot of epoch 1. + sw := &CertifiedSwitch{Slot: 432000, Executed: swHash(1), Certified: swHash(2)} + assertUnwindFallbackReason(t, unwindFallbackCrossEpoch, sw, newTail(true), mithrilState, sched, 1, nil) + + // Partitioned-rewards window: re-execution would double-apply distribution + // bookkeeping. + sw = &CertifiedSwitch{Slot: 8, Executed: swHash(1), Certified: swHash(2)} + rewardsActive := &rewards.PartitionedRewardDistributionInfo{NumRewardPartitionsRemaining: 3} + assertUnwindFallbackReason(t, unwindFallbackRewardsWindow, sw, newTail(true), mithrilState, sched, 0, rewardsActive) + + // Missing parent context: nothing retained to rebuild execution state from. + assertUnwindFallbackReason(t, unwindFallbackMissingContext, sw, newTail(false), mithrilState, sched, 0, nil) + + // Control: with every guard clear, the unwind proceeds. + assertUnwindOK(t, sw, newTail(true), mithrilState, sched, 0, nil) +} + +// assertUnwindOK asserts the in-RAM unwind proceeds (no fallback reason). +func assertUnwindOK(t *testing.T, sw *CertifiedSwitch, tail *unrootedTail, ms *state.MithrilState, sched *sealevel.SysvarEpochSchedule, epoch uint64, ri *rewards.PartitionedRewardDistributionInfo) { + t.Helper() + rs, reason := tryInLoopUnwind(sw, tail, ms, sched, epoch, ri) + require.NotNil(t, rs, "expected in-RAM unwind to proceed, got fallback %q", reason) + assert.Empty(t, reason) +} + +// assertUnwindFallback asserts the unwind falls back (any reason). +func assertUnwindFallback(t *testing.T, sw *CertifiedSwitch, tail *unrootedTail, ms *state.MithrilState, sched *sealevel.SysvarEpochSchedule, epoch uint64, ri *rewards.PartitionedRewardDistributionInfo) { + t.Helper() + rs, reason := tryInLoopUnwind(sw, tail, ms, sched, epoch, ri) + assert.Nil(t, rs) + assert.NotEmpty(t, reason, "fallback must carry a reason for the instrumentation") +} + +// assertUnwindFallbackReason asserts the unwind falls back with the exact +// instrumented reason operators will see. +func assertUnwindFallbackReason(t *testing.T, want string, sw *CertifiedSwitch, tail *unrootedTail, ms *state.MithrilState, sched *sealevel.SysvarEpochSchedule, epoch uint64, ri *rewards.PartitionedRewardDistributionInfo) { + t.Helper() + rs, reason := tryInLoopUnwind(sw, tail, ms, sched, epoch, ri) + assert.Nil(t, rs) + assert.Equal(t, want, reason) +} diff --git a/pkg/replay/async_promotion_test.go b/pkg/replay/async_promotion_test.go new file mode 100644 index 000000000..5462f4b5f --- /dev/null +++ b/pkg/replay/async_promotion_test.go @@ -0,0 +1,181 @@ +package replay + +import ( + "sync" + "testing" + "time" + + "github.com/Overclock-Validator/mithril/pkg/accounts" + "github.com/Overclock-Validator/mithril/pkg/accountsdb" + "github.com/Overclock-Validator/mithril/pkg/state" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// slowCommitter delays each CommitBatch so tests can observe the in-flight +// window; it also records call times to prove the loop was never blocked. +type slowCommitter struct { + fakeCommitter + mu sync.Mutex + delay time.Duration +} + +func (c *slowCommitter) CommitBatch(deltas []accounts.SlotDelta, throughSlot uint64, bankhashes map[uint64][32]byte, resumeCtx []byte) (accountsdb.BatchCommitResult, error) { + time.Sleep(c.delay) + c.mu.Lock() + defer c.mu.Unlock() + return c.fakeCommitter.CommitBatch(deltas, throughSlot, bankhashes, resumeCtx) +} + +func asyncTestTail(committer batchCommitter, slots ...uint64) *unrootedTail { + tail := newUnrootedTail(&fakeDurable{}, committer, 512, 2, "") + for i, s := range slots { + tail.Add(s, []*accounts.Account{testAccount(byte(i+1), s)}, testHashBytes(byte(s))) + tail.SetContext(s, &state.ResumeContext{Slot: s}) + } + return tail +} + +// The build/run/apply split preserves the sync path's semantics: the overlay +// retains the chunk while the fold is in flight (reads stay correct) and +// drops it only at apply. +func TestAsyncFoldBuildRunApply(t *testing.T) { + fc := &fakeCommitter{durable: accounts.NewMemAccounts()} + tail := asyncTestTail(fc, 5, 6, 7) + + job, err := tail.buildFoldJob(7, false) + require.NoError(t, err) + require.NotNil(t, job) + assert.Equal(t, uint64(6), job.through, "one K=2 chunk: slots 5..6") + + // In-flight: overlay still holds everything (reads unaffected). + assert.Equal(t, 3, tail.overlay.HeldSlots()) + + require.NoError(t, runFoldJob(fc, job)) + assert.Equal(t, []uint64{5, 6}, fc.committed) + // Committed but not applied: overlay STILL holds the chunk. + assert.Equal(t, 3, tail.overlay.HeldSlots()) + + ctx := tail.applyFoldJob(job) + require.NotNil(t, ctx) + assert.Equal(t, uint64(6), ctx.Slot) + assert.Equal(t, 1, tail.overlay.HeldSlots(), "only slot 7 remains") + assert.Empty(t, tail.bankhashes[uint64(5)]) + _, has5 := tail.contexts[5] + assert.False(t, has5, "contexts pruned through the fold") +} + +// A partial trailing chunk builds only under force (the shutdown flush). +func TestBuildFoldJobPartialChunkOnlyWhenForced(t *testing.T) { + fc := &fakeCommitter{durable: accounts.NewMemAccounts()} + tail := asyncTestTail(fc, 5) // one slot < K=2 + + job, err := tail.buildFoldJob(5, false) + require.NoError(t, err) + assert.Nil(t, job, "partial chunk must stay in RAM without force") + + job, err = tail.buildFoldJob(5, true) + require.NoError(t, err) + require.NotNil(t, job) + assert.Equal(t, uint64(5), job.through) +} + +// A context-less chunk-top refuses to build: committing it would produce a +// manifest recovery cannot resume from. +func TestBuildFoldJobRefusesMissingContext(t *testing.T) { + fc := &fakeCommitter{durable: accounts.NewMemAccounts()} + tail := newUnrootedTail(&fakeDurable{}, fc, 512, 2, "") + tail.Add(5, []*accounts.Account{testAccount(1, 5)}, testHashBytes(5)) + tail.Add(6, []*accounts.Account{testAccount(2, 6)}, testHashBytes(6)) + tail.SetContext(5, &state.ResumeContext{Slot: 5}) // 6 (chunk top) missing + + job, err := tail.buildFoldJob(6, false) + require.Error(t, err) + assert.Nil(t, job) + assert.Equal(t, 2, tail.overlay.HeldSlots(), "nothing folds on a refused build") +} + +// The promoter runs jobs off-thread: enqueue returns immediately, poll is +// non-blocking during the commit, and drain settles the in-flight job. +func TestAsyncPromoterOffLoopAndDrain(t *testing.T) { + sc := &slowCommitter{fakeCommitter: fakeCommitter{durable: accounts.NewMemAccounts()}, delay: 60 * time.Millisecond} + tail := asyncTestTail(sc, 5, 6, 7) + p := newAsyncPromoter(sc) + defer p.stop() + + job, err := tail.buildFoldJob(7, false) + require.NoError(t, err) + require.NotNil(t, job) + + start := time.Now() + p.enqueue(job) + require.Less(t, time.Since(start), 20*time.Millisecond, "enqueue must not block on the commit") + + // While the worker commits, the loop keeps going: poll stays nil. + assert.Nil(t, p.poll(), "poll must be non-blocking while the fold runs") + assert.True(t, p.inFlight) + + // Drain blocks until completion — the fork-unwind / shutdown barrier. + res := p.drain() + require.NotNil(t, res) + require.NoError(t, res.err) + assert.Equal(t, uint64(6), res.job.through) + assert.False(t, p.inFlight) + assert.GreaterOrEqual(t, time.Since(start), sc.delay, "drain waited for the worker") + + ctx := tail.applyFoldJob(res.job) + require.NotNil(t, ctx) + assert.Equal(t, 1, tail.overlay.HeldSlots()) +} + +// A failed fold leaves the tail untouched (natural retry: the same chunk +// rebuilds because nothing advanced) and reports the error via the result. +func TestAsyncPromoterFailedFoldRetries(t *testing.T) { + fc := &fakeCommitter{durable: accounts.NewMemAccounts(), failOn: 5} + tail := asyncTestTail(fc, 5, 6, 7) + p := newAsyncPromoter(fc) + defer p.stop() + + job, err := tail.buildFoldJob(7, false) + require.NoError(t, err) + p.enqueue(job) + res := p.drain() + require.NotNil(t, res) + require.Error(t, res.err) + assert.Equal(t, 3, tail.overlay.HeldSlots(), "failed fold must not touch the overlay") + + // Retry after the fault clears: identical chunk rebuilds and succeeds. + fc.failOn = 0 + job2, err := tail.buildFoldJob(7, false) + require.NoError(t, err) + require.Equal(t, job.through, job2.through, "same chunk rebuilds after a failed fold") + p.enqueue(job2) + res = p.drain() + require.NoError(t, res.err) + tail.applyFoldJob(res.job) + assert.Equal(t, 1, tail.overlay.HeldSlots()) +} + +// Codex item 5 — the shutdown gate proof: the flush path folds THROUGH THE +// GATE-DERIVED TARGET AND NO FURTHER, even under force. Slots above +// min(finality, verified) stay in RAM no matter how the process exits. +func TestShutdownFlushCannotFoldPastGateTarget(t *testing.T) { + fc := &fakeCommitter{durable: accounts.NewMemAccounts()} + tail := asyncTestTail(fc, 5, 6, 7, 8, 9) + + // The shared gate: finality says 9, the verifier has only reached 7. + target := safePromoteTarget(9, true, 7, 0) + require.Equal(t, uint64(7), target) + + promoted, ctx, err := tail.flush(target) + require.NoError(t, err) + assert.Equal(t, uint64(7), promoted, "force-flush stops exactly at the gate target") + require.NotNil(t, ctx) + assert.Equal(t, uint64(7), ctx.Slot) + assert.Equal(t, []uint64{5, 6, 7}, fc.committed) + assert.Equal(t, 2, tail.overlay.HeldSlots(), "unverified slots 8,9 must survive shutdown in RAM") + + // And the divergence floor clamps even harder than the verifier. + target = safePromoteTarget(9, true, 7, 6) + assert.Equal(t, uint64(5), target, "persisted-divergence floor holds promotion below the disputed slot") +} diff --git a/pkg/replay/block.go b/pkg/replay/block.go index 980857c99..4652d1a28 100644 --- a/pkg/replay/block.go +++ b/pkg/replay/block.go @@ -16,7 +16,6 @@ import ( "path/filepath" "runtime" "runtime/trace" - "sort" "sync" "sync/atomic" "time" @@ -32,7 +31,6 @@ import ( consensusengine "github.com/Overclock-Validator/mithril/pkg/consensus" "github.com/Overclock-Validator/mithril/pkg/features" "github.com/Overclock-Validator/mithril/pkg/fees" - "github.com/Overclock-Validator/mithril/pkg/forkchoice" "github.com/Overclock-Validator/mithril/pkg/global" "github.com/Overclock-Validator/mithril/pkg/lthash" "github.com/Overclock-Validator/mithril/pkg/metrics" @@ -195,6 +193,9 @@ type ResumeState struct { InflationTaper float64 InflationFoundation float64 InflationFoundationTerm float64 + // TransactionCount as of the resume slot. nil = the source context predates + // the field (seed from the snapshot manifest, approximate); non-nil is exact. + TransactionCount *uint64 // ComputedEpochStakes contains epoch stakes computed at boundaries. // Key: epoch number (the leader schedule epoch), Value: serialized JSON @@ -798,7 +799,6 @@ func setupInitialVoteAcctsAndStakeAccts(acctsDb *accountsdb.AccountsDb, block *b if err := RebuildVoteCacheFromAccountsDB(acctsDb, block.Slot, voteAcctStakes, 0); err != nil { mlog.Log.Warnf("vote cache rebuild had errors: %v", err) } - rebuildAuthorizedVotersFromVoteCache(block.Epoch) // Seed EpochStakesPerVoteAcct and TotalEpochStake from the epoch stakes cache, // loaded by buildInitialEpochStakesCache() from the manifest. These are @@ -1165,25 +1165,6 @@ func buildInitialEpochStakesCache(mithrilState *state.MithrilState, currentEpoch } } - // Load EpochAuthorizedVoters from state file (required) - // Supports multiple authorized voters per vote account (matches original manifest behavior) - if len(mithrilState.ManifestEpochAuthorizedVoters) == 0 { - return fmt.Errorf("state file missing manifest_epoch_authorized_voters - delete AccountsDB and rebuild from snapshot") - } - for voteAcctStr, authorizedVoterStrs := range mithrilState.ManifestEpochAuthorizedVoters { - voteAcct, err := base58.DecodeFromString(voteAcctStr) - if err != nil { - return fmt.Errorf("corrupted state file: failed to decode epoch_authorized_voters key %s: %w", voteAcctStr, err) - } - for _, authorizedVoterStr := range authorizedVoterStrs { - authorizedVoter, err := base58.DecodeFromString(authorizedVoterStr) - if err != nil { - return fmt.Errorf("corrupted state file: failed to decode epoch_authorized_voters value %s: %w", authorizedVoterStr, err) - } - global.PutEpochAuthorizedVoter(voteAcct, authorizedVoter) - } - } - return nil } @@ -1244,6 +1225,8 @@ func ReplayBlocks( if CurrentRunID == "" { CurrentRunID = GenerateRunID() } + // Fresh vote/stake dirty watermark for this run (gates the in-loop unwind). + resetVoteStakeDirty() // Create bankhash log file bankhashLogPath := fmt.Sprintf("%s/bankhash.log", acctsDbPath) bankhashLogFile, bankhashLogErr := os.OpenFile(bankhashLogPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) @@ -1298,18 +1281,25 @@ func ReplayBlocks( return result } - // Use state file for transaction count (required) - global.IncrTransactionCount(mithrilState.ManifestTransactionCount) + // Seed the running transaction count. On resume, the checkpoint carries the + // exact count as of the last rooted slot; on a fresh start use the snapshot + // manifest. Set (not increment) so a re-replay in the same process never + // double-counts. A checkpoint from before the field existed can only be + // seeded approximately (the folded snapshot→root span is unrecorded) — + // warn rather than fail: the count is RPC/metadata, not consensus. + { + txCount, exact := resolveInitialTransactionCount(resumeState, mithrilState.ManifestTransactionCount) + global.SetTransactionCount(txCount) + if !exact && mithrilState.LastRootedSlot > mithrilState.SnapshotSlot { + mlog.Log.Warnf("resume checkpoint at slot %d predates transaction-count tracking: transactionCount seeded from the snapshot (slot %d) and will read LOW by the folded span until the next re-bootstrap", + mithrilState.LastRootedSlot, mithrilState.SnapshotSlot) + } + } isFirstSlotInEpoch := epochSchedule.FirstSlotInEpoch(currentEpoch) == startSlot - // alpenglowReplayMode switches replay to Alpenglow clock/feature/finality - // semantics; computed here so feature overrides apply before the first block. - // Driven by consensus.mode ONLY (NOT useTurbine) — our turbine path also serves - // mainnet TowerBFT, where forcing alpenglow features would diverge the bankhash. - alpenglowReplayMode := isAlpenglowReplayMode(consensusOpts) + // Alpenglow-only node: Alpenglow clock/feature/finality semantics apply + // unconditionally (this binary targets Alpenglow clusters exclusively). replayCtx.CurrentFeatures, featuresActivatedInFirstSlot, parentFeaturesActivatedInFirstSlot = scanAndEnableFeatures(acctsDb, replayCtx, startSlot, isFirstSlotInEpoch) - if alpenglowReplayMode { - applyAlpenglowRuntimeFeatureOverrides(replayCtx.CurrentFeatures, startSlot) - } + applyAlpenglowRuntimeFeatureOverrides(replayCtx.CurrentFeatures, startSlot) partitionedEpochRewardsEnabled = replayCtx.CurrentFeatures.IsActive(features.EnablePartitionedEpochReward) || replayCtx.CurrentFeatures.IsActive(features.EnablePartitionedEpochRewardsSuperfeature) // Load epoch stakes - persisted stakes on resume, state file on fresh start @@ -1343,25 +1333,6 @@ func ReplayBlocks( result.Error = fmt.Errorf("missing required epoch stakes for current epoch %d - cannot resume (need fresh snapshot)", currentEpoch) return result } - // Load EpochAuthorizedVoters from state file (required for forkchoice vote parsing). - // buildInitialEpochStakesCache loads these, but this path skips that function. - if len(mithrilState.ManifestEpochAuthorizedVoters) > 0 { - for voteAcctStr, authorizedVoterStrs := range mithrilState.ManifestEpochAuthorizedVoters { - voteAcct, vErr := base58.DecodeFromString(voteAcctStr) - if vErr != nil { - result.Error = fmt.Errorf("corrupted state file: failed to decode epoch_authorized_voters key %s: %w", voteAcctStr, vErr) - return result - } - for _, authorizedVoterStr := range authorizedVoterStrs { - authorizedVoter, vErr := base58.DecodeFromString(authorizedVoterStr) - if vErr != nil { - result.Error = fmt.Errorf("corrupted state file: failed to decode epoch_authorized_voters value %s: %w", authorizedVoterStr, vErr) - return result - } - global.PutEpochAuthorizedVoter(voteAcct, authorizedVoter) - } - } - } } else { // Resume in same epoch as snapshot, no boundaries crossed - state file epoch stakes still valid if err := buildInitialEpochStakesCache(mithrilState, currentEpoch, snapshotEpoch); err != nil { @@ -1380,40 +1351,13 @@ func ReplayBlocks( result.Error = err return result } - // Resolve consensus config defaults before forkchoice init so we can - // check whether enforcement requires authorized voters. - useLiveShredStream := useLightbringer || useTurbine - consensusCfg := resolveConsensusConfig(consensusOpts, useLightbringer, useTurbine, isLive) - consensusManagedLiveStream := consensusCfg.enforceActive && - isLive && - consensusManagesLiveShredStream(consensusCfg.enforceSource, useLightbringer, useTurbine) - - // Alpenglow: the consensus engine (nil-safe; ClassicEngine in classic mode is a - // no-op). alpenglowReplayMode switches replay to Alpenglow clock/finality semantics. + // Alpenglow consensus engine (certificate-driven finality; nil-safe). var consensusEngine consensusengine.Engine if consensusOpts != nil { consensusEngine = consensusOpts.Engine } - consensusLiveStreamName := "Lightbringer" - if useTurbine { - consensusLiveStreamName = "TURBINE" - } - - epochAuthVoters := global.EpochAuthorizedVoters() - if epochAuthVoters == nil { - // Without authorized voters, forkchoice can't parse votes → no supermajority → enforcement is blind. - // If consensus enforcement is active, this is a fatal misconfiguration. - if consensusCfg.enforceActive && consensusCfg.policy == "halt" { - result.Error = fmt.Errorf("forkchoice: EpochAuthorizedVoters is nil — cannot enforce consensus without vote parsing (check snapshot/state file)") - return result - } - mlog.Log.Warnf("forkchoice: EpochAuthorizedVoters is nil — vote parsing will be skipped until populated") - } - forkChoice := forkchoice.NewForkChoiceService(currentEpoch, global.EpochStakes(currentEpoch), global.EpochTotalStake(currentEpoch), epochAuthVoters) - forkChoice.Start() - defer forkChoice.Stop() - if alpenglowReplayMode && consensusEngine != nil { + if consensusEngine != nil { // Certs must verify against their own epoch's validator set; without the // lookup the engine falls back to the latest set and cross-epoch certs // silently fail BLS (and the deferred-cert replay never triggers). @@ -1422,34 +1366,41 @@ func ReplayBlocks( } installCachedAlpenglowValidatorSets(consensusEngine, currentEpoch) } - global.SetForkChoice(forkChoice) - - // Instantiate the consensus coordinator for skip-path resolution and policy. - // In Lightbringer mode this now resolves a pre-execution block/skip path from - // the current anchor to a vote-confirmed leaf. - consensusCoordinator := forkchoice.NewConsensusCoordinator(forkChoice, consensusCfg.maxDepth, consensusCfg.policy) - consensusBufferedExecutionActive := consensusCfg.bufferedExecutionActive + // 100-slot summary window collectors ("full" = reconstructable-from-shreds, + // Agave SlotMeta/is_full sense; detailed debugging stays in file logs). var statsCounter int - var execTimes []float64 // seconds per block - var waitTimes []float64 // seconds per block - var cuValues []uint64 // CU per block - var voteTxCounts []uint64 // vote txns per block - var nonVoteTxCounts []uint64 // non-vote txns per block + var execTimes []float64 // seconds per executed block + var cuValues []uint64 // CU per executed block + var txnCounts []uint64 // transactions per executed block + var shredSamples []shredSample + var windowRepairedShreds int + var windowRepairedSlots int + var windowEmptyBlocks int + var windowSkippedWithShreds int // skipped slots where the leader sent partial shreds + var windowSwitches int // certificate switches detected this window + var windowSwitchInRAM int // switches resolved by the in-RAM unwind + var windowSwitchFallback int // switches that fell back to rooted-checkpoint re-replay + switchFallbackReasons := make(map[string]int) + var promotionHolds int // iterations promotion was fully stalled while finality ran a chunk ahead + windowStart := time.Now() + var lastGCCount uint32 var justCrossedEpochBoundary bool // Preallocate slices for 100 blocks const summaryInterval = 100 execTimes = make([]float64, 0, summaryInterval) - waitTimes = make([]float64, 0, summaryInterval) cuValues = make([]uint64, 0, summaryInterval) - voteTxCounts = make([]uint64, 0, summaryInterval) - nonVoteTxCounts = make([]uint64, 0, summaryInterval) - - var readyConsensusPath *pendingConsensusPath - observedConsensusBlocks := make(map[uint64]*b.Block) - var lastRootedWatermark uint64 // diagnostics: highest explicit-root finality slot seen - var lastVerifiedLeafSlot uint64 // highest slot whose leaf bankhash matched the confirmed one + txnCounts = make([]uint64, 0, summaryInterval) + shredSamples = make([]shredSample, 0, summaryInterval) + + var lastRootedWatermark uint64 // highest certificate/delegated finality slot seen + var highestExecutedSlot uint64 // highest slot ProcessBlock has executed; bounds the promotion-gate walk + // While partitioned rewards distribute, promotion holds below the boundary + // block so a crash-resume always re-runs it (the distribution bookkeeping is + // RAM-only and not reconstructible mid-window). Self-clears when the window + // completes (NumRewardPartitionsRemaining reaches 0). + var rewardsHoldBelowSlot uint64 // Alpenglow finality identities captured at observe/ingest time for the promotion // gate (the tracker's own state may be pruned by promotion time). Pruned as slots // promote; bounded by the unrooted tail cap. @@ -1480,47 +1431,264 @@ func ReplayBlocks( var delegatedFinalizedSlot uint64 var delegatedFinalizedAt time.Time - // unrootedTailState holds the in-RAM speculative state in rooted-durable mode; - // nil in legacy mode. When enabled, acctsDb serves durable reads + CommitSlotAtomic. - // fork_aware selects the branch-tree engine (forkCoordinator) over the linear tail. - var unrootedTailState unrootedState + // unrootedTailState holds the in-RAM speculative state — the working set plus + // its per-slot undo journal — in rooted-durable mode (the only mode of the + // Alpenglow build). It buffers replayed slots and folds them to disk via + // CommitBatch once rooted; block reads resolve through it. + var unrootedTailState *unrootedTail + var promoter *asyncPromoter + var applyFoldOutcome func(res *foldResult) // assigned below, used by the exit drain if acctsDb.RootedDurable { - if acctsDb.ForkAware { - unrootedTailState = newForkTail(acctsDb, acctsDb, unrootedTailHaltCap) - mlog.Log.Infof("fork-aware mode: replayed slots buffer in a branch tree until rooted (halt cap %d branches)", unrootedTailHaltCap) - } else { - unrootedTailState = newUnrootedTail(acctsDb, acctsDb, unrootedTailHaltCap) - mlog.Log.Infof("rooted-durable mode: canonical store stays rooted-only; replayed slots buffer in RAM until rooted (halt cap %d slots)", unrootedTailHaltCap) + unrootedTailState = newUnrootedTail(acctsDb, acctsDb, unrootedTailHaltCap, FoldBatchSlots, filepath.Join(acctsDb.AcctsDir, "..")) + // Folds run on a worker goroutine so replay never stalls on the + // segment write + fsync; the loop builds jobs and applies completions. + promoter = newAsyncPromoter(acctsDb) + defer func() { + // Settle the worker on ANY exit: apply a completed fold so the + // in-process recovery retry resumes from the true durable frontier + // instead of re-folding it (a discarded-but-committed fold is + // still safe — RecoverFoldState reconciles — just wasteful). + if applyFoldOutcome != nil { + applyFoldOutcome(promoter.drain()) + } + promoter.stop() + }() + mlog.Log.Infof("rooted-durable mode: canonical store stays rooted-only; replayed slots buffer in RAM until rooted (halt cap %d slots); folds run async off the replay loop", unrootedTailHaltCap) + } + // Any stake-index entries still pending from a previous in-process replay + // attempt (rooted-checkpoint re-replay after a fork switch or finality + // mismatch) belong to slots this run re-executes — or to a discarded wrong + // fork. Either way they re-enqueue if real; stale ones must not leak. + global.ClearPendingStakePubkeys() + + // Trailing execution verifier: the dual-watermark's second leg. Runs on + // its own RPC client + budget so it never competes with block fetch. + var trailingVerifier *TrailingVerifier + replayDivergenceFloor := uint64(0) + for _, ev := range mithrilState.ReplayDivergenceEvidence { + if replayDivergenceFloor == 0 || ev.Slot < replayDivergenceFloor { + replayDivergenceFloor = ev.Slot + } + } + if replayDivergenceFloor > 0 { + mlog.Log.Warnf("replay divergence evidence present (earliest slot %d): folds are blocked at that slot until the evidence is cleared after triage", replayDivergenceFloor) + } + // Switch sweep: detects executed slots contradicted by later decisive + // certificates (wrong sibling / certified skip) under execute-on-receipt. + switchSweeper := newAlpenglowSwitchSweeper(consensusEngine) + + if TrailingVerifierCfg.Enabled && unrootedTailState != nil { + trailingVerifier = newTrailingVerifier(&rpcVerificationSource{rpcc: rpcclient.NewRpcClient(rpcEndpoints[0])}, TrailingVerifierCfg) + go trailingVerifier.Run(ctx) + // Publish a run-local tx-capture registry for the verifier's lifetime and + // unpublish it on return, so no other run (a later re-replay, a test, a + // sim) shares or inherits this run's capture state. + stopCapture := beginTxCapture() + defer stopCapture() + if !TrailingVerifierCfg.Required { + mlog.Log.Warnf("trailing verifier running in ADVISORY mode (verifier.required=false): folds are NOT gated on execution verification") + } + mlog.Log.Infof("trailing verifier active: lag=%d slots, budget=%d rps — folds gate on min(finality, verified)", TrailingVerifierCfg.LagSlots, TrailingVerifierCfg.MaxRPS) + } else if unrootedTailState != nil { + mlog.Log.Warnf("trailing verifier DISABLED: folds gate on certificate finality only — certificates attest block data, not execution; a mithril-side execution divergence would fold to disk undetected") + } + + // applyPromotionBookkeeping advances the durable watermark and prunes every + // per-slot structure bounded by it. Shared by async fold application, the + // shutdown flush, and nothing else — it is the ONLY place LastRootedSlot + // advances during replay. + applyPromotionBookkeeping := func(promotedThrough uint64, rootedCtx *state.ResumeContext) { + mithrilState.LastRootedSlot = promotedThrough + mithrilState.LastRootedBankhash = rootedCtx.Bankhash + mithrilState.LastRootedContext = rootedCtx + for slot := range alpenglowFooterFinalized { + if slot <= promotedThrough { + delete(alpenglowFooterFinalized, slot) + } + } + for slot := range alpenglowExecutedBlockIDs { + if slot <= promotedThrough { + delete(alpenglowExecutedBlockIDs, slot) + } + } + if trailingVerifier != nil { + trailingVerifier.PruneThrough(promotedThrough) + } + if pruner, ok := consensusEngine.(consensusengine.AlpenglowPruneSink); ok { + pruner.PruneAlpenglowBefore(promotedThrough) + } + // Disputed slots that promoted passed the exact-match requirement — + // the evidence is satisfied. + for slot := range alpenglowForced { + if slot <= promotedThrough { + delete(alpenglowForced, slot) + clearAlpenglowEvidence(mithrilState, slot) + } + } + } + + // applyFoldOutcome applies a completed async fold on the loop thread. A + // failed fold only logs: LastRootedSlot did not advance, so the next + // iteration rebuilds the same chunk (natural retry); a permanently broken + // store surfaces as the OverCap halt (fail-closed). + applyFoldOutcome = func(res *foldResult) { + if res == nil { + return } + if res.err != nil { + mlog.Log.Errorf("rooted-durable: async fold failed: %v", res.err) + return + } + rootedCtx := unrootedTailState.applyFoldJob(res.job) + if rootedCtx == nil { + mlog.Log.Errorf("rooted-durable: fold through slot %d returned no resume context; watermark held back", res.job.through) + return + } + applyPromotionBookkeeping(res.job.through, rootedCtx) + } + + // foldRootedPrefix folds the rooted RAM prefix onto disk up to the SAFE + // target = min(certificate finality, trailing-verification watermark), after + // the persisted-divergence floor and the Alpenglow exact-block-id gate. It is + // the SINGLE fold path shared by in-loop promotion and the graceful-shutdown + // flush, so shutdown can never fold a slot the loop would refuse. It runs on + // every loop iteration (not only when finality advances) so verified progress + // alone can advance the watermark, and it checks the verifier for a divergence + // unconditionally so a failure halts even while finality is flat. Returns true + // when the caller must halt (result.Error is already set). force=true + // force-folds the trailing partial chunk (shutdown); force=false folds full + // chunks only. + foldRootedPrefix := func(force bool) (halt bool) { + if unrootedTailState == nil { + return false + } + // Apply any completed async fold first so the gates below see the + // current durable frontier. + applyFoldOutcome(promoter.poll()) + if lastRootedWatermark == 0 { + return false + } + // The trailing verifier is the only execution-correctness oracle; a + // divergence halts regardless of finality progress. + if trailingVerifier != nil { + if div := trailingVerifier.Failure(); div != nil { + recordReplayDivergenceEvidence(mithrilState, div) + if result.Error == nil { + result.Error = fmt.Errorf("REPLAY DIVERGENCE (verified vs RPC): %w; halting — durable state remains at slot %d", div, mithrilState.LastRootedSlot) + } + mlog.Log.Errorf("REPLAY DIVERGENCE (verified vs RPC): %v; durable state remains at slot %d", div, mithrilState.LastRootedSlot) + return true + } + } + // Dual watermark: nothing folds unless BOTH certificate finality AND the + // trailing verifier cover it (certificates attest block data, not + // execution), and never at or past a persisted-divergence floor. + verifierRequired := trailingVerifier != nil && TrailingVerifierCfg.Required + verifiedWM := uint64(0) + if verifierRequired { + verifiedWM = trailingVerifier.VerifiedWatermark() + } + promoteThrough := safePromoteTarget(lastRootedWatermark, verifierRequired, verifiedWM, replayDivergenceFloor) + // Partitioned-rewards window: hold promotion below the boundary block + // until every partition distributes, so a crash-resume re-runs the + // boundary and rebuilds the RAM-only distribution bookkeeping. + if rewardsHoldBelowSlot > 0 && partitionedRewardsInfo != nil && partitionedRewardsInfo.NumRewardPartitionsRemaining > 0 { + if promoteThrough >= rewardsHoldBelowSlot { + promoteThrough = rewardsHoldBelowSlot - 1 + } + } + if promoteThrough <= mithrilState.LastRootedSlot { + // Operator signal: promotion is fully stalled (verifier lag, + // divergence floor, or rewards hold) while finality has run at + // least a whole fold chunk ahead. Healthy steady state stays 0. + if lastRootedWatermark >= mithrilState.LastRootedSlot+uint64(FoldBatchSlots) { + promotionHolds++ + } + return false // nothing new is both final and verified + } + // Alpenglow: never fold a slot whose executed block contradicts certificate + // finality (prefix-stop; equivocation fails closed). + if consensusEngine != nil { + gated, gerr := alpenglowPromotionGate(consensusEngine, + alpenglowFooterFinalized, alpenglowExecutedBlockIDs, alpenglowForced, + mithrilState.LastRootedSlot, promoteThrough, highestExecutedSlot, &gateStats) + promoteThrough = gated + if gerr != nil { + var mismatch *AlpenglowFinalityMismatch + if errors.As(gerr, &mismatch) { + recordAlpenglowEvidence(mithrilState, mismatch) + } + if result.Error == nil { + result.Error = fmt.Errorf("ALPENGLOW SAFETY: %w; halting before folding slot %d", gerr, gated+1) + } + mlog.Log.Errorf("ALPENGLOW SAFETY: %v; halting before folding slot %d", gerr, gated+1) + return true + } + mlog.Log.FileOnlyf("alpenglow gate: checked=%d matched=%d no_finality=%d no_local_id=%d", + gateStats.checked, gateStats.matched, gateStats.noFinality, gateStats.noLocalID) + } + if promoteThrough <= mithrilState.LastRootedSlot { + return false + } + + if force { + // Shutdown flush: settle the worker first, then fold everything + // (including the trailing partial chunk) synchronously through the + // SAME gate-derived target — shutdown can never fold a slot the + // loop would refuse. + if res := promoter.drain(); res != nil { + applyFoldOutcome(res) + } + promotedThrough, rootedCtx, perr := unrootedTailState.flush(promoteThrough) + if perr != nil { + mlog.Log.Errorf("rooted-durable: shutdown flush stopped at slot %d: %v", promotedThrough, perr) + } + if promotedThrough > mithrilState.LastRootedSlot && rootedCtx != nil { + applyPromotionBookkeeping(promotedThrough, rootedCtx) + } + return false + } + // Async: one chunk in flight at a time. Enqueue the next chunk only + // when idle; completions are applied at the top of this function on a + // later iteration. + if !promoter.inFlight { + job, jerr := unrootedTailState.buildFoldJob(promoteThrough, false) + if jerr != nil { + mlog.Log.Errorf("rooted-durable: %v; watermark held back", jerr) + return false + } + if job != nil { + promoter.enqueue(job) + } + } + return false } var opts *blockstream.BlockSourceOpts if useLightbringer { opts = &blockstream.BlockSourceOpts{ - SourceType: blockstream.BlockSourceLightbringer, - RpcClient: rpcc, - LightbringerEndpoint: lightbringerEndpoint, - BackupRpcEndpoints: rpcBackups, - StartSlot: startSlot, - EndSlot: endSlot, - BlockDir: blockDir, - ConsensusManagedLightbringer: consensusManagedLiveStream, + SourceType: blockstream.BlockSourceLightbringer, + RpcClient: rpcc, + LightbringerEndpoint: lightbringerEndpoint, + BackupRpcEndpoints: rpcBackups, + StartSlot: startSlot, + EndSlot: endSlot, + BlockDir: blockDir, } } else if useTurbine { opts = &blockstream.BlockSourceOpts{ - SourceType: blockstream.BlockSourceTurbine, - RpcClient: rpcc, - TurbineBindAddr: turbineBindAddr, - TurbineGossipEntrypoint: turbineGossipEntrypoint, - TurbineGossipBindAddr: turbineGossipBindAddr, - TurbineAdvertisedIP: turbineAdvertisedIP, - TurbineShredVersion: turbineShredVersion, - LeaderForSlot: global.LeaderForSlot, - BackupRpcEndpoints: rpcBackups, - StartSlot: startSlot, - EndSlot: endSlot, - BlockDir: blockDir, - ConsensusManagedLightbringer: consensusManagedLiveStream, + SourceType: blockstream.BlockSourceTurbine, + RpcClient: rpcc, + TurbineBindAddr: turbineBindAddr, + TurbineGossipEntrypoint: turbineGossipEntrypoint, + TurbineGossipBindAddr: turbineGossipBindAddr, + TurbineAdvertisedIP: turbineAdvertisedIP, + TurbineShredVersion: turbineShredVersion, + LeaderForSlot: global.LeaderForSlot, + BackupRpcEndpoints: rpcBackups, + StartSlot: startSlot, + EndSlot: endSlot, + BlockDir: blockDir, } } else { opts = &blockstream.BlockSourceOpts{ @@ -1536,7 +1704,7 @@ func ReplayBlocks( // Alpenglow: drive cert-based block/skip selection at the block source from the // engine's ChainTracker when running native turbine in observer mode. Without this // the decision source is nil and applyAlpenglowDecisionLocked is a no-op. - if useTurbine && alpenglowReplayMode && consensusEngine != nil { + if useTurbine && consensusEngine != nil { if ds, ok := consensusEngine.(consensusengine.AlpenglowDecisionSource); ok { opts.TurbineAlpenglowBlockIDHints = true opts.AlpenglowDecisionSource = ds.NextAlpenglowDecision @@ -1546,6 +1714,13 @@ func ReplayBlocks( if co, ok := consensusEngine.(consensusengine.AlpenglowCandidateBlockObserver); ok { opts.AlpenglowCandidateBlockSink = co.ObserveAlpenglowCandidateBlock } + // Cert-driven repair: the source's repair loop steers turbine toward + // certified-but-unobserved blocks and cancels shred state for + // certificate-skipped slots. + if wb, ok := consensusEngine.(consensusengine.AlpenglowWantedBlocksSource); ok { + opts.AlpenglowWantedBlocks = wb.AlpenglowWantedBlocks + opts.AlpenglowSkipCertified = wb.SkipCertifiedAt + } } // Apply block fetching options if provided @@ -1574,7 +1749,7 @@ func ReplayBlocks( var skippedSlotsCount int // Track skipped slots for 100-slot summary replayStartLogged := false - currentConsensusAnchorSlot := func() uint64 { + currentExecutedAnchorSlot := func() uint64 { if lastSlotCtx != nil { return lastSlotCtx.Slot } @@ -1584,115 +1759,6 @@ func ReplayBlocks( return mithrilState.ManifestParentSlot } - observeConsensusAnchor := func() { - if lastSlotCtx != nil { - forkChoice.ObserveExecutionAnchor(lastSlotCtx.Slot, solana.Hash(lastSlotCtx.Blockhash)) - return - } - if resumeState != nil && resumeState.LastBlockhash != ([32]byte{}) { - forkChoice.ObserveExecutionAnchor(resumeState.ParentSlot, solana.Hash(resumeState.LastBlockhash)) - return - } - if mithrilState != nil && len(mithrilState.ManifestRecentBlockhashes) > 0 { - // Fresh snapshot start: seed the snapshot slot's PoH blockhash (newest - // recent blockhash), NOT the bank hash — RPC children carry the parent's - // PoH blockhash to recover their parent; the bank hash never matches. - manifestParentBlockhash, err := base58.DecodeFromString(mithrilState.ManifestRecentBlockhashes[0].Blockhash) - if err != nil { - mlog.Log.Warnf("forkchoice: failed to decode manifest parent blockhash for anchor seeding: %v", err) - return - } - forkChoice.ObserveExecutionAnchor(mithrilState.ManifestParentSlot, solana.Hash(manifestParentBlockhash)) - } - } - - syncConsensusBufferedExecutionMode := func(triggerSlot uint64) { - if !consensusManagedLiveStream { - return - } - - stats := blockStream.GetFetchStats() - if consensusBufferedExecutionActive && !stats.IsNearTip { - anchorSlot := currentConsensusAnchorSlot() - discardedObservedBlocks := len(observedConsensusBlocks) - readyDecisionCount := 0 - if readyConsensusPath != nil { - readyDecisionCount = len(readyConsensusPath.decisions) - } - - consensusBufferedExecutionActive = false - readyConsensusPath = nil - clearObservedConsensusBlocks(observedConsensusBlocks) - observeConsensusAnchor() - mlog.Log.Warnf("forkchoice: suspending buffered execution at slot %d because block source left near-tip mode; RPC catchup will continue from anchor %d (discarded_observed_blocks=%d discarded_ready_decisions=%d next_emitted_slot=%d)", - triggerSlot, anchorSlot, discardedObservedBlocks, readyDecisionCount, stats.NextSlot) - } - } - - observeBlockForConsensus := func(block *b.Block) error { - if !consensusCfg.enforceActive { - return nil - } - - if !consensusBufferedExecutionActive && consensusManagedLiveStream { - if block == nil || !block.FromLightbringer { - // Live catchup (RPC blocks while the managed stream is suspended): - // keep vote observation alive so the explicit-root watermark advances - // and rooted-durable promotion can drain the RAM tail. - if unrootedTailState != nil && block != nil && !block.IsSkipped { - forkChoice.ObserveVotesOnly(block.Slot, block.Transactions) - if block.Slot > 2*uint64(unrootedTailHaltCap) { - forkChoice.PruneBeforeSlot(block.Slot - 2*uint64(unrootedTailHaltCap)) - } - } - return nil - } - consensusBufferedExecutionActive = true - readyConsensusPath = nil - observeConsensusAnchor() - pruneObservedConsensusBlocks(observedConsensusBlocks, currentConsensusAnchorSlot()) - mlog.Log.Warnf("forkchoice: enabling buffered execution at slot %d after block source switched to %s", block.Slot, consensusLiveStreamName) - } - - if !consensusBufferedExecutionActive { - // Live catchup: block registration/path resolution stay suspended, but - // keep VOTE observation alive so the explicit-root finality watermark - // advances and rooted-durable promotion can drain the RAM tail. Prune - // so per-slot vote state stays bounded across a long catchup. - if unrootedTailState != nil && !block.IsSkipped { - forkChoice.ObserveVotesOnly(block.Slot, block.Transactions) - if block.Slot > 2*uint64(unrootedTailHaltCap) { - forkChoice.PruneBeforeSlot(block.Slot - 2*uint64(unrootedTailHaltCap)) - } - } - return nil - } - - if block.IsSkipped { - forkChoice.ObserveSkippedSlot(block.Slot) - return nil - } - - meta := forkchoice.ObservedBlockMeta{ - Slot: block.Slot, - Blockhash: solana.Hash(block.Blockhash), - ParentSlot: block.SourceParentSlot, - ParentSlotKnown: block.FromLightbringer && block.SourceParentSlot != 0, - ParentBlockhash: solana.Hash(block.LastBlockhash), - } - - if err := forkChoice.ObserveBlock(meta, block.Transactions); err != nil { - return err - } - - if consensusBufferedExecutionActive { - observedConsensusBlocks[block.Slot] = block - } - return nil - } - - observeConsensusAnchor() - for { if ctx.Err() != nil { mlog.Log.Infof("context cancelled, stopping replay: %v", ctx.Err()) @@ -1700,32 +1766,13 @@ func ReplayBlocks( break } - syncConsensusBufferedExecutionMode(currentConsensusAnchorSlot()) - var ( block *b.Block waitTime time.Duration + neededAt time.Time // when replay asked the source for this slot ) - if consensusBufferedExecutionActive && readyConsensusPath != nil && len(readyConsensusPath.decisions) > 0 { - nextDecision := readyConsensusPath.decisions[0] - readyConsensusPath.decisions = readyConsensusPath.decisions[1:] - - if nextDecision.UseBlock { - var exists bool - block, exists = observedConsensusBlocks[nextDecision.Slot] - if !exists { - result.Error = fmt.Errorf("forkchoice: missing observed block for resolved slot %d", nextDecision.Slot) - break - } - delete(observedConsensusBlocks, nextDecision.Slot) - } else { - delete(observedConsensusBlocks, nextDecision.Slot) - mlog.Log.Infof("forkchoice: resolved slot %d as skipped on path to confirmed leaf %d", - nextDecision.Slot, readyConsensusPath.leafSlot) - block = &b.Block{Slot: nextDecision.Slot, IsSkipped: true} - } - } else { + { // Start stall monitor goroutine (only after first block to avoid startup false positives) // Logs to file every second while waiting for a block var stallDone chan struct{} @@ -1755,9 +1802,9 @@ func ReplayBlocks( }() } - waitStart := time.Now() + neededAt = time.Now() block = blockStream.NextBlock() - waitTime = time.Since(waitStart) + waitTime = time.Since(neededAt) if stallDone != nil { close(stallDone) @@ -1775,7 +1822,7 @@ func ReplayBlocks( break } - if anchorSlot := currentConsensusAnchorSlot(); anchorSlot != 0 && block.Slot <= anchorSlot { + if anchorSlot := currentExecutedAnchorSlot(); anchorSlot != 0 && block.Slot <= anchorSlot { mlog.Log.Warnf("replay: discarding stale block source emission for slot %d; already executed through slot %d", block.Slot, anchorSlot) continue @@ -1783,7 +1830,7 @@ func ReplayBlocks( // Alpenglow: feed the observed block to the consensus engine. Observer // telemetry must never break replay, so log-and-continue on error. - if alpenglowReplayMode && consensusEngine != nil { + if consensusEngine != nil { if err := consensusEngine.ObserveBlock(ctx, consensusengine.BlockObservation{ Block: block, Source: blockStream.GetFetchStats().CurrentSource, @@ -1801,192 +1848,100 @@ func ReplayBlocks( } } - syncConsensusBufferedExecutionMode(block.Slot) - - if block.FromLightbringer { - stats := blockStream.GetFetchStats() - if shouldDiscardLightbringerObservationAfterFallback(isLive, useLiveShredStream, block, stats) { - modeStr := "catchup" - if stats.IsNearTip { - modeStr = "near-tip" - } - mlog.Log.Warnf("forkchoice: discarding stale %s observation for slot %d after source fallback (mode=%s current_source=%s anchor=%d next_emitted_slot=%d)", - consensusLiveStreamName, block.Slot, modeStr, stats.CurrentSource, currentConsensusAnchorSlot(), stats.NextSlot) - continue - } - } - - if err := observeBlockForConsensus(block); err != nil { - if errors.Is(err, forkchoice.ErrEquivocation) { - if acctsDb.ForkAware { - // Fork-aware: keep the version we hold and let the confirmed-leaf - // bankhash check adjudicate — a wrong version triggers - // dump-then-repair (self-healing) instead of a manual-restart halt. - mlog.Log.Warnf("forkchoice: equivocation observed at slot %d; continuing with held version (leaf check adjudicates)", block.Slot) - } else { - result.Error = fmt.Errorf("forkchoice: equivocation detected at slot %d", block.Slot) + // Execute-on-receipt correction: certificates arriving after a slot + // executed can name a different outcome. The sweep reports the first + // contradiction. The COMMON path resolves it in RAM: evict the wrong + // suffix from the WorkingSet, rebuild execution state from the + // retained parent context, and continue the loop. Guarded cases + // (reasons below) surface a typed error instead and the node-level + // recovery loop re-replays from the rooted checkpoint (repair + // re-fetches the certified version either way). + if unrootedTailState != nil { + if sw := switchSweeper.sweep(alpenglowExecutedBlockIDs, mithrilState.LastRootedSlot, currentExecutedAnchorSlot()); sw != nil { + windowSwitches++ + blockStream.RewindForAlpenglowSwitch(sw.Slot, sw.Certified) + // Settle the in-flight fold before touching the overlay: the + // worker reads chunk layers the unwind would evict. If the + // applied fold moved the durable frontier past the switch + // slot, the contradiction is now at/below durable state — + // that is the node-level rewind/recovery path, not an + // in-RAM unwind. + applyFoldOutcome(promoter.drain()) + if sw.Slot <= mithrilState.LastRootedSlot { + windowSwitchFallback++ + switchFallbackReasons["durable-overlap"]++ + result.Error = sw + mlog.Log.Warnf("%v — switch slot is at/below the durable watermark %d after settling the in-flight fold; deferring to the recovery loop", sw, mithrilState.LastRootedSlot) break } - } else { - result.Error = err + rs, fallbackReason := tryInLoopUnwind(sw, unrootedTailState, mithrilState, epochSchedule, currentEpoch, partitionedRewardsInfo) + if rs != nil { + // In-RAM unwind: drop the wrong suffix, rebuild execution state + // from the retained parent context, and let the certified + // version re-execute — no process restart, cost = the unwound + // blocks' re-execution. + for slot := range alpenglowExecutedBlockIDs { + if slot >= sw.Slot { + delete(alpenglowExecutedBlockIDs, slot) + } + } + resumeState = rs + lastSlotCtx = nil // next block configures from the rebuilt resume context + replayCtx.Capitalization = rs.Capitalization + global.SetBlockHeight(rs.ParentBlockHeight) + if rs.TransactionCount != nil { + global.SetTransactionCount(*rs.TransactionCount) // drop the discarded fork's txs + } + blockStream.SetLastExecutedSlot(sw.Slot - 1) + windowSwitchInRAM++ + mlog.Log.Warnf("%v — unwound in RAM to slot %d; re-executing the certified chain (in-RAM switches this window: %d)", sw, sw.Slot-1, windowSwitchInRAM) + continue + } + // Guarded out: fall back to the rooted-checkpoint re-replay, + // recording WHY (the signal for whether the in-RAM engine + // suffices or FD-style branching is actually needed). + windowSwitchFallback++ + switchFallbackReasons[fallbackReason]++ + result.Error = sw + mlog.Log.Warnf("%v — in-RAM unwind unavailable (%s); re-replaying the certified chain from the rooted checkpoint", sw, fallbackReason) break } } - // Advance the finality watermark, then fold the now-rooted RAM prefix onto - // disk (irreversible). Finality source is mode-switched: classic uses the - // TowerBFT 2/3-vote-root; alpenglow uses the certificate-finalized slot. - // In alpenglow mode the promotion gate below verifies executed-vs-certified - // block identity per slot; once competing forks execute as side branches, - // promotion will also resolve the winning branch path. + // Advance the certificate-finality watermark. Prefer engine + // cert-finality; fall back to the RPC-attested finalized slot + // (delegated) since an unstaked observer gets no certs. Poll + // throttled (the RPC round-trip is slow). { - rooted, ok := forkChoice.HighestRootedSlot() - if alpenglowReplayMode { - // Alpenglow finality never uses the TowerBFT vote-root; start clean. - // Prefer engine cert-finality; fall back to RPC-attested finalized - // slot (delegated) since an unstaked observer gets no certs. Poll - // throttled (RPC round-trip is slow); promote() bounds the rooting. - rooted, ok = 0, false - if certRooted, certOk := alpenglowRootedSlot(consensusEngine); certOk { - rooted, ok = certRooted, true - } else { - if time.Since(delegatedFinalizedAt) > 2*time.Second { - // Record the attempt time regardless of outcome, so an RPC - // outage doesn't re-issue a blocking poll on every block. - delegatedFinalizedAt = time.Now() - if fin, err := rpcc.GetSlotWithTimeoutAndCommitment(15*time.Second, rpc.CommitmentFinalized); err == nil { - delegatedFinalizedSlot = fin - } - } - if delegatedFinalizedSlot > 0 { - rooted, ok = delegatedFinalizedSlot, true + rooted, ok := uint64(0), false + if certRooted, certOk := alpenglowRootedSlot(consensusEngine); certOk { + rooted, ok = certRooted, true + } else { + if time.Since(delegatedFinalizedAt) > 2*time.Second { + // Record the attempt time regardless of outcome, so an RPC + // outage doesn't re-issue a blocking poll on every block. + delegatedFinalizedAt = time.Now() + if fin, err := rpcc.GetSlotWithTimeoutAndCommitment(15*time.Second, rpc.CommitmentFinalized); err == nil { + delegatedFinalizedSlot = fin } } + if delegatedFinalizedSlot > 0 { + rooted, ok = delegatedFinalizedSlot, true + } } if ok && rooted > lastRootedWatermark { lastRootedWatermark = rooted mlog.Log.Infof("forkchoice: rooted watermark advanced to slot %d", rooted) - - // Rooted-durable: fold the now-rooted prefix into the canonical store, - // advance the last rooted slot, and persist the resume context as of that slot. - if unrootedTailState != nil { - promoteThrough := rooted - // Fork-aware near-tip: only fold slots covered by a passed leaf - // bankhash check (chaining verifies all executed ancestors). - // During suspended catchup no leaf checks exist — promote on the - // raw 2/3-vote-root watermark (catchup blocks are sequential - // cluster-final data; same trust as bootstrap itself). - if acctsDb.ForkAware && consensusBufferedExecutionActive && promoteThrough > lastVerifiedLeafSlot { - promoteThrough = lastVerifiedLeafSlot - } - // Alpenglow: never fold a slot whose executed block contradicts - // certificate finality (prefix-stop; equivocation fails closed). - if alpenglowReplayMode && consensusEngine != nil { - gated, gerr := alpenglowPromotionGate(consensusEngine, - alpenglowFooterFinalized, alpenglowExecutedBlockIDs, alpenglowForced, - mithrilState.LastRootedSlot, promoteThrough, block.Slot, &gateStats) - promoteThrough = gated - if gerr != nil { - var mismatch *AlpenglowFinalityMismatch - if errors.As(gerr, &mismatch) { - recordAlpenglowEvidence(mithrilState, mismatch) - } - result.Error = fmt.Errorf("ALPENGLOW SAFETY: %w; halting before folding slot %d", gerr, gated+1) - mlog.Log.Errorf("%v", result.Error) - break - } - mlog.Log.FileOnlyf("alpenglow gate: checked=%d matched=%d no_finality=%d no_local_id=%d", - gateStats.checked, gateStats.matched, gateStats.noFinality, gateStats.noLocalID) - } - if promoteThrough > 0 { - promotedThrough, rootedCtx, perr := unrootedTailState.promote(promoteThrough) - if perr != nil { - mlog.Log.Errorf("rooted-durable: promotion stopped at slot %d: %v", promotedThrough, perr) - } - if promotedThrough > mithrilState.LastRootedSlot { - if rootedCtx == nil { - mlog.Log.Errorf("rooted-durable: promoted through slot %d with no resume context; watermark held back", promotedThrough) - } else { - mithrilState.LastRootedSlot = promotedThrough - mithrilState.LastRootedBankhash = rootedCtx.Bankhash - mithrilState.LastRootedContext = rootedCtx - for slot := range alpenglowFooterFinalized { - if slot <= promotedThrough { - delete(alpenglowFooterFinalized, slot) - } - } - for slot := range alpenglowExecutedBlockIDs { - if slot <= promotedThrough { - delete(alpenglowExecutedBlockIDs, slot) - } - } - // Disputed slots that promoted passed the exact-match - // requirement — the evidence is satisfied. - for slot := range alpenglowForced { - if slot <= promotedThrough { - delete(alpenglowForced, slot) - clearAlpenglowEvidence(mithrilState, slot) - } - } - } - } - } - } } } - - if consensusBufferedExecutionActive { - resolvedPath, err := consensusCoordinator.ResolveFromAnchor(currentConsensusAnchorSlot()) - if err != nil { - switch { - case errors.Is(err, forkchoice.ErrNeedWait), errors.Is(err, forkchoice.ErrPathIncomplete): - continue - case errors.Is(err, forkchoice.ErrDepthExceeded): - if consensusManagedLiveStream && isLive && useLiveShredStream { - anchorSlot := currentConsensusAnchorSlot() - discardedObservedBlocks := len(observedConsensusBlocks) - readyDecisionCount := 0 - if readyConsensusPath != nil { - readyDecisionCount = len(readyConsensusPath.decisions) - } - mlog.Log.Warnf("forkchoice: unable to resolve %s consensus path within %d slots from anchor %d after observing slot %d; falling back to RPC catchup (discarded_observed_blocks=%d discarded_ready_decisions=%d)", - consensusLiveStreamName, consensusCfg.maxDepth, anchorSlot, block.Slot, discardedObservedBlocks, readyDecisionCount) - consensusBufferedExecutionActive = false - readyConsensusPath = nil - clearObservedConsensusBlocks(observedConsensusBlocks) - observeConsensusAnchor() - blockStream.ForceRPCFallback("consensus_depth_exceeded") - continue - } - if consensusCoordinator.Policy() == "halt" { - result.Error = fmt.Errorf("forkchoice: unable to resolve a confirmed path within %d slots from anchor %d", - consensusCfg.maxDepth, currentConsensusAnchorSlot()) - break - } - mlog.Log.Warnf("forkchoice: path resolution exceeded max depth from anchor %d", currentConsensusAnchorSlot()) - continue - default: - if acctsDb.ForkAware && errors.Is(err, forkchoice.ErrEquivocation) { - // Fork-aware: an equivocated slot on the path is adjudicated by - // the leaf bankhash check + dump-then-repair; wait for more votes. - mlog.Log.Warnf("forkchoice: equivocation on path from anchor %d; waiting for confirmation to adjudicate", currentConsensusAnchorSlot()) - continue - } - mlog.Log.Warnf("forkchoice: failed to resolve a confirmed path from anchor %d after observing slot %d: %v", - currentConsensusAnchorSlot(), block.Slot, err) - result.Error = err - } - } - if result.Error != nil { - break - } - if resolvedPath == nil || len(resolvedPath.SlotDecisions) == 0 { - continue - } - - readyConsensusPath = newPendingConsensusPath(currentConsensusAnchorSlot(), resolvedPath) - continue + // Fold the rooted RAM prefix onto disk (irreversible) through the + // shared dual-watermark + Alpenglow gate. Runs every iteration so + // verified progress alone advances the watermark and a verifier + // divergence halts promptly even while finality is flat. + if foldRootedPrefix(false) { + break } + } if block == nil { @@ -2000,11 +1955,19 @@ func ReplayBlocks( if leader, exists := global.LeaderForSlot(block.Slot); exists { leaderStr = leader.String() } - // Log skipped slot in same format as regular blocks (with N/A for missing values) - // Padding: cu=10 chars, txns fields, exec/wait/total=%7.3fs = 8 chars (7 for number + 's') - mlog.Log.InfofPrecise("slot %-10d | leader: %-44s | txns: N/A | cu: N/A | exec: N/A | wait:%7.3fs | total:%7.3fs (skip)", - block.Slot, leaderStr, waitTime.Seconds(), waitTime.Seconds()) + // Terminal: aligned skipped-slot line, reporting any PARTIAL shred + // arrivals (leader sent something but the slot never became full). + // Full detail (wait) stays in logs. + partialShreds, repairedShreds, _, _ := blockStream.TurbineShredObservation(block.Slot) + mlog.Log.InfofPrecise("%s", buildSkippedStatsLine(block.Slot, leaderStr, partialShreds, repairedShreds)) + if partialShreds > 0 { + windowSkippedWithShreds++ + } + mlog.Log.FileOnlyf("slot %d skipped | leader %s | wait %.3fs | partial shreds %d (repair %d)", block.Slot, leaderStr, waitTime.Seconds(), partialShreds, repairedShreds) skippedSlotsCount++ + if trailingVerifier != nil { + trailingVerifier.RecordSkip(block.Slot) + } // A resolved skip still advances replay progress for near-tip mode and // consensus-managed Lightbringer delivery. blockStream.SetLastExecutedSlot(block.Slot) @@ -2046,19 +2009,6 @@ func ReplayBlocks( result.Error = configErr break } - if initialBlockConfigured { - // Initial block configuration rebuilds VoteCache and EpochAuthorizedVoters - // from AccountsDB. Forkchoice is created before that happens, so refresh - // its epoch view here to avoid using stale manifest voters after resume or - // an epoch boundary. - forkChoice.UpdateEpoch( - block.Epoch, - global.EpochStakes(block.Epoch), - global.EpochTotalStake(block.Epoch), - global.EpochAuthorizedVoters(), - ) - } - // Log replay start message once, after initial configuration completes if !replayStartLogged { fmt.Println() @@ -2074,40 +2024,52 @@ func ReplayBlocks( var newlyActivatedFeatures, parentNewlyActivatedFeatures []*accounts.Account replayCtx.CurrentFeatures, newlyActivatedFeatures, parentNewlyActivatedFeatures = scanAndEnableFeatures(acctsDb, replayCtx, currentSlot, true) - if alpenglowReplayMode { - applyAlpenglowRuntimeFeatureOverrides(replayCtx.CurrentFeatures, currentSlot) - } + applyAlpenglowRuntimeFeatureOverrides(replayCtx.CurrentFeatures, currentSlot) partitionedEpochRewardsEnabled = replayCtx.CurrentFeatures.IsActive(features.EnablePartitionedEpochReward) || replayCtx.CurrentFeatures.IsActive(features.EnablePartitionedEpochRewardsSuperfeature) partitionedRewardsInfo = handleEpochTransition(acctsDb, partitionedEpochRewardsEnabled, lastSlotCtx, replayCtx, epochSchedule, replayCtx.CurrentFeatures, block, currentEpoch, rpcc, dbgOpts) currentEpoch = block.Epoch justCrossedEpochBoundary = true - - // Refresh forkchoice with new epoch's stake weights and authorized voters - forkChoice.UpdateEpoch( - currentEpoch, - global.EpochStakes(currentEpoch), - global.EpochTotalStake(currentEpoch), - global.EpochAuthorizedVoters(), - ) + // While partitioned rewards are distributing, hold durable promotion + // BELOW the boundary block: the distribution bookkeeping exists only + // in RAM, so if the rooted watermark landed inside the window a crash + // would resume past the boundary with no way to rebuild it — the + // remaining partitions would be silently skipped and the first + // re-executed distribution slot would diverge. Holding keeps the + // boundary in the re-execution window so a resume rebuilds the + // bookkeeping by re-running it. Costs tail RAM for the window length + // (bounded by the OverCap halt if a huge stake count exceeds it — + // fail-closed; reconstructing from the EpochRewards sysvar is the + // eventual lift for mainnet-scale partition counts). + if partitionedRewardsInfo != nil && partitionedRewardsInfo.NumRewardPartitionsRemaining > 0 { + rewardsHoldBelowSlot = block.Slot + mlog.Log.Infof("epoch boundary: holding durable promotion below slot %d until %d reward partitions distribute", + block.Slot, partitionedRewardsInfo.NumRewardPartitionsRemaining) + } + + // Persist the freshly computed epoch stakes NOW: the state file is + // otherwise written only on graceful shutdown, so a hard crash any + // time after the boundary would resume at R+1 in the new epoch with + // no stakes for it — forcing a snapshot re-bootstrap and defeating + // the manifest-recovery design. Once per epoch; atomic tmp+rename. + if mithrilState != nil { + if all := serializeAllEpochStakes(); len(all) > 0 { + if mithrilState.ComputedEpochStakes == nil { + mithrilState.ComputedEpochStakes = make(map[uint64]string, len(all)) + } + for e, b := range all { + mithrilState.ComputedEpochStakes[e] = string(b) + } + if serr := mithrilState.Save(acctsDbPath); serr != nil { + mlog.Log.Errorf("failed to persist epoch %d stakes at the boundary (crash before next save would force re-bootstrap): %v", currentEpoch, serr) + } + } + } // Alpenglow: reinstall the BLS validator set for the new epoch. - if alpenglowReplayMode && consensusEngine != nil { + if consensusEngine != nil { installAlpenglowValidatorSet(consensusEngine, currentEpoch) } - // Persist rebuilt authorized voters to state file so resume loads fresh data - if cache := global.EpochAuthorizedVoters(); cache != nil && mithrilState != nil { - updatedVoters := make(map[string][]string, cache.Len()) - for voteAcct, voters := range cache.Entries() { - voterStrs := make([]string, len(voters)) - for i, v := range voters { - voterStrs[i] = base58.Encode(v[:]) - } - updatedVoters[base58.Encode(voteAcct[:])] = voterStrs - } - mithrilState.ManifestEpochAuthorizedVoters = updatedVoters - } - if len(newlyActivatedFeatures) != 0 { block.EpochUpdatedAccts = append(block.EpochUpdatedAccts, newlyActivatedFeatures...) block.ParentEpochUpdatedAccts = append(block.ParentEpochUpdatedAccts, parentNewlyActivatedFeatures...) @@ -2149,7 +2111,7 @@ func ReplayBlocks( metrics.GlobalBlockReplay.PreprocessBlock.AddTimingSince(start) - alpenglowClock := useAlpenglowClockSemantics(alpenglowReplayMode, replayCtx.CurrentFeatures) + alpenglowClock := true // Alpenglow-only node: footer-clock semantics always on lastSlotCtx, err = ProcessBlock(acctsDb, block, epochSchedule, txParallelism, dbgOpts, persistedHashes, unrootedTailState, alpenglowClock) if err != nil { mlog.Log.Errorf("error encountered during block replay: %s\n", err) @@ -2167,10 +2129,16 @@ func ReplayBlocks( break } global.SetBlockHeight(block.BlockHeight) + if trailingVerifier != nil { + trailingVerifier.Record(buildSlotDigest(block)) + } + if block.Slot > highestExecutedSlot { + highestExecutedSlot = block.Slot // bounds the promotion-gate walk (shared fold path) + } // Alpenglow: report the replayed slot's bankhash to the engine (drives cert // replay reconciliation). Log-and-continue — never break replay on telemetry. - if alpenglowReplayMode && consensusEngine != nil && lastSlotCtx != nil { + if consensusEngine != nil && lastSlotCtx != nil { // Record the executed identity here — execution is proven (a block // captured at observe time can still be discarded before it runs). if block.HasAlpenglowBlockID && unrootedTailState != nil { @@ -2190,62 +2158,13 @@ func ReplayBlocks( rpcServer.SetSlotCtx(lastSlotCtx) } - if consensusBufferedExecutionActive { - if readyConsensusPath != nil && block.Slot == readyConsensusPath.leafSlot { - actualBankhash := solana.HashFromBytes(lastSlotCtx.FinalBankhash) - if actualBankhash == readyConsensusPath.leafBankhash { - // Bankhash chaining: a matching leaf verifies every executed ancestor. - lastVerifiedLeafSlot = block.Slot - } - if actualBankhash != readyConsensusPath.leafBankhash { - mlog.Log.Errorf("CONSENSUS MISMATCH: replayed leaf slot %d to bankhash %s, but votes confirmed %s", - block.Slot, - base58.Encode(actualBankhash[:]), - base58.Encode(readyConsensusPath.leafBankhash[:]), - ) - writeConsensusArtifact( - fmt.Sprintf("bankhash_mismatch_slot_%d.json", block.Slot), - buildConsensusMismatchArtifact( - block, - lastSlotCtx, - readyConsensusPath, - actualBankhash, - blockStream.GetFetchStats(), - forkChoice, - consensusCoordinator.Policy(), - observedConsensusBlocks, - currentConsensusAnchorSlot(), - ), - ) - if consensusCoordinator.Policy() == "halt" { - if acctsDb.ForkAware { - // Fork-aware: typed error triggers dump-then-repair in the - // caller (drop RAM tail, re-replay the confirmed chain from - // the rooted checkpoint). An identical repeat fails closed. - result.Error = &ConfirmedDivergence{ - Slot: block.Slot, - Ours: actualBankhash, - Confirmed: readyConsensusPath.leafBankhash, - } - break - } - result.Error = fmt.Errorf("consensus halt: slot %d bankhash mismatch (our=%s winning=%s)", - block.Slot, base58.Encode(actualBankhash[:]), base58.Encode(readyConsensusPath.leafBankhash[:])) - break - } - } - readyConsensusPath = nil - } - observeConsensusAnchor() - pruneObservedConsensusBlocks(observedConsensusBlocks, currentConsensusAnchorSlot()) - } - replayCtx.Capitalization -= lastSlotCtx.LamportsBurnt // Rooted-durable: capture this slot's end-of-slot resume context (deep-copied, // no pointers into the global SysvarCache) and retain it in the tail until // promotion, so resume restarts from the last rooted slot not the lost in-RAM replayed tip. if unrootedTailState != nil && lastSlotCtx != nil { + txCountAtSlot := global.TransactionCount() // ProcessBlock already added this block's txs resumeCtx := &state.ResumeContext{ Slot: block.Slot, Bankhash: base58.Encode(lastSlotCtx.FinalBankhash), @@ -2263,6 +2182,7 @@ func ReplayBlocks( InflationTaper: replayCtx.Inflation.Taper, InflationFoundation: replayCtx.Inflation.FoundationVal, InflationFoundationTerm: replayCtx.Inflation.FoundationTerm, + TransactionCount: &txCountAtSlot, } if lastSlotCtx.AcctsLtHash != nil { resumeCtx.AcctsLtHash = base64.StdEncoding.EncodeToString(lastSlotCtx.AcctsLtHash.Hash()) @@ -2335,15 +2255,7 @@ func ReplayBlocks( slotReplayDuration := time.Since(start) - // Calculate slot stats: vote/non-vote tx counts and locally replayed CU. - var voteTxCount, nonVoteTxCount int - for _, tx := range block.Transactions { - if tx.IsVote() { - voteTxCount++ - } else { - nonVoteTxCount++ - } - } + txnCount := len(block.Transactions) totalCU := lastSlotCtx.TotalComputeUnitsConsumed // Get leader from block (set by configureBlock in live mode, or by block source in verify mode) @@ -2352,11 +2264,27 @@ func ReplayBlocks( leaderStr = block.Leader.String() } - // Fixed-width format for consistent alignment (use precise timing for block replay) - // exec/wait/total use 7 char width to handle times up to 99.999s without breaking alignment - totalSlotTime := waitTime + slotReplayDuration - mlog.Log.InfofPrecise("slot %-10d | leader: %-44s | txns: v:%-5d nv:%-5d | cu: %-10d | exec:%7.3fs | wait:%7.3fs | total:%7.3fs", - block.Slot, leaderStr, voteTxCount, nonVoteTxCount, totalCU, slotReplayDuration.Seconds(), waitTime.Seconds(), totalSlotTime.Seconds()) + // Terminal: concise per-slot line. Shred timings only for shred-sourced + // blocks (never fabricated for RPC/file). ready = assembly completion + // minus when replay asked for the slot (negative: ready that long + // early; positive: replay waited); asm = first shred -> full. + execMsLine := slotReplayDuration.Seconds() * 1000 + hasShreds := block.ShredFirstNanos > 0 && block.ShredFullNanos > 0 + var readySecsLine, asmSecsLine float64 + if hasShreds { + readySecsLine = float64(block.ShredFullNanos-neededAt.UnixNano()) / 1e9 + asmSecsLine = float64(block.ShredFullNanos-block.ShredFirstNanos) / 1e9 + } + mlog.Log.InfofPrecise("%s", buildSlotStatsLine(block.Slot, leaderStr, txnCount, totalCU, execMsLine, hasShreds, readySecsLine, asmSecsLine, block.RepairedShreds)) + // Full detail (wait, vote split) stays in file logs for debugging. + var voteTxCount int + for _, tx := range block.Transactions { + if tx.IsVote() { + voteTxCount++ + } + } + mlog.Log.FileOnlyf("slot %d detail | leader %s | txns v:%d nv:%d | exec %.3fs | wait %.3fs | total %.3fs", + block.Slot, leaderStr, voteTxCount, txnCount-voteTxCount, slotReplayDuration.Seconds(), waitTime.Seconds(), (waitTime + slotReplayDuration).Seconds()) // Write bankhash to log file if bankhashLogFile != nil { @@ -2375,10 +2303,18 @@ func ReplayBlocks( if !justCrossedEpochBoundary { statsCounter++ execTimes = append(execTimes, slotReplayDuration.Seconds()) - waitTimes = append(waitTimes, waitTime.Seconds()) cuValues = append(cuValues, totalCU) - voteTxCounts = append(voteTxCounts, uint64(voteTxCount)) - nonVoteTxCounts = append(nonVoteTxCounts, uint64(nonVoteTxCount)) + txnCounts = append(txnCounts, uint64(txnCount)) + if txnCount == 0 { + windowEmptyBlocks++ + } + if hasShreds { + shredSamples = append(shredSamples, shredSample{readySecs: readySecsLine, asmSecs: asmSecsLine}) + if block.RepairedShreds > 0 { + windowRepairedSlots++ + windowRepairedShreds += block.RepairedShreds + } + } // Trigger async tip refresh 5 slots before summary so it's fresh when we print if statsCounter == summaryInterval-5 { @@ -2386,229 +2322,158 @@ func ReplayBlocks( } if statsCounter == summaryInterval { - // Calculate statistics for float64 slices - medianFloat := func(vals []float64) float64 { - if len(vals) == 0 { - return 0 - } - sorted := make([]float64, len(vals)) - copy(sorted, vals) - sort.Float64s(sorted) - n := len(sorted) - if n%2 == 0 { - return (sorted[n/2-1] + sorted[n/2]) / 2 - } - return sorted[n/2] - } - minFloat := func(vals []float64) float64 { - if len(vals) == 0 { - return 0 - } - m := vals[0] - for _, v := range vals[1:] { - if v < m { - m = v - } - } - return m - } - maxFloat := func(vals []float64) float64 { - if len(vals) == 0 { - return 0 - } - m := vals[0] - for _, v := range vals[1:] { - if v > m { - m = v - } - } - return m + fetchStats := blockStream.GetFetchStats() + elapsed := time.Since(windowStart).Seconds() + slotsPerSec := 0.0 + if elapsed > 0 { + slotsPerSec = float64(statsCounter+skippedSlotsCount) / elapsed } - // Calculate statistics for uint64 slices - medianUint := func(vals []uint64) uint64 { - if len(vals) == 0 { - return 0 - } - sorted := make([]uint64, len(vals)) - copy(sorted, vals) - sort.Slice(sorted, func(i, j int) bool { return sorted[i] < sorted[j] }) - n := len(sorted) - if n%2 == 0 { - return (sorted[n/2-1] + sorted[n/2]) / 2 + execMs := make([]float64, len(execTimes)) + slowBlocks := 0 + for i, secs := range execTimes { + execMs[i] = secs * 1000 + if execMs[i] > 200 { + slowBlocks++ } - return sorted[n/2] } - minUint := func(vals []uint64) uint64 { - if len(vals) == 0 { - return 0 + effVals := make([]float64, 0, len(execMs)) + cuPerTx := make([]float64, 0, len(execMs)) + txF := make([]float64, 0, len(txnCounts)) + cuF := make([]float64, 0, len(cuValues)) + for i := range cuValues { + cuF = append(cuF, float64(cuValues[i])) + txF = append(txF, float64(txnCounts[i])) + if cuValues[i] > 0 { + effVals = append(effVals, execMs[i]/(float64(cuValues[i])/1e6)) } - m := vals[0] - for _, v := range vals[1:] { - if v < m { - m = v - } + if txnCounts[i] > 0 { + cuPerTx = append(cuPerTx, float64(cuValues[i])/float64(txnCounts[i])) } - return m } - maxUint := func(vals []uint64) uint64 { - if len(vals) == 0 { - return 0 + + mlog.Log.InfofPrecise("") + mlog.Log.InfofPrecise("=== 100 Slot Summary ===") + mlog.Log.InfofPrecise(" source: %s", fetchStats.CurrentSource) + + // Shred gaps only when a turbine receiver is live — never fabricated + // for RPC-only operation. + progress := fmt.Sprintf(" progress: %.1f slots/sec", slotsPerSec) + if latestShred, highestFull, edgesOK := blockStream.TurbineShredEdges(); edgesOK && latestShred > 0 { + replayGap := int64(latestShred) - int64(block.Slot) + fullGap := int64(latestShred) - int64(highestFull) + if replayGap < 0 { + replayGap = 0 } - m := vals[0] - for _, v := range vals[1:] { - if v > m { - m = v - } + if fullGap < 0 { + fullGap = 0 } - return m + progress += fmt.Sprintf(" | behind latest shred: replay %d, full %d", replayGap, fullGap) } - - // Compute total times (exec + wait for each block) - totalTimes := make([]float64, len(execTimes)) - for i := range execTimes { - totalTimes[i] = execTimes[i] + waitTimes[i] + if windowSkippedWithShreds > 0 { + progress += fmt.Sprintf(" | skipped %d (%d with shreds) | empty blocks %d", skippedSlotsCount, windowSkippedWithShreds, windowEmptyBlocks) + } else { + progress += fmt.Sprintf(" | skipped %d | empty blocks %d", skippedSlotsCount, windowEmptyBlocks) } + mlog.Log.InfofPrecise("%s", progress) - // Execution stats - medExec := medianFloat(execTimes) - minExec := minFloat(execTimes) - maxExec := maxFloat(execTimes) - - // Wait stats - medWait := medianFloat(waitTimes) - minWait := minFloat(waitTimes) - maxWait := maxFloat(waitTimes) - - // Total stats (only median needed - min/max can be inferred from execution + wait) - medTotal := medianFloat(totalTimes) - - // CU stats - medCU := medianUint(cuValues) - minCU := minUint(cuValues) - maxCU := maxUint(cuValues) - - // Txn stats - medVoteTx := medianUint(voteTxCounts) - medNonVoteTx := medianUint(nonVoteTxCounts) - - // Blocks per second based on median total time - var blocksPerSec float64 - if medTotal > 0 { - blocksPerSec = 1.0 / medTotal + finalizedStr := "--" + if lastRootedWatermark > 0 { + finalizedStr = fmt.Sprintf("%d", lastRootedWatermark) } - - // Get fetch stats (includes tip snapshot - refreshed at slot 95) - fetchStats := blockStream.GetFetchStats() - - // Calculate distance from tip using current slot (more accurate than TipAtSlot) - // TipAtSlot is when we started the refresh, but block.Slot is what we just executed - var tipDistanceStr string - currentSlotForTip := block.Slot - if fetchStats.ConfirmedTip > 0 { - var behindConfirmed uint64 - if currentSlotForTip < fetchStats.ConfirmedTip { - behindConfirmed = fetchStats.ConfirmedTip - currentSlotForTip + if windowSwitches > 0 { + mlog.Log.InfofPrecise(" consensus: finalized slot %s | switches %d (in-RAM %d, fallback %d)", finalizedStr, windowSwitches, windowSwitchInRAM, windowSwitchFallback) + if len(switchFallbackReasons) > 0 { + mlog.Log.FileOnlyf("switch fallback reasons this window: %v", switchFallbackReasons) } - tipDistanceStr = fmt.Sprintf("%d slots behind confirmed", behindConfirmed) } else { - tipDistanceStr = "tip unknown" + mlog.Log.InfofPrecise(" consensus: finalized slot %s | switches 0", finalizedStr) } - // Print summary in reorganized format - mlog.Log.InfofPrecise("") - mlog.Log.InfofPrecise("=== 100 Slot Summary ===") - - // Line 1: Mode, blocks/sec, skipped slots, tip distance - modeStr := "catchup" - if fetchStats.IsNearTip { - modeStr = "near-tip" - } - if skippedSlotsCount > 0 { - mlog.Log.InfofPrecise(" mode: %s | %.1f blocks/sec | %d skipped | %s", - modeStr, blocksPerSec, skippedSlotsCount, tipDistanceStr) - } else { - mlog.Log.InfofPrecise(" mode: %s | %.1f blocks/sec | %s", - modeStr, blocksPerSec, tipDistanceStr) + checkedStr := "--" + if trailingVerifier != nil { + if vw := trailingVerifier.VerifiedWatermark(); vw > 0 { + checkedStr = fmt.Sprintf("%d", vw) + } } - - // Line 2: Current block source - mlog.Log.InfofPrecise(" block source: %s", formatBlockSourceStatus(fetchStats)) - if consensusBufferedExecutionActive { - readyDecisionCount := 0 - readyLeafSlot := uint64(0) - if readyConsensusPath != nil { - readyDecisionCount = len(readyConsensusPath.decisions) - readyLeafSlot = readyConsensusPath.leafSlot + mlog.Log.InfofPrecise(" safety: exec checked slot %s | holds %d", checkedStr, promotionHolds) + + if len(shredSamples) > 0 { + ready := make([]float64, 0, len(shredSamples)) + asm := make([]float64, 0, len(shredSamples)) + for _, s := range shredSamples { + ready = append(ready, s.readySecs) + asm = append(asm, s.asmSecs) } - mlog.Log.InfofPrecise(" consensus buffer: observed=%d ready_decisions=%d anchor=%d ready_leaf=%d", - len(observedConsensusBlocks), readyDecisionCount, currentConsensusAnchorSlot(), readyLeafSlot) + mlog.Log.InfofPrecise(" shreds: ready median %+.1fs, worst %+.1fs (neg = assembled before replay needed it) | asm median %.1fs, max %.1fs", + medianF(ready), maxF(ready), medianF(asm), maxF(asm)) + mlog.Log.InfofPrecise(" repair: %d slots, %d shreds", windowRepairedSlots, windowRepairedShreds) } - // Line 3: CU and transaction stats (median/min/max) - mlog.Log.InfofPrecise(" cu: median %d, min %d, max %d | txns: median vote %d, median non-vote %d", - medCU, minCU, maxCU, medVoteTx, medNonVoteTx) + mlog.Log.InfofPrecise(" txns: median %.0f | p90 %.0f | max %.0f | cu/tx median %s | p90 %s", + medianF(txF), percentileF(txF, 90), maxF(txF), fmtK(medianF(cuPerTx)), fmtK(percentileF(cuPerTx, 90))) + mlog.Log.InfofPrecise(" cu: median %s | p90 %s | max %s", + fmtMcu(uint64(medianF(cuF))), fmtMcu(uint64(percentileF(cuF, 90))), fmtMcu(uint64(maxF(cuF)))) + mlog.Log.InfofPrecise(" execution: median %.0fms | p95 %.0fms | max %.0fms | >200ms %d", + medianF(execMs), percentileF(execMs, 95), maxF(execMs), slowBlocks) + mlog.Log.InfofPrecise(" efficiency: median %.1fms/Mcu | p95 %.1fms/Mcu | max %.1fms/Mcu", + medianF(effVals), percentileF(effVals, 95), maxF(effVals)) - // Line 4: Execution stats (median/min/max for execution, wait; median for replay total) - mlog.Log.InfofPrecise(" execution: median %.3fs, min %.3fs, max %.3fs | wait: median %.3fs, min %.3fs, max %.3fs | replay total: median %.3fs", - medExec, minExec, maxExec, medWait, minWait, maxWait, medTotal) + var mem runtime.MemStats + runtime.ReadMemStats(&mem) + const gib = 1024 * 1024 * 1024 + gcDelta := mem.NumGC - lastGCCount + lastGCCount = mem.NumGC + resLine := " resources:" + if rss := processRSSBytes(); rss > 0 { + resLine += fmt.Sprintf(" rss %.1fGiB |", float64(rss)/gib) + } + resLine += fmt.Sprintf(" heap %.1fGiB | heap inuse %.1fGiB | gc %d", + float64(mem.HeapAlloc)/gib, float64(mem.HeapInuse)/gib, gcDelta) + mlog.Log.InfofPrecise("%s", resLine) + mlog.Log.InfofPrecise("") - // Account clone stats for copy-on-write optimization profiling + // Detailed debugging stays in file logs. cloneStats := GetAndResetCloneStats() if cloneStats.TxCount > 0 { var cloneRatio float64 if cloneStats.AcctsLoaded > 0 { cloneRatio = float64(cloneStats.AcctsCloned) / float64(cloneStats.AcctsLoaded) * 100 } - avgLoadedPerTx := float64(cloneStats.AcctsLoaded) / float64(cloneStats.TxCount) - avgClonedPerTx := float64(cloneStats.AcctsCloned) / float64(cloneStats.TxCount) - avgTouchedPerTx := float64(cloneStats.AcctsTouched) / float64(cloneStats.TxCount) - loadedMB := float64(cloneStats.AcctsLoadedBytes) / 1024 / 1024 - clonedMB := float64(cloneStats.AcctsClonedBytes) / 1024 / 1024 - touchedMB := float64(cloneStats.AcctsTouchedBytes) / 1024 / 1024 - mlog.Log.InfofPrecise(" account COW: %.1f%% cloned on write (%d/%d accts) | %.1fMB loaded, %.1fMB cloned, %.1fMB modified | avg/tx: %.1f loaded, %.1f cloned, %.1f modified", + mlog.Log.FileOnlyf("account COW: %.1f%% cloned (%d/%d accts) | %.1fMB loaded, %.1fMB cloned, %.1fMB modified", cloneRatio, cloneStats.AcctsCloned, cloneStats.AcctsLoaded, - loadedMB, clonedMB, touchedMB, avgLoadedPerTx, avgClonedPerTx, avgTouchedPerTx) + float64(cloneStats.AcctsLoadedBytes)/1024/1024, float64(cloneStats.AcctsClonedBytes)/1024/1024, float64(cloneStats.AcctsTouchedBytes)/1024/1024) } - - var mem runtime.MemStats - runtime.ReadMemStats(&mem) - const gib = 1024 * 1024 * 1024 - mlog.Log.InfofPrecise(" memory: alloc %.1fGiB | inuse %.1fGiB | idle %.1fGiB | released %.1fGiB | next_gc %.1fGiB | objs %d | gc %d | queue=%d", - float64(mem.HeapAlloc)/gib, - float64(mem.HeapInuse)/gib, - float64(mem.HeapIdle)/gib, - float64(mem.HeapReleased)/gib, - float64(mem.NextGC)/gib, - mem.HeapObjects, - mem.NumGC, - acctsDb.StoreQueueLen(), - ) - - // Line 5: RPC/fetch debugging info + mlog.Log.FileOnlyf("memory detail: alloc %.1fGiB | inuse %.1fGiB | idle %.1fGiB | released %.1fGiB | next_gc %.1fGiB | objs %d | gc_total %d | store_queue %d", + float64(mem.HeapAlloc)/gib, float64(mem.HeapInuse)/gib, float64(mem.HeapIdle)/gib, + float64(mem.HeapReleased)/gib, float64(mem.NextGC)/gib, mem.HeapObjects, mem.NumGC, acctsDb.StoreQueueLen()) if fetchStats.Attempts > 0 { retryRate := float64(fetchStats.Retries) / float64(fetchStats.Attempts) * 100 - prefetch := fetchStats.BufferDepth + fetchStats.ReorderBufLen - mlog.Log.InfofPrecise(" getBlock fetch: %.1f rps (%d calls) | avg %.0fms | %.0f%% success | retries %.1f%% | buf %d (stream:%d ro:%d) | wq %d | errs: na:%d rl:%d bt:%d tr:%d", - fetchStats.GetBlockRPS, fetchStats.Attempts, fetchStats.AvgLatencyMs, fetchStats.SuccessRate, retryRate, prefetch, fetchStats.BufferDepth, fetchStats.ReorderBufLen, - fetchStats.WorkQueueLen, fetchStats.ErrNotAvail, fetchStats.ErrRateLimit, fetchStats.ErrBeyondTip, fetchStats.ErrTransient) - - // Surface tip poll issues (only show if there are problems) + mlog.Log.FileOnlyf("getBlock fetch: %.1f rps (%d calls) | avg %.0fms | %.0f%% success | retries %.1f%% | errs: na:%d rl:%d bt:%d tr:%d", + fetchStats.GetBlockRPS, fetchStats.Attempts, fetchStats.AvgLatencyMs, fetchStats.SuccessRate, retryRate, + fetchStats.ErrNotAvail, fetchStats.ErrRateLimit, fetchStats.ErrBeyondTip, fetchStats.ErrTransient) if fetchStats.TipStaleSecs > 30 || fetchStats.TotalTipPollFails > 0 { mlog.Log.InfofPrecise(" WARNING: tip stale %ds | tip poll fails: %d (consecutive: %d)", fetchStats.TipStaleSecs, fetchStats.TotalTipPollFails, fetchStats.TipPollFailures) } - blockStream.ResetStats() } - mlog.Log.InfofPrecise("") - // Reset slices (reuse capacity) + // Reset window collectors (reuse capacity) execTimes = execTimes[:0] - waitTimes = waitTimes[:0] cuValues = cuValues[:0] - voteTxCounts = voteTxCounts[:0] - nonVoteTxCounts = nonVoteTxCounts[:0] + txnCounts = txnCounts[:0] + shredSamples = shredSamples[:0] + windowRepairedShreds = 0 + windowRepairedSlots = 0 + windowEmptyBlocks = 0 + windowSkippedWithShreds = 0 + windowSwitches = 0 + windowSwitchInRAM = 0 + windowSwitchFallback = 0 + clear(switchFallbackReasons) + promotionHolds = 0 + windowStart = time.Now() statsCounter = 0 skippedSlotsCount = 0 } @@ -2635,6 +2500,25 @@ func ReplayBlocks( result.Error = fmt.Errorf("block fetch stalled - no progress for %v", blockStream.StallTimeout()) } + // Graceful shutdown: force-fold the trailing partial chunk of the rooted + // prefix through the SAME dual-watermark + Alpenglow gate as the in-loop + // path, so a Ctrl+C can never fold a slot normal promotion would refuse. + // Bounds restart re-execution to the chunk size instead of chunk size plus + // however long the partial ran. + if unrootedTailState != nil { + preFlushRooted := mithrilState.LastRootedSlot + foldRootedPrefix(true) + // The cancel-path state save ran BEFORE this flush; if the flush + // advanced the watermark, re-save so the state file matches the store + // exactly. (Without this, startup's store-ahead reconcile still adopts + // the manifest context — this just keeps the file authoritative.) + if result.StateWrittenOnCancel && onCancelWriteState != nil && mithrilState.LastRootedSlot > preFlushRooted { + if err := onCancelWriteState(result); err != nil { + mlog.Log.Errorf("failed to re-write state after shutdown flush (recovery reconcile will cover it): %v", err) + } + } + } + acctsDb.WaitForStoreWorker() result.LastPersistedSlot, result.LastPersistedBankhash = persistedHashes.Get() result.LastBlockHeight = global.BlockHeight() @@ -2791,7 +2675,6 @@ func newSlotCtx(block *b.Block, accts accounts.Accounts, parentAccts accounts.Ac SerializedParameterArena: SerializedParameterArena, } - // Guard: a nil *unrootedTail stored in the AccountReader interface would be a // non-nil typed-nil and break the nil check in GetAccountFromAccountsDb. if tail != nil { @@ -3054,8 +2937,9 @@ func ProcessBlock( // persistedHashes is updated after StoreAccounts completes through a callback. // Must be non-nil. persistedHashes *persistedTracker, - // tail is the in-RAM unrooted overlay in rooted-durable mode; nil in legacy - // mode. When set, block reads resolve through it and commits buffer into it. + // tail is the in-RAM working set in rooted-durable mode; nil when rooted- + // durable is off. When set, block reads resolve through it and commits + // buffer into it. tail unrootedState, alpenglowClock bool, ) (*sealevel.SlotCtx, error) { @@ -3226,22 +3110,22 @@ func ProcessBlock( if tail != nil { // Rooted-durable: accounts + bankhash are buffered in the overlay and // become durable only on promotion; nothing written here (rooted-only). - } else if acctsDb.DurableCommit { - // CommitSlotAtomic already stored accounts + bankhash durably; finalize - // the crash-safe commit by removing its redo record. - if derr := accountsdb.DeleteRedo(acctsDb.AcctsDir, persistedSlot); derr != nil { - mlog.Log.Errorf("failed to delete redo for slot %d: %v", persistedSlot, derr) - } } else { if berr := acctsDb.StoreBankHashForSlot(persistedSlot, persistedBankhash); berr != nil { mlog.Log.Infof("unable to store bankhash for slot %d", persistedSlot) } } - flushed, err := global.FlushPendingStakePubkeys(stakeIndexDir) - if err != nil { - mlog.Log.Errorf("failed to flush stake pubkey index: %v", err) - } else if flushed > 0 { - mlog.Log.Debugf("flushed %d new stake pubkeys to index", flushed) + if tail == nil { + // Legacy/verify modes (no fork ambiguity): flush per block as before. + // Rooted-durable replay flushes at FOLD time instead — entries stay + // slot-scoped in RAM so a fork unwind can drop them, and scans merge + // the pending set (StreamStakeAccounts) for completeness meanwhile. + flushed, err := global.FlushPendingStakePubkeys(stakeIndexDir) + if err != nil { + mlog.Log.Errorf("failed to flush stake pubkey index: %v", err) + } else if flushed > 0 { + mlog.Log.Debugf("flushed %d new stake pubkeys to index", flushed) + } } persistedHashes.Set(persistedBlockSlot, persistedBankhash) @@ -3256,10 +3140,6 @@ func ProcessBlock( // (always, even when empty, so the bankhash is recorded); no durable write. tail.Add(slotCtx.Slot, modifiedAccts, persistedBankhash) afterStoreAccounts() - } else if acctsDb.DurableCommit { - // Always enqueue (even with no modified accounts) so the commit window is - // always closed and the bankhash recorded — avoids the empty-block hang. - err = acctsDb.StoreAccountsDurable(modifiedAccts, slotCtx.Slot, persistedBankhash, afterStoreAccounts) } else if len(modifiedAccts) > 0 { err = acctsDb.StoreAccounts(modifiedAccts, slotCtx.Slot, afterStoreAccounts) } diff --git a/pkg/replay/consensus.go b/pkg/replay/consensus.go index 3c8bf76b7..dd8f59bfb 100644 --- a/pkg/replay/consensus.go +++ b/pkg/replay/consensus.go @@ -1,158 +1,12 @@ package replay import ( - b "github.com/Overclock-Validator/mithril/pkg/block" - "github.com/Overclock-Validator/mithril/pkg/blockstream" consensusengine "github.com/Overclock-Validator/mithril/pkg/consensus" - "github.com/Overclock-Validator/mithril/pkg/forkchoice" - "github.com/Overclock-Validator/mithril/pkg/mlog" - "github.com/gagliardetto/solana-go" ) -const ( - defaultConsensusMaxDepth = 64 - defaultConsensusPolicy = "halt" - defaultConsensusEnforceSource = "stream" -) - -// ConsensusOpts contains vote-anchored consensus configuration. -// Nil means use defaults (max_depth=64, policy="halt"). +// ConsensusOpts carries the Alpenglow consensus engine into replay. +// Nil (or a nil Engine) runs replay without certificate finality — promotion +// then relies solely on delegated (RPC-attested) finality. type ConsensusOpts struct { - SkipPathMaxDepth int // Max slots for skip-path solver (default: 64) - UnresolvedPolicy string // "halt" or "warn" (default: "halt") - EnforceOnSource string // "lightbringer", "turbine", "stream", or "all" (default: "stream") - Mode string // "classic", "alpenglow-observer", or "alpenglow" (default: "classic") - Engine consensusengine.Engine -} - -type consensusConfig struct { - maxDepth int - policy string - enforceSource string - enforceActive bool - bufferedExecutionActive bool -} - -// pendingConsensusPath tracks a vote-resolved path that replay has observed but -// has not yet executed through to the confirmed leaf. -type pendingConsensusPath struct { - anchorSlot uint64 - leafSlot uint64 - leafBankhash solana.Hash - decisions []forkchoice.SlotDecision - originalDecisions []forkchoice.SlotDecision -} - -func resolveConsensusConfig(opts *ConsensusOpts, useLightbringer, useTurbine, isLive bool) consensusConfig { - cfg := consensusConfig{ - maxDepth: defaultConsensusMaxDepth, - policy: defaultConsensusPolicy, - enforceSource: defaultConsensusEnforceSource, - } - - if opts != nil { - if opts.SkipPathMaxDepth > 0 { - cfg.maxDepth = opts.SkipPathMaxDepth - } - if opts.UnresolvedPolicy != "" { - cfg.policy = opts.UnresolvedPolicy - } - if opts.EnforceOnSource != "" { - cfg.enforceSource = opts.EnforceOnSource - } - if opts.Mode != "" { - mode, err := consensusengine.NormalizeMode(opts.Mode) - if err != nil { - mlog.Log.Warnf("%v; defaulting to %q", err, consensusengine.ModeClassic) - } else if mode != consensusengine.ModeClassic { - cfg.enforceActive = false - cfg.bufferedExecutionActive = false - return cfg - } - } - } - - if isLive && useTurbine && !useLightbringer && cfg.enforceSource == "lightbringer" { - mlog.Log.Warnf("forkchoice: consensus.enforce_on_source=%q is legacy Lightbringer-only while block source is native turbine; treating it as %q for this run", - cfg.enforceSource, "turbine") - cfg.enforceSource = "turbine" - } - - switch cfg.enforceSource { - case "lightbringer", "turbine", "stream", "all": - default: - mlog.Log.Warnf("forkchoice: invalid EnforceOnSource=%q, defaulting to %q", cfg.enforceSource, defaultConsensusEnforceSource) - cfg.enforceSource = defaultConsensusEnforceSource - } - - cfg.enforceActive = consensusAppliesToRun(cfg.enforceSource, useLightbringer, useTurbine) - cfg.bufferedExecutionActive = !isLive || cfg.enforceSource == "all" - return cfg -} - -func consensusAppliesToRun(enforceSource string, useLightbringer, useTurbine bool) bool { - switch enforceSource { - case "all": - return true - case "stream": - return useLightbringer || useTurbine - case "lightbringer": - return useLightbringer - case "turbine": - return useTurbine - default: - return false - } -} - -func consensusManagesLiveShredStream(enforceSource string, useLightbringer, useTurbine bool) bool { - switch enforceSource { - case "stream", "all": - return useLightbringer || useTurbine - case "lightbringer": - return useLightbringer - case "turbine": - return useTurbine - default: - return false - } -} - -func newPendingConsensusPath(anchorSlot uint64, resolvedPath *forkchoice.ResolvedPath) *pendingConsensusPath { - if resolvedPath == nil { - return nil - } - decisions := append([]forkchoice.SlotDecision(nil), resolvedPath.SlotDecisions...) - return &pendingConsensusPath{ - anchorSlot: anchorSlot, - leafSlot: resolvedPath.LeafSlot, - leafBankhash: resolvedPath.LeafBankhash, - decisions: append([]forkchoice.SlotDecision(nil), decisions...), - originalDecisions: decisions, - } -} - -func pruneObservedConsensusBlocks(blocks map[uint64]*b.Block, anchorSlot uint64) { - if blocks == nil || anchorSlot == 0 { - return - } - for slot := range blocks { - if slot <= anchorSlot { - delete(blocks, slot) - } - } -} - -func clearObservedConsensusBlocks(blocks map[uint64]*b.Block) { - for slot := range blocks { - delete(blocks, slot) - } -} - -func shouldDiscardLightbringerObservationAfterFallback(isLive, useLightbringer bool, block *b.Block, stats blockstream.FetchStatsSnapshot) bool { - return isLive && - useLightbringer && - block != nil && - block.FromLightbringer && - (!stats.IsNearTip || (stats.CurrentSource != "lightbringer" && stats.CurrentSource != "turbine")) + Engine consensusengine.Engine } diff --git a/pkg/replay/consensus_fallback_test.go b/pkg/replay/consensus_fallback_test.go deleted file mode 100644 index 6ee123b04..000000000 --- a/pkg/replay/consensus_fallback_test.go +++ /dev/null @@ -1,111 +0,0 @@ -package replay - -import ( - "testing" - - b "github.com/Overclock-Validator/mithril/pkg/block" - "github.com/Overclock-Validator/mithril/pkg/blockstream" -) - -func TestShouldDiscardLightbringerObservationAfterFallback(t *testing.T) { - lightbringerBlock := &b.Block{Slot: 123, FromLightbringer: true} - - if !shouldDiscardLightbringerObservationAfterFallback(true, true, lightbringerBlock, blockstream.FetchStatsSnapshot{ - IsNearTip: false, - CurrentSource: "rpc", - }) { - t.Fatalf("expected Lightbringer observation to be discarded after catchup fallback") - } - - if !shouldDiscardLightbringerObservationAfterFallback(true, true, lightbringerBlock, blockstream.FetchStatsSnapshot{ - IsNearTip: true, - CurrentSource: "rpc", - }) { - t.Fatalf("expected Lightbringer observation to be discarded while near-tip has not handed back to Lightbringer") - } - - if shouldDiscardLightbringerObservationAfterFallback(true, true, lightbringerBlock, blockstream.FetchStatsSnapshot{ - IsNearTip: true, - CurrentSource: "lightbringer", - }) { - t.Fatalf("expected active Lightbringer observations to be retained") - } - - if shouldDiscardLightbringerObservationAfterFallback(true, true, lightbringerBlock, blockstream.FetchStatsSnapshot{ - IsNearTip: true, - CurrentSource: "turbine", - }) { - t.Fatalf("expected active native turbine observations to be retained") - } - - if shouldDiscardLightbringerObservationAfterFallback(true, true, &b.Block{Slot: 123}, blockstream.FetchStatsSnapshot{ - IsNearTip: false, - CurrentSource: "rpc", - }) { - t.Fatalf("expected RPC block to be retained") - } - - if !shouldDiscardLightbringerObservationAfterFallback(true, true, &b.Block{Slot: 123, IsSkipped: true, FromLightbringer: true}, blockstream.FetchStatsSnapshot{ - IsNearTip: false, - CurrentSource: "rpc", - }) { - t.Fatalf("expected queued live-stream skip marker to be discarded after catchup fallback") - } -} - -func TestResolveConsensusConfigAppliesToNativeTurbineByDefault(t *testing.T) { - cfg := resolveConsensusConfig(nil, false, true, true) - if !cfg.enforceActive { - t.Fatalf("default stream consensus should apply to native turbine") - } - if cfg.enforceSource != "stream" { - t.Fatalf("default consensus source = %q, want stream", cfg.enforceSource) - } - if cfg.bufferedExecutionActive { - t.Fatalf("live turbine consensus should arm buffered execution only after handoff") - } - - cfg = resolveConsensusConfig(&ConsensusOpts{EnforceOnSource: "lightbringer"}, false, true, true) - if !cfg.enforceActive || cfg.enforceSource != "turbine" { - t.Fatalf("legacy lightbringer consensus should be upgraded for native turbine, got active=%v source=%q", cfg.enforceActive, cfg.enforceSource) - } - if cfg.bufferedExecutionActive { - t.Fatalf("live turbine consensus should arm buffered execution only after handoff") - } - - cfg = resolveConsensusConfig(&ConsensusOpts{EnforceOnSource: "turbine"}, false, true, true) - if !cfg.enforceActive { - t.Fatalf("explicit turbine consensus should apply to native turbine") - } - if cfg.bufferedExecutionActive { - t.Fatalf("live turbine consensus should arm buffered execution only after handoff") - } - - cfg = resolveConsensusConfig(&ConsensusOpts{EnforceOnSource: "stream"}, false, true, true) - if !cfg.enforceActive { - t.Fatalf("stream consensus should apply to native turbine") - } - - cfg = resolveConsensusConfig(&ConsensusOpts{EnforceOnSource: "lightbringer"}, true, false, true) - if !cfg.enforceActive { - t.Fatalf("lightbringer consensus should apply to lightbringer") - } - - cfg = resolveConsensusConfig(&ConsensusOpts{EnforceOnSource: "all"}, false, false, true) - if !cfg.enforceActive || !cfg.bufferedExecutionActive { - t.Fatalf("all consensus should apply immediately") - } -} - -func TestResolveConsensusConfigDisablesClassicGateForAlpenglowObserver(t *testing.T) { - cfg := resolveConsensusConfig(&ConsensusOpts{ - Mode: "alpenglow-observer", - EnforceOnSource: "stream", - }, false, true, true) - if cfg.enforceActive { - t.Fatalf("alpenglow observer should not run classic vote-anchored enforcement") - } - if cfg.bufferedExecutionActive { - t.Fatalf("alpenglow observer should not arm classic buffered execution") - } -} diff --git a/pkg/replay/diagnostics.go b/pkg/replay/diagnostics.go index 35dacfcb5..12c236124 100644 --- a/pkg/replay/diagnostics.go +++ b/pkg/replay/diagnostics.go @@ -6,16 +6,11 @@ import ( "fmt" "os" "path/filepath" - "sort" - "time" "github.com/Overclock-Validator/mithril/pkg/base58" b "github.com/Overclock-Validator/mithril/pkg/block" - "github.com/Overclock-Validator/mithril/pkg/blockstream" - "github.com/Overclock-Validator/mithril/pkg/forkchoice" "github.com/Overclock-Validator/mithril/pkg/lthash" "github.com/Overclock-Validator/mithril/pkg/mlog" - "github.com/Overclock-Validator/mithril/pkg/sealevel" "github.com/gagliardetto/solana-go" "github.com/gagliardetto/solana-go/rpc" ) @@ -230,41 +225,6 @@ func consensusBlockDiagnostic(block *b.Block) map[string]any { } } -func consensusObservedBlocksDiagnostic(blocks map[uint64]*b.Block) []map[string]any { - slots := make([]uint64, 0, len(blocks)) - for slot := range blocks { - slots = append(slots, slot) - } - sort.Slice(slots, func(i, j int) bool { return slots[i] < slots[j] }) - - out := make([]map[string]any, 0, len(slots)) - for _, slot := range slots { - block := blocks[slot] - out = append(out, map[string]any{ - "slot": block.Slot, - "source_parent_slot": block.SourceParentSlot, - "from_lightbringer": block.FromLightbringer, - "is_skipped": block.IsSkipped, - "blockhash": consensusHashString(block.Blockhash), - "last_blockhash": consensusHashString(block.LastBlockhash), - "tx_count": len(block.Transactions), - "entry_count": len(block.Entries), - }) - } - return out -} - -func consensusDecisionDiagnostics(decisions []forkchoice.SlotDecision) []map[string]any { - out := make([]map[string]any, 0, len(decisions)) - for _, decision := range decisions { - out = append(out, map[string]any{ - "slot": decision.Slot, - "use_block": decision.UseBlock, - }) - } - return out -} - // writeConsensusArtifact writes a best-effort JSON diagnostic artifact to the // per-run consensus subdirectory. If the log dir is empty or any step fails, // it logs a warning and continues; artifact failure must not crash replay. @@ -290,72 +250,3 @@ func writeConsensusArtifact(filename string, data map[string]interface{}) { } mlog.Log.FileOnlyf("consensus artifact written: %s", artifactPath) } - -func buildConsensusMismatchArtifact( - block *b.Block, - slotCtx *sealevel.SlotCtx, - path *pendingConsensusPath, - actualBankhash solana.Hash, - fetchStats blockstream.FetchStatsSnapshot, - forkChoice *forkchoice.ForkChoiceService, - consensusPolicy string, - observedConsensusBlocks map[uint64]*b.Block, - executionAnchorAfterReplay uint64, -) map[string]interface{} { - artifact := map[string]interface{}{ - "type": "bankhash_mismatch", - "checked_slot": block.Slot, - "our_bankhash": base58.Encode(actualBankhash[:]), - "winning_bankhash": base58.Encode(path.leafBankhash[:]), - "policy": consensusPolicy, - "run_id": CurrentRunID, - "created_at": time.Now().UTC().Format(time.RFC3339Nano), - "path_anchor_slot": path.anchorSlot, - "execution_anchor_after_replay": executionAnchorAfterReplay, - "source": map[string]interface{}{ - "current_source": fetchStats.CurrentSource, - "source_status": fetchStats.SourceStatus, - "is_near_tip": fetchStats.IsNearTip, - "next_slot": fetchStats.NextSlot, - "confirmed_tip": fetchStats.ConfirmedTip, - "processed_tip": fetchStats.ProcessedTip, - "handoff_slot": fetchStats.HandoffSlot, - "waiting_slot_state": fetchStats.WaitingSlotState, - "waiting_slot_retries": fetchStats.WaitingSlotRetries, - "inflight": fetchStats.InflightCount, - "retry_queue_len": fetchStats.RetryQueueLen, - "stream_buffer_depth": fetchStats.BufferDepth, - "reorder_buffer_len": fetchStats.ReorderBufLen, - }, - "block": consensusBlockDiagnostic(block), - "forkchoice_vote_summary": forkChoice.SlotVoteDiagnostics(path.leafSlot), - "parent_vote_summary": forkChoice.SlotVoteDiagnostics(block.ParentSlot), - "observed_consensus_blocks": consensusObservedBlocksDiagnostic(observedConsensusBlocks), - "resolved_path": map[string]interface{}{ - "anchor_slot": path.anchorSlot, - "leaf_slot": path.leafSlot, - "leaf_bankhash": base58.Encode(path.leafBankhash[:]), - "remaining_decisions": consensusDecisionDiagnostics(path.decisions), - "original_decisions": consensusDecisionDiagnostics(path.originalDecisions), - }, - } - if slotCtx != nil { - artifact["slot_context"] = map[string]interface{}{ - "slot": slotCtx.Slot, - "parent_slot": slotCtx.ParentSlot, - "epoch": slotCtx.Epoch, - "blockhash": consensusHashString(slotCtx.Blockhash), - "last_blockhash": consensusHashString(slotCtx.LastBlockhash), - "latest_evicted_blockhash": consensusHashString(slotCtx.LatestEvictedBlockhash), - "final_bankhash": consensusByteHashString(slotCtx.FinalBankhash), - "accts_lthash_checksum": consensusLtHashChecksum(slotCtx.AcctsLtHash), - "num_signatures": slotCtx.NumSignatures, - "lamports_burnt": slotCtx.LamportsBurnt, - "total_compute_units_consumed": slotCtx.TotalComputeUnitsConsumed, - "modified_account_count": len(slotCtx.ModifiedAccts), - "writable_account_count": len(slotCtx.WritableAccts), - "total_epoch_stake": slotCtx.TotalEpochStake, - } - } - return artifact -} diff --git a/pkg/replay/epoch.go b/pkg/replay/epoch.go index 58f4c9e64..0e8fff0d7 100644 --- a/pkg/replay/epoch.go +++ b/pkg/replay/epoch.go @@ -186,13 +186,10 @@ func updateStakeHistorySysvar(acctsDb *accountsdb.AccountsDb, block *block.Block } func handleEpochTransition(acctsDb *accountsdb.AccountsDb, partitionedEpochRewards bool, prevSlotCtx *sealevel.SlotCtx, replayCtx *ReplayCtx, epochSchedule *sealevel.SysvarEpochSchedule, f *features.Features, block *block.Block, epoch uint64, rpcc *rpcclient.RpcClient, dbgOpts *DebugOptions) *rewards.PartitionedRewardDistributionInfo { - // Flush any pending stake pubkeys to the index file before scanning. - // The async StoreAccounts callback from the previous block may not have - // run yet, so flush here to ensure the index is complete for the scan. - acctsDbDir := filepath.Join(acctsDb.AcctsDir, "..") - if _, err := global.FlushPendingStakePubkeys(acctsDbDir); err != nil { - mlog.Log.Errorf("failed to flush stake pubkeys before epoch scan: %v", err) - } + // No pre-scan index flush: StreamStakeAccounts merges the RAM-pending + // stake entries (slots not yet folded) with the file-backed index, so the + // scan is complete without durably writing entries for slots a fork + // switch could still unwind. Entries reach the file only at fold time. // Load stake history (used by both scan and rewards) var stakeHistory sealevel.SysvarStakeHistory @@ -250,7 +247,9 @@ func handleEpochTransition(acctsDb *accountsdb.AccountsDb, partitionedEpochRewar t5 := time.Now() // Compact stake index at epoch boundary — removes duplicates from appends - if err := global.CompactStakePubkeyIndex(acctsDbDir); err != nil { + // (rewrites from the file-backed cache only; RAM-pending entries for + // unfolded slots are untouched and flush at their own fold). + if err := global.CompactStakePubkeyIndex(filepath.Join(acctsDb.AcctsDir, "..")); err != nil { mlog.Log.Errorf("failed to compact stake pubkey index: %v", err) } @@ -274,11 +273,6 @@ func updateEpochStakesAndRefreshVoteCache(leaderScheduleEpoch uint64, b *block.B mlog.Log.Errorf("failed to rebuild vote cache at epoch boundary: %v", err) } - // Rebuild authorized voters cache from vote accounts for the new epoch. - // This ensures forkchoice vote parsing uses current authorities, not stale manifest data. - newEpoch := b.Epoch - rebuildAuthorizedVotersFromVoteCache(newEpoch) - // Skip epoch stakes storage if already cached (resume) if hasEpochStakes { mlog.Log.Infof("already had EpochStakes for epoch %d", leaderScheduleEpoch) @@ -301,40 +295,3 @@ func updateEpochStakesAndRefreshVoteCache(leaderScheduleEpoch uint64, b *block.B maps.Copy(b.EpochStakesPerVoteAcct, global.EpochStakes(leaderScheduleEpoch)) b.TotalEpochStake = scanResult.TotalEffectiveStake } - -// rebuildAuthorizedVotersFromVoteCache rebuilds the epoch authorized voters cache -// using vote states already loaded in the global VoteCache. This avoids re-reading -// AccountsDB since RebuildVoteCacheFromAccountsDB already populated the cache. -func rebuildAuthorizedVotersFromVoteCache(epoch uint64) { - voteCache := global.VoteCache() - newCache := epochstakes.NewEpochAuthorizedVotersCache() - - for voteAcct, voteState := range voteCache { - if voteState == nil { - continue - } - switch voteState.Type { - case sealevel.VoteStateVersionV0_23_5: - // V0_23_5 has a single authorized voter - newCache.PutEntry(voteAcct, voteState.V0_23_5.AuthorizedVoter) - case sealevel.VoteStateVersionV1_14_11: - voter, _, err := voteState.V1_14_11.AuthorizedVoters.GetOrCalculateAuthorizedVoterForEpoch(epoch) - if err == nil { - newCache.PutEntry(voteAcct, voter) - } - case sealevel.VoteStateVersionCurrent: - voter, _, err := voteState.Current.AuthorizedVoters.GetOrCalculateAuthorizedVoterForEpoch(epoch) - if err == nil { - newCache.PutEntry(voteAcct, voter) - } - case sealevel.VoteStateVersionV4: - voter, _, err := voteState.V4.AuthorizedVoters.GetOrCalculateAuthorizedVoterForEpoch(epoch) - if err == nil { - newCache.PutEntry(voteAcct, voter) - } - } - } - - global.SetEpochAuthorizedVoters(newCache) - mlog.Log.Infof("forkchoice: rebuilt authorized voters cache for epoch %d (%d entries)", epoch, newCache.Len()) -} diff --git a/pkg/replay/epoch_authorized_voters_test.go b/pkg/replay/epoch_authorized_voters_test.go deleted file mode 100644 index e03d7069a..000000000 --- a/pkg/replay/epoch_authorized_voters_test.go +++ /dev/null @@ -1,36 +0,0 @@ -package replay - -import ( - "testing" - - "github.com/Overclock-Validator/mithril/pkg/global" - "github.com/Overclock-Validator/mithril/pkg/sealevel" - "github.com/gagliardetto/solana-go" - "github.com/stretchr/testify/require" -) - -func TestRebuildAuthorizedVotersFromVoteCacheIncludesV4(t *testing.T) { - epoch := uint64(973) - voteAcct := solana.PublicKey{0x44} - authorizedVoter := solana.PublicKey{0x55} - - oldCache := global.EpochAuthorizedVoters() - defer global.SetEpochAuthorizedVoters(oldCache) - defer global.DeleteVoteCacheItem(voteAcct) - - var authorizedVoters sealevel.AuthorizedVoters - authorizedVoters.AuthorizedVoters.Set(epoch, authorizedVoter) - - global.PutVoteCacheItem(voteAcct, &sealevel.VoteStateVersions{ - Type: sealevel.VoteStateVersionV4, - V4: sealevel.VoteState4{ - AuthorizedVoters: authorizedVoters, - }, - }) - - rebuildAuthorizedVotersFromVoteCache(epoch) - - cache := global.EpochAuthorizedVoters() - require.NotNil(t, cache) - require.True(t, cache.IsAuthorizedVoter(voteAcct, authorizedVoter)) -} diff --git a/pkg/replay/fork_coordinator.go b/pkg/replay/fork_coordinator.go deleted file mode 100644 index a2c585020..000000000 --- a/pkg/replay/fork_coordinator.go +++ /dev/null @@ -1,174 +0,0 @@ -package replay - -import ( - "context" - "fmt" - - "github.com/Overclock-Validator/mithril/pkg/accounts" - "github.com/Overclock-Validator/mithril/pkg/state" - "github.com/gagliardetto/solana-go" -) - -// slotBlock identifies a specific block (fork) at a slot, for ingestion + equivocation. -type slotBlock struct { - slot uint64 - blockID [32]byte -} - -// branchMeta is the per-branch replay context the BranchTree doesn't hold: the slot's -// bankhash and its as-of-slot resume context (deep-copied, no SysvarCache aliasing). -type branchMeta struct { - bankhash [32]byte - ctx *state.ResumeContext -} - -// forkCoordinator drives the multi-branch fork engine: an in-RAM BranchTree over the -// durable rooted store, per-branch bankhash/context, a (slot,blockID) index for fork -// ingestion + equivocation, and finality-gated two-phase promotion of the winner. It -// is the multi-branch generalization of unrootedTail (the linear single-branch case). -type forkCoordinator struct { - tree *accounts.BranchTree - durable blockAccountSource - committer slotCommitter - meta map[uint64]*branchMeta // branchID -> bankhash + resume context - index map[slotBlock]uint64 // (slot,blockID) -> branchID - haltCap int -} - -func newForkCoordinator(durable blockAccountSource, committer slotCommitter, haltCap int) *forkCoordinator { - return &forkCoordinator{ - tree: accounts.NewBranchTree(), - durable: durable, - committer: committer, - meta: make(map[uint64]*branchMeta), - index: make(map[slotBlock]uint64), - haltCap: haltCap, - } -} - -// Ingest registers a block at slot/blockID extending parentBranchID (0 = over durable), -// returning its branch id. A repeat (slot,blockID) returns the existing id (idempotent); -// a different blockID at the same slot is a competing fork tracked as its own branch. -// Returns (0,false) if parentBranchID is non-zero but unknown. -func (fc *forkCoordinator) Ingest(parentBranchID, slot uint64, blockID [32]byte) (uint64, bool) { - sb := slotBlock{slot: slot, blockID: blockID} - if id, ok := fc.index[sb]; ok { - return id, true - } - id, ok := fc.tree.AddBranch(parentBranchID, slot, blockID) - if !ok { - return 0, false - } - fc.index[sb] = id - return id, true -} - -// BranchIDAt returns the branch id for a (slot, blockID), so the replay loop can find -// the parent branch to extend or resolve a finalized block to its branch. -func (fc *forkCoordinator) BranchIDAt(slot uint64, blockID [32]byte) (uint64, bool) { - id, ok := fc.index[slotBlock{slot: slot, blockID: blockID}] - return id, ok -} - -// GetAccount resolves pubkey on branchID: nearest-ancestor branch overlay, else durable. -// Reads are valid only for the branch owned by the current serial execution epoch — -// the caller must not evict/promote a branch that has in-flight readers. -func (fc *forkCoordinator) GetAccount(branchID, slot uint64, pubkey solana.PublicKey) (*accounts.Account, error) { - if a, ok := fc.tree.Get(branchID, [32]byte(pubkey)); ok { - return a, nil - } - return fc.durable.GetAccount(slot, pubkey) -} - -// GetAccountsBatch resolves each key against branchID's overlay chain, falling through to -// a single durable batch for the misses, preserving order and placeholder semantics. -func (fc *forkCoordinator) GetAccountsBatch(ctx context.Context, branchID, slot uint64, pks []solana.PublicKey) ([]*accounts.Account, error) { - return batchOverDurable(ctx, slot, pks, fc.durable, func(pk solana.PublicKey) (*accounts.Account, bool) { - return fc.tree.Get(branchID, [32]byte(pk)) - }) -} - -// Commit installs a replayed slot's writes on its branch and records the branch's -// bankhash + resume context. ctx MUST be deep-copied (no SysvarCache pointers). -func (fc *forkCoordinator) Commit(branchID uint64, delta []*accounts.Account, bankhash []byte, ctx *state.ResumeContext) { - fc.tree.Commit(branchID, delta) - var bh [32]byte - copy(bh[:], bankhash) - fc.meta[branchID] = &branchMeta{bankhash: bh, ctx: ctx} -} - -// Promote folds the finalized branch's chain into durable then drops it and all -// non-descendant branches from RAM (two-phase: durable-commit BEFORE tree drop, so a -// re-rooted survivor never reads a not-yet-updated base). Returns the highest slot made -// durable and the finalized branch's resume context. On a commit error it stops, leaves -// the tree intact, and returns the error (retry is idempotent via the redo log). -func (fc *forkCoordinator) Promote(finalizedBranchID uint64) (uint64, *state.ResumeContext, error) { - chain := fc.tree.PromotionChain(finalizedBranchID) - if len(chain) == 0 { - return 0, nil, nil - } - // Every branch in the winning chain must have been committed (bankhash recorded). - // Pre-validate so a missing one is caught before any durable write, rather than - // persisting an empty bankhash + dropping the slot's state. A skipped slot is still - // Committed (empty delta + its bankhash), so it passes. - for _, ps := range chain { - if fc.meta[ps.BranchID] == nil { - return 0, nil, fmt.Errorf("cannot promote: branch %d (slot %d) not committed", ps.BranchID, ps.Slot) - } - } - var promotedThrough, lastDurableBranch uint64 - for _, ps := range chain { - if err := fc.committer.CommitRootedSlot(ps.Delta, ps.Slot, fc.meta[ps.BranchID].bankhash[:]); err != nil { - // Partial failure: return the last durable slot's context (matches the - // linear engine) so the caller's watermark and resume context stay paired. - var ctx *state.ResumeContext - if lastDurableBranch != 0 { - ctx = fc.meta[lastDurableBranch].ctx - } - return promotedThrough, ctx, fmt.Errorf("promote slot %d: %w", ps.Slot, err) - } - promotedThrough = ps.Slot - lastDurableBranch = ps.BranchID - } - - ctx := fc.meta[finalizedBranchID].ctx - fc.tree.Promote(finalizedBranchID) - fc.pruneToLive() - return promotedThrough, ctx, nil -} - -// SetContext attaches/updates the resume context of a committed branch. ctx MUST be -// deep-copied (no SysvarCache pointers). No-op for an unknown/uncommitted branch. -func (fc *forkCoordinator) SetContext(branchID uint64, ctx *state.ResumeContext) { - if m := fc.meta[branchID]; m != nil && ctx != nil { - m.ctx = ctx - } -} - -// Evict drops a losing fork (branchID + descendants) from the tree and side maps. -func (fc *forkCoordinator) Evict(branchID uint64) { - fc.tree.EvictSubtree(branchID) - fc.pruneToLive() -} - -// pruneToLive drops meta/index entries for branches no longer in the tree. -func (fc *forkCoordinator) pruneToLive() { - live := fc.tree.LiveIDs() - for id := range fc.meta { - if !live[id] { - delete(fc.meta, id) - } - } - for sb, id := range fc.index { - if !live[id] { - delete(fc.index, sb) - } - } -} - -// OverCap reports whether the live branch count exceeds the halt cap (fork fan-out or -// stalled finality growing RAM unbounded). Interim safety valve bounding branch COUNT; -// the depth×writes bound is P6 (bounded resource policy). -func (fc *forkCoordinator) OverCap() bool { - return fc.haltCap > 0 && fc.tree.Len() > fc.haltCap -} diff --git a/pkg/replay/fork_coordinator_test.go b/pkg/replay/fork_coordinator_test.go deleted file mode 100644 index 0a35592d1..000000000 --- a/pkg/replay/fork_coordinator_test.go +++ /dev/null @@ -1,248 +0,0 @@ -package replay - -import ( - "context" - "testing" - - "github.com/Overclock-Validator/mithril/pkg/accounts" - "github.com/Overclock-Validator/mithril/pkg/state" - "github.com/gagliardetto/solana-go" -) - -func newTestCoordinator() (*forkCoordinator, *fakeCommitter) { - durableSrc := &fakeDurable{known: map[solana.PublicKey]uint64{}} - committer := &fakeCommitter{durable: accounts.NewMemAccounts()} - return newForkCoordinator(durableSrc, committer, 512), committer -} - -func TestForkCoordinatorIngestAndRead(t *testing.T) { - coordinator, _ := newTestCoordinator() - b, ok := coordinator.Ingest(0, 1, testHash(0xB1)) - if !ok { - t.Fatal("ingest over durable base should succeed") - } - coordinator.Commit(b, []*accounts.Account{testAccount(1, 100)}, testHashBytes(1), &state.ResumeContext{Slot: 1}) - - if a, err := coordinator.GetAccount(b, 1, testKey(1)); err != nil || a.Lamports != 100 { - t.Fatalf("branch read: err=%v acct=%v", err, a) - } - // unwritten key falls through to durable (fake returns a zero-lamport placeholder) - if a, err := coordinator.GetAccount(b, 1, testKey(2)); err != nil || a.Lamports != 0 { - t.Fatalf("durable fall-through: err=%v acct=%v", err, a) - } -} - -func TestForkCoordinatorIngestIdempotentAndMissingParent(t *testing.T) { - coordinator, _ := newTestCoordinator() - b1, _ := coordinator.Ingest(0, 1, testHash(0xAA)) - b2, ok := coordinator.Ingest(0, 1, testHash(0xAA)) // same (slot,blockID) - if !ok || b1 != b2 { - t.Fatalf("repeat ingest must be idempotent: b1=%d b2=%d ok=%v", b1, b2, ok) - } - if _, ok := coordinator.Ingest(9999, 2, testHash(0xCC)); ok { - t.Fatal("ingest under a missing parent must fail") - } -} - -func TestForkCoordinatorEquivocationTwoBlocksSameSlot(t *testing.T) { - coordinator, _ := newTestCoordinator() - p, _ := coordinator.Ingest(0, 1, testHash(0x01)) - coordinator.Commit(p, []*accounts.Account{testAccount(9, 1)}, testHashBytes(1), nil) - a, _ := coordinator.Ingest(p, 2, testHash(0x0A)) // two competing blocks at slot 2 - b, okB := coordinator.Ingest(p, 2, testHash(0x0B)) - if !okB || a == b { - t.Fatalf("competing blocks must be distinct branches: a=%d b=%d", a, b) - } - coordinator.Commit(a, []*accounts.Account{testAccount(1, 111)}, testHashBytes(2), nil) - coordinator.Commit(b, []*accounts.Account{testAccount(1, 222)}, testHashBytes(3), nil) - if av, _ := coordinator.GetAccount(a, 2, testKey(1)); av.Lamports != 111 { - t.Fatalf("fork A isolation: %v", av) - } - if bv, _ := coordinator.GetAccount(b, 2, testKey(1)); bv.Lamports != 222 { - t.Fatalf("fork B isolation: %v", bv) - } -} - -func TestForkCoordinatorGetAccountsBatch(t *testing.T) { - coordinator, durableSrc := newTestCoordinatorWithKnown(map[solana.PublicKey]uint64{testKey(2): 20}) - _ = durableSrc - b, _ := coordinator.Ingest(0, 1, testHash(1)) - coordinator.Commit(b, []*accounts.Account{testAccount(1, 10)}, testHashBytes(1), nil) // key1 in overlay, key2 in durable - out, err := coordinator.GetAccountsBatch(context.Background(), b, 1, []solana.PublicKey{testKey(1), testKey(2)}) - if err != nil || len(out) != 2 { - t.Fatalf("batch: err=%v out=%v", err, out) - } - if out[0].Lamports != 10 || out[1].Lamports != 20 { - t.Fatalf("batch resolution wrong: %v %v", out[0], out[1]) - } -} - -func TestForkCoordinatorPromoteWinner(t *testing.T) { - coordinator, committer := newTestCoordinator() - p, _ := coordinator.Ingest(0, 1, testHash(1)) - coordinator.Commit(p, []*accounts.Account{testAccount(1, 10)}, testHashBytes(1), &state.ResumeContext{Slot: 1}) - a, _ := coordinator.Ingest(p, 2, testHash(2)) - coordinator.Commit(a, []*accounts.Account{testAccount(2, 20)}, testHashBytes(2), &state.ResumeContext{Slot: 2}) - loser, _ := coordinator.Ingest(p, 2, testHash(0x99)) // competing fork at slot 2 - coordinator.Commit(loser, []*accounts.Account{testAccount(2, 999)}, testHashBytes(9), nil) - c, _ := coordinator.Ingest(a, 3, testHash(3)) - coordinator.Commit(c, []*accounts.Account{testAccount(3, 30)}, testHashBytes(3), &state.ResumeContext{Slot: 3}) - - through, ctx, err := coordinator.Promote(c) - if err != nil || through != 3 { - t.Fatalf("promote: through=%d err=%v", through, err) - } - if ctx == nil || ctx.Slot != 3 { - t.Fatalf("promote should return the finalized branch's context (slot 3): %+v", ctx) - } - // durable committed ascending, winner's state applied, loser never committed - if len(committer.committed) != 3 || committer.committed[0] != 1 || committer.committed[2] != 3 { - t.Fatalf("committed slots wrong: %v", committer.committed) - } - if a, _ := committer.durable.GetAccountWithoutLock(testKey(3)); a == nil || a.Lamports != 30 { - t.Fatalf("winner slot-3 state must be durable: %v", a) - } - // tree + side maps collapsed to survivors (none here → empty) - if coordinator.tree.Len() != 0 || len(coordinator.meta) != 0 || len(coordinator.index) != 0 { - t.Fatalf("post-promote not pruned: len=%d meta=%d index=%d", coordinator.tree.Len(), len(coordinator.meta), len(coordinator.index)) - } -} - -func TestForkCoordinatorPromotePartialFailureStopsAndKeepsTree(t *testing.T) { - coordinator, committer := newTestCoordinator() - committer.failOn = 2 // slot 2 commit fails - p, _ := coordinator.Ingest(0, 1, testHash(1)) - coordinator.Commit(p, []*accounts.Account{testAccount(1, 10)}, testHashBytes(1), nil) - a, _ := coordinator.Ingest(p, 2, testHash(2)) - coordinator.Commit(a, []*accounts.Account{testAccount(2, 20)}, testHashBytes(2), nil) - - through, _, err := coordinator.Promote(a) - if err == nil { - t.Fatal("expected commit error") - } - if through != 1 { - t.Fatalf("promotedThrough should be the last durable slot (1), got %d", through) - } - // tree left intact for idempotent retry - if coordinator.tree.Len() != 2 { - t.Fatalf("tree must be untouched on partial failure; Len=%d", coordinator.tree.Len()) - } - if len(committer.committed) != 1 || committer.committed[0] != 1 { - t.Fatalf("only slot 1 should be committed: %v", committer.committed) - } -} - -func TestForkCoordinatorEvict(t *testing.T) { - coordinator, _ := newTestCoordinator() - p, _ := coordinator.Ingest(0, 1, testHash(1)) - a, _ := coordinator.Ingest(p, 2, testHash(2)) - coordinator.Commit(a, []*accounts.Account{testAccount(1, 1)}, testHashBytes(2), nil) - coordinator.Evict(a) - if coordinator.tree.Len() != 1 { - t.Fatalf("evict should drop A, leaving P; Len=%d", coordinator.tree.Len()) - } - if _, ok := coordinator.meta[a]; ok { - t.Fatal("evicted branch meta must be pruned") - } -} - -func TestForkCoordinatorOverCap(t *testing.T) { - durableSrc := &fakeDurable{known: map[solana.PublicKey]uint64{}} - committer := &fakeCommitter{durable: accounts.NewMemAccounts()} - coordinator := newForkCoordinator(durableSrc, committer, 2) // cap = 2 - parent := uint64(0) - for slot := uint64(1); slot <= 3; slot++ { - id, _ := coordinator.Ingest(parent, slot, testHash(byte(slot))) - parent = id - } - if !coordinator.OverCap() { - t.Fatalf("3 branches over cap 2 should be OverCap; Len=%d", coordinator.tree.Len()) - } -} - -// Promote must refuse a winning chain that contains an ingested-but-never-committed -// branch (else it would persist an empty bankhash and drop that slot's state). -func TestForkCoordinatorPromoteUncommittedBranchRefused(t *testing.T) { - coordinator, committer := newTestCoordinator() - p, _ := coordinator.Ingest(0, 1, testHash(1)) - coordinator.Commit(p, []*accounts.Account{testAccount(1, 10)}, testHashBytes(1), nil) - b, _ := coordinator.Ingest(p, 2, testHash(2)) // ingested, never committed - c, _ := coordinator.Ingest(b, 3, testHash(3)) - coordinator.Commit(c, []*accounts.Account{testAccount(3, 30)}, testHashBytes(3), nil) - - if _, _, err := coordinator.Promote(c); err == nil { - t.Fatal("promote must refuse a chain with an uncommitted branch") - } - if len(committer.committed) != 0 { - t.Fatalf("nothing may be committed durably on refusal; got %v", committer.committed) - } - if coordinator.tree.Len() != 3 { - t.Fatalf("tree must be intact after refusal; Len=%d", coordinator.tree.Len()) - } -} - -func TestForkCoordinatorBranchIDAt(t *testing.T) { - coordinator, _ := newTestCoordinator() - id, _ := coordinator.Ingest(0, 1, testHash(7)) - if got, ok := coordinator.BranchIDAt(1, testHash(7)); !ok || got != id { - t.Fatalf("BranchIDAt should find the ingested branch: got=%d ok=%v", got, ok) - } - if _, ok := coordinator.BranchIDAt(1, testHash(8)); ok { - t.Fatal("unknown (slot,blockID) must miss") - } - coordinator.Commit(id, []*accounts.Account{testAccount(1, 1)}, testHashBytes(1), nil) - coordinator.Promote(id) - if _, ok := coordinator.BranchIDAt(1, testHash(7)); ok { - t.Fatal("after promote the branch id must be pruned from the index") - } -} - -// Concurrent readers on a fixed branch id while the main goroutine ingests/commits/ -// promotes — makes -race exercise the coordinator's read path against tree mutation. -func TestForkCoordinatorConcurrentReads(t *testing.T) { - coordinator, _ := newTestCoordinator() - root, _ := coordinator.Ingest(0, 1, testHash(1)) - coordinator.Commit(root, []*accounts.Account{testAccount(1, 1)}, testHashBytes(1), nil) - - stop := make(chan struct{}) - done := make(chan struct{}) - for range 8 { - go func() { - for { - select { - case <-stop: - done <- struct{}{} - return - default: - // GetAccount hits the locked tree then durable; exercises the read - // path against concurrent tree mutation. (GetAccountsBatch is omitted - // only because the test fake's call counter isn't concurrency-safe.) - coordinator.GetAccount(root, 1, testKey(1)) // root may be promoted away → misses to durable (safe) - } - } - }() - } - - parent := root - for slot := uint64(2); slot <= 200; slot++ { - id, ok := coordinator.Ingest(parent, slot, testHash(byte(slot))) - if !ok { - continue - } - coordinator.Commit(id, []*accounts.Account{testAccount(byte(slot), slot)}, testHashBytes(byte(slot)), nil) - parent = id - if slot%25 == 0 { - coordinator.Promote(id) - } - } - close(stop) - for range 8 { - <-done - } -} - -func newTestCoordinatorWithKnown(known map[solana.PublicKey]uint64) (*forkCoordinator, *fakeDurable) { - durableSrc := &fakeDurable{known: known} - committer := &fakeCommitter{durable: accounts.NewMemAccounts()} - return newForkCoordinator(durableSrc, committer, 512), durableSrc -} diff --git a/pkg/replay/fork_driver.go b/pkg/replay/fork_driver.go deleted file mode 100644 index ca73eb59b..000000000 --- a/pkg/replay/fork_driver.go +++ /dev/null @@ -1,157 +0,0 @@ -package replay - -import ( - "bytes" - "fmt" - - "github.com/Overclock-Validator/mithril/pkg/accounts" - "github.com/Overclock-Validator/mithril/pkg/forkchoice" - "github.com/Overclock-Validator/mithril/pkg/state" -) - -// ExecuteFn replays one candidate block on its branch: reads resolve through the -// branch (nearest-ancestor then durable) and the block's writes + bankhash are -// returned for the branch commit. The turbine-era loop passes real ProcessBlock; -// tests pass synthetic executors. -type ExecuteFn func(branchID uint64) (delta []*accounts.Account, bankhash []byte, ctx *state.ResumeContext, err error) - -// forkDriver composes fork SELECTION (HeaviestSubtreeForkChoice) with fork STATE -// (forkCoordinator): candidate blocks are executed into isolated branches, votes -// drive the heaviest-fork tip, duplicates are excluded until confirmed, and -// finality promotes the winner (folding it durable, evicting all losers). -type forkDriver struct { - fc *forkCoordinator - choice *forkchoice.HeaviestSubtreeForkChoice - stakeAt forkchoice.StakeFn - tip forkchoice.SlotHashKey // current heaviest tip (the chain to extend) -} - -func newForkDriver(durable blockAccountSource, committer slotCommitter, root forkchoice.SlotHashKey, stakeAt forkchoice.StakeFn, haltCap int) *forkDriver { - return &forkDriver{ - fc: newForkCoordinator(durable, committer, haltCap), - choice: forkchoice.NewHeaviestSubtreeForkChoice(root), - stakeAt: stakeAt, - tip: root, - } -} - -// OnBlock ingests and executes one candidate block version. parent is the root -// key for the first block above durable. Competing versions of the same slot land -// on isolated branches. Idempotent per (slot, blockID). -func (d *forkDriver) OnBlock(key, parent forkchoice.SlotHashKey, execute ExecuteFn) error { - if d.choice.ContainsBlock(key) { - return nil - } - parentBranch := uint64(0) // 0 = extend the durable base (parent == tree root) - if parent != d.choice.TreeRoot() { - pb, ok := d.fc.BranchIDAt(parent.Slot, parent.Hash) - if !ok { - return fmt.Errorf("fork driver: parent (%d) not ingested", parent.Slot) - } - parentBranch = pb - } - id, ok := d.fc.Ingest(parentBranch, key.Slot, key.Hash) - if !ok { - return fmt.Errorf("fork driver: ingest (%d) under branch %d failed", key.Slot, parentBranch) - } - delta, bankhash, ctx, err := execute(id) - if err != nil { - // Execution failure = dead candidate: never a vote target, never promotable. - d.fc.Evict(id) - return fmt.Errorf("fork driver: execute (%d): %w", key.Slot, err) - } - d.fc.Commit(id, delta, bankhash, ctx) - d.choice.AddNewLeafSlot(key, &parent) - if best := d.choice.BestOverallSlot(); best != d.tip { - d.tip = best - } - return nil -} - -// OnVotes applies observed votes and returns the (possibly switched) heaviest tip. -// Duplicate votes per validator in one batch are deduped to the vote the -// latest-vote rule prefers (higher slot; same slot only for a smaller hash). -func (d *forkDriver) OnVotes(votes []forkchoice.VoteKey) forkchoice.SlotHashKey { - best := make(map[[32]byte]forkchoice.VoteKey, len(votes)) - for _, v := range votes { - pk := [32]byte(v.Pubkey) - if prev, ok := best[pk]; ok { - if v.Key.Slot < prev.Key.Slot || - (v.Key.Slot == prev.Key.Slot && bytes.Compare(v.Key.Hash[:], prev.Key.Hash[:]) >= 0) { - continue - } - } - best[pk] = v - } - deduped := make([]forkchoice.VoteKey, 0, len(best)) - for _, v := range best { - deduped = append(deduped, v) - } - d.tip = d.choice.AddVotes(deduped, d.stakeAt) - return d.tip -} - -// OnDuplicate marks a block version an unconfirmed duplicate: excluded from -// selection (weight still backs ancestors) until confirmed via OnDuplicateConfirmed. -// No-op for unknown/pruned keys and for already-confirmed blocks (stale gossip). -func (d *forkDriver) OnDuplicate(key forkchoice.SlotHashKey) { - if !d.choice.ContainsBlock(key) { - return - } - if confirmed, _ := d.choice.IsDuplicateConfirmed(key); confirmed { - return // finality/confirmation already decided this version; stale proof - } - d.choice.MarkForkInvalidCandidate(key) - if best := d.choice.BestOverallSlot(); best != d.tip { - d.tip = best - } -} - -// OnDuplicateConfirmed re-admits a version once the cluster confirms it. -// No-op for unknown/pruned keys (stale or never-ingested gossip). -func (d *forkDriver) OnDuplicateConfirmed(key forkchoice.SlotHashKey) { - if !d.choice.ContainsBlock(key) { - return - } - d.choice.MarkForkValidCandidate(key) - if best := d.choice.BestOverallSlot(); best != d.tip { - d.tip = best - } -} - -// OnFinalized promotes the finalized block's chain durably (two-phase; losers -// evicted from both the state tree and fork choice) and re-roots selection at it. -// Returns the highest slot made durable and its resume context. -func (d *forkDriver) OnFinalized(key forkchoice.SlotHashKey) (uint64, *state.ResumeContext, error) { - branchID, ok := d.fc.BranchIDAt(key.Slot, key.Hash) - if !ok { - return 0, nil, fmt.Errorf("fork driver: finalized block (%d) not ingested", key.Slot) - } - // Finality implies duplicate-confirmation: clear any stale invalid marks so the - // re-rooted tree (and leaves later added under it) stay valid candidates. - d.choice.MarkForkValidCandidate(key) - through, ctx, err := d.fc.Promote(branchID) - if err != nil { - return through, ctx, err - } - d.choice.SetTreeRoot(key) - if best := d.choice.BestOverallSlot(); best != d.tip { - d.tip = best - } - return through, ctx, nil -} - -// Tip is the current heaviest valid tip — the chain the node follows. -func (d *forkDriver) Tip() forkchoice.SlotHashKey { return d.tip } - -// TipBranch resolves the tip's state branch for reads/extension. A tip that IS -// the tree root (everything finalized) resolves to the durable base (branch 0). -func (d *forkDriver) TipBranch() (uint64, bool) { - if d.tip == d.choice.TreeRoot() { - return 0, true - } - return d.fc.BranchIDAt(d.tip.Slot, d.tip.Hash) -} - -// OverCap reports fork-state RAM pressure (rooting stalled or fork spam). -func (d *forkDriver) OverCap() bool { return d.fc.OverCap() } diff --git a/pkg/replay/fork_driver_test.go b/pkg/replay/fork_driver_test.go deleted file mode 100644 index 3f397cb48..000000000 --- a/pkg/replay/fork_driver_test.go +++ /dev/null @@ -1,403 +0,0 @@ -package replay - -import ( - "fmt" - "math/rand" - "testing" - - "github.com/Overclock-Validator/mithril/pkg/accounts" - "github.com/Overclock-Validator/mithril/pkg/forkchoice" - "github.com/Overclock-Validator/mithril/pkg/state" - "github.com/gagliardetto/solana-go" -) - -// Synthetic fork harness: drives the composed selection (heaviest-subtree) + -// state (fork coordinator) machinery with manufactured forks — the scenarios the -// RPC path can physically never produce. - -func fdKey(slot uint64, h byte) forkchoice.SlotHashKey { - k := forkchoice.SlotHashKey{Slot: slot} - k.Hash[0] = h - return k -} - -// fdExec returns an executor whose single account write encodes which block -// version executed (key = slot byte, lamports = version byte), so durable state -// proves which fork won. -func fdExec(key forkchoice.SlotHashKey) ExecuteFn { - return func(branchID uint64) ([]*accounts.Account, []byte, *state.ResumeContext, error) { - acct := &accounts.Account{Key: solana.PublicKey{byte(key.Slot)}, Lamports: uint64(key.Hash[0])} - bh := make([]byte, 32) - bh[0] = byte(key.Slot) - bh[1] = key.Hash[0] - return []*accounts.Account{acct}, bh, &state.ResumeContext{Slot: key.Slot}, nil - } -} - -func fdVoter(b byte) solana.PublicKey { return solana.PublicKey{0xD0, b} } - -func newTestDriver() (*forkDriver, *fakeCommitter) { - committer := &fakeCommitter{durable: accounts.NewMemAccounts()} - dur := &fakeDurable{known: map[solana.PublicKey]uint64{}} - root := forkchoice.SlotHashKey{} // durable base at slot 0 - driver := newForkDriver(dur, committer, root, func(solana.PublicKey, uint64) uint64 { return 100 }, 512) - return driver, committer -} - -func TestForkDriverLinearHappyPath(t *testing.T) { - driver, committer := newTestDriver() - root := forkchoice.SlotHashKey{} - k1, k2, k3 := fdKey(1, 1), fdKey(2, 1), fdKey(3, 1) - if err := driver.OnBlock(k1, root, fdExec(k1)); err != nil { - t.Fatal(err) - } - if err := driver.OnBlock(k2, k1, fdExec(k2)); err != nil { - t.Fatal(err) - } - if err := driver.OnBlock(k3, k2, fdExec(k3)); err != nil { - t.Fatal(err) - } - driver.OnVotes([]forkchoice.VoteKey{{Pubkey: fdVoter(1), Key: k3}}) - if driver.Tip() != k3 { - t.Fatalf("tip = %+v, want slot 3", driver.Tip()) - } - through, ctx, err := driver.OnFinalized(k2) - if err != nil || through != 2 || ctx == nil || ctx.Slot != 2 { - t.Fatalf("finalize: through=%d ctx=%+v err=%v", through, ctx, err) - } - if len(committer.committed) != 2 || committer.committed[0] != 1 || committer.committed[1] != 2 { - t.Fatalf("durable slots = %v, want [1 2]", committer.committed) - } - // the unfinalized tip block survives and remains the tip - if driver.Tip() != k3 { - t.Fatalf("post-finalize tip = %+v, want slot 3", driver.Tip()) - } -} - -// Skip-fork: chain A (1→2) vs chain B (1→3, skipping 2). Votes move the tip; -// finality promotes the winner and the loser's state never reaches durable. -func TestForkDriverSkipForkSwitchAndFinalize(t *testing.T) { - driver, committer := newTestDriver() - root := forkchoice.SlotHashKey{} - k1, kA, kB := fdKey(1, 1), fdKey(2, 0xA), fdKey(3, 0xB) // B skips slot 2 - for _, blk := range []struct { - key, parent forkchoice.SlotHashKey - }{{k1, root}, {kA, k1}, {kB, k1}} { - if err := driver.OnBlock(blk.key, blk.parent, fdExec(blk.key)); err != nil { - t.Fatal(err) - } - } - // one vote on A: tip = A - driver.OnVotes([]forkchoice.VoteKey{{Pubkey: fdVoter(1), Key: kA}}) - if driver.Tip() != kA { - t.Fatalf("tip = %+v, want A", driver.Tip()) - } - // two votes on B: heaviest switches - driver.OnVotes([]forkchoice.VoteKey{{Pubkey: fdVoter(2), Key: kB}, {Pubkey: fdVoter(3), Key: kB}}) - if driver.Tip() != kB { - t.Fatalf("tip after votes = %+v, want B", driver.Tip()) - } - // cluster finalizes B - through, _, err := driver.OnFinalized(kB) - if err != nil || through != 3 { - t.Fatalf("finalize B: through=%d err=%v", through, err) - } - // durable must hold B's version at slot 3 and NOTHING from A's slot 2 - if a, _ := committer.durable.GetAccountWithoutLock(solana.PublicKey{3}); a == nil || a.Lamports != 0xB { - t.Fatalf("slot-3 durable must be B's write: %v", a) - } - if a, _ := committer.durable.GetAccountWithoutLock(solana.PublicKey{2}); a != nil { - t.Fatalf("loser A's slot-2 state must never reach durable: %v", a) - } - // state tree fully collapsed (no survivors above B) - if driver.fc.tree.Len() != 0 { - t.Fatalf("state tree should be empty, len=%d", driver.fc.tree.Len()) - } -} - -// Equivocation: twin leader produces two versions of slot 2. Tie-break picks the -// smaller hash; duplicate-marking diverts selection; confirmation restores it; -// finalizing the confirmed version evicts its twin. -func TestForkDriverEquivocationLifecycle(t *testing.T) { - driver, committer := newTestDriver() - root := forkchoice.SlotHashKey{} - k1, twinA, twinB := fdKey(1, 1), fdKey(2, 0xA), fdKey(2, 0xB) - for _, blk := range []struct { - key, parent forkchoice.SlotHashKey - }{{k1, root}, {twinA, k1}, {twinB, k1}} { - if err := driver.OnBlock(blk.key, blk.parent, fdExec(blk.key)); err != nil { - t.Fatal(err) - } - } - driver.OnVotes([]forkchoice.VoteKey{ - {Pubkey: fdVoter(1), Key: twinA}, - {Pubkey: fdVoter(2), Key: twinB}, - }) - if driver.Tip() != twinA { // equal stake: smaller hash wins - t.Fatalf("tie tip = %+v, want twinA", driver.Tip()) - } - // twinA detected as an unconfirmed duplicate: selection diverts - driver.OnDuplicate(twinA) - if driver.Tip() == twinA { - t.Fatal("tip must divert off an unconfirmed duplicate") - } - // cluster duplicate-confirms twinA: selection returns - driver.OnDuplicateConfirmed(twinA) - if driver.Tip() != twinA { - t.Fatalf("confirmed duplicate must be selectable again: tip=%+v", driver.Tip()) - } - // finality on twinA: twinB (the equivocating loser) is evicted everywhere - if _, _, err := driver.OnFinalized(twinA); err != nil { - t.Fatal(err) - } - if a, _ := committer.durable.GetAccountWithoutLock(solana.PublicKey{2}); a == nil || a.Lamports != 0xA { - t.Fatalf("durable slot-2 must be twinA's write: %v", a) - } - if _, ok := driver.fc.BranchIDAt(2, twinB.Hash); ok { - t.Fatal("twinB must be evicted from the state index") - } -} - -// "Cluster confirms the block we didn't pick": we follow the heavier fork, but -// finality lands on the other one — the driver must promote the cluster's choice. -func TestForkDriverFinalityOverridesLocalChoice(t *testing.T) { - driver, committer := newTestDriver() - root := forkchoice.SlotHashKey{} - k1, kA, kB := fdKey(1, 1), fdKey(2, 0xA), fdKey(3, 0xB) - for _, blk := range []struct { - key, parent forkchoice.SlotHashKey - }{{k1, root}, {kA, k1}, {kB, k1}} { - if err := driver.OnBlock(blk.key, blk.parent, fdExec(blk.key)); err != nil { - t.Fatal(err) - } - } - // our local view: A is heavier - driver.OnVotes([]forkchoice.VoteKey{{Pubkey: fdVoter(1), Key: kA}, {Pubkey: fdVoter(2), Key: kA}}) - if driver.Tip() != kA { - t.Fatalf("local tip = %+v, want A", driver.Tip()) - } - // but the cluster finalizes B - if _, _, err := driver.OnFinalized(kB); err != nil { - t.Fatal(err) - } - if a, _ := committer.durable.GetAccountWithoutLock(solana.PublicKey{3}); a == nil || a.Lamports != 0xB { - t.Fatalf("cluster's finalized fork must win durably: %v", a) - } - if a, _ := committer.durable.GetAccountWithoutLock(solana.PublicKey{2}); a != nil { - t.Fatalf("our locally-preferred fork must not persist: %v", a) - } -} - -// Twins-style seeded generator: random skip-forks + twin (equivocating) leaders + -// random votes across many rounds. Invariants: the tip always exists, is always a -// candidate, execution failures never leak, and the finalized chain's state — and -// ONLY that chain's — reaches durable. -func TestForkDriverTwinsRandomized(t *testing.T) { - for seed := int64(1); seed <= 5; seed++ { - rng := rand.New(rand.NewSource(seed)) - driver, committer := newTestDriver() - root := forkchoice.SlotHashKey{} - - type blockInfo struct{ key, parent forkchoice.SlotHashKey } - blocks := map[forkchoice.SlotHashKey]blockInfo{} - heads := []forkchoice.SlotHashKey{root} // possible parents - voters := 8 - - for slot := uint64(1); slot <= 40; slot++ { - parent := heads[rng.Intn(len(heads))] - versions := 1 - if rng.Intn(5) == 0 { // twin leader: equivocate - versions = 2 - } - for v := 0; v < versions; v++ { - key := fdKey(slot, byte(0xA+v)) - if err := driver.OnBlock(key, parent, fdExec(key)); err != nil { - t.Fatalf("seed %d: OnBlock(%d): %v", seed, slot, err) - } - blocks[key] = blockInfo{key: key, parent: parent} - heads = append(heads, key) - } - // random votes from a few voters onto random known blocks - var votes []forkchoice.VoteKey - seen := map[byte]bool{} - for i := 0; i < rng.Intn(3); i++ { - vb := byte(rng.Intn(voters)) - if seen[vb] { - continue - } - seen[vb] = true - votes = append(votes, forkchoice.VoteKey{ - Pubkey: fdVoter(vb), Key: heads[rng.Intn(len(heads))]}) - } - if len(votes) > 0 { - driver.OnVotes(votes) - } - // invariant: tip exists and is a candidate - tip := driver.Tip() - if tip != root { - if _, ok := blocks[tip]; !ok { - t.Fatalf("seed %d: tip %+v is not a known block", seed, tip) - } - if cand, ok := driver.choice.IsCandidate(tip); !ok || !cand { - t.Fatalf("seed %d: tip %+v is not a candidate", seed, tip) - } - } - } - - // finalize the current tip; every durable slot must match the tip's chain - tip := driver.Tip() - if tip == root { - continue // degenerate seed: no votes landed; nothing to finalize - } - if _, _, err := driver.OnFinalized(tip); err != nil { - t.Fatalf("seed %d: finalize: %v", seed, err) - } - expected := map[uint64]byte{} // slot -> version byte on the winning chain - for k := tip; k != root; k = blocks[k].parent { - expected[k.Slot] = k.Hash[0] - } - for slot, version := range expected { - a, _ := committer.durable.GetAccountWithoutLock(solana.PublicKey{byte(slot)}) - if a == nil || a.Lamports != uint64(version) { - t.Fatalf("seed %d: durable slot %d = %v, want version %#x (finalized chain only)", - seed, slot, a, version) - } - } - for i, slotKey := range committer.committed { - version, onChain := expected[slotKey] - if !onChain { - t.Fatalf("seed %d: durable slot %d is NOT on the finalized chain", seed, slotKey) - } - // per-commit bankhash carries (slot, version): the committed VERSION must - // be the finalized chain's — catches a losing twin committed then overwritten - if bh := committer.bankhashes[i]; bh[0] != byte(slotKey) || bh[1] != version { - t.Fatalf("seed %d: slot %d committed version %#x, want %#x", seed, slotKey, bh[1], version) - } - } - } -} - -// F1: duplicate-confirmation gossip for a block we never ingested (or already -// pruned below the root) must be a safe no-op, not a panic. -func TestForkDriverDuplicateConfirmedUnknownKey(t *testing.T) { - driver, _ := newTestDriver() - driver.OnDuplicateConfirmed(fdKey(99, 9)) // must not panic - root := forkchoice.SlotHashKey{} - k1 := fdKey(1, 1) - if err := driver.OnBlock(k1, root, fdExec(k1)); err != nil { - t.Fatal(err) - } - if _, _, err := driver.OnFinalized(k1); err != nil { - t.Fatal(err) - } - driver.OnDuplicateConfirmed(fdKey(1, 1)) // now the root; must not panic - driver.OnDuplicate(fdKey(99, 9)) // unknown; must not panic -} - -// F2: out-of-order gossip — confirm a descendant (which transitively confirms its -// ancestors), then a duplicate-proof arrives for an ancestor. Must be a no-op. -func TestForkDriverDuplicateAfterConfirmedNoop(t *testing.T) { - driver, _ := newTestDriver() - root := forkchoice.SlotHashKey{} - k1, k2 := fdKey(1, 1), fdKey(2, 1) - if err := driver.OnBlock(k1, root, fdExec(k1)); err != nil { - t.Fatal(err) - } - if err := driver.OnBlock(k2, k1, fdExec(k2)); err != nil { - t.Fatal(err) - } - driver.OnDuplicateConfirmed(k2) // confirms k1 transitively - driver.OnDuplicate(k1) // stale duplicate-proof: must not panic, must not exclude - if cand, _ := driver.choice.IsCandidate(k1); !cand { - t.Fatal("confirmed ancestor must stay a candidate") - } -} - -// F3: finality implies duplicate-confirmation. A block marked duplicate then -// finalized WITHOUT an explicit confirm must not poison the re-rooted tree -// (new children must be candidates, and the tip must stay valid). -func TestForkDriverFinalizeImpliesDuplicateConfirmed(t *testing.T) { - driver, _ := newTestDriver() - root := forkchoice.SlotHashKey{} - k1, twinA, twinB := fdKey(1, 1), fdKey(2, 0xA), fdKey(2, 0xB) - for _, blk := range []struct { - key, parent forkchoice.SlotHashKey - }{{k1, root}, {twinA, k1}, {twinB, k1}} { - if err := driver.OnBlock(blk.key, blk.parent, fdExec(blk.key)); err != nil { - t.Fatal(err) - } - } - driver.OnDuplicate(twinA) - // cluster finalizes twinA directly (finalized ⟹ duplicate-confirmed) - if _, _, err := driver.OnFinalized(twinA); err != nil { - t.Fatal(err) - } - k3 := fdKey(3, 1) - if err := driver.OnBlock(k3, twinA, fdExec(k3)); err != nil { - t.Fatal(err) - } - if cand, ok := driver.choice.IsCandidate(k3); !ok || !cand { - t.Fatal("children of the finalized (implicitly confirmed) root must be candidates") - } - driver.OnVotes([]forkchoice.VoteKey{{Pubkey: fdVoter(1), Key: k3}}) - if tip := driver.Tip(); tip != k3 { - t.Fatalf("tip = %+v, want k3 (valid candidate)", tip) - } -} - -// F4: when the tip IS the tree root (everything finalized), TipBranch must report -// the durable base (branch 0) as a valid resolution, not a miss. -func TestForkDriverTipBranchAtRoot(t *testing.T) { - driver, _ := newTestDriver() - root := forkchoice.SlotHashKey{} - k1 := fdKey(1, 1) - if err := driver.OnBlock(k1, root, fdExec(k1)); err != nil { - t.Fatal(err) - } - if _, _, err := driver.OnFinalized(k1); err != nil { - t.Fatal(err) - } - if branch, ok := driver.TipBranch(); !ok || branch != 0 { - t.Fatalf("tip at root must resolve to the durable base: (%d,%v)", branch, ok) - } -} - -// F6: duplicate votes from one validator in a single batch must not panic the -// driver (dedupe keeps the vote the latest-vote filter would prefer). -func TestForkDriverDuplicateVotesInBatch(t *testing.T) { - driver, _ := newTestDriver() - root := forkchoice.SlotHashKey{} - k1, k2 := fdKey(1, 1), fdKey(2, 1) - if err := driver.OnBlock(k1, root, fdExec(k1)); err != nil { - t.Fatal(err) - } - if err := driver.OnBlock(k2, k1, fdExec(k2)); err != nil { - t.Fatal(err) - } - tip := driver.OnVotes([]forkchoice.VoteKey{ - {Pubkey: fdVoter(1), Key: k1}, - {Pubkey: fdVoter(1), Key: k2}, // same validator, later slot: must win - }) - if tip != k2 { - t.Fatalf("deduped batch should land the later vote: tip=%+v", tip) - } -} - -// A failing execution must evict the candidate and never make it selectable. -func TestForkDriverExecutionFailureEvicts(t *testing.T) { - driver, _ := newTestDriver() - root := forkchoice.SlotHashKey{} - k1 := fdKey(1, 1) - failExec := func(uint64) ([]*accounts.Account, []byte, *state.ResumeContext, error) { - return nil, nil, nil, fmt.Errorf("boom") - } - if err := driver.OnBlock(k1, root, failExec); err == nil { - t.Fatal("execution failure must propagate") - } - if driver.choice.ContainsBlock(k1) { - t.Fatal("failed candidate must not enter fork choice") - } - if _, ok := driver.fc.BranchIDAt(1, k1.Hash); ok { - t.Fatal("failed candidate must be evicted from state") - } -} diff --git a/pkg/replay/fork_switch.go b/pkg/replay/fork_switch.go deleted file mode 100644 index e30331459..000000000 --- a/pkg/replay/fork_switch.go +++ /dev/null @@ -1,29 +0,0 @@ -package replay - -import ( - "fmt" - - "github.com/mr-tron/base58" -) - -// ConfirmedDivergence reports that replay computed a different bankhash than the -// one a >2/3 stake supermajority confirmed for the slot. In fork-aware mode this -// triggers dump-then-repair: drop the unrooted RAM tail, re-replay the confirmed -// chain from the rooted checkpoint. An IDENTICAL repeat means a deterministic -// replay bug, not a fork — fail closed. -type ConfirmedDivergence struct { - Slot uint64 - Ours [32]byte - Confirmed [32]byte -} - -func (e *ConfirmedDivergence) Error() string { - return fmt.Sprintf("consensus divergence: slot %d bankhash mismatch (our=%s confirmed=%s)", - e.Slot, base58.Encode(e.Ours[:]), base58.Encode(e.Confirmed[:])) -} - -// Same reports whether two divergences are identical (same slot and hash pair) — -// a repeat implies deterministic replay divergence, so retrying cannot help. -func (e *ConfirmedDivergence) Same(o *ConfirmedDivergence) bool { - return o != nil && e.Slot == o.Slot && e.Ours == o.Ours && e.Confirmed == o.Confirmed -} diff --git a/pkg/replay/fork_switch_test.go b/pkg/replay/fork_switch_test.go deleted file mode 100644 index ef1f3992c..000000000 --- a/pkg/replay/fork_switch_test.go +++ /dev/null @@ -1,32 +0,0 @@ -package replay - -import ( - "errors" - "fmt" - "testing" -) - -func TestConfirmedDivergenceSame(t *testing.T) { - a := &ConfirmedDivergence{Slot: 5, Ours: [32]byte{1}, Confirmed: [32]byte{2}} - if !a.Same(&ConfirmedDivergence{Slot: 5, Ours: [32]byte{1}, Confirmed: [32]byte{2}}) { - t.Fatal("identical divergence must compare Same") - } - if a.Same(nil) { - t.Fatal("nil is never Same") - } - if a.Same(&ConfirmedDivergence{Slot: 6, Ours: [32]byte{1}, Confirmed: [32]byte{2}}) { - t.Fatal("different slot is not Same") - } - if a.Same(&ConfirmedDivergence{Slot: 5, Ours: [32]byte{9}, Confirmed: [32]byte{2}}) { - t.Fatal("different our-hash is not Same") - } -} - -// The retry loop unwraps via errors.As, so the typed error must survive wrapping. -func TestConfirmedDivergenceErrorsAs(t *testing.T) { - err := fmt.Errorf("replay stopped: %w", &ConfirmedDivergence{Slot: 7}) - var div *ConfirmedDivergence - if !errors.As(err, &div) || div.Slot != 7 { - t.Fatalf("errors.As must recover the divergence: %v", div) - } -} diff --git a/pkg/replay/fork_tail.go b/pkg/replay/fork_tail.go deleted file mode 100644 index b51e92c62..000000000 --- a/pkg/replay/fork_tail.go +++ /dev/null @@ -1,95 +0,0 @@ -package replay - -import ( - "context" - - "github.com/Overclock-Validator/mithril/pkg/accounts" - "github.com/Overclock-Validator/mithril/pkg/mlog" - "github.com/Overclock-Validator/mithril/pkg/state" - "github.com/gagliardetto/solana-go" -) - -// forkTail adapts forkCoordinator to the unrootedState interface the replay loop -// drives: it tracks the executed chain (tip branch + slot→branch map) and uses the -// slot bankhash as the block identity, so a finalized slot resolves to its branch. -// The live replay path is linear (one executed chain); competing forks will execute -// as side branches once branch-aware execution lands. -type forkTail struct { - fc *forkCoordinator - tipBranch uint64 // branch of the last replayed slot (0 = durable base) - tipSlot uint64 // slot of the tip branch - slotBranch map[uint64]uint64 // executed chain: slot -> branch id -} - -func newForkTail(durable blockAccountSource, committer slotCommitter, haltCap int) *forkTail { - return &forkTail{ - fc: newForkCoordinator(durable, committer, haltCap), - slotBranch: make(map[uint64]uint64), - } -} - -// GetAccount resolves on the executed chain's tip branch (the slot being replayed -// extends it), else durable. -func (t *forkTail) GetAccount(slot uint64, pubkey solana.PublicKey) (*accounts.Account, error) { - return t.fc.GetAccount(t.tipBranch, slot, pubkey) -} - -func (t *forkTail) GetAccountsBatch(ctx context.Context, slot uint64, pks []solana.PublicKey) ([]*accounts.Account, error) { - return t.fc.GetAccountsBatch(ctx, t.tipBranch, slot, pks) -} - -// Add ingests the replayed slot as a child of the tip branch (bankhash = block -// identity) and buffers its writes; the new branch becomes the tip. -func (t *forkTail) Add(slot uint64, delta []*accounts.Account, bankhash []byte) { - var blockID [32]byte - copy(blockID[:], bankhash) - id, ok := t.fc.Ingest(t.tipBranch, slot, blockID) - if !ok { - // Unreachable (tipBranch is always live); loud because silence = state loss. - mlog.Log.Errorf("fork-aware: dropping slot %d writes: tip branch %d not live", slot, t.tipBranch) - return - } - t.fc.Commit(id, delta, bankhash, nil) - t.tipBranch = id - t.tipSlot = slot - t.slotBranch[slot] = id -} - -// SetContext attaches the slot's deep-copied resume context to its branch. -func (t *forkTail) SetContext(slot uint64, ctx *state.ResumeContext) { - if id, ok := t.slotBranch[slot]; ok { - t.fc.SetContext(id, ctx) - } -} - -// promote folds the executed chain through the highest replayed slot <= through into -// durable (two-phase inside the coordinator) and prunes the promoted prefix. -func (t *forkTail) promote(through uint64) (uint64, *state.ResumeContext, error) { - var bestSlot, bestID uint64 - found := false - for s, id := range t.slotBranch { - if s <= through && (!found || s > bestSlot) { - bestSlot, bestID, found = s, id, true - } - } - if !found { - return 0, nil, nil - } - promotedThrough, ctx, err := t.fc.Promote(bestID) - if promotedThrough > 0 { - for s := range t.slotBranch { - if s <= promotedThrough { - delete(t.slotBranch, s) - } - } - // If the tip itself was folded, the next slot extends the durable base. - if t.tipSlot <= promotedThrough { - t.tipBranch, t.tipSlot = 0, 0 - } - } - return promotedThrough, ctx, err -} - -func (t *forkTail) OverCap() bool { - return t.fc.OverCap() -} diff --git a/pkg/replay/fork_tail_test.go b/pkg/replay/fork_tail_test.go deleted file mode 100644 index 115e94573..000000000 --- a/pkg/replay/fork_tail_test.go +++ /dev/null @@ -1,174 +0,0 @@ -package replay - -import ( - "bytes" - "context" - "testing" - - "github.com/Overclock-Validator/mithril/pkg/accounts" - "github.com/Overclock-Validator/mithril/pkg/state" - "github.com/gagliardetto/solana-go" -) - -// driveEngine runs the same replay-shaped sequence (Add+SetContext per slot, promote -// partway, reads, final promote) against any unrootedState engine and returns its -// observable outputs for parity comparison. -type engineOutputs struct { - committedSlots []uint64 - midRead *accounts.Account - promote1 uint64 - promote2 uint64 - postRead *accounts.Account - overCap bool - durableK1 *accounts.Account -} - -func driveEngine(t *testing.T, eng unrootedState, committer *fakeCommitter) engineOutputs { - t.Helper() - // slots 1..5: key1 written at 1 and 4 (cross-slot overwrite), key(N) per slot - for slot := uint64(1); slot <= 5; slot++ { - delta := []*accounts.Account{testAccount(byte(slot), slot*10)} - if slot == 4 { - delta = append(delta, testAccount(1, 444)) - } - eng.Add(slot, delta, testHashBytes(byte(slot))) - eng.SetContext(slot, &state.ResumeContext{Slot: slot}) - } - var out engineOutputs - // mid-state read: key1 must be the slot-4 override - out.midRead, _ = eng.GetAccount(5, testKey(1)) - // promote through 3 (mid-chain), then through 5 (to the tip) - out.promote1, _, _ = eng.promote(3) - out.promote2, _, _ = eng.promote(5) - out.overCap = eng.OverCap() - out.committedSlots = append([]uint64(nil), committer.committed...) - out.durableK1, _ = committer.durable.GetAccountWithoutLock(testKey(1)) - // batch read after full promotion falls through to the (fake) durable source - if outs, err := eng.GetAccountsBatch(context.Background(), 6, []solana.PublicKey{testKey(1)}); err == nil && len(outs) == 1 { - out.postRead = outs[0] - } - return out -} - -// PARITY: forkTail (branch-tree engine in linear mode) must be observably identical -// to the proven unrootedTail for the same replay sequence. -func TestForkTailParityWithUnrootedTail(t *testing.T) { - comA := &fakeCommitter{durable: accounts.NewMemAccounts()} - linear := newUnrootedTail(&fakeDurable{known: map[solana.PublicKey]uint64{}}, comA, 512) - outA := driveEngine(t, linear, comA) - - comB := &fakeCommitter{durable: accounts.NewMemAccounts()} - forky := newForkTail(&fakeDurable{known: map[solana.PublicKey]uint64{}}, comB, 512) - outB := driveEngine(t, forky, comB) - - if outA.promote1 != outB.promote1 || outA.promote2 != outB.promote2 { - t.Fatalf("promote watermarks differ: linear=(%d,%d) fork=(%d,%d)", - outA.promote1, outA.promote2, outB.promote1, outB.promote2) - } - if len(outA.committedSlots) != len(outB.committedSlots) { - t.Fatalf("committed slots differ: linear=%v fork=%v", outA.committedSlots, outB.committedSlots) - } - for i := range outA.committedSlots { - if outA.committedSlots[i] != outB.committedSlots[i] { - t.Fatalf("commit order differs at %d: linear=%v fork=%v", i, outA.committedSlots, outB.committedSlots) - } - } - if outA.midRead.Lamports != outB.midRead.Lamports { - t.Fatalf("mid-state read differs: linear=%d fork=%d", outA.midRead.Lamports, outB.midRead.Lamports) - } - if outA.durableK1.Lamports != outB.durableK1.Lamports || outA.durableK1.Lamports != 444 { - t.Fatalf("durable end-state differs or wrong: linear=%d fork=%d (want 444)", - outA.durableK1.Lamports, outB.durableK1.Lamports) - } - if outA.overCap != outB.overCap { - t.Fatalf("OverCap differs") - } -} - -// promote returns the finalized slot's context and prunes; a tip-catching promote -// resets the fork tail so the next slot extends the durable base. -func TestForkTailPromoteToTipThenContinue(t *testing.T) { - committer := &fakeCommitter{durable: accounts.NewMemAccounts()} - tail := newForkTail(&fakeDurable{known: map[solana.PublicKey]uint64{}}, committer, 512) - - tail.Add(1, []*accounts.Account{testAccount(1, 10)}, testHashBytes(1)) - tail.SetContext(1, &state.ResumeContext{Slot: 1}) - through, ctx, err := tail.promote(1) // rooted catches the tip - if err != nil || through != 1 || ctx == nil || ctx.Slot != 1 { - t.Fatalf("promote-to-tip: through=%d ctx=%+v err=%v", through, ctx, err) - } - - // the next slot must extend the durable base, not a dead branch - tail.Add(2, []*accounts.Account{testAccount(2, 20)}, testHashBytes(2)) - if a, _ := tail.GetAccount(2, testKey(2)); a == nil || a.Lamports != 20 { - t.Fatalf("slot after tip-promote must be readable: %v", a) - } - if through, _, err := tail.promote(2); err != nil || through != 2 { - t.Fatalf("follow-up promote: through=%d err=%v", through, err) - } - if a, _ := committer.durable.GetAccountWithoutLock(testKey(2)); a == nil || a.Lamports != 20 { - t.Fatalf("slot-2 state must be durable: %v", a) - } -} - -// promote(through) where through is below every replayed slot must be a no-op. -func TestForkTailPromoteBelowChain(t *testing.T) { - committer := &fakeCommitter{durable: accounts.NewMemAccounts()} - tail := newForkTail(&fakeDurable{known: map[solana.PublicKey]uint64{}}, committer, 512) - tail.Add(10, []*accounts.Account{testAccount(1, 1)}, testHashBytes(1)) - if through, ctx, err := tail.promote(5); through != 0 || ctx != nil || err != nil { - t.Fatalf("promote below chain must no-op: %d %v %v", through, ctx, err) - } - if len(committer.committed) != 0 { - t.Fatalf("nothing durable expected: %v", committer.committed) - } -} - -// Partial promote failure must leave the engine retryable: watermark + context stay -// paired, the tree keeps the unpromoted branches, and a retry (idempotent re-commit -// of the durable prefix via the redo path) completes to the tip. -func TestForkTailPromotePartialFailureThenRetry(t *testing.T) { - committer := &fakeCommitter{durable: accounts.NewMemAccounts()} - tail := newForkTail(&fakeDurable{known: map[solana.PublicKey]uint64{}}, committer, 512) - for slot := uint64(1); slot <= 3; slot++ { - tail.Add(slot, []*accounts.Account{testAccount(byte(slot), slot*10)}, testHashBytes(byte(slot))) - tail.SetContext(slot, &state.ResumeContext{Slot: slot}) - } - - committer.failOn = 2 - through, ctx, err := tail.promote(3) - if err == nil { - t.Fatal("expected partial failure") - } - if through != 1 { - t.Fatalf("watermark should be last durable slot 1, got %d", through) - } - // context must be paired with the watermark (slot 1), matching the linear engine - if ctx == nil || ctx.Slot != 1 { - t.Fatalf("partial-failure context must be slot 1's, got %+v", ctx) - } - - committer.failOn = 0 - through, ctx, err = tail.promote(3) // retry: re-commits slot 1-2 idempotently, then 3 - if err != nil || through != 3 || ctx == nil || ctx.Slot != 3 { - t.Fatalf("retry should complete to tip: through=%d ctx=%+v err=%v", through, ctx, err) - } - for b := byte(1); b <= 3; b++ { - if a, _ := committer.durable.GetAccountWithoutLock(testKey(b)); a == nil || a.Lamports != uint64(b)*10 { - t.Fatalf("slot %d state must be durable after retry: %v", b, a) - } - } -} - -// bankhash bytes recorded per slot must round-trip through the fork engine unchanged. -func TestForkTailBankhashRoundTrip(t *testing.T) { - committer := &fakeCommitter{durable: accounts.NewMemAccounts()} - tail := newForkTail(&fakeDurable{known: map[solana.PublicKey]uint64{}}, committer, 512) - tail.Add(1, []*accounts.Account{testAccount(1, 1)}, testHashBytes(0xEE)) - if _, _, err := tail.promote(1); err != nil { - t.Fatal(err) - } - if len(committer.bankhashes) != 1 || !bytes.Equal(committer.bankhashes[0], testHashBytes(0xEE)) { - t.Fatalf("bankhash must round-trip: %v", committer.bankhashes) - } -} diff --git a/pkg/replay/promotion.go b/pkg/replay/promotion.go index c1d3e63b9..0871e66f5 100644 --- a/pkg/replay/promotion.go +++ b/pkg/replay/promotion.go @@ -2,9 +2,14 @@ package replay import ( "context" + "encoding/json" "fmt" + "time" "github.com/Overclock-Validator/mithril/pkg/accounts" + "github.com/Overclock-Validator/mithril/pkg/accountsdb" + "github.com/Overclock-Validator/mithril/pkg/global" + "github.com/Overclock-Validator/mithril/pkg/mlog" "github.com/Overclock-Validator/mithril/pkg/state" "github.com/gagliardetto/solana-go" ) @@ -13,12 +18,23 @@ import ( // exceed it rather than growing RAM unbounded (~16x normal rooting lag). const unrootedTailHaltCap = 512 -// slotCommitter durably folds one rooted slot into the canonical store, -// finalizing its crash-safe commit. Satisfied by AccountsDb.CommitRootedSlot. -type slotCommitter interface { - CommitRootedSlot(accts []*accounts.Account, slot uint64, bankhash []byte) error +// batchCommitter durably folds a batch of rooted slots into the canonical +// store as one sequential segment (union-deduped, one fsync, atomic index +// flip). Satisfied by AccountsDb.CommitBatch. +type batchCommitter interface { + CommitBatch(deltas []accounts.SlotDelta, throughSlot uint64, bankhashes map[uint64][32]byte, resumeCtx []byte) (accountsdb.BatchCommitResult, error) } +// defaultFoldBatchSlots is the fold chunk size K when none is configured: +// rooted slots fold to disk K at a time (union-deduped), and a trailing +// partial chunk stays in RAM (bounded by K) until it fills or flush() runs. +const defaultFoldBatchSlots = 128 + +// FoldBatchSlots is the configured fold chunk size (storage.fold_batch_slots), +// set by node startup before ReplayBlocks. Zero uses the default. Larger K = +// better union dedupe and less NVMe wear, more RAM, longer crash re-execution. +var FoldBatchSlots = defaultFoldBatchSlots + // blockAccountSource is the slot-scoped read API the block loader needs; both // AccountsDb and unrootedTail satisfy it, so the loader is mode-agnostic. type blockAccountSource interface { @@ -35,30 +51,40 @@ type unrootedState interface { Add(slot uint64, delta []*accounts.Account, bankhash []byte) SetContext(slot uint64, ctx *state.ResumeContext) promote(through uint64) (uint64, *state.ResumeContext, error) + // flush force-folds the trailing partial chunk <= through (graceful + // shutdown), so restart re-execution is bounded by the fold batch size. + flush(through uint64) (uint64, *state.ResumeContext, error) OverCap() bool } // unrootedTail layers an in-RAM UnrootedOverlay over the durable store: reads // resolve overlay→durable, commits buffer until rooted slots promote out. type unrootedTail struct { - overlay *accounts.UnrootedOverlay - durable blockAccountSource // the canonical rooted store (for read fall-through) - committer slotCommitter // durable promotion of rooted slots - bankhashes map[uint64][32]byte + overlay *accounts.WorkingSet + durable blockAccountSource // the canonical rooted store (for read fall-through) + committer batchCommitter // durable promotion of rooted slot batches + bankhashes map[uint64][32]byte + batchSlots int // fold chunk size K + stakeIdxDir string // directory of stake_pubkeys.idx; pending stake entries flush here at fold time // contexts holds the deep-copied end-of-slot resume context per held slot, // retained until promotion so the context as of the last rooted slot survives for resume. contexts map[uint64]*state.ResumeContext haltCap int // halt replay if held slots exceed this (rooting stalled) } -func newUnrootedTail(durable blockAccountSource, committer slotCommitter, haltCap int) *unrootedTail { +func newUnrootedTail(durable blockAccountSource, committer batchCommitter, haltCap int, batchSlots int, stakeIdxDir string) *unrootedTail { + if batchSlots <= 0 { + batchSlots = defaultFoldBatchSlots + } return &unrootedTail{ - overlay: accounts.NewUnrootedOverlay(), - durable: durable, - committer: committer, - bankhashes: make(map[uint64][32]byte), - contexts: make(map[uint64]*state.ResumeContext), - haltCap: haltCap, + overlay: accounts.NewWorkingSet(), + durable: durable, + committer: committer, + bankhashes: make(map[uint64][32]byte), + batchSlots: batchSlots, + stakeIdxDir: stakeIdxDir, + contexts: make(map[uint64]*state.ResumeContext), + haltCap: haltCap, } } @@ -130,10 +156,21 @@ func (t *unrootedTail) SetContext(slot uint64, ctx *state.ResumeContext) { } } -// promote durably commits + drops every held slot <= through. Returns the highest -// slot now durable and its resume context as of the last rooted slot (nil if none). See promoteRooted. +// promote folds full K-slot chunks of the rooted prefix <= through to disk; +// a trailing partial chunk stays in RAM until it fills (or flush() forces it). +// Returns the highest slot now durable and its resume context (nil if none). func (t *unrootedTail) promote(through uint64) (uint64, *state.ResumeContext, error) { - promotedThrough, err := promoteRooted(t.overlay, through, t.bankhashes, t.committer) + return t.promoteChunked(through, false) +} + +// flush force-folds everything <= through including the partial trailing +// chunk — the graceful-shutdown path, bounding restart re-execution. +func (t *unrootedTail) flush(through uint64) (uint64, *state.ResumeContext, error) { + return t.promoteChunked(through, true) +} + +func (t *unrootedTail) promoteChunked(through uint64, force bool) (uint64, *state.ResumeContext, error) { + promotedThrough, err := promoteRootedBatched(t.overlay, through, t.bankhashes, t.contexts, t.committer, t.batchSlots, t.stakeIdxDir, force) if promotedThrough == 0 { return 0, nil, err } @@ -146,43 +183,333 @@ func (t *unrootedTail) promote(through uint64) (uint64, *state.ResumeContext, er return promotedThrough, ctx, err } +// ── Async promotion ───────────────────────────────────────────────────────── +// +// CommitBatch is the replay loop's only heavy synchronous stall (segment write +// + fsync + index flip, ~hundreds of ms per K-slot chunk). The async promoter +// moves it off the loop: the loop BUILDS an immutable fold job (chunk snapshot +// + marshaled context), a worker goroutine runs the durable part (stake-index +// flush + CommitBatch), and the loop APPLIES the completion on a later +// iteration (PromotePrefix + map pruning + watermark bookkeeping). All +// WorkingSet/map mutation stays on the loop thread — the worker touches only +// its job and the committer. +// +// Safety notes: +// - While a fold is in flight the chunk's overlay layers are retained (reads +// stay correct: overlay wins over durable) and are immutable — Add only +// appends new slots, and the fork-switch unwind DRAINS the promoter before +// evicting (block.go), so EvictFrom can never race the worker's reads. +// - One job in flight at a time; the next chunk builds only after apply, so +// the alpenglow promotion gate re-checks every span it folds. +// - A completed-but-unapplied fold on exit is identical to the supported +// "crash after commit, before state-file update" case: RecoverFoldState +// reconciles the store frontier forward on the next start. +// - A failed fold is retried naturally: LastRootedSlot did not advance, so the +// next iteration rebuilds the same chunk. The stake-index flush that already +// landed is a harmless superset (second flush is a no-op). + +// foldJob is an immutable snapshot of one K-slot fold chunk. +type foldJob struct { + chunk []accounts.SlotDelta + through uint64 + bankhashes map[uint64][32]byte + ctx *state.ResumeContext + ctxJSON []byte + stakeIdxDir string +} + +type foldResult struct { + job *foldJob + err error +} + +// buildFoldJob snapshots the FIRST fold chunk of the rooted prefix <= through +// (loop thread). force also takes a trailing partial chunk. Returns nil when +// no chunk is ready. A missing/unmarshalable chunk-top context is an error — +// a context-less fold manifest would be unrecoverable, so it must not commit. +func (t *unrootedTail) buildFoldJob(through uint64, force bool) (*foldJob, error) { + prefix := t.overlay.PromotionPrefix(through) + if len(prefix) == 0 { + return nil, nil + } + chunk := prefix + if len(chunk) > t.batchSlots { + chunk = chunk[:t.batchSlots] + } else if len(chunk) < t.batchSlots && !force { + return nil, nil // trailing partial chunk stays in RAM + } + through = chunk[len(chunk)-1].Slot + + ctx := t.contexts[through] + if ctx == nil { + return nil, fmt.Errorf("fold chunk through slot %d: no resume context recorded for chunk-top slot", through) + } + ctxJSON, err := json.Marshal(ctx) + if err != nil { + return nil, fmt.Errorf("fold chunk through slot %d: marshal resume context: %w", through, err) + } + bankhashes := make(map[uint64][32]byte, len(chunk)) + for _, sd := range chunk { + if bh, ok := t.bankhashes[sd.Slot]; ok { + bankhashes[sd.Slot] = bh + } + } + return &foldJob{ + chunk: append([]accounts.SlotDelta(nil), chunk...), + through: through, + bankhashes: bankhashes, + ctx: ctx, + ctxJSON: ctxJSON, + stakeIdxDir: t.stakeIdxDir, + }, nil +} + +// runFoldJob performs the durable half of a fold (worker-safe: no tail +// state). Stake-index entries flush (fsync'd) BEFORE the batch commit — see +// promoteRootedBatched for why that order is a correctness requirement. +func runFoldJob(committer batchCommitter, job *foldJob) error { + if job.stakeIdxDir != "" { + if _, err := global.FlushPendingStakePubkeysThrough(job.stakeIdxDir, job.through); err != nil { + return fmt.Errorf("fold chunk through slot %d: flush stake index: %w", job.through, err) + } + } + if _, err := committer.CommitBatch(job.chunk, job.through, job.bankhashes, job.ctxJSON); err != nil { + return fmt.Errorf("fold chunk through slot %d: %w", job.through, err) + } + return nil +} + +// applyFoldJob applies a completed fold on the loop thread: the overlay drops +// the now-durable prefix and the per-slot maps prune. Returns the rooted +// context (the job snapshot — identical to what the manifest carries). +func (t *unrootedTail) applyFoldJob(job *foldJob) *state.ResumeContext { + t.overlay.PromotePrefix(job.through) + for s := range t.bankhashes { + if s <= job.through { + delete(t.bankhashes, s) + } + } + for s := range t.contexts { + if s <= job.through { + delete(t.contexts, s) + } + } + return job.ctx +} + +// asyncPromoter runs fold jobs on a worker goroutine, one in flight at a time. +// inFlight is loop-thread-owned; jobs/results carry the handoff. +type asyncPromoter struct { + committer batchCommitter + jobs chan *foldJob + results chan foldResult + inFlight bool + done chan struct{} +} + +func newAsyncPromoter(committer batchCommitter) *asyncPromoter { + p := &asyncPromoter{ + committer: committer, + jobs: make(chan *foldJob, 1), + results: make(chan foldResult, 1), + done: make(chan struct{}), + } + go p.run() + return p +} + +func (p *asyncPromoter) run() { + defer close(p.done) + for job := range p.jobs { + start := time.Now() + err := runFoldJob(p.committer, job) + if err == nil { + mlog.Log.FileOnlyf("async fold: committed %d slots through %d in %s", len(job.chunk), job.through, time.Since(start).Round(time.Millisecond)) + } + p.results <- foldResult{job: job, err: err} + } +} + +// enqueue hands a job to the worker (loop thread; requires !inFlight). +func (p *asyncPromoter) enqueue(job *foldJob) { + p.jobs <- job + p.inFlight = true +} + +// poll returns a completed result without blocking (nil when none / none in +// flight). +func (p *asyncPromoter) poll() *foldResult { + if !p.inFlight { + return nil + } + select { + case res := <-p.results: + p.inFlight = false + return &res + default: + return nil + } +} + +// drain blocks until the in-flight job (if any) completes and returns it. +// Called before fork unwinds, the shutdown flush, and loop exit — anywhere +// that must not race the worker or needs the durable frontier settled. +func (p *asyncPromoter) drain() *foldResult { + if !p.inFlight { + return nil + } + res := <-p.results + p.inFlight = false + return &res +} + +// stop drains any in-flight job and terminates the worker. The result of a +// drained-but-unapplied fold is intentionally discarded: the store is ahead +// of the state file, which RecoverFoldState reconciles on the next start. +func (p *asyncPromoter) stop() { + p.drain() + close(p.jobs) + <-p.done +} + +// unwind drops all held slots >= fromSlot (the execute-on-receipt fork +// switch) and returns the retained resume context of the last surviving slot +// so the replay loop can rebuild execution state and re-run the certified +// version. Returns nil when no context for fromSlot-1 is retained (caller +// falls back to the rooted-checkpoint re-replay). +func (t *unrootedTail) unwind(fromSlot uint64) *state.ResumeContext { + t.overlay.EvictFrom(fromSlot) + // Branch-scoped side effect: stake pubkeys enqueued by the evicted slots + // must never reach the durable index — drop them with the state. + if dropped := global.DropPendingStakePubkeysFrom(fromSlot); dropped > 0 { + mlog.Log.Infof("fork unwind: dropped %d pending stake-index entries from slots >= %d", dropped, fromSlot) + } + for s := range t.bankhashes { + if s >= fromSlot { + delete(t.bankhashes, s) + } + } + var ctx *state.ResumeContext + for s, c := range t.contexts { + if s >= fromSlot { + delete(t.contexts, s) + continue + } + if ctx == nil || s > ctx.Slot { + ctx = c + } + } + // ctx is the highest retained context with slot < fromSlot: the ACTUAL + // executed parent. It need not be numerically fromSlot-1 — when slots between + // it and fromSlot were skipped, they retain no context (only executed held + // slots call SetContext), so the last executed slot IS the parent bank of the + // certified block at fromSlot. Returning nil (parent already durably folded, + // or nothing retained) routes the caller to the rooted-checkpoint fallback. + return ctx +} + // OverCap reports whether the unrooted tail has grown past the halt cap, i.e. // rooting has stalled and we must stop replay rather than grow RAM unbounded. func (t *unrootedTail) OverCap() bool { return t.haltCap > 0 && t.overlay.HeldSlots() > t.haltCap } -// promoteRooted commits held slots <= through (ascending), then drops them, folding -// the rooted prefix onto disk. Crash-safe: stops at the first commit error, promoting -// only through the last durable slot. Returns the highest durable slot (0 if none). -func promoteRooted( - overlay *accounts.UnrootedOverlay, +// promoteRootedBatched folds held slots <= through in chunks of batchSlots. +// Each chunk = one CommitBatch (one segment + one fsync + one index flip), +// then the chunk drops from RAM. A trailing partial chunk folds only when +// force is set; otherwise it stays in RAM, so restart re-execution from the +// blockstore is bounded by the chunk size. Crash-safe: an error stops at the +// last fully durable chunk boundary (a failed chunk leaves only an orphan +// segment that recovery GCs). +// safePromoteTarget is the dual-watermark fold target: certificate finality +// clamped by the trailing-verification watermark (only when the verifier is +// required) and never at or beyond a persisted-divergence floor. Promotion is +// driven by this target exceeding the durable watermark, so verified progress +// alone can advance it even when certificate finality is momentarily flat. +func safePromoteTarget(finality uint64, verifierRequired bool, verifiedWatermark, divergenceFloor uint64) uint64 { + target := finality + if verifierRequired && verifiedWatermark < target { + target = verifiedWatermark + } + if divergenceFloor > 0 && target >= divergenceFloor { + target = divergenceFloor - 1 + } + return target +} + +func promoteRootedBatched( + overlay *accounts.WorkingSet, through uint64, bankhashes map[uint64][32]byte, - committer slotCommitter, + contexts map[uint64]*state.ResumeContext, + committer batchCommitter, + batchSlots int, + stakeIdxDir string, + force bool, ) (promotedThrough uint64, err error) { - batch := overlay.PromotionPrefix(through) - if len(batch) == 0 { + prefix := overlay.PromotionPrefix(through) + if len(prefix) == 0 { return 0, nil } - for _, sd := range batch { - slotBankhash := bankhashes[sd.Slot] - if cerr := committer.CommitRootedSlot(sd.Delta, sd.Slot, slotBankhash[:]); cerr != nil { - err = fmt.Errorf("promote slot %d: %w", sd.Slot, cerr) + for start := 0; start < len(prefix); start += batchSlots { + end := start + batchSlots + if end > len(prefix) { + if !force { + break // trailing partial chunk stays in RAM + } + end = len(prefix) + } + chunk := prefix[start:end] + chunkThrough := chunk[len(chunk)-1].Slot + + chunkBankhashes := make(map[uint64][32]byte, len(chunk)) + for _, sd := range chunk { + if bh, ok := bankhashes[sd.Slot]; ok { + chunkBankhashes[sd.Slot] = bh + } + } + + // The chunk-top resume context rides in the manifest so the durable + // watermark + context survive a hard crash without the state file. A + // missing or unmarshalable context would produce a manifest that + // recovery cannot resume from — it fatals when the store outruns the + // state file. Fail the fold here instead of committing an unrecoverable + // batch; the caller holds the watermark and the slots stay in RAM. + ctx := contexts[chunkThrough] + if ctx == nil { + err = fmt.Errorf("promote chunk through slot %d: no resume context recorded for chunk-top slot", chunkThrough) + break + } + ctxJSON, merr := json.Marshal(ctx) + if merr != nil { + err = fmt.Errorf("promote chunk through slot %d: marshal resume context: %w", chunkThrough, merr) break } - promotedThrough = sd.Slot - } - if promotedThrough > 0 { - overlay.PromotePrefix(promotedThrough) - for _, sd := range batch { - if sd.Slot > promotedThrough { + // Stake-index entries for this chunk's slots flush (fsync'd) BEFORE the + // batch commit: if we crash between the two, the index holds a harmless + // superset (those slots re-execute and re-enqueue; scans dedup). The + // reverse order could leave folded slots' stake accounts missing from + // the index — a subset — which would silently corrupt the epoch-stakes + // scan. Entries for unfolded slots stay in RAM (branch-scoped). + if stakeIdxDir != "" { + if _, ferr := global.FlushPendingStakePubkeysThrough(stakeIdxDir, chunkThrough); ferr != nil { + err = fmt.Errorf("promote chunk through slot %d: flush stake index: %w", chunkThrough, ferr) break } + } + + if _, cerr := committer.CommitBatch(chunk, chunkThrough, chunkBankhashes, ctxJSON); cerr != nil { + err = fmt.Errorf("promote chunk through slot %d: %w", chunkThrough, cerr) + break + } + + overlay.PromotePrefix(chunkThrough) + for _, sd := range chunk { delete(bankhashes, sd.Slot) } + promotedThrough = chunkThrough } return promotedThrough, err } diff --git a/pkg/replay/promotion_test.go b/pkg/replay/promotion_test.go index 359898f71..31dc88110 100644 --- a/pkg/replay/promotion_test.go +++ b/pkg/replay/promotion_test.go @@ -2,10 +2,12 @@ package replay import ( "context" + "encoding/json" "fmt" "testing" "github.com/Overclock-Validator/mithril/pkg/accounts" + "github.com/Overclock-Validator/mithril/pkg/accountsdb" "github.com/Overclock-Validator/mithril/pkg/state" "github.com/gagliardetto/solana-go" "github.com/stretchr/testify/assert" @@ -43,40 +45,63 @@ func testAccount(b byte, lamports uint64) *accounts.Account { func testHash(b byte) [32]byte { var h [32]byte; h[0] = b; return h } func testHashBytes(b byte) []byte { h := make([]byte, 32); h[0] = b; return h } -// fakeCommitter records CommitSlotAtomic calls and applies deltas to a durable -// MemAccounts, optionally failing on a chosen slot. +// fakeCommitter records CommitBatch calls and applies deltas to a durable +// MemAccounts, optionally failing any chunk containing a chosen slot. +// Failure is all-or-nothing per chunk, mirroring the real segment fold. type fakeCommitter struct { - durable accounts.MemAccounts - committed []uint64 - bankhashes [][]byte - failOn uint64 + durable accounts.MemAccounts + committed []uint64 // every folded slot, ascending across batches + throughs []uint64 // chunkThrough per CommitBatch call + slotBH map[uint64][32]byte + ctxs map[uint64][]byte + failOn uint64 } -func (f *fakeCommitter) CommitRootedSlot(accts []*accounts.Account, slot uint64, bankhash []byte) error { - if f.failOn != 0 && slot == f.failOn { - return fmt.Errorf("commit boom at slot %d", slot) +func (f *fakeCommitter) CommitBatch(deltas []accounts.SlotDelta, throughSlot uint64, bankhashes map[uint64][32]byte, resumeCtx []byte) (accountsdb.BatchCommitResult, error) { + for _, sd := range deltas { + if f.failOn != 0 && sd.Slot == f.failOn { + return accountsdb.BatchCommitResult{}, fmt.Errorf("commit boom at slot %d", sd.Slot) + } } - for _, a := range accts { - _ = f.durable.SetAccountWithoutLock(a.Key, a) + keys := 0 + for _, sd := range deltas { + for _, a := range sd.Delta { + if a == nil { + continue + } + _ = f.durable.SetAccountWithoutLock(a.Key, a) + keys++ + } + f.committed = append(f.committed, sd.Slot) } - f.committed = append(f.committed, slot) - f.bankhashes = append(f.bankhashes, append([]byte(nil), bankhash...)) - return nil + f.throughs = append(f.throughs, throughSlot) + if f.slotBH == nil { + f.slotBH = make(map[uint64][32]byte) + } + for slot, bh := range bankhashes { + f.slotBH[slot] = bh + } + if f.ctxs == nil { + f.ctxs = make(map[uint64][]byte) + } + f.ctxs[throughSlot] = append([]byte(nil), resumeCtx...) + return accountsdb.BatchCommitResult{ThroughSlot: throughSlot, Keys: keys}, nil } // Happy path: rooted prefix is committed durably in slot order and dropped from // the overlay; the unrooted tail above `through` stays held; bankhashes pruned. func TestPromoteRootedHappyPath(t *testing.T) { durable := accounts.NewMemAccounts() - overlay := accounts.NewUnrootedOverlay() + overlay := accounts.NewWorkingSet() overlay.Add(5, []*accounts.Account{testAccount(1, 51), testAccount(2, 52)}) overlay.Add(7, []*accounts.Account{testAccount(2, 72)}) overlay.Add(9, []*accounts.Account{testAccount(3, 93)}) // unrooted tip, stays held bankhashes := map[uint64][32]byte{5: testHash(5), 7: testHash(7), 9: testHash(9)} + contexts := map[uint64]*state.ResumeContext{5: {Slot: 5}, 7: {Slot: 7}, 9: {Slot: 9}} fc := &fakeCommitter{durable: durable} - promoted, err := promoteRooted(overlay, 7, bankhashes, fc) + promoted, err := promoteRootedBatched(overlay, 7, bankhashes, contexts, fc, 2, "", false) require.NoError(t, err) assert.Equal(t, uint64(7), promoted) assert.Equal(t, []uint64{5, 7}, fc.committed, "committed ascending") @@ -92,15 +117,16 @@ func TestPromoteRootedHappyPath(t *testing.T) { // overlay so no fall-through read can see a gap. func TestPromoteRootedPartialFailureStopsAtLastDurable(t *testing.T) { durable := accounts.NewMemAccounts() - overlay := accounts.NewUnrootedOverlay() + overlay := accounts.NewWorkingSet() overlay.Add(5, []*accounts.Account{testAccount(1, 51)}) overlay.Add(7, []*accounts.Account{testAccount(2, 72)}) overlay.Add(9, []*accounts.Account{testAccount(3, 93)}) bankhashes := map[uint64][32]byte{5: testHash(5), 7: testHash(7), 9: testHash(9)} + contexts := map[uint64]*state.ResumeContext{5: {Slot: 5}, 7: {Slot: 7}, 9: {Slot: 9}} fc := &fakeCommitter{durable: durable, failOn: 7} - promoted, err := promoteRooted(overlay, 9, bankhashes, fc) + promoted, err := promoteRootedBatched(overlay, 9, bankhashes, contexts, fc, 1, "", false) require.Error(t, err, "commit failure surfaces") assert.Equal(t, uint64(5), promoted, "advance only to last durable slot") assert.Equal(t, []uint64{5}, fc.committed) @@ -115,23 +141,69 @@ func TestPromoteRootedPartialFailureStopsAtLastDurable(t *testing.T) { // recorded in the durable store. func TestPromoteRootedEmptyDeltaSlot(t *testing.T) { durable := accounts.NewMemAccounts() - overlay := accounts.NewUnrootedOverlay() + overlay := accounts.NewWorkingSet() overlay.Add(5, nil) // empty block bankhashes := map[uint64][32]byte{5: testHash(5)} + contexts := map[uint64]*state.ResumeContext{5: {Slot: 5}} fc := &fakeCommitter{durable: durable} - promoted, err := promoteRooted(overlay, 5, bankhashes, fc) + promoted, err := promoteRootedBatched(overlay, 5, bankhashes, contexts, fc, 1, "", false) require.NoError(t, err) assert.Equal(t, uint64(5), promoted) assert.Equal(t, []uint64{5}, fc.committed, "empty slot still committed for its bankhash") assert.Equal(t, 0, overlay.HeldSlots()) } +// The dual-watermark target: finality clamped by verification (when required) +// and the divergence floor. The key property (Codex review) is that verified +// progress advances the target even when finality is flat. +func TestSafePromoteTarget(t *testing.T) { + // Verifier not required: target follows finality regardless of verified. + assert.Equal(t, uint64(100), safePromoteTarget(100, false, 0, 0)) + assert.Equal(t, uint64(100), safePromoteTarget(100, false, 40, 0)) + + // Required + verifier lagged: clamped to the verified watermark... + assert.Equal(t, uint64(50), safePromoteTarget(100, true, 50, 0)) + // ...and as verification advances with finality FLAT at 100, the target + // advances too — the verification-driven retry this fix enables. + assert.Equal(t, uint64(75), safePromoteTarget(100, true, 75, 0)) + assert.Equal(t, uint64(100), safePromoteTarget(100, true, 100, 0)) + // Verified past finality never exceeds finality. + assert.Equal(t, uint64(100), safePromoteTarget(100, true, 150, 0)) + + // Persisted divergence floor holds the target below the disputed slot, + // independent of the verifier. + assert.Equal(t, uint64(79), safePromoteTarget(100, true, 100, 80)) + assert.Equal(t, uint64(79), safePromoteTarget(100, false, 0, 80)) + // Floor above the target does not raise it. + assert.Equal(t, uint64(100), safePromoteTarget(100, true, 100, 200)) +} + +// A chunk whose top slot has no recorded resume context must NOT fold: the +// manifest would carry no context and recovery fatals if the store later +// outruns the state file. The fold fails and the watermark stays back. +func TestPromoteRootedMissingContextFailsClosed(t *testing.T) { + durable := accounts.NewMemAccounts() + overlay := accounts.NewWorkingSet() + overlay.Add(5, []*accounts.Account{testAccount(1, 51)}) + overlay.Add(7, []*accounts.Account{testAccount(2, 72)}) + + bankhashes := map[uint64][32]byte{5: testHash(5), 7: testHash(7)} + // Context for slot 5 only; the chunk through slot 7 has none. + contexts := map[uint64]*state.ResumeContext{5: {Slot: 5}} + fc := &fakeCommitter{durable: durable} + + promoted, err := promoteRootedBatched(overlay, 7, bankhashes, contexts, fc, 1, "", false) + require.ErrorContains(t, err, "no resume context") + assert.Equal(t, uint64(5), promoted, "advance only through the last chunk that had a context") + assert.Equal(t, []uint64{5}, fc.committed, "context-less chunk never committed") +} + // Tail reads: overlay value wins; misses fall through to durable. func TestUnrootedTailGetAccount(t *testing.T) { durable := &fakeDurable{known: map[solana.PublicKey]uint64{testKey(1): 100, testKey(2): 200}} - tail := newUnrootedTail(durable, &fakeCommitter{}, 512) + tail := newUnrootedTail(durable, &fakeCommitter{}, 512, 1, "") tail.Add(5, []*accounts.Account{testAccount(1, 51)}, testHashBytes(5)) // key 1 written unrooted a, err := tail.GetAccount(5, testKey(1)) @@ -147,7 +219,7 @@ func TestUnrootedTailGetAccount(t *testing.T) { // durable batch, placeholder for unknown keys. func TestUnrootedTailGetAccountsBatch(t *testing.T) { durable := &fakeDurable{known: map[solana.PublicKey]uint64{testKey(2): 200}} - tail := newUnrootedTail(durable, &fakeCommitter{}, 512) + tail := newUnrootedTail(durable, &fakeCommitter{}, 512, 1, "") tail.Add(5, []*accounts.Account{testAccount(1, 51), testAccount(4, 54)}, testHashBytes(5)) keys := []solana.PublicKey{testKey(1), testKey(2), testKey(3), testKey(4)} @@ -163,7 +235,7 @@ func TestUnrootedTailGetAccountsBatch(t *testing.T) { // OverCap trips only when held slots exceed the cap (backpressure on stalled rooting). func TestUnrootedTailOverCap(t *testing.T) { - tail := newUnrootedTail(&fakeDurable{}, &fakeCommitter{}, 2) + tail := newUnrootedTail(&fakeDurable{}, &fakeCommitter{}, 2, 1, "") tail.Add(1, nil, testHashBytes(1)) tail.Add(2, nil, testHashBytes(2)) assert.False(t, tail.OverCap(), "2 held == cap, not over") @@ -174,7 +246,7 @@ func TestUnrootedTailOverCap(t *testing.T) { // promote returns the resume context as of the highest promoted slot and prunes // the context map for promoted slots, retaining still-held ones. func TestUnrootedTailContextCaptureAndPromote(t *testing.T) { - tail := newUnrootedTail(&fakeDurable{}, &fakeCommitter{durable: accounts.NewMemAccounts()}, 512) + tail := newUnrootedTail(&fakeDurable{}, &fakeCommitter{durable: accounts.NewMemAccounts()}, 512, 1, "") tail.Add(5, []*accounts.Account{testAccount(1, 51)}, testHashBytes(5)) tail.SetContext(5, &state.ResumeContext{Slot: 5, Bankhash: "bh5"}) tail.Add(7, []*accounts.Account{testAccount(2, 72)}, testHashBytes(7)) @@ -199,13 +271,68 @@ func TestUnrootedTailContextCaptureAndPromote(t *testing.T) { // Nothing to promote (through below all held slots) is a no-op, not an error. func TestPromoteRootedNoPrefix(t *testing.T) { durable := accounts.NewMemAccounts() - overlay := accounts.NewUnrootedOverlay() + overlay := accounts.NewWorkingSet() overlay.Add(10, []*accounts.Account{testAccount(1, 10)}) fc := &fakeCommitter{durable: durable} - promoted, err := promoteRooted(overlay, 5, map[uint64][32]byte{}, fc) + promoted, err := promoteRootedBatched(overlay, 5, map[uint64][32]byte{}, map[uint64]*state.ResumeContext{}, fc, 1, "", false) require.NoError(t, err) assert.Equal(t, uint64(0), promoted) assert.Empty(t, fc.committed) assert.Equal(t, 1, overlay.HeldSlots()) } + +// A trailing partial chunk (fewer than batchSlots rooted slots) must NOT fold +// on promote() — restart re-execution stays bounded by the chunk size — but +// flush() (graceful shutdown) forces it. +func TestPromoteBatchedDefersPartialChunkUntilFlush(t *testing.T) { + fc := &fakeCommitter{durable: accounts.NewMemAccounts()} + tail := newUnrootedTail(&fakeDurable{}, fc, 512, 4, "") + for slot := uint64(1); slot <= 3; slot++ { + tail.Add(slot, []*accounts.Account{testAccount(byte(slot), slot)}, testHashBytes(byte(slot))) + tail.SetContext(slot, &state.ResumeContext{Slot: slot}) + } + + promoted, ctx, err := tail.promote(3) + require.NoError(t, err) + assert.Zero(t, promoted, "partial chunk must stay in RAM on promote") + assert.Nil(t, ctx) + assert.Empty(t, fc.committed) + + flushed, fctx, err := tail.flush(3) + require.NoError(t, err) + assert.Equal(t, uint64(3), flushed, "flush force-folds the partial chunk") + require.NotNil(t, fctx) + assert.Equal(t, uint64(3), fctx.Slot) + assert.Equal(t, []uint64{1, 2, 3}, fc.committed) + assert.Equal(t, []uint64{3}, fc.throughs, "one batch, through the tip") +} + +// Full chunks fold on promote; the chunk-top resume context rides along as +// serialized JSON; per-chunk bankhash maps carry exactly the chunk's slots. +func TestPromoteBatchedChunkBoundariesAndContext(t *testing.T) { + fc := &fakeCommitter{durable: accounts.NewMemAccounts()} + tail := newUnrootedTail(&fakeDurable{}, fc, 512, 2, "") + for slot := uint64(1); slot <= 5; slot++ { + tail.Add(slot, []*accounts.Account{testAccount(byte(slot), slot)}, testHashBytes(byte(slot))) + tail.SetContext(slot, &state.ResumeContext{Slot: slot, Bankhash: "bh"}) + } + + promoted, ctx, err := tail.promote(5) + require.NoError(t, err) + assert.Equal(t, uint64(4), promoted, "two full chunks fold; slot 5 is a deferred partial") + require.NotNil(t, ctx) + assert.Equal(t, uint64(4), ctx.Slot) + assert.Equal(t, []uint64{2, 4}, fc.throughs) + assert.NotEmpty(t, fc.ctxs[2], "chunk-top context serialized into the fold") + assert.NotEmpty(t, fc.ctxs[4]) + + var decoded state.ResumeContext + require.NoError(t, json.Unmarshal(fc.ctxs[4], &decoded)) + assert.Equal(t, uint64(4), decoded.Slot) + + flushed, _, err := tail.flush(5) + require.NoError(t, err) + assert.Equal(t, uint64(5), flushed) + assert.Equal(t, []uint64{1, 2, 3, 4, 5}, fc.committed) +} diff --git a/pkg/replay/resume_context.go b/pkg/replay/resume_context.go new file mode 100644 index 000000000..2bfeb8875 --- /dev/null +++ b/pkg/replay/resume_context.go @@ -0,0 +1,144 @@ +package replay + +import ( + "encoding/base64" + "fmt" + + "github.com/Overclock-Validator/mithril/pkg/lthash" + "github.com/Overclock-Validator/mithril/pkg/mlog" + "github.com/Overclock-Validator/mithril/pkg/sealevel" + "github.com/Overclock-Validator/mithril/pkg/state" + "github.com/mr-tron/base58" +) + +// Rebuilding replay's resume state from a rooted (or retained in-RAM) resume +// context: used at startup for checkpoint resume and in-loop by the +// execute-on-receipt fork switch to re-run a certified sibling without a +// process restart. + +// resolveInitialTransactionCount picks the transaction count to seed a replay +// run with: the checkpoint's exact count when the context recorded one, else +// the snapshot manifest's count (exact only when nothing folded past the +// snapshot — a checkpoint written before the field existed loses the folded +// span, so the caller warns that the count is approximate until re-bootstrap). +func resolveInitialTransactionCount(rs *ResumeState, manifestCount uint64) (count uint64, exact bool) { + if rs != nil && rs.TransactionCount != nil { + return *rs.TransactionCount, true + } + return manifestCount, false +} + +func DecodeRecentBlockhashes(entries []state.BlockhashEntry) sealevel.SysvarRecentBlockhashes { + result := make(sealevel.SysvarRecentBlockhashes, 0, len(entries)) + dropped := 0 + for _, entry := range entries { + hashBytes, err := base58.Decode(entry.Blockhash) + if err != nil || len(hashBytes) != 32 { + dropped++ + continue + } + var blockhash [32]byte + copy(blockhash[:], hashBytes) + result = append(result, sealevel.RecentBlockHashesEntry{ + Blockhash: blockhash, + FeeCalculator: sealevel.FeeCalculator{LamportsPerSignature: entry.LamportsPerSignature}, + }) + } + if dropped > 0 { + mlog.Log.Errorf("dropped %d/%d RecentBlockhashes entries due to invalid base58 - state file may be corrupted", dropped, len(entries)) + } + return result +} + +// decodeSlotHashes converts state.SlotHashEntry list to sealevel.SysvarSlotHashes +func DecodeSlotHashes(entries []state.SlotHashEntry) sealevel.SysvarSlotHashes { + result := make(sealevel.SysvarSlotHashes, 0, len(entries)) + dropped := 0 + for _, entry := range entries { + hashBytes, err := base58.Decode(entry.Hash) + if err != nil || len(hashBytes) != 32 { + dropped++ + continue + } + var hash [32]byte + copy(hash[:], hashBytes) + result = append(result, sealevel.SlotHash{ + Slot: entry.Slot, + Hash: hash, + }) + } + if dropped > 0 { + mlog.Log.Errorf("dropped %d/%d SlotHashes entries due to invalid base58 - state file may be corrupted", dropped, len(entries)) + } + return result +} + +// resumeStateFromRootedContext builds a replay.ResumeState from the context +// captured at promotion (as of the last rooted slot); the next block is the slot +// after the last rooted slot, whose parent is the last rooted slot. +func ResumeStateFromRootedContext(rc *state.ResumeContext, epochStakes map[uint64]string) (*ResumeState, error) { + parentBankhash, err := base58.Decode(rc.Bankhash) + if err != nil { + return nil, fmt.Errorf("decode rooted bankhash: %w", err) + } + ltHashBytes, err := base64.StdEncoding.DecodeString(rc.AcctsLtHash) + if err != nil { + return nil, fmt.Errorf("decode rooted accts_lt_hash: %w", err) + } + ltHash := <hash.LtHash{} + ltHash.InitWithHash(ltHashBytes) + + rs := &ResumeState{ + ParentSlot: rc.Slot, + ParentBlockHeight: rc.BlockHeight, + ParentBankhash: parentBankhash, + AcctsLtHash: ltHash, + LamportsPerSignature: rc.LamportsPerSignature, + PrevLamportsPerSignature: rc.PrevLamportsPerSig, + NumSignatures: rc.NumSignatures, + Capitalization: rc.Capitalization, + SlotsPerYear: rc.SlotsPerYear, + InflationInitial: rc.InflationInitial, + InflationTerminal: rc.InflationTerminal, + InflationTaper: rc.InflationTaper, + InflationFoundation: rc.InflationFoundation, + InflationFoundationTerm: rc.InflationFoundationTerm, + } + if rc.TransactionCount != nil { + txc := *rc.TransactionCount // deep copy: contexts must not share pointers + rs.TransactionCount = &txc + } + + if len(rc.RecentBlockhashes) > 0 { + recentBlockhashes := DecodeRecentBlockhashes(rc.RecentBlockhashes) + rs.RecentBlockhashes = &recentBlockhashes + if rc.EvictedBlockhash != "" { + if evb, err := base58.Decode(rc.EvictedBlockhash); err == nil && len(evb) == 32 { + copy(rs.EvictedBlockhash[:], evb) + } + } + if rc.Blockhash != "" { + if bb, err := base58.Decode(rc.Blockhash); err == nil && len(bb) == 32 { + copy(rs.LastBlockhash[:], bb) + } + } + } + if len(rc.SlotHashes) > 0 { + slotHashes := DecodeSlotHashes(rc.SlotHashes) + rs.SlotHashes = &slotHashes + } + if rc.Clock != "" { + clockData, err := base64.StdEncoding.DecodeString(rc.Clock) + if err != nil { + return nil, fmt.Errorf("decode rooted clock sysvar: %w", err) + } + rs.Clock = clockData + } + if len(epochStakes) > 0 { + rs.ComputedEpochStakes = make(map[uint64][]byte, len(epochStakes)) + for epoch, data := range epochStakes { + rs.ComputedEpochStakes[epoch] = []byte(data) + } + } + return rs, nil +} diff --git a/pkg/replay/stake_index_fold_test.go b/pkg/replay/stake_index_fold_test.go new file mode 100644 index 000000000..23a3ab7cd --- /dev/null +++ b/pkg/replay/stake_index_fold_test.go @@ -0,0 +1,121 @@ +package replay + +import ( + "os" + "path/filepath" + "testing" + + "github.com/Overclock-Validator/mithril/pkg/accounts" + "github.com/Overclock-Validator/mithril/pkg/accountsdb" + "github.com/Overclock-Validator/mithril/pkg/global" + "github.com/Overclock-Validator/mithril/pkg/state" + "github.com/gagliardetto/solana-go" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// orderAssertingCommitter checks, AT CommitBatch time, that the stake-index +// file already contains the chunk's entries: the flush must happen BEFORE the +// fold commit so a crash between the two leaves a harmless superset (slots +// re-execute and re-enqueue), never a subset — the index feeds epoch-stakes +// enumeration and must not miss folded slots' stake accounts. +type orderAssertingCommitter struct { + fakeCommitter + t *testing.T + idxPath string + expectAt map[uint64][]solana.PublicKey // chunkThrough -> pubkeys that must already be durable +} + +func (o *orderAssertingCommitter) CommitBatch(deltas []accounts.SlotDelta, throughSlot uint64, bankhashes map[uint64][32]byte, resumeCtx []byte) (accountsdb.BatchCommitResult, error) { + if want := o.expectAt[throughSlot]; len(want) > 0 { + onDisk := readStakeIndexPubkeys(o.t, o.idxPath) + for _, pk := range want { + if _, ok := onDisk[pk]; !ok { + o.t.Fatalf("CommitBatch(through=%d): stake pubkey %s not yet flushed to the index — flush must precede the fold commit", throughSlot, pk) + } + } + } + return o.fakeCommitter.CommitBatch(deltas, throughSlot, bankhashes, resumeCtx) +} + +// readStakeIndexPubkeys parses the on-disk index directly (8-byte "STKI" +// header + 48-byte records) so assertions are independent of the global's +// load cache. +func readStakeIndexPubkeys(t *testing.T, path string) map[solana.PublicKey]struct{} { + t.Helper() + data, err := os.ReadFile(path) + if err != nil { + return map[solana.PublicKey]struct{}{} // no file yet = nothing flushed + } + out := make(map[solana.PublicKey]struct{}) + if len(data) < 8 { + return out + } + for off := 8; off+accountsdb.StakeIndexRecordSize <= len(data); off += accountsdb.StakeIndexRecordSize { + var pk solana.PublicKey + copy(pk[:], data[off:off+32]) + out[pk] = struct{}{} + } + return out +} + +// Stake-index entries are slot-scoped: they reach the durable index only when +// their slot FOLDS (flushed before the commit), entries above the fold stay +// RAM-pending and visible to scans, and a fork unwind drops the evicted +// suffix's entries instead of leaking them into the file. +func TestStakeIndexFoldScopedFlush(t *testing.T) { + global.ClearPendingStakePubkeys() + defer global.ClearPendingStakePubkeys() + + dir := t.TempDir() + idxPath := filepath.Join(dir, global.StakePubkeyIndexFileName) + + inChunk := testKey(0xA1) // created at slot 5 — folds + inChunk2 := testKey(0xA2) // created at slot 7 — folds + aboveFold := testKey(0xB1) // created at slot 9 — stays RAM-pending + wrongFork := testKey(0xC1) // created at slot 9 — dropped by unwind + + global.EnqueuePendingStakePubkey(5, inChunk) + global.EnqueuePendingStakePubkey(7, inChunk2) + global.EnqueuePendingStakePubkey(9, aboveFold) + global.EnqueuePendingStakePubkey(9, wrongFork) + + tail := newUnrootedTail(&fakeDurable{}, nil, 512, 2, dir) + oc := &orderAssertingCommitter{ + fakeCommitter: fakeCommitter{durable: accounts.NewMemAccounts()}, + t: t, + idxPath: idxPath, + expectAt: map[uint64][]solana.PublicKey{7: {inChunk, inChunk2}}, + } + tail.committer = oc + tail.Add(5, []*accounts.Account{testAccount(1, 51)}, testHashBytes(5)) + tail.Add(7, []*accounts.Account{testAccount(2, 72)}, testHashBytes(7)) + tail.Add(9, []*accounts.Account{testAccount(3, 93)}, testHashBytes(9)) + tail.SetContext(5, &state.ResumeContext{Slot: 5}) + tail.SetContext(7, &state.ResumeContext{Slot: 7}) + tail.SetContext(9, &state.ResumeContext{Slot: 9}) + + // Fold slots 5..7 (one chunk of 2). The committer asserts the flush order. + promoted, _, err := tail.promote(7) + require.NoError(t, err) + require.Equal(t, uint64(7), promoted) + + // Folded slots' entries are durable; slot 9's are NOT in the file... + onDisk := readStakeIndexPubkeys(t, idxPath) + assert.Contains(t, onDisk, inChunk) + assert.Contains(t, onDisk, inChunk2) + assert.NotContains(t, onDisk, aboveFold, "unfolded slot's entries must not be durable") + + // ...but ARE visible to scans via the pending snapshot (completeness). + pending := global.PendingStakeEntriesSnapshot() + require.Len(t, pending, 2) + + // Fork switch unwinds slot 9: its entries drop with the state. + tail.unwind(9) + assert.Empty(t, global.PendingStakeEntriesSnapshot(), "unwound slots' stake entries must be dropped, not flushed") + + // The file is untouched by the unwind — wrong-fork pubkeys never leaked. + onDisk = readStakeIndexPubkeys(t, idxPath) + assert.NotContains(t, onDisk, wrongFork) + assert.Len(t, onDisk, 2) +} diff --git a/pkg/replay/summary_stats.go b/pkg/replay/summary_stats.go new file mode 100644 index 000000000..31e1a3f56 --- /dev/null +++ b/pkg/replay/summary_stats.go @@ -0,0 +1,161 @@ +package replay + +import ( + "fmt" + "os" + "sort" + "strconv" + "strings" +) + +// Terminal replay stats for the Alpenglow/native-shred path. Terminology +// follows Agave: a slot is "full" when all its data shreds are present and the +// block is reconstructable (SlotMeta/is_full) — NOT finalized/consensus-safe. +// +// Shred timings are replay-relative, the only clock the operator actually +// cares about: +// +// ready = when the block finished assembling MINUS when replay asked for +// it. Negative: it was ready that long BEFORE replay needed it +// (the pipeline is ahead). Positive: replay sat idle that long +// waiting for shreds (the pipeline is the bottleneck). +// asm = first shred seen -> fully assembled. How long the slot took to +// collect, i.e. the repair grind for holes and pre-join slots; +// near-live slots read a few hundred ms (one broadcast pass). +// +// Both come from two timestamps the receiver already stamps per block plus +// one time.Now() replay already takes — no added tracking cost. + +// shredSample is one executed slot's shred timing record for the summary +// window. +type shredSample struct { + readySecs float64 + asmSecs float64 +} + +// medianF / percentileF / maxF operate on a copy; empty input returns 0. +func medianF(vals []float64) float64 { return percentileF(vals, 50) } + +func percentileF(vals []float64, pct int) float64 { + if len(vals) == 0 { + return 0 + } + sorted := append([]float64(nil), vals...) + sort.Float64s(sorted) + if pct <= 0 { + return sorted[0] + } + if pct >= 100 { + return sorted[len(sorted)-1] + } + idx := (len(sorted) - 1) * pct / 100 + return sorted[idx] +} + +func maxF(vals []float64) float64 { + if len(vals) == 0 { + return 0 + } + m := vals[0] + for _, v := range vals[1:] { + if v > m { + m = v + } + } + return m +} + +// fmtMcu renders compute units as millions: 31_000_000 -> "31.0M". +func fmtMcu(cu uint64) string { + return fmt.Sprintf("%.1fM", float64(cu)/1e6) +} + +// fmtK renders a count in thousands: 38_000 -> "38k"; below 1000 verbatim. +func fmtK(v float64) string { + if v >= 1000 { + return fmt.Sprintf("%.0fk", v/1000) + } + return fmt.Sprintf("%.0f", v) +} + +// shortPubkey renders "7abc...Q9x" (first 4 + last 3) for terminal alignment. +func shortPubkey(s string) string { + if len(s) <= 10 { + return s + } + return s[:4] + "..." + s[len(s)-3:] +} + +// Dash cells for fields with no value, padded to the exact content width of +// their populated counterparts (txns %5d, cu %5s, exec %4.0f+"ms", +// eff %5.1f+"ms/Mcu") so the pipe separators land in the same terminal +// columns down a mixed stream of executed, zero-txn, and skipped lines. The +// dashes right-align under the digits. +const ( + dashTxns = " --" + dashCU = " --" + dashExec = " -- " + dashEff = " -- " +) + +// buildSlotStatsLine renders the per-slot terminal line. The shreds segment is +// omitted for blocks that did not come from shreds (never fabricated). Value +// fields use fixed cell widths (see dash constants); an extreme value +// overflows its cell and shifts only its own line's tail. +// +// ready < 0: block was assembled |ready| before replay asked for it (good — +// the pipeline runs ahead). ready > 0: replay waited that long for shreds. +// asm: first shred seen -> fully assembled (the collection/repair grind). +func buildSlotStatsLine(slot uint64, leader string, txns int, cu uint64, execMs float64, hasShreds bool, readySecs, asmSecs float64, repaired int) string { + var b strings.Builder + fmt.Fprintf(&b, "slot %d | leader %s | txns %5d | cu %5s | exec %4.0fms", slot, shortPubkey(leader), txns, fmtMcu(cu), execMs) + if cu > 0 { + fmt.Fprintf(&b, " | eff %5.1fms/Mcu", execMs/(float64(cu)/1e6)) + } else { + b.WriteString(" | eff " + dashEff) + } + if hasShreds { + fmt.Fprintf(&b, " | shreds(ready %+6.1fs, asm %5.1fs, repair %d)", readySecs, asmSecs, repaired) + } + return strings.TrimRight(b.String(), " ") +} + +// buildSkippedStatsLine renders the skipped-slot terminal line with the SAME +// field order as executed lines (slot | leader | txns | cu | exec | eff) so +// the columns read straight down a mixed stream; "skipped" trails as the +// status. A shreds segment appears ONLY when partial shreds actually arrived +// — "the leader sent 12 shreds then stopped" is a different operator story +// from "the leader never transmitted" — matching executed lines, which also +// omit the segment when there is no shred data. Nothing is ever fabricated. +func buildSkippedStatsLine(slot uint64, leader string, partialShreds, repairedShreds int) string { + line := fmt.Sprintf("slot %d | leader %s | txns %s | cu %s | exec %s | eff %s | skipped", + slot, shortPubkey(leader), dashTxns, dashCU, dashExec, dashEff) + if partialShreds > 0 { + // Turbine's independent observation of the skipped slot: the leader + // DID transmit (we hold this many distinct data shreds, this many of + // them repair-fetched) but the block never completed and consensus + // skipped it. Plain words — "seen/repaired" — because "partial" read + // as jargon. + line += fmt.Sprintf(" | shreds seen %d (repaired %d) — block never completed", partialShreds, repairedShreds) + } + return line +} + +// processRSSBytes reads the resident set size on Linux (/proc/self/statm, +// second field, pages). Returns 0 (omit from output) where unavailable — +// stats must not fabricate on other platforms. +func processRSSBytes() uint64 { + data, err := os.ReadFile("/proc/self/statm") + if err != nil { + return 0 + } + fields := strings.Fields(string(data)) + if len(fields) < 2 { + return 0 + } + pages, err := strconv.ParseUint(fields[1], 10, 64) + if err != nil { + return 0 + } + return pages * uint64(os.Getpagesize()) +} diff --git a/pkg/replay/summary_stats_test.go b/pkg/replay/summary_stats_test.go new file mode 100644 index 000000000..6465992cd --- /dev/null +++ b/pkg/replay/summary_stats_test.go @@ -0,0 +1,85 @@ +package replay + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestPercentileHelpers(t *testing.T) { + vals := []float64{5, 1, 3, 2, 4} + assert.Equal(t, 3.0, medianF(vals)) + assert.Equal(t, 1.0, percentileF(vals, 0)) + assert.Equal(t, 5.0, percentileF(vals, 100)) + assert.Equal(t, 4.0, percentileF(vals, 90)) // index (5-1)*90/100 = 3 of sorted + assert.Equal(t, 5.0, maxF(vals)) + // ready values can be all-negative (pipeline ahead everywhere): max must + // return the true (least-negative) maximum, not a zero floor. + assert.Equal(t, -1.5, maxF([]float64{-4.2, -1.5, -9.0})) + // Input order untouched (helpers sort a copy). + assert.Equal(t, []float64{5, 1, 3, 2, 4}, vals) + // Empty inputs are zeros, never a panic. + assert.Equal(t, 0.0, medianF(nil)) + assert.Equal(t, 0.0, maxF(nil)) +} + +func TestFormatHelpers(t *testing.T) { + assert.Equal(t, "31.0M", fmtMcu(31_000_000)) + assert.Equal(t, "0.5M", fmtMcu(480_000)) + assert.Equal(t, "38k", fmtK(38_000)) + assert.Equal(t, "850", fmtK(850)) + assert.Equal(t, "7abc...Q9x", shortPubkey("7abcDEFGHIJKLMNOPQ9x")) + assert.Equal(t, "short", shortPubkey("short")) +} + +// Per-slot lines match the handoff spec shape; the shreds segment is omitted +// (not dashed, not fabricated) for non-shred-sourced blocks. Value fields are +// fixed-width cells and dashes are padded to the same widths, so the pipe +// separators land in identical columns on every line of a mixed stream — +// these assertions lock the exact padding. +func TestBuildSlotStatsLines(t *testing.T) { + // ready < 0: the block was fully assembled 45.2s before replay asked for + // it (pipeline ahead). asm: 69.6s from first shred to full. + line := buildSlotStatsLine(123456789, "7abcDEFGHIJKLMNOPQ9x", 862, 31_000_000, 170, true, -45.2, 69.6, 3168) + assert.Equal(t, "slot 123456789 | leader 7abc...Q9x | txns 862 | cu 31.0M | exec 170ms | eff 5.5ms/Mcu | shreds(ready -45.2s, asm 69.6s, repair 3168)", line) + + // ready > 0: replay sat waiting 12.4s for the slot to finish assembling. + waited := buildSlotStatsLine(123456790, "7abcDEFGHIJKLMNOPQ9x", 100, 1_000_000, 30, true, 12.4, 0.3, 96) + assert.Contains(t, waited, "shreds(ready +12.4s, asm 0.3s, repair 96)") + + rpcLine := buildSlotStatsLine(42, "7abcDEFGHIJKLMNOPQ9x", 10, 2_000_000, 30, false, 0, 0, 0) + assert.NotContains(t, rpcLine, "shreds(", "RPC-sourced blocks must not fabricate shred stats") + + zeroCU := buildSlotStatsLine(43, "7abcDEFGHIJKLMNOPQ9x", 0, 0, 5, false, 0, 0, 0) + assert.Equal(t, "slot 43 | leader 7abc...Q9x | txns 0 | cu 0.0M | exec 5ms | eff --", zeroCU, + "no efficiency without compute units; dash padded to the eff cell, trailing spaces trimmed") + + // Skipped with nothing observed: identical field order to executed lines, + // dashes padded to the executed cells' widths so the columns align, + // "skipped" as the trailing status, and NO shreds segment — same omission + // rule as executed lines. + skipped := buildSkippedStatsLine(123456790, "4defGHIJKLMNOPQRK2p", 0, 0) + assert.Equal(t, "slot 123456790 | leader 4def...K2p | txns -- | cu -- | exec -- | eff -- | skipped", skipped) + + // Every pipe — including the one before the status/shreds tail — must sit + // in the same column as the executed line's: the alignment property + // itself, not just the text. + assert.Equal(t, pipeColumns(line), pipeColumns(skipped), "executed and skipped separators must align") + + // Skipped but the leader DID send shreds before dying: report the partial + // arrivals — the signal separating "leader started then stopped" from + // "leader never transmitted". + partial := buildSkippedStatsLine(123456791, "4defGHIJKLMNOPQRK2p", 12, 3) + assert.Equal(t, "slot 123456791 | leader 4def...K2p | txns -- | cu -- | exec -- | eff -- | skipped | shreds seen 12 (repaired 3) — block never completed", partial) +} + +// pipeColumns returns the byte offsets of every '|' in a line. +func pipeColumns(s string) []int { + var cols []int + for i, r := range s { + if r == '|' { + cols = append(cols, i) + } + } + return cols +} diff --git a/pkg/replay/trailing_verifier.go b/pkg/replay/trailing_verifier.go new file mode 100644 index 000000000..f6b5fd485 --- /dev/null +++ b/pkg/replay/trailing_verifier.go @@ -0,0 +1,423 @@ +package replay + +import ( + "context" + "fmt" + "sync" + "time" + + "github.com/Overclock-Validator/mithril/pkg/mlog" + "github.com/Overclock-Validator/mithril/pkg/rpcclient" + "github.com/Overclock-Validator/mithril/pkg/state" + "github.com/gagliardetto/solana-go" +) + +// The trailing verifier is the execution-correctness oracle of the fold +// pipeline. Alpenglow certificates attest a block's DATA (block_id), never +// the results of executing it — so after TowerBFT's voted-bankhash check was +// removed, nothing in consensus can catch a mithril-side execution bug. The +// verifier re-derives each executed slot's per-transaction results from RPC +// getBlock metadata (finalized commitment) a configurable lag behind the tip +// and compares against the slot digests replay recorded. The fold watermark +// is min(finality, verified): nothing reaches durable state unverified. +// +// Failure semantics are deliberately asymmetric: +// - a same-block digest mismatch is an execution divergence -> halt + +// persist evidence; deterministic divergence is NOT self-healing, so the +// node-level recovery loop must not auto-retry it +// - a different blockhash at the same slot is a SIBLING question (fork +// identity), owned by the certificate gate/switch — requeue, never halt +// - RPC outages just stall the watermark; with Required=true the fold +// stalls with it until the OverCap halt (designed fail-closed backpressure) + +// VerifierConfig configures the trailing verifier ([verifier] section). +type VerifierConfig struct { + Enabled bool + LagSlots uint64 // don't attempt verification until executedTip - LagSlots + MaxRPS int // verifier's own RPC budget (never shares block fetch) + Required bool // gate folds on the verified watermark +} + +// TrailingVerifierDefaults returns the default configuration: enabled, +// required, 32-slot lag, 8 requests/second. +func TrailingVerifierDefaults() VerifierConfig { + return VerifierConfig{Enabled: true, LagSlots: 32, MaxRPS: 8, Required: true} +} + +// TrailingVerifierCfg is the active configuration ([verifier] section), set by +// node startup before ReplayBlocks. +var TrailingVerifierCfg = TrailingVerifierDefaults() + +// recordReplayDivergenceEvidence persists a confirmed execution divergence to +// the state file (written on shutdown), so restarts refuse to fold at or past +// the disputed slot until the operator clears the evidence after triage. +func recordReplayDivergenceEvidence(st *state.MithrilState, d *ReplayDivergence) { + if st == nil || d == nil { + return + } + for _, ev := range st.ReplayDivergenceEvidence { + if ev.Slot == d.Slot && ev.TxIndex == d.TxIndex && ev.Kind == d.Kind { + return + } + } + st.ReplayDivergenceEvidence = append(st.ReplayDivergenceEvidence, state.ReplayDivergenceRecord{ + Slot: d.Slot, + TxIndex: d.TxIndex, + TxSignature: d.TxSignature, + Kind: d.Kind, + Detail: d.Detail, + RecordedAt: time.Now().UTC().Format(time.RFC3339), + }) + mlog.Log.Errorf("replay divergence evidence recorded for slot %d (%s) — folds blocked at that slot until cleared", d.Slot, d.Kind) +} + +// ReplayDivergence identifies the first verified execution mismatch. +type ReplayDivergence struct { + Slot uint64 + TxIndex int + TxSignature string + Kind string // "tx_record", "tx_count", "skip_mismatch", "missing_record" + Detail string +} + +func (d *ReplayDivergence) Error() string { + return fmt.Sprintf("replay divergence at slot %d tx %d (%s): %s — %s", d.Slot, d.TxIndex, d.TxSignature, d.Kind, d.Detail) +} + +// verifierTx is one transaction's externally-attested record extracted from +// RPC metadata. +type verifierTx struct { + Sig solana.Signature + Fee uint64 + Failed bool + Pre []uint64 + Post []uint64 +} + +// verifiedBlock is the extracted finalized view of one slot. +type verifiedBlock struct { + Blockhash solana.Hash + Txs []verifierTx +} + +// blockVerificationSource fetches the finalized attested view of a slot. +// Returns rpcclient.SlotSkipped for finalized-skipped slots; any other error +// is transient (backoff + retry). Faked in tests. +type blockVerificationSource interface { + FetchFinalized(slot uint64) (*verifiedBlock, error) +} + +// rpcVerificationSource adapts an RpcClient to blockVerificationSource. +type rpcVerificationSource struct { + rpcc *rpcclient.RpcClient +} + +func (r *rpcVerificationSource) FetchFinalized(slot uint64) (*verifiedBlock, error) { + result, err := r.rpcc.GetBlockFinalizedOnce(slot) + if err != nil { + return nil, err + } + vb := &verifiedBlock{Blockhash: result.Blockhash, Txs: make([]verifierTx, 0, len(result.Transactions))} + for i := range result.Transactions { + rtx := &result.Transactions[i] + parsed, perr := rtx.GetTransaction() + if perr != nil || parsed == nil || len(parsed.Signatures) == 0 || rtx.Meta == nil { + return nil, fmt.Errorf("slot %d tx %d: unparseable RPC transaction/meta", slot, i) + } + vb.Txs = append(vb.Txs, verifierTx{ + Sig: parsed.Signatures[0], + Fee: rtx.Meta.Fee, + Failed: rtx.Meta.Err != nil, + Pre: rtx.Meta.PreBalances, + Post: rtx.Meta.PostBalances, + }) + } + return vb, nil +} + +type pendingDigest struct { + digest *SlotDigest + attempts int + skipHits int // cross-checks confirming an RPC skip disagreement + siblingHits int // consecutive different-blockhash observations + nextTry time.Time +} + +// TrailingVerifier verifies executed slots against RPC metadata in the +// background and exposes the contiguous verified watermark. +type TrailingVerifier struct { + cfg VerifierConfig + src blockVerificationSource + + mu sync.Mutex + pending map[uint64]*pendingDigest + order []uint64 // ascending recorded slots not yet verified + firstSlot uint64 // first slot ever recorded (watermark floor anchor) + verified uint64 // all recorded slots <= verified are verified + executedTip uint64 + failure *ReplayDivergence + + verifiedCount uint64 + requeues uint64 +} + +func newTrailingVerifier(src blockVerificationSource, cfg VerifierConfig) *TrailingVerifier { + if cfg.LagSlots == 0 { + cfg.LagSlots = 32 + } + if cfg.MaxRPS <= 0 { + cfg.MaxRPS = 8 + } + return &TrailingVerifier{ + cfg: cfg, + src: src, + pending: make(map[uint64]*pendingDigest), + } +} + +// Record registers an executed slot's digest for verification. +func (v *TrailingVerifier) Record(d *SlotDigest) { + if v == nil || d == nil { + return + } + v.mu.Lock() + defer v.mu.Unlock() + if v.firstSlot == 0 || d.Slot < v.firstSlot { + v.firstSlot = d.Slot + if v.verified == 0 { + v.verified = d.Slot - 1 + } + } + if _, exists := v.pending[d.Slot]; !exists { + v.order = append(v.order, d.Slot) + } + v.pending[d.Slot] = &pendingDigest{digest: d} + if d.Slot > v.executedTip { + v.executedTip = d.Slot + } +} + +// RecordSkip registers a slot replay treated as skipped. +func (v *TrailingVerifier) RecordSkip(slot uint64) { + v.Record(&SlotDigest{Slot: slot, Skipped: true}) +} + +// SetExecutedTip advances the tip used for the verification lag. +func (v *TrailingVerifier) SetExecutedTip(slot uint64) { + if v == nil { + return + } + v.mu.Lock() + if slot > v.executedTip { + v.executedTip = slot + } + v.mu.Unlock() +} + +// VerifiedWatermark returns the highest slot V such that every recorded slot +// <= V verified clean. Before anything is recorded it returns 0 (nothing is +// foldable yet anyway). Nil receiver (verifier disabled) = no gating. +func (v *TrailingVerifier) VerifiedWatermark() uint64 { + if v == nil { + return ^uint64(0) + } + v.mu.Lock() + defer v.mu.Unlock() + return v.verified +} + +// Failure returns the first confirmed divergence (nil if none). +func (v *TrailingVerifier) Failure() *ReplayDivergence { + if v == nil { + return nil + } + v.mu.Lock() + defer v.mu.Unlock() + return v.failure +} + +// PruneThrough drops verified bookkeeping for slots <= slot (post-fold). +func (v *TrailingVerifier) PruneThrough(slot uint64) { + if v == nil { + return + } + v.mu.Lock() + defer v.mu.Unlock() + kept := v.order[:0] + for _, s := range v.order { + if s <= slot { + delete(v.pending, s) + continue + } + kept = append(kept, s) + } + v.order = kept +} + +// Run drives verification until ctx is done. One slot per permit, oldest +// first, capped at MaxRPS. +func (v *TrailingVerifier) Run(ctx context.Context) { + interval := time.Second / time.Duration(v.cfg.MaxRPS) + ticker := time.NewTicker(interval) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + v.verifyNext() + } + } +} + +// verifyNext picks the oldest eligible pending slot and verifies it. +func (v *TrailingVerifier) verifyNext() { + v.mu.Lock() + if v.failure != nil { + v.mu.Unlock() + return + } + var slot uint64 + var pd *pendingDigest + now := time.Now() + for _, s := range v.order { + cand := v.pending[s] + if cand == nil { + continue + } + if v.executedTip < v.cfg.LagSlots || s > v.executedTip-v.cfg.LagSlots { + break // too fresh; order is ascending so nothing later is eligible + } + if now.Before(cand.nextTry) { + continue + } + slot, pd = s, cand + break + } + v.mu.Unlock() + if pd == nil { + return + } + + result, err := v.src.FetchFinalized(slot) + v.mu.Lock() + defer v.mu.Unlock() + if v.pending[slot] != pd { // pruned concurrently + return + } + + switch { + case err == rpcclient.SlotSkipped: + if pd.digest.Skipped { + v.markVerifiedLocked(slot) + return + } + // We executed a block; RPC (finalized) says skipped. Confirm across + // retries before declaring divergence — transient RPC views exist. + pd.skipHits++ + if pd.skipHits >= 3 { + v.failure = &ReplayDivergence{Slot: slot, TxIndex: -1, Kind: "skip_mismatch", + Detail: "replay executed a block but RPC finalized view reports the slot skipped"} + return + } + pd.attempts++ + pd.nextTry = time.Now().Add(5 * time.Second) + return + case err != nil: + // Transient (not yet finalized on the RPC view, outage, etc.): back off. + pd.attempts++ + pd.nextTry = time.Now().Add(backoffFor(pd.attempts)) + return + } + + if pd.digest.Skipped { + // We skipped; RPC has a finalized block. Confirm, then diverge. + pd.skipHits++ + if pd.skipHits >= 3 { + v.failure = &ReplayDivergence{Slot: slot, TxIndex: -1, Kind: "skip_mismatch", + Detail: "replay skipped the slot but RPC finalized view has a block"} + return + } + pd.attempts++ + pd.nextTry = time.Now().Add(5 * time.Second) + return + } + + // Sibling check: a different blockhash is a fork-identity question owned + // by the certificate gate, NOT an execution divergence. Requeue. + if result.Blockhash != pd.digest.Blockhash { + pd.siblingHits++ + v.requeues++ + if pd.siblingHits%10 == 0 { + mlog.Log.Warnf("trailing verifier: slot %d blockhash differs from RPC finalized view (%d observations) — executed a non-canonical sibling? holding the fold watermark; the certificate gate owns identity", + slot, pd.siblingHits) + } + pd.attempts++ + pd.nextTry = time.Now().Add(backoffFor(pd.attempts)) + return + } + + if div := compareSlotDigest(pd.digest, result); div != nil { + v.failure = div + return + } + v.markVerifiedLocked(slot) +} + +func backoffFor(attempts int) time.Duration { + d := time.Duration(attempts) * 2 * time.Second + if d > 30*time.Second { + d = 30 * time.Second + } + return d +} + +// markVerifiedLocked marks slot verified (pending -> nil) and advances the +// watermark over the contiguous verified prefix of recorded slots. +func (v *TrailingVerifier) markVerifiedLocked(slot uint64) { + if _, ok := v.pending[slot]; !ok { + return + } + v.pending[slot] = nil + v.verifiedCount++ + for len(v.order) > 0 { + s := v.order[0] + if pd, ok := v.pending[s]; ok && pd != nil { + break // oldest recorded slot still unverified + } + delete(v.pending, s) + v.order = v.order[1:] + if s > v.verified { + v.verified = s + } + } +} + +// compareSlotDigest recomputes each transaction's record hash from the +// attested view (using the recorded comparability mask) and compares. +func compareSlotDigest(d *SlotDigest, vb *verifiedBlock) *ReplayDivergence { + if len(vb.Txs) != len(d.Txs) { + return &ReplayDivergence{Slot: d.Slot, TxIndex: -1, Kind: "tx_count", + Detail: fmt.Sprintf("replay executed %d transactions, attested block has %d", len(d.Txs), len(vb.Txs))} + } + for i := range vb.Txs { + vt := &vb.Txs[i] + td := &d.Txs[i] + var sigPrefix [8]byte + copy(sigPrefix[:], vt.Sig[:8]) + if sigPrefix != td.SigPrefix { + return &ReplayDivergence{Slot: d.Slot, TxIndex: i, TxSignature: vt.Sig.String(), Kind: "tx_record", + Detail: "transaction order/signature differs from attested block"} + } + if td.RecordHash == ([16]byte{}) { + return &ReplayDivergence{Slot: d.Slot, TxIndex: i, TxSignature: vt.Sig.String(), Kind: "missing_record", + Detail: "replay captured no execution record for this transaction"} + } + got := txRecordHash(vt.Sig, vt.Fee, vt.Failed, td.NumAccts, td.SkipMask, vt.Pre, vt.Post) + if got != td.RecordHash { + return &ReplayDivergence{Slot: d.Slot, TxIndex: i, TxSignature: vt.Sig.String(), Kind: "tx_record", + Detail: fmt.Sprintf("fee/status/balance record differs from attested metadata (fee=%d failed=%v accts=%d)", vt.Fee, vt.Failed, td.NumAccts)} + } + } + return nil +} diff --git a/pkg/replay/trailing_verifier_test.go b/pkg/replay/trailing_verifier_test.go new file mode 100644 index 000000000..a1ee34f06 --- /dev/null +++ b/pkg/replay/trailing_verifier_test.go @@ -0,0 +1,226 @@ +package replay + +import ( + "errors" + "testing" + "time" + + "github.com/Overclock-Validator/mithril/pkg/rpcclient" + "github.com/gagliardetto/solana-go" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func vsig(b byte) solana.Signature { var s solana.Signature; s[0] = b; return s } +func vhash(b byte) solana.Hash { var h solana.Hash; h[0] = b; return h } + +// fakeVerificationSource serves canned per-slot attested views. +type fakeVerificationSource struct { + blocks map[uint64]*verifiedBlock + skipped map[uint64]bool + errs map[uint64]error + calls int +} + +func (f *fakeVerificationSource) FetchFinalized(slot uint64) (*verifiedBlock, error) { + f.calls++ + if f.errs[slot] != nil { + return nil, f.errs[slot] + } + if f.skipped[slot] { + return nil, rpcclient.SlotSkipped + } + if vb, ok := f.blocks[slot]; ok { + return vb, nil + } + return nil, errors.New("block not available yet") +} + +// digestFor builds a matching (digest, attested view) pair for one tx. +func digestFor(slot uint64, blockhash solana.Hash, sig solana.Signature, fee uint64, failed bool, pre, post []uint64, mask []byte) (*SlotDigest, *verifiedBlock) { + n := uint16(len(pre)) + td := TxDigest{NumAccts: n, SkipMask: mask} + copy(td.SigPrefix[:], sig[:8]) + td.RecordHash = txRecordHash(sig, fee, failed, n, mask, pre, post) + d := &SlotDigest{Slot: slot, Blockhash: blockhash, Txs: []TxDigest{td}} + vb := &verifiedBlock{Blockhash: blockhash, Txs: []verifierTx{{Sig: sig, Fee: fee, Failed: failed, Pre: pre, Post: post}}} + return d, vb +} + +// The record hash is deterministic, sensitive to every compared field, and +// insensitive to masked balances. +func TestTxRecordHashProperties(t *testing.T) { + sig := vsig(1) + mask := []byte{0b00000010} // index 1 masked + pre := []uint64{100, 555, 300} + post := []uint64{90, 555, 310} + + h1 := txRecordHash(sig, 5000, false, 3, mask, pre, post) + h2 := txRecordHash(sig, 5000, false, 3, mask, pre, post) + assert.Equal(t, h1, h2, "deterministic") + + assert.NotEqual(t, h1, txRecordHash(sig, 5001, false, 3, mask, pre, post), "fee changes hash") + assert.NotEqual(t, h1, txRecordHash(sig, 5000, true, 3, mask, pre, post), "status changes hash") + pre2 := []uint64{101, 555, 300} + assert.NotEqual(t, h1, txRecordHash(sig, 5000, false, 3, mask, pre2, post), "unmasked pre-balance changes hash") + + // Masked index differs -> hash identical (index 1 is not comparable). + preMasked := []uint64{100, 999999, 300} + assert.Equal(t, h1, txRecordHash(sig, 5000, false, 3, mask, preMasked, post), "masked balance is ignored") + + // Failed txs ignore post-balances entirely (mirrors RPC-mode checks). + hf1 := txRecordHash(sig, 5000, true, 3, mask, pre, post) + hf2 := txRecordHash(sig, 5000, true, 3, mask, pre, []uint64{1, 2, 3}) + assert.Equal(t, hf1, hf2, "post ignored for failed txs") +} + +func TestCompareSlotDigest(t *testing.T) { + sig := vsig(7) + d, vb := digestFor(100, vhash(0xAA), sig, 5000, false, []uint64{10, 20}, []uint64{5, 25}, []byte{0}) + assert.Nil(t, compareSlotDigest(d, vb), "matching digest verifies clean") + + vb.Txs[0].Fee = 5001 + div := compareSlotDigest(d, vb) + require.NotNil(t, div, "fee mismatch diverges") + assert.Equal(t, "tx_record", div.Kind) + assert.Equal(t, 0, div.TxIndex) + + vb.Txs[0].Fee = 5000 + vb.Txs = append(vb.Txs, verifierTx{Sig: vsig(9)}) + div = compareSlotDigest(d, vb) + require.NotNil(t, div, "tx count mismatch diverges") + assert.Equal(t, "tx_count", div.Kind) +} + +func newTestVerifier(src blockVerificationSource) *TrailingVerifier { + return newTrailingVerifier(src, VerifierConfig{Enabled: true, LagSlots: 4, MaxRPS: 1000, Required: true}) +} + +// Watermark advances contiguously as slots verify, oldest-first. +func TestVerifierWatermarkAdvancesContiguously(t *testing.T) { + src := &fakeVerificationSource{blocks: map[uint64]*verifiedBlock{}, skipped: map[uint64]bool{}, errs: map[uint64]error{}} + v := newTestVerifier(src) + + for slot := uint64(100); slot <= 102; slot++ { + d, vb := digestFor(slot, vhash(byte(slot)), vsig(byte(slot)), 5000, false, []uint64{1}, []uint64{2}, []byte{0}) + v.Record(d) + src.blocks[slot] = vb + } + v.SetExecutedTip(200) // all eligible past the lag + + assert.Equal(t, uint64(99), v.VerifiedWatermark(), "floor anchors below first recorded slot") + for i := 0; i < 3; i++ { + v.verifyNext() + } + assert.Equal(t, uint64(102), v.VerifiedWatermark()) + assert.Nil(t, v.Failure()) +} + +// A mid-window unverified slot holds the watermark even when later slots verified. +func TestVerifierWatermarkHeldByGap(t *testing.T) { + src := &fakeVerificationSource{blocks: map[uint64]*verifiedBlock{}, skipped: map[uint64]bool{}, errs: map[uint64]error{}} + v := newTestVerifier(src) + + for slot := uint64(100); slot <= 102; slot++ { + d, vb := digestFor(slot, vhash(byte(slot)), vsig(byte(slot)), 5000, false, []uint64{1}, []uint64{2}, []byte{0}) + v.Record(d) + if slot != 101 { + src.blocks[slot] = vb + } else { + src.errs[101] = errors.New("rpc outage") + } + } + v.SetExecutedTip(200) + + for i := 0; i < 6; i++ { + v.verifyNext() + } + assert.Equal(t, uint64(100), v.VerifiedWatermark(), "gap at 101 holds the watermark") + assert.Nil(t, v.Failure(), "transient errors are not divergences") +} + +// A different blockhash is a sibling question: requeued, watermark held, no failure. +func TestVerifierSiblingBlockhashRequeuesNotFails(t *testing.T) { + src := &fakeVerificationSource{blocks: map[uint64]*verifiedBlock{}, skipped: map[uint64]bool{}, errs: map[uint64]error{}} + v := newTestVerifier(src) + + d, vb := digestFor(100, vhash(0xAA), vsig(1), 5000, false, []uint64{1}, []uint64{2}, []byte{0}) + vb.Blockhash = vhash(0xBB) // canonical block is a different sibling + v.Record(d) + src.blocks[100] = vb + v.SetExecutedTip(200) + + for i := 0; i < 5; i++ { + v.verifyNext() + } + assert.Nil(t, v.Failure(), "sibling mismatch is not an execution divergence") + assert.Equal(t, uint64(99), v.VerifiedWatermark(), "watermark held while identity is unresolved") +} + +// Execution divergence (same block, different results) fails exactly once. +func TestVerifierExecutionDivergenceFails(t *testing.T) { + src := &fakeVerificationSource{blocks: map[uint64]*verifiedBlock{}, skipped: map[uint64]bool{}, errs: map[uint64]error{}} + v := newTestVerifier(src) + + d, vb := digestFor(100, vhash(0xAA), vsig(1), 5000, false, []uint64{1}, []uint64{2}, []byte{0}) + vb.Txs[0].Post = []uint64{999} // attested post-balance differs + v.Record(d) + src.blocks[100] = vb + v.SetExecutedTip(200) + + v.verifyNext() + div := v.Failure() + require.NotNil(t, div) + assert.Equal(t, uint64(100), div.Slot) + assert.Equal(t, "tx_record", div.Kind) + assert.Equal(t, uint64(99), v.VerifiedWatermark(), "nothing folds past a divergence") +} + +// Skip agreement verifies; skip disagreement needs 3 confirmations to fail. +func TestVerifierSkipHandling(t *testing.T) { + src := &fakeVerificationSource{blocks: map[uint64]*verifiedBlock{}, skipped: map[uint64]bool{100: true}, errs: map[uint64]error{}} + v := newTestVerifier(src) + v.RecordSkip(100) + v.SetExecutedTip(200) + v.verifyNext() + assert.Equal(t, uint64(100), v.VerifiedWatermark(), "agreed skip verifies") + + // Disagreement: we executed, RPC says skipped. + v2 := newTestVerifier(src) + d, _ := digestFor(101, vhash(1), vsig(1), 5000, false, []uint64{1}, []uint64{2}, []byte{0}) + src.skipped[101] = true + v2.Record(d) + v2.SetExecutedTip(200) + for i := 0; i < 2; i++ { + v2.verifyNext() + v2.mu.Lock() + if pd := v2.pending[101]; pd != nil { + pd.nextTry = pd.nextTry.Add(-time.Hour) // bypass the confirm backoff + } + v2.mu.Unlock() + } + assert.Nil(t, v2.Failure(), "needs 3 confirmations") + v2.verifyNext() + div := v2.Failure() + require.NotNil(t, div) + assert.Equal(t, "skip_mismatch", div.Kind) +} + +// The lag keeps fresh slots unverified until the tip moves past them. +func TestVerifierRespectsLag(t *testing.T) { + src := &fakeVerificationSource{blocks: map[uint64]*verifiedBlock{}, skipped: map[uint64]bool{}, errs: map[uint64]error{}} + v := newTestVerifier(src) // lag 4 + + d, vb := digestFor(100, vhash(1), vsig(1), 5000, false, []uint64{1}, []uint64{2}, []byte{0}) + v.Record(d) + src.blocks[100] = vb + // tip = 103: slot 100 > 103-4 -> not yet eligible + v.SetExecutedTip(103) + v.verifyNext() + assert.Equal(t, 0, src.calls, "slot within the lag window is not verified yet") + + v.SetExecutedTip(104) + v.verifyNext() + assert.Equal(t, 1, src.calls) + assert.Equal(t, uint64(100), v.VerifiedWatermark()) +} diff --git a/pkg/replay/transaction.go b/pkg/replay/transaction.go index 451ddb538..d52a9cbb3 100644 --- a/pkg/replay/transaction.go +++ b/pkg/replay/transaction.go @@ -189,7 +189,7 @@ func handleModifiedAccounts(slotCtx *sealevel.SlotCtx, execCtx *sealevel.Executi TxAcctsTouchedBytes.Add(touchedBytes) } -func recordStakeDelegation(acct *accounts.Account) { +func recordStakeDelegation(slot uint64, acct *accounts.Account) { isEmpty := acct.Lamports == 0 isUninitialized := true @@ -199,8 +199,10 @@ func recordStakeDelegation(acct *accounts.Account) { } if !isEmpty && !isUninitialized { - // Enqueue pubkey for index append so StreamStakeAccounts sees new stake accounts - global.EnqueuePendingStakePubkey(acct.Key) + // Slot-keyed enqueue: the entry reaches the durable index only when + // this slot folds; an unwound wrong-fork slot drops it. Scans see it + // from RAM meanwhile (StreamStakeAccounts merges pending entries). + global.EnqueuePendingStakePubkey(slot, acct.Key) } } @@ -245,6 +247,7 @@ func recordStakeAndVoteAccounts(slotCtx *sealevel.SlotCtx, execCtx *sealevel.Exe if acct.Lamports == 0 || acct.Owner != a.VoteProgramAddr { if global.VoteCacheItem(acct.Key) != nil { global.DeleteVoteCacheItem(acct.Key) + markVoteStakeDirty(slotCtx.Slot) // global cache mutated — gates in-loop unwind } } else if modifiedVoteAccts { recordVoteTimestampAndSlot(slotCtx, acct) @@ -252,10 +255,12 @@ func recordStakeAndVoteAccounts(slotCtx *sealevel.SlotCtx, execCtx *sealevel.Exe if wasModified { global.PutVoteCacheItem(acct.Key, newVersionedVoteState) } + markVoteStakeDirty(slotCtx.Slot) } if acct.Owner == a.StakeProgramAddr { - recordStakeDelegation(acct) + recordStakeDelegation(slotCtx.Slot, acct) + markVoteStakeDirty(slotCtx.Slot) } } } @@ -515,6 +520,22 @@ func ProcessTransaction(slotCtx *sealevel.SlotCtx, sigverifyWg *sync.WaitGroup, // Handle transaction errors from the pure function if output.ProcessingResult.TransactionError != nil { txErr := output.ProcessingResult.TransactionError + // Trailing-verifier capture (failed tx): fee + status + pre-balances. + // Post-balances are never compared for failed txs (mirrors the + // RPC-mode checks, which only compare post on success). Capture is a + // replay-side observation; the execution context is not modified. + if txCaptureActive() && len(tx.Signatures) > 0 { + var fee uint64 + if output.FeeInfo != nil { + fee = output.FeeInfo.TotalFee + } + recordTxExecCapture(slotCtx.Slot, tx.Signatures[0], &txExecRecord{ + Fee: fee, + Failed: true, + Pre: output.PreBalances, + SkipMask: txComparabilityMask(execCtx, len(output.PreBalances)), + }) + } if dbgOpts.IsDebugTx(tx.Signatures[0]) && execCtx != nil { if logRecorder, ok := execCtx.Log.(*sealevel.LogRecorder); ok { for _, l := range logRecorder.Logs { @@ -615,6 +636,33 @@ func ProcessTransaction(slotCtx *sealevel.SlotCtx, sigverifyWg *sync.WaitGroup, } metrics.GlobalBlockReplay.PostBalanceDivergenceCheck.AddTimingSince(start) + // Trailing-verifier capture (successful tx): fee + status + pre AND post + // balances, with the native/dummy comparability mask. Read-only walk; + // the execution context is not modified. + if txCaptureActive() && len(tx.Signatures) > 0 { + n := len(tx.Message.AccountKeys) + post := make([]uint64, n) + for count := 0; count < n; count++ { + txAcct, aerr := execCtx.TransactionContext.Accounts.GetAccount(uint64(count)) + if aerr != nil { + continue + } + post[count] = txAcct.Lamports + execCtx.TransactionContext.Accounts.Unlock(uint64(count)) + } + var fee uint64 + if txFeeInfo != nil { + fee = txFeeInfo.TotalFee + } + recordTxExecCapture(slotCtx.Slot, tx.Signatures[0], &txExecRecord{ + Fee: fee, + Failed: false, + Pre: output.PreBalances, + Post: post, + SkipMask: txComparabilityMask(execCtx, n), + }) + } + // Apply state changes to slotCtx start = time.Now() writablePubkeys := output.ExecutionResult.WritableAccounts @@ -630,3 +678,28 @@ func ProcessTransaction(slotCtx *sealevel.SlotCtx, sigverifyWg *sync.WaitGroup, return txFeeInfo, processTransactionComputeUnits(execCtx), nil } + +// txComparabilityMask marks account indices the trailing verifier must not +// compare: native programs (mithril models their account bodies differently) +// and dummy placeholders for accounts that did not exist. Mirrors the skip +// rules of the RPC-mode pre/post-balance divergence checks. +func txComparabilityMask(execCtx *sealevel.ExecutionCtx, numAccts int) []byte { + mask := make([]byte, (numAccts+7)/8) + if execCtx == nil { + for i := range mask { + mask[i] = 0xFF + } + return mask + } + accts := execCtx.TransactionContext.Accounts.Accounts + for i := 0; i < numAccts; i++ { + if i >= len(accts) || accts[i] == nil { + setMaskBit(mask, i) + continue + } + if isNativeProgram(accts[i].Key) || accts[i].IsDummy { + setMaskBit(mask, i) + } + } + return mask +} diff --git a/pkg/replay/txdigest.go b/pkg/replay/txdigest.go new file mode 100644 index 000000000..c293abdc2 --- /dev/null +++ b/pkg/replay/txdigest.go @@ -0,0 +1,203 @@ +package replay + +import ( + "encoding/binary" + "sync" + "sync/atomic" + + b "github.com/Overclock-Validator/mithril/pkg/block" + "github.com/gagliardetto/solana-go" + "github.com/zeebo/blake3" +) + +// The slot digest is the trailing verifier's compact record of what replay +// produced for one slot: per transaction, a 128-bit hash over exactly the +// fields the RPC-mode divergence checks compare (fee, success/failure status, +// pre-balances always, post-balances only on success), plus the mask of +// account indices mithril considers non-comparable (native programs and +// internal dummy placeholders). The verifier recomputes the same hash from +// RPC transaction metadata using the recorded mask — a mismatch is an +// execution divergence localized to the exact transaction. +// +// Compute units are deliberately excluded: CU divergence is a known benign, +// non-failing difference and would false-halt the fold pipeline. + +// txExecRecord captures one transaction's externally-comparable results for +// the trailing verifier. Pre is recorded for every transaction; Post only for +// successful ones (mirroring the RPC-mode divergence checks). SkipMask bit i +// set = account index i is not comparable (native program or dummy +// placeholder). +// +// The registry lives entirely in pkg/replay — the VM and sealevel execution +// context are deliberately untouched; capture is a read-only observation at +// the ProcessTransaction boundary, active only while the verifier runs. +type txExecRecord struct { + Fee uint64 + Failed bool + Pre []uint64 + Post []uint64 + SkipMask []byte +} + +// captureRegistry holds ONE replay run's per-transaction execution captures. It +// is instance state — published in activeCapture only for the duration of a +// verifier-enabled run and unpublished on exit — so separate runs (sequential +// recovery re-replays, tests, simulations) never share or consume each other's +// records. A nil activeCapture means capture is off; the record path then +// no-ops, replacing the old sticky package-global enable flag. +type captureRegistry struct { + mu sync.Mutex + slots map[uint64]map[solana.Signature]*txExecRecord +} + +var activeCapture atomic.Pointer[captureRegistry] + +// beginTxCapture publishes a fresh run-local capture registry and returns a +// stop function that unpublishes exactly this one (defer it for a +// verifier-enabled run). +func beginTxCapture() (stop func()) { + reg := &captureRegistry{slots: make(map[uint64]map[solana.Signature]*txExecRecord)} + activeCapture.Store(reg) + return func() { activeCapture.CompareAndSwap(reg, nil) } +} + +// txCaptureActive reports whether a run is capturing — cheap enough to gate the +// hot-path capture points so balances are only walked when the verifier runs. +func txCaptureActive() bool { return activeCapture.Load() != nil } + +// recordTxExecCapture stores a transaction's execution record in the active +// run's registry (nil-safe; no-ops when no run is capturing). Old slots are +// janitored so an unwound or abandoned slot cannot leak its capture set. +func recordTxExecCapture(slot uint64, sig solana.Signature, rec *txExecRecord) { + if rec == nil { + return + } + reg := activeCapture.Load() + if reg == nil { + return + } + reg.mu.Lock() + set := reg.slots[slot] + if set == nil { + set = make(map[solana.Signature]*txExecRecord) + reg.slots[slot] = set + for s := range reg.slots { + if s+256 < slot { + delete(reg.slots, s) + } + } + } + set[sig] = rec + reg.mu.Unlock() +} + +// takeTxCaptures removes and returns a slot's capture set from the active run. +func takeTxCaptures(slot uint64) map[solana.Signature]*txExecRecord { + reg := activeCapture.Load() + if reg == nil { + return nil + } + reg.mu.Lock() + set := reg.slots[slot] + delete(reg.slots, slot) + reg.mu.Unlock() + return set +} + +// TxDigest is one transaction's comparable execution result (~26-40 bytes). +type TxDigest struct { + SigPrefix [8]byte + RecordHash [16]byte + NumAccts uint16 + SkipMask []byte // ceil(NumAccts/8) bytes; bit set = index not comparable +} + +// SlotDigest is one replayed (or skipped) slot's verification record. +type SlotDigest struct { + Slot uint64 + Blockhash solana.Hash // the block's PoH blockhash — sibling disambiguation vs RPC + Skipped bool + Txs []TxDigest +} + +// txRecordHash hashes the comparable fields of one transaction. Layout: +// sig(64) ‖ fee u64le ‖ status byte (0 ok / 1 failed) ‖ numAccts u16le ‖ +// skipMask ‖ pre[i] u64le for unmasked i ‖ (post[i] u64le for unmasked i, only +// when status == 0). The mask is inside the hash so the verifier must use the +// identical mask, and masked balances contribute nothing. +func txRecordHash(sig solana.Signature, fee uint64, failed bool, numAccts uint16, skipMask []byte, pre, post []uint64) [16]byte { + h := blake3.New() + var u64 [8]byte + _, _ = h.Write(sig[:]) + binary.LittleEndian.PutUint64(u64[:], fee) + _, _ = h.Write(u64[:]) + status := byte(0) + if failed { + status = 1 + } + _, _ = h.Write([]byte{status}) + var u16 [2]byte + binary.LittleEndian.PutUint16(u16[:], numAccts) + _, _ = h.Write(u16[:]) + _, _ = h.Write(skipMask) + writeUnmasked := func(vals []uint64) { + for i := 0; i < int(numAccts) && i < len(vals); i++ { + if maskBit(skipMask, i) { + continue + } + binary.LittleEndian.PutUint64(u64[:], vals[i]) + _, _ = h.Write(u64[:]) + } + } + writeUnmasked(pre) + if !failed { + writeUnmasked(post) + } + var out [16]byte + sum := h.Sum(nil) + copy(out[:], sum[:16]) + return out +} + +func maskBit(mask []byte, i int) bool { + if i/8 >= len(mask) { + return false + } + return mask[i/8]&(1<<(i%8)) != 0 +} + +func setMaskBit(mask []byte, i int) { + if i/8 < len(mask) { + mask[i/8] |= 1 << (i % 8) + } +} + +// buildSlotDigest assembles the slot's digest from the per-transaction records +// captured during execution, in block transaction order. Transactions with no +// record (should not happen) hash as "mithril produced nothing" — a guaranteed +// verifier mismatch, which is the correct fail-closed outcome. +func buildSlotDigest(block *b.Block) *SlotDigest { + records := takeTxCaptures(block.Slot) + d := &SlotDigest{ + Slot: block.Slot, + Blockhash: solana.Hash(block.Blockhash), + Txs: make([]TxDigest, 0, len(block.Transactions)), + } + for _, tx := range block.Transactions { + if len(tx.Signatures) == 0 { + d.Txs = append(d.Txs, TxDigest{}) + continue + } + sig := tx.Signatures[0] + rec := records[sig] + td := TxDigest{} + copy(td.SigPrefix[:], sig[:8]) + if rec != nil { + td.NumAccts = uint16(len(rec.Pre)) + td.SkipMask = rec.SkipMask + td.RecordHash = txRecordHash(sig, rec.Fee, rec.Failed, td.NumAccts, rec.SkipMask, rec.Pre, rec.Post) + } + d.Txs = append(d.Txs, td) + } + return d +} diff --git a/pkg/replay/txdigest_test.go b/pkg/replay/txdigest_test.go new file mode 100644 index 000000000..2562a862f --- /dev/null +++ b/pkg/replay/txdigest_test.go @@ -0,0 +1,65 @@ +package replay + +import ( + "testing" + + "github.com/gagliardetto/solana-go" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func capSig(b byte) solana.Signature { var s solana.Signature; s[0] = b; return s } + +// With no run capturing, the record path no-ops and drains return nothing — +// there is no sticky global left enabled from a prior run. +func TestTxCaptureInactiveByDefault(t *testing.T) { + require.Nil(t, activeCapture.Load(), "no capture registry should be published at rest") + assert.False(t, txCaptureActive()) + recordTxExecCapture(10, capSig(1), &txExecRecord{Fee: 5}) + assert.Nil(t, takeTxCaptures(10), "records must not be stored while inactive") +} + +// A run's captures are isolated to that run: a second run cannot see or consume +// the first run's records, and unpublishing clears capture. +func TestTxCaptureIsRunLocal(t *testing.T) { + stop1 := beginTxCapture() + require.True(t, txCaptureActive()) + recordTxExecCapture(100, capSig(1), &txExecRecord{Fee: 11, Pre: []uint64{1}}) + recordTxExecCapture(100, capSig(2), &txExecRecord{Fee: 22}) + + // A fresh run publishes its OWN registry — the first run's records are not + // visible through it (no cross-run consumption / false digest data). + stop2 := beginTxCapture() + assert.Nil(t, takeTxCaptures(100), "second run must not see the first run's records") + recordTxExecCapture(100, capSig(9), &txExecRecord{Fee: 99}) + got := takeTxCaptures(100) + require.Len(t, got, 1) + assert.Equal(t, uint64(99), got[capSig(9)].Fee) + stop2() + + // Unpublishing the last active run turns capture off. + assert.False(t, txCaptureActive(), "capture is off once the active run stops") + recordTxExecCapture(100, capSig(3), &txExecRecord{Fee: 33}) + assert.Nil(t, takeTxCaptures(100), "no records after the run stopped") + + stop1() // idempotent CAS: does not clobber a different published registry + assert.False(t, txCaptureActive()) +} + +// takeTxCaptures drains a slot exactly once; the janitor drops slots far below +// the newest so an unwound/abandoned slot cannot leak. +func TestTxCaptureDrainAndJanitor(t *testing.T) { + stop := beginTxCapture() + defer stop() + + recordTxExecCapture(200, capSig(1), &txExecRecord{Fee: 1}) + first := takeTxCaptures(200) + require.Len(t, first, 1) + assert.Nil(t, takeTxCaptures(200), "a slot drains exactly once") + + // A slot far below the newest is janitored on the next record. + recordTxExecCapture(1000, capSig(2), &txExecRecord{Fee: 2}) + recordTxExecCapture(5000, capSig(3), &txExecRecord{Fee: 3}) // 1000 + 256 < 5000 -> 1000 evicted + assert.Nil(t, takeTxCaptures(1000), "stale slot must be janitored") + assert.Len(t, takeTxCaptures(5000), 1) +} diff --git a/pkg/rpcclient/blockfetch.go b/pkg/rpcclient/blockfetch.go index 484e0cb24..ff6e878e5 100644 --- a/pkg/rpcclient/blockfetch.go +++ b/pkg/rpcclient/blockfetch.go @@ -56,6 +56,35 @@ func (fetcher *RpcClient) GetBlockConfirmed(slot uint64) (*rpc.GetBlockResult, e var SlotSkipped = errors.New("slot skipped") +// GetBlockFinalizedOnce fetches a block at FINALIZED commitment with a single +// attempt and a hard timeout. Used by the trailing verifier, which does its +// own scheduling/backoff and must never block the caller for long. +func (fetcher *RpcClient) GetBlockFinalizedOnce(slot uint64) (*rpc.GetBlockResult, error) { + includeRewards := false + maxSupportedTxVer := uint64(0) + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + result, err := fetcher.client.GetBlockWithOpts( + ctx, + slot, + &rpc.GetBlockOpts{ + MaxSupportedTransactionVersion: &maxSupportedTxVer, + Commitment: rpc.CommitmentFinalized, + TransactionDetails: rpc.TransactionDetailsFull, + Rewards: &includeRewards, + }, + ) + if err != nil { + if strings.Contains(err.Error(), fmt.Sprintf("Slot %d was skipped", slot)) { + return nil, SlotSkipped + } + return nil, err + } + return result, nil +} + // GetBlockConfirmedOnce fetches a block with a single RPC attempt (no internal retry). // Use this with rate-limited parallel fetching where the scheduler handles retries. // Uses a 30-second timeout to prevent worker stalls on hung RPC connections. diff --git a/pkg/snapshot/build_db.go b/pkg/snapshot/build_db.go index bc3f9da75..8495f8de6 100644 --- a/pkg/snapshot/build_db.go +++ b/pkg/snapshot/build_db.go @@ -369,6 +369,15 @@ func BuildAccountsDbPaths( return nil, nil, err } + // bootstrap_high_file_id: write-once record of the highest fileId produced + // by snapshot bootstrap. The batch-fold engine uses it to classify data + // files: anything newer without a manifest is an undecided orphan. + bootstrapHighPath := filepath.Join(accountsDbDir, "bootstrap_high_file_id") + if err := os.WriteFile(bootstrapHighPath, largestFileIdBytes[:], 0644); err != nil { + mlog.Log.Errorf("error while writing bootstrap high file ID to %s: %s", bootstrapHighPath, err) + return nil, nil, err + } + bankHashOutputFileName := filepath.Join(accountsDbDir, "bank_hash") if err := os.WriteFile(bankHashOutputFileName, manifest.Bank.Hash[:], 0644); err != nil { mlog.Log.Errorf("error writing bank hash=%x to file=%s: %s", manifest.Bank.Hash, bankHashOutputFileName, err) diff --git a/pkg/snapshot/build_db_with_incr.go b/pkg/snapshot/build_db_with_incr.go index 2499b0fc4..5e7cf2e0b 100644 --- a/pkg/snapshot/build_db_with_incr.go +++ b/pkg/snapshot/build_db_with_incr.go @@ -232,6 +232,15 @@ func BuildAccountsDbAuto( return nil, nil, err } + // bootstrap_high_file_id: write-once record of the highest fileId produced + // by snapshot bootstrap. The batch-fold engine uses it to classify data + // files: anything newer without a manifest is an undecided orphan. + bootstrapHighPath := filepath.Join(accountsDbDir, "bootstrap_high_file_id") + if err := os.WriteFile(bootstrapHighPath, largestFileIdBytes[:], 0644); err != nil { + mlog.Log.Errorf("error while writing bootstrap high file ID to %s: %s", bootstrapHighPath, err) + return nil, nil, err + } + bankHashOutputFileName := filepath.Join(accountsDbDir, "bank_hash") if err := os.WriteFile(bankHashOutputFileName, manifest.Bank.Hash[:], 0644); err != nil { mlog.Log.Errorf("error writing bank hash=%x to file=%s: %s", manifest.Bank.Hash, bankHashOutputFileName, err) diff --git a/pkg/snapshot/manifest_seed.go b/pkg/snapshot/manifest_seed.go index 0e8a5bec3..e57648df9 100644 --- a/pkg/snapshot/manifest_seed.go +++ b/pkg/snapshot/manifest_seed.go @@ -83,64 +83,11 @@ func PopulateManifestSeed(s *state.MithrilState, m *SnapshotManifest) { // Transaction count at snapshot s.ManifestTransactionCount = m.Bank.TransactionCount - // Epoch authorized voters (for snapshot epoch only) - // Supports multiple authorized voters per vote account (matches original manifest behavior) - snapshotEpoch := manifestSeedAuthorizedVotersEpoch(s, m) - s.ManifestEpochAuthorizedVoters = make(map[string][]string) - for _, epochStake := range m.VersionedEpochStakes { - if epochStake.Epoch == snapshotEpoch { - for _, entry := range epochStake.Val.EpochAuthorizedVoters { - voteAcctStr := base58.Encode(entry.Key[:]) - authorizedVoterStr := base58.Encode(entry.Val[:]) - s.ManifestEpochAuthorizedVoters[voteAcctStr] = append(s.ManifestEpochAuthorizedVoters[voteAcctStr], authorizedVoterStr) - } - } - } - // Epoch stakes: convert VersionedEpochStakes to PersistedEpochStakes format // This stores ONLY vote-account aggregates, NOT full stake account data s.ManifestEpochStakes = convertVersionedEpochStakesToPersisted(m.VersionedEpochStakes) } -func manifestSeedAuthorizedVotersEpoch(s *state.MithrilState, m *SnapshotManifest) uint64 { - if m == nil || m.Bank == nil { - return 0 - } - if manifestEpochHasAuthorizedVoters(m, m.Bank.Epoch) { - return m.Bank.Epoch - } - if s != nil && s.SnapshotEpoch != 0 { - if manifestEpochHasAuthorizedVoters(m, s.SnapshotEpoch) { - return s.SnapshotEpoch - } - } - if m.Bank.EpochSchedule.SlotsPerEpoch != 0 { - scheduleEpoch := m.Bank.EpochSchedule.GetEpoch(m.Bank.Slot) - if manifestEpochHasAuthorizedVoters(m, scheduleEpoch) { - return scheduleEpoch - } - } - if s != nil && s.SnapshotEpoch != 0 { - return s.SnapshotEpoch - } - return m.Bank.Epoch -} - -func manifestEpochHasAuthorizedVoters(m *SnapshotManifest, epoch uint64) bool { - if m == nil { - return false - } - for _, epochStake := range m.VersionedEpochStakes { - if epochStake.Epoch == epoch && len(epochStake.Val.EpochAuthorizedVoters) > 0 { - return true - } - } - return false -} - -// convertVersionedEpochStakesToPersisted converts manifest epoch stakes to -// the same PersistedEpochStakes JSON format used by ComputedEpochStakes. -// Only stores vote-account stakes (aggregated), NOT full stake account data. func convertVersionedEpochStakesToPersisted(stakes []VersionedEpochStakesPair) map[uint64]string { result := make(map[uint64]string, len(stakes)) diff --git a/pkg/snapshot/manifest_seed_test.go b/pkg/snapshot/manifest_seed_test.go index 326b4eb29..e87c00815 100644 --- a/pkg/snapshot/manifest_seed_test.go +++ b/pkg/snapshot/manifest_seed_test.go @@ -4,11 +4,9 @@ import ( "encoding/json" "testing" - "github.com/Overclock-Validator/mithril/pkg/base58" "github.com/Overclock-Validator/mithril/pkg/epochstakes" "github.com/Overclock-Validator/mithril/pkg/sealevel" "github.com/Overclock-Validator/mithril/pkg/state" - "github.com/gagliardetto/solana-go" "github.com/stretchr/testify/require" ) @@ -51,88 +49,3 @@ func TestPopulateManifestSeedKeepsManifestEpochFrame(t *testing.T) { t.Fatalf("persisted epoch = %d, want 1073", persisted.Epoch) } } - -func TestPopulateManifestSeedUsesSnapshotEpochForAuthorizedVoters(t *testing.T) { - var voteAcct solana.PublicKey - var authorizedVoter solana.PublicKey - voteAcct[0] = 1 - authorizedVoter[0] = 2 - - manifest := &SnapshotManifest{ - Bank: &DeserializableVersionedBank{ - Slot: 2509799, - Epoch: 0, - EpochSchedule: sealevel.SysvarEpochSchedule{ - SlotsPerEpoch: 54000, - LeaderScheduleSlotOffset: 54000, - }, - }, - VersionedEpochStakes: []VersionedEpochStakesPair{ - { - Epoch: 46, - Val: VersionedEpochStakes{ - Stakes: Stake{}, - EpochAuthorizedVoters: []PubkeyPair{ - {Key: voteAcct, Val: authorizedVoter}, - }, - }, - }, - }, - } - mithrilState := state.NewReadyState(manifest.Bank.Slot, 46, "", "", 0, 0) - - PopulateManifestSeed(mithrilState, manifest) - - voteAcctStr := base58.Encode(voteAcct[:]) - require.Equal(t, []string{base58.Encode(authorizedVoter[:])}, mithrilState.ManifestEpochAuthorizedVoters[voteAcctStr]) -} - -func TestPopulateManifestSeedPrefersBankEpochAuthorizedVoters(t *testing.T) { - var bankEpochVoteAcct solana.PublicKey - var bankEpochAuthorizedVoter solana.PublicKey - var scheduleEpochVoteAcct solana.PublicKey - var scheduleEpochAuthorizedVoter solana.PublicKey - bankEpochVoteAcct[0] = 3 - bankEpochAuthorizedVoter[0] = 4 - scheduleEpochVoteAcct[0] = 5 - scheduleEpochAuthorizedVoter[0] = 6 - - manifest := &SnapshotManifest{ - Bank: &DeserializableVersionedBank{ - Slot: 2509799, - Epoch: 45, - EpochSchedule: sealevel.SysvarEpochSchedule{ - SlotsPerEpoch: 54000, - LeaderScheduleSlotOffset: 54000, - }, - }, - VersionedEpochStakes: []VersionedEpochStakesPair{ - { - Epoch: 45, - Val: VersionedEpochStakes{ - Stakes: Stake{}, - EpochAuthorizedVoters: []PubkeyPair{ - {Key: bankEpochVoteAcct, Val: bankEpochAuthorizedVoter}, - }, - }, - }, - { - Epoch: 46, - Val: VersionedEpochStakes{ - Stakes: Stake{}, - EpochAuthorizedVoters: []PubkeyPair{ - {Key: scheduleEpochVoteAcct, Val: scheduleEpochAuthorizedVoter}, - }, - }, - }, - }, - } - mithrilState := state.NewReadyState(manifest.Bank.Slot, 46, "", "", 0, 0) - - PopulateManifestSeed(mithrilState, manifest) - - bankEpochVoteAcctStr := base58.Encode(bankEpochVoteAcct[:]) - scheduleEpochVoteAcctStr := base58.Encode(scheduleEpochVoteAcct[:]) - require.Equal(t, []string{base58.Encode(bankEpochAuthorizedVoter[:])}, mithrilState.ManifestEpochAuthorizedVoters[bankEpochVoteAcctStr]) - require.NotContains(t, mithrilState.ManifestEpochAuthorizedVoters, scheduleEpochVoteAcctStr) -} diff --git a/pkg/state/state.go b/pkg/state/state.go index e7a882062..15b9662d3 100644 --- a/pkg/state/state.go +++ b/pkg/state/state.go @@ -96,11 +96,6 @@ type MithrilState struct { // Transaction count at snapshot slot ManifestTransactionCount uint64 `json:"manifest_transaction_count,omitempty"` - // Epoch authorized voters (for current epoch only) - // Maps vote account pubkey (base58) -> list of authorized voter pubkeys (base58) - // Multiple authorized voters per vote account are supported (matches original manifest behavior) - ManifestEpochAuthorizedVoters map[string][]string `json:"manifest_epoch_authorized_voters,omitempty"` - // Epoch stakes seed - AGGREGATED vote-account stakes only (NOT full VersionedEpochStakes) // Same format as ComputedEpochStakes (PersistedEpochStakes JSON) // Cleared after first replayed slot to save space. @@ -129,6 +124,12 @@ type MithrilState struct { // hashes) block promotion until removed by an operator. AlpenglowEvidence []AlpenglowFinalityEvidence `json:"alpenglow_finality_evidence,omitempty"` + // ReplayDivergenceEvidence records trailing-verifier execution mismatches + // (replayed results vs RPC metadata). Deterministic divergence is not + // self-healing: while evidence is present the node refuses to fold at or + // past the disputed slot; the operator clears it after triage. + ReplayDivergenceEvidence []ReplayDivergenceRecord `json:"replay_divergence_evidence,omitempty"` + // ========================================================================= // Resume Context (everything needed to continue replay from LastSlot) // These fields capture state at the end of the last successfully replayed slot @@ -228,6 +229,16 @@ type AlpenglowFinalityEvidence struct { Conflict bool `json:"conflict,omitempty"` } +// ReplayDivergenceRecord is one trailing-verifier execution mismatch. +type ReplayDivergenceRecord struct { + Slot uint64 `json:"slot"` + TxIndex int `json:"tx_index"` + TxSignature string `json:"tx_signature,omitempty"` + Kind string `json:"kind"` + Detail string `json:"detail"` + RecordedAt string `json:"recorded_at"` +} + type ResumeContext struct { Slot uint64 `json:"slot"` Bankhash string `json:"bankhash"` // base58 @@ -249,6 +260,13 @@ type ResumeContext struct { InflationTaper float64 `json:"inflation_taper"` InflationFoundation float64 `json:"inflation_foundation"` InflationFoundationTerm float64 `json:"inflation_foundation_term"` + // TransactionCount is the running chain transaction count as of this slot, so + // resume and the in-loop fork-switch unwind restore it exactly (getEpochInfo + // and future bank metadata must not carry a discarded fork's transactions). + // A pointer so presence is explicit: nil = context predates the field + // (callers fall back to the snapshot-manifest count and flag the count as + // approximate); non-nil is exact even when the value is zero (dev genesis). + TransactionCount *uint64 `json:"transaction_count,omitempty"` } // SnapshotInfo contains metadata about a downloaded snapshot file. @@ -294,16 +312,46 @@ func (s *MithrilState) Save(accountsDbDir string) error { return fmt.Errorf("failed to marshal state: %w", err) } - // Write to temp file first, then rename for atomicity + // Full-durability write: tmp + fsync + rename + dir fsync. Atomicity alone + // (tmp+rename) survives process crashes but a power cut can lose the + // un-synced rename and revert to the old file. Most state fields self-heal + // from the fsynced fold manifests at startup, but the epoch stakes saved at + // an epoch boundary have no other durable home — losing that save would + // force a snapshot re-bootstrap. Save is called only at rare moments + // (bootstrap, epoch boundary, shutdown, halt evidence), so the fsyncs are + // free. tmpFile := stateFile + ".tmp" - if err := os.WriteFile(tmpFile, data, 0644); err != nil { + f, err := os.OpenFile(tmpFile, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0644) + if err != nil { + return fmt.Errorf("failed to create state tmp file: %w", err) + } + if _, err := f.Write(data); err != nil { + f.Close() + os.Remove(tmpFile) return fmt.Errorf("failed to write state file: %w", err) } + if err := f.Sync(); err != nil { + f.Close() + os.Remove(tmpFile) + return fmt.Errorf("failed to fsync state file: %w", err) + } + if err := f.Close(); err != nil { + os.Remove(tmpFile) + return fmt.Errorf("failed to close state tmp file: %w", err) + } if err := os.Rename(tmpFile, stateFile); err != nil { os.Remove(tmpFile) return fmt.Errorf("failed to rename state file: %w", err) } + // Make the rename itself durable. + if dir, err := os.Open(accountsDbDir); err == nil { + syncErr := dir.Sync() + dir.Close() + if syncErr != nil { + return fmt.Errorf("failed to fsync state directory: %w", syncErr) + } + } return nil } @@ -530,13 +578,12 @@ type BankhashGetter interface { // This detects cases where the process was killed (Ctrl+Z, kill -9) without // updating the state file, leaving AccountsDB in an inconsistent state. func (s *MithrilState) ValidateAgainstBankhashDB(bankhashDb BankhashGetter) error { - // Rooted-durable: bankhash_db must end exactly at the last rooted slot (the in-RAM - // slots above it were never written); a bankhash beyond it = a torn durable write. + // Rooted-durable: the fold meta + manifests are the commit authority (see + // accountsdb.RecoverFoldState, which reconciles R before this runs). + // Bankhash rows BEYOND R are legal: fold bankhashes are written NoSync and + // batches can carry rows for slots above a partially-advanced state file. + // The only hard check left is that R itself matches. if s.LastRootedSlot > 0 { - checkSlot := s.LastRootedSlot + 1 - if bankhash, err := bankhashDb.GetBankHashForSlot(checkSlot); err == nil && len(bankhash) > 0 { - return fmt.Errorf("state file shows last_rooted_slot=%d, but bankhash_db has entry for slot %d - torn durable commit", s.LastRootedSlot, checkSlot) - } rootedBankhash, err := bankhashDb.GetBankHashForSlot(s.LastRootedSlot) if err != nil || len(rootedBankhash) == 0 { return fmt.Errorf("state file shows last_rooted_slot=%d, but no bankhash found in bankhash_db", s.LastRootedSlot) diff --git a/pkg/state/state_test.go b/pkg/state/state_test.go index 18a78c902..1ca77e1fd 100644 --- a/pkg/state/state_test.go +++ b/pkg/state/state_test.go @@ -3,6 +3,8 @@ package state import ( "encoding/json" "fmt" + "os" + "path/filepath" "reflect" "testing" @@ -126,9 +128,9 @@ func TestDurableHighWater(t *testing.T) { } // In rooted mode, ValidateAgainstBankhashDB must assert the durable high-water -// is exactly R: bankhash_db has an entry at R (matching LastRootedBankhash) and -// NOTHING beyond R. A bankhash beyond R = a torn durable write (process killed -// after CommitSlotAtomic wrote the next slot.s bankhash but before the state file recorded it). +// is exactly R: bankhash_db has an entry at R (matching LastRootedBankhash). +// Rows beyond R are tolerated now (fold bankhashes are written NoSync and a +// batch can carry rows for slots above a partially-advanced state file). func TestValidateAgainstBankhashDB_RootedMode(t *testing.T) { t.Run("clean: db high-water == R", func(t *testing.T) { s := &MithrilState{LastSlot: 110, LastRootedSlot: 100, LastRootedBankhash: base58.Encode(bh(0xAA))} @@ -138,11 +140,14 @@ func TestValidateAgainstBankhashDB_RootedMode(t *testing.T) { } }) - t.Run("torn: bankhash beyond R", func(t *testing.T) { + t.Run("bankhash beyond R is tolerated (NoSync fold rows)", func(t *testing.T) { + // Batch folds write bankhash rows NoSync and RecoverFoldState is the + // commit authority — rows beyond the state file's R are expected after + // a hard kill, not evidence of a torn write. s := &MithrilState{LastSlot: 110, LastRootedSlot: 100, LastRootedBankhash: base58.Encode(bh(0xAA))} db := &mockBankhashDb{hashes: map[uint64][]byte{100: bh(0xAA), 101: bh(0xBB)}} - if err := s.ValidateAgainstBankhashDB(db); err == nil { - t.Fatal("expected error for bankhash beyond R, got nil") + if err := s.ValidateAgainstBankhashDB(db); err != nil { + t.Fatalf("bankhash rows beyond R must be tolerated, got: %v", err) } }) @@ -182,3 +187,32 @@ func TestValidateAgainstBankhashDB_LegacyUnchanged(t *testing.T) { } }) } + +// Older state files carry manifest_epoch_authorized_voters (removed in the +// Alpenglow-only build). Loading such a file must succeed — unknown JSON +// fields are ignored — so upgrades don't force a re-bootstrap. +func TestStateFileWithRemovedAuthorizedVotersFieldStillLoads(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, StateFileName) + legacy := fmt.Sprintf(`{ + "state_schema_version": %d, + "stage": "ready", + "last_slot": 123, + "manifest_epoch_authorized_voters": { + "Vote111111111111111111111111111111111111111": ["Voter11111111111111111111111111111111111111"] + } + }`, CurrentStateSchemaVersion) + if err := os.WriteFile(path, []byte(legacy), 0o644); err != nil { + t.Fatalf("write legacy state file: %v", err) + } + st, err := LoadState(dir) + if err != nil { + t.Fatalf("LoadState returned error for legacy state file: %v", err) + } + if st == nil { + t.Fatal("LoadState returned nil state") + } + if st.LastSlot != 123 { + t.Fatalf("LastSlot = %d, want 123", st.LastSlot) + } +} diff --git a/pkg/turbine/assembler.go b/pkg/turbine/assembler.go index 053334f62..d59aa5a3d 100644 --- a/pkg/turbine/assembler.go +++ b/pkg/turbine/assembler.go @@ -6,6 +6,7 @@ import ( "fmt" "sort" "sync" + "time" "github.com/Overclock-Validator/mithril/pkg/block" "github.com/gagliardetto/solana-go" @@ -38,7 +39,9 @@ type SlotAssembler struct { priorityRepairSlots map[uint64]struct{} priorityRepairOrder []uint64 encoders map[fecLayout]reedsolomon.Encoder + partialShredObs map[uint64]PartialShredObservation // shreds seen for slots that never became full (retained for skip observability) maxObservedSlot uint64 + highestFullSlot uint64 // monotonic: highest slot reconstructed from shreds ("full", Agave SlotMeta/is_full sense) recoveredDataShreds uint64 nonCanonicalBlockIDs uint64 lastNonCanonicalSlot uint64 @@ -55,6 +58,15 @@ type SlotRepairRequest struct { HighestDataShredIndex uint32 } +// PartialShredObservation records what arrived for a slot that never became +// full — the operator signal distinguishing "leader sent some shreds then +// stopped" from "leader never transmitted" when a slot ends up skipped. +type PartialShredObservation struct { + DataShreds int // distinct data shreds received + RepairedShreds int // of those, delivered via repair + FirstNanos int64 // wall clock (unix nanos) of the first accepted shred +} + type slotState struct { slot uint64 parentSlot uint64 @@ -64,6 +76,11 @@ type slotState struct { haveLast bool shredVer uint16 firstParent bool + + // Observability: when the slot's first shred was accepted, and how many of + // its shreds arrived via repair rather than turbine. + firstShredAt time.Time + repairedShreds int } type fecLayout struct { @@ -91,10 +108,42 @@ func NewSlotAssembler() *SlotAssembler { completedSlots: make(map[uint64]struct{}), knownBlockIDs: make(map[uint64]solana.Hash), priorityRepairSlots: make(map[uint64]struct{}), + partialShredObs: make(map[uint64]PartialShredObservation), encoders: make(map[fecLayout]reedsolomon.Encoder), } } +// recordPartialObsLocked snapshots a never-completed slot's shred arrivals +// before its state is dropped (reset or pruned), so skip reporting can still +// say what the leader managed to send. +func (a *SlotAssembler) recordPartialObsLocked(state *slotState) { + if state == nil || len(state.shreds) == 0 { + return + } + a.partialShredObs[state.slot] = PartialShredObservation{ + DataShreds: len(state.shreds), + RepairedShreds: state.repairedShreds, + FirstNanos: state.firstShredAt.UnixNano(), + } +} + +// ShredObservation reports what has been seen for a slot that did not (or has +// not yet) become full: live partial state first, then retained observations +// from reset/pruned slots. ok is false when no shred was ever accepted. +func (a *SlotAssembler) ShredObservation(slot uint64) (PartialShredObservation, bool) { + a.mu.Lock() + defer a.mu.Unlock() + if state := a.slots[slot]; state != nil && len(state.shreds) > 0 { + return PartialShredObservation{ + DataShreds: len(state.shreds), + RepairedShreds: state.repairedShreds, + FirstNanos: state.firstShredAt.UnixNano(), + }, true + } + obs, ok := a.partialShredObs[slot] + return obs, ok +} + func (a *SlotAssembler) AddPacket(packet []byte) (*block.Block, error) { shred, err := ParseShred(packet) if err != nil { @@ -107,6 +156,12 @@ func (a *SlotAssembler) AddPacket(packet []byte) (*block.Block, error) { } func (a *SlotAssembler) AddShred(shred *Shred) (*block.Block, error) { + return a.AddShredFrom(shred, false) +} + +// AddShredFrom ingests a shred, recording whether it arrived via repair (for +// per-slot observability) rather than turbine. +func (a *SlotAssembler) AddShredFrom(shred *Shred, fromRepair bool) (*block.Block, error) { if shred == nil { return nil, nil } @@ -127,6 +182,9 @@ func (a *SlotAssembler) AddShred(shred *Shred) (*block.Block, error) { } state := a.slotState(shred.Slot, shred.Version) + if fromRepair { + state.repairedShreds++ + } var err error switch shred.Type { case ShredTypeData: @@ -168,15 +226,35 @@ func (a *SlotAssembler) AddShred(shred *Shred) (*block.Block, error) { } if !a.acceptAlpenglowBlockIDLocked(blk) { a.trackNonCanonicalBlockIDLocked(blk) + a.recordPartialObsLocked(state) // shreds DID arrive; useful if the slot ends up skipped delete(a.slots, shred.Slot) return nil, nil } delete(a.slots, shred.Slot) a.completedSlots[shred.Slot] = struct{}{} a.trackBlockIDLocked(blk) + // Shred-path observability: stamp when the slot's shreds started arriving + // and when it became full ("full" = reconstructable, Agave is_full sense). + if !state.firstShredAt.IsZero() { + blk.ShredFirstNanos = state.firstShredAt.UnixNano() + } + blk.ShredFullNanos = time.Now().UnixNano() + blk.RepairedShreds = state.repairedShreds + if shred.Slot > a.highestFullSlot { + a.highestFullSlot = shred.Slot + } return blk, nil } +// ShredEdges reports the monotonic shred frontier: the highest slot any +// accepted shred has been seen for, and the highest slot that became full +// (reconstructable). Both only ever advance. +func (a *SlotAssembler) ShredEdges() (latestShredSlot, highestFullSlot uint64) { + a.mu.Lock() + defer a.mu.Unlock() + return a.maxObservedSlot, a.highestFullSlot +} + func (a *SlotAssembler) SetKnownAlpenglowBlockID(slot uint64, blockID solana.Hash) { a.mu.Lock() defer a.mu.Unlock() @@ -188,6 +266,7 @@ func (a *SlotAssembler) ResetSlot(slot uint64) { a.mu.Lock() defer a.mu.Unlock() + a.recordPartialObsLocked(a.slots[slot]) delete(a.slots, slot) delete(a.completedSlots, slot) } @@ -230,11 +309,12 @@ func (a *SlotAssembler) slotState(slot uint64, version uint16) *slotState { return state } state = &slotState{ - slot: slot, - shreds: make(map[uint32]*Shred), - fecSets: make(map[uint32]*fecState), - shredVer: version, - lastIndex: ^uint32(0), + slot: slot, + shreds: make(map[uint32]*Shred), + fecSets: make(map[uint32]*fecState), + shredVer: version, + lastIndex: ^uint32(0), + firstShredAt: time.Now(), } a.slots[slot] = state return state @@ -250,8 +330,9 @@ func (a *SlotAssembler) slotTooOldLocked(slot uint64) bool { func (a *SlotAssembler) pruneOldSlotsLocked() { if len(a.slots) > 0 && a.maxObservedSlot > maxRetainedIncompleteSlotLag { minSlot := a.maxObservedSlot - maxRetainedIncompleteSlotLag - for slot := range a.slots { + for slot, state := range a.slots { if slot < minSlot { + a.recordPartialObsLocked(state) delete(a.slots, slot) a.evictedSlots++ } @@ -269,6 +350,11 @@ func (a *SlotAssembler) pruneOldSlotsLocked() { delete(a.knownBlockIDs, slot) } } + for slot := range a.partialShredObs { + if slot < minSlot { + delete(a.partialShredObs, slot) + } + } } a.prunePriorityRepairSlotsLocked() @@ -287,6 +373,7 @@ func (a *SlotAssembler) pruneOldSlotsLocked() { if first { return } + a.recordPartialObsLocked(a.slots[oldest]) delete(a.slots, oldest) a.evictedSlots++ } diff --git a/pkg/turbine/receiver.go b/pkg/turbine/receiver.go index 2e29a43f7..cf7a3a058 100644 --- a/pkg/turbine/receiver.go +++ b/pkg/turbine/receiver.go @@ -103,6 +103,18 @@ func (r *UDPReceiver) ResetSlot(slot uint64) { r.assembler.ResetSlot(slot) } +// ShredEdges reports the monotonic shred frontier from the assembler: highest +// slot with any accepted shred, and highest slot that became full. +func (r *UDPReceiver) ShredEdges() (latestShredSlot, highestFullSlot uint64) { + return r.assembler.ShredEdges() +} + +// ShredObservation reports partial shred arrivals for a slot that never +// became full (skip observability). +func (r *UDPReceiver) ShredObservation(slot uint64) (PartialShredObservation, bool) { + return r.assembler.ShredObservation(slot) +} + func (r *UDPReceiver) PrioritizeRepairSlot(slot uint64) { if r == nil || r.assembler == nil { return @@ -228,7 +240,14 @@ func (r *UDPReceiver) Run(ctx context.Context) error { switch shred.Type { case ShredTypeData: r.dataShreds.Add(1) - r.lastDataSlot.Store(shred.Slot) + // Monotonic max: out-of-order packets must not move the reported + // latest-shred edge backward. + for { + cur := r.lastDataSlot.Load() + if shred.Slot <= cur || r.lastDataSlot.CompareAndSwap(cur, shred.Slot) { + break + } + } case ShredTypeCode: r.codingShreds.Add(1) } @@ -251,10 +270,11 @@ func (r *UDPReceiver) Run(ctx context.Context) error { continue } } + fromRepair := false if r.repairClient != nil { - r.repairClient.observeShredResponse(conn, packet, addr, shred) + fromRepair = r.repairClient.observeShredResponse(conn, packet, addr, shred) } - blk, err := r.assembler.AddShred(shred) + blk, err := r.assembler.AddShredFrom(shred, fromRepair) if err != nil { if errors.Is(err, ErrDuplicateShred) { continue diff --git a/pkg/turbine/repair.go b/pkg/turbine/repair.go index 4d14c7270..1fc2426e1 100644 --- a/pkg/turbine/repair.go +++ b/pkg/turbine/repair.go @@ -177,17 +177,21 @@ func (c *repairClient) handleRepairPing(conn *net.UDPConn, packet []byte, from * return true } -func (c *repairClient) observeShredResponse(conn *net.UDPConn, packet []byte, from *net.UDPAddr, shred *Shred) { +// observeShredResponse matches an incoming packet against outstanding repair +// requests (responder address + nonce). Returns true when the shred was +// delivered BY REPAIR — it answers one of our requests — so the caller can +// attribute it in per-slot repair accounting. +func (c *repairClient) observeShredResponse(conn *net.UDPConn, packet []byte, from *net.UDPAddr, shred *Shred) bool { if from == nil || shred == nil { - return + return false } nonce, ok := repairproto.ResponseNonce(packet) if !ok { - return + return false } addrKey, ok := repairAddressKeyFromUDP(from) if !ok { - return + return false } responseKey := repairResponseKey{addr: addrKey, nonce: nonce} @@ -195,7 +199,7 @@ func (c *repairClient) observeShredResponse(conn *net.UDPConn, packet []byte, fr reqKey, ok := c.byResponse[responseKey] if !ok { c.mu.Unlock() - return + return false } outstanding := c.outstanding[reqKey] delete(c.byResponse, responseKey) @@ -203,16 +207,16 @@ func (c *repairClient) observeShredResponse(conn *net.UDPConn, packet []byte, fr c.mu.Unlock() if outstanding.key.slot != shred.Slot { - return + return false } c.responses.Add(1) if outstanding.key.kind != repairRequestHighestWindowIndex || shred.Type != ShredTypeData { - return + return true } peers := c.peerSnapshot(time.Now()) if len(peers) == 0 { - return + return true } start := outstanding.key.index followups := 0 @@ -224,6 +228,7 @@ func (c *repairClient) observeShredResponse(conn *net.UDPConn, packet []byte, fr if !shred.LastInSlot() && followups < repairMaxFollowupRequests && shred.Index < maxDataShredsPerSlot-1 { c.sendRequest(conn, peers, repairRequestHighestWindowIndex, shred.Slot, shred.Index+1) } + return true } func (c *repairClient) sendRequest(conn *net.UDPConn, peers []gossip.RepairPeer, kind repairRequestKind, slot uint64, index uint32) bool {