Merge phase/05 into milestone/v0.6-nomad-web-ui (P5 complete → v0.5.5)
---ci--- project: oy phase: 5 milestone: v0.6 status: complete requirements: covered: [REQ-040, REQ-041, REQ-042, REQ-043, REQ-044, REQ-045] ---/ci---
This commit is contained in:
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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:])
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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-<holderID>".
|
||||
if strings.HasPrefix(stashID, "stash-"+holderID) {
|
||||
out = append(out, rec)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
{{define "title"}}Bloom — OpenYield{{end}}
|
||||
|
||||
{{define "content"}}
|
||||
<section class="panel">
|
||||
<h1>Bloom</h1>
|
||||
<p>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.</p>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<h2>Bloom record for {{.StashID}}</h2>
|
||||
<table class="kv">
|
||||
<tr><th>Stash ID</th><td>{{.StashID}}</td></tr>
|
||||
<tr><th>Accrued Grain</th><td>{{.Record.AccruedGrain}}</td></tr>
|
||||
<tr><th>Rate</th><td>{{printf "%.1f" .RatePct}}%</td></tr>
|
||||
<tr><th>Last accrual block</th><td>{{.Record.LastAccrualBlock}}</td></tr>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<h2>Target rate band</h2>
|
||||
<table class="kv">
|
||||
<tr><th>Target rate</th><td>{{printf "%.1f" .TargetRatePct}}%</td></tr>
|
||||
<tr><th>Min rate</th><td>{{printf "%.1f" .MinRatePct}}%</td></tr>
|
||||
<tr><th>Max rate</th><td>{{printf "%.1f" .MaxRatePct}}%</td></tr>
|
||||
<tr><th>Accrual period</th><td>{{.AccrualPeriod}} blocks (daily, ~10min blocks)</td></tr>
|
||||
</table>
|
||||
<p><em>{{.MissionLockNote}}</em></p>
|
||||
</section>
|
||||
|
||||
<p><a href="/stash/{{slice .StashID 6}}">Back to Stash</a></p>
|
||||
{{end}}
|
||||
Reference in New Issue
Block a user