diff --git a/app/upgrades/audit_store_loader.go b/app/upgrades/audit_store_loader.go new file mode 100644 index 00000000..e4c960e4 --- /dev/null +++ b/app/upgrades/audit_store_loader.go @@ -0,0 +1,59 @@ +package upgrades + +import ( + "cosmossdk.io/log" + storetypes "cosmossdk.io/store/types" + upgradetypes "cosmossdk.io/x/upgrade/types" + + "github.com/cosmos/cosmos-sdk/baseapp" +) + +// AuditStoreLoader builds a store loader that safely adds the audit store only +// when it is missing on-disk. This allows v1.11.1 to work for both: +// - chains that already ran v1.11.0 (audit store exists) +// - chains upgrading directly from v1.10.1 (audit store missing) +func AuditStoreLoader( + upgradeHeight int64, + baseUpgrades *storetypes.StoreUpgrades, + logger log.Logger, +) baseapp.StoreLoader { + fallbackLoader := upgradetypes.UpgradeStoreLoader(upgradeHeight, baseUpgrades) + + return func(ms storetypes.CommitMultiStore) error { + if upgradeHeight != ms.LastCommitID().Version+1 { + return baseapp.DefaultStoreLoader(ms) + } + + existingStoreNames, err := loadExistingStoreNames(ms) + if err != nil { + logger.Error("Failed to load existing stores; falling back to standard upgrade loader", "error", err) + return fallbackLoader(ms) + } + + effective := computeAuditStoreUpgrades(baseUpgrades, existingStoreNames) + if len(effective.Added) == 0 && len(effective.Deleted) == 0 && len(effective.Renamed) == 0 { + logger.Info("No store upgrades required; loading latest version", "height", upgradeHeight) + return baseapp.DefaultStoreLoader(ms) + } + + logger.Info( + "Applying store upgrades", + "height", upgradeHeight, + "added", effective.Added, + "deleted", effective.Deleted, + "renamed", formatStoreRenames(effective.Renamed), + ) + + return ms.LoadLatestVersionAndUpgrade(&effective) + } +} + +func computeAuditStoreUpgrades(baseUpgrades *storetypes.StoreUpgrades, existingStoreNames map[string]struct{}) storetypes.StoreUpgrades { + effective := cloneStoreUpgrades(baseUpgrades) + effective.Added = filterStoreAdds(effective.Added, existingStoreNames) + effective.Deleted = filterStoreDeletes(effective.Deleted, existingStoreNames) + effective.Renamed = filterStoreRenames(effective.Renamed, existingStoreNames) + effective.Added = uniqueSortedStores(effective.Added) + effective.Deleted = uniqueSortedStores(effective.Deleted) + return effective +} diff --git a/app/upgrades/audit_store_loader_test.go b/app/upgrades/audit_store_loader_test.go new file mode 100644 index 00000000..217cf366 --- /dev/null +++ b/app/upgrades/audit_store_loader_test.go @@ -0,0 +1,28 @@ +package upgrades + +import ( + "testing" + + storetypes "cosmossdk.io/store/types" + "github.com/stretchr/testify/require" +) + +func TestComputeAuditStoreUpgrades_AddsAuditWhenMissing(t *testing.T) { + base := &storetypes.StoreUpgrades{Added: []string{"audit"}} + existing := setOf("auth", "bank") + + effective := computeAuditStoreUpgrades(base, existing) + require.ElementsMatch(t, []string{"audit"}, effective.Added) + require.Empty(t, effective.Deleted) + require.Empty(t, effective.Renamed) +} + +func TestComputeAuditStoreUpgrades_SkipsAuditWhenAlreadyPresent(t *testing.T) { + base := &storetypes.StoreUpgrades{Added: []string{"audit"}} + existing := setOf("auth", "bank", "audit") + + effective := computeAuditStoreUpgrades(base, existing) + require.Empty(t, effective.Added) + require.Empty(t, effective.Deleted) + require.Empty(t, effective.Renamed) +} diff --git a/app/upgrades/store_loader_selector.go b/app/upgrades/store_loader_selector.go index 0fe5be55..de86ef88 100644 --- a/app/upgrades/store_loader_selector.go +++ b/app/upgrades/store_loader_selector.go @@ -10,6 +10,7 @@ import ( "github.com/cosmos/cosmos-sdk/baseapp" upgrade_v1_10_1 "github.com/LumeraProtocol/lumera/app/upgrades/v1_10_1" + upgrade_v1_11_1 "github.com/LumeraProtocol/lumera/app/upgrades/v1_11_1" ) type StoreLoaderSelection struct { @@ -34,6 +35,12 @@ func StoreLoaderForUpgrade( LogLabel: "consensus rename", } } + if upgradeName == upgrade_v1_11_1.UpgradeName { + return StoreLoaderSelection{ + Loader: AuditStoreLoader(upgradeHeight, baseUpgrades, logger), + LogLabel: "conditional audit store", + } + } return StoreLoaderSelection{ Loader: AdaptiveStoreLoader(upgradeHeight, baseUpgrades, expectedStoreNames, logger), LogLabel: "adaptive mode", @@ -46,6 +53,12 @@ func StoreLoaderForUpgrade( LogLabel: "consensus rename", } } + if upgradeName == upgrade_v1_11_1.UpgradeName { + return StoreLoaderSelection{ + Loader: AuditStoreLoader(upgradeHeight, baseUpgrades, logger), + LogLabel: "conditional audit store", + } + } return StoreLoaderSelection{ Loader: upgradetypes.UpgradeStoreLoader(upgradeHeight, baseUpgrades), diff --git a/app/upgrades/store_loader_selector_test.go b/app/upgrades/store_loader_selector_test.go index 8acd8280..71c3afa1 100644 --- a/app/upgrades/store_loader_selector_test.go +++ b/app/upgrades/store_loader_selector_test.go @@ -7,6 +7,7 @@ import ( "github.com/stretchr/testify/require" upgrade_v1_10_1 "github.com/LumeraProtocol/lumera/app/upgrades/v1_10_1" + upgrade_v1_11_1 "github.com/LumeraProtocol/lumera/app/upgrades/v1_11_1" ) func TestStoreLoaderForUpgrade_AdaptiveConsensusRename(t *testing.T) { @@ -23,6 +24,20 @@ func TestStoreLoaderForUpgrade_AdaptiveConsensusRename(t *testing.T) { require.Equal(t, "Configured store loader for upgrade (consensus rename)", selection.LogMessage()) } +func TestStoreLoaderForUpgrade_AdaptiveAuditStore(t *testing.T) { + selection := StoreLoaderForUpgrade( + upgrade_v1_11_1.UpgradeName, + 100, + nil, + map[string]struct{}{}, + log.NewNopLogger(), + true, + ) + + require.NotNil(t, selection.Loader) + require.Equal(t, "Configured store loader for upgrade (conditional audit store)", selection.LogMessage()) +} + func TestStoreLoaderForUpgrade_AdaptiveDefault(t *testing.T) { selection := StoreLoaderForUpgrade( "v9.9.9", @@ -51,6 +66,20 @@ func TestStoreLoaderForUpgrade_NonAdaptiveConsensusRename(t *testing.T) { require.Equal(t, "Configured store loader for upgrade (consensus rename)", selection.LogMessage()) } +func TestStoreLoaderForUpgrade_NonAdaptiveAuditStore(t *testing.T) { + selection := StoreLoaderForUpgrade( + upgrade_v1_11_1.UpgradeName, + 100, + nil, + nil, + log.NewNopLogger(), + false, + ) + + require.NotNil(t, selection.Loader) + require.Equal(t, "Configured store loader for upgrade (conditional audit store)", selection.LogMessage()) +} + func TestStoreLoaderForUpgrade_NonAdaptiveDefault(t *testing.T) { selection := StoreLoaderForUpgrade( "v9.9.9", diff --git a/app/upgrades/upgrades.go b/app/upgrades/upgrades.go index 97302edc..cf850ab7 100644 --- a/app/upgrades/upgrades.go +++ b/app/upgrades/upgrades.go @@ -14,6 +14,7 @@ import ( upgrade_v1_10_0 "github.com/LumeraProtocol/lumera/app/upgrades/v1_10_0" upgrade_v1_10_1 "github.com/LumeraProtocol/lumera/app/upgrades/v1_10_1" upgrade_v1_11_0 "github.com/LumeraProtocol/lumera/app/upgrades/v1_11_0" + upgrade_v1_11_1 "github.com/LumeraProtocol/lumera/app/upgrades/v1_11_1" upgrade_v1_6_1 "github.com/LumeraProtocol/lumera/app/upgrades/v1_6_1" upgrade_v1_8_0 "github.com/LumeraProtocol/lumera/app/upgrades/v1_8_0" upgrade_v1_8_4 "github.com/LumeraProtocol/lumera/app/upgrades/v1_8_4" @@ -35,6 +36,7 @@ import ( // | v1.10.0 | custom | drop crisis | Migrate consensus params from x/params to x/consensus; remove x/crisis // | v1.10.1 | custom | drop crisis (if not already) | Ensure consensus params are present in x/consensus // | v1.11.0 | custom | add audit store | Initializes audit params with dynamic epoch_zero_height +// | v1.11.1 | custom | conditional add audit store | Supports direct v1.10.1->v1.11.1 and enforces audit min_disk_free_percent floor (>=15) // ================================================================================================================================= type UpgradeConfig struct { @@ -63,6 +65,7 @@ var upgradeNames = []string{ upgrade_v1_10_0.UpgradeName, upgrade_v1_10_1.UpgradeName, upgrade_v1_11_0.UpgradeName, + upgrade_v1_11_1.UpgradeName, } var NoUpgradeConfig = UpgradeConfig{ @@ -132,6 +135,11 @@ func SetupUpgrades(upgradeName string, params appParams.AppUpgradeParams) (Upgra StoreUpgrade: &upgrade_v1_11_0.StoreUpgrades, Handler: upgrade_v1_11_0.CreateUpgradeHandler(params), }, true + case upgrade_v1_11_1.UpgradeName: + return UpgradeConfig{ + StoreUpgrade: &upgrade_v1_11_1.StoreUpgrades, + Handler: upgrade_v1_11_1.CreateUpgradeHandler(params), + }, true // add future upgrades here default: diff --git a/app/upgrades/upgrades_test.go b/app/upgrades/upgrades_test.go index d816333a..fd7faf50 100644 --- a/app/upgrades/upgrades_test.go +++ b/app/upgrades/upgrades_test.go @@ -14,6 +14,7 @@ import ( upgrade_v1_10_0 "github.com/LumeraProtocol/lumera/app/upgrades/v1_10_0" upgrade_v1_10_1 "github.com/LumeraProtocol/lumera/app/upgrades/v1_10_1" upgrade_v1_11_0 "github.com/LumeraProtocol/lumera/app/upgrades/v1_11_0" + upgrade_v1_11_1 "github.com/LumeraProtocol/lumera/app/upgrades/v1_11_1" upgrade_v1_6_1 "github.com/LumeraProtocol/lumera/app/upgrades/v1_6_1" upgrade_v1_8_0 "github.com/LumeraProtocol/lumera/app/upgrades/v1_8_0" upgrade_v1_8_4 "github.com/LumeraProtocol/lumera/app/upgrades/v1_8_4" @@ -35,6 +36,7 @@ func TestUpgradeNamesOrder(t *testing.T) { upgrade_v1_10_0.UpgradeName, upgrade_v1_10_1.UpgradeName, upgrade_v1_11_0.UpgradeName, + upgrade_v1_11_1.UpgradeName, } require.Equal(t, expected, upgradeNames, "upgradeNames should stay in ascending order") } @@ -80,7 +82,7 @@ func TestSetupUpgradesAndHandlers(t *testing.T) { // v1.9.0 and v1.11.0 require full keeper wiring; exercising them here would require // a full app harness. This test only verifies registration and gating. - if upgradeName == upgrade_v1_9_0.UpgradeName || upgradeName == upgrade_v1_10_0.UpgradeName || upgradeName == upgrade_v1_10_1.UpgradeName || upgradeName == upgrade_v1_11_0.UpgradeName { + if upgradeName == upgrade_v1_9_0.UpgradeName || upgradeName == upgrade_v1_10_0.UpgradeName || upgradeName == upgrade_v1_10_1.UpgradeName || upgradeName == upgrade_v1_11_0.UpgradeName || upgradeName == upgrade_v1_11_1.UpgradeName { continue } @@ -125,7 +127,7 @@ func expectStoreUpgrade(upgradeName, chainID string) bool { return IsMainnet(chainID) case upgrade_v1_10_0.UpgradeName: return true - case upgrade_v1_10_1.UpgradeName, upgrade_v1_11_0.UpgradeName: + case upgrade_v1_10_1.UpgradeName, upgrade_v1_11_0.UpgradeName, upgrade_v1_11_1.UpgradeName: return true default: return false diff --git a/app/upgrades/v1_11_1/store.go b/app/upgrades/v1_11_1/store.go new file mode 100644 index 00000000..e3c0c5f4 --- /dev/null +++ b/app/upgrades/v1_11_1/store.go @@ -0,0 +1,17 @@ +package v1_11_1 + +import ( + storetypes "cosmossdk.io/store/types" + + audittypes "github.com/LumeraProtocol/lumera/x/audit/v1/types" +) + +// StoreUpgrades declares store additions/deletions for v1.11.1. +// +// The audit store is included so direct upgrades from pre-audit binaries +// (e.g. v1.10.1) can add it. The store loader for this upgrade is conditional +// and will skip adding it when the store already exists (e.g. upgrading from +// v1.11.0). +var StoreUpgrades = storetypes.StoreUpgrades{ + Added: []string{audittypes.StoreKey}, +} diff --git a/app/upgrades/v1_11_1/upgrade.go b/app/upgrades/v1_11_1/upgrade.go new file mode 100644 index 00000000..8fea5bf9 --- /dev/null +++ b/app/upgrades/v1_11_1/upgrade.go @@ -0,0 +1,117 @@ +package v1_11_1 + +import ( + "context" + "fmt" + + upgradetypes "cosmossdk.io/x/upgrade/types" + + sdk "github.com/cosmos/cosmos-sdk/types" + "github.com/cosmos/cosmos-sdk/types/module" + + appParams "github.com/LumeraProtocol/lumera/app/upgrades/params" + audittypes "github.com/LumeraProtocol/lumera/x/audit/v1/types" +) + +// UpgradeName is the on-chain name used for this upgrade. +const UpgradeName = "v1.11.1" + +// auditMinDiskFreePercentFloor is the minimum acceptable value for +// audit.params.min_disk_free_percent after this upgrade. +const auditMinDiskFreePercentFloor = uint32(15) + +// CreateUpgradeHandler runs module migrations and enforces a floor for +// audit.params.min_disk_free_percent. +// +// This handler supports both: +// - direct upgrades from pre-audit binaries (e.g. v1.10.1), and +// - upgrades from v1.11.0 where audit is already initialized. +func CreateUpgradeHandler(p appParams.AppUpgradeParams) upgradetypes.UpgradeHandler { + return func(goCtx context.Context, _ upgradetypes.Plan, fromVM module.VersionMap) (module.VersionMap, error) { + p.Logger.Info(fmt.Sprintf("Starting upgrade %s...", UpgradeName)) + + ctx := sdk.UnwrapSDKContext(goCtx) + if p.AuditKeeper == nil { + return nil, fmt.Errorf("%s upgrade requires audit keeper to be wired", UpgradeName) + } + + _, auditModuleAlreadyMigrated := fromVM[audittypes.ModuleName] + migrationVM := prepareVersionMapForConditionalAuditInit(fromVM) + + p.Logger.Info("Running module migrations...") + newVM, err := p.ModuleManager.RunMigrations(ctx, p.Configurator, migrationVM) + if err != nil { + p.Logger.Error("Failed to run migrations", "error", err) + return nil, fmt.Errorf("failed to run migrations: %w", err) + } + p.Logger.Info("Module migrations completed.") + + if !auditModuleAlreadyMigrated { + if err := initializeAuditForDirectUpgrade(ctx, p); err != nil { + return nil, err + } + } + + params := p.AuditKeeper.GetParams(ctx).WithDefaults() + updatedParams, changed := withMinDiskFreePercentFloor(params, auditMinDiskFreePercentFloor) + if changed { + if err := p.AuditKeeper.SetParams(ctx, updatedParams); err != nil { + return nil, fmt.Errorf("set audit params: %w", err) + } + p.Logger.Info("Updated audit params min_disk_free_percent floor", + "previous", params.MinDiskFreePercent, + "current", updatedParams.MinDiskFreePercent, + ) + } else { + p.Logger.Info("Audit min_disk_free_percent already satisfies floor", + "current", params.MinDiskFreePercent, + ) + } + + p.Logger.Info(fmt.Sprintf("Successfully completed upgrade %s", UpgradeName)) + return newVM, nil + } +} + +func prepareVersionMapForConditionalAuditInit(fromVM module.VersionMap) module.VersionMap { + migrationVM := make(module.VersionMap, len(fromVM)+1) + for moduleName, version := range fromVM { + migrationVM[moduleName] = version + } + + migrationVM[audittypes.ModuleName] = audittypes.ConsensusVersion + return migrationVM +} + +func initializeAuditForDirectUpgrade(ctx sdk.Context, p appParams.AppUpgradeParams) error { + gs := audittypes.DefaultGenesis() + params := gs.Params.WithDefaults() + + // When enabling audit on an already-running chain, epoch zero must start at + // the current upgrade height. + params.EpochZeroHeight = uint64(ctx.BlockHeight()) + gs.Params = params + + if err := p.AuditKeeper.InitGenesis(ctx, *gs); err != nil { + return fmt.Errorf("init audit module: %w", err) + } + + // Create epoch-0 anchor immediately at upgrade height. + epochID := uint64(0) + epochStart := ctx.BlockHeight() + epochEnd := epochStart + int64(params.EpochLengthBlocks) - 1 + if err := p.AuditKeeper.CreateEpochAnchorIfNeeded(ctx, epochID, epochStart, epochEnd, params); err != nil { + return fmt.Errorf("create audit epoch anchor: %w", err) + } + + return nil +} + +func withMinDiskFreePercentFloor(params audittypes.Params, floor uint32) (audittypes.Params, bool) { + params = params.WithDefaults() + if params.MinDiskFreePercent >= floor { + return params, false + } + params.MinDiskFreePercent = floor + return params, true +} diff --git a/app/upgrades/v1_11_1/upgrade_test.go b/app/upgrades/v1_11_1/upgrade_test.go new file mode 100644 index 00000000..fc1ae0e6 --- /dev/null +++ b/app/upgrades/v1_11_1/upgrade_test.go @@ -0,0 +1,64 @@ +package v1_11_1 + +import ( + "testing" + + "github.com/cosmos/cosmos-sdk/types/module" + "github.com/stretchr/testify/require" + + audittypes "github.com/LumeraProtocol/lumera/x/audit/v1/types" +) + +func TestPrepareVersionMapForConditionalAuditInit_NilInput(t *testing.T) { + migrationVM := prepareVersionMapForConditionalAuditInit(nil) + + require.Equal(t, module.VersionMap{ + audittypes.ModuleName: audittypes.ConsensusVersion, + }, migrationVM) +} + +func TestPrepareVersionMapForConditionalAuditInit_ClonesAndPinsAuditVersion(t *testing.T) { + fromVM := module.VersionMap{ + "bank": 3, + "auth": 2, + "audit": 0, + } + + migrationVM := prepareVersionMapForConditionalAuditInit(fromVM) + + require.Equal(t, uint64(0), fromVM[audittypes.ModuleName], "input map must not be mutated") + require.Equal(t, uint64(3), migrationVM["bank"]) + require.Equal(t, uint64(2), migrationVM["auth"]) + require.Equal(t, uint64(audittypes.ConsensusVersion), migrationVM[audittypes.ModuleName]) +} + +func TestWithMinDiskFreePercentFloor_RaisesWhenBelowFloor(t *testing.T) { + params := audittypes.DefaultParams() + params.MinDiskFreePercent = 0 + + updated, changed := withMinDiskFreePercentFloor(params, 15) + require.True(t, changed) + require.Equal(t, uint32(15), updated.MinDiskFreePercent) +} + +func TestWithMinDiskFreePercentFloor_NoChangeAtOrAboveFloor(t *testing.T) { + testCases := []struct { + name string + value uint32 + floor uint32 + }{ + {name: "equal", value: 15, floor: 15}, + {name: "greater", value: 22, floor: 15}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + params := audittypes.DefaultParams() + params.MinDiskFreePercent = tc.value + + updated, changed := withMinDiskFreePercentFloor(params, tc.floor) + require.False(t, changed) + require.Equal(t, tc.value, updated.MinDiskFreePercent) + }) + } +}