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
4 changes: 2 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ jobs:
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: "1.22"
go-version: "1.24"
cache-dependency-path: owm-coordinator/go.sum

- name: Install protoc
Expand Down Expand Up @@ -84,7 +84,7 @@ jobs:
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: "1.22"
go-version: "1.24"
cache-dependency-path: owm-coordinator/go.sum

- name: Install protoc
Expand Down
2 changes: 1 addition & 1 deletion owm-coordinator/Dockerfile
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# ── Build stage ──────────────────────────────────────────────────────────────
FROM golang:1.22-alpine AS builder
FROM golang:1.24-alpine AS builder

# Install build dependencies.
RUN apk add --no-cache git ca-certificates tzdata
Expand Down
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