56db37a463
web/handlers/bloom.go: GET /bloom/{stashID} renders 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) + the 4.0%-5.0% band
(Min/MaxBloomRateBasisPoints) + Mission Lock note. Store extensions:
GetBloomRecord/ListBloomRecords. Seed: holder-alia at target rate (450
bps), holder-bryn at 420 bps (within band). bloom_test.go: D-073
target-rate source-of-truth regression guard (asserts TargetBloomRate-
BasisPoints=450, Min=400, Max=500 from code constants; would fail if
hardcoded), G-026 error lexicon check, highest-risk screen for banned-
term drift (uses "Bloom"/"real production"/"accrual" labels only).
Coverage: store 98.1%, handlers 89.2%.
---ci---
project: oy
phase: 5
milestone: v0.6
status: execute
---/ci---
429 lines
14 KiB
Go
429 lines
14 KiB
Go
// Package store is the in-memory mock data layer for the OpenYield web UI.
|
|
//
|
|
// It instantiates the real x/*/types structs (Reach, Stash, StashActivity)
|
|
// from in-memory fixtures and provides create/get/list methods. This is the
|
|
// app-layer consumption of protocol types (D-070), NOT a cross-x/ production
|
|
// import — web/ is NOT an x/ module. No keeper, no Cosmos runtime, no app.go
|
|
// (G-003 boundary enforced by import_test.go / G-025).
|
|
package store
|
|
|
|
import (
|
|
"fmt"
|
|
"strings"
|
|
"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"
|
|
windowtypes "github.com/oy/openyield/x/window/types"
|
|
)
|
|
|
|
// seedBalanceGrain is the test balance seeded to a new Stash at signup (D-071
|
|
// example: 500000 Grain = 50 Bread per GrainsPerBread=10000).
|
|
const seedBalanceGrain int64 = 500000
|
|
|
|
// Store is the in-memory mock store. All methods are goroutine-safe (mu).
|
|
type Store struct {
|
|
mu sync.Mutex
|
|
reaches map[string]identitytypes.Reach
|
|
stashes map[string]stashtypes.Stash
|
|
stashActivities map[string]stashtypes.StashActivity
|
|
windows map[string]windowtypes.Window
|
|
auditLogs map[string][]windowtypes.AuditEntry
|
|
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).
|
|
func NewStore() *Store {
|
|
s := &Store{
|
|
reaches: map[string]identitytypes.Reach{},
|
|
stashes: map[string]stashtypes.Stash{},
|
|
stashActivities: map[string]stashtypes.StashActivity{},
|
|
windows: map[string]windowtypes.Window{},
|
|
auditLogs: map[string][]windowtypes.AuditEntry{},
|
|
ratings: map[string][]standingtypes.Rating{},
|
|
vouches: map[string][]standingtypes.Vouch{},
|
|
slashes: map[string][]standingtypes.Slash{},
|
|
bloomRecords: map[string]bloomtypes.BloomRecord{},
|
|
}
|
|
s.seed()
|
|
return s
|
|
}
|
|
|
|
// CreateReach atomically creates a Reach (IsNomad=true) + a Stash (D-071).
|
|
// G-027: HolderID and PublicKey are validated (non-empty, <=128 bytes, no
|
|
// path separators, no template syntax) before any map write. Returns the
|
|
// created Reach + Stash.
|
|
func (s *Store) CreateReach(holderID, publicKey string) (identitytypes.Reach, stashtypes.Stash, error) {
|
|
if err := validateReachInput(holderID, publicKey); err != nil {
|
|
return identitytypes.Reach{}, stashtypes.Stash{}, err
|
|
}
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if _, dup := s.reaches[holderID]; dup {
|
|
return identitytypes.Reach{}, stashtypes.Stash{}, fmt.Errorf("holder %q already has a Reach", holderID)
|
|
}
|
|
now := time.Now().Unix()
|
|
reachID := "reach-" + holderID
|
|
stashID := "stash-" + holderID
|
|
reach := identitytypes.Reach{
|
|
ReachID: reachID,
|
|
HolderID: holderID,
|
|
CreatedAt: now,
|
|
PublicKey: publicKey,
|
|
IsNomad: true,
|
|
}
|
|
stash := stashtypes.Stash{
|
|
HolderID: holderID,
|
|
StashID: stashID,
|
|
CreatedAt: now,
|
|
LastActive: now,
|
|
BalanceGrain: seedBalanceGrain,
|
|
}
|
|
activity := stashtypes.StashActivity{
|
|
StashID: stashID,
|
|
ActiveDays: 1,
|
|
MaxGapDays: 1,
|
|
LastActivityDay: now,
|
|
}
|
|
s.reaches[holderID] = reach
|
|
s.stashes[holderID] = stash
|
|
s.stashActivities[stashID] = activity
|
|
return reach, stash, nil
|
|
}
|
|
|
|
// ListReaches returns all seeded + created Reaches.
|
|
func (s *Store) ListReaches() []identitytypes.Reach {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
out := make([]identitytypes.Reach, 0, len(s.reaches))
|
|
for _, r := range s.reaches {
|
|
out = append(out, r)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// GetReach returns the Reach for a holderID (by HolderID, the stable key).
|
|
func (s *Store) GetReach(holderID string) (identitytypes.Reach, bool) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
r, ok := s.reaches[holderID]
|
|
return r, ok
|
|
}
|
|
|
|
// GetStash returns the Stash for a holderID.
|
|
func (s *Store) GetStash(holderID string) (stashtypes.Stash, bool) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
st, ok := s.stashes[holderID]
|
|
return st, ok
|
|
}
|
|
|
|
// GetStashActivity returns the StashActivity for a stashID.
|
|
func (s *Store) GetStashActivity(stashID string) (stashtypes.StashActivity, bool) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
a, ok := s.stashActivities[stashID]
|
|
return a, ok
|
|
}
|
|
|
|
// OpenWindow creates a new Window in the Open status (REQ-042) with an initial
|
|
// AuditEntry. Returns the created Window. The Window is keyed by a generated
|
|
// windowID derived from the grantor + a counter (mock; not cryptographic).
|
|
func (s *Store) OpenWindow(grantorHolder, grantee string, scope windowtypes.Scope, start, end int64, rateLimit windowtypes.RateLimit) (windowtypes.Window, error) {
|
|
if grantorHolder == "" {
|
|
return windowtypes.Window{}, fmt.Errorf("grantor holder is required")
|
|
}
|
|
if grantee == "" {
|
|
return windowtypes.Window{}, fmt.Errorf("grantee is required")
|
|
}
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
windowID := fmt.Sprintf("window-%s-%d", grantorHolder, len(s.windows)+1)
|
|
now := time.Now().Unix()
|
|
w := windowtypes.Window{
|
|
WindowID: windowID,
|
|
GrantorHolder: grantorHolder,
|
|
Grantee: grantee,
|
|
Scope: scope,
|
|
Start: start,
|
|
End: end,
|
|
RateLimit: rateLimit,
|
|
Status: windowtypes.StatusOpen,
|
|
}
|
|
s.windows[windowID] = w
|
|
entry := windowtypes.AuditEntry{
|
|
EntryID: windowID + "-audit-1",
|
|
Timestamp: now,
|
|
Action: "open",
|
|
Result: "created",
|
|
GranterRef: grantorHolder,
|
|
}
|
|
s.auditLogs[windowID] = []windowtypes.AuditEntry{entry}
|
|
w.AuditLogRefs = []string{entry.EntryID}
|
|
s.windows[windowID] = w
|
|
return w, nil
|
|
}
|
|
|
|
// ActivateWindow transitions a Window from Open to Active by calling the real
|
|
// x/window/types.Window.Activate() method (not a reimplementation). Appends an
|
|
// AuditEntry. Returns an error if the Window is not in the Open status.
|
|
func (s *Store) ActivateWindow(windowID string) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
w, ok := s.windows[windowID]
|
|
if !ok {
|
|
return fmt.Errorf("window %q not found", windowID)
|
|
}
|
|
if err := w.Activate(); err != nil {
|
|
return err
|
|
}
|
|
s.windows[windowID] = w
|
|
s.appendAuditLocked(windowID, "activate", "active", w.GrantorHolder)
|
|
return nil
|
|
}
|
|
|
|
// RevokeWindow transitions a Window to Revoked by calling the real
|
|
// x/window/types.Window.Revoke() method. Idempotent on already-revoked;
|
|
// no-op on Expired (terminal state wins — v0.2 type contract). Appends an
|
|
// AuditEntry only if the status actually changed.
|
|
func (s *Store) RevokeWindow(windowID string) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
w, ok := s.windows[windowID]
|
|
if !ok {
|
|
return fmt.Errorf("window %q not found", windowID)
|
|
}
|
|
prevStatus := w.Status
|
|
if err := w.Revoke(); err != nil {
|
|
return err
|
|
}
|
|
s.windows[windowID] = w
|
|
if w.Status != prevStatus {
|
|
s.appendAuditLocked(windowID, "revoke", "revoked", w.GrantorHolder)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ExpireWindow transitions a Window to Expired by calling the real
|
|
// x/window/types.Window.Expire() method. Appends an AuditEntry.
|
|
func (s *Store) ExpireWindow(windowID string) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
w, ok := s.windows[windowID]
|
|
if !ok {
|
|
return fmt.Errorf("window %q not found", windowID)
|
|
}
|
|
prevStatus := w.Status
|
|
w.Expire()
|
|
s.windows[windowID] = w
|
|
if w.Status != prevStatus {
|
|
s.appendAuditLocked(windowID, "expire", "expired", w.GrantorHolder)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ListWindows returns all Windows for a grantor holder.
|
|
func (s *Store) ListWindows(grantorHolder string) []windowtypes.Window {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
out := []windowtypes.Window{}
|
|
for _, w := range s.windows {
|
|
if w.GrantorHolder == grantorHolder {
|
|
out = append(out, w)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// GetWindow returns the Window for a windowID.
|
|
func (s *Store) GetWindow(windowID string) (windowtypes.Window, bool) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
w, ok := s.windows[windowID]
|
|
return w, ok
|
|
}
|
|
|
|
// GetAuditLog returns the audit-log entries for a windowID.
|
|
func (s *Store) GetAuditLog(windowID string) []windowtypes.AuditEntry {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
return s.auditLogs[windowID]
|
|
}
|
|
|
|
// appendAuditLocked appends an AuditEntry to the window's audit log. Caller
|
|
// MUST hold s.mu.
|
|
func (s *Store) appendAuditLocked(windowID, action, result, granterRef string) {
|
|
logs := s.auditLogs[windowID]
|
|
now := time.Now().Unix()
|
|
entry := windowtypes.AuditEntry{
|
|
EntryID: fmt.Sprintf("%s-audit-%d", windowID, len(logs)+1),
|
|
Timestamp: now,
|
|
Action: action,
|
|
Result: result,
|
|
GranterRef: granterRef,
|
|
}
|
|
s.auditLogs[windowID] = append(logs, entry)
|
|
w := s.windows[windowID]
|
|
w.AuditLogRefs = append(w.AuditLogRefs, entry.EntryID)
|
|
s.windows[windowID] = w
|
|
}
|
|
|
|
// validateReachInput enforces G-027: HolderID and PublicKey must be non-empty,
|
|
// <=128 bytes, and contain no path separators or template syntax. This is a
|
|
// prototype-robustness gate (the mock store uses holderID as a map key).
|
|
func validateReachInput(holderID, publicKey string) error {
|
|
if holderID == "" {
|
|
return fmt.Errorf("holder id is required")
|
|
}
|
|
if len(holderID) > 128 {
|
|
return fmt.Errorf("holder id too long (max 128)")
|
|
}
|
|
if strings.ContainsAny(holderID, "/\\") {
|
|
return fmt.Errorf("holder id must not contain path separators")
|
|
}
|
|
if strings.Contains(holderID, "{{") {
|
|
return fmt.Errorf("holder id must not contain template syntax")
|
|
}
|
|
if publicKey == "" {
|
|
return fmt.Errorf("public key is required")
|
|
}
|
|
if len(publicKey) > 128 {
|
|
return fmt.Errorf("public key too long (max 128)")
|
|
}
|
|
if strings.ContainsAny(publicKey, "/\\") {
|
|
return fmt.Errorf("public key must not contain path separators")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// --- Standing + Freeholder signals (P4) ---
|
|
|
|
// ListRatings returns all Ratings for a ratee (per-Reach).
|
|
func (s *Store) ListRatings(rateeID string) []standingtypes.Rating {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
return s.ratings[rateeID]
|
|
}
|
|
|
|
// ListVouches returns all Vouches for a vouchee.
|
|
func (s *Store) ListVouches(voucheeID string) []standingtypes.Vouch {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
return s.vouches[voucheeID]
|
|
}
|
|
|
|
// ListSlashes returns all Slashes for a Reach.
|
|
func (s *Store) ListSlashes(reachID string) []standingtypes.Slash {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
return s.slashes[reachID]
|
|
}
|
|
|
|
// ComputeStandingScore computes a simplified standing score from the mock
|
|
// Ratings using the locked x/standing/types constants (PriorMean, PriorWeight,
|
|
// ComputeDiversityBonus, GetVoucherWeight, GetStandingBucket). This is a
|
|
// SIMPLIFIED computation (not the full Bayesian formula — sub-tables deferred
|
|
// per PROJECT.md Q2); the test asserts it uses the locked constants, not that
|
|
// it matches a full oracle.
|
|
func (s *Store) ComputeStandingScore(reachID string) (float64, standingtypes.StandingBucket) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
ratings := s.ratings[reachID]
|
|
slashes := s.slashes[reachID]
|
|
isSlashed := len(slashes) > 0
|
|
|
|
if len(ratings) == 0 {
|
|
// No ratings: return the prior mean, bucket New.
|
|
return standingtypes.PriorMean, standingtypes.GetStandingBucket(standingtypes.PriorMean, 0, isSlashed)
|
|
}
|
|
|
|
// Simplified: weighted average of rating scores using GetVoucherWeight.
|
|
// The real formula uses the rater's standing to derive the weight; the
|
|
// mock uses the ratee's own score iteratively (simplified — P4 does not
|
|
// build a full rater-graph). Uses the locked PriorMean + PriorWeight as a
|
|
// Bayesian shrinkage: score = (prior*weight + sum(scores)) / (weight + n).
|
|
sum := 0.0
|
|
categories := map[string]bool{}
|
|
for _, r := range ratings {
|
|
w := standingtypes.GetVoucherWeight(false, r.Score, len(ratings))
|
|
sum += r.Score * w
|
|
categories[r.Category] = true
|
|
}
|
|
n := float64(len(ratings))
|
|
raw := (standingtypes.PriorMean*float64(standingtypes.PriorWeight) + sum) /
|
|
(float64(standingtypes.PriorWeight) + n)
|
|
// Apply diversity bonus (locked const).
|
|
raw += standingtypes.ComputeDiversityBonus(len(categories))
|
|
bucket := standingtypes.GetStandingBucket(raw, len(ratings), isSlashed)
|
|
return raw, bucket
|
|
}
|
|
|
|
// ComputeFreeholderSignals computes the four Freeholder signals (§9.1) from
|
|
// the mock data. StashMaturity from StashActivity.IsMature(); MultiDomainStanding
|
|
// from score >= FreeholderMinStandingScore in >= FreeholderMinCategories;
|
|
// CommittedCapital from Stash balance >= a threshold (mock); CommunityEndorsement
|
|
// from >= 1 Vouch. Returns the real standingtypes.FreeholderSignals struct.
|
|
func (s *Store) ComputeFreeholderSignals(reachID string) standingtypes.FreeholderSignals {
|
|
s.mu.Lock()
|
|
stash, hasStash := s.stashes[reachID]
|
|
ratings := s.ratings[reachID]
|
|
vouches := s.vouches[reachID]
|
|
s.mu.Unlock()
|
|
|
|
var signals standingtypes.FreeholderSignals
|
|
// StashMaturity: from StashActivity.IsMature() (the real method).
|
|
if hasStash {
|
|
if activity, ok := s.GetStashActivity(stash.StashID); ok {
|
|
signals.StashMaturity = activity.IsMature()
|
|
}
|
|
}
|
|
// MultiDomainStanding: score >= 4.5 in >= 3 categories.
|
|
score, _ := s.ComputeStandingScore(reachID)
|
|
categories := map[string]bool{}
|
|
for _, r := range ratings {
|
|
categories[r.Category] = true
|
|
}
|
|
if score >= standingtypes.FreeholderMinStandingScore && len(categories) >= standingtypes.FreeholderMinCategories {
|
|
signals.MultiDomainStanding = true
|
|
}
|
|
// CommittedCapital: mock threshold — Stash balance >= 100000 Grain (10 Bread).
|
|
if hasStash && stash.BalanceGrain >= 100000 {
|
|
signals.CommittedCapital = true
|
|
}
|
|
// CommunityEndorsement: >= 1 Vouch.
|
|
if len(vouches) >= 1 {
|
|
signals.CommunityEndorsement = true
|
|
}
|
|
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
|
|
}
|