Skip to content
70 changes: 70 additions & 0 deletions owm-coordinator/internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -248,5 +248,75 @@ func (c *Config) validate() error {
if c.Observer.Enabled && strings.TrimSpace(c.Observer.APIEndpoint) == "" {
return fmt.Errorf("observer.api_endpoint is required when observer.enabled is true")
}

// ── Stake tier minimums (BRS-POS-02) ─────────────────────────────────────
// Each tier's configured minimum must be at or above the BRS floor.
tierFloors := []struct {
key string
floor int64
}{
{"t1", 100_000},
{"t2", 500_000},
{"t3", 2_000_000},
}
for _, tf := range tierFloors {
val := c.Stake.TierMinimumSats[tf.key]
if val < tf.floor {
return fmt.Errorf("stake.tier_minimum_sats.%s must be ≥ %d sats (BRS-POS-02), got %d",
tf.key, tf.floor, val)
}
}
t1, t2, t3 := c.Stake.TierMinimumSats["t1"], c.Stake.TierMinimumSats["t2"], c.Stake.TierMinimumSats["t3"]
if t1 > t2 || t2 > t3 {
return fmt.Errorf("stake tier minimums must be ordered t1 ≤ t2 ≤ t3 (got t1=%d t2=%d t3=%d)", t1, t2, t3)
}

// ── Stake operational config ──────────────────────────────────────────────
if c.Stake.VerifyIntervalHours < 1 {
return fmt.Errorf("stake.verify_interval_hours must be ≥ 1, got %d", c.Stake.VerifyIntervalHours)
}
if c.Stake.DegradedGracePeriodHours < 1 {
return fmt.Errorf("stake.degraded_grace_period_hours must be ≥ 1, got %d", c.Stake.DegradedGracePeriodHours)
}
if c.Stake.SlashCooldownDays < 1 {
return fmt.Errorf("stake.slash_cooldown_days must be ≥ 1, got %d", c.Stake.SlashCooldownDays)
}
// SRS-STAKE-04: T1 requires ≥ 3 signals; T2/T3 requires ≥ 2 maintainer acks.
if c.Stake.T1AutoSlashSignals < 3 {
return fmt.Errorf("stake.t1_auto_slash_signals must be ≥ 3 (SRS-STAKE-04), got %d", c.Stake.T1AutoSlashSignals)
}
if c.Stake.T2T3MaintainerAcks < 2 {
return fmt.Errorf("stake.t2t3_maintainer_acks must be ≥ 2 (SRS-STAKE-04), got %d", c.Stake.T2T3MaintainerAcks)
}

// ── FL config bounds ──────────────────────────────────────────────────────
if c.FL.MinParticipants < 2 {
return fmt.Errorf("fl.min_participants must be ≥ 2, got %d", c.FL.MinParticipants)
}
if c.FL.GradientL2ClipNorm <= 0 {
return fmt.Errorf("fl.gradient_l2_clip_norm must be > 0, got %g", c.FL.GradientL2ClipNorm)
}
if c.FL.AnomalyStdDevThreshold <= 0 {
return fmt.Errorf("fl.anomaly_std_dev_threshold must be > 0, got %g", c.FL.AnomalyStdDevThreshold)
}
if c.FL.RoundIntervalMinutes < 1 {
return fmt.Errorf("fl.round_interval_minutes must be ≥ 1, got %d", c.FL.RoundIntervalMinutes)
}
if c.FL.TopKSparsificationPct < 0 || c.FL.TopKSparsificationPct > 1 {
return fmt.Errorf("fl.top_k_sparsification_pct must be in [0, 1], got %g", c.FL.TopKSparsificationPct)
}

// ── S3 group check ────────────────────────────────────────────────────────
// S3 credentials are optional, but if any field is provided all four must be.
s3Count := 0
for _, v := range []string{c.S3.Endpoint, c.S3.Bucket, c.S3.AccessKey, c.S3.SecretKey} {
if v != "" {
s3Count++
}
}
if s3Count > 0 && s3Count < 4 {
return fmt.Errorf("s3 is partially configured: endpoint, bucket, access_key, and secret_key must all be set together (or all left empty)")
}

return nil
}
241 changes: 241 additions & 0 deletions owm-coordinator/internal/config/validate_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,241 @@
package config

import (
"strings"
"testing"
)

// validBase returns a minimal Config that passes every validate() check.
// Individual tests mutate one field at a time to trigger a specific error.
func validBase() *Config {
return &Config{
DevMode: true, // skip Lightning credential checks
Database: DatabaseConfig{DSN: "postgres://localhost/test"},
Lightning: LightningConfig{Backend: "lnd"},
FL: FLConfig{
MinParticipants: 2,
GradientL2ClipNorm: 1.0,
AnomalyStdDevThreshold: 3.0,
RoundIntervalMinutes: 1,
TopKSparsificationPct: 0.10,
},
Stake: StakeConfig{
TierMinimumSats: map[string]int64{
"t1": 100_000, "t2": 500_000, "t3": 2_000_000,
},
VerifyIntervalHours: 1,
DegradedGracePeriodHours: 1,
SlashCooldownDays: 1,
T1AutoSlashSignals: 3,
T2T3MaintainerAcks: 2,
},
}
}

func mustFail(t *testing.T, cfg *Config, wantSubstr string) {
t.Helper()
err := cfg.validate()
if err == nil {
t.Fatalf("expected validation error containing %q, got nil", wantSubstr)
}
if !strings.Contains(err.Error(), wantSubstr) {
t.Fatalf("error %q does not contain %q", err.Error(), wantSubstr)
}
}

func mustPass(t *testing.T, cfg *Config) {
t.Helper()
if err := cfg.validate(); err != nil {
t.Fatalf("unexpected validation error: %v", err)
}
}

// ── Tier minimums ─────────────────────────────────────────────────────────────

func TestValidate_TierFloor_T1(t *testing.T) {
cfg := validBase()
cfg.Stake.TierMinimumSats["t1"] = 99_999
mustFail(t, cfg, "tier_minimum_sats.t1")
}

func TestValidate_TierFloor_T2(t *testing.T) {
cfg := validBase()
cfg.Stake.TierMinimumSats["t2"] = 499_999
mustFail(t, cfg, "tier_minimum_sats.t2")
}

func TestValidate_TierFloor_T3(t *testing.T) {
cfg := validBase()
cfg.Stake.TierMinimumSats["t3"] = 1_999_999
mustFail(t, cfg, "tier_minimum_sats.t3")
}

func TestValidate_TierFloor_NilMap(t *testing.T) {
cfg := validBase()
cfg.Stake.TierMinimumSats = nil // all tiers read as 0 → below every floor
mustFail(t, cfg, "tier_minimum_sats.t1")
}

func TestValidate_TierOrdering_T1GtT2(t *testing.T) {
cfg := validBase()
cfg.Stake.TierMinimumSats["t1"] = 600_000 // above t2
mustFail(t, cfg, "t1 ≤ t2 ≤ t3")
}

func TestValidate_TierOrdering_T2GtT3(t *testing.T) {
cfg := validBase()
cfg.Stake.TierMinimumSats["t2"] = 3_000_000 // above t3
mustFail(t, cfg, "t1 ≤ t2 ≤ t3")
}

func TestValidate_TierOrdering_EqualBoundariesOK(t *testing.T) {
// t1 == t2 == t3 is allowed by the ordering check (≤ not <).
cfg := validBase()
cfg.Stake.TierMinimumSats["t1"] = 2_000_000
cfg.Stake.TierMinimumSats["t2"] = 2_000_000
cfg.Stake.TierMinimumSats["t3"] = 2_000_000
mustPass(t, cfg)
}

// ── Stake operational config ──────────────────────────────────────────────────

func TestValidate_Stake_VerifyIntervalZero(t *testing.T) {
cfg := validBase()
cfg.Stake.VerifyIntervalHours = 0
mustFail(t, cfg, "verify_interval_hours")
}

func TestValidate_Stake_DegradedGracePeriodZero(t *testing.T) {
cfg := validBase()
cfg.Stake.DegradedGracePeriodHours = 0
mustFail(t, cfg, "degraded_grace_period_hours")
}

func TestValidate_Stake_SlashCooldownZero(t *testing.T) {
cfg := validBase()
cfg.Stake.SlashCooldownDays = 0
mustFail(t, cfg, "slash_cooldown_days")
}

func TestValidate_Stake_T1AutoSlashSignalsTooLow(t *testing.T) {
cfg := validBase()
cfg.Stake.T1AutoSlashSignals = 2
mustFail(t, cfg, "t1_auto_slash_signals")
}

func TestValidate_Stake_T2T3MaintainerAcksTooLow(t *testing.T) {
cfg := validBase()
cfg.Stake.T2T3MaintainerAcks = 1
mustFail(t, cfg, "t2t3_maintainer_acks")
}

func TestValidate_Stake_HigherThresholdsOK(t *testing.T) {
cfg := validBase()
cfg.Stake.T1AutoSlashSignals = 10 // more conservative than SRS minimum
cfg.Stake.T2T3MaintainerAcks = 5
mustPass(t, cfg)
}

// ── FL config bounds ──────────────────────────────────────────────────────────

func TestValidate_FL_MinParticipantsTooLow(t *testing.T) {
cfg := validBase()
cfg.FL.MinParticipants = 1
mustFail(t, cfg, "min_participants")
}

func TestValidate_FL_MinParticipantsExactly2(t *testing.T) {
cfg := validBase()
cfg.FL.MinParticipants = 2
mustPass(t, cfg)
}

func TestValidate_FL_GradientClipNormZero(t *testing.T) {
cfg := validBase()
cfg.FL.GradientL2ClipNorm = 0
mustFail(t, cfg, "gradient_l2_clip_norm")
}

func TestValidate_FL_GradientClipNormNegative(t *testing.T) {
cfg := validBase()
cfg.FL.GradientL2ClipNorm = -1
mustFail(t, cfg, "gradient_l2_clip_norm")
}

func TestValidate_FL_AnomalyThresholdZero(t *testing.T) {
cfg := validBase()
cfg.FL.AnomalyStdDevThreshold = 0
mustFail(t, cfg, "anomaly_std_dev_threshold")
}

func TestValidate_FL_RoundIntervalZero(t *testing.T) {
cfg := validBase()
cfg.FL.RoundIntervalMinutes = 0
mustFail(t, cfg, "round_interval_minutes")
}

func TestValidate_FL_TopKPctNegative(t *testing.T) {
cfg := validBase()
cfg.FL.TopKSparsificationPct = -0.01
mustFail(t, cfg, "top_k_sparsification_pct")
}

func TestValidate_FL_TopKPctAbove1(t *testing.T) {
cfg := validBase()
cfg.FL.TopKSparsificationPct = 1.01
mustFail(t, cfg, "top_k_sparsification_pct")
}

func TestValidate_FL_TopKPctZeroOK(t *testing.T) {
cfg := validBase()
cfg.FL.TopKSparsificationPct = 0 // 0 = sparsification disabled
mustPass(t, cfg)
}

func TestValidate_FL_TopKPctOneOK(t *testing.T) {
cfg := validBase()
cfg.FL.TopKSparsificationPct = 1.0 // keep all
mustPass(t, cfg)
}

// ── S3 group check ────────────────────────────────────────────────────────────

func TestValidate_S3_AllEmptyOK(t *testing.T) {
cfg := validBase() // S3 fields are all zero-value → OK
mustPass(t, cfg)
}

func TestValidate_S3_AllSetOK(t *testing.T) {
cfg := validBase()
cfg.S3 = S3Config{
Endpoint: "https://s3.example.com",
Bucket: "owm-data",
AccessKey: "AKID",
SecretKey: "secret",
}
mustPass(t, cfg)
}

func TestValidate_S3_PartialMissingBucket(t *testing.T) {
cfg := validBase()
cfg.S3 = S3Config{Endpoint: "https://s3.example.com", AccessKey: "AKID", SecretKey: "secret"}
mustFail(t, cfg, "s3 is partially configured")
}

func TestValidate_S3_PartialOnlyEndpoint(t *testing.T) {
cfg := validBase()
cfg.S3 = S3Config{Endpoint: "https://s3.example.com"}
mustFail(t, cfg, "s3 is partially configured")
}

func TestValidate_S3_PartialOnlyBucket(t *testing.T) {
cfg := validBase()
cfg.S3 = S3Config{Bucket: "owm-data"}
mustFail(t, cfg, "s3 is partially configured")
}

// ── Full valid config ─────────────────────────────────────────────────────────

func TestValidate_BaseConfigPasses(t *testing.T) {
mustPass(t, validBase())
}
Loading
Loading