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---
This commit is contained in:
@@ -52,3 +52,93 @@ func knownBondStatus(s BondStatus) bool {
|
|||||||
}
|
}
|
||||||
return false
|
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
|
||||||
|
}
|
||||||
|
|||||||
+167
-11
@@ -113,26 +113,33 @@ type Params struct{}
|
|||||||
|
|
||||||
func DefaultParams() Params { return Params{} }
|
func DefaultParams() Params { return Params{} }
|
||||||
|
|
||||||
// GenesisState defines the bond module genesis state (REQ-021). Bonds is the
|
// GenesisState defines the bond module genesis state (REQ-021, REQ-026).
|
||||||
// top-level set of issued bonds. ValidateGenesis enforces bond-id uniqueness
|
// Bonds is the top-level set of issued bonds (v0.2). GrowthBonds (v0.3) and
|
||||||
// and the coupon clamp at genesis load (the data-engineer's genesis.go holds
|
// Orders (v0.3) extend the genesis with growth bonds and secondary-market
|
||||||
// the schema helpers per G-008).
|
// 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 {
|
type GenesisState struct {
|
||||||
Params Params `json:"params" yaml:"params"`
|
Params Params `json:"params" yaml:"params"`
|
||||||
Bonds []Bond `json:"bonds" yaml:"bonds"`
|
Bonds []Bond `json:"bonds" yaml:"bonds"`
|
||||||
|
GrowthBonds []GrowthBond `json:"growth_bonds" yaml:"growth_bonds"`
|
||||||
|
Orders []SecondaryOrder `json:"orders" yaml:"orders"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func DefaultGenesisState() *GenesisState {
|
func DefaultGenesisState() *GenesisState {
|
||||||
return &GenesisState{
|
return &GenesisState{
|
||||||
Params: DefaultParams(),
|
Params: DefaultParams(),
|
||||||
Bonds: []Bond{},
|
Bonds: []Bond{},
|
||||||
|
GrowthBonds: []GrowthBond{},
|
||||||
|
Orders: []SecondaryOrder{},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ValidateGenesis performs ID-uniqueness checks (A-212 upgrade from v0.1
|
// ValidateGenesis performs ID-uniqueness checks (A-212 upgrade from v0.1
|
||||||
// no-op): rejects duplicate bond-ids, and runs the coupon clamp at genesis
|
// no-op): rejects duplicate bond-ids / growth-bond-ids / order-ids, and runs
|
||||||
// load (each genesis bond's coupon-bps must be within [floor, cap]). Delegates
|
// the coupon clamp at genesis load (each genesis bond's coupon-bps must be
|
||||||
// to the data-engineer's genesis.go helpers (G-008).
|
// within [floor, cap]). Delegates to the data-engineer's genesis.go helpers
|
||||||
|
// (G-008).
|
||||||
func ValidateGenesis(bz json.RawMessage) error {
|
func ValidateGenesis(bz json.RawMessage) error {
|
||||||
var gs GenesisState
|
var gs GenesisState
|
||||||
if err := json.Unmarshal(bz, &gs); err != nil {
|
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 {
|
if err := ValidateBonds(gs.Bonds); err != nil {
|
||||||
return fmt.Errorf("bond: %w", err)
|
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
|
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"`
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
// packageDir resolves a Go import path to its filesystem directory by
|
||||||
// walking up from this test file (v0.2 skeleton has zero external deps).
|
// walking up from this test file (v0.2 skeleton has zero external deps).
|
||||||
func packageDir(t *testing.T, importPath string) string {
|
func packageDir(t *testing.T, importPath string) string {
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user