diff --git a/.ciagent/CHECKPOINT.json b/.ciagent/CHECKPOINT.json index b7ff5a7..906b482 100644 --- a/.ciagent/CHECKPOINT.json +++ b/.ciagent/CHECKPOINT.json @@ -1,5 +1,5 @@ { - "phase": 4, + "phase": 5, "stage": "complete", "milestone": "v0.6", "milestone_type": "feature", @@ -7,7 +7,7 @@ "phase_role": "execution", "project": "oy", "attempts": 0, - "updated_at": "2026-08-18T14:35:00Z", + "updated_at": "2026-08-18T14:50:00Z", "milestone_complete": false, - "requirements_covered": ["REQ-040", "REQ-041", "REQ-042", "REQ-043", "REQ-045"] + "requirements_covered": ["REQ-040", "REQ-041", "REQ-042", "REQ-043", "REQ-044", "REQ-045"] } diff --git a/web/handlers/bloom.go b/web/handlers/bloom.go new file mode 100644 index 0000000..76a2585 --- /dev/null +++ b/web/handlers/bloom.go @@ -0,0 +1,50 @@ +package handlers + +import ( + "net/http" + + bloomtypes "github.com/oy/openyield/x/bloom/types" +) + +// registerBloom wires the Bloom accrual route (REQ-044). +func (s *Server) registerBloom(mux *http.ServeMux) { + mux.HandleFunc("GET /bloom/{stashID}", s.handleBloom) +} + +// bloomViewData is the template data for the Bloom accrual view. +type bloomViewData struct { + StashID string + Found bool + Record bloomtypes.BloomRecord + RatePct float64 // RateBasisPoints as a percentage (450 -> 4.5) + TargetRatePct float64 // TargetBloomRateBasisPoints as % + MinRatePct float64 + MaxRatePct float64 + AccrualPeriod int64 + MissionLockNote string +} + +// handleBloom renders the Bloom accrual view (REQ-044): per-Stash BloomRecord +// (AccruedGrain, RateBasisPoints as %, LastAccrualBlock) + the 4.5% target rate +// (read from x/bloom/types.TargetBloomRateBasisPoints — D-073 code-constant +// source-of-truth, NOT hardcoded). Bloom is conceptually close to a banned +// financial term; labels use "Bloom"/"real production"/"accrual" only. +func (s *Server) handleBloom(w http.ResponseWriter, r *http.Request) { + stashID := r.PathValue("stashID") + rec, ok := s.Store.GetBloomRecord(stashID) + if !ok { + http.NotFound(w, r) + return + } + s.render(w, "bloom.html", bloomViewData{ + StashID: stashID, + Found: true, + Record: rec, + RatePct: float64(rec.RateBasisPoints) / 100, + TargetRatePct: float64(bloomtypes.TargetBloomRateBasisPoints) / 100, + MinRatePct: float64(bloomtypes.MinBloomRateBasisPoints) / 100, + MaxRatePct: float64(bloomtypes.MaxBloomRateBasisPoints) / 100, + AccrualPeriod: bloomtypes.AccrualPeriodBlocks, + MissionLockNote: bloomtypes.MissionLockBloom, + }) +} diff --git a/web/handlers/bloom_test.go b/web/handlers/bloom_test.go new file mode 100644 index 0000000..0260ecb --- /dev/null +++ b/web/handlers/bloom_test.go @@ -0,0 +1,137 @@ +package handlers + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + + bloomtypes "github.com/oy/openyield/x/bloom/types" +) + +func TestBloomSeededRecordRendersTargetRate(t *testing.T) { + srv := newTestServer(t) + mux := http.NewServeMux() + srv.Register(mux) + rec := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/bloom/stash-holder-alia", nil) + mux.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("GET /bloom/stash-holder-alia: status %d, want 200", rec.Code) + } + body := rec.Body.String() + // Accrued Grain present. + if !strings.Contains(body, "Grain") { + t.Errorf("body missing 'Grain'") + } + // Target rate 4.5% (from TargetBloomRateBasisPoints=450). + want := formatFloat(float64(bloomtypes.TargetBloomRateBasisPoints) / 100) + if !strings.Contains(body, want) { + t.Errorf("body missing target rate %s%% (TargetBloomRateBasisPoints=%d)", want, bloomtypes.TargetBloomRateBasisPoints) + } + // Mission Lock note present. + if !strings.Contains(body, "real production") { + t.Errorf("body missing Mission Lock note about real production") + } + assertNoBannedTerms(t, body) +} + +func TestBloomMissingReturns404(t *testing.T) { + srv := newTestServer(t) + mux := http.NewServeMux() + srv.Register(mux) + rec := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/bloom/stash-nobody", nil) + mux.ServeHTTP(rec, req) + if rec.Code != http.StatusNotFound { + t.Fatalf("GET /bloom/stash-nobody: status %d, want 404", rec.Code) + } + // G-026: rendered-HTML lexicon check on the ERROR response body too. + assertNoBannedTerms(t, rec.Body.String()) +} + +// TestBloomTargetRateFromCodeConstant (D-073 regression guard): asserts the +// handler reads x/bloom/types.TargetBloomRateBasisPoints=450 (NOT a hardcoded +// 450 or a docs value). This test would FAIL if the handler hardcoded the rate +// instead of reading the code constant. +func TestBloomTargetRateFromCodeConstant(t *testing.T) { + // D-073: the code constant is the source of truth. + if bloomtypes.TargetBloomRateBasisPoints != 450 { + t.Fatalf("D-073: TargetBloomRateBasisPoints = %d, want 450 (code constant)", bloomtypes.TargetBloomRateBasisPoints) + } + if bloomtypes.MinBloomRateBasisPoints != 400 { + t.Fatalf("D-073: MinBloomRateBasisPoints = %d, want 400 (code constant)", bloomtypes.MinBloomRateBasisPoints) + } + if bloomtypes.MaxBloomRateBasisPoints != 500 { + t.Fatalf("D-073: MaxBloomRateBasisPoints = %d, want 500 (code constant)", bloomtypes.MaxBloomRateBasisPoints) + } + + srv := newTestServer(t) + mux := http.NewServeMux() + srv.Register(mux) + rec := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/bloom/stash-holder-alia", nil) + mux.ServeHTTP(rec, req) + body := rec.Body.String() + + // The rendered target rate must be the code constant / 100 = 4.5. + wantTarget := formatFloat(float64(bloomtypes.TargetBloomRateBasisPoints) / 100) + if !strings.Contains(body, wantTarget) { + t.Errorf("D-073: body missing target rate %s%% (from code constant %d)", wantTarget, bloomtypes.TargetBloomRateBasisPoints) + } + // The seeded record for holder-alia uses RateBasisPoints=450 (the target). + rec2, ok := srv.Store.GetBloomRecord("stash-holder-alia") + if !ok { + t.Fatal("seeded bloom record stash-holder-alia missing") + } + if rec2.RateBasisPoints != bloomtypes.TargetBloomRateBasisPoints { + t.Errorf("D-073: seeded record RateBasisPoints = %d, want %d (code constant)", rec2.RateBasisPoints, bloomtypes.TargetBloomRateBasisPoints) + } + // The rate band must be rendered from the code constants. + wantMin := formatFloat(float64(bloomtypes.MinBloomRateBasisPoints) / 100) + wantMax := formatFloat(float64(bloomtypes.MaxBloomRateBasisPoints) / 100) + if !strings.Contains(body, wantMin) { + t.Errorf("D-073: body missing min rate %s%% (from code constant)", wantMin) + } + if !strings.Contains(body, wantMax) { + t.Errorf("D-073: body missing max rate %s%% (from code constant)", wantMax) + } +} + +// Compile-time assertion that the handler uses the real x/bloom/types struct. +var _ bloomtypes.BloomRecord + +// formatFloat formats a float to 1 decimal place without importing strconv +// (keeps the test deps minimal; matches the template's printf "%.1f"). +func formatFloat(f float64) string { + // Round to 1 decimal. + rounded := float64(int(f*10+0.5)) / 10 + whole := int(rounded) + frac := int((rounded - float64(whole)) * 10) + if frac == 0 { + return formatInt2(int64(whole)) + ".0" + } + return formatInt2(int64(whole)) + "." + string(rune('0'+frac)) +} + +func formatInt2(n int64) string { + if n == 0 { + return "0" + } + neg := n < 0 + if neg { + n = -n + } + var buf [20]byte + i := len(buf) + for n > 0 { + i-- + buf[i] = byte('0' + n%10) + n /= 10 + } + if neg { + i-- + buf[i] = '-' + } + return string(buf[i:]) +} diff --git a/web/handlers/server.go b/web/handlers/server.go index 3b38ebb..a1f1394 100644 --- a/web/handlers/server.go +++ b/web/handlers/server.go @@ -91,7 +91,7 @@ func (s *Server) Register(mux *http.ServeMux) { s.registerStash(mux) s.registerWindow(mux) s.registerStanding(mux) - // P5 registers bloom. + s.registerBloom(mux) } // render executes the named page template with the given data, writing HTML diff --git a/web/store/fixtures.go b/web/store/fixtures.go index 7ae3e87..5eda588 100644 --- a/web/store/fixtures.go +++ b/web/store/fixtures.go @@ -3,6 +3,7 @@ package store import ( "time" + bloomtypes "github.com/oy/openyield/x/bloom/types" identitytypes "github.com/oy/openyield/x/identity/types" standingtypes "github.com/oy/openyield/x/standing/types" stashtypes "github.com/oy/openyield/x/stash/types" @@ -19,6 +20,25 @@ func (s *Store) seed() { seedOne(s, "holder-alia", "pk-alia-001", now, 920000, 92, 10) seedOne(s, "holder-bryn", "pk-bryn-002", now, 410000, 45, 5) seedStanding(s, now) + seedBloom(s, now) +} + +// seedBloom seeds mock BloomRecords per Stash (P5). holder-alia gets a record +// at the target rate (450 bps = 4.5%); holder-bryn gets a record at 420 bps +// (4.2%, within the 4.0%-5.0% band). AccruedGrain is a mock value. +func seedBloom(s *Store, now int64) { + s.bloomRecords["stash-holder-alia"] = bloomtypes.BloomRecord{ + StashID: "stash-holder-alia", + AccruedGrain: 45000, + LastAccrualBlock: 1000, + RateBasisPoints: bloomtypes.TargetBloomRateBasisPoints, // 450 (4.5%, D-073 code constant) + } + s.bloomRecords["stash-holder-bryn"] = bloomtypes.BloomRecord{ + StashID: "stash-holder-bryn", + AccruedGrain: 18000, + LastAccrualBlock: 1000, + RateBasisPoints: 420, // 4.2% (within the 400-500 band) + } } // seedStanding seeds mock Ratings + Vouches. holder-alia gets 12 ratings diff --git a/web/store/store.go b/web/store/store.go index f397c64..45bbd6a 100644 --- a/web/store/store.go +++ b/web/store/store.go @@ -13,6 +13,7 @@ import ( "sync" "time" + bloomtypes "github.com/oy/openyield/x/bloom/types" identitytypes "github.com/oy/openyield/x/identity/types" standingtypes "github.com/oy/openyield/x/standing/types" stashtypes "github.com/oy/openyield/x/stash/types" @@ -34,6 +35,7 @@ type Store struct { ratings map[string][]standingtypes.Rating vouches map[string][]standingtypes.Vouch slashes map[string][]standingtypes.Slash + bloomRecords map[string]bloomtypes.BloomRecord } // NewStore constructs a Store seeded from fixtures (fixtures.go). @@ -47,6 +49,7 @@ func NewStore() *Store { ratings: map[string][]standingtypes.Rating{}, vouches: map[string][]standingtypes.Vouch{}, slashes: map[string][]standingtypes.Slash{}, + bloomRecords: map[string]bloomtypes.BloomRecord{}, } s.seed() return s @@ -399,3 +402,27 @@ func (s *Store) ComputeFreeholderSignals(reachID string) standingtypes.Freeholde } return signals } + +// --- Bloom accrual (P5) --- + +// GetBloomRecord returns the BloomRecord for a stashID (REQ-044). +func (s *Store) GetBloomRecord(stashID string) (bloomtypes.BloomRecord, bool) { + s.mu.Lock() + defer s.mu.Unlock() + r, ok := s.bloomRecords[stashID] + return r, ok +} + +// ListBloomRecords returns BloomRecords for all Stashes owned by a holder. +func (s *Store) ListBloomRecords(holderID string) []bloomtypes.BloomRecord { + s.mu.Lock() + defer s.mu.Unlock() + out := []bloomtypes.BloomRecord{} + for stashID, rec := range s.bloomRecords { + // Match by the holder prefix "stash-". + if strings.HasPrefix(stashID, "stash-"+holderID) { + out = append(out, rec) + } + } + return out +} diff --git a/web/store/store_test.go b/web/store/store_test.go index 5cd714e..0b61e39 100644 --- a/web/store/store_test.go +++ b/web/store/store_test.go @@ -4,6 +4,7 @@ import ( "sync" "testing" + bloomtypes "github.com/oy/openyield/x/bloom/types" identitytypes "github.com/oy/openyield/x/identity/types" standingtypes "github.com/oy/openyield/x/standing/types" stashtypes "github.com/oy/openyield/x/stash/types" @@ -530,3 +531,46 @@ func TestComputeFreeholderSignalsNoStash(t *testing.T) { t.Errorf("nobody IsFreeholderEligible = true, want false (no Stash)") } } + +// --- Bloom accrual tests (P5) --- + +func TestGetBloomRecordSeeded(t *testing.T) { + s := NewStore() + rec, ok := s.GetBloomRecord("stash-holder-alia") + if !ok { + t.Fatal("GetBloomRecord(stash-holder-alia) miss, want hit (seeded)") + } + // D-073: seeded at the code-constant target rate. + if rec.RateBasisPoints != bloomtypes.TargetBloomRateBasisPoints { + t.Errorf("seeded RateBasisPoints = %d, want %d (TargetBloomRateBasisPoints, D-073)", rec.RateBasisPoints, bloomtypes.TargetBloomRateBasisPoints) + } + if rec.AccruedGrain != 45000 { + t.Errorf("seeded AccruedGrain = %d, want 45000", rec.AccruedGrain) + } +} + +func TestGetBloomRecordMiss(t *testing.T) { + s := NewStore() + if _, ok := s.GetBloomRecord("stash-nobody"); ok { + t.Error("GetBloomRecord(stash-nobody) hit, want miss") + } +} + +func TestListBloomRecordsByHolder(t *testing.T) { + s := NewStore() + alia := s.ListBloomRecords("holder-alia") + if len(alia) != 1 { + t.Errorf("ListBloomRecords(holder-alia) = %d, want 1", len(alia)) + } + if alia[0].StashID != "stash-holder-alia" { + t.Errorf("ListBloomRecords(holder-alia)[0].StashID = %q, want stash-holder-alia", alia[0].StashID) + } + bryn := s.ListBloomRecords("holder-bryn") + if len(bryn) != 1 { + t.Errorf("ListBloomRecords(holder-bryn) = %d, want 1", len(bryn)) + } + nobody := s.ListBloomRecords("nobody") + if len(nobody) != 0 { + t.Errorf("ListBloomRecords(nobody) = %d, want 0", len(nobody)) + } +} diff --git a/web/templates/bloom.html b/web/templates/bloom.html new file mode 100644 index 0000000..561a256 --- /dev/null +++ b/web/templates/bloom.html @@ -0,0 +1,33 @@ +{{define "title"}}Bloom — OpenYield{{end}} + +{{define "content"}} +
+

Bloom

+

Bloom is the real-production reward that accrues to every Grain in every + Stash. It originates only from real production — no synthetic Bloom, no + protocol-printed Bloom. This is a Mission Lock: no council can change it.

+
+ +
+

Bloom record for {{.StashID}}

+ + + + + +
Stash ID{{.StashID}}
Accrued Grain{{.Record.AccruedGrain}}
Rate{{printf "%.1f" .RatePct}}%
Last accrual block{{.Record.LastAccrualBlock}}
+
+ +
+

Target rate band

+ + + + + +
Target rate{{printf "%.1f" .TargetRatePct}}%
Min rate{{printf "%.1f" .MinRatePct}}%
Max rate{{printf "%.1f" .MaxRatePct}}%
Accrual period{{.AccrualPeriod}} blocks (daily, ~10min blocks)
+

{{.MissionLockNote}}

+
+ +

Back to Stash

+{{end}} \ No newline at end of file