Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 30e35ea1c9 | |||
| ef80a8e1c2 | |||
| 56db37a463 |
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"phase": 4,
|
"phase": 5,
|
||||||
"stage": "complete",
|
"stage": "complete",
|
||||||
"milestone": "v0.6",
|
"milestone": "v0.6",
|
||||||
"milestone_type": "feature",
|
"milestone_type": "feature",
|
||||||
@@ -7,7 +7,7 @@
|
|||||||
"phase_role": "execution",
|
"phase_role": "execution",
|
||||||
"project": "oy",
|
"project": "oy",
|
||||||
"attempts": 0,
|
"attempts": 0,
|
||||||
"updated_at": "2026-08-18T14:35:00Z",
|
"updated_at": "2026-08-18T14:50:00Z",
|
||||||
"milestone_complete": false,
|
"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.registerStash(mux)
|
||||||
s.registerWindow(mux)
|
s.registerWindow(mux)
|
||||||
s.registerStanding(mux)
|
s.registerStanding(mux)
|
||||||
// P5 registers bloom.
|
s.registerBloom(mux)
|
||||||
}
|
}
|
||||||
|
|
||||||
// render executes the named page template with the given data, writing HTML
|
// render executes the named page template with the given data, writing HTML
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package store
|
|||||||
import (
|
import (
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
bloomtypes "github.com/oy/openyield/x/bloom/types"
|
||||||
identitytypes "github.com/oy/openyield/x/identity/types"
|
identitytypes "github.com/oy/openyield/x/identity/types"
|
||||||
standingtypes "github.com/oy/openyield/x/standing/types"
|
standingtypes "github.com/oy/openyield/x/standing/types"
|
||||||
stashtypes "github.com/oy/openyield/x/stash/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-alia", "pk-alia-001", now, 920000, 92, 10)
|
||||||
seedOne(s, "holder-bryn", "pk-bryn-002", now, 410000, 45, 5)
|
seedOne(s, "holder-bryn", "pk-bryn-002", now, 410000, 45, 5)
|
||||||
seedStanding(s, now)
|
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
|
// seedStanding seeds mock Ratings + Vouches. holder-alia gets 12 ratings
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
bloomtypes "github.com/oy/openyield/x/bloom/types"
|
||||||
identitytypes "github.com/oy/openyield/x/identity/types"
|
identitytypes "github.com/oy/openyield/x/identity/types"
|
||||||
standingtypes "github.com/oy/openyield/x/standing/types"
|
standingtypes "github.com/oy/openyield/x/standing/types"
|
||||||
stashtypes "github.com/oy/openyield/x/stash/types"
|
stashtypes "github.com/oy/openyield/x/stash/types"
|
||||||
@@ -34,6 +35,7 @@ type Store struct {
|
|||||||
ratings map[string][]standingtypes.Rating
|
ratings map[string][]standingtypes.Rating
|
||||||
vouches map[string][]standingtypes.Vouch
|
vouches map[string][]standingtypes.Vouch
|
||||||
slashes map[string][]standingtypes.Slash
|
slashes map[string][]standingtypes.Slash
|
||||||
|
bloomRecords map[string]bloomtypes.BloomRecord
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewStore constructs a Store seeded from fixtures (fixtures.go).
|
// NewStore constructs a Store seeded from fixtures (fixtures.go).
|
||||||
@@ -47,6 +49,7 @@ func NewStore() *Store {
|
|||||||
ratings: map[string][]standingtypes.Rating{},
|
ratings: map[string][]standingtypes.Rating{},
|
||||||
vouches: map[string][]standingtypes.Vouch{},
|
vouches: map[string][]standingtypes.Vouch{},
|
||||||
slashes: map[string][]standingtypes.Slash{},
|
slashes: map[string][]standingtypes.Slash{},
|
||||||
|
bloomRecords: map[string]bloomtypes.BloomRecord{},
|
||||||
}
|
}
|
||||||
s.seed()
|
s.seed()
|
||||||
return s
|
return s
|
||||||
@@ -399,3 +402,27 @@ func (s *Store) ComputeFreeholderSignals(reachID string) standingtypes.Freeholde
|
|||||||
}
|
}
|
||||||
return signals
|
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"
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
bloomtypes "github.com/oy/openyield/x/bloom/types"
|
||||||
identitytypes "github.com/oy/openyield/x/identity/types"
|
identitytypes "github.com/oy/openyield/x/identity/types"
|
||||||
standingtypes "github.com/oy/openyield/x/standing/types"
|
standingtypes "github.com/oy/openyield/x/standing/types"
|
||||||
stashtypes "github.com/oy/openyield/x/stash/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)")
|
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