From c1aa274ab19e512127a74ffeda1c9899e5be3ed9 Mon Sep 17 00:00:00 2001 From: cloudinit-bot Date: Mon, 17 Aug 2026 22:27:23 +0000 Subject: [PATCH] docs(P05): complete Bearers skeleton II phase P5 complete. Bearers skeleton II (D-020/D-035 pattern, zero ext deps): - x/hub/types (NEW): HubService enum (3), LendingCouponCapBps=800 LOCAL const cross-documented to D-028 (A-304, no x/bond import), ClampLendingCoupon. - x/services/types (NEW): ServiceKind enum (4), window-id + operator-reach-id by-ID-string refs (A-307, G-003). - x/bond/types (EXT): GrowthBond, ClampGrowth with G-012 underflow guard (currentBps >= cap returns 0, no uint32 underflow), OrderSide/OrderStatus/ SecondaryOrder. D-028 regression (800/0 consts unchanged). Coverage: hub 93.3%, services 100%, bond 95.1%. Both firewalls green. G-003 intact. ---ci--- project: oy phase: 5 milestone: v0.3 status: complete tag_base: v0.2.x phase_role: execution requirements: covered: [REQ-024, REQ-025, REQ-026] partial: [] ---/ci--- --- x/bond/types/genesis.go | 90 ++++++ x/bond/types/types.go | 178 ++++++++++- x/bond/types/types_test.go | 533 +++++++++++++++++++++++++++++++++ x/hub/types/types.go | 245 +++++++++++++++ x/hub/types/types_test.go | 424 ++++++++++++++++++++++++++ x/services/types/types.go | 225 ++++++++++++++ x/services/types/types_test.go | 369 +++++++++++++++++++++++ 7 files changed, 2053 insertions(+), 11 deletions(-) create mode 100644 x/hub/types/types.go create mode 100644 x/hub/types/types_test.go create mode 100644 x/services/types/types.go create mode 100644 x/services/types/types_test.go diff --git a/x/bond/types/genesis.go b/x/bond/types/genesis.go index d21e9a5..c26a559 100644 --- a/x/bond/types/genesis.go +++ b/x/bond/types/genesis.go @@ -52,3 +52,93 @@ func knownBondStatus(s BondStatus) bool { } return false } + +// --- v0.3 extension: GrowthBond + Order genesis helpers (REQ-026, G-008) -------- +// +// genesis.go also holds the data-engineer's genesis schema helpers for the +// v0.3 GrowthBond + SecondaryOrder sets (G-008). ValidateGenesis in types.go +// composes ValidateGrowthBonds + ValidateOrders; the security-engineer's test +// assertions live in types_test.go / genesis_test.go. + +// ValidateGrowthBonds asserts growth-bond-ids are present and unique, that +// each embedded Bond's coupon-bps is within the LOCKED [floor, cap] bounds +// (D-028), and that each growth-bond's growth-rate-bps would not push the +// coupon above the cap (ClampGrowth(currentBps=coupon, growth) == growth — +// i.e. the post-growth coupon stays <= cap). The genesis-side clamp is the +// authoritative check (a genesis growth-bond with an out-of-bounds coupon or +// growth rate is rejected rather than silently clamped). +func ValidateGrowthBonds(gbs []GrowthBond) error { + seen := make(map[string]bool, len(gbs)) + for i, gb := range gbs { + if gb.BondID == "" { + return fmt.Errorf("growth bond [%d]: empty bond-id", i) + } + if seen[gb.BondID] { + return fmt.Errorf("growth bond: duplicate bond-id %q", gb.BondID) + } + seen[gb.BondID] = true + if !knownBondStatus(gb.Status) { + return fmt.Errorf("growth bond %q: unknown bond status %q", gb.BondID, gb.Status) + } + // D-028 clamp on the embedded Bond's coupon. + if gb.CouponBps < CouponFloorBps || gb.CouponBps > CouponCapBps { + return fmt.Errorf("growth bond %q: coupon-bps %d outside [%d, %d] (D-028 clamp at genesis load)", + gb.BondID, gb.CouponBps, CouponFloorBps, CouponCapBps) + } + // G-012 / A-306: the growth-rate must not push the coupon above the + // cap. ClampGrowth(coupon, growth) must equal growth (i.e. the + // requested growth fits within the room-to-cap); otherwise the + // genesis growth-bond is rejected as out-of-bounds. + if ClampGrowth(gb.CouponBps, gb.GrowthRateBps) != gb.GrowthRateBps { + return fmt.Errorf("growth bond %q: growth-rate-bps %d would push coupon-bps %d above cap %d (G-012/A-306 clamp at genesis load)", + gb.BondID, gb.GrowthRateBps, gb.CouponBps, CouponCapBps) + } + } + return nil +} + +// ValidateOrders asserts order-ids are present and unique, that each order's +// bond-id is present, that the side is a known OrderSide, and that the status +// is a known OrderStatus (A-212, A-313). +func ValidateOrders(orders []SecondaryOrder) error { + seen := make(map[string]bool, len(orders)) + for i, o := range orders { + if o.OrderID == "" { + return fmt.Errorf("order [%d]: empty order-id", i) + } + if seen[o.OrderID] { + return fmt.Errorf("order: duplicate order-id %q", o.OrderID) + } + seen[o.OrderID] = true + if o.BondID == "" { + return fmt.Errorf("order %q: empty bond-id", o.OrderID) + } + if !knownOrderSide(o.Side) { + return fmt.Errorf("order %q: unknown order side %q", o.OrderID, o.Side) + } + if !knownOrderStatus(o.Status) { + return fmt.Errorf("order %q: unknown order status %q", o.OrderID, o.Status) + } + } + return nil +} + +// knownOrderSide reports whether s is one of the two OrderSide values. +func knownOrderSide(s OrderSide) bool { + for _, ss := range AllOrderSides() { + if s == ss { + return true + } + } + return false +} + +// knownOrderStatus reports whether s is one of the three OrderStatus values. +func knownOrderStatus(s OrderStatus) bool { + for _, ss := range AllOrderStatuses() { + if s == ss { + return true + } + } + return false +} diff --git a/x/bond/types/types.go b/x/bond/types/types.go index 4a5a528..e215ae8 100644 --- a/x/bond/types/types.go +++ b/x/bond/types/types.go @@ -113,26 +113,33 @@ type Params struct{} func DefaultParams() Params { return Params{} } -// GenesisState defines the bond module genesis state (REQ-021). Bonds is the -// top-level set of issued bonds. ValidateGenesis enforces bond-id uniqueness -// and the coupon clamp at genesis load (the data-engineer's genesis.go holds -// the schema helpers per G-008). +// GenesisState defines the bond module genesis state (REQ-021, REQ-026). +// Bonds is the top-level set of issued bonds (v0.2). GrowthBonds (v0.3) and +// Orders (v0.3) extend the genesis with growth bonds and secondary-market +// orders. ValidateGenesis enforces bond-id / growth-bond-id / order-id +// uniqueness and the coupon clamp at genesis load (the data-engineer's +// genesis.go holds the schema helpers per G-008). type GenesisState struct { - Params Params `json:"params" yaml:"params"` - Bonds []Bond `json:"bonds" yaml:"bonds"` + Params Params `json:"params" yaml:"params"` + Bonds []Bond `json:"bonds" yaml:"bonds"` + GrowthBonds []GrowthBond `json:"growth_bonds" yaml:"growth_bonds"` + Orders []SecondaryOrder `json:"orders" yaml:"orders"` } func DefaultGenesisState() *GenesisState { return &GenesisState{ - Params: DefaultParams(), - Bonds: []Bond{}, + Params: DefaultParams(), + Bonds: []Bond{}, + GrowthBonds: []GrowthBond{}, + Orders: []SecondaryOrder{}, } } // ValidateGenesis performs ID-uniqueness checks (A-212 upgrade from v0.1 -// no-op): rejects duplicate bond-ids, and runs the coupon clamp at genesis -// load (each genesis bond's coupon-bps must be within [floor, cap]). Delegates -// to the data-engineer's genesis.go helpers (G-008). +// no-op): rejects duplicate bond-ids / growth-bond-ids / order-ids, and runs +// the coupon clamp at genesis load (each genesis bond's coupon-bps must be +// within [floor, cap]). Delegates to the data-engineer's genesis.go helpers +// (G-008). func ValidateGenesis(bz json.RawMessage) error { var gs GenesisState if err := json.Unmarshal(bz, &gs); err != nil { @@ -141,5 +148,154 @@ func ValidateGenesis(bz json.RawMessage) error { if err := ValidateBonds(gs.Bonds); err != nil { return fmt.Errorf("bond: %w", err) } + if err := ValidateGrowthBonds(gs.GrowthBonds); err != nil { + return fmt.Errorf("bond: %w", err) + } + if err := ValidateOrders(gs.Orders); err != nil { + return fmt.Errorf("bond: %w", err) + } return nil } + +// --- v0.3 extension: GrowthBond + secondary market (REQ-026, D-041, G-012) ------- +// +// The v0.3 bond extension adds GrowthBond (a bond whose coupon grows with +// protocol health, vision §17) and secondary-market order types. The 8%/0% +// consts (D-028) are UNCHANGED — the regression firewall in types_test.go +// asserts CouponCapBps==800 and CouponFloorBps==0 are still the v0.2 values. +// Full secondary-market matching is deferred to v0.4. + +// OrderSideCount is the locked count of OrderSide enum values (vision §17 +// secondary market, A-313). A regression firewall: adding/removing/renaming +// an order side breaks this const's test. +const OrderSideCount = 2 + +// OrderStatusCount is the locked count of OrderStatus enum values (A-313). +const OrderStatusCount = 3 + +// OrderSide enumerates the two sides of a secondary-market order (vision §17, +// REQ-026, A-313): Buy (a bid for a bond), Sell (an ask for a bond). +type OrderSide string + +const ( + OrderBuy OrderSide = "Buy" // bid + OrderSell OrderSide = "Sell" // ask +) + +// AllOrderSides returns both OrderSide values in vision-§17 order. Locked- +// const test asserts exactly 2 entries with these names (A-313). +func AllOrderSides() []OrderSide { + return []OrderSide{ + OrderBuy, + OrderSell, + } +} + +// OrderStatus enumerates the three lifecycle states of a secondary-market +// order (vision §17, REQ-026, A-313): Open (resting on the book), Filled +// (matched and settled), Cancelled (removed by the holder or expired). The +// matching engine is v0.4; v0.3 types the order shape only. +type OrderStatus string + +const ( + OrderOpen OrderStatus = "Open" // resting on the book + OrderFilled OrderStatus = "Filled" // matched and settled + OrderCancelled OrderStatus = "Cancelled" // removed by the holder or expired +) + +// AllOrderStatuses returns all three OrderStatus values in A-313 order. +// Locked-const test asserts exactly 3 entries with these names. +func AllOrderStatuses() []OrderStatus { + return []OrderStatus{ + OrderOpen, + OrderFilled, + OrderCancelled, + } +} + +// ClampGrowth returns the additional bps a GrowthBond's coupon can grow so +// that the post-growth coupon (currentBps + additional) never exceeds +// CouponCapBps (D-028, A-306, G-012). The "post-growth coupon <= cap" +// invariant holds UNCONDITIONALLY. +// +// G-012 BINDING: ClampGrowth MUST guard currentBps > CouponCapBps BEFORE +// computing cap - current. The naive `min(cap - current, growth)` underflows +// uint32 when current > cap (cap - current wraps to a huge value, then min +// picks growthBps — the invariant is violated). This implementation guards +// explicitly: +// - If currentBps >= CouponCapBps: return 0 (no room to grow; the cap is +// already reached or exceeded — the post-growth coupon cannot grow +// without breaching the cap). +// - Otherwise: return min(CouponCapBps - currentBps, growthBps) (the room- +// to-cap, clamped by the requested growth). +// +// The two G-012-mandated test cases are: currentBps == CouponCapBps (return 0, +// the at-cap boundary) and currentBps > CouponCapBps (return 0, the guard +// against uint32 underflow — NOT a wrapped huge value). +func ClampGrowth(currentBps, growthBps uint32) uint32 { + // G-012 guard: at-or-above cap means no room to grow. This MUST be checked + // before the cap - current subtraction to avoid uint32 underflow when + // currentBps > cap. + if currentBps >= CouponCapBps { + return 0 + } + // currentBps < cap is guaranteed here; cap - current does not underflow. + room := CouponCapBps - currentBps + if growthBps < room { + return growthBps + } + return room +} + +// GrowthBond is a bond whose coupon grows with protocol health (vision §17, +// REQ-026, D-041, A-306). It embeds the v0.2 Bond (anonymous field) so it +// carries all Bond fields (bond-id, issuer-stand-id, principal-grain, +// coupon-bps, term-days, issued-at, maturity, status) PLUS a GrowthRateBps +// field (the per-period growth rate of the coupon, in bps). The growth rate +// is clamped at issuance so that the post-growth coupon never exceeds +// CouponCapBps (800 bps) — see IssueGrowth, which clamps couponBps via Clamp +// and growthRateBps via ClampGrowth (with currentBps=couponBps). +// +// The 8%/0% consts (D-028) apply to GrowthBonds too: the growth coupon is +// clamped to [0, 800] bps at any point. GrowthBond is in the same package as +// Bond (no G-003 concern for the Clamp/ClampGrowth reuse). +type GrowthBond struct { + Bond // anonymous embed — carries all v0.2 Bond fields + GrowthRateBps uint32 `json:"growth_rate_bps" yaml:"growth_rate_bps"` +} + +// IssueGrowth is the GrowthBond issuance stub (REQ-026, D-041). It constructs a +// GrowthBond with the coupon clamped to [CouponFloorBps, CouponCapBps] via +// Clamp, and the growth-rate clamped so that coupon + growth never exceeds +// CouponCapBps via ClampGrowth (with currentBps=couponBps). The returned +// GrowthBond has status BondIssued (inherited from Issue's Bond construction). +// The stub does not persist or enforce referential integrity of issuer-stand- +// id (a v0.4 keeper concern); it only enforces the coupon + growth clamp +// invariants at construction time. +func IssueGrowth(bondID, issuerStandID string, principalGrain int64, couponBps, growthRateBps uint32, termDays uint32, issuedAt, maturity int64) GrowthBond { + clampedCoupon := Clamp(couponBps) + clampedGrowth := ClampGrowth(clampedCoupon, growthRateBps) + return GrowthBond{ + Bond: Issue(bondID, issuerStandID, principalGrain, clampedCoupon, termDays, issuedAt, maturity), + GrowthRateBps: clampedGrowth, + } +} + +// SecondaryOrder is a secondary-market order on an issued bond (vision §17, +// REQ-026, D-041, A-313). order-id is the unique identifier. bond-id references +// a Bond (by-ID-string ref to a Bond — same package, so this is an in-package +// ID-string ref, not a cross-module G-003 concern). side picks OrderSide +// (Buy/Sell). price-grain is the order price in Grain (fraction of principal, +// expressed in Grain for fixed-point precision). holder-reach-id references +// an x/identity Reach by ID-string (G-003 — use "holder-reach-id" not the +// banned Holder-identity term). status is the OrderStatus. created-at is the +// unix timestamp. +type SecondaryOrder struct { + OrderID string `json:"order_id" yaml:"order_id"` + BondID string `json:"bond_id" yaml:"bond_id"` + Side OrderSide `json:"side" yaml:"side"` + PriceGrain int64 `json:"price_grain" yaml:"price_grain"` + HolderReachID string `json:"holder_reach_id" yaml:"holder_reach_id"` + Status OrderStatus `json:"status" yaml:"status"` + CreatedAt int64 `json:"created_at" yaml:"created_at"` +} diff --git a/x/bond/types/types_test.go b/x/bond/types/types_test.go index e3853b2..200915a 100644 --- a/x/bond/types/types_test.go +++ b/x/bond/types/types_test.go @@ -416,6 +416,539 @@ func TestLexiconNoBannedTermsInBondTestFile(t *testing.T) { } } +// --- v0.3 extension: ClampGrowth (G-012 BINDING) --------------------------------- +// ClampGrowth is the G-012 binding decision: it MUST guard currentBps > +// CouponCapBps before computing cap - current, otherwise the uint32 +// subtraction underflows (cap - current wraps to a huge value, then min picks +// growthBps — the post-growth coupon invariant is violated). These tests are +// written FIRST (TDD) to confirm the guard works before the function existed; +// they are the highest-severity v0.3 bond firewall. +// +// The five G-012-mandated test cases: +// 1. currentBps == 0 (full growth room) +// 2. currentBps == CouponCapBps (no room, return 0 — the at-cap boundary) +// 3. currentBps > CouponCapBps (the underflow GUARD — return 0, NOT a wrapped +// huge value) +// 4. growthBps larger than room (clamp to room) +// 5. growthBps smaller than room (return growthBps) + +// TestClampGrowthCurrentZeroFullRoom asserts case 1: currentBps == 0 leaves +// the full room to the cap; the growth is clamped to min(cap, growth). +func TestClampGrowthCurrentZeroFullRoom(t *testing.T) { + // growth < cap (room) -> return growth + if got := btypes.ClampGrowth(0, 500); got != 500 { + t.Errorf("ClampGrowth(0, 500) = %d, expected 500 (full room, growth < cap)", got) + } + // growth == cap (room) -> return cap (room) + if got := btypes.ClampGrowth(0, btypes.CouponCapBps); got != btypes.CouponCapBps { + t.Errorf("ClampGrowth(0, cap) = %d, expected cap %d (full room, growth == cap)", got, btypes.CouponCapBps) + } + // growth > cap (room) -> return cap (room) + if got := btypes.ClampGrowth(0, 1000); got != btypes.CouponCapBps { + t.Errorf("ClampGrowth(0, 1000) = %d, expected cap %d (full room, growth > cap clamps to cap)", got, btypes.CouponCapBps) + } +} + +// TestClampGrowthCurrentAtCapReturnsZero asserts case 2: currentBps == +// CouponCapBps (the at-cap boundary). There is no room to grow; return 0. +// This is the G-012-mandated at-cap test. +func TestClampGrowthCurrentAtCapReturnsZero(t *testing.T) { + got := btypes.ClampGrowth(btypes.CouponCapBps, 100) + if got != 0 { + t.Errorf("ClampGrowth(cap, 100) = %d, expected 0 (at-cap boundary — no room to grow, G-012)", got) + } +} + +// TestClampGrowthCurrentAboveCapReturnsZero asserts case 3: currentBps > +// CouponCapBps (the uint32 underflow GUARD). The naive min(cap-current, +// growth) would underflow uint32 (cap-current wraps to a huge value, then min +// picks growth — invariant violated). ClampGrowth MUST return 0, NOT a +// wrapped huge value. This is the G-012-mandated above-cap test. +func TestClampGrowthCurrentAboveCapReturnsZero(t *testing.T) { + cases := []struct { + current uint32 + growth uint32 + }{ + {uint32(btypes.CouponCapBps) + 1, 100}, + {uint32(btypes.CouponCapBps) + 100, 500}, + {uint32(btypes.CouponCapBps) + 1000, 50}, + {5000, 100}, + {100_000, 1}, + } + for _, c := range cases { + got := btypes.ClampGrowth(c.current, c.growth) + if got != 0 { + t.Errorf("ClampGrowth(%d, %d) = %d, expected 0 (above-cap GUARD — uint32 underflow must NOT happen, G-012)", + c.current, c.growth, got) + } + } +} + +// TestClampGrowthGrowthLargerThanRoomClampsToRoom asserts case 4: growthBps +// larger than the room-to-cap is clamped to the room. +func TestClampGrowthGrowthLargerThanRoomClampsToRoom(t *testing.T) { + // current=500, cap=800, room=300. growth=400 > room -> return 300. + got := btypes.ClampGrowth(500, 400) + if got != 300 { + t.Errorf("ClampGrowth(500, 400) = %d, expected 300 (growth larger than room clamps to room)", got) + } + // current=799, cap=800, room=1. growth=50 > room -> return 1. + got = btypes.ClampGrowth(799, 50) + if got != 1 { + t.Errorf("ClampGrowth(799, 50) = %d, expected 1 (room=1, growth clamps to room)", got) + } +} + +// TestClampGrowthGrowthSmallerThanRoomReturnsGrowth asserts case 5: growthBps +// smaller than the room-to-cap is returned unchanged. +func TestClampGrowthGrowthSmallerThanRoomReturnsGrowth(t *testing.T) { + // current=500, cap=800, room=300. growth=200 < room -> return 200. + got := btypes.ClampGrowth(500, 200) + if got != 200 { + t.Errorf("ClampGrowth(500, 200) = %d, expected 200 (growth < room, unchanged)", got) + } + // current=0, cap=800, room=800. growth=100 < room -> return 100. + got = btypes.ClampGrowth(0, 100) + if got != 100 { + t.Errorf("ClampGrowth(0, 100) = %d, expected 100 (growth < room, unchanged)", got) + } +} + +// TestClampGrowthInvariantPostGrowthLeCap is the meta-assert: ClampGrowth +// never ADDS growth that would push the post-growth coupon past the cap. The +// invariant is: current + ClampGrowth(current, growth) <= max(current, cap). +// When current <= cap, this means post-growth <= cap (no growth past the +// cap). When current > cap (the G-012 misuse/guard case), ClampGrowth returns +// 0 (no additional growth), so post == current (the already-broken state is +// not made worse; the guard prevents the uint32 underflow from adding a +// wrapped-huge value as growth). +func TestClampGrowthInvariantPostGrowthLeCap(t *testing.T) { + cases := []struct { + current uint32 + growth uint32 + }{ + {0, 0}, + {0, 800}, + {0, 1000}, + {400, 400}, + {400, 500}, + {799, 1}, + {799, 100}, + {800, 100}, // at-cap + {801, 100}, // above-cap (guard) + {5000, 1000}, // way above-cap (guard) + } + for _, c := range cases { + got := btypes.ClampGrowth(c.current, c.growth) + post := c.current + got + // The bound: post <= max(current, cap). When current <= cap, this is + // post <= cap (no growth past the cap). When current > cap, this is + // post <= current (no additional growth — the guard returned 0). + upper := c.current + if uint32(btypes.CouponCapBps) > upper { + upper = btypes.CouponCapBps + } + if post > upper { + t.Errorf("ClampGrowth(%d, %d) = %d; post-growth coupon %d > %d (G-012 invariant violated)", + c.current, c.growth, got, post, upper) + } + // Stronger assert for the in-bounds case: when current <= cap, post + // must be <= cap exactly (no growth past the cap). + if c.current <= btypes.CouponCapBps && post > btypes.CouponCapBps { + t.Errorf("ClampGrowth(%d, %d) = %d; post-growth coupon %d > cap %d (in-bounds invariant violated)", + c.current, c.growth, got, post, btypes.CouponCapBps) + } + } +} + +// --- D-028 regression: 8%/0% consts unchanged (v0.3 must not change v0.2) ------- +// These tests are re-declared here in the v0.3 block to make the regression +// firewall explicit in the extension context. The v0.2 tests above +// (TestCouponCapBpsLockedConst / TestCouponFloorBpsLockedConst) are the +// primary firewall; this block re-asserts in the v0.3 extension context. + +// TestD028RegressionCouponCapUnchanged asserts CouponCapBps is still 800 +// after the v0.3 GrowthBond extension (D-028 regression firewall). +func TestD028RegressionCouponCapUnchanged(t *testing.T) { + if btypes.CouponCapBps != 800 { + t.Errorf("D-028 regression: CouponCapBps = %d, expected 800 (v0.3 must not change v0.2 const)", btypes.CouponCapBps) + } +} + +// TestD028RegressionCouponFloorUnchanged asserts CouponFloorBps is still 0. +func TestD028RegressionCouponFloorUnchanged(t *testing.T) { + if btypes.CouponFloorBps != 0 { + t.Errorf("D-028 regression: CouponFloorBps = %d, expected 0 (v0.3 must not change v0.2 const)", btypes.CouponFloorBps) + } +} + +// TestD028RegressionBondStatusCountUnchanged asserts BondStatusCount is still +// 5 (the v0.2 enum is unchanged by the v0.3 extension). +func TestD028RegressionBondStatusCountUnchanged(t *testing.T) { + if btypes.BondStatusCount != 5 { + t.Errorf("D-028 regression: BondStatusCount = %d, expected 5 (v0.2 enum unchanged)", btypes.BondStatusCount) + } +} + +// --- OrderSide enum coverage (2) ---------------------------------------------- + +// TestOrderSideCountLockedConst asserts OrderSideCount == 2 and AllOrderSides() +// returns exactly 2 (A-313). A regression firewall. +func TestOrderSideCountLockedConst(t *testing.T) { + if btypes.OrderSideCount != 2 { + t.Errorf("OrderSideCount = %d, expected 2 (A-313 LOCKED)", btypes.OrderSideCount) + } + all := btypes.AllOrderSides() + if len(all) != 2 { + t.Errorf("AllOrderSides() len = %d, expected 2", len(all)) + } +} + +// TestAllOrderSidesNames asserts the 2 A-313 names in order with no extras, no +// dups, no renames. +func TestAllOrderSidesNames(t *testing.T) { + want := []string{"Buy", "Sell"} + all := btypes.AllOrderSides() + if len(all) != len(want) { + t.Fatalf("len = %d, want %d", len(all), len(want)) + } + seen := map[string]bool{} + for i, s := range all { + if string(s) != want[i] { + t.Errorf("AllOrderSides()[%d] = %q, want %q", i, s, want[i]) + } + if seen[string(s)] { + t.Errorf("duplicate OrderSide %q", s) + } + seen[string(s)] = true + } +} + +// TestOrderSideValues asserts each named const matches its AllOrderSides entry. +func TestOrderSideValues(t *testing.T) { + if btypes.OrderBuy != "Buy" { + t.Errorf("OrderBuy = %q", btypes.OrderBuy) + } + if btypes.OrderSell != "Sell" { + t.Errorf("OrderSell = %q", btypes.OrderSell) + } +} + +// --- OrderStatus enum coverage (3) ------------------------------------------- + +// TestOrderStatusCountLockedConst asserts OrderStatusCount == 3 and +// AllOrderStatuses() returns exactly 3 (A-313). A regression firewall. +func TestOrderStatusCountLockedConst(t *testing.T) { + if btypes.OrderStatusCount != 3 { + t.Errorf("OrderStatusCount = %d, expected 3 (A-313 LOCKED)", btypes.OrderStatusCount) + } + all := btypes.AllOrderStatuses() + if len(all) != 3 { + t.Errorf("AllOrderStatuses() len = %d, expected 3", len(all)) + } +} + +// TestAllOrderStatusesNames asserts the 3 A-313 names in order with no extras, +// no dups, no renames. +func TestAllOrderStatusesNames(t *testing.T) { + want := []string{"Open", "Filled", "Cancelled"} + all := btypes.AllOrderStatuses() + if len(all) != len(want) { + t.Fatalf("len = %d, want %d", len(all), len(want)) + } + seen := map[string]bool{} + for i, s := range all { + if string(s) != want[i] { + t.Errorf("AllOrderStatuses()[%d] = %q, want %q", i, s, want[i]) + } + if seen[string(s)] { + t.Errorf("duplicate OrderStatus %q", s) + } + seen[string(s)] = true + } +} + +// TestOrderStatusValues asserts each named const matches its AllOrderStatuses +// entry. +func TestOrderStatusValues(t *testing.T) { + if btypes.OrderOpen != "Open" { + t.Errorf("OrderOpen = %q", btypes.OrderOpen) + } + if btypes.OrderFilled != "Filled" { + t.Errorf("OrderFilled = %q", btypes.OrderFilled) + } + if btypes.OrderCancelled != "Cancelled" { + t.Errorf("OrderCancelled = %q", btypes.OrderCancelled) + } +} + +// --- GrowthBond + IssueGrowth -------------------------------------------------- + +// TestGrowthBondStructFields asserts GrowthBond embeds Bond and adds +// GrowthRateBps. +func TestGrowthBondStructFields(t *testing.T) { + gb := btypes.GrowthBond{ + Bond: btypes.Bond{BondID: "gb-1", IssuerStandID: "stand-1", PrincipalGrain: 1_000_000, CouponBps: 500, TermDays: 365, IssuedAt: 1000, Maturity: 1365, Status: btypes.BondIssued}, + GrowthRateBps: 200, + } + if gb.BondID != "gb-1" || gb.IssuerStandID != "stand-1" || gb.PrincipalGrain != 1_000_000 || + gb.CouponBps != 500 || gb.TermDays != 365 || gb.IssuedAt != 1000 || gb.Maturity != 1365 || + gb.Status != btypes.BondIssued || gb.GrowthRateBps != 200 { + t.Error("GrowthBond fields not set correctly") + } + // The embedded Bond is accessible via the anonymous field. + if gb.Bond.BondID != "gb-1" { + t.Errorf("embedded Bond.BondID = %q", gb.Bond.BondID) + } +} + +// TestIssueGrowthConstruction asserts IssueGrowth clamps the coupon via Clamp +// and the growth-rate via ClampGrowth, and returns status BondIssued. +func TestIssueGrowthConstruction(t *testing.T) { + // In-range coupon and growth: both unchanged. + gb := btypes.IssueGrowth("gb-2", "stand-1", 1_000_000, 500, 200, 365, 1000, 1365) + if gb.BondID != "gb-2" { + t.Errorf("BondID = %q", gb.BondID) + } + if gb.CouponBps != 500 { + t.Errorf("CouponBps = %d, expected 500 (in-range, unchanged)", gb.CouponBps) + } + if gb.GrowthRateBps != 200 { + t.Errorf("GrowthRateBps = %d, expected 200 (in-range, growth < room)", gb.GrowthRateBps) + } + if gb.Status != btypes.BondIssued { + t.Errorf("Status = %q, expected BondIssued", gb.Status) + } +} + +// TestIssueGrowthClampsAboveCapCoupon asserts IssueGrowth clamps an above-cap +// coupon down to the cap (via Clamp), and the growth-rate is then clamped +// against the clamped coupon (currentBps=cap -> growth returns 0, G-012). +func TestIssueGrowthClampsAboveCapCoupon(t *testing.T) { + gb := btypes.IssueGrowth("gb-3", "stand-1", 1_000_000, 1200, 100, 365, 1000, 1365) + if gb.CouponBps != btypes.CouponCapBps { + t.Errorf("CouponBps = %d, expected cap %d (IssueGrowth must clamp above-cap coupon)", gb.CouponBps, btypes.CouponCapBps) + } + // coupon clamped to cap -> ClampGrowth(cap, 100) == 0 (no room, G-012). + if gb.GrowthRateBps != 0 { + t.Errorf("GrowthRateBps = %d, expected 0 (coupon at cap -> no room, G-012)", gb.GrowthRateBps) + } +} + +// TestIssueGrowthClampsGrowthToRoom asserts IssueGrowth clamps a growth-rate +// that would push the coupon above the cap down to the room-to-cap. +func TestIssueGrowthClampsGrowthToRoom(t *testing.T) { + // coupon=500, cap=800, room=300. growth=400 -> clamped to 300. + gb := btypes.IssueGrowth("gb-4", "stand-1", 1_000_000, 500, 400, 365, 1000, 1365) + if gb.CouponBps != 500 { + t.Errorf("CouponBps = %d, expected 500", gb.CouponBps) + } + if gb.GrowthRateBps != 300 { + t.Errorf("GrowthRateBps = %d, expected 300 (growth clamped to room, G-012)", gb.GrowthRateBps) + } + // post-growth coupon: 500 + 300 = 800 == cap (invariant holds). + if gb.CouponBps+gb.GrowthRateBps > btypes.CouponCapBps { + t.Errorf("post-growth coupon %d > cap %d (G-012 invariant)", gb.CouponBps+gb.GrowthRateBps, btypes.CouponCapBps) + } +} + +// --- SecondaryOrder struct ---------------------------------------------------- + +// TestSecondaryOrderStructFields asserts SecondaryOrder carries order-id, +// bond-id (by-ID-string ref to a Bond — in-package), side, price-grain, +// holder-reach-id (by-ID-string ref to x/identity — G-003), status, created-at. +func TestSecondaryOrderStructFields(t *testing.T) { + o := btypes.SecondaryOrder{ + OrderID: "order-1", + BondID: "bond-1", + Side: btypes.OrderBuy, + PriceGrain: 950_000, + HolderReachID: "reach-holder-1", + Status: btypes.OrderOpen, + CreatedAt: 5000, + } + if o.OrderID != "order-1" || o.BondID != "bond-1" || o.Side != btypes.OrderBuy || + o.PriceGrain != 950_000 || o.HolderReachID != "reach-holder-1" || + o.Status != btypes.OrderOpen || o.CreatedAt != 5000 { + t.Error("SecondaryOrder fields not set correctly") + } +} + +// TestSecondaryOrderBondIDIsString asserts bond-id is string-typed (in-package +// by-ID-string ref to a Bond — same package, not a G-003 cross-module import). +func TestSecondaryOrderBondIDIsString(t *testing.T) { + o := btypes.SecondaryOrder{BondID: "bond-xyz"} + if o.BondID != "bond-xyz" { + t.Errorf("BondID = %q", o.BondID) + } +} + +// TestSecondaryOrderHolderReachIDIsString asserts holder-reach-id is +// string-typed (G-003 by-ID-string ref to x/identity Reach — no struct import). +func TestSecondaryOrderHolderReachIDIsString(t *testing.T) { + o := btypes.SecondaryOrder{HolderReachID: "reach-abc"} + if o.HolderReachID != "reach-abc" { + t.Errorf("HolderReachID = %q", o.HolderReachID) + } +} + +// --- Genesis v0.3 extension: GrowthBonds + Orders ----------------------------- + +// TestDefaultGenesisStateV3Empty asserts DefaultGenesisState returns non-nil +// empty slices for the v0.3 GrowthBonds and Orders sets. +func TestDefaultGenesisStateV3Empty(t *testing.T) { + gs := btypes.DefaultGenesisState() + if gs.GrowthBonds == nil || len(gs.GrowthBonds) != 0 { + t.Errorf("Default GrowthBonds should be non-nil empty slice; got len=%d nil=%v", len(gs.GrowthBonds), gs.GrowthBonds == nil) + } + if gs.Orders == nil || len(gs.Orders) != 0 { + t.Errorf("Default Orders should be non-nil empty slice; got len=%d nil=%v", len(gs.Orders), gs.Orders == nil) + } +} + +// TestValidateGenesisRejectsDupGrowthBondIDs asserts A-212: duplicate +// growth-bond-ids are rejected. +func TestValidateGenesisRejectsDupGrowthBondIDs(t *testing.T) { + gs := btypes.GenesisState{ + GrowthBonds: []btypes.GrowthBond{ + {Bond: btypes.Bond{BondID: "gb1", IssuerStandID: "s1", CouponBps: 500, Status: btypes.BondIssued}, GrowthRateBps: 100}, + {Bond: btypes.Bond{BondID: "gb1", IssuerStandID: "s2", CouponBps: 200, Status: btypes.BondActive}, GrowthRateBps: 50}, // dup + }, + } + bz, _ := json.Marshal(gs) + if err := btypes.ValidateGenesis(bz); err == nil { + t.Error("ValidateGenesis should reject duplicate growth-bond-ids") + } +} + +// TestValidateGenesisRejectsGrowthBondCouponAboveCap asserts a genesis +// GrowthBond with coupon-bps above the cap is rejected (D-028 at genesis). +func TestValidateGenesisRejectsGrowthBondCouponAboveCap(t *testing.T) { + gs := btypes.GenesisState{ + GrowthBonds: []btypes.GrowthBond{ + {Bond: btypes.Bond{BondID: "gb1", IssuerStandID: "s1", CouponBps: 900, Status: btypes.BondIssued}, GrowthRateBps: 0}, + }, + } + bz, _ := json.Marshal(gs) + if err := btypes.ValidateGenesis(bz); err == nil { + t.Error("ValidateGenesis should reject growth-bond coupon above cap (D-028)") + } +} + +// TestValidateGenesisRejectsGrowthBondGrowthAboveRoom asserts a genesis +// GrowthBond whose growth-rate would push the coupon above the cap is +// rejected (G-012 / A-306 at genesis). +func TestValidateGenesisRejectsGrowthBondGrowthAboveRoom(t *testing.T) { + gs := btypes.GenesisState{ + GrowthBonds: []btypes.GrowthBond{ + // coupon=500, cap=800, room=300. growth=400 -> would push to 900 > cap. + {Bond: btypes.Bond{BondID: "gb1", IssuerStandID: "s1", CouponBps: 500, Status: btypes.BondIssued}, GrowthRateBps: 400}, + }, + } + bz, _ := json.Marshal(gs) + if err := btypes.ValidateGenesis(bz); err == nil { + t.Error("ValidateGenesis should reject growth-bond growth-rate above room (G-012/A-306)") + } +} + +// TestValidateGenesisRejectsDupOrderIDs asserts A-212: duplicate order-ids are +// rejected. +func TestValidateGenesisRejectsDupOrderIDs(t *testing.T) { + gs := btypes.GenesisState{ + Orders: []btypes.SecondaryOrder{ + {OrderID: "o1", BondID: "b1", Side: btypes.OrderBuy, Status: btypes.OrderOpen}, + {OrderID: "o1", BondID: "b2", Side: btypes.OrderSell, Status: btypes.OrderOpen}, // dup + }, + } + bz, _ := json.Marshal(gs) + if err := btypes.ValidateGenesis(bz); err == nil { + t.Error("ValidateGenesis should reject duplicate order-ids") + } +} + +// TestValidateGenesisRejectsEmptyOrderBondID asserts an order with an empty +// bond-id is rejected. +func TestValidateGenesisRejectsEmptyOrderBondID(t *testing.T) { + gs := btypes.GenesisState{ + Orders: []btypes.SecondaryOrder{{OrderID: "o1", BondID: "", Side: btypes.OrderBuy, Status: btypes.OrderOpen}}, + } + bz, _ := json.Marshal(gs) + if err := btypes.ValidateGenesis(bz); err == nil { + t.Error("ValidateGenesis should reject empty order bond-id") + } +} + +// TestValidateGenesisRejectsUnknownOrderSide asserts an unknown OrderSide is +// rejected. +func TestValidateGenesisRejectsUnknownOrderSide(t *testing.T) { + gs := btypes.GenesisState{ + Orders: []btypes.SecondaryOrder{{OrderID: "o1", BondID: "b1", Side: btypes.OrderSide("Bogus"), Status: btypes.OrderOpen}}, + } + bz, _ := json.Marshal(gs) + if err := btypes.ValidateGenesis(bz); err == nil { + t.Error("ValidateGenesis should reject unknown order side") + } +} + +// TestValidateGenesisRejectsUnknownOrderStatus asserts an unknown OrderStatus +// is rejected. +func TestValidateGenesisRejectsUnknownOrderStatus(t *testing.T) { + gs := btypes.GenesisState{ + Orders: []btypes.SecondaryOrder{{OrderID: "o1", BondID: "b1", Side: btypes.OrderBuy, Status: btypes.OrderStatus("Bogus")}}, + } + bz, _ := json.Marshal(gs) + if err := btypes.ValidateGenesis(bz); err == nil { + t.Error("ValidateGenesis should reject unknown order status") + } +} + +// TestValidateGenesisAcceptsCleanV3 asserts a clean v0.3 genesis (bonds + +// growth bonds + orders) validates. +func TestValidateGenesisAcceptsCleanV3(t *testing.T) { + gs := btypes.GenesisState{ + Bonds: []btypes.Bond{ + {BondID: "b1", IssuerStandID: "s1", CouponBps: 100, Status: btypes.BondIssued}, + }, + GrowthBonds: []btypes.GrowthBond{ + {Bond: btypes.Bond{BondID: "gb1", IssuerStandID: "s1", CouponBps: 500, Status: btypes.BondIssued}, GrowthRateBps: 200}, + {Bond: btypes.Bond{BondID: "gb2", IssuerStandID: "s1", CouponBps: 800, Status: btypes.BondActive}, GrowthRateBps: 0}, + }, + Orders: []btypes.SecondaryOrder{ + {OrderID: "o1", BondID: "b1", Side: btypes.OrderBuy, PriceGrain: 950_000, HolderReachID: "r1", Status: btypes.OrderOpen, CreatedAt: 1000}, + {OrderID: "o2", BondID: "gb1", Side: btypes.OrderSell, PriceGrain: 1_050_000, HolderReachID: "r2", Status: btypes.OrderFilled, CreatedAt: 2000}, + }, + } + bz, _ := json.Marshal(gs) + if err := btypes.ValidateGenesis(bz); err != nil { + t.Errorf("ValidateGenesis should accept clean v0.3 genesis, got: %v", err) + } +} + +// TestValidateGrowthBondsAcceptsClean asserts the data-engineer's +// ValidateGrowthBonds helper accepts a clean set. +func TestValidateGrowthBondsAcceptsClean(t *testing.T) { + gbs := []btypes.GrowthBond{ + {Bond: btypes.Bond{BondID: "gb1", CouponBps: 0, Status: btypes.BondIssued}, GrowthRateBps: 800}, + {Bond: btypes.Bond{BondID: "gb2", CouponBps: 500, Status: btypes.BondActive}, GrowthRateBps: 300}, + {Bond: btypes.Bond{BondID: "gb3", CouponBps: 800, Status: btypes.BondMatured}, GrowthRateBps: 0}, + } + if err := btypes.ValidateGrowthBonds(gbs); err != nil { + t.Errorf("ValidateGrowthBonds should accept clean set; got: %v", err) + } +} + +// TestValidateOrdersAcceptsClean asserts ValidateOrders accepts a clean set. +func TestValidateOrdersAcceptsClean(t *testing.T) { + orders := []btypes.SecondaryOrder{ + {OrderID: "o1", BondID: "b1", Side: btypes.OrderBuy, Status: btypes.OrderOpen}, + {OrderID: "o2", BondID: "b1", Side: btypes.OrderSell, Status: btypes.OrderFilled}, + {OrderID: "o3", BondID: "b2", Side: btypes.OrderBuy, Status: btypes.OrderCancelled}, + } + if err := btypes.ValidateOrders(orders); err != nil { + t.Errorf("ValidateOrders should accept clean set; got: %v", err) + } +} + // packageDir resolves a Go import path to its filesystem directory by // walking up from this test file (v0.2 skeleton has zero external deps). func packageDir(t *testing.T, importPath string) string { diff --git a/x/hub/types/types.go b/x/hub/types/types.go new file mode 100644 index 0000000..d49f6a4 --- /dev/null +++ b/x/hub/types/types.go @@ -0,0 +1,245 @@ +// Package types defines the Hub API module types (vision §13, REQ-024, D-039). +// +// The Hub is the B2B backbone: a registry of Hub-brokered services an Anchor +// partner operates. v0.3 ships the skeleton (enum + per-service struct stubs +// + genesis); the live B2B runtime is deferred to v0.4 (D-039). +// +// Lexicon note (REQ-012, A-210): the Hub is HIGH lexicon-risk because the +// lending primitive is a natural fit for the banned financial terms. The +// coupon vocabulary is used EXCLUSIVELY here — "lending"/"coupon"/"custody"/ +// "compliance"/"jurisdiction" are the safe vision-§13 phrasings; the banned +// synonyms for these concepts NEVER appear in this package. "lending" is NOT +// a banned term (the banned list has the compounding term and the storage +// terms, not "lending" or "loan"); "coupon" is the bond vocabulary (vision +// §17). The per-package lexicon assertion in types_test.go is the gate. +// +// Cross-module references are by-ID-string per G-003 (no struct imports): +// - operator-partner-id references an x/partner Anchor Partner by ID-string +// (A-304, G-003). The Anchor extension lands in P4; x/hub in P5. The +// reference is a string, validated by the keeper against the partner +// registry at runtime, not by the type system. +// - LendingCouponCapBps is a LOCAL const cross-documented to D-028 / +// x/bond CouponCapBps (A-304). x/hub does NOT import x/bond; the cap is +// redefined locally so the lending-primitive coupon clamp is enforced +// without a cross-module struct import (mirrors how x/guild cross-docs +// x/feecovenant WaiverHandPassGuild). +package types + +import ( + "encoding/json" + "fmt" +) + +const ( + ModuleName = "hub" + StoreKey = ModuleName + RouterKey = ModuleName + QuerierRoute = ModuleName + + // HubServiceCount is the locked count of HubService enum values (vision + // §13, REQ-024, A-312). A regression firewall: adding/removing/renaming a + // Hub service breaks this const's test. + HubServiceCount = 3 + + // LendingCouponCapBps is the LOCAL upper bound on a lending-primitive + // coupon in basis points (A-304). It is cross-documented to D-028 and + // x/bond.CouponCapBps (also 800, the mission-locked 8pct bond coupon cap). + // This const is LOCAL to x/hub to avoid importing x/bond (G-003 — no + // cross-module struct imports). The two consts MUST stay in sync; a + // change to x/bond.CouponCapBps requires a matching change here. The + // ClampLendingCoupon helper uses this local const, NOT x/bond.Clamp. + LendingCouponCapBps = uint32(800) // 8pct (cross-doc D-028 / x/bond CouponCapBps — A-304) + + // LendingCouponFloorBps is the LOCAL lower bound on a lending-primitive + // coupon (A-304, cross-doc to D-028 / x/bond.CouponFloorBps = 0). Local + // const for the same G-003 reason as LendingCouponCapBps. + LendingCouponFloorBps = uint32(0) // 0pct (cross-doc D-028 / x/bond CouponFloorBps — A-304) +) + +// HubService enumerates the three Hub-brokered B2B service categories (vision +// §13, REQ-024, A-312): Custody (asset safekeeping), LendingPrimitive (the +// protocol-level lending primitive, NOT a live market), Compliance (on-chain +// compliance attestations). The full B2B suite is deferred to v0.4 (D-039). +type HubService string + +const ( + ServiceCustody HubService = "Custody" // asset safekeeping + ServiceLendingPrimitive HubService = "LendingPrimitive" // protocol-level lending primitive + ServiceCompliance HubService = "Compliance" // on-chain compliance attestations +) + +// AllHubServices returns all three HubService values in vision §13 order. +// Locked-const test asserts exactly 3 entries with these names (REQ-024). +func AllHubServices() []HubService { + return []HubService{ + ServiceCustody, + ServiceLendingPrimitive, + ServiceCompliance, + } +} + +// CustodyService is the per-service struct stub for a Hub custody service +// (vision §13, REQ-024). custody-id is the service identifier. operator- +// partner-id references an x/partner Anchor Partner by ID-string (A-304, +// G-003 — no struct import of x/partner). asset-ref is an opaque reference to +// the custodied asset (the asset identifier is opaque so the Hub does not +// import any asset-denom module). +type CustodyService struct { + CustodyID string `json:"custody_id" yaml:"custody_id"` + OperatorPartnerID string `json:"operator_partner_id" yaml:"operator_partner_id"` + AssetRef string `json:"asset_ref" yaml:"asset_ref"` +} + +// LendingPrimitive is the per-service struct stub for a Hub lending-primitive +// service (vision §13, REQ-024). loan-id is the primitive identifier. +// principal-grain is the principal in Grain (the OY internal unit, cross-ref +// x/bread by name only — no struct import). coupon-bps is the coupon rate in +// basis points, clamped to [LendingCouponFloorBps, LendingCouponCapBps] by +// ClampLendingCoupon at construction (NewLendingPrimitive). term-days is the +// primitive term length. The coupon vocabulary is used EXCLUSIVELY here +// (A-210); the banned compounding term and storage terms NEVER appear. +type LendingPrimitive struct { + LoanID string `json:"loan_id" yaml:"loan_id"` + PrincipalGrain int64 `json:"principal_grain" yaml:"principal_grain"` + CouponBps uint32 `json:"coupon_bps" yaml:"coupon_bps"` + TermDays uint32 `json:"term_days" yaml:"term_days"` +} + +// ComplianceService is the per-service struct stub for a Hub compliance +// service (vision §13, REQ-024). compliance-id is the service identifier. +// jurisdiction is an opaque jurisdiction tag (e.g. "EU-MiCA"). attestation- +// uri is an opaque URI to the compliance attestation (kept opaque in the +// skeleton, like the v0.2 Pier CredentialRef). +type ComplianceService struct { + ComplianceID string `json:"compliance_id" yaml:"compliance_id"` + Jurisdiction string `json:"jurisdiction" yaml:"jurisdiction"` + AttestationURI string `json:"attestation_uri" yaml:"attestation_uri"` +} + +// ClampLendingCoupon ensures a lending-primitive coupon is within the LOCKED +// LOCAL bounds (A-304: never above the local cap, never below the local floor). +// This mirrors x/bond.Clamp's shape (min(cap, max(floor, coupon))) but uses the +// LOCAL LendingCouponCapBps / LendingCouponFloorBps consts — it does NOT import +// x/bond.Clamp (G-003). The clamp is automatic and authoritative; the live +// keeper enforces it at construction and at genesis load. +func ClampLendingCoupon(couponBps uint32) uint32 { + if couponBps > LendingCouponCapBps { + return LendingCouponCapBps + } + if couponBps < LendingCouponFloorBps { + return LendingCouponFloorBps + } + return couponBps +} + +// NewLendingPrimitive constructs a LendingPrimitive with the coupon clamped to +// the LOCAL [floor, cap] bounds via ClampLendingCoupon (A-304). The stub does +// not persist or enforce referential integrity of operator-partner-id; it only +// enforces the coupon clamp invariant at construction time. +func NewLendingPrimitive(loanID string, principalGrain int64, couponBps uint32, termDays uint32) LendingPrimitive { + return LendingPrimitive{ + LoanID: loanID, + PrincipalGrain: principalGrain, + CouponBps: ClampLendingCoupon(couponBps), + TermDays: termDays, + } +} + +// Params for the hub module (skeleton — no tunables in v0.3; the lending +// coupon cap/floor are LOCKED LOCAL consts, not Params fields). +type Params struct{} + +// DefaultParams returns the zero-value Params (skeleton — no tunables). +func DefaultParams() Params { return Params{} } + +// GenesisState defines the hub module genesis state (REQ-024). The three +// slices hold the per-service stubs. ValidateGenesis enforces per-set ID +// uniqueness (A-212) and the lending-primitive coupon clamp at genesis load +// (each LendingPrimitive's coupon-bps must be within the LOCAL bounds). +type GenesisState struct { + Params Params `json:"params" yaml:"params"` + CustodyServices []CustodyService `json:"custody_services" yaml:"custody_services"` + LendingPrimitives []LendingPrimitive `json:"lending_primitives" yaml:"lending_primitives"` + ComplianceServices []ComplianceService `json:"compliance_services" yaml:"compliance_services"` +} + +// DefaultGenesisState returns an empty genesis state with non-nil slices. +func DefaultGenesisState() *GenesisState { + return &GenesisState{ + Params: DefaultParams(), + CustodyServices: []CustodyService{}, + LendingPrimitives: []LendingPrimitive{}, + ComplianceServices: []ComplianceService{}, + } +} + +// ValidateGenesis performs ID-uniqueness checks (A-212 upgrade from v0.1 +// no-op) and the lending-primitive coupon clamp at genesis load (A-304): +// rejects duplicate custody-ids, loan-ids, compliance-ids, and any +// LendingPrimitive whose coupon-bps is outside the LOCAL [floor, cap] bounds. +func ValidateGenesis(bz json.RawMessage) error { + var gs GenesisState + if err := json.Unmarshal(bz, &gs); err != nil { + return fmt.Errorf("hub: invalid genesis: %w", err) + } + if err := validateCustodyServices(gs.CustodyServices); err != nil { + return fmt.Errorf("hub: %w", err) + } + if err := validateLendingPrimitives(gs.LendingPrimitives); err != nil { + return fmt.Errorf("hub: %w", err) + } + if err := validateComplianceServices(gs.ComplianceServices); err != nil { + return fmt.Errorf("hub: %w", err) + } + return nil +} + +// validateCustodyServices enforces custody-id presence and uniqueness. +func validateCustodyServices(svcs []CustodyService) error { + seen := make(map[string]bool, len(svcs)) + for i, c := range svcs { + if c.CustodyID == "" { + return fmt.Errorf("custody service [%d]: empty custody-id", i) + } + if seen[c.CustodyID] { + return fmt.Errorf("custody service: duplicate custody-id %q", c.CustodyID) + } + seen[c.CustodyID] = true + } + return nil +} + +// validateLendingPrimitives enforces loan-id presence/uniqueness and the +// LOCAL coupon clamp at genesis load (A-304). +func validateLendingPrimitives(svcs []LendingPrimitive) error { + seen := make(map[string]bool, len(svcs)) + for i, l := range svcs { + if l.LoanID == "" { + return fmt.Errorf("lending primitive [%d]: empty loan-id", i) + } + if seen[l.LoanID] { + return fmt.Errorf("lending primitive: duplicate loan-id %q", l.LoanID) + } + seen[l.LoanID] = true + if l.CouponBps < LendingCouponFloorBps || l.CouponBps > LendingCouponCapBps { + return fmt.Errorf("lending primitive %q: coupon-bps %d outside [%d, %d] (A-304 clamp at genesis load)", + l.LoanID, l.CouponBps, LendingCouponFloorBps, LendingCouponCapBps) + } + } + return nil +} + +// validateComplianceServices enforces compliance-id presence and uniqueness. +func validateComplianceServices(svcs []ComplianceService) error { + seen := make(map[string]bool, len(svcs)) + for i, c := range svcs { + if c.ComplianceID == "" { + return fmt.Errorf("compliance service [%d]: empty compliance-id", i) + } + if seen[c.ComplianceID] { + return fmt.Errorf("compliance service: duplicate compliance-id %q", c.ComplianceID) + } + seen[c.ComplianceID] = true + } + return nil +} diff --git a/x/hub/types/types_test.go b/x/hub/types/types_test.go new file mode 100644 index 0000000..3252e03 --- /dev/null +++ b/x/hub/types/types_test.go @@ -0,0 +1,424 @@ +package types_test + +import ( + "encoding/json" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + + "github.com/oy/openyield/lexicon" + htypes "github.com/oy/openyield/x/hub/types" +) + +// --- HubService enum coverage (3) ---------------------------------------------- + +// TestHubServiceCountLockedConst asserts HubServiceCount == 3 and +// AllHubServices() returns exactly 3 (REQ-024, A-312). A regression firewall. +func TestHubServiceCountLockedConst(t *testing.T) { + if htypes.HubServiceCount != 3 { + t.Errorf("HubServiceCount = %d, expected 3 (REQ-024 LOCKED)", htypes.HubServiceCount) + } + all := htypes.AllHubServices() + if len(all) != 3 { + t.Errorf("AllHubServices() len = %d, expected 3", len(all)) + } +} + +// TestAllHubServicesNames asserts the 3 REQ-024 names in order with no +// extras, no dups, no renames. +func TestAllHubServicesNames(t *testing.T) { + want := []string{"Custody", "LendingPrimitive", "Compliance"} + all := htypes.AllHubServices() + if len(all) != len(want) { + t.Fatalf("len = %d, want %d", len(all), len(want)) + } + seen := map[string]bool{} + for i, s := range all { + if string(s) != want[i] { + t.Errorf("AllHubServices()[%d] = %q, want %q", i, s, want[i]) + } + if seen[string(s)] { + t.Errorf("duplicate HubService %q", s) + } + seen[string(s)] = true + } +} + +// TestHubServiceValues asserts each named const matches its AllHubServices +// entry. +func TestHubServiceValues(t *testing.T) { + if htypes.ServiceCustody != "Custody" { + t.Errorf("ServiceCustody = %q", htypes.ServiceCustody) + } + if htypes.ServiceLendingPrimitive != "LendingPrimitive" { + t.Errorf("ServiceLendingPrimitive = %q", htypes.ServiceLendingPrimitive) + } + if htypes.ServiceCompliance != "Compliance" { + t.Errorf("ServiceCompliance = %q", htypes.ServiceCompliance) + } +} + +// --- LendingCouponCapBps LOCAL const (A-304) ----------------------------------- + +// TestLendingCouponCapBpsLockedConst asserts the LOCAL LendingCouponCapBps == +// 800 (A-304 cross-doc to D-028 / x/bond.CouponCapBps). The const is LOCAL to +// x/hub to avoid importing x/bond (G-003); the test asserts the value matches +// the bond cap so the two consts stay in sync. +func TestLendingCouponCapBpsLockedConst(t *testing.T) { + if htypes.LendingCouponCapBps != 800 { + t.Errorf("LendingCouponCapBps = %d, expected 800 (A-304 cross-doc D-028)", htypes.LendingCouponCapBps) + } +} + +// TestLendingCouponFloorBpsLockedConst asserts the LOCAL +// LendingCouponFloorBps == 0 (A-304 cross-doc to D-028 / x/bond.CouponFloorBps). +func TestLendingCouponFloorBpsLockedConst(t *testing.T) { + if htypes.LendingCouponFloorBps != 0 { + t.Errorf("LendingCouponFloorBps = %d, expected 0 (A-304 cross-doc D-028)", htypes.LendingCouponFloorBps) + } +} + +// --- ClampLendingCoupon invariants (A-304) ------------------------------------- +// The ClampLendingCoupon invariant is the hub module's firewall (A-304): a +// lending-primitive coupon can never exceed the local cap (8pct) and can +// never fall below the local floor (0pct). These tests are the regression +// firewall — a change to LendingCouponCapBps or LendingCouponFloorBps breaks +// them. + +// TestClampLendingCouponBelowFloorReturnsFloor asserts a coupon below the +// floor is clamped up to the floor. The floor is 0 and uint32 cannot be +// negative, so the below-floor case is type-prevented; the test asserts the +// floor boundary passes through. +func TestClampLendingCouponBelowFloorReturnsFloor(t *testing.T) { + got := htypes.ClampLendingCoupon(htypes.LendingCouponFloorBps) + if got != htypes.LendingCouponFloorBps { + t.Errorf("ClampLendingCoupon(floor) = %d, expected floor %d", got, htypes.LendingCouponFloorBps) + } +} + +// TestClampLendingCouponAboveCapReturnsCap asserts a coupon above the cap is +// clamped down to the cap. +func TestClampLendingCouponAboveCapReturnsCap(t *testing.T) { + cases := []uint32{ + uint32(htypes.LendingCouponCapBps) + 1, + uint32(htypes.LendingCouponCapBps) + 100, + uint32(htypes.LendingCouponCapBps) + 1000, + 900, + 1000, + 5000, + } + for _, c := range cases { + got := htypes.ClampLendingCoupon(c) + if got != htypes.LendingCouponCapBps { + t.Errorf("ClampLendingCoupon(%d) = %d, expected cap %d (above-cap must clamp to cap)", c, got, htypes.LendingCouponCapBps) + } + } +} + +// TestClampLendingCouponInRangeUnchanged asserts a coupon within [floor, cap] +// is unchanged. +func TestClampLendingCouponInRangeUnchanged(t *testing.T) { + cases := []uint32{ + 0, + 1, + 100, + 400, + 500, + 799, + uint32(htypes.LendingCouponCapBps), + } + for _, c := range cases { + got := htypes.ClampLendingCoupon(c) + if got != c { + t.Errorf("ClampLendingCoupon(%d) = %d, expected %d (in-range must be unchanged)", c, got, c) + } + } +} + +// TestClampLendingCouponShape asserts the min(cap, max(floor, coupon)) shape +// at the boundaries. +func TestClampLendingCouponShape(t *testing.T) { + if htypes.ClampLendingCoupon(0) != 0 { + t.Error("ClampLendingCoupon(0) should be 0 (floor boundary)") + } + if htypes.ClampLendingCoupon(800) != 800 { + t.Error("ClampLendingCoupon(800) should be 800 (cap boundary)") + } + if htypes.ClampLendingCoupon(801) != 800 { + t.Error("ClampLendingCoupon(801) should be 800 (above-cap clamps to cap)") + } +} + +// --- Per-service struct stubs -------------------------------------------------- + +// TestCustodyServiceStructFields asserts CustodyService carries custody-id, +// operator-partner-id (by-ID-string ref to x/partner Anchor — G-003), asset-ref. +func TestCustodyServiceStructFields(t *testing.T) { + c := htypes.CustodyService{ + CustodyID: "cust-1", + OperatorPartnerID: "anchor-partner-1", + AssetRef: "bread-grain", + } + if c.CustodyID != "cust-1" || c.OperatorPartnerID != "anchor-partner-1" || c.AssetRef != "bread-grain" { + t.Error("CustodyService fields not set correctly") + } +} + +// TestCustodyServiceOperatorPartnerIDIsString asserts operator-partner-id is +// string-typed (G-003 by-ID-string ref to x/partner Anchor; no struct import). +func TestCustodyServiceOperatorPartnerIDIsString(t *testing.T) { + c := htypes.CustodyService{OperatorPartnerID: "anchor-1"} + if c.OperatorPartnerID != "anchor-1" { + t.Errorf("OperatorPartnerID = %q", c.OperatorPartnerID) + } +} + +// TestLendingPrimitiveStructFields asserts LendingPrimitive carries loan-id, +// principal-grain, coupon-bps, term-days. +func TestLendingPrimitiveStructFields(t *testing.T) { + l := htypes.LendingPrimitive{ + LoanID: "loan-1", + PrincipalGrain: 1_000_000, + CouponBps: 500, + TermDays: 365, + } + if l.LoanID != "loan-1" || l.PrincipalGrain != 1_000_000 || l.CouponBps != 500 || l.TermDays != 365 { + t.Error("LendingPrimitive fields not set correctly") + } +} + +// TestNewLendingPrimitiveClampsCoupon asserts NewLendingPrimitive clamps an +// above-cap coupon down to the cap and leaves an in-range coupon unchanged. +func TestNewLendingPrimitiveClampsCoupon(t *testing.T) { + l := htypes.NewLendingPrimitive("loan-2", 500_000, 1200, 180) + if l.CouponBps != htypes.LendingCouponCapBps { + t.Errorf("CouponBps = %d, expected cap %d (NewLendingPrimitive must clamp above-cap coupon)", l.CouponBps, htypes.LendingCouponCapBps) + } + l2 := htypes.NewLendingPrimitive("loan-3", 500_000, 300, 180) + if l2.CouponBps != 300 { + t.Errorf("CouponBps = %d, expected 300 (in-range, unchanged)", l2.CouponBps) + } +} + +// TestComplianceServiceStructFields asserts ComplianceService carries +// compliance-id, jurisdiction, attestation-uri. +func TestComplianceServiceStructFields(t *testing.T) { + c := htypes.ComplianceService{ + ComplianceID: "comp-1", + Jurisdiction: "EU-MiCA", + AttestationURI: "ipfs://attestation/abc", + } + if c.ComplianceID != "comp-1" || c.Jurisdiction != "EU-MiCA" || c.AttestationURI != "ipfs://attestation/abc" { + t.Error("ComplianceService fields not set correctly") + } +} + +// --- Module consts + Params ---------------------------------------------------- + +// TestModuleConsts asserts the four Cosmos-convention module consts. +func TestModuleConsts(t *testing.T) { + if htypes.ModuleName != "hub" { + t.Errorf("ModuleName = %q", htypes.ModuleName) + } + if htypes.StoreKey != "hub" { + t.Errorf("StoreKey = %q", htypes.StoreKey) + } + if htypes.RouterKey != "hub" { + t.Errorf("RouterKey = %q", htypes.RouterKey) + } + if htypes.QuerierRoute != "hub" { + t.Errorf("QuerierRoute = %q", htypes.QuerierRoute) + } +} + +// TestDefaultParams asserts DefaultParams returns a zero-value Params. +func TestDefaultParams(t *testing.T) { + _ = htypes.DefaultParams() // no panics +} + +// --- Genesis ------------------------------------------------------------------- + +// TestDefaultGenesisStateEmpty asserts DefaultGenesisState returns non-nil +// empty slices for all three service sets. +func TestDefaultGenesisStateEmpty(t *testing.T) { + gs := htypes.DefaultGenesisState() + if gs == nil { + t.Fatal("DefaultGenesisState returned nil") + } + if gs.CustodyServices == nil || len(gs.CustodyServices) != 0 { + t.Errorf("Default CustodyServices should be non-nil empty slice; got len=%d nil=%v", len(gs.CustodyServices), gs.CustodyServices == nil) + } + if gs.LendingPrimitives == nil || len(gs.LendingPrimitives) != 0 { + t.Errorf("Default LendingPrimitives should be non-nil empty slice; got len=%d nil=%v", len(gs.LendingPrimitives), gs.LendingPrimitives == nil) + } + if gs.ComplianceServices == nil || len(gs.ComplianceServices) != 0 { + t.Errorf("Default ComplianceServices should be non-nil empty slice; got len=%d nil=%v", len(gs.ComplianceServices), gs.ComplianceServices == nil) + } +} + +// TestValidateGenesisRejectsDupCustodyIDs asserts A-212: duplicate custody-ids +// are rejected. +func TestValidateGenesisRejectsDupCustodyIDs(t *testing.T) { + gs := htypes.GenesisState{ + CustodyServices: []htypes.CustodyService{ + {CustodyID: "c1", OperatorPartnerID: "a1"}, + {CustodyID: "c1", OperatorPartnerID: "a2"}, // dup + }, + } + bz, _ := json.Marshal(gs) + if err := htypes.ValidateGenesis(bz); err == nil { + t.Error("ValidateGenesis should reject duplicate custody-ids") + } +} + +// TestValidateGenesisRejectsEmptyCustodyID asserts empty custody-id is rejected. +func TestValidateGenesisRejectsEmptyCustodyID(t *testing.T) { + gs := htypes.GenesisState{ + CustodyServices: []htypes.CustodyService{{CustodyID: "", OperatorPartnerID: "a1"}}, + } + bz, _ := json.Marshal(gs) + if err := htypes.ValidateGenesis(bz); err == nil { + t.Error("ValidateGenesis should reject empty custody-id") + } +} + +// TestValidateGenesisRejectsDupLoanIDs asserts duplicate loan-ids are rejected. +func TestValidateGenesisRejectsDupLoanIDs(t *testing.T) { + gs := htypes.GenesisState{ + LendingPrimitives: []htypes.LendingPrimitive{ + {LoanID: "l1", CouponBps: 100}, + {LoanID: "l1", CouponBps: 200}, // dup + }, + } + bz, _ := json.Marshal(gs) + if err := htypes.ValidateGenesis(bz); err == nil { + t.Error("ValidateGenesis should reject duplicate loan-ids") + } +} + +// TestValidateGenesisRejectsCouponAboveCap asserts the genesis-side clamp: a +// LendingPrimitive with coupon-bps above the local cap is rejected (A-304). +func TestValidateGenesisRejectsCouponAboveCap(t *testing.T) { + gs := htypes.GenesisState{ + LendingPrimitives: []htypes.LendingPrimitive{ + {LoanID: "l1", CouponBps: uint32(htypes.LendingCouponCapBps) + 1}, + }, + } + bz, _ := json.Marshal(gs) + if err := htypes.ValidateGenesis(bz); err == nil { + t.Error("ValidateGenesis should reject lending-primitive coupon-bps above local cap (A-304)") + } +} + +// TestValidateGenesisRejectsDupComplianceIDs asserts duplicate compliance-ids +// are rejected. +func TestValidateGenesisRejectsDupComplianceIDs(t *testing.T) { + gs := htypes.GenesisState{ + ComplianceServices: []htypes.ComplianceService{ + {ComplianceID: "comp-1"}, + {ComplianceID: "comp-1"}, // dup + }, + } + bz, _ := json.Marshal(gs) + if err := htypes.ValidateGenesis(bz); err == nil { + t.Error("ValidateGenesis should reject duplicate compliance-ids") + } +} + +// TestValidateGenesisRejectsBadJSON asserts malformed JSON is rejected. +func TestValidateGenesisRejectsBadJSON(t *testing.T) { + if err := htypes.ValidateGenesis(json.RawMessage(`{not json`)); err == nil { + t.Error("ValidateGenesis should reject malformed JSON") + } +} + +// TestValidateGenesisAcceptsClean asserts a clean genesis validates. +func TestValidateGenesisAcceptsClean(t *testing.T) { + gs := htypes.GenesisState{ + CustodyServices: []htypes.CustodyService{ + {CustodyID: "c1", OperatorPartnerID: "a1", AssetRef: "bread"}, + }, + LendingPrimitives: []htypes.LendingPrimitive{ + {LoanID: "l1", PrincipalGrain: 1_000_000, CouponBps: 500, TermDays: 365}, + {LoanID: "l2", PrincipalGrain: 500_000, CouponBps: 800, TermDays: 180}, + }, + ComplianceServices: []htypes.ComplianceService{ + {ComplianceID: "comp-1", Jurisdiction: "EU-MiCA", AttestationURI: "ipfs://x"}, + }, + } + bz, _ := json.Marshal(gs) + if err := htypes.ValidateGenesis(bz); err != nil { + t.Errorf("ValidateGenesis should accept clean genesis, got: %v", err) + } +} + +// --- Lexicon assertion (REQ-012) ------------------------------------------------- +// The hub module is HIGH lexicon-risk (lending primitive): the banned terms +// that are natural fit-words for a lending primitive (the compounding term, +// the storage terms, the tradable-unit terms) must NEVER appear. The coupon +// + lending vocabulary is used EXCLUSIVELY. The lexicon helpers are used +// here — no banned literals are inlined in this test file. + +// TestLexiconNoBannedTermsInHubPackage scans every non-test .go file in the +// hub/types package directory for the banned terms (case-insensitive). +// Production files only — the test file references banned terms via the +// lexicon package helpers (standard lexicon-test bootstrapping pattern). +func TestLexiconNoBannedTermsInHubPackage(t *testing.T) { + pkgDir := packageDir(t, "github.com/oy/openyield/x/hub/types") + files, err := filepath.Glob(filepath.Join(pkgDir, "*.go")) + if err != nil { + t.Fatalf("glob: %v", err) + } + prodFiles := []string{} + for _, f := range files { + if strings.HasSuffix(f, "_test.go") { + continue + } + prodFiles = append(prodFiles, f) + } + if len(prodFiles) == 0 { + t.Fatal("no production .go files found in hub/types") + } + for _, f := range prodFiles { + bz, err := os.ReadFile(f) + if err != nil { + t.Fatalf("read %s: %v", f, err) + } + if found, ok := lexicon.FindBannedTerm(string(bz)); ok { + t.Errorf("%s: banned term %q (REQ-012 lexicon firewall — coupon+lending vocabulary only)", filepath.Base(f), found) + } + } +} + +// TestLexiconNoBannedTermsInHubTestFile asserts this test file itself does not +// contain any banned term as a literal (the firewall scans test files too; +// the lexicon helpers must be used rather than inlining banned terms). +func TestLexiconNoBannedTermsInHubTestFile(t *testing.T) { + _, thisFile, _, ok := runtime.Caller(0) + if !ok { + t.Fatal("runtime.Caller failed") + } + bz, err := os.ReadFile(thisFile) + if err != nil { + t.Fatalf("read self: %v", err) + } + if found, ok := lexicon.FindBannedTerm(string(bz)); ok { + t.Fatalf("hub test file contains banned term %q — use lexicon helpers, not literals", found) + } +} + +// packageDir resolves a Go import path to its filesystem directory by walking +// up from this test file (v0.3 skeleton has zero external deps). +func packageDir(t *testing.T, importPath string) string { + t.Helper() + _, file, _, ok := runtime.Caller(0) + if !ok { + t.Fatal("runtime.Caller failed") + } + // file = .../oy/x/hub/types/types_test.go -> repoRoot = .../oy (4 dirs up) + repoRoot := filepath.Dir(filepath.Dir(filepath.Dir(filepath.Dir(file)))) + rel := strings.TrimPrefix(importPath, "github.com/oy/openyield/") + return filepath.Join(repoRoot, rel) +} diff --git a/x/services/types/types.go b/x/services/types/types.go new file mode 100644 index 0000000..1678703 --- /dev/null +++ b/x/services/types/types.go @@ -0,0 +1,225 @@ +// Package types defines the Services module types (vision §13, REQ-025, +// D-040, A-307). +// +// OY-protocol services beyond the financial layer: Care (community care), +// SIM (connectivity), Vault (storage service), Mail (messaging). v0.3 ships +// the skeleton (enum + per-service struct stubs + genesis); no live services. +// +// Lexicon note (REQ-012): "Mail"/"SIM"/"Care"/"Vault" are not banned terms. +// Avoid the banned Holder-identity term (use "operator-reach-id" not the +// banned term). The per-package lexicon assertion in types_test.go is the gate. +// +// Cross-module references are by-ID-string per G-003 (no struct imports): +// - operator-reach-id references an x/identity Reach by ID-string (G-003). +// - window-id references an x/window Window by ID-string (A-307, G-003). +// A service-grant opens a Window on the holder's behalf (the Window +// Lifecycle interface hook, typed in v0.3, invoked at runtime in v0.4). +// - mailbox-id (MailService) and storage-quota-grain (VaultService) are +// opaque / in-package values; VaultService references x/vault by name only +// (the ServiceKind "Vault" is a service kind, NOT a struct import of +// x/vault — the naming collision is concept-level, not package-level). +package types + +import ( + "encoding/json" + "fmt" +) + +const ( + ModuleName = "services" + StoreKey = ModuleName + RouterKey = ModuleName + QuerierRoute = ModuleName + + // ServiceKindCount is the locked count of ServiceKind enum values (vision + // §13, REQ-025, A-307). A regression firewall: adding/removing/renaming a + // service kind breaks this const's test. + ServiceKindCount = 4 +) + +// ServiceKind enumerates the four OY-protocol service kinds (vision §13, +// REQ-025, A-307): Care (community care), SIM (subscriber identity module / +// connectivity), Vault (storage service), Mail (messaging). The full services +// suite (the real-return token, Travel, +11 more) is Phase 4, out of v0.3 +// scope (D-040). The real-return token's name in vision §13 uses a banned +// standalone term; this comment uses the lexicon-safe "real-return" phrasing. +type ServiceKind string + +const ( + KindCare ServiceKind = "Care" // community care + KindSIM ServiceKind = "SIM" // connectivity + KindVault ServiceKind = "Vault" // storage service + KindMail ServiceKind = "Mail" // messaging +) + +// AllServiceKinds returns all four ServiceKind values in vision §13 order. +// Locked-const test asserts exactly 4 entries with these names (REQ-025). +func AllServiceKinds() []ServiceKind { + return []ServiceKind{ + KindCare, + KindSIM, + KindVault, + KindMail, + } +} + +// ServiceStatus enumerates the lifecycle states of a service (REQ-025). This +// is a LOCAL redefinition of the 4-state shape (mirrors the v0.2 PartnerStatus +// shape); no struct import of x/partner (G-003). +type ServiceStatus string + +const ( + ServicePending ServiceStatus = "Pending" // registered, not yet active + ServiceActive ServiceStatus = "Active" // live + ServiceSuspended ServiceStatus = "Suspended" // temporarily halted + ServiceRevoked ServiceStatus = "Revoked" // permanently revoked +) + +// ServiceStatusCount is the locked count of ServiceStatus enum values. +const ServiceStatusCount = 4 + +// ServiceInfo is the registry record for a service (REQ-025, A-307). +// service-id is the unique identifier. kind picks the ServiceKind. +// operator-reach-id references an x/identity Reach by ID-string (G-003 — use +// "operator-reach-id" not the banned Holder-identity term). name is a human- +// readable label. status is the lifecycle state. window-id references an +// x/window Window by ID-string (A-307, G-003 — a service-grant opens a Window +// on the holder's behalf; the Window Lifecycle interface hook, typed in v0.3, +// invoked at runtime in v0.4). The window-id field is the by-ID-string ref +// that ties a service-grant to a Window scope. +type ServiceInfo struct { + ServiceID string `json:"service_id" yaml:"service_id"` + Kind ServiceKind `json:"kind" yaml:"kind"` + OperatorReachID string `json:"operator_reach_id" yaml:"operator_reach_id"` + Name string `json:"name" yaml:"name"` + Status ServiceStatus `json:"status" yaml:"status"` + WindowID string `json:"window_id" yaml:"window_id"` +} + +// CareService is the per-service struct stub for a Care service (vision §13, +// REQ-025). care-id is the service identifier. care-kind is an opaque string +// (the kind of community care, e.g. "mutual-aid" — opaque so the enum is not +// locked in v0.3; care kinds are operational, not protocol-locked). +type CareService struct { + CareID string `json:"care_id" yaml:"care_id"` + CareKind string `json:"care_kind" yaml:"care_kind"` +} + +// SIMService is the per-service struct stub for a SIM (connectivity) service +// (vision §13, REQ-025). sim-id is the service identifier. carrier is an +// opaque string (the connectivity carrier — opaque so the enum is not locked +// in v0.3 per A-308 venue pattern; carriers are operational). +type SIMService struct { + SIMID string `json:"sim_id" yaml:"sim_id"` + Carrier string `json:"carrier" yaml:"carrier"` +} + +// VaultService is the per-service struct stub for a Vault (storage) service +// (vision §13, REQ-025). vault-id is the service identifier. holder-reach-id +// references an x/identity Reach by ID-string (G-003 — use "holder-reach-id" +// not the banned Holder-identity term). storage-quota-grain is the storage +// quota in Grain (the OY internal unit, by name only — no x/bread import). +// "Vault" here is a service kind, NOT a struct import of x/vault (the naming +// collision is concept-level; VaultService references x/vault by ID-string at +// runtime, not by Go import). +type VaultService struct { + VaultID string `json:"vault_id" yaml:"vault_id"` + HolderReachID string `json:"holder_reach_id" yaml:"holder_reach_id"` + StorageQuotaGrain int64 `json:"storage_quota_grain" yaml:"storage_quota_grain"` +} + +// MailService is the per-service struct stub for a Mail (messaging) service +// (vision §13, REQ-025). mail-id is the service identifier. holder-reach-id +// references an x/identity Reach by ID-string (G-003). mailbox-id is the +// opaque mailbox identifier. +type MailService struct { + MailID string `json:"mail_id" yaml:"mail_id"` + HolderReachID string `json:"holder_reach_id" yaml:"holder_reach_id"` + MailboxID string `json:"mailbox_id" yaml:"mailbox_id"` +} + +// Params for the services module (skeleton — no tunables in v0.3). +type Params struct{} + +// DefaultParams returns the zero-value Params (skeleton — no tunables). +func DefaultParams() Params { return Params{} } + +// GenesisState defines the services module genesis state (REQ-025). The +// ServiceInfos slice holds the registry records. The per-service stub slices +// hold the service-specific metadata. ValidateGenesis enforces service-id +// uniqueness across the registry (A-212). +type GenesisState struct { + Params Params `json:"params" yaml:"params"` + ServiceInfos []ServiceInfo `json:"service_infos" yaml:"service_infos"` + CareServices []CareService `json:"care_services" yaml:"care_services"` + SIMServices []SIMService `json:"sim_services" yaml:"sim_services"` + VaultServices []VaultService `json:"vault_services" yaml:"vault_services"` + MailServices []MailService `json:"mail_services" yaml:"mail_services"` +} + +// DefaultGenesisState returns an empty genesis state with non-nil slices. +func DefaultGenesisState() *GenesisState { + return &GenesisState{ + Params: DefaultParams(), + ServiceInfos: []ServiceInfo{}, + CareServices: []CareService{}, + SIMServices: []SIMService{}, + VaultServices: []VaultService{}, + MailServices: []MailService{}, + } +} + +// ValidateGenesis performs ID-uniqueness checks (A-212 upgrade from v0.1 +// no-op): rejects duplicate or empty service-ids in the registry, and unknown +// ServiceKind / ServiceStatus values. +func ValidateGenesis(bz json.RawMessage) error { + var gs GenesisState + if err := json.Unmarshal(bz, &gs); err != nil { + return fmt.Errorf("services: invalid genesis: %w", err) + } + if err := validateServiceInfos(gs.ServiceInfos); err != nil { + return fmt.Errorf("services: %w", err) + } + return nil +} + +// validateServiceInfos enforces service-id presence and uniqueness, and that +// each Kind/Status is a known enum value. +func validateServiceInfos(infos []ServiceInfo) error { + seen := make(map[string]bool, len(infos)) + for i, s := range infos { + if s.ServiceID == "" { + return fmt.Errorf("service info [%d]: empty service-id", i) + } + if seen[s.ServiceID] { + return fmt.Errorf("service info: duplicate service-id %q", s.ServiceID) + } + seen[s.ServiceID] = true + if !knownServiceKind(s.Kind) { + return fmt.Errorf("service %q: unknown service kind %q", s.ServiceID, s.Kind) + } + if !knownServiceStatus(s.Status) { + return fmt.Errorf("service %q: unknown service status %q", s.ServiceID, s.Status) + } + } + return nil +} + +// knownServiceKind reports whether k is one of the four ServiceKind values. +func knownServiceKind(k ServiceKind) bool { + for _, kk := range AllServiceKinds() { + if k == kk { + return true + } + } + return false +} + +// knownServiceStatus reports whether s is one of the four ServiceStatus values. +func knownServiceStatus(s ServiceStatus) bool { + switch s { + case ServicePending, ServiceActive, ServiceSuspended, ServiceRevoked: + return true + } + return false +} diff --git a/x/services/types/types_test.go b/x/services/types/types_test.go new file mode 100644 index 0000000..42a3247 --- /dev/null +++ b/x/services/types/types_test.go @@ -0,0 +1,369 @@ +package types_test + +import ( + "encoding/json" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + + "github.com/oy/openyield/lexicon" + stypes "github.com/oy/openyield/x/services/types" +) + +// --- ServiceKind enum coverage (4) -------------------------------------------- + +// TestServiceKindCountLockedConst asserts ServiceKindCount == 4 and +// AllServiceKinds() returns exactly 4 (REQ-025, A-307). A regression firewall. +func TestServiceKindCountLockedConst(t *testing.T) { + if stypes.ServiceKindCount != 4 { + t.Errorf("ServiceKindCount = %d, expected 4 (REQ-025 LOCKED)", stypes.ServiceKindCount) + } + all := stypes.AllServiceKinds() + if len(all) != 4 { + t.Errorf("AllServiceKinds() len = %d, expected 4", len(all)) + } +} + +// TestAllServiceKindsNames asserts the 4 REQ-025 names in order with no extras, +// no dups, no renames. +func TestAllServiceKindsNames(t *testing.T) { + want := []string{"Care", "SIM", "Vault", "Mail"} + all := stypes.AllServiceKinds() + if len(all) != len(want) { + t.Fatalf("len = %d, want %d", len(all), len(want)) + } + seen := map[string]bool{} + for i, k := range all { + if string(k) != want[i] { + t.Errorf("AllServiceKinds()[%d] = %q, want %q", i, k, want[i]) + } + if seen[string(k)] { + t.Errorf("duplicate ServiceKind %q", k) + } + seen[string(k)] = true + } +} + +// TestServiceKindValues asserts each named const matches its AllServiceKinds +// entry. +func TestServiceKindValues(t *testing.T) { + if stypes.KindCare != "Care" { + t.Errorf("KindCare = %q", stypes.KindCare) + } + if stypes.KindSIM != "SIM" { + t.Errorf("KindSIM = %q", stypes.KindSIM) + } + if stypes.KindVault != "Vault" { + t.Errorf("KindVault = %q", stypes.KindVault) + } + if stypes.KindMail != "Mail" { + t.Errorf("KindMail = %q", stypes.KindMail) + } +} + +// --- ServiceStatus enum coverage (4) ------------------------------------------ + +// TestServiceStatusCountLockedConst asserts ServiceStatusCount == 4. +func TestServiceStatusCountLockedConst(t *testing.T) { + if stypes.ServiceStatusCount != 4 { + t.Errorf("ServiceStatusCount = %d, expected 4", stypes.ServiceStatusCount) + } +} + +// TestServiceStatusValues asserts the four ServiceStatus named consts. +func TestServiceStatusValues(t *testing.T) { + if stypes.ServicePending != "Pending" { + t.Errorf("ServicePending = %q", stypes.ServicePending) + } + if stypes.ServiceActive != "Active" { + t.Errorf("ServiceActive = %q", stypes.ServiceActive) + } + if stypes.ServiceSuspended != "Suspended" { + t.Errorf("ServiceSuspended = %q", stypes.ServiceSuspended) + } + if stypes.ServiceRevoked != "Revoked" { + t.Errorf("ServiceRevoked = %q", stypes.ServiceRevoked) + } +} + +// --- ServiceInfo struct + by-ID-string refs (G-003, A-307) -------------------- + +// TestServiceInfoStructFields asserts ServiceInfo carries service-id, kind, +// operator-reach-id, name, status, window-id. +func TestServiceInfoStructFields(t *testing.T) { + s := stypes.ServiceInfo{ + ServiceID: "svc-1", + Kind: stypes.KindCare, + OperatorReachID: "reach-holder-1", + Name: "Care Service", + Status: stypes.ServiceActive, + WindowID: "window-1", + } + if s.ServiceID != "svc-1" || s.Kind != stypes.KindCare || s.OperatorReachID != "reach-holder-1" || + s.Name != "Care Service" || s.Status != stypes.ServiceActive || s.WindowID != "window-1" { + t.Error("ServiceInfo fields not set correctly") + } +} + +// TestServiceInfoWindowIDIsString asserts window-id is string-typed (A-307 +// by-ID-string ref to x/window — G-003, no struct import). This is the +// window-id by-ID-string ref presence test mandated by the P5 task spec. +func TestServiceInfoWindowIDIsString(t *testing.T) { + s := stypes.ServiceInfo{WindowID: "window-abc"} + if s.WindowID != "window-abc" { + t.Errorf("WindowID = %q", s.WindowID) + } +} + +// TestServiceInfoOperatorReachIDIsString asserts operator-reach-id is +// string-typed (G-003 by-ID-string ref to x/identity Reach — no struct import). +func TestServiceInfoOperatorReachIDIsString(t *testing.T) { + s := stypes.ServiceInfo{OperatorReachID: "reach-xyz"} + if s.OperatorReachID != "reach-xyz" { + t.Errorf("OperatorReachID = %q", s.OperatorReachID) + } +} + +// --- Per-service struct stubs -------------------------------------------------- + +// TestCareServiceStructFields asserts CareService carries care-id, care-kind. +func TestCareServiceStructFields(t *testing.T) { + c := stypes.CareService{CareID: "care-1", CareKind: "mutual-aid"} + if c.CareID != "care-1" || c.CareKind != "mutual-aid" { + t.Error("CareService fields not set correctly") + } +} + +// TestSIMServiceStructFields asserts SIMService carries sim-id, carrier. +func TestSIMServiceStructFields(t *testing.T) { + s := stypes.SIMService{SIMID: "sim-1", Carrier: "oy-mobile"} + if s.SIMID != "sim-1" || s.Carrier != "oy-mobile" { + t.Error("SIMService fields not set correctly") + } +} + +// TestVaultServiceStructFields asserts VaultService carries vault-id, +// holder-reach-id (by-ID-string ref to x/identity — G-003), storage-quota-grain. +func TestVaultServiceStructFields(t *testing.T) { + v := stypes.VaultService{ + VaultID: "vault-1", + HolderReachID: "reach-holder-1", + StorageQuotaGrain: 1_000_000, + } + if v.VaultID != "vault-1" || v.HolderReachID != "reach-holder-1" || v.StorageQuotaGrain != 1_000_000 { + t.Error("VaultService fields not set correctly") + } +} + +// TestVaultServiceHolderReachIDIsString asserts holder-reach-id is string-typed +// (G-003 by-ID-string ref to x/identity Reach — no struct import). +func TestVaultServiceHolderReachIDIsString(t *testing.T) { + v := stypes.VaultService{HolderReachID: "reach-abc"} + if v.HolderReachID != "reach-abc" { + t.Errorf("HolderReachID = %q", v.HolderReachID) + } +} + +// TestMailServiceStructFields asserts MailService carries mail-id, +// holder-reach-id (by-ID-string ref to x/identity — G-003), mailbox-id. +func TestMailServiceStructFields(t *testing.T) { + m := stypes.MailService{ + MailID: "mail-1", + HolderReachID: "reach-holder-1", + MailboxID: "mbox-1", + } + if m.MailID != "mail-1" || m.HolderReachID != "reach-holder-1" || m.MailboxID != "mbox-1" { + t.Error("MailService fields not set correctly") + } +} + +// --- Module consts + Params ---------------------------------------------------- + +// TestModuleConsts asserts the four Cosmos-convention module consts. +func TestModuleConsts(t *testing.T) { + if stypes.ModuleName != "services" { + t.Errorf("ModuleName = %q", stypes.ModuleName) + } + if stypes.StoreKey != "services" { + t.Errorf("StoreKey = %q", stypes.StoreKey) + } + if stypes.RouterKey != "services" { + t.Errorf("RouterKey = %q", stypes.RouterKey) + } + if stypes.QuerierRoute != "services" { + t.Errorf("QuerierRoute = %q", stypes.QuerierRoute) + } +} + +// TestDefaultParams asserts DefaultParams returns a zero-value Params. +func TestDefaultParams(t *testing.T) { + _ = stypes.DefaultParams() // no panics +} + +// --- Genesis ------------------------------------------------------------------- + +// TestDefaultGenesisStateEmpty asserts DefaultGenesisState returns non-nil +// empty slices for all five sets. +func TestDefaultGenesisStateEmpty(t *testing.T) { + gs := stypes.DefaultGenesisState() + if gs == nil { + t.Fatal("DefaultGenesisState returned nil") + } + if gs.ServiceInfos == nil || len(gs.ServiceInfos) != 0 { + t.Errorf("Default ServiceInfos should be non-nil empty slice; got len=%d nil=%v", len(gs.ServiceInfos), gs.ServiceInfos == nil) + } + if gs.CareServices == nil || len(gs.CareServices) != 0 { + t.Errorf("Default CareServices should be non-nil empty slice; got len=%d nil=%v", len(gs.CareServices), gs.CareServices == nil) + } + if gs.SIMServices == nil || len(gs.SIMServices) != 0 { + t.Errorf("Default SIMServices should be non-nil empty slice; got len=%d nil=%v", len(gs.SIMServices), gs.SIMServices == nil) + } + if gs.VaultServices == nil || len(gs.VaultServices) != 0 { + t.Errorf("Default VaultServices should be non-nil empty slice; got len=%d nil=%v", len(gs.VaultServices), gs.VaultServices == nil) + } + if gs.MailServices == nil || len(gs.MailServices) != 0 { + t.Errorf("Default MailServices should be non-nil empty slice; got len=%d nil=%v", len(gs.MailServices), gs.MailServices == nil) + } +} + +// TestValidateGenesisRejectsDupServiceIDs asserts A-212: duplicate service-ids +// are rejected. +func TestValidateGenesisRejectsDupServiceIDs(t *testing.T) { + gs := stypes.GenesisState{ + ServiceInfos: []stypes.ServiceInfo{ + {ServiceID: "s1", Kind: stypes.KindCare, Status: stypes.ServiceActive}, + {ServiceID: "s1", Kind: stypes.KindSIM, Status: stypes.ServiceActive}, // dup + }, + } + bz, _ := json.Marshal(gs) + if err := stypes.ValidateGenesis(bz); err == nil { + t.Error("ValidateGenesis should reject duplicate service-ids") + } +} + +// TestValidateGenesisRejectsEmptyServiceID asserts empty service-id is rejected. +func TestValidateGenesisRejectsEmptyServiceID(t *testing.T) { + gs := stypes.GenesisState{ + ServiceInfos: []stypes.ServiceInfo{{ServiceID: "", Kind: stypes.KindCare, Status: stypes.ServiceActive}}, + } + bz, _ := json.Marshal(gs) + if err := stypes.ValidateGenesis(bz); err == nil { + t.Error("ValidateGenesis should reject empty service-id") + } +} + +// TestValidateGenesisRejectsUnknownServiceKind asserts an unknown ServiceKind +// is rejected. +func TestValidateGenesisRejectsUnknownServiceKind(t *testing.T) { + gs := stypes.GenesisState{ + ServiceInfos: []stypes.ServiceInfo{{ServiceID: "s1", Kind: stypes.ServiceKind("Bogus"), Status: stypes.ServiceActive}}, + } + bz, _ := json.Marshal(gs) + if err := stypes.ValidateGenesis(bz); err == nil { + t.Error("ValidateGenesis should reject unknown service kind") + } +} + +// TestValidateGenesisRejectsUnknownServiceStatus asserts an unknown +// ServiceStatus is rejected. +func TestValidateGenesisRejectsUnknownServiceStatus(t *testing.T) { + gs := stypes.GenesisState{ + ServiceInfos: []stypes.ServiceInfo{{ServiceID: "s1", Kind: stypes.KindCare, Status: stypes.ServiceStatus("Bogus")}}, + } + bz, _ := json.Marshal(gs) + if err := stypes.ValidateGenesis(bz); err == nil { + t.Error("ValidateGenesis should reject unknown service status") + } +} + +// TestValidateGenesisRejectsBadJSON asserts malformed JSON is rejected. +func TestValidateGenesisRejectsBadJSON(t *testing.T) { + if err := stypes.ValidateGenesis(json.RawMessage(`{not json`)); err == nil { + t.Error("ValidateGenesis should reject malformed JSON") + } +} + +// TestValidateGenesisAcceptsClean asserts a clean genesis validates. +func TestValidateGenesisAcceptsClean(t *testing.T) { + gs := stypes.GenesisState{ + ServiceInfos: []stypes.ServiceInfo{ + {ServiceID: "s1", Kind: stypes.KindCare, OperatorReachID: "r1", Name: "Care", Status: stypes.ServiceActive, WindowID: "w1"}, + {ServiceID: "s2", Kind: stypes.KindMail, OperatorReachID: "r2", Name: "Mail", Status: stypes.ServicePending, WindowID: "w2"}, + }, + } + bz, _ := json.Marshal(gs) + if err := stypes.ValidateGenesis(bz); err != nil { + t.Errorf("ValidateGenesis should accept clean genesis, got: %v", err) + } +} + +// --- Lexicon assertion (REQ-012) ------------------------------------------------- +// The services module must avoid the banned Holder-identity term (use +// "operator-reach-id"/"holder-reach-id" not the banned term). "Mail"/"SIM"/ +// "Care"/"Vault" are not banned. The lexicon helpers are used here — no +// banned literals are inlined in this test file. + +// TestLexiconNoBannedTermsInServicesPackage scans every non-test .go file in +// the services/types package directory for the banned terms +// (case-insensitive). Production files only — the test file references banned +// terms via the lexicon package helpers (standard lexicon-test bootstrapping +// pattern). +func TestLexiconNoBannedTermsInServicesPackage(t *testing.T) { + pkgDir := packageDir(t, "github.com/oy/openyield/x/services/types") + files, err := filepath.Glob(filepath.Join(pkgDir, "*.go")) + if err != nil { + t.Fatalf("glob: %v", err) + } + prodFiles := []string{} + for _, f := range files { + if strings.HasSuffix(f, "_test.go") { + continue + } + prodFiles = append(prodFiles, f) + } + if len(prodFiles) == 0 { + t.Fatal("no production .go files found in services/types") + } + for _, f := range prodFiles { + bz, err := os.ReadFile(f) + if err != nil { + t.Fatalf("read %s: %v", f, err) + } + if found, ok := lexicon.FindBannedTerm(string(bz)); ok { + t.Errorf("%s: banned term %q (REQ-012 lexicon firewall — use operator-reach-id not the banned Holder-identity term)", filepath.Base(f), found) + } + } +} + +// TestLexiconNoBannedTermsInServicesTestFile asserts this test file itself does +// not contain any banned term as a literal (the firewall scans test files too; +// the lexicon helpers must be used rather than inlining banned terms). +func TestLexiconNoBannedTermsInServicesTestFile(t *testing.T) { + _, thisFile, _, ok := runtime.Caller(0) + if !ok { + t.Fatal("runtime.Caller failed") + } + bz, err := os.ReadFile(thisFile) + if err != nil { + t.Fatalf("read self: %v", err) + } + if found, ok := lexicon.FindBannedTerm(string(bz)); ok { + t.Fatalf("services test file contains banned term %q — use lexicon helpers, not literals", found) + } +} + +// packageDir resolves a Go import path to its filesystem directory by walking +// up from this test file (v0.3 skeleton has zero external deps). +func packageDir(t *testing.T, importPath string) string { + t.Helper() + _, file, _, ok := runtime.Caller(0) + if !ok { + t.Fatal("runtime.Caller failed") + } + // file = .../oy/x/services/types/types_test.go -> repoRoot = .../oy (4 dirs up) + repoRoot := filepath.Dir(filepath.Dir(filepath.Dir(filepath.Dir(file)))) + rel := strings.TrimPrefix(importPath, "github.com/oy/openyield/") + return filepath.Join(repoRoot, rel) +}