Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions cmd/mithril/node/manifest_seed_refresh_test.go
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
package node

import (
"encoding/json"
"testing"

"github.com/Overclock-Validator/mithril/pkg/epochstakes"
"github.com/Overclock-Validator/mithril/pkg/sealevel"
"github.com/Overclock-Validator/mithril/pkg/snapshot"
"github.com/Overclock-Validator/mithril/pkg/state"
"github.com/gagliardetto/solana-go"
"github.com/stretchr/testify/require"
)

Expand Down Expand Up @@ -35,3 +38,26 @@ func TestRefreshManifestSeedRestoresClearedEpochStakes(t *testing.T) {

require.Contains(t, s.ManifestEpochStakes, uint64(7))
}

func TestRefreshManifestSeedRestoresDiscardedCommissions(t *testing.T) {
key := solana.PublicKey{32}
bps := uint16(10000)
manifest := &snapshot.SnapshotManifest{
Bank: &snapshot.DeserializableVersionedBank{Slot: 451934700, Epoch: 1058, EpochSchedule: sealevel.SysvarEpochSchedule{SlotsPerEpoch: 432000, LeaderScheduleSlotOffset: 432000, Warmup: true, FirstNormalEpoch: 14, FirstNormalSlot: 524256}},
VersionedEpochStakes: []snapshot.VersionedEpochStakesPair{{Epoch: 1058, Val: snapshot.VersionedEpochStakes{TotalStake: 42, Stakes: snapshot.Stake{VoteAccounts: []snapshot.VoteAccountsPair{{Key: key, Stake: 42, Value: snapshot.VoteAccount{InflationRewardsCommissionBps: &bps}}}}}}},
}
s := state.NewReadyState(manifest.Bank.Slot, 1058, "", "", 0, 0)
snapshot.PopulateManifestSeed(s, manifest)
var legacy epochstakes.PersistedEpochStakes
require.NoError(t, json.Unmarshal([]byte(s.ManifestEpochStakes[1058]), &legacy))
legacy.VoteAccts[key.String()].InflationRewardsCommissionBps = nil
raw, err := json.Marshal(legacy)
require.NoError(t, err)
s.ManifestEpochStakes[1058] = string(raw)
dir := t.TempDir()
refreshManifestSeedFromManifest(dir, s, manifest)
require.True(t, epochstakes.HasInflationCommissionMetadata([]byte(s.ManifestEpochStakes[1058])))
restored, err := state.LoadState(dir)
require.NoError(t, err)
require.Equal(t, s.ManifestEpochStakes, restored.ManifestEpochStakes)
}
13 changes: 11 additions & 2 deletions cmd/mithril/node/node.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import (
"github.com/Overclock-Validator/mithril/pkg/blockstream"
"github.com/Overclock-Validator/mithril/pkg/config"
consensusengine "github.com/Overclock-Validator/mithril/pkg/consensus"
"github.com/Overclock-Validator/mithril/pkg/epochstakes"
"github.com/Overclock-Validator/mithril/pkg/features"
"github.com/Overclock-Validator/mithril/pkg/global"
"github.com/Overclock-Validator/mithril/pkg/gossip"
Expand Down Expand Up @@ -490,8 +491,16 @@ func refreshManifestSeedFromManifest(accountsPath string, s *state.MithrilState,
// ManifestEpochStakes is deliberately cleared after replay starts to release
// the large JSON seed. A later graceful state save persists that cleared
// value, so schedule metadata alone does not mean the manifest seed is
// complete on restart. Rehydrate when the stakes payload is absent.
if manifestEpochScheduleSeedMatches(s, manifest) &&
// complete on restart. Rehydrate when the payload is absent or an older
// binary discarded its historical commissions.
commissionsComplete := true
for _, seed := range s.ManifestEpochStakes {
if !epochstakes.HasInflationCommissionMetadata([]byte(seed)) {
commissionsComplete = false
break
}
}
if commissionsComplete && manifestEpochScheduleSeedMatches(s, manifest) &&
s.SnapshotEpoch == snapshotEpoch && len(s.ManifestEpochStakes) > 0 {
return
}
Expand Down
51 changes: 51 additions & 0 deletions pkg/epochstakes/commission_metadata.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
package epochstakes

import (
"encoding/json"
"fmt"
)

// HasInflationCommissionMetadata distinguishes complete historical state from
// checkpoints written before commissions were retained. Explicit zero is known.
func HasInflationCommissionMetadata(data []byte) bool {
var persisted PersistedEpochStakes
if err := json.Unmarshal(data, &persisted); err != nil {
return false
}
for _, vote := range persisted.VoteAccts {
if vote == nil || vote.InflationRewardsCommissionBps == nil {
return false
}
}
return true
}

// RestoreInflationCommissionMetadata fills only missing commission fields from
// the same historical generation in the retained snapshot. It never substitutes
// current vote state or changes already persisted values, stake, or membership.
func RestoreInflationCommissionMetadata(data, historical []byte) ([]byte, error) {
var persisted, source PersistedEpochStakes
if err := json.Unmarshal(data, &persisted); err != nil {
return nil, err
}
if err := json.Unmarshal(historical, &source); err != nil {
return nil, err
}
if persisted.Epoch != source.Epoch {
return nil, fmt.Errorf("cannot restore commission metadata for epoch %d from epoch %d", persisted.Epoch, source.Epoch)
}
changed := false
for pubkey, vote := range persisted.VoteAccts {
if vote == nil || vote.InflationRewardsCommissionBps != nil {
continue
}
if original := source.VoteAccts[pubkey]; original != nil && original.InflationRewardsCommissionBps != nil {
vote.InflationRewardsCommissionBps = original.InflationRewardsCommissionBps
changed = true
}
}
if !changed {
return data, nil
}
return json.Marshal(persisted)
}
60 changes: 60 additions & 0 deletions pkg/epochstakes/commission_metadata_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
package epochstakes

import (
"encoding/json"
"testing"

"github.com/gagliardetto/solana-go"
"github.com/stretchr/testify/require"
)

func TestCommissionMetadataSurvivesRestartAndOwnsItsMemory(t *testing.T) {
for _, bps := range []uint16{0, 1234, 10000, 65535} {
vote := solana.PublicKey{1}
commission := bps
cache := NewEpochStakesCache()
cache.PutEpoch(1058, map[solana.PublicKey]uint64{vote: 42}, map[solana.PublicKey]*VoteAccount{vote: {InflationRewardsCommissionBps: &commission}}, 42)
commission++
*cache.EpochStakesAccts(1058)[vote].InflationRewardsCommissionBps = 7
raw, err := cache.SerializeEpoch(1058)
require.NoError(t, err)
require.True(t, HasInflationCommissionMetadata(raw))
restored := NewEpochStakesCache()
_, err = restored.DeserializeAndLoadEpoch(raw)
require.NoError(t, err)
got, _ := restored.Snapshot(1058)
require.Equal(t, bps, *got.VoteAccounts[vote].InflationRewardsCommissionBps)
var old PersistedEpochStakes
require.NoError(t, json.Unmarshal(raw, &old))
old.VoteAccts[vote.String()].InflationRewardsCommissionBps = nil
legacy, err := json.Marshal(old)
require.NoError(t, err)
require.False(t, HasInflationCommissionMetadata(legacy))
_, err = restored.DeserializeAndLoadEpoch(legacy)
require.NoError(t, err)
got, _ = restored.Snapshot(1058)
require.Nil(t, got.VoteAccounts[vote].InflationRewardsCommissionBps)
}
}

func TestRestoreCommissionsOnlyFillsMissingFieldsInSameGeneration(t *testing.T) {
zero, full := uint16(0), uint16(10000)
old := PersistedEpochStakes{Epoch: 1058, TotalStake: 42, Stakes: map[string]uint64{"vote": 42}, VoteAccts: map[string]*VoteAccountJSON{
"vote": {Lamports: 123}, "explicit-zero": {InflationRewardsCommissionBps: &zero}, "no-source": {},
}}
source := PersistedEpochStakes{Epoch: 1058, TotalStake: 999, Stakes: map[string]uint64{"vote": 999}, VoteAccts: map[string]*VoteAccountJSON{
"vote": {Lamports: 999, InflationRewardsCommissionBps: &full}, "explicit-zero": {InflationRewardsCommissionBps: &full}, "source-only": {InflationRewardsCommissionBps: &full},
}}
encode := func(v any) []byte { b, e := json.Marshal(v); require.NoError(t, e); return b }
raw := encode(old)
repaired, err := RestoreInflationCommissionMetadata(raw, encode(source))
require.NoError(t, err)
old.VoteAccts["vote"].InflationRewardsCommissionBps = &full
require.JSONEq(t, string(encode(old)), string(repaired))
require.False(t, HasInflationCommissionMetadata(repaired), "missing history must stay unknown")
source.Epoch++
_, err = RestoreInflationCommissionMetadata(raw, encode(source))
require.ErrorContains(t, err, "from epoch 1059")
_, err = RestoreInflationCommissionMetadata([]byte("invalid"), encode(source))
require.Error(t, err)
}
74 changes: 42 additions & 32 deletions pkg/epochstakes/epoch_stakes.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,11 +26,14 @@ type VoteAccount struct {
// Nil means an older persisted cache omitted this consensus-critical field.
// Legacy vote states explicitly use the scheduled node identity.
BlockRevenueCollector *solana.PublicKey
LastTimestampTs int64
LastTimestampSlot uint64
Owner solana.PublicKey
Executable byte
RentEpoch uint64
// Nil means an older checkpoint omitted the historical rate; zero is a
// known zero commission and must not fall back to a newer epoch's rate.
InflationRewardsCommissionBps *uint16
LastTimestampTs int64
LastTimestampSlot uint64
Owner solana.PublicKey
Executable byte
RentEpoch uint64
}

// Snapshot is one immutable, atomically published view of an epoch's stake
Expand Down Expand Up @@ -179,15 +182,16 @@ type PersistedEpochStakes struct {

// VoteAccountJSON is the JSON-serializable format for vote account metadata.
type VoteAccountJSON struct {
Lamports uint64 `json:"lamports"`
NodePubkey string `json:"node_pubkey"`
BlsPubkeyCompressed []byte `json:"bls_pubkey_compressed,omitempty"`
BlockRevenueCollector string `json:"block_revenue_collector,omitempty"`
LastTimestampTs int64 `json:"last_ts"`
LastTimestampSlot uint64 `json:"last_ts_slot"`
Owner string `json:"owner"`
Executable byte `json:"executable"`
RentEpoch uint64 `json:"rent_epoch"`
Lamports uint64 `json:"lamports"`
NodePubkey string `json:"node_pubkey"`
BlsPubkeyCompressed []byte `json:"bls_pubkey_compressed,omitempty"`
BlockRevenueCollector string `json:"block_revenue_collector,omitempty"`
InflationRewardsCommissionBps *uint16 `json:"inflation_rewards_commission_bps,omitempty"`
LastTimestampTs int64 `json:"last_ts"`
LastTimestampSlot uint64 `json:"last_ts_slot"`
Owner string `json:"owner"`
Executable byte `json:"executable"`
RentEpoch uint64 `json:"rent_epoch"`
}

// SerializeEpoch serializes the stakes for a single epoch to JSON.
Expand Down Expand Up @@ -219,15 +223,16 @@ func (cache *EpochStakesCache) SerializeEpoch(epoch uint64) ([]byte, error) {
collector = va.BlockRevenueCollector.String()
}
persisted.VoteAccts[pk.String()] = &VoteAccountJSON{
BlockRevenueCollector: collector,
Lamports: va.Lamports,
NodePubkey: va.NodePubkey.String(),
BlsPubkeyCompressed: bls,
LastTimestampTs: va.LastTimestampTs,
LastTimestampSlot: va.LastTimestampSlot,
Owner: va.Owner.String(),
Executable: va.Executable,
RentEpoch: va.RentEpoch,
BlockRevenueCollector: collector,
InflationRewardsCommissionBps: va.InflationRewardsCommissionBps,
Lamports: va.Lamports,
NodePubkey: va.NodePubkey.String(),
BlsPubkeyCompressed: bls,
LastTimestampTs: va.LastTimestampTs,
LastTimestampSlot: va.LastTimestampSlot,
Owner: va.Owner.String(),
Executable: va.Executable,
RentEpoch: va.RentEpoch,
}
}
}
Expand Down Expand Up @@ -292,15 +297,16 @@ func (cache *EpochStakesCache) DeserializeAndLoadEpoch(data []byte) (uint64, err
collector = &key
}
voteAccounts[pk] = &VoteAccount{
BlockRevenueCollector: collector,
Lamports: vaJSON.Lamports,
NodePubkey: nodePubkey,
BlsPubkeyCompressed: bls,
LastTimestampTs: vaJSON.LastTimestampTs,
LastTimestampSlot: vaJSON.LastTimestampSlot,
Owner: owner,
Executable: vaJSON.Executable,
RentEpoch: vaJSON.RentEpoch,
BlockRevenueCollector: collector,
InflationRewardsCommissionBps: vaJSON.InflationRewardsCommissionBps,
Lamports: vaJSON.Lamports,
NodePubkey: nodePubkey,
BlsPubkeyCompressed: bls,
LastTimestampTs: vaJSON.LastTimestampTs,
LastTimestampSlot: vaJSON.LastTimestampSlot,
Owner: owner,
Executable: vaJSON.Executable,
RentEpoch: vaJSON.RentEpoch,
}
}

Expand Down Expand Up @@ -378,6 +384,10 @@ func cloneVoteAccount(voteAccount *VoteAccount) *VoteAccount {
return nil
}
cloned := *voteAccount
if voteAccount.InflationRewardsCommissionBps != nil {
commission := *voteAccount.InflationRewardsCommissionBps
cloned.InflationRewardsCommissionBps = &commission
}
if voteAccount.BlockRevenueCollector != nil {
collector := *voteAccount.BlockRevenueCollector
cloned.BlockRevenueCollector = &collector
Expand Down
45 changes: 0 additions & 45 deletions pkg/global/global_ctx.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,6 @@ type GlobalCtx struct {
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
slotsConfirmed map[uint64]struct{}
leaderSchedule *leaderschedule.LeaderSchedule // compatibility schedule installed by SetLeaderSchedule
Expand Down Expand Up @@ -186,50 +185,6 @@ func VoteCacheSnapshot() map[solana.PublicKey]*sealevel.VoteStateVersions {
return snapshot
}

func PutEpochVoteStateSnapshot(epoch uint64, snapshot map[solana.PublicKey]*sealevel.VoteStateVersions) {
instance.voteCacheMutex.Lock()
defer instance.voteCacheMutex.Unlock()

if instance.epochVoteStateSnapshots == nil {
instance.epochVoteStateSnapshots = make(map[uint64]map[solana.PublicKey]*sealevel.VoteStateVersions)
}

snapshotCopy := make(map[solana.PublicKey]*sealevel.VoteStateVersions, len(snapshot))
for pk, voteState := range snapshot {
snapshotCopy[pk] = voteState
}
instance.epochVoteStateSnapshots[epoch] = snapshotCopy

for cachedEpoch := range instance.epochVoteStateSnapshots {
if cachedEpoch+2 < epoch {
delete(instance.epochVoteStateSnapshots, cachedEpoch)
}
}
}

func EpochVoteStateSnapshot(epoch uint64) map[solana.PublicKey]*sealevel.VoteStateVersions {
instance.voteCacheMutex.RLock()
defer instance.voteCacheMutex.RUnlock()

snapshot := instance.epochVoteStateSnapshots[epoch]
if snapshot == nil {
return nil
}

snapshotCopy := make(map[solana.PublicKey]*sealevel.VoteStateVersions, len(snapshot))
for pk, voteState := range snapshot {
snapshotCopy[pk] = voteState
}
return snapshotCopy
}

func ClearEpochVoteStateSnapshots() {
instance.voteCacheMutex.Lock()
defer instance.voteCacheMutex.Unlock()

instance.epochVoteStateSnapshots = nil
}

func PutEpochStakesEntry(epoch uint64, pubkey solana.PublicKey, stake uint64, voteAcct *epochstakes.VoteAccount) {
instance.epochStakes.PutEntry(epoch, pubkey, stake, voteAcct)
}
Expand Down
Loading
Loading