Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fdf5bd71ff | |||
| a70d6faa59 |
@@ -0,0 +1,286 @@
|
||||
package keeper
|
||||
|
||||
// clob.go holds the CLOB (central-limit order book) matching engine for the
|
||||
// bond secondary market (P6-02-01, REQ-038, D-057 — price-time priority FCFS
|
||||
// per REQ-007; NO AMM — D-057/A-564).
|
||||
//
|
||||
// The CLOB engine is PER-TX matching (dYdX-v4-shaped, no batch end-of-block
|
||||
// matching in v0.5 simtest — D-054). The handler loads the resting book for
|
||||
// the bond, sorts by (price, sequence) for price-time priority, and matches
|
||||
// the incoming taker against the best opposing price until filled or the
|
||||
// book is empty.
|
||||
//
|
||||
// G-019 BINDING: this file defines the SINGLE ImpliedCoupon(priceBps,
|
||||
// principal) helper used by BOTH the CLOB match and the per-match clamp
|
||||
// check (D-063). The "implied coupon" derivation from trade price (fraction
|
||||
// of principal in bps) is the unstated precondition of the D-063 REJECT
|
||||
// threshold; a single helper + boundary unit test (800/801/799 bps) closes
|
||||
// the formula ambiguity.
|
||||
//
|
||||
// D-063/A-562: a match whose ImpliedCoupon EXCEEDS 800 bps is REJECTED
|
||||
// (fails closed — the resting order stays, the incoming order rests or is
|
||||
// cancelled; no refund path). The 8% cap is a Mission-Lock invariant (D-028),
|
||||
// not a soft cap. Matches within [0, 800] use Clamp (in-band, no refund
|
||||
// needed).
|
||||
//
|
||||
// The 8%/0% consts (CouponCapBps=800 / CouponFloorBps=0, D-028) are
|
||||
// referenced DIRECTLY from x/bond/types (same package — NOT a local copy;
|
||||
// A-563). The REQ-030 cross-const test stays green.
|
||||
//
|
||||
// Lexicon (REQ-012, A-210): the coupon vocabulary is used EXCLUSIVELY. The
|
||||
// banned coupon-synonyms are NEVER used.
|
||||
//
|
||||
// FEATURE PURITY GATE: the v0.3 types.SecondaryOrder struct is FROZEN (it
|
||||
// has PriceGrain int64, no PriceBps or QuantityGrain). To avoid amending the
|
||||
// v0.3 types/ contract, the CLOB book uses a keeper-internal restingOrder
|
||||
// struct carrying the price-bps + remaining quantity (the runtime book
|
||||
// state). The restingOrder embeds the public SecondaryOrder (the v0.3
|
||||
// contract is preserved) PLUS the keeper-internal book fields. This is the
|
||||
// "runtime adds behavior on top, not changes to the contract" pattern.
|
||||
|
||||
import (
|
||||
"sort"
|
||||
|
||||
sdk "github.com/cosmos/cosmos-sdk/types"
|
||||
|
||||
"github.com/oy/openyield/x/bond/types"
|
||||
)
|
||||
|
||||
// restingOrder is the in-keeper book entry for a resting secondary-market
|
||||
// order. It carries the public SecondaryOrder (the v0.3 type — frozen, not
|
||||
// amended, per the feature purity gate) PLUS the keeper-internal price-bps
|
||||
// and remaining-quantity and sequence for price-time priority FCFS
|
||||
// (REQ-007). The price-bps, remaining-quantity, and sequence are keeper-
|
||||
// internal concerns (NOT types/ contract fields); adding them here keeps
|
||||
// the v0.3 types/ contract unchanged (feature purity gate — no breaking
|
||||
// schema changes).
|
||||
type restingOrder struct {
|
||||
// Order is the public v0.3 SecondaryOrder (frozen contract). Carries
|
||||
// OrderID, BondID, Side, PriceGrain, HolderReachID, Status, CreatedAt.
|
||||
Order types.SecondaryOrder `json:"order" yaml:"order"`
|
||||
// PriceBps is the order price in basis points (the price as a fraction
|
||||
// of principal in bps — this is the implied coupon of a match at this
|
||||
// price; the CLOB matching engine's ImpliedCoupon helper derives the
|
||||
// per-match implied coupon from the resting order's price-bps, G-019).
|
||||
// Keeper-internal (the v0.3 SecondaryOrder has PriceGrain int64, not
|
||||
// PriceBps; the runtime uses PriceBps for the CLOB match).
|
||||
PriceBps uint32 `json:"price_bps" yaml:"price_bps"`
|
||||
// Sequence is the price-time-priority ordering key (monotonic; lower
|
||||
// sequence = earlier resting order = fills first at the same price —
|
||||
// REQ-007 FCFS).
|
||||
Sequence uint64 `json:"sequence" yaml:"sequence"`
|
||||
// RemainingQuantityGrain is the unfilled quantity of the order (a
|
||||
// resting order may be partially filled by an earlier match; the
|
||||
// remaining quantity is what later takers can match against).
|
||||
RemainingQuantityGrain int64 `json:"remaining_quantity_grain" yaml:"remaining_quantity_grain"`
|
||||
}
|
||||
|
||||
// ImpliedCoupon is the G-019 BINDING helper: it derives the implied coupon
|
||||
// (in basis points) of a trade at the given price-bps against the principal.
|
||||
// The implied coupon is the fraction of principal the trade price represents,
|
||||
// expressed in bps: a price of 10000 bps (100% of principal) implies a 0-bps
|
||||
// coupon (par); a price of 9500 bps (95% of principal, a discount) implies a
|
||||
// 500-bps coupon (the buyer pays 95% of principal and receives the full
|
||||
// principal at maturity, earning a 500-bps coupon).
|
||||
//
|
||||
// The formula: impliedCouponBps = max(0, 10000 - priceBps).
|
||||
// - priceBps == 10000 (par) -> impliedCoupon 0 (no discount, no coupon).
|
||||
// - priceBps < 10000 (discount) -> impliedCoupon = 10000 - priceBps (the
|
||||
// discount is the implied coupon).
|
||||
// - priceBps > 10000 (premium) -> the discount is negative; the implied
|
||||
// coupon is floored at 0 (a premium bond has a 0 implied coupon — the
|
||||
// buyer pays MORE than principal, so the implied coupon is 0, not
|
||||
// negative).
|
||||
//
|
||||
// The principal argument is accepted for signature compatibility with the
|
||||
// plan text (G-019: "ImpliedCoupon(priceBps, principal)") but does not
|
||||
// affect the implied-coupon derivation for a fixed-coupon bond (the coupon
|
||||
// is the discount-from-par in bps, independent of the principal amount).
|
||||
// It is retained so a future v0.6+ amortization model can use it.
|
||||
//
|
||||
// G-019 boundary: the D-063 REJECT threshold is 800 bps. A match whose
|
||||
// ImpliedCoupon exceeds 800 (price-bps < 9200 — a discount greater than
|
||||
// 800 bps) is REJECTED (fails closed). The boundary unit test in
|
||||
// msg_server_simtest_test.go covers:
|
||||
// - price-bps 9200 -> ImpliedCoupon 800 (== cap, in-band, clears via Clamp).
|
||||
// - price-bps 9199 -> ImpliedCoupon 801 (> cap, REJECTED — D-063).
|
||||
// - price-bps 9201 -> ImpliedCoupon 799 (< cap, in-band, clears).
|
||||
func ImpliedCoupon(priceBps uint32, principalGrain int64) uint32 {
|
||||
_ = principalGrain // retained for G-019 signature compatibility; unused
|
||||
// at v0.5 (fixed-coupon bond — coupon is discount-from-par in bps).
|
||||
if priceBps >= 10000 {
|
||||
return 0 // par or premium -> 0 implied coupon (floored at 0)
|
||||
}
|
||||
return 10000 - priceBps // discount -> the discount is the implied coupon
|
||||
}
|
||||
|
||||
// --- CLOB matching engine ----------------------------------------------------
|
||||
//
|
||||
// matchTaker attempts to match an incoming taker order against the resting
|
||||
// book for the given bond. Price-time priority FCFS per REQ-007:
|
||||
// - Buy taker matches against Sell resting orders with price-bps <= the
|
||||
// taker's price-bps, best (lowest) price first, then earliest sequence.
|
||||
// - Sell taker matches against Buy resting orders with price-bps >= the
|
||||
// taker's price-bps, best (highest) price first, then earliest sequence.
|
||||
//
|
||||
// Per D-063/A-562: every match's ImpliedCoupon is computed from the resting
|
||||
// order's price-bps; a match whose ImpliedCoupon EXCEEDS 800 bps is REJECTED
|
||||
// (fails closed). The rejection is PER-MATCH (not per-taker): if the best
|
||||
// resting order is above cap, that match is rejected, the resting order
|
||||
// stays on the book, and the taker does NOT advance to the next resting order
|
||||
// (fails closed — the taker is rejected; the resting book above cap is
|
||||
// unreachable). This is the mission-lock-true choice: the 8% cap is a hard
|
||||
// invariant, not a soft cap.
|
||||
//
|
||||
// Returns the total filled quantity, the list of filled order-ids (for
|
||||
// event emission), and a boolean indicating whether a per-match REJECT
|
||||
// occurred (D-063 — when true, no match occurred for the offending resting
|
||||
// order; the resting book is unchanged; the caller reports the reject).
|
||||
func (k Keeper) matchTaker(
|
||||
ctx sdk.Context,
|
||||
bondID string,
|
||||
takerSide types.OrderSide,
|
||||
takerPriceBps uint32,
|
||||
takerQuantityGrain int64,
|
||||
) (filledQuantityGrain int64, filledOrderIDs []string, rejected bool) {
|
||||
// Load the resting book for the bond.
|
||||
resting := k.restingBookForBond(ctx, bondID)
|
||||
// Sort for price-time priority.
|
||||
sortRestingBook(resting, takerSide)
|
||||
|
||||
remaining := takerQuantityGrain
|
||||
filledOrderIDs = []string{}
|
||||
|
||||
for i := range resting {
|
||||
if remaining <= 0 {
|
||||
break
|
||||
}
|
||||
ro := &resting[i]
|
||||
if ro.Order.Status != types.OrderOpen {
|
||||
continue // skip non-resting (defensive — the book holds Open only)
|
||||
}
|
||||
// Price check: does this resting order's price satisfy the taker?
|
||||
if !priceCrosses(takerSide, takerPriceBps, ro.PriceBps) {
|
||||
// The book is sorted best-price-first; once the price does not
|
||||
// cross, no later (worse-price) resting order will cross. Stop.
|
||||
break
|
||||
}
|
||||
// D-063 per-match coupon clamp (G-019 ImpliedCoupon helper). The
|
||||
// implied coupon is derived from the RESTING order's price-bps
|
||||
// (the price at which the match executes). A match above 800 bps
|
||||
// is REJECTED (fails closed — the resting order stays, the taker
|
||||
// does not advance).
|
||||
implied := ImpliedCoupon(ro.PriceBps, 0)
|
||||
if implied > types.CouponCapBps {
|
||||
// D-063 REJECT: the resting order stays on the book; the taker
|
||||
// is rejected (fails closed — no refund path, no advance to
|
||||
// the next resting order).
|
||||
return filledQuantityGrain, filledOrderIDs, true
|
||||
}
|
||||
// In-band match (implied coupon within [0, 800]). Clamp it (the
|
||||
// 8% cap is the firewall; Clamp is the helper — defense in depth,
|
||||
// though ImpliedCoupon <= 800 here so Clamp is a no-op).
|
||||
clampedCoupon := types.Clamp(implied)
|
||||
// Determine the fill quantity (the smaller of the taker's
|
||||
// remaining quantity and the resting order's remaining quantity).
|
||||
fill := remaining
|
||||
if ro.RemainingQuantityGrain < fill {
|
||||
fill = ro.RemainingQuantityGrain
|
||||
}
|
||||
// Update the resting order's remaining quantity.
|
||||
ro.RemainingQuantityGrain -= fill
|
||||
remaining -= fill
|
||||
filledQuantityGrain += fill
|
||||
filledOrderIDs = append(filledOrderIDs, ro.Order.OrderID)
|
||||
// If the resting order is fully filled, mark it Filled and delete
|
||||
// it from the book; otherwise persist the updated remaining.
|
||||
if ro.RemainingQuantityGrain <= 0 {
|
||||
ro.Order.Status = types.OrderFilled
|
||||
k.deleteRestingOrder(ctx, ro.Order.OrderID)
|
||||
} else {
|
||||
k.setRestingOrder(ctx, *ro)
|
||||
}
|
||||
// Emit a match event with the clamped coupon for simtest assertion.
|
||||
emitMatchEvent(ctx, ro.Order.OrderID, bondID, clampedCoupon, fill)
|
||||
}
|
||||
return filledQuantityGrain, filledOrderIDs, false
|
||||
}
|
||||
|
||||
// restingBookForBond loads all resting orders for a given bond-id (the CLOB
|
||||
// book for that bond). The book is unordered here; matchTaker sorts it for
|
||||
// price-time priority.
|
||||
func (k Keeper) restingBookForBond(ctx sdk.Context, bondID string) []restingOrder {
|
||||
all := k.AllRestingOrders(ctx)
|
||||
out := make([]restingOrder, 0, len(all))
|
||||
for _, ro := range all {
|
||||
if ro.Order.BondID == bondID && ro.Order.Status == types.OrderOpen {
|
||||
out = append(out, ro)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// sortRestingBook sorts the resting book for price-time priority FCFS
|
||||
// (REQ-007). For a Buy taker (matching against Sell resting orders), the
|
||||
// best price is the LOWEST Sell price (cheapest to buy); for a Sell taker
|
||||
// (matching against Buy resting orders), the best price is the HIGHEST Buy
|
||||
// price (most expensive to sell to). Ties at the same price are broken by
|
||||
// sequence (earlier sequence fills first — FCFS).
|
||||
func sortRestingBook(book []restingOrder, takerSide types.OrderSide) {
|
||||
if takerSide == types.OrderBuy {
|
||||
// Buy taker: sort Sell resting orders by ascending price, then
|
||||
// ascending sequence (best price = lowest; FCFS at same price).
|
||||
sort.SliceStable(book, func(i, j int) bool {
|
||||
if book[i].PriceBps != book[j].PriceBps {
|
||||
return book[i].PriceBps < book[j].PriceBps
|
||||
}
|
||||
return book[i].Sequence < book[j].Sequence
|
||||
})
|
||||
} else {
|
||||
// Sell taker: sort Buy resting orders by descending price, then
|
||||
// ascending sequence (best price = highest; FCFS at same price).
|
||||
sort.SliceStable(book, func(i, j int) bool {
|
||||
if book[i].PriceBps != book[j].PriceBps {
|
||||
return book[i].PriceBps > book[j].PriceBps
|
||||
}
|
||||
return book[i].Sequence < book[j].Sequence
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// priceCrosses reports whether the taker's price satisfies the resting
|
||||
// order's price (a match can execute). For a Buy taker, the taker's price-
|
||||
// bps must be >= the resting Sell's price-bps (the buyer will pay up to
|
||||
// takerPriceBps; the seller asked for restingPriceBps; if taker >= resting,
|
||||
// the price crosses). For a Sell taker, the taker's price-bps must be <=
|
||||
// the resting Buy's price-bps (the seller will accept as low as
|
||||
// takerPriceBps; the buyer bid restingPriceBps; if taker <= resting, the
|
||||
// price crosses).
|
||||
func priceCrosses(takerSide types.OrderSide, takerPriceBps, restingPriceBps uint32) bool {
|
||||
if takerSide == types.OrderBuy {
|
||||
return takerPriceBps >= restingPriceBps
|
||||
}
|
||||
return takerPriceBps <= restingPriceBps
|
||||
}
|
||||
|
||||
// emitMatchEvent emits a per-match event for simtest assertion. The event
|
||||
// carries the resting order-id, the bond-id, the clamped matched coupon
|
||||
// (within [0, 800] bps — D-063 in-band), and the fill quantity.
|
||||
//
|
||||
// NOTE: emitMatchEvent is called from matchTaker, which is a Keeper method
|
||||
// (not on msgServer). The ctx is the sdk.Context passed to matchTaker. This
|
||||
// helper is defined here (not in msg_server.go) so the CLOB engine is
|
||||
// self-contained.
|
||||
func emitMatchEvent(ctx sdk.Context, restingOrderID, bondID string, matchedCouponBps uint32, fillQuantityGrain int64) {
|
||||
// Avoid importing sdk event helpers in clob.go to keep the import list
|
||||
// lean; delegate to the msg_server.go helper via a function variable.
|
||||
// (The simtest asserts events via ctx.EventManager().Events().)
|
||||
if emitMatchEventHook != nil {
|
||||
emitMatchEventHook(ctx, restingOrderID, bondID, matchedCouponBps, fillQuantityGrain)
|
||||
}
|
||||
}
|
||||
|
||||
// emitMatchEventHook is set by msg_server.go (which imports sdk event
|
||||
// helpers). This indirection keeps clob.go's import list minimal (sort +
|
||||
// types only) and avoids a circular dependency on the sdk event package.
|
||||
var emitMatchEventHook func(ctx sdk.Context, restingOrderID, bondID string, matchedCouponBps uint32, fillQuantityGrain int64)
|
||||
@@ -0,0 +1,261 @@
|
||||
package keeper
|
||||
|
||||
// keeper.go holds the store-backed Keeper for the bond module's market
|
||||
// runtime (P6-02-01, REQ-038, D-057 — CLOB price-time priority FCFS per
|
||||
// REQ-007; NO AMM — D-057/A-564).
|
||||
//
|
||||
// The Keeper wraps an sdk.KVStore via a storeKey. It holds:
|
||||
// - the issued bonds (bond-id → Bond);
|
||||
// - the issued GrowthBonds (bond-id → GrowthBond);
|
||||
// - the resting secondary-market orders (the CLOB book — order-id →
|
||||
// restingOrder, plus a per-bond price-time-priority sequence index in
|
||||
// clob.go).
|
||||
//
|
||||
// The Keeper also holds the StandKeeper expected-keeper shim (G-003 —
|
||||
// interface, NOT a struct import of x/stand/types; the concrete stand
|
||||
// keeper satisfies it structurally; the P6 simtest wires a stub).
|
||||
//
|
||||
// The 8%/0% consts (CouponCapBps=800 / CouponFloorBps=0, D-028) are
|
||||
// referenced DIRECTLY from x/bond/types (same package — NOT a local copy;
|
||||
// A-563). The REQ-030 cross-const test (x/hub LendingCouponCapBps ==
|
||||
// x/bond CouponCapBps) stays green because the consts are unchanged.
|
||||
//
|
||||
// State-machine ordering (vision §7, enforced in every handler):
|
||||
// ValidateBasic → keeper authz → state mutation → ctx.EventManager().EmitEvent
|
||||
//
|
||||
// D-054: simtest-grade — in-memory sdk.Context + dbm in-memory store, no
|
||||
// real IBC light clients, no real Stand keeper (the StandKeeper shim is a
|
||||
// stub), no real DEX venues. The handler is documented as NOT front-running-
|
||||
// safe for mainnet (a Year-3+ concern; the simtest does NOT assert front-
|
||||
// running safety).
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
storetypes "cosmossdk.io/store/types"
|
||||
"github.com/cosmos/cosmos-sdk/codec"
|
||||
sdk "github.com/cosmos/cosmos-sdk/types"
|
||||
|
||||
"github.com/oy/openyield/x/bond/types"
|
||||
)
|
||||
|
||||
// Keeper is the store-backed bond market keeper.
|
||||
type Keeper struct {
|
||||
cdc codec.Codec
|
||||
storeKey storetypes.StoreKey
|
||||
standKeeper types.StandKeeper
|
||||
seq uint64 // monotonic sequence for price-time priority (CLOB)
|
||||
}
|
||||
|
||||
// NewKeeper constructs a new store-backed bond Keeper. The StandKeeper
|
||||
// expected-keeper shim is injected (nil-able for partial tests; the
|
||||
// IssueBond / IssueGrowthBond handlers guard a nil shim and skip the
|
||||
// StandExists check, still mutating state — the simtest wiring documents
|
||||
// this).
|
||||
func NewKeeper(cdc codec.Codec, storeKey storetypes.StoreKey, sk types.StandKeeper) Keeper {
|
||||
return Keeper{
|
||||
cdc: cdc,
|
||||
storeKey: storeKey,
|
||||
standKeeper: sk,
|
||||
}
|
||||
}
|
||||
|
||||
// SetStandKeeper sets the StandKeeper expected-keeper shim (for post-
|
||||
// construction wiring, e.g., app wiring or test setup).
|
||||
func (k *Keeper) SetStandKeeper(sk types.StandKeeper) { k.standKeeper = sk }
|
||||
|
||||
// StoreKey returns the keeper's store key (exported for simtest access to
|
||||
// the raw KVStore for corrupt-byte injection in marshal-error coverage
|
||||
// paths).
|
||||
func (k Keeper) StoreKey() storetypes.StoreKey { return k.storeKey }
|
||||
|
||||
// nextSequence returns the next monotonic sequence number for price-time
|
||||
// priority ordering on the CLOB book (REQ-007 FCFS — earlier resting orders
|
||||
// have lower sequence numbers and fill first at the same price). The
|
||||
// sequence is monotonically increasing across all orders in the keeper's
|
||||
// lifetime (simtest grade — not persisted across restarts; a live chain would
|
||||
// persist the sequence in the store).
|
||||
func (k *Keeper) nextSequence() uint64 {
|
||||
k.seq++
|
||||
return k.seq
|
||||
}
|
||||
|
||||
// --- Bond store --------------------------------------------------------------
|
||||
|
||||
var bondKeyPrefix = []byte("bond/")
|
||||
|
||||
func bondKey(bondID string) []byte {
|
||||
return append(bondKeyPrefix, []byte(bondID)...)
|
||||
}
|
||||
|
||||
// GetBond loads an issued Bond by bond-id. Returns the Bond and true if
|
||||
// found, or zero value + false if not.
|
||||
func (k Keeper) GetBond(ctx sdk.Context, bondID string) (types.Bond, bool) {
|
||||
store := ctx.KVStore(k.storeKey)
|
||||
bz := store.Get(bondKey(bondID))
|
||||
if bz == nil {
|
||||
return types.Bond{}, false
|
||||
}
|
||||
var b types.Bond
|
||||
if err := json.Unmarshal(bz, &b); err != nil {
|
||||
return types.Bond{}, false
|
||||
}
|
||||
return b, true
|
||||
}
|
||||
|
||||
// SetBond persists an issued Bond by bond-id.
|
||||
func (k Keeper) SetBond(ctx sdk.Context, b types.Bond) {
|
||||
store := ctx.KVStore(k.storeKey)
|
||||
bz, err := json.Marshal(b)
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("bond: marshal bond %q: %v", b.BondID, err))
|
||||
}
|
||||
store.Set(bondKey(b.BondID), bz)
|
||||
}
|
||||
|
||||
// AllBonds returns all issued Bonds (iteration helper, unordered).
|
||||
func (k Keeper) AllBonds(ctx sdk.Context) []types.Bond {
|
||||
store := ctx.KVStore(k.storeKey)
|
||||
iterator := store.Iterator(bondKeyPrefix, prefixEnd(bondKeyPrefix))
|
||||
defer iterator.Close()
|
||||
out := []types.Bond{}
|
||||
for ; iterator.Valid(); iterator.Next() {
|
||||
var b types.Bond
|
||||
if err := json.Unmarshal(iterator.Value(), &b); err == nil {
|
||||
out = append(out, b)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// --- GrowthBond store --------------------------------------------------------
|
||||
|
||||
var growthBondKeyPrefix = []byte("growth/")
|
||||
|
||||
func growthBondKey(bondID string) []byte {
|
||||
return append(growthBondKeyPrefix, []byte(bondID)...)
|
||||
}
|
||||
|
||||
// GetGrowthBond loads an issued GrowthBond by bond-id. Returns the GrowthBond
|
||||
// and true if found, or zero value + false if not.
|
||||
func (k Keeper) GetGrowthBond(ctx sdk.Context, bondID string) (types.GrowthBond, bool) {
|
||||
store := ctx.KVStore(k.storeKey)
|
||||
bz := store.Get(growthBondKey(bondID))
|
||||
if bz == nil {
|
||||
return types.GrowthBond{}, false
|
||||
}
|
||||
var gb types.GrowthBond
|
||||
if err := json.Unmarshal(bz, &gb); err != nil {
|
||||
return types.GrowthBond{}, false
|
||||
}
|
||||
return gb, true
|
||||
}
|
||||
|
||||
// SetGrowthBond persists an issued GrowthBond by bond-id.
|
||||
func (k Keeper) SetGrowthBond(ctx sdk.Context, gb types.GrowthBond) {
|
||||
store := ctx.KVStore(k.storeKey)
|
||||
bz, err := json.Marshal(gb)
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("bond: marshal growth bond %q: %v", gb.BondID, err))
|
||||
}
|
||||
store.Set(growthBondKey(gb.BondID), bz)
|
||||
}
|
||||
|
||||
// AllGrowthBonds returns all issued GrowthBonds (iteration helper, unordered).
|
||||
func (k Keeper) AllGrowthBonds(ctx sdk.Context) []types.GrowthBond {
|
||||
store := ctx.KVStore(k.storeKey)
|
||||
iterator := store.Iterator(growthBondKeyPrefix, prefixEnd(growthBondKeyPrefix))
|
||||
defer iterator.Close()
|
||||
out := []types.GrowthBond{}
|
||||
for ; iterator.Valid(); iterator.Next() {
|
||||
var gb types.GrowthBond
|
||||
if err := json.Unmarshal(iterator.Value(), &gb); err == nil {
|
||||
out = append(out, gb)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// --- Order store (CLOB resting book) -----------------------------------------
|
||||
//
|
||||
// The resting book is keyed by order-id → restingOrder (the in-keeper book
|
||||
// entry carrying the order + its price-time-priority sequence). The CLOB
|
||||
// matching engine (clob.go) loads all resting orders for a bond, sorts them
|
||||
// by (price, sequence) for price-time priority FCFS, and matches the
|
||||
// incoming taker against the best opposing price until filled or the book
|
||||
// is empty.
|
||||
|
||||
var orderKeyPrefix = []byte("order/")
|
||||
|
||||
func orderKey(orderID string) []byte {
|
||||
return append(orderKeyPrefix, []byte(orderID)...)
|
||||
}
|
||||
|
||||
// GetRestingOrder loads a resting order by order-id. Returns the order and
|
||||
// true if found, or zero value + false if not.
|
||||
func (k Keeper) GetRestingOrder(ctx sdk.Context, orderID string) (restingOrder, bool) {
|
||||
store := ctx.KVStore(k.storeKey)
|
||||
bz := store.Get(orderKey(orderID))
|
||||
if bz == nil {
|
||||
return restingOrder{}, false
|
||||
}
|
||||
var o restingOrder
|
||||
if err := json.Unmarshal(bz, &o); err != nil {
|
||||
return restingOrder{}, false
|
||||
}
|
||||
return o, true
|
||||
}
|
||||
|
||||
// setRestingOrder persists a resting order by order-id.
|
||||
func (k Keeper) setRestingOrder(ctx sdk.Context, o restingOrder) {
|
||||
store := ctx.KVStore(k.storeKey)
|
||||
bz, err := json.Marshal(o)
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("bond: marshal order %q: %v", o.Order.OrderID, err))
|
||||
}
|
||||
store.Set(orderKey(o.Order.OrderID), bz)
|
||||
}
|
||||
|
||||
// deleteRestingOrder removes a resting order by order-id.
|
||||
func (k Keeper) deleteRestingOrder(ctx sdk.Context, orderID string) {
|
||||
store := ctx.KVStore(k.storeKey)
|
||||
store.Delete(orderKey(orderID))
|
||||
}
|
||||
|
||||
// AllRestingOrders returns all resting orders (iteration helper, unordered).
|
||||
// Exported for simtest assertion.
|
||||
func (k Keeper) AllRestingOrders(ctx sdk.Context) []restingOrder {
|
||||
store := ctx.KVStore(k.storeKey)
|
||||
iterator := store.Iterator(orderKeyPrefix, prefixEnd(orderKeyPrefix))
|
||||
defer iterator.Close()
|
||||
out := []restingOrder{}
|
||||
for ; iterator.Valid(); iterator.Next() {
|
||||
var o restingOrder
|
||||
if err := json.Unmarshal(iterator.Value(), &o); err == nil {
|
||||
out = append(out, o)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// --- prefixEnd helper --------------------------------------------------------
|
||||
|
||||
// prefixEnd returns the key that sorts immediately after all keys sharing
|
||||
// the given prefix (the standard prefix-iteration end key: increment the
|
||||
// last byte, drop overflow). Mirrors x/hub/keeper/keeper.go.
|
||||
func prefixEnd(prefix []byte) []byte {
|
||||
if len(prefix) == 0 {
|
||||
return nil
|
||||
}
|
||||
end := make([]byte, len(prefix))
|
||||
copy(end, prefix)
|
||||
for i := len(end) - 1; i >= 0; i-- {
|
||||
end[i]++
|
||||
if end[i] != 0 {
|
||||
return end
|
||||
}
|
||||
}
|
||||
// All bytes were 0xFF; return nil (iterate to end of store).
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,428 @@
|
||||
package keeper
|
||||
|
||||
// msg_server.go implements the bond module's MsgServer (P6-02-01, REQ-038;
|
||||
// G-023 ownership split: cosmos-engineer scaffolds the file structure +
|
||||
// method signatures; backend-engineer implements the handler logic bodies;
|
||||
// security-engineer reviews the CLOB per-match clamp D-063 + the 8%/0%
|
||||
// const firewall A-563). The MsgServer wraps the Keeper + the StandKeeper
|
||||
// expected-keeper shim (already on the Keeper).
|
||||
//
|
||||
// Each method returns a (*Response, error). Handler state-machine ordering
|
||||
// is enforced: ValidateBasic → keeper authz → state mutation →
|
||||
// ctx.EventManager().EmitEvent.
|
||||
//
|
||||
// Handler set (REQ-038):
|
||||
// - IssueBond: invokes v0.3 Clamp on the coupon at issuance (the clamped
|
||||
// value is recorded, NOT the original). StandKeeper shim validates the
|
||||
// issuer-stand-id exists (P1-02-01 stand-id-ref edge).
|
||||
// - IssueGrowthBond: invokes Clamp on the coupon + ClampGrowth on the
|
||||
// growth-rate (post-growth coupon <= cap, G-012).
|
||||
// - TickGrowthBond: applies one growth tick (coupon += growth-rate, then
|
||||
// clamped so post-growth <= cap via ClampGrowth with currentBps = the
|
||||
// current coupon).
|
||||
// - PlaceSecondaryOrder: rests a secondary-market order on the CLOB book
|
||||
// (price-time priority FCFS per REQ-007; NO AMM — D-057).
|
||||
// - CancelSecondaryOrder: removes a resting order (status -> Cancelled).
|
||||
// - MatchSecondaryOrder: CLOB match against the resting book (per-tx
|
||||
// matching, dYdX-v4-shaped); per-match coupon clamp via the G-019
|
||||
// ImpliedCoupon helper; D-063 REJECT above 800 (fails closed).
|
||||
//
|
||||
// Nil-shim behavior (simtest wiring): a nil StandKeeper shim skips the
|
||||
// StandExists check (the handler still mutates state — the simtest documents
|
||||
// the wiring contract). The 8%/0% consts are referenced directly from
|
||||
// x/bond/types (same package — NOT a local copy; A-563); the REQ-030
|
||||
// cross-const test stays green.
|
||||
//
|
||||
// The handler is documented as NOT front-running-safe for mainnet (a
|
||||
// Year-3+ concern; the simtest does NOT assert front-running safety — D-054).
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
sdk "github.com/cosmos/cosmos-sdk/types"
|
||||
|
||||
"github.com/oy/openyield/x/bond/types"
|
||||
)
|
||||
|
||||
// init wires the emitMatchEventHook so the CLOB engine (clob.go) emits
|
||||
// sdk events via the keeper's ctx without importing the sdk event helpers
|
||||
// in clob.go (keeps clob.go's import list minimal).
|
||||
func init() {
|
||||
emitMatchEventHook = func(ctx sdk.Context, restingOrderID, bondID string, matchedCouponBps uint32, fillQuantityGrain int64) {
|
||||
ctx.EventManager().EmitEvent(sdk.NewEvent(
|
||||
"bond.match",
|
||||
sdk.NewAttribute("resting_order_id", restingOrderID),
|
||||
sdk.NewAttribute("bond_id", bondID),
|
||||
sdk.NewAttribute("matched_coupon_bps", fmt.Sprintf("%d", matchedCouponBps)),
|
||||
sdk.NewAttribute("fill_quantity_grain", fmt.Sprintf("%d", fillQuantityGrain)),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
// msgServer is the concrete MsgServer implementation wrapping the Keeper.
|
||||
type msgServer struct {
|
||||
Keeper
|
||||
}
|
||||
|
||||
// NewMsgServerImpl returns the bond MsgServer for the provided Keeper.
|
||||
func NewMsgServerImpl(k Keeper) types.MsgServer {
|
||||
return &msgServer{Keeper: k}
|
||||
}
|
||||
|
||||
var _ types.MsgServer = msgServer{}
|
||||
|
||||
// unwrapCtx extracts the sdk.Context from the interface-typed ctx.
|
||||
func unwrapCtx(ctx interface{}) sdk.Context {
|
||||
if c, ok := ctx.(sdk.Context); ok {
|
||||
return c
|
||||
}
|
||||
panic(fmt.Sprintf("bond: expected sdk.Context, got %T", ctx))
|
||||
}
|
||||
|
||||
// --- IssueBond ---------------------------------------------------------------
|
||||
|
||||
// IssueBond issues a fixed-coupon Bond (REQ-038). The handler enforces:
|
||||
// 1. ValidateBasic (stateless).
|
||||
// 2. Idempotency: bond-id must not already exist.
|
||||
// 3. StandKeeper shim: the issuer-stand-id must reference an existing
|
||||
// Stand (P1-02-01 stand-id-ref edge). A nil shim skips this check
|
||||
// (simtest wiring); a non-nil shim that returns false REJECTS the
|
||||
// issuance (the bond is not created).
|
||||
// 4. Coupon clamp: the coupon-bps is CLAMPED to [CouponFloorBps=0,
|
||||
// CouponCapBps=800] at runtime via the v0.3 Clamp helper (A-563 —
|
||||
// defense in depth; ValidateBasic already rejected out-of-band, but the
|
||||
// handler re-clamps to defend against any future cap change).
|
||||
//
|
||||
// On success the Bond is persisted with the clamped coupon and an event is
|
||||
// emitted.
|
||||
func (s msgServer) IssueBond(ctx interface{}, msg *types.MsgIssueBond) (*types.MsgIssueBondResponse, error) {
|
||||
if err := msg.ValidateBasic(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sdkCtx := unwrapCtx(ctx)
|
||||
|
||||
// Idempotency: bond-id must not already exist.
|
||||
if _, ok := s.Keeper.GetBond(sdkCtx, msg.BondID); ok {
|
||||
return nil, fmt.Errorf("bond: bond-id %q already exists", msg.BondID)
|
||||
}
|
||||
|
||||
// StandKeeper: issuer-stand-id must reference an existing Stand (P1-02-01
|
||||
// edge). A nil shim skips the check (simtest wiring); a non-nil shim that
|
||||
// returns false REJECTS the issuance.
|
||||
if s.Keeper.standKeeper != nil {
|
||||
if !s.Keeper.standKeeper.StandExists(msg.IssuerStandID) {
|
||||
return nil, fmt.Errorf("bond: issuer-stand-id %q does not exist (IssueBond rejected)", msg.IssuerStandID)
|
||||
}
|
||||
}
|
||||
|
||||
// A-563: coupon clamp at runtime. The clamped value (NOT the original)
|
||||
// is recorded. ValidateBasic already rejected out-of-band, so Clamp is
|
||||
// a no-op here; the re-clamp is defense in depth against any future cap
|
||||
// change.
|
||||
clamped := types.Clamp(msg.CouponBps)
|
||||
b := types.Issue(msg.BondID, msg.IssuerStandID, msg.PrincipalGrain, clamped, msg.TermDays, msg.IssuedAt, msg.Maturity)
|
||||
s.Keeper.SetBond(sdkCtx, b)
|
||||
|
||||
if clamped != msg.CouponBps {
|
||||
sdkCtx.EventManager().EmitEvent(sdk.NewEvent(
|
||||
"bond.coupon_clamped",
|
||||
sdk.NewAttribute("bond_id", msg.BondID),
|
||||
sdk.NewAttribute("original_coupon_bps", fmt.Sprintf("%d", msg.CouponBps)),
|
||||
sdk.NewAttribute("clamped_coupon_bps", fmt.Sprintf("%d", clamped)),
|
||||
))
|
||||
}
|
||||
|
||||
sdkCtx.EventManager().EmitEvent(sdk.NewEvent(
|
||||
"bond.issued",
|
||||
sdk.NewAttribute("bond_id", msg.BondID),
|
||||
sdk.NewAttribute("issuer_stand_id", msg.IssuerStandID),
|
||||
sdk.NewAttribute("coupon_bps", fmt.Sprintf("%d", clamped)),
|
||||
))
|
||||
return &types.MsgIssueBondResponse{ClampedCouponBps: clamped}, nil
|
||||
}
|
||||
|
||||
// --- IssueGrowthBond ---------------------------------------------------------
|
||||
|
||||
// IssueGrowthBond issues a GrowthBond (REQ-038). The handler enforces:
|
||||
// 1. ValidateBasic (stateless).
|
||||
// 2. Idempotency: bond-id must not already exist (as a Bond or GrowthBond).
|
||||
// 3. StandKeeper shim: the issuer-stand-id must reference an existing
|
||||
// Stand (P1-02-01 edge). A nil shim skips (simtest wiring).
|
||||
// 4. Coupon clamp + growth clamp: the coupon is CLAMPED to [0, 800] via
|
||||
// Clamp, and the growth-rate is CLAMPED via ClampGrowth so post-growth
|
||||
// coupon <= cap (G-012).
|
||||
//
|
||||
// On success the GrowthBond is persisted with the clamped coupon + clamped
|
||||
// growth-rate and an event is emitted.
|
||||
func (s msgServer) IssueGrowthBond(ctx interface{}, msg *types.MsgIssueGrowthBond) (*types.MsgIssueGrowthBondResponse, error) {
|
||||
if err := msg.ValidateBasic(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sdkCtx := unwrapCtx(ctx)
|
||||
|
||||
// Idempotency: bond-id must not already exist (as Bond or GrowthBond).
|
||||
if _, ok := s.Keeper.GetBond(sdkCtx, msg.BondID); ok {
|
||||
return nil, fmt.Errorf("bond: bond-id %q already exists (as a Bond)", msg.BondID)
|
||||
}
|
||||
if _, ok := s.Keeper.GetGrowthBond(sdkCtx, msg.BondID); ok {
|
||||
return nil, fmt.Errorf("bond: bond-id %q already exists (as a GrowthBond)", msg.BondID)
|
||||
}
|
||||
|
||||
// StandKeeper: issuer-stand-id must reference an existing Stand.
|
||||
if s.Keeper.standKeeper != nil {
|
||||
if !s.Keeper.standKeeper.StandExists(msg.IssuerStandID) {
|
||||
return nil, fmt.Errorf("bond: issuer-stand-id %q does not exist (IssueGrowthBond rejected)", msg.IssuerStandID)
|
||||
}
|
||||
}
|
||||
|
||||
// Coupon clamp + growth clamp. The v0.3 IssueGrowth helper clamps the
|
||||
// coupon via Clamp and the growth-rate via ClampGrowth (G-012).
|
||||
clampedCoupon := types.Clamp(msg.CouponBps)
|
||||
clampedGrowth := types.ClampGrowth(clampedCoupon, msg.GrowthRateBps)
|
||||
gb := types.IssueGrowth(msg.BondID, msg.IssuerStandID, msg.PrincipalGrain, clampedCoupon, clampedGrowth, msg.TermDays, msg.IssuedAt, msg.Maturity)
|
||||
s.Keeper.SetGrowthBond(sdkCtx, gb)
|
||||
|
||||
if clampedCoupon != msg.CouponBps || clampedGrowth != msg.GrowthRateBps {
|
||||
sdkCtx.EventManager().EmitEvent(sdk.NewEvent(
|
||||
"bond.growth_coupon_clamped",
|
||||
sdk.NewAttribute("bond_id", msg.BondID),
|
||||
sdk.NewAttribute("original_coupon_bps", fmt.Sprintf("%d", msg.CouponBps)),
|
||||
sdk.NewAttribute("clamped_coupon_bps", fmt.Sprintf("%d", clampedCoupon)),
|
||||
sdk.NewAttribute("original_growth_rate_bps", fmt.Sprintf("%d", msg.GrowthRateBps)),
|
||||
sdk.NewAttribute("clamped_growth_rate_bps", fmt.Sprintf("%d", clampedGrowth)),
|
||||
))
|
||||
}
|
||||
|
||||
sdkCtx.EventManager().EmitEvent(sdk.NewEvent(
|
||||
"bond.growth_issued",
|
||||
sdk.NewAttribute("bond_id", msg.BondID),
|
||||
sdk.NewAttribute("issuer_stand_id", msg.IssuerStandID),
|
||||
sdk.NewAttribute("coupon_bps", fmt.Sprintf("%d", clampedCoupon)),
|
||||
sdk.NewAttribute("growth_rate_bps", fmt.Sprintf("%d", clampedGrowth)),
|
||||
))
|
||||
return &types.MsgIssueGrowthBondResponse{
|
||||
ClampedCouponBps: clampedCoupon,
|
||||
ClampedGrowthRateBps: clampedGrowth,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// --- TickGrowthBond ----------------------------------------------------------
|
||||
|
||||
// TickGrowthBond applies one growth tick to a GrowthBond (REQ-038). The
|
||||
// handler enforces:
|
||||
// 1. ValidateBasic (stateless).
|
||||
// 2. The GrowthBond must exist.
|
||||
// 3. Growth tick: the coupon grows by the growth-rate, clamped so post-
|
||||
// growth coupon <= CouponCapBps via ClampGrowth (with currentBps = the
|
||||
// current coupon). The growth-rate is NOT changed (it persists across
|
||||
// ticks).
|
||||
//
|
||||
// On success the GrowthBond's coupon is updated to the post-growth (clamped)
|
||||
// value and an event is emitted.
|
||||
func (s msgServer) TickGrowthBond(ctx interface{}, msg *types.MsgTickGrowthBond) (*types.MsgTickGrowthBondResponse, error) {
|
||||
if err := msg.ValidateBasic(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sdkCtx := unwrapCtx(ctx)
|
||||
|
||||
gb, ok := s.Keeper.GetGrowthBond(sdkCtx, msg.BondID)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("bond: growth-bond %q not found (TickGrowthBond rejected)", msg.BondID)
|
||||
}
|
||||
|
||||
// Growth tick: coupon += growth-rate, clamped so post-growth <= cap.
|
||||
// ClampGrowth(currentBps=current coupon, growthBps=growth-rate) returns
|
||||
// the additional bps the coupon can grow; post-growth coupon = current +
|
||||
// additional, which is <= cap by ClampGrowth's G-012 guard.
|
||||
additional := types.ClampGrowth(gb.CouponBps, gb.GrowthRateBps)
|
||||
postGrowth := gb.CouponBps + additional
|
||||
gb.CouponBps = postGrowth
|
||||
s.Keeper.SetGrowthBond(sdkCtx, gb)
|
||||
|
||||
sdkCtx.EventManager().EmitEvent(sdk.NewEvent(
|
||||
"bond.growth_ticked",
|
||||
sdk.NewAttribute("bond_id", msg.BondID),
|
||||
sdk.NewAttribute("post_growth_coupon_bps", fmt.Sprintf("%d", postGrowth)),
|
||||
sdk.NewAttribute("growth_rate_bps", fmt.Sprintf("%d", gb.GrowthRateBps)),
|
||||
))
|
||||
return &types.MsgTickGrowthBondResponse{PostGrowthCouponBps: postGrowth}, nil
|
||||
}
|
||||
|
||||
// --- PlaceSecondaryOrder -----------------------------------------------------
|
||||
|
||||
// PlaceSecondaryOrder rests a secondary-market order on the CLOB book
|
||||
// (REQ-038, D-057 — price-time priority FCFS per REQ-007; NO AMM). The
|
||||
// handler enforces:
|
||||
// 1. ValidateBasic (stateless).
|
||||
// 2. Idempotency: order-id must not already exist.
|
||||
// 3. The referenced bond must exist (the order rests on an issued bond).
|
||||
// 4. The order is rested on the book with a monotonic sequence for price-
|
||||
// time priority (REQ-007 FCFS — earlier resting orders fill first at
|
||||
// the same price).
|
||||
//
|
||||
// On success the order is persisted as Open (resting) and an event is
|
||||
// emitted.
|
||||
func (s msgServer) PlaceSecondaryOrder(ctx interface{}, msg *types.MsgPlaceSecondaryOrder) (*types.MsgPlaceSecondaryOrderResponse, error) {
|
||||
if err := msg.ValidateBasic(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sdkCtx := unwrapCtx(ctx)
|
||||
|
||||
// Idempotency: order-id must not already exist.
|
||||
if _, ok := s.Keeper.GetRestingOrder(sdkCtx, msg.OrderID); ok {
|
||||
return nil, fmt.Errorf("bond: order-id %q already exists (PlaceSecondaryOrder rejected)", msg.OrderID)
|
||||
}
|
||||
// The referenced bond must exist (the order rests on an issued bond).
|
||||
if _, ok := s.Keeper.GetBond(sdkCtx, msg.BondID); !ok {
|
||||
if _, ok := s.Keeper.GetGrowthBond(sdkCtx, msg.BondID); !ok {
|
||||
return nil, fmt.Errorf("bond: bond-id %q does not exist (PlaceSecondaryOrder rejected)", msg.BondID)
|
||||
}
|
||||
}
|
||||
|
||||
// Construct the public v0.3 SecondaryOrder (the frozen contract). The
|
||||
// price-bps is stored on the keeper-internal restingOrder (NOT on the
|
||||
// public SecondaryOrder, which has PriceGrain int64 — feature purity
|
||||
// gate: the v0.3 contract is not amended). PriceGrain is seeded from
|
||||
// PriceBps for cross-reference (the v0.3 field retains a value for
|
||||
// genesis round-trip; the CLOB match uses PriceBps).
|
||||
so := types.SecondaryOrder{
|
||||
OrderID: msg.OrderID,
|
||||
BondID: msg.BondID,
|
||||
Side: msg.Side,
|
||||
PriceGrain: int64(msg.PriceBps),
|
||||
HolderReachID: msg.HolderReachID,
|
||||
Status: types.OrderOpen,
|
||||
CreatedAt: sdkCtx.BlockTime().Unix(),
|
||||
}
|
||||
ro := restingOrder{
|
||||
Order: so,
|
||||
PriceBps: msg.PriceBps,
|
||||
Sequence: s.Keeper.nextSequence(),
|
||||
RemainingQuantityGrain: msg.QuantityGrain,
|
||||
}
|
||||
s.Keeper.setRestingOrder(sdkCtx, ro)
|
||||
|
||||
sdkCtx.EventManager().EmitEvent(sdk.NewEvent(
|
||||
"bond.order_placed",
|
||||
sdk.NewAttribute("order_id", msg.OrderID),
|
||||
sdk.NewAttribute("bond_id", msg.BondID),
|
||||
sdk.NewAttribute("side", string(msg.Side)),
|
||||
sdk.NewAttribute("price_bps", fmt.Sprintf("%d", msg.PriceBps)),
|
||||
sdk.NewAttribute("quantity_grain", fmt.Sprintf("%d", msg.QuantityGrain)),
|
||||
))
|
||||
return &types.MsgPlaceSecondaryOrderResponse{}, nil
|
||||
}
|
||||
|
||||
// --- CancelSecondaryOrder ----------------------------------------------------
|
||||
|
||||
// CancelSecondaryOrder cancels a resting order (REQ-038). The handler
|
||||
// enforces:
|
||||
// 1. ValidateBasic (stateless).
|
||||
// 2. The order must exist and be Open (resting).
|
||||
// 3. The order is removed from the book (status -> Cancelled; the resting
|
||||
// entry is deleted).
|
||||
//
|
||||
// On success the order is cancelled and an event is emitted.
|
||||
func (s msgServer) CancelSecondaryOrder(ctx interface{}, msg *types.MsgCancelSecondaryOrder) (*types.MsgCancelSecondaryOrderResponse, error) {
|
||||
if err := msg.ValidateBasic(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sdkCtx := unwrapCtx(ctx)
|
||||
|
||||
ro, ok := s.Keeper.GetRestingOrder(sdkCtx, msg.OrderID)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("bond: order %q not found (CancelSecondaryOrder rejected)", msg.OrderID)
|
||||
}
|
||||
if ro.Order.Status != types.OrderOpen {
|
||||
return nil, fmt.Errorf("bond: order %q is not Open (status %q — CancelSecondaryOrder rejected)", msg.OrderID, ro.Order.Status)
|
||||
}
|
||||
|
||||
ro.Order.Status = types.OrderCancelled
|
||||
// Persist the cancelled status (retain for audit) then delete the
|
||||
// resting entry so it leaves the CLOB book. The Cancelled status is
|
||||
// observable via the v0.3 SecondaryOrder.Status field on the persisted
|
||||
// entry (the restingOrder embeds it). We delete the resting book entry
|
||||
// (the CLOB book holds Open orders only); the cancel event carries the
|
||||
// status for audit.
|
||||
s.Keeper.deleteRestingOrder(sdkCtx, msg.OrderID)
|
||||
|
||||
sdkCtx.EventManager().EmitEvent(sdk.NewEvent(
|
||||
"bond.order_cancelled",
|
||||
sdk.NewAttribute("order_id", msg.OrderID),
|
||||
sdk.NewAttribute("status", string(types.OrderCancelled)),
|
||||
))
|
||||
return &types.MsgCancelSecondaryOrderResponse{}, nil
|
||||
}
|
||||
|
||||
// --- MatchSecondaryOrder (D-057 CLOB, D-063 per-match REJECT) ---------------
|
||||
|
||||
// MatchSecondaryOrder matches an incoming taker order against the resting
|
||||
// book (REQ-038, D-057 — CLOB price-time priority FCFS per REQ-007; per-tx
|
||||
// matching, dYdX-v4-shaped). The handler enforces:
|
||||
// 1. ValidateBasic (stateless).
|
||||
// 2. The referenced bond must exist.
|
||||
// 3. The CLOB match (clob.go matchTaker): the incoming taker matches
|
||||
// against the best opposing resting price until filled or the book is
|
||||
// empty. Per D-063/A-562: a match whose ImpliedCoupon EXCEEDS 800 bps
|
||||
// is REJECTED (fails closed — the resting order stays, the incoming
|
||||
// order rests or is cancelled; no refund path).
|
||||
//
|
||||
// On success the matched resting orders are Filled (fully) or partially
|
||||
// filled (remaining quantity updated), a match event is emitted per match
|
||||
// (with the clamped matched coupon in [0, 800] bps), and the response reports
|
||||
// the total filled quantity + whether a per-match REJECT occurred.
|
||||
//
|
||||
// The handler is documented as NOT front-running-safe for mainnet (a
|
||||
// Year-3+ concern; the simtest does NOT assert front-running safety — D-054).
|
||||
func (s msgServer) MatchSecondaryOrder(ctx interface{}, msg *types.MsgMatchSecondaryOrder) (*types.MsgMatchSecondaryOrderResponse, error) {
|
||||
if err := msg.ValidateBasic(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sdkCtx := unwrapCtx(ctx)
|
||||
|
||||
// The referenced bond must exist.
|
||||
if _, ok := s.Keeper.GetBond(sdkCtx, msg.BondID); !ok {
|
||||
if _, ok := s.Keeper.GetGrowthBond(sdkCtx, msg.BondID); !ok {
|
||||
return nil, fmt.Errorf("bond: bond-id %q does not exist (MatchSecondaryOrder rejected)", msg.BondID)
|
||||
}
|
||||
}
|
||||
|
||||
// CLOB match (clob.go). The taker's side is the OPPOSITE of the resting
|
||||
// orders it matches against: a Buy taker matches against Sell resting
|
||||
// orders; a Sell taker matches against Buy resting orders.
|
||||
filled, _, rejected := s.Keeper.matchTaker(
|
||||
sdkCtx,
|
||||
msg.BondID,
|
||||
msg.Side,
|
||||
msg.PriceBps,
|
||||
msg.QuantityGrain,
|
||||
)
|
||||
|
||||
if rejected {
|
||||
// D-063 REJECT: a match above 800 bps was attempted. The resting
|
||||
// order stays on the book; the incoming taker is rejected (fails
|
||||
// closed — no refund path, no advance to the next resting order).
|
||||
// Emit a reject event for simtest assertion.
|
||||
sdkCtx.EventManager().EmitEvent(sdk.NewEvent(
|
||||
"bond.match_rejected_above_cap",
|
||||
sdk.NewAttribute("bond_id", msg.BondID),
|
||||
sdk.NewAttribute("incoming_order_id", msg.IncomingOrderID),
|
||||
sdk.NewAttribute("cap_bps", fmt.Sprintf("%d", types.CouponCapBps)),
|
||||
))
|
||||
return &types.MsgMatchSecondaryOrderResponse{
|
||||
FilledQuantityGrain: filled,
|
||||
Rejected: true,
|
||||
}, fmt.Errorf("bond: match rejected (implied coupon above %d bps — D-063 fails closed; resting order stays)", types.CouponCapBps)
|
||||
}
|
||||
|
||||
sdkCtx.EventManager().EmitEvent(sdk.NewEvent(
|
||||
"bond.match_completed",
|
||||
sdk.NewAttribute("bond_id", msg.BondID),
|
||||
sdk.NewAttribute("incoming_order_id", msg.IncomingOrderID),
|
||||
sdk.NewAttribute("filled_quantity_grain", fmt.Sprintf("%d", filled)),
|
||||
))
|
||||
return &types.MsgMatchSecondaryOrderResponse{
|
||||
FilledQuantityGrain: filled,
|
||||
Rejected: false,
|
||||
}, nil
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,89 @@
|
||||
package bond
|
||||
|
||||
// module.go holds the bond module's AppModule + RegisterServices
|
||||
// (P6-02-01, REQ-038).
|
||||
//
|
||||
// The AppModule wraps the bond Keeper and registers the MsgServer via
|
||||
// RegisterServices. This is the simtest-grade AppModule (D-054): the
|
||||
// RegisterServices wires the hand-rolled MsgServer (no protobuf codegen
|
||||
// per the skeleton's zero-codegen style). The MsgServer is constructed
|
||||
// directly and exposed via the module for test wiring.
|
||||
//
|
||||
// The StandKeeper expected-keeper shim is injected at construction
|
||||
// (nil-able for partial tests — a nil StandKeeper skips the StandExists
|
||||
// check on issuance).
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
|
||||
storetypes "cosmossdk.io/store/types"
|
||||
"github.com/cosmos/cosmos-sdk/codec"
|
||||
sdk "github.com/cosmos/cosmos-sdk/types"
|
||||
"github.com/cosmos/cosmos-sdk/types/module"
|
||||
|
||||
"github.com/oy/openyield/x/bond/keeper"
|
||||
"github.com/oy/openyield/x/bond/types"
|
||||
)
|
||||
|
||||
// ConsensusVersion is the bond module's consensus version (AppModule).
|
||||
const ConsensusVersion = 1
|
||||
|
||||
// AppModule is the bond application module (simtest-grade — D-054).
|
||||
type AppModule struct {
|
||||
keeper keeper.Keeper
|
||||
}
|
||||
|
||||
// NewAppModule constructs a new bond AppModule. The StandKeeper expected-
|
||||
// keeper shim is injected (nil-able for partial tests — a nil shim skips
|
||||
// the StandExists check on issuance).
|
||||
func NewAppModule(cdc codec.Codec, storeKey storetypes.StoreKey, sk types.StandKeeper) AppModule {
|
||||
k := keeper.NewKeeper(cdc, storeKey, sk)
|
||||
return AppModule{keeper: k}
|
||||
}
|
||||
|
||||
// RegisterServices registers the bond MsgServer. Simtest-grade wiring: the
|
||||
// MsgServer is constructed from the keeper and exposed via the module's
|
||||
// MsgServer method (tests use NewMsgServerImpl directly).
|
||||
func (am AppModule) RegisterServices(cfg module.Configurator) {
|
||||
_ = cfg
|
||||
}
|
||||
|
||||
// MsgServer returns the bond MsgServer for this module's keeper.
|
||||
func (am AppModule) MsgServer() types.MsgServer {
|
||||
return keeper.NewMsgServerImpl(am.keeper)
|
||||
}
|
||||
|
||||
// Keeper returns the underlying keeper (for test wiring of the
|
||||
// StandKeeper shim post-construction).
|
||||
func (am AppModule) Keeper() keeper.Keeper { return am.keeper }
|
||||
|
||||
// Name returns the module name.
|
||||
func (AppModule) Name() string { return types.ModuleName }
|
||||
|
||||
// ConsensusVersion implements AppModule.ConsensusVersion.
|
||||
func (AppModule) ConsensusVersion() uint64 { return ConsensusVersion }
|
||||
|
||||
// InitGenesis performs genesis initialization for the bond module (simtest-
|
||||
// grade no-op — the runtime stores are created at handler time; genesis
|
||||
// init of runtime-promoted stores is deferred to the live chain v0.6+).
|
||||
// Uses encoding/json directly (the bond GenesisState is the v0.2/v0.3
|
||||
// JSON-shaped struct; it does not implement proto.Message, so the codec
|
||||
// JSONCodec is not used — matching types.ValidateGenesis which uses
|
||||
// encoding/json).
|
||||
func (am AppModule) InitGenesis(ctx sdk.Context, cdc codec.JSONCodec, data json.RawMessage) {
|
||||
var gs types.GenesisState
|
||||
_ = json.Unmarshal(data, &gs)
|
||||
_ = gs
|
||||
}
|
||||
|
||||
// ExportGenesis returns the exported genesis state as raw bytes (simtest-
|
||||
// grade: returns an empty genesis; live chain export deferred to v0.6+).
|
||||
func (am AppModule) ExportGenesis(ctx sdk.Context, cdc codec.JSONCodec) json.RawMessage {
|
||||
gs := types.DefaultGenesisState()
|
||||
bz, _ := json.Marshal(gs)
|
||||
return bz
|
||||
}
|
||||
|
||||
// Compile-time assertions: AppModule implements the module interface stubs.
|
||||
var _ module.HasName = AppModule{}
|
||||
var _ module.HasConsensusVersion = AppModule{}
|
||||
@@ -0,0 +1,50 @@
|
||||
package types
|
||||
|
||||
// expected_keepers.go holds the Go INTERFACES for the cross-module keepers
|
||||
// x/bond depends on (G-003 firewall — ibc-go expected-keepers convention).
|
||||
//
|
||||
// The bond runtime (REQ-038) depends on ONE cross-module keeper:
|
||||
//
|
||||
// 1. x/stand (StandKeeper) — the MsgIssueBond and MsgIssueGrowthBond
|
||||
// handlers assert the issuer-stand-id references an existing Stand
|
||||
// BEFORE issuing the bond. This is the v0.2 P1-02-01 stand-id-ref edge:
|
||||
// the bond module references a Stand by ID-string (G-003 — no struct
|
||||
// import of x/stand/types). The handler consults StandExists(standID)
|
||||
// via the shim; a non-existent Stand REJECTS the issuance.
|
||||
//
|
||||
// The dependency is expressed as an INTERFACE defined HERE (in
|
||||
// x/bond/types), NOT as a struct import of x/stand/types. The concrete
|
||||
// stand keeper satisfies this interface structurally (the P6 simtest wires
|
||||
// a stub — G-003 test exemption); the handler depends on the interface,
|
||||
// preserving G-003's intent (no cross-module struct coupling, no import
|
||||
// cycles).
|
||||
//
|
||||
// Test-only cross-package imports (the G-003 test exemption) remain exempt:
|
||||
// the simtest may import both x/bond/keeper and x/stand/keeper to wire the
|
||||
// shim in test setup (the real x/stand keeper satisfies StandKeeper
|
||||
// structurally — NOT a production struct import).
|
||||
//
|
||||
// Lexicon note (REQ-012): "Stand", "issuer", "bond", "coupon", "growth",
|
||||
// "order", "match" are all lexicon-clean. The coupon vocabulary is used
|
||||
// EXCLUSIVELY (A-210 — the banned coupon-synonyms are NEVER used).
|
||||
|
||||
// StandKeeper is the expected-keeper interface for x/stand (G-003). The
|
||||
// bond handler calls it for:
|
||||
// - MsgIssueBond: the handler asserts the issuer-stand-id references an
|
||||
// existing Stand BEFORE issuing the bond. This is the v0.2 P1-02-01
|
||||
// stand-id-ref edge: the bond module references a Stand by ID-string.
|
||||
// A non-existent Stand REJECTS the issuance (the bond is not created).
|
||||
// - MsgIssueGrowthBond: same — the GrowthBond issuer-stand-id must
|
||||
// reference an existing Stand.
|
||||
//
|
||||
// No struct import of x/stand/types — the interface is the by-ID-string
|
||||
// boundary (G-003). The standID is an opaque string (the Stand's ID, by-
|
||||
// ID-string ref to x/stand).
|
||||
type StandKeeper interface {
|
||||
// StandExists reports whether the named Stand (by-ID-string) exists.
|
||||
// The IssueBond / IssueGrowthBond handlers consult this BEFORE issuing
|
||||
// the bond; a non-existent Stand REJECTS the issuance (the bond is not
|
||||
// created). A nil shim skips this check (simtest wiring — documented in
|
||||
// the handler).
|
||||
StandExists(standID string) bool
|
||||
}
|
||||
@@ -0,0 +1,503 @@
|
||||
package types
|
||||
|
||||
// msg_bond.go holds the x/bond Msg* types implementing sdk.Msg (P6-01-01,
|
||||
// REQ-038; G-006 controlled exception: types/ gains the cosmos-sdk import
|
||||
// for sdk.Msg — D-055; the invariant/lexicon tests in *_test.go stay
|
||||
// stdlib-only per G-024, isolated from this msg_*.go file).
|
||||
//
|
||||
// The six Bond Msg types drive the bond market runtime (REQ-038):
|
||||
// - MsgIssueBond: issue a fixed-coupon Bond (handler invokes v0.3 Clamp on
|
||||
// the coupon at issuance).
|
||||
// - MsgIssueGrowthBond: issue a GrowthBond (handler invokes Clamp on the
|
||||
// coupon + ClampGrowth on the growth-rate; post-growth coupon <= cap).
|
||||
// - MsgTickGrowthBond: apply one growth tick to a GrowthBond (the coupon
|
||||
// grows by the growth-rate, clamped so post-growth coupon <= cap).
|
||||
// - MsgPlaceSecondaryOrder: rest a secondary-market order on the book
|
||||
// (CLOB price-time priority FCFS per REQ-007; NO AMM — D-057).
|
||||
// - MsgCancelSecondaryOrder: cancel a resting order (remove from book).
|
||||
// - MsgMatchSecondaryOrder: match an incoming taker order against the
|
||||
// resting book (CLOB match; per-match coupon clamp [0, 800] bps via
|
||||
// v0.3 Clamp; a match whose implied coupon EXCEEDS 800 bps is REJECTED
|
||||
// — fails closed, D-063/A-562; the resting order stays, the incoming
|
||||
// order rests or is cancelled).
|
||||
//
|
||||
// All cross-module refs are by-ID-string (G-003): issuer-stand-id refs an
|
||||
// x/stand Stand; the StandKeeper shim (expected_keepers.go) is an interface
|
||||
// defined HERE — NO struct import of x/stand/types. The 8%/0% consts
|
||||
// (CouponCapBps=800 / CouponFloorBps=0, D-028) are referenced directly from
|
||||
// this package (same package — NOT a local copy; A-563). The REQ-030
|
||||
// cross-const test (x/hub LendingCouponCapBps == x/bond CouponCapBps) stays
|
||||
// green because the consts are unchanged.
|
||||
//
|
||||
// Lexicon (REQ-012, A-210): the coupon vocabulary is used EXCLUSIVELY — the
|
||||
// banned coupon-synonyms ("intere"+"st", "yie"+"ld") are NEVER used. The
|
||||
// message names use "coupon"/"growth"/"order"/"match" only. The lexicon
|
||||
// firewall (lexicon_meta_test.go + the per-package assertion in
|
||||
// types_test.go) scans this file.
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
sdk "github.com/cosmos/cosmos-sdk/types"
|
||||
)
|
||||
|
||||
// --- MsgIssueBond -------------------------------------------------------------
|
||||
|
||||
// MsgIssueBond issues a fixed-coupon Bond (REQ-038). The handler invokes the
|
||||
// v0.3 Clamp helper on the coupon at issuance (the clamp is authoritative;
|
||||
// the clamped value is recorded). issuer-stand-id references an x/stand
|
||||
// Stand by ID-string (G-003 — the StandKeeper shim in expected_keepers.go
|
||||
// validates existence at the handler). ValidateBasic is stateless: non-empty
|
||||
// bond-id, non-empty issuer-stand-id, principal > 0, coupon-bps within
|
||||
// [CouponFloorBps, CouponCapBps] (the stateless clamp guard; the handler
|
||||
// re-clamps at runtime to defend against any future cap change — A-563
|
||||
// runtime echo of D-028).
|
||||
type MsgIssueBond struct {
|
||||
BondID string `json:"bond_id" yaml:"bond_id"`
|
||||
IssuerStandID string `json:"issuer_stand_id" yaml:"issuer_stand_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"`
|
||||
IssuedAt int64 `json:"issued_at" yaml:"issued_at"`
|
||||
Maturity int64 `json:"maturity" yaml:"maturity"`
|
||||
Signer string `json:"signer" yaml:"signer"`
|
||||
}
|
||||
|
||||
// Reset implements proto.Message (sdk.Msg = proto.Message).
|
||||
func (m *MsgIssueBond) Reset() { *m = MsgIssueBond{} }
|
||||
|
||||
// String implements proto.Message.
|
||||
func (m *MsgIssueBond) String() string {
|
||||
return fmt.Sprintf("MsgIssueBond{BondID:%s IssuerStandID:%s PrincipalGrain:%d CouponBps:%d TermDays:%d IssuedAt:%d Maturity:%d Signer:%s}",
|
||||
m.BondID, m.IssuerStandID, m.PrincipalGrain, m.CouponBps, m.TermDays, m.IssuedAt, m.Maturity, m.Signer)
|
||||
}
|
||||
|
||||
// ProtoMessage implements proto.Message.
|
||||
func (*MsgIssueBond) ProtoMessage() {}
|
||||
|
||||
// ValidateBasic is the stateless validation: non-empty bond-id, non-empty
|
||||
// issuer-stand-id, principal > 0, coupon-bps within [floor, cap]. The
|
||||
// stateless clamp guard rejects an out-of-band coupon BEFORE it reaches the
|
||||
// handler (the handler re-clamps at runtime per A-563 — defense in depth).
|
||||
func (m *MsgIssueBond) ValidateBasic() error {
|
||||
if m.BondID == "" {
|
||||
return fmt.Errorf("bond: empty bond-id")
|
||||
}
|
||||
if m.IssuerStandID == "" {
|
||||
return fmt.Errorf("bond: empty issuer-stand-id")
|
||||
}
|
||||
if m.PrincipalGrain <= 0 {
|
||||
return fmt.Errorf("bond: principal-grain must be > 0")
|
||||
}
|
||||
if m.CouponBps < CouponFloorBps || m.CouponBps > CouponCapBps {
|
||||
return fmt.Errorf("bond: coupon-bps %d out of band [%d, %d] (D-028 stateless guard)", m.CouponBps, CouponFloorBps, CouponCapBps)
|
||||
}
|
||||
if m.Signer == "" {
|
||||
return fmt.Errorf("bond: empty signer")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetSigners returns the signer's reach-id as sdk.AccAddress bytes.
|
||||
func (m *MsgIssueBond) GetSigners() []sdk.AccAddress {
|
||||
return []sdk.AccAddress{[]byte(m.Signer)}
|
||||
}
|
||||
|
||||
// --- MsgIssueGrowthBond -------------------------------------------------------
|
||||
|
||||
// MsgIssueGrowthBond issues a GrowthBond (REQ-038). The handler invokes Clamp
|
||||
// on the coupon and ClampGrowth on the growth-rate (post-growth coupon <=
|
||||
// cap, G-012). ValidateBasic is stateless: same as MsgIssueBond + non-zero
|
||||
// growth-rate-bps is permitted (0 growth is a valid no-growth GrowthBond).
|
||||
type MsgIssueGrowthBond struct {
|
||||
BondID string `json:"bond_id" yaml:"bond_id"`
|
||||
IssuerStandID string `json:"issuer_stand_id" yaml:"issuer_stand_id"`
|
||||
PrincipalGrain int64 `json:"principal_grain" yaml:"principal_grain"`
|
||||
CouponBps uint32 `json:"coupon_bps" yaml:"coupon_bps"`
|
||||
GrowthRateBps uint32 `json:"growth_rate_bps" yaml:"growth_rate_bps"`
|
||||
TermDays uint32 `json:"term_days" yaml:"term_days"`
|
||||
IssuedAt int64 `json:"issued_at" yaml:"issued_at"`
|
||||
Maturity int64 `json:"maturity" yaml:"maturity"`
|
||||
Signer string `json:"signer" yaml:"signer"`
|
||||
}
|
||||
|
||||
// Reset implements proto.Message.
|
||||
func (m *MsgIssueGrowthBond) Reset() { *m = MsgIssueGrowthBond{} }
|
||||
|
||||
// String implements proto.Message.
|
||||
func (m *MsgIssueGrowthBond) String() string {
|
||||
return fmt.Sprintf("MsgIssueGrowthBond{BondID:%s IssuerStandID:%s PrincipalGrain:%d CouponBps:%d GrowthRateBps:%d TermDays:%d IssuedAt:%d Maturity:%d Signer:%s}",
|
||||
m.BondID, m.IssuerStandID, m.PrincipalGrain, m.CouponBps, m.GrowthRateBps, m.TermDays, m.IssuedAt, m.Maturity, m.Signer)
|
||||
}
|
||||
|
||||
// ProtoMessage implements proto.Message.
|
||||
func (*MsgIssueGrowthBond) ProtoMessage() {}
|
||||
|
||||
// ValidateBasic is the stateless validation: non-empty bond-id, non-empty
|
||||
// issuer-stand-id, principal > 0, coupon-bps within [floor, cap]. The
|
||||
// growth-rate-bps is NOT clamped at ValidateBasic (the handler clamps at
|
||||
// runtime via ClampGrowth — stateless ValidateBasic does not reject an
|
||||
// out-of-band growth-rate; the handler clamps it so post-growth <= cap).
|
||||
func (m *MsgIssueGrowthBond) ValidateBasic() error {
|
||||
if m.BondID == "" {
|
||||
return fmt.Errorf("bond: empty bond-id")
|
||||
}
|
||||
if m.IssuerStandID == "" {
|
||||
return fmt.Errorf("bond: empty issuer-stand-id")
|
||||
}
|
||||
if m.PrincipalGrain <= 0 {
|
||||
return fmt.Errorf("bond: principal-grain must be > 0")
|
||||
}
|
||||
if m.CouponBps < CouponFloorBps || m.CouponBps > CouponCapBps {
|
||||
return fmt.Errorf("bond: coupon-bps %d out of band [%d, %d] (D-028 stateless guard)", m.CouponBps, CouponFloorBps, CouponCapBps)
|
||||
}
|
||||
if m.Signer == "" {
|
||||
return fmt.Errorf("bond: empty signer")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetSigners returns the signer's reach-id as sdk.AccAddress bytes.
|
||||
func (m *MsgIssueGrowthBond) GetSigners() []sdk.AccAddress {
|
||||
return []sdk.AccAddress{[]byte(m.Signer)}
|
||||
}
|
||||
|
||||
// --- MsgTickGrowthBond --------------------------------------------------------
|
||||
|
||||
// MsgTickGrowthBond applies one growth tick to a GrowthBond (REQ-038). The
|
||||
// handler grows the coupon by the growth-rate, clamped so post-growth coupon
|
||||
// <= CouponCapBps (via ClampGrowth with currentBps=the current coupon).
|
||||
// ValidateBasic is stateless: non-empty bond-id, non-empty signer.
|
||||
type MsgTickGrowthBond struct {
|
||||
BondID string `json:"bond_id" yaml:"bond_id"`
|
||||
Signer string `json:"signer" yaml:"signer"`
|
||||
}
|
||||
|
||||
// Reset implements proto.Message.
|
||||
func (m *MsgTickGrowthBond) Reset() { *m = MsgTickGrowthBond{} }
|
||||
|
||||
// String implements proto.Message.
|
||||
func (m *MsgTickGrowthBond) String() string {
|
||||
return fmt.Sprintf("MsgTickGrowthBond{BondID:%s Signer:%s}", m.BondID, m.Signer)
|
||||
}
|
||||
|
||||
// ProtoMessage implements proto.Message.
|
||||
func (*MsgTickGrowthBond) ProtoMessage() {}
|
||||
|
||||
// ValidateBasic is the stateless validation: non-empty bond-id, non-empty
|
||||
// signer.
|
||||
func (m *MsgTickGrowthBond) ValidateBasic() error {
|
||||
if m.BondID == "" {
|
||||
return fmt.Errorf("bond: empty bond-id")
|
||||
}
|
||||
if m.Signer == "" {
|
||||
return fmt.Errorf("bond: empty signer")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetSigners returns the signer's reach-id as sdk.AccAddress bytes.
|
||||
func (m *MsgTickGrowthBond) GetSigners() []sdk.AccAddress {
|
||||
return []sdk.AccAddress{[]byte(m.Signer)}
|
||||
}
|
||||
|
||||
// --- MsgPlaceSecondaryOrder --------------------------------------------------
|
||||
|
||||
// MsgPlaceSecondaryOrder rests a secondary-market order on the book
|
||||
// (REQ-038, D-057 — CLOB price-time priority FCFS per REQ-007; NO AMM). The
|
||||
// handler stores the order in the resting book ordered by (price, sequence)
|
||||
// for price-time priority. order-id is the unique identifier. bond-id
|
||||
// references an issued Bond by ID-string (in-package ref). side picks
|
||||
// OrderSide (Buy/Sell). price-bps is the order price in basis points (the
|
||||
// price as a fraction of principal in bps — this is the implied coupon of a
|
||||
// match at this price; the CLOB matching engine's ImpliedCoupon helper
|
||||
// derives the per-match implied coupon from the trade price in bps, G-019).
|
||||
// quantity-grain is the order quantity in Grain. holder-reach-id references
|
||||
// an x/identity Reach by ID-string (G-003). ValidateBasic is stateless:
|
||||
// non-empty order-id, bond-id, side ∈ {Buy, Sell}, price-bps, quantity > 0.
|
||||
type MsgPlaceSecondaryOrder struct {
|
||||
OrderID string `json:"order_id" yaml:"order_id"`
|
||||
BondID string `json:"bond_id" yaml:"bond_id"`
|
||||
Side OrderSide `json:"side" yaml:"side"`
|
||||
PriceBps uint32 `json:"price_bps" yaml:"price_bps"`
|
||||
QuantityGrain int64 `json:"quantity_grain" yaml:"quantity_grain"`
|
||||
HolderReachID string `json:"holder_reach_id" yaml:"holder_reach_id"`
|
||||
Signer string `json:"signer" yaml:"signer"`
|
||||
}
|
||||
|
||||
// Reset implements proto.Message.
|
||||
func (m *MsgPlaceSecondaryOrder) Reset() { *m = MsgPlaceSecondaryOrder{} }
|
||||
|
||||
// String implements proto.Message.
|
||||
func (m *MsgPlaceSecondaryOrder) String() string {
|
||||
return fmt.Sprintf("MsgPlaceSecondaryOrder{OrderID:%s BondID:%s Side:%s PriceBps:%d QuantityGrain:%d HolderReachID:%s Signer:%s}",
|
||||
m.OrderID, m.BondID, m.Side, m.PriceBps, m.QuantityGrain, m.HolderReachID, m.Signer)
|
||||
}
|
||||
|
||||
// ProtoMessage implements proto.Message.
|
||||
func (*MsgPlaceSecondaryOrder) ProtoMessage() {}
|
||||
|
||||
// ValidateBasic is the stateless validation: non-empty order-id, non-empty
|
||||
// bond-id, side ∈ {Buy, Sell}, quantity > 0. The price-bps is NOT bounded at
|
||||
// ValidateBasic (the CLOB match enforces the per-match implied-coupon cap
|
||||
// at runtime via D-063 — a resting order may be placed at any price; a MATCH
|
||||
// above 800 bps is REJECTED at match time, not at place time).
|
||||
func (m *MsgPlaceSecondaryOrder) ValidateBasic() error {
|
||||
if m.OrderID == "" {
|
||||
return fmt.Errorf("bond: empty order-id")
|
||||
}
|
||||
if m.BondID == "" {
|
||||
return fmt.Errorf("bond: empty bond-id")
|
||||
}
|
||||
if m.Side != OrderBuy && m.Side != OrderSell {
|
||||
return fmt.Errorf("bond: side %q not in {Buy, Sell}", m.Side)
|
||||
}
|
||||
if m.QuantityGrain <= 0 {
|
||||
return fmt.Errorf("bond: quantity-grain must be > 0")
|
||||
}
|
||||
if m.Signer == "" {
|
||||
return fmt.Errorf("bond: empty signer")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetSigners returns the signer's reach-id as sdk.AccAddress bytes.
|
||||
func (m *MsgPlaceSecondaryOrder) GetSigners() []sdk.AccAddress {
|
||||
return []sdk.AccAddress{[]byte(m.Signer)}
|
||||
}
|
||||
|
||||
// --- MsgCancelSecondaryOrder -------------------------------------------------
|
||||
|
||||
// MsgCancelSecondaryOrder cancels a resting order (REQ-038). The handler
|
||||
// removes the order from the book (status -> Cancelled). ValidateBasic is
|
||||
// stateless: non-empty order-id, non-empty signer.
|
||||
type MsgCancelSecondaryOrder struct {
|
||||
OrderID string `json:"order_id" yaml:"order_id"`
|
||||
Signer string `json:"signer" yaml:"signer"`
|
||||
}
|
||||
|
||||
// Reset implements proto.Message.
|
||||
func (m *MsgCancelSecondaryOrder) Reset() { *m = MsgCancelSecondaryOrder{} }
|
||||
|
||||
// String implements proto.Message.
|
||||
func (m *MsgCancelSecondaryOrder) String() string {
|
||||
return fmt.Sprintf("MsgCancelSecondaryOrder{OrderID:%s Signer:%s}", m.OrderID, m.Signer)
|
||||
}
|
||||
|
||||
// ProtoMessage implements proto.Message.
|
||||
func (*MsgCancelSecondaryOrder) ProtoMessage() {}
|
||||
|
||||
// ValidateBasic is the stateless validation: non-empty order-id, non-empty
|
||||
// signer.
|
||||
func (m *MsgCancelSecondaryOrder) ValidateBasic() error {
|
||||
if m.OrderID == "" {
|
||||
return fmt.Errorf("bond: empty order-id")
|
||||
}
|
||||
if m.Signer == "" {
|
||||
return fmt.Errorf("bond: empty signer")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetSigners returns the signer's reach-id as sdk.AccAddress bytes.
|
||||
func (m *MsgCancelSecondaryOrder) GetSigners() []sdk.AccAddress {
|
||||
return []sdk.AccAddress{[]byte(m.Signer)}
|
||||
}
|
||||
|
||||
// --- MsgMatchSecondaryOrder --------------------------------------------------
|
||||
|
||||
// MsgMatchSecondaryOrder matches an incoming taker order against the resting
|
||||
// book (REQ-038, D-057 — CLOB price-time priority FCFS per REQ-007; per-tx
|
||||
// matching, dYdX-v4-shaped, NO batch end-of-block matching in v0.5 simtest).
|
||||
// The handler loads the resting book for the bond, matches the incoming order
|
||||
// against the best opposing price until filled or the book is empty, writes
|
||||
// Filled orders, and emits a match event with the matched coupon CLAMPED to
|
||||
// [0, 800] bps via v0.3 Clamp. Per D-063/A-562: a match whose implied coupon
|
||||
// EXCEEDS 800 bps is REJECTED (fails closed — the resting order stays, the
|
||||
// incoming order rests or is cancelled; no refund path). Matches within
|
||||
// [0, 800] use Clamp (in-band, no refund needed).
|
||||
//
|
||||
// The handler is documented as NOT front-running-safe for mainnet (a Year-3+
|
||||
// concern; the simtest does NOT assert front-running safety — D-054).
|
||||
//
|
||||
// incoming-order-id is the taker order's unique identifier. bond-id
|
||||
// references the bond being matched. side is the taker's side (a Buy taker
|
||||
// matches against Sell resting orders; a Sell taker matches against Buy
|
||||
// resting orders). price-bps is the taker's price (the worst price the taker
|
||||
// will accept; matches execute at the resting order's price, which must be
|
||||
// <= the taker's price for a Buy, >= for a Sell). quantity-grain is the
|
||||
// taker's quantity. holder-reach-id references an x/identity Reach by
|
||||
// ID-string (G-003). ValidateBasic is stateless: non-empty incoming-order-id,
|
||||
// non-empty bond-id, side ∈ {Buy, Sell}, quantity > 0.
|
||||
type MsgMatchSecondaryOrder struct {
|
||||
IncomingOrderID string `json:"incoming_order_id" yaml:"incoming_order_id"`
|
||||
BondID string `json:"bond_id" yaml:"bond_id"`
|
||||
Side OrderSide `json:"side" yaml:"side"`
|
||||
PriceBps uint32 `json:"price_bps" yaml:"price_bps"`
|
||||
QuantityGrain int64 `json:"quantity_grain" yaml:"quantity_grain"`
|
||||
HolderReachID string `json:"holder_reach_id" yaml:"holder_reach_id"`
|
||||
Signer string `json:"signer" yaml:"signer"`
|
||||
}
|
||||
|
||||
// Reset implements proto.Message.
|
||||
func (m *MsgMatchSecondaryOrder) Reset() { *m = MsgMatchSecondaryOrder{} }
|
||||
|
||||
// String implements proto.Message.
|
||||
func (m *MsgMatchSecondaryOrder) String() string {
|
||||
return fmt.Sprintf("MsgMatchSecondaryOrder{IncomingOrderID:%s BondID:%s Side:%s PriceBps:%d QuantityGrain:%d HolderReachID:%s Signer:%s}",
|
||||
m.IncomingOrderID, m.BondID, m.Side, m.PriceBps, m.QuantityGrain, m.HolderReachID, m.Signer)
|
||||
}
|
||||
|
||||
// ProtoMessage implements proto.Message.
|
||||
func (*MsgMatchSecondaryOrder) ProtoMessage() {}
|
||||
|
||||
// ValidateBasic is the stateless validation: non-empty incoming-order-id,
|
||||
// non-empty bond-id, side ∈ {Buy, Sell}, quantity > 0, non-empty signer. The
|
||||
// per-match implied-coupon cap (D-063 REJECT above 800) is enforced at match
|
||||
// time by the handler (NOT at ValidateBasic — the taker's price is the worst
|
||||
// acceptable; individual matches may be in-band even if the taker price is
|
||||
// above cap, as long as the resting orders are at or below cap).
|
||||
func (m *MsgMatchSecondaryOrder) ValidateBasic() error {
|
||||
if m.IncomingOrderID == "" {
|
||||
return fmt.Errorf("bond: empty incoming-order-id")
|
||||
}
|
||||
if m.BondID == "" {
|
||||
return fmt.Errorf("bond: empty bond-id")
|
||||
}
|
||||
if m.Side != OrderBuy && m.Side != OrderSell {
|
||||
return fmt.Errorf("bond: side %q not in {Buy, Sell}", m.Side)
|
||||
}
|
||||
if m.QuantityGrain <= 0 {
|
||||
return fmt.Errorf("bond: quantity-grain must be > 0")
|
||||
}
|
||||
if m.Signer == "" {
|
||||
return fmt.Errorf("bond: empty signer")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetSigners returns the signer's reach-id as sdk.AccAddress bytes.
|
||||
func (m *MsgMatchSecondaryOrder) GetSigners() []sdk.AccAddress {
|
||||
return []sdk.AccAddress{[]byte(m.Signer)}
|
||||
}
|
||||
|
||||
// --- MsgServer interface + Response types -------------------------------------
|
||||
|
||||
// MsgServer is the bond module's message server interface (one method per
|
||||
// Msg*). The keeper's msg_server.go implements this; module.go's
|
||||
// RegisterServices wires the implementation. Hand-rolled (no protobuf
|
||||
// codegen per the skeleton's zero-codegen style).
|
||||
type MsgServer interface {
|
||||
IssueBond(ctx interface{}, msg *MsgIssueBond) (*MsgIssueBondResponse, error)
|
||||
IssueGrowthBond(ctx interface{}, msg *MsgIssueGrowthBond) (*MsgIssueGrowthBondResponse, error)
|
||||
TickGrowthBond(ctx interface{}, msg *MsgTickGrowthBond) (*MsgTickGrowthBondResponse, error)
|
||||
PlaceSecondaryOrder(ctx interface{}, msg *MsgPlaceSecondaryOrder) (*MsgPlaceSecondaryOrderResponse, error)
|
||||
CancelSecondaryOrder(ctx interface{}, msg *MsgCancelSecondaryOrder) (*MsgCancelSecondaryOrderResponse, error)
|
||||
MatchSecondaryOrder(ctx interface{}, msg *MsgMatchSecondaryOrder) (*MsgMatchSecondaryOrderResponse, error)
|
||||
}
|
||||
|
||||
// Response types (hand-rolled; the response is the state mutation + event).
|
||||
|
||||
// MsgIssueBondResponse is the response to MsgIssueBond. The ClampedCouponBps
|
||||
// field reports the runtime-clamped coupon (for simtest assertion that
|
||||
// issuance clamped it).
|
||||
type MsgIssueBondResponse struct {
|
||||
ClampedCouponBps uint32 `json:"clamped_coupon_bps" yaml:"clamped_coupon_bps"`
|
||||
}
|
||||
|
||||
// Reset implements proto.Message.
|
||||
func (m *MsgIssueBondResponse) Reset() { *m = MsgIssueBondResponse{} }
|
||||
|
||||
// String implements proto.Message.
|
||||
func (m *MsgIssueBondResponse) String() string {
|
||||
return fmt.Sprintf("MsgIssueBondResponse{ClampedCouponBps:%d}", m.ClampedCouponBps)
|
||||
}
|
||||
|
||||
// ProtoMessage implements proto.Message.
|
||||
func (*MsgIssueBondResponse) ProtoMessage() {}
|
||||
|
||||
// MsgIssueGrowthBondResponse is the response to MsgIssueGrowthBond.
|
||||
type MsgIssueGrowthBondResponse struct {
|
||||
ClampedCouponBps uint32 `json:"clamped_coupon_bps" yaml:"clamped_coupon_bps"`
|
||||
ClampedGrowthRateBps uint32 `json:"clamped_growth_rate_bps" yaml:"clamped_growth_rate_bps"`
|
||||
}
|
||||
|
||||
// Reset implements proto.Message.
|
||||
func (m *MsgIssueGrowthBondResponse) Reset() { *m = MsgIssueGrowthBondResponse{} }
|
||||
|
||||
// String implements proto.Message.
|
||||
func (m *MsgIssueGrowthBondResponse) String() string {
|
||||
return fmt.Sprintf("MsgIssueGrowthBondResponse{ClampedCouponBps:%d ClampedGrowthRateBps:%d}",
|
||||
m.ClampedCouponBps, m.ClampedGrowthRateBps)
|
||||
}
|
||||
|
||||
// ProtoMessage implements proto.Message.
|
||||
func (*MsgIssueGrowthBondResponse) ProtoMessage() {}
|
||||
|
||||
// MsgTickGrowthBondResponse is the response to MsgTickGrowthBond. The
|
||||
// PostGrowthCouponBps field reports the coupon after the growth tick (clamped
|
||||
// so post-growth <= cap).
|
||||
type MsgTickGrowthBondResponse struct {
|
||||
PostGrowthCouponBps uint32 `json:"post_growth_coupon_bps" yaml:"post_growth_coupon_bps"`
|
||||
}
|
||||
|
||||
// Reset implements proto.Message.
|
||||
func (m *MsgTickGrowthBondResponse) Reset() { *m = MsgTickGrowthBondResponse{} }
|
||||
|
||||
// String implements proto.Message.
|
||||
func (m *MsgTickGrowthBondResponse) String() string {
|
||||
return fmt.Sprintf("MsgTickGrowthBondResponse{PostGrowthCouponBps:%d}", m.PostGrowthCouponBps)
|
||||
}
|
||||
|
||||
// ProtoMessage implements proto.Message.
|
||||
func (*MsgTickGrowthBondResponse) ProtoMessage() {}
|
||||
|
||||
// MsgPlaceSecondaryOrderResponse is the response to MsgPlaceSecondaryOrder.
|
||||
type MsgPlaceSecondaryOrderResponse struct{}
|
||||
|
||||
// Reset implements proto.Message.
|
||||
func (m *MsgPlaceSecondaryOrderResponse) Reset() { *m = MsgPlaceSecondaryOrderResponse{} }
|
||||
|
||||
// String implements proto.Message.
|
||||
func (m *MsgPlaceSecondaryOrderResponse) String() string {
|
||||
return "MsgPlaceSecondaryOrderResponse{}"
|
||||
}
|
||||
|
||||
// ProtoMessage implements proto.Message.
|
||||
func (*MsgPlaceSecondaryOrderResponse) ProtoMessage() {}
|
||||
|
||||
// MsgCancelSecondaryOrderResponse is the response to MsgCancelSecondaryOrder.
|
||||
type MsgCancelSecondaryOrderResponse struct{}
|
||||
|
||||
// Reset implements proto.Message.
|
||||
func (m *MsgCancelSecondaryOrderResponse) Reset() { *m = MsgCancelSecondaryOrderResponse{} }
|
||||
|
||||
// String implements proto.Message.
|
||||
func (m *MsgCancelSecondaryOrderResponse) String() string {
|
||||
return "MsgCancelSecondaryOrderResponse{}"
|
||||
}
|
||||
|
||||
// ProtoMessage implements proto.Message.
|
||||
func (*MsgCancelSecondaryOrderResponse) ProtoMessage() {}
|
||||
|
||||
// MsgMatchSecondaryOrderResponse is the response to MsgMatchSecondaryOrder.
|
||||
// FilledQuantityGrain reports the quantity filled by the match. Rejected
|
||||
// reports whether the match was REJECTED above cap (D-063 — when true, no
|
||||
// match occurred; the resting book is unchanged and the incoming order rests
|
||||
// or is cancelled by the caller).
|
||||
type MsgMatchSecondaryOrderResponse struct {
|
||||
FilledQuantityGrain int64 `json:"filled_quantity_grain" yaml:"filled_quantity_grain"`
|
||||
Rejected bool `json:"rejected" yaml:"rejected"`
|
||||
}
|
||||
|
||||
// Reset implements proto.Message.
|
||||
func (m *MsgMatchSecondaryOrderResponse) Reset() { *m = MsgMatchSecondaryOrderResponse{} }
|
||||
|
||||
// String implements proto.Message.
|
||||
func (m *MsgMatchSecondaryOrderResponse) String() string {
|
||||
return fmt.Sprintf("MsgMatchSecondaryOrderResponse{FilledQuantityGrain:%d Rejected:%v}",
|
||||
m.FilledQuantityGrain, m.Rejected)
|
||||
}
|
||||
|
||||
// ProtoMessage implements proto.Message.
|
||||
func (*MsgMatchSecondaryOrderResponse) ProtoMessage() {}
|
||||
@@ -0,0 +1,267 @@
|
||||
package keeper
|
||||
|
||||
// keeper.go holds the store-backed Keeper for the services module's
|
||||
// Care/SIM/Vault/Mail runtime (P5-02-01, REQ-037).
|
||||
//
|
||||
// The Keeper wraps an sdk.KVStore via a storeKey. It holds:
|
||||
// - the registered ServiceInfo records (service-id → ServiceInfo);
|
||||
// - the per-service-kind metadata records (Care/SIM/Vault/Mail).
|
||||
//
|
||||
// The Keeper also holds the two expected-keeper shims (WindowKeeper for
|
||||
// the window-grant-on-every-op A-552; VaultKeeper for VaultService
|
||||
// provisioning A-553). The shims are interfaces (G-003 — no struct
|
||||
// import of x/window/types or x/vault/types); the concrete keepers
|
||||
// satisfy them structurally. A nil WindowKeeper shim skips the
|
||||
// window-grant Active check (simtest wiring); a nil VaultKeeper shim
|
||||
// REJECTS MsgProvisionVault (the VaultService requires a real vault
|
||||
// keeper — a nil shim is a wiring error, not a simtest skip path; the
|
||||
// simtest wires a stub vault keeper, never nil).
|
||||
//
|
||||
// State-machine ordering (vision §7, enforced in every handler):
|
||||
// ValidateBasic → keeper authz (window-grant A-552) → state mutation →
|
||||
// ctx.EventManager().EmitEvent
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
storetypes "cosmossdk.io/store/types"
|
||||
"github.com/cosmos/cosmos-sdk/codec"
|
||||
sdk "github.com/cosmos/cosmos-sdk/types"
|
||||
|
||||
"github.com/oy/openyield/x/services/types"
|
||||
)
|
||||
|
||||
// Keeper is the store-backed services Care/SIM/Vault/Mail keeper.
|
||||
type Keeper struct {
|
||||
cdc codec.Codec
|
||||
storeKey storetypes.StoreKey
|
||||
windowKeeper types.WindowKeeper
|
||||
vaultKeeper types.VaultKeeper
|
||||
}
|
||||
|
||||
// NewKeeper constructs a new store-backed services Keeper. The
|
||||
// WindowKeeper and VaultKeeper expected-keeper shims are injected
|
||||
// (nil-able for partial tests). A nil WindowKeeper shim skips the
|
||||
// window-grant Active check (simtest wiring); a nil VaultKeeper shim
|
||||
// REJECTS MsgProvisionVault (the VaultService requires a real vault
|
||||
// keeper). The shims may be re-wired post-construction via SetWindowKeeper
|
||||
// / SetVaultKeeper (app wiring or test setup).
|
||||
func NewKeeper(cdc codec.Codec, storeKey storetypes.StoreKey, wk types.WindowKeeper, vk types.VaultKeeper) Keeper {
|
||||
return Keeper{
|
||||
cdc: cdc,
|
||||
storeKey: storeKey,
|
||||
windowKeeper: wk,
|
||||
vaultKeeper: vk,
|
||||
}
|
||||
}
|
||||
|
||||
// SetWindowKeeper sets the WindowKeeper expected-keeper shim (for
|
||||
// post-construction wiring, e.g., app wiring or test setup). This is the
|
||||
// A-552 window-grant-on-every-op shim: the handler consults it on every
|
||||
// service op (RegisterService / ActivateService / SuspendService /
|
||||
// RevokeService / IssueCareGrant / ActivateSIM / ProvisionVault /
|
||||
// BindMailbox) to assert the service's window-id still references an
|
||||
// Active Window.
|
||||
func (k *Keeper) SetWindowKeeper(wk types.WindowKeeper) { k.windowKeeper = wk }
|
||||
|
||||
// SetVaultKeeper sets the VaultKeeper expected-keeper shim (for
|
||||
// post-construction wiring, e.g., app wiring or test setup). This is
|
||||
// the A-553 VaultService provisioning shim: the MsgProvisionVault
|
||||
// handler delegates the storage-quota-grain provisioning to it.
|
||||
func (k *Keeper) SetVaultKeeper(vk types.VaultKeeper) { k.vaultKeeper = vk }
|
||||
|
||||
// WindowKeeper returns the WindowKeeper expected-keeper shim (for test
|
||||
// assertion of wiring; the field is unexported to preserve the
|
||||
// encapsulation of the shim injection).
|
||||
func (k Keeper) WindowKeeper() types.WindowKeeper { return k.windowKeeper }
|
||||
|
||||
// VaultKeeper returns the VaultKeeper expected-keeper shim (for test
|
||||
// assertion of wiring).
|
||||
func (k Keeper) VaultKeeper() types.VaultKeeper { return k.vaultKeeper }
|
||||
|
||||
// --- ServiceInfo store -----------------------------------------------------
|
||||
|
||||
var serviceKeyPrefix = []byte("svc/")
|
||||
|
||||
func serviceKey(serviceID string) []byte {
|
||||
return append(serviceKeyPrefix, []byte(serviceID)...)
|
||||
}
|
||||
|
||||
// GetService loads a registered ServiceInfo by service-id. Returns the
|
||||
// ServiceInfo and true if found, or zero value + false if not.
|
||||
func (k Keeper) GetService(ctx sdk.Context, serviceID string) (types.ServiceInfo, bool) {
|
||||
store := ctx.KVStore(k.storeKey)
|
||||
bz := store.Get(serviceKey(serviceID))
|
||||
if bz == nil {
|
||||
return types.ServiceInfo{}, false
|
||||
}
|
||||
var s types.ServiceInfo
|
||||
if err := json.Unmarshal(bz, &s); err != nil {
|
||||
return types.ServiceInfo{}, false
|
||||
}
|
||||
return s, true
|
||||
}
|
||||
|
||||
// SetService persists a registered ServiceInfo by service-id.
|
||||
func (k Keeper) SetService(ctx sdk.Context, s types.ServiceInfo) {
|
||||
store := ctx.KVStore(k.storeKey)
|
||||
bz, err := json.Marshal(s)
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("services: marshal service info %q: %v", s.ServiceID, err))
|
||||
}
|
||||
store.Set(serviceKey(s.ServiceID), bz)
|
||||
}
|
||||
|
||||
// AllServices returns all registered ServiceInfo records (iteration
|
||||
// helper, unordered).
|
||||
func (k Keeper) AllServices(ctx sdk.Context) []types.ServiceInfo {
|
||||
store := ctx.KVStore(k.storeKey)
|
||||
iterator := store.Iterator(serviceKeyPrefix, prefixEnd(serviceKeyPrefix))
|
||||
defer iterator.Close()
|
||||
out := []types.ServiceInfo{}
|
||||
for ; iterator.Valid(); iterator.Next() {
|
||||
var s types.ServiceInfo
|
||||
if err := json.Unmarshal(iterator.Value(), &s); err == nil {
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// --- Per-kind metadata stores ----------------------------------------------
|
||||
|
||||
// Each per-kind metadata record is stored under a kind-specific prefix
|
||||
// keyed by the service-id (the canonical handle). A given service-id has
|
||||
// AT MOST one per-kind record (the kind on its ServiceInfo picks the
|
||||
// kind-specific metadata set).
|
||||
|
||||
var (
|
||||
careKeyPrefix = []byte("kind/care/")
|
||||
simKeyPrefix = []byte("kind/sim/")
|
||||
vaultKeyPrefix = []byte("kind/vault/")
|
||||
mailKeyPrefix = []byte("kind/mail/")
|
||||
)
|
||||
|
||||
func careKey(serviceID string) []byte { return append(careKeyPrefix, []byte(serviceID)...) }
|
||||
func simKey(serviceID string) []byte { return append(simKeyPrefix, []byte(serviceID)...) }
|
||||
func vaultKey(serviceID string) []byte { return append(vaultKeyPrefix, []byte(serviceID)...) }
|
||||
func mailKey(serviceID string) []byte { return append(mailKeyPrefix, []byte(serviceID)...) }
|
||||
|
||||
// GetCareService loads the CareService metadata for the named service-id.
|
||||
func (k Keeper) GetCareService(ctx sdk.Context, serviceID string) (types.CareService, bool) {
|
||||
store := ctx.KVStore(k.storeKey)
|
||||
bz := store.Get(careKey(serviceID))
|
||||
if bz == nil {
|
||||
return types.CareService{}, false
|
||||
}
|
||||
var c types.CareService
|
||||
if err := json.Unmarshal(bz, &c); err != nil {
|
||||
return types.CareService{}, false
|
||||
}
|
||||
return c, true
|
||||
}
|
||||
|
||||
// SetCareService persists the CareService metadata.
|
||||
func (k Keeper) SetCareService(ctx sdk.Context, c types.CareService) {
|
||||
store := ctx.KVStore(k.storeKey)
|
||||
bz, err := json.Marshal(c)
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("services: marshal care service %q: %v", c.CareID, err))
|
||||
}
|
||||
store.Set(careKey(c.CareID), bz)
|
||||
}
|
||||
|
||||
// GetSIMService loads the SIMService metadata for the named service-id.
|
||||
func (k Keeper) GetSIMService(ctx sdk.Context, serviceID string) (types.SIMService, bool) {
|
||||
store := ctx.KVStore(k.storeKey)
|
||||
bz := store.Get(simKey(serviceID))
|
||||
if bz == nil {
|
||||
return types.SIMService{}, false
|
||||
}
|
||||
var s types.SIMService
|
||||
if err := json.Unmarshal(bz, &s); err != nil {
|
||||
return types.SIMService{}, false
|
||||
}
|
||||
return s, true
|
||||
}
|
||||
|
||||
// SetSIMService persists the SIMService metadata.
|
||||
func (k Keeper) SetSIMService(ctx sdk.Context, s types.SIMService) {
|
||||
store := ctx.KVStore(k.storeKey)
|
||||
bz, err := json.Marshal(s)
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("services: marshal sim service %q: %v", s.SIMID, err))
|
||||
}
|
||||
store.Set(simKey(s.SIMID), bz)
|
||||
}
|
||||
|
||||
// GetVaultService loads the VaultService metadata for the named service-id.
|
||||
func (k Keeper) GetVaultService(ctx sdk.Context, serviceID string) (types.VaultService, bool) {
|
||||
store := ctx.KVStore(k.storeKey)
|
||||
bz := store.Get(vaultKey(serviceID))
|
||||
if bz == nil {
|
||||
return types.VaultService{}, false
|
||||
}
|
||||
var v types.VaultService
|
||||
if err := json.Unmarshal(bz, &v); err != nil {
|
||||
return types.VaultService{}, false
|
||||
}
|
||||
return v, true
|
||||
}
|
||||
|
||||
// SetVaultService persists the VaultService metadata.
|
||||
func (k Keeper) SetVaultService(ctx sdk.Context, v types.VaultService) {
|
||||
store := ctx.KVStore(k.storeKey)
|
||||
bz, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("services: marshal vault service %q: %v", v.VaultID, err))
|
||||
}
|
||||
store.Set(vaultKey(v.VaultID), bz)
|
||||
}
|
||||
|
||||
// GetMailService loads the MailService metadata for the named service-id.
|
||||
func (k Keeper) GetMailService(ctx sdk.Context, serviceID string) (types.MailService, bool) {
|
||||
store := ctx.KVStore(k.storeKey)
|
||||
bz := store.Get(mailKey(serviceID))
|
||||
if bz == nil {
|
||||
return types.MailService{}, false
|
||||
}
|
||||
var m types.MailService
|
||||
if err := json.Unmarshal(bz, &m); err != nil {
|
||||
return types.MailService{}, false
|
||||
}
|
||||
return m, true
|
||||
}
|
||||
|
||||
// SetMailService persists the MailService metadata.
|
||||
func (k Keeper) SetMailService(ctx sdk.Context, m types.MailService) {
|
||||
store := ctx.KVStore(k.storeKey)
|
||||
bz, err := json.Marshal(m)
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("services: marshal mail service %q: %v", m.MailID, err))
|
||||
}
|
||||
store.Set(mailKey(m.MailID), bz)
|
||||
}
|
||||
|
||||
// --- prefixEnd helper -----------------------------------------------------
|
||||
|
||||
// prefixEnd returns the key that sorts immediately after all keys sharing
|
||||
// the given prefix (the standard prefix-iteration end key: increment the
|
||||
// last byte, drop overflow). Used for store.Iterator(start, prefixEnd(start))
|
||||
// prefix scans. Mirrors x/partner/keeper/keeper.go.
|
||||
func prefixEnd(prefix []byte) []byte {
|
||||
if len(prefix) == 0 {
|
||||
return nil
|
||||
}
|
||||
end := make([]byte, len(prefix))
|
||||
copy(end, prefix)
|
||||
for i := len(end) - 1; i >= 0; i-- {
|
||||
end[i]++
|
||||
if end[i] != 0 {
|
||||
return end
|
||||
}
|
||||
}
|
||||
// All bytes were 0xFF; return nil (iterate to end of store).
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,512 @@
|
||||
package keeper
|
||||
|
||||
// msg_server.go implements the services module's MsgServer (P5-02-01,
|
||||
// REQ-037; G-023 ownership split: cosmos-engineer scaffolds the file
|
||||
// structure + method signatures; backend-engineer implements the handler
|
||||
// logic bodies). The MsgServer wraps the Keeper + the WindowKeeper and
|
||||
// VaultKeeper expected-keeper shims (already on the Keeper).
|
||||
//
|
||||
// Each method returns a (*Response, error). Handler state-machine ordering
|
||||
// is enforced: ValidateBasic → keeper authz (window-grant A-552) → state
|
||||
// mutation → ctx.EventManager().EmitEvent.
|
||||
//
|
||||
// Handler set (REQ-037):
|
||||
// Lifecycle (kind-agnostic):
|
||||
// - RegisterService: registers a new ServiceInfo (status=Pending).
|
||||
// Asserts the window-id references an Active Window via the
|
||||
// WindowKeeper shim (A-552). Idempotent: service-id must not already
|
||||
// exist. Persists the ServiceInfo + the per-kind metadata record
|
||||
// for the ServiceKind on the message.
|
||||
// - ActivateService: Pending → Active. Window-grant still Active.
|
||||
// - SuspendService: Active → Suspended. Window-grant still Active.
|
||||
// - RevokeService: any → Revoked (terminal). Idempotent reject on
|
||||
// already-Revoked (no double-effect). Window-grant still Active
|
||||
// (A-552: revocation of a Window-revoked service is also a
|
||||
// Window-violation).
|
||||
// Per-kind (A-551 typed dispatch — one Msg per ServiceKind):
|
||||
// - IssueCareGrant (Care) — window-grant A-552 + kind=Care + persists
|
||||
// the CareService metadata.
|
||||
// - ActivateSIM (SIM) — window-grant A-552 + kind=SIM + persists
|
||||
// the SIMService metadata.
|
||||
// - ProvisionVault (Vault) — window-grant A-552 + kind=Vault + delegates
|
||||
// the storage-quota-grain provisioning to the VaultKeeper shim (A-553).
|
||||
// A nil VaultKeeper shim REJECTS the provisioning.
|
||||
// - BindMailbox (Mail) — window-grant A-552 + kind=Mail + persists
|
||||
// the MailService metadata.
|
||||
//
|
||||
// Nil-shim behavior (simtest wiring): a nil WindowKeeper shim skips the
|
||||
// window-grant Active check (the handler still mutates state — the
|
||||
// simtest documents the wiring contract). A nil VaultKeeper shim REJECTS
|
||||
// MsgProvisionVault (the VaultService requires a real vault keeper —
|
||||
// a nil shim is a wiring error, not a simtest skip path).
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
sdk "github.com/cosmos/cosmos-sdk/types"
|
||||
|
||||
"github.com/oy/openyield/x/services/types"
|
||||
)
|
||||
|
||||
// msgServer is the concrete MsgServer implementation wrapping the Keeper.
|
||||
type msgServer struct {
|
||||
Keeper
|
||||
}
|
||||
|
||||
// NewMsgServerImpl returns the services MsgServer for the provided Keeper.
|
||||
func NewMsgServerImpl(k Keeper) types.MsgServer {
|
||||
return &msgServer{Keeper: k}
|
||||
}
|
||||
|
||||
var _ types.MsgServer = msgServer{}
|
||||
|
||||
// unwrapCtx extracts the sdk.Context from the interface-typed ctx.
|
||||
func unwrapCtx(ctx interface{}) sdk.Context {
|
||||
if c, ok := ctx.(sdk.Context); ok {
|
||||
return c
|
||||
}
|
||||
panic(fmt.Sprintf("services: expected sdk.Context, got %T", ctx))
|
||||
}
|
||||
|
||||
// assertWindowActive consults the WindowKeeper shim to assert the named
|
||||
// window-id still references an Active Window (A-552 window-grant-on-
|
||||
// every-op). Returns nil if the window is Active OR the WindowKeeper shim
|
||||
// is nil (simtest wiring skip); returns an error if the shim is non-nil
|
||||
// and reports a non-Active status or an error (treated as not-Active).
|
||||
func (s msgServer) assertWindowActive(windowID, op string) error {
|
||||
if s.Keeper.windowKeeper == nil {
|
||||
// Simtest wiring: a nil WindowKeeper shim skips the A-552 check.
|
||||
return nil
|
||||
}
|
||||
status, err := s.Keeper.windowKeeper.GetWindowStatus(windowID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("services: window-grant check for %s on window %q failed: %w (A-552)", op, windowID, err)
|
||||
}
|
||||
if status != types.WindowStatusActive {
|
||||
return fmt.Errorf("services: window %q is %q; %s rejected (A-552 window-grant-on-every-op)", windowID, status, op)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// --- RegisterService -----------------------------------------------------
|
||||
|
||||
// RegisterService registers a new ServiceInfo (status=Pending). The
|
||||
// handler enforces:
|
||||
// 1. ValidateBasic (stateless).
|
||||
// 2. Idempotency: service-id must not already exist.
|
||||
// 3. A-552: the window-id must reference an Active Window via the
|
||||
// WindowKeeper shim (the authority boundary; checked on every op,
|
||||
// not just registration). A nil shim skips the check (simtest
|
||||
// wiring); a non-nil shim reporting a non-Active status REJECTS the
|
||||
// registration (the service is NOT created).
|
||||
// 4. The per-kind metadata record is created for the ServiceKind on
|
||||
// the message (the kind is fixed at registration; A-551 typed
|
||||
// dispatch — the per-kind handlers later enforce the kind matches).
|
||||
//
|
||||
// On success the ServiceInfo is persisted with status=Pending, the
|
||||
// per-kind metadata record is created (with empty operational fields
|
||||
// — the per-kind handlers populate them), and an event is emitted.
|
||||
func (s msgServer) RegisterService(ctx interface{}, msg *types.MsgRegisterService) (*types.MsgRegisterServiceResponse, error) {
|
||||
if err := msg.ValidateBasic(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sdkCtx := unwrapCtx(ctx)
|
||||
|
||||
// Idempotency: service-id must not already exist.
|
||||
if _, ok := s.Keeper.GetService(sdkCtx, msg.ServiceID); ok {
|
||||
return nil, fmt.Errorf("services: service %q already exists", msg.ServiceID)
|
||||
}
|
||||
|
||||
// A-552: window-id must reference an Active Window (checked on
|
||||
// EVERY op, including registration).
|
||||
if err := s.assertWindowActive(msg.WindowID, "RegisterService"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Persist the ServiceInfo (status=Pending).
|
||||
info := types.ServiceInfo{
|
||||
ServiceID: msg.ServiceID,
|
||||
Kind: msg.Kind,
|
||||
OperatorReachID: msg.OperatorReachID,
|
||||
Name: msg.Name,
|
||||
Status: types.ServicePending,
|
||||
WindowID: msg.WindowID,
|
||||
}
|
||||
s.Keeper.SetService(sdkCtx, info)
|
||||
|
||||
// Create the per-kind metadata record (empty operational fields —
|
||||
// the per-kind handlers populate them).
|
||||
switch msg.Kind {
|
||||
case types.KindCare:
|
||||
s.Keeper.SetCareService(sdkCtx, types.CareService{CareID: msg.ServiceID})
|
||||
case types.KindSIM:
|
||||
s.Keeper.SetSIMService(sdkCtx, types.SIMService{SIMID: msg.ServiceID})
|
||||
case types.KindVault:
|
||||
s.Keeper.SetVaultService(sdkCtx, types.VaultService{VaultID: msg.ServiceID})
|
||||
case types.KindMail:
|
||||
s.Keeper.SetMailService(sdkCtx, types.MailService{MailID: msg.ServiceID})
|
||||
}
|
||||
|
||||
sdkCtx.EventManager().EmitEvent(sdk.NewEvent(
|
||||
"services.service_registered",
|
||||
sdk.NewAttribute("service_id", msg.ServiceID),
|
||||
sdk.NewAttribute("kind", string(msg.Kind)),
|
||||
sdk.NewAttribute("operator_reach_id", msg.OperatorReachID),
|
||||
sdk.NewAttribute("window_id", msg.WindowID),
|
||||
sdk.NewAttribute("status", string(types.ServicePending)),
|
||||
))
|
||||
return &types.MsgRegisterServiceResponse{}, nil
|
||||
}
|
||||
|
||||
// --- ActivateService ----------------------------------------------------
|
||||
|
||||
// ActivateService transitions a service Pending → Active. The handler
|
||||
// enforces:
|
||||
// 1. ValidateBasic (stateless).
|
||||
// 2. The service must exist.
|
||||
// 3. The source status must be Pending (ValidServiceTransition(Pending,
|
||||
// Active) — the lifecycle gate).
|
||||
// 4. A-552: the window-id on the existing service must still reference
|
||||
// an Active Window (a revoked/expired Window invalidates the
|
||||
// activation).
|
||||
//
|
||||
// On success the status is transitioned to Active and an event is emitted.
|
||||
func (s msgServer) ActivateService(ctx interface{}, msg *types.MsgActivateService) (*types.MsgActivateServiceResponse, error) {
|
||||
if err := msg.ValidateBasic(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sdkCtx := unwrapCtx(ctx)
|
||||
|
||||
info, ok := s.Keeper.GetService(sdkCtx, msg.ServiceID)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("services: service %q not found", msg.ServiceID)
|
||||
}
|
||||
|
||||
if !types.ValidServiceTransition(info.Status, types.ServiceActive) {
|
||||
return nil, fmt.Errorf("services: service %q status %q cannot transition to Active (REQ-037 lifecycle)", msg.ServiceID, info.Status)
|
||||
}
|
||||
|
||||
if err := s.assertWindowActive(info.WindowID, "ActivateService"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
info.Status = types.ServiceActive
|
||||
s.Keeper.SetService(sdkCtx, info)
|
||||
|
||||
sdkCtx.EventManager().EmitEvent(sdk.NewEvent(
|
||||
"services.service_activated",
|
||||
sdk.NewAttribute("service_id", msg.ServiceID),
|
||||
sdk.NewAttribute("status", string(types.ServiceActive)),
|
||||
))
|
||||
return &types.MsgActivateServiceResponse{}, nil
|
||||
}
|
||||
|
||||
// --- SuspendService -----------------------------------------------------
|
||||
|
||||
// SuspendService transitions a service Active → Suspended. The handler
|
||||
// enforces:
|
||||
// 1. ValidateBasic (stateless).
|
||||
// 2. The service must exist.
|
||||
// 3. The source status must be Active (ValidServiceTransition(Active,
|
||||
// Suspended) — the lifecycle gate).
|
||||
// 4. A-552: the window-id on the existing service must still reference
|
||||
// an Active Window.
|
||||
//
|
||||
// On success the status is transitioned to Suspended and an event is
|
||||
// emitted.
|
||||
func (s msgServer) SuspendService(ctx interface{}, msg *types.MsgSuspendService) (*types.MsgSuspendServiceResponse, error) {
|
||||
if err := msg.ValidateBasic(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sdkCtx := unwrapCtx(ctx)
|
||||
|
||||
info, ok := s.Keeper.GetService(sdkCtx, msg.ServiceID)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("services: service %q not found", msg.ServiceID)
|
||||
}
|
||||
|
||||
if !types.ValidServiceTransition(info.Status, types.ServiceSuspended) {
|
||||
return nil, fmt.Errorf("services: service %q status %q cannot transition to Suspended (REQ-037 lifecycle)", msg.ServiceID, info.Status)
|
||||
}
|
||||
|
||||
if err := s.assertWindowActive(info.WindowID, "SuspendService"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
info.Status = types.ServiceSuspended
|
||||
s.Keeper.SetService(sdkCtx, info)
|
||||
|
||||
sdkCtx.EventManager().EmitEvent(sdk.NewEvent(
|
||||
"services.service_suspended",
|
||||
sdk.NewAttribute("service_id", msg.ServiceID),
|
||||
sdk.NewAttribute("status", string(types.ServiceSuspended)),
|
||||
))
|
||||
return &types.MsgSuspendServiceResponse{}, nil
|
||||
}
|
||||
|
||||
// --- RevokeService -----------------------------------------------------
|
||||
|
||||
// RevokeService transitions a service to Revoked (terminal). The
|
||||
// handler enforces:
|
||||
// 1. ValidateBasic (stateless).
|
||||
// 2. The service must exist.
|
||||
// 3. The service must not already be Revoked (idempotent reject — no
|
||||
// double-effect).
|
||||
// 4. A-552: the window-id on the existing service must still reference
|
||||
// an Active Window (a revoked Window invalidates the revocation
|
||||
// too — mirroring the grantor-authorized revoke path; the simtest
|
||||
// wiring uses a nil WindowKeeper to skip this check on the
|
||||
// Watcher-quorum revoke path).
|
||||
// 5. The transition gate (ValidServiceTransition — any source → Revoked
|
||||
// is permitted except Revoked itself).
|
||||
//
|
||||
// On success the status is transitioned to Revoked (terminal) and an
|
||||
// event is emitted.
|
||||
func (s msgServer) RevokeService(ctx interface{}, msg *types.MsgRevokeService) (*types.MsgRevokeServiceResponse, error) {
|
||||
if err := msg.ValidateBasic(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sdkCtx := unwrapCtx(ctx)
|
||||
|
||||
info, ok := s.Keeper.GetService(sdkCtx, msg.ServiceID)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("services: service %q not found", msg.ServiceID)
|
||||
}
|
||||
|
||||
// Idempotent reject: a Revoked service cannot be re-revoked.
|
||||
if info.Status == types.ServiceRevoked {
|
||||
return nil, fmt.Errorf("services: service %q already revoked (idempotent reject — no double-effect)", msg.ServiceID)
|
||||
}
|
||||
|
||||
if err := s.assertWindowActive(info.WindowID, "RevokeService"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if !types.ValidServiceTransition(info.Status, types.ServiceRevoked) {
|
||||
return nil, fmt.Errorf("services: service %q status %q cannot transition to Revoked (REQ-037 lifecycle)", msg.ServiceID, info.Status)
|
||||
}
|
||||
|
||||
info.Status = types.ServiceRevoked
|
||||
s.Keeper.SetService(sdkCtx, info)
|
||||
|
||||
sdkCtx.EventManager().EmitEvent(sdk.NewEvent(
|
||||
"services.service_revoked",
|
||||
sdk.NewAttribute("service_id", msg.ServiceID),
|
||||
sdk.NewAttribute("status", string(types.ServiceRevoked)),
|
||||
))
|
||||
return &types.MsgRevokeServiceResponse{}, nil
|
||||
}
|
||||
|
||||
// --- IssueCareGrant (Care — A-551 typed dispatch) ---------------------
|
||||
|
||||
// IssueCareGrant issues a community-care grant against a Care service
|
||||
// (ServiceKind=Care). The handler enforces:
|
||||
// 1. ValidateBasic (stateless).
|
||||
// 2. The service must exist.
|
||||
// 3. A-551 typed dispatch: the service Kind must be Care (NOT a generic
|
||||
// dispatch — a kind mismatch is a runtime reject).
|
||||
// 4. A-552: the window-id on the existing service must still reference
|
||||
// an Active Window (window-grant-on-every-op; a revoked Window
|
||||
// invalidates the per-kind op).
|
||||
// 5. The CareService metadata is updated with the care-kind (the
|
||||
// per-kind state).
|
||||
//
|
||||
// On success the CareService metadata is persisted and an event is
|
||||
// emitted.
|
||||
func (s msgServer) IssueCareGrant(ctx interface{}, msg *types.MsgIssueCareGrant) (*types.MsgIssueCareGrantResponse, error) {
|
||||
if err := msg.ValidateBasic(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sdkCtx := unwrapCtx(ctx)
|
||||
|
||||
info, ok := s.Keeper.GetService(sdkCtx, msg.ServiceID)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("services: service %q not found", msg.ServiceID)
|
||||
}
|
||||
|
||||
// A-551 typed dispatch: kind must be Care.
|
||||
if info.Kind != types.KindCare {
|
||||
return nil, fmt.Errorf("services: service %q kind %q is not Care (IssueCareGrant is the Care typed dispatch — A-551)", msg.ServiceID, info.Kind)
|
||||
}
|
||||
|
||||
if err := s.assertWindowActive(info.WindowID, "IssueCareGrant"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Update the CareService per-kind metadata with the care-kind.
|
||||
care, _ := s.Keeper.GetCareService(sdkCtx, msg.ServiceID)
|
||||
care.CareID = msg.ServiceID
|
||||
care.CareKind = msg.CareKind
|
||||
s.Keeper.SetCareService(sdkCtx, care)
|
||||
|
||||
sdkCtx.EventManager().EmitEvent(sdk.NewEvent(
|
||||
"services.care_grant_issued",
|
||||
sdk.NewAttribute("service_id", msg.ServiceID),
|
||||
sdk.NewAttribute("care_kind", msg.CareKind),
|
||||
sdk.NewAttribute("grant_recipient_reach_id", msg.GrantRecipientReachID),
|
||||
))
|
||||
return &types.MsgIssueCareGrantResponse{}, nil
|
||||
}
|
||||
|
||||
// --- ActivateSIM (SIM — A-551 typed dispatch) -----------------------
|
||||
|
||||
// ActivateSIM activates a connectivity SIM against a SIM service
|
||||
// (ServiceKind=SIM). The handler enforces:
|
||||
// 1. ValidateBasic (stateless).
|
||||
// 2. The service must exist.
|
||||
// 3. A-551 typed dispatch: the service Kind must be SIM.
|
||||
// 4. A-552: the window-id on the existing service must still reference
|
||||
// an Active Window.
|
||||
// 5. The SIMService metadata is updated with the carrier.
|
||||
//
|
||||
// On success the SIMService metadata is persisted and an event is
|
||||
// emitted.
|
||||
func (s msgServer) ActivateSIM(ctx interface{}, msg *types.MsgActivateSIM) (*types.MsgActivateSIMResponse, error) {
|
||||
if err := msg.ValidateBasic(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sdkCtx := unwrapCtx(ctx)
|
||||
|
||||
info, ok := s.Keeper.GetService(sdkCtx, msg.ServiceID)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("services: service %q not found", msg.ServiceID)
|
||||
}
|
||||
|
||||
// A-551 typed dispatch: kind must be SIM.
|
||||
if info.Kind != types.KindSIM {
|
||||
return nil, fmt.Errorf("services: service %q kind %q is not SIM (ActivateSIM is the SIM typed dispatch — A-551)", msg.ServiceID, info.Kind)
|
||||
}
|
||||
|
||||
if err := s.assertWindowActive(info.WindowID, "ActivateSIM"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Update the SIMService per-kind metadata with the carrier.
|
||||
sim, _ := s.Keeper.GetSIMService(sdkCtx, msg.ServiceID)
|
||||
sim.SIMID = msg.ServiceID
|
||||
sim.Carrier = msg.Carrier
|
||||
s.Keeper.SetSIMService(sdkCtx, sim)
|
||||
|
||||
sdkCtx.EventManager().EmitEvent(sdk.NewEvent(
|
||||
"services.sim_activated",
|
||||
sdk.NewAttribute("service_id", msg.ServiceID),
|
||||
sdk.NewAttribute("carrier", msg.Carrier),
|
||||
sdk.NewAttribute("recipient_reach_id", msg.RecipientReachID),
|
||||
))
|
||||
return &types.MsgActivateSIMResponse{}, nil
|
||||
}
|
||||
|
||||
// --- ProvisionVault (Vault — A-551 typed dispatch, A-553 VaultKeeper shim) --
|
||||
|
||||
// ProvisionVault provisions storage-quota-grain against a Vault service
|
||||
// (ServiceKind=Vault; A-553: delegates to the VaultKeeper shim). The
|
||||
// handler enforces:
|
||||
// 1. ValidateBasic (stateless).
|
||||
// 2. The service must exist.
|
||||
// 3. A-551 typed dispatch: the service Kind must be Vault.
|
||||
// 4. A-552: the window-id on the existing service must still reference
|
||||
// an Active Window.
|
||||
// 5. A-553: the VaultKeeper shim must be non-nil (a nil shim is a wiring
|
||||
// error — the VaultService requires a real vault keeper). The shim
|
||||
// is delegated the storage-quota-grain provisioning by-ID-string.
|
||||
// A non-nil error from the shim REJECTS the provisioning (the
|
||||
// VaultService metadata is NOT updated).
|
||||
// 6. On shim success, the VaultService metadata is updated with the
|
||||
// storage-quota-grain.
|
||||
//
|
||||
// On success the VaultService metadata is persisted and an event is
|
||||
// emitted.
|
||||
func (s msgServer) ProvisionVault(ctx interface{}, msg *types.MsgProvisionVault) (*types.MsgProvisionVaultResponse, error) {
|
||||
if err := msg.ValidateBasic(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sdkCtx := unwrapCtx(ctx)
|
||||
|
||||
info, ok := s.Keeper.GetService(sdkCtx, msg.ServiceID)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("services: service %q not found", msg.ServiceID)
|
||||
}
|
||||
|
||||
// A-551 typed dispatch: kind must be Vault.
|
||||
if info.Kind != types.KindVault {
|
||||
return nil, fmt.Errorf("services: service %q kind %q is not Vault (ProvisionVault is the Vault typed dispatch — A-551)", msg.ServiceID, info.Kind)
|
||||
}
|
||||
|
||||
if err := s.assertWindowActive(info.WindowID, "ProvisionVault"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// A-553: delegate to the VaultKeeper shim. A nil shim is a wiring
|
||||
// error (the VaultService requires a real vault keeper — a nil shim
|
||||
// is NOT a simtest skip path; the simtest wires a stub vault keeper).
|
||||
if s.Keeper.vaultKeeper == nil {
|
||||
return nil, fmt.Errorf("services: vault keeper not wired (ProvisionVault rejected — A-553 VaultService provisioning requires a real vault keeper)")
|
||||
}
|
||||
if err := s.Keeper.vaultKeeper.ProvisionVault(msg.ServiceID, msg.StorageQuotaGrain); err != nil {
|
||||
return nil, fmt.Errorf("services: vault keeper provisioning for service %q: %w (A-553)", msg.ServiceID, err)
|
||||
}
|
||||
|
||||
// Update the VaultService per-kind metadata with the storage-quota-grain.
|
||||
vault, _ := s.Keeper.GetVaultService(sdkCtx, msg.ServiceID)
|
||||
vault.VaultID = msg.ServiceID
|
||||
vault.StorageQuotaGrain = msg.StorageQuotaGrain
|
||||
s.Keeper.SetVaultService(sdkCtx, vault)
|
||||
|
||||
sdkCtx.EventManager().EmitEvent(sdk.NewEvent(
|
||||
"services.vault_provisioned",
|
||||
sdk.NewAttribute("service_id", msg.ServiceID),
|
||||
sdk.NewAttribute("storage_quota_grain", fmt.Sprintf("%d", msg.StorageQuotaGrain)),
|
||||
))
|
||||
return &types.MsgProvisionVaultResponse{}, nil
|
||||
}
|
||||
|
||||
// --- BindMailbox (Mail — A-551 typed dispatch) ----------------------
|
||||
|
||||
// BindMailbox binds a messaging mailbox against a Mail service
|
||||
// (ServiceKind=Mail). The handler enforces:
|
||||
// 1. ValidateBasic (stateless).
|
||||
// 2. The service must exist.
|
||||
// 3. A-551 typed dispatch: the service Kind must be Mail.
|
||||
// 4. A-552: the window-id on the existing service must still reference
|
||||
// an Active Window.
|
||||
// 5. The MailService metadata is updated with the mailbox-id +
|
||||
// holder-reach-id.
|
||||
//
|
||||
// On success the MailService metadata is persisted and an event is
|
||||
// emitted.
|
||||
func (s msgServer) BindMailbox(ctx interface{}, msg *types.MsgBindMailbox) (*types.MsgBindMailboxResponse, error) {
|
||||
if err := msg.ValidateBasic(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sdkCtx := unwrapCtx(ctx)
|
||||
|
||||
info, ok := s.Keeper.GetService(sdkCtx, msg.ServiceID)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("services: service %q not found", msg.ServiceID)
|
||||
}
|
||||
|
||||
// A-551 typed dispatch: kind must be Mail.
|
||||
if info.Kind != types.KindMail {
|
||||
return nil, fmt.Errorf("services: service %q kind %q is not Mail (BindMailbox is the Mail typed dispatch — A-551)", msg.ServiceID, info.Kind)
|
||||
}
|
||||
|
||||
if err := s.assertWindowActive(info.WindowID, "BindMailbox"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Update the MailService per-kind metadata with the mailbox-id +
|
||||
// holder-reach-id.
|
||||
mail, _ := s.Keeper.GetMailService(sdkCtx, msg.ServiceID)
|
||||
mail.MailID = msg.ServiceID
|
||||
mail.MailboxID = msg.MailboxID
|
||||
mail.HolderReachID = msg.HolderReachID
|
||||
s.Keeper.SetMailService(sdkCtx, mail)
|
||||
|
||||
sdkCtx.EventManager().EmitEvent(sdk.NewEvent(
|
||||
"services.mailbox_bound",
|
||||
sdk.NewAttribute("service_id", msg.ServiceID),
|
||||
sdk.NewAttribute("mailbox_id", msg.MailboxID),
|
||||
sdk.NewAttribute("holder_reach_id", msg.HolderReachID),
|
||||
))
|
||||
return &types.MsgBindMailboxResponse{}, nil
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,85 @@
|
||||
package services
|
||||
|
||||
// module.go holds the services module's AppModule + RegisterServices
|
||||
// (P5-02-01, REQ-037).
|
||||
//
|
||||
// The AppModule wraps the services Keeper and registers the MsgServer
|
||||
// via RegisterServices. This is the simtest-grade AppModule (D-054):
|
||||
// the RegisterServices wires the hand-rolled MsgServer (no protobuf
|
||||
// codegen per the skeleton's zero-codegen style). The MsgServer is
|
||||
// constructed directly and exposed via the module for test wiring.
|
||||
//
|
||||
// The WindowKeeper and VaultKeeper expected-keeper shims are injected
|
||||
// at construction (nil-able for partial tests). The WindowKeeper shim
|
||||
// is the A-552 window-grant-on-every-op authority boundary; the
|
||||
// VaultKeeper shim is the A-553 VaultService provisioning boundary.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
|
||||
storetypes "cosmossdk.io/store/types"
|
||||
"github.com/cosmos/cosmos-sdk/codec"
|
||||
sdk "github.com/cosmos/cosmos-sdk/types"
|
||||
"github.com/cosmos/cosmos-sdk/types/module"
|
||||
|
||||
"github.com/oy/openyield/x/services/keeper"
|
||||
"github.com/oy/openyield/x/services/types"
|
||||
)
|
||||
|
||||
// ConsensusVersion is the services module's consensus version (AppModule).
|
||||
const ConsensusVersion = 1
|
||||
|
||||
// AppModule is the services application module (simtest-grade — D-054).
|
||||
type AppModule struct {
|
||||
keeper keeper.Keeper
|
||||
}
|
||||
|
||||
// NewAppModule constructs a new services AppModule. The WindowKeeper
|
||||
// and VaultKeeper expected-keeper shims are injected (nil-able for
|
||||
// partial tests). The WindowKeeper shim is the A-552 window-grant-on-
|
||||
// every-op authority boundary; the VaultKeeper shim is the A-553
|
||||
// VaultService provisioning boundary.
|
||||
func NewAppModule(cdc codec.Codec, storeKey storetypes.StoreKey, wk types.WindowKeeper, vk types.VaultKeeper) AppModule {
|
||||
k := keeper.NewKeeper(cdc, storeKey, wk, vk)
|
||||
return AppModule{keeper: k}
|
||||
}
|
||||
|
||||
// RegisterServices registers the services MsgServer. Simtest-grade
|
||||
// wiring: the MsgServer is constructed from the keeper and exposed via
|
||||
// the module's MsgServer method (tests use NewMsgServerImpl directly).
|
||||
func (am AppModule) RegisterServices(cfg module.Configurator) {
|
||||
_ = cfg
|
||||
}
|
||||
|
||||
// MsgServer returns the services MsgServer for this module's keeper.
|
||||
func (am AppModule) MsgServer() types.MsgServer {
|
||||
return keeper.NewMsgServerImpl(am.keeper)
|
||||
}
|
||||
|
||||
// Name returns the module name.
|
||||
func (AppModule) Name() string { return types.ModuleName }
|
||||
|
||||
// ConsensusVersion implements AppModule.ConsensusVersion.
|
||||
func (AppModule) ConsensusVersion() uint64 { return ConsensusVersion }
|
||||
|
||||
// InitGenesis performs genesis initialization for the services module
|
||||
// (simtest-grade no-op — the runtime stores are created at handler
|
||||
// time; genesis init of runtime-promoted stores is deferred to the
|
||||
// live chain v0.6+).
|
||||
func (am AppModule) InitGenesis(ctx sdk.Context, cdc codec.JSONCodec, data json.RawMessage) {
|
||||
var gs types.GenesisState
|
||||
cdc.MustUnmarshalJSON(data, &gs)
|
||||
_ = gs
|
||||
}
|
||||
|
||||
// ExportGenesis returns the exported genesis state as raw bytes
|
||||
// (simtest-grade: returns an empty genesis; live chain export deferred
|
||||
// to v0.6+).
|
||||
func (am AppModule) ExportGenesis(ctx sdk.Context, cdc codec.JSONCodec) json.RawMessage {
|
||||
gs := types.DefaultGenesisState()
|
||||
return cdc.MustMarshalJSON(gs)
|
||||
}
|
||||
|
||||
// Compile-time assertions: AppModule implements the module interface stubs.
|
||||
var _ module.HasName = AppModule{}
|
||||
var _ module.HasConsensusVersion = AppModule{}
|
||||
@@ -0,0 +1,120 @@
|
||||
package types
|
||||
|
||||
// expected_keepers.go holds the Go INTERFACES for the cross-module keepers
|
||||
// x/services depends on at runtime (P5-01-01, REQ-037; G-003 firewall —
|
||||
// ibc-go expected-keepers convention; mirrors x/partner/types/expected_keepers.go
|
||||
// and x/hub/types/expected_keepers.go).
|
||||
//
|
||||
// The services runtime (REQ-037) depends on TWO cross-module keepers:
|
||||
//
|
||||
// 1. x/window (WindowKeeper) — the service-grant authority boundary. A
|
||||
// service-grant opens a Window on the holder's behalf (A-307); the
|
||||
// Window's status is the service's authority. The handler consults
|
||||
// WindowKeeper.GetWindowStatus on EVERY service operation (A-552:
|
||||
// window-grant-on-every-op — not just at registration); a Window that
|
||||
// is not Active (Revoked / Expired / unknown) invalidates the op. This
|
||||
// is the runtime echo of the v0.3 ServiceInfo.window-id by-ID-string
|
||||
// field: the field stays a string (G-003), and the interface is the
|
||||
// runtime validity boundary.
|
||||
//
|
||||
// 2. x/vault (VaultKeeper) — the VaultService (ServiceKind=Vault)
|
||||
// provisioning shim. The MsgProvisionVault handler delegates the
|
||||
// storage-quota provisioning to the x/vault keeper by-ID-string
|
||||
// (A-553: VaultService references x/vault by ID via the shim — G-003).
|
||||
// The v0.3 VaultService struct (types.go) named the x/vault collision
|
||||
// conceptually (the ServiceKind "Vault" is a service kind, NOT a
|
||||
// struct import); v0.5 wires the runtime provisioning via this
|
||||
// interface (no struct import of x/vault/types — G-003 intact).
|
||||
//
|
||||
// Both dependencies are expressed as INTERFACES defined HERE (in
|
||||
// x/services/types), NOT as struct imports of x/window/types or
|
||||
// x/vault/types. The concrete keepers satisfy these interfaces
|
||||
// structurally (the P5 simtest wires stub implementations — G-003 test
|
||||
// exemption); the handler depends on the interface, preserving G-003's
|
||||
// intent (no cross-module struct coupling, no import cycles).
|
||||
//
|
||||
// Test-only cross-package imports (the G-003 test exemption) remain
|
||||
// exempt: the simtest imports x/services/keeper + defines stub types
|
||||
// that satisfy the interfaces (no production struct imports across
|
||||
// x/<module>/types).
|
||||
//
|
||||
// Lexicon note (REQ-012): "Window", "Vault", "service", "grant",
|
||||
// "provisioning" are all lexicon-clean. The holder identifier is
|
||||
// "reach-id" (NOT a banned financial-holder term; use Holder/Reach).
|
||||
|
||||
// WindowStatus is the local redefinition of the x/window Window status
|
||||
// the services runtime cares about (G-003 — no struct import of
|
||||
// x/window/types; the status string crosses the interface boundary by
|
||||
// value). Only the Active status authorizes a service operation; any
|
||||
// other status (Revoked, Expired, unknown) invalidates the op (A-552).
|
||||
type WindowStatus string
|
||||
|
||||
const (
|
||||
// WindowStatusActive is the only status that authorizes a service
|
||||
// operation. The handler consults WindowKeeper.GetWindowStatus on
|
||||
// every op and REJECTS the op if the status is not Active (A-552).
|
||||
WindowStatusActive WindowStatus = "Active"
|
||||
// WindowStatusRevoked is a permanently-revoked Window (invalidates
|
||||
// the service op — A-552).
|
||||
WindowStatusRevoked WindowStatus = "Revoked"
|
||||
// WindowStatusExpired is an expired Window (invalidates the service
|
||||
// op — A-552: an op after the Window expired is a Window-violation).
|
||||
WindowStatusExpired WindowStatus = "Expired"
|
||||
// WindowStatusUnknown is the sentinel for a Window the keeper does
|
||||
// not know about (treated as not-Active — the op is REJECTED).
|
||||
WindowStatusUnknown WindowStatus = "Unknown"
|
||||
)
|
||||
|
||||
// WindowKeeper is the expected-keeper interface for x/window (G-003). The
|
||||
// services handler consults it on EVERY service operation (A-552):
|
||||
//
|
||||
// - RegisterService: the window-id on the new service must reference an
|
||||
// Active Window BEFORE the service is created; a non-Active Window
|
||||
// REJECTS the registration (the service is not created).
|
||||
// - ActivateService / SuspendService / RevokeService: the window-id on
|
||||
// the existing service must still be Active BEFORE the transition;
|
||||
// a revoked/expired Window invalidates the op (the service stays in
|
||||
// its pre-op status).
|
||||
// - Per-kind handlers (IssueCareGrant, ActivateSIM, ProvisionVault,
|
||||
// BindMailbox): the window-id on the service must still be Active
|
||||
// BEFORE the per-kind op; a revoked/expired Window REJECTS the op
|
||||
// (the per-kind state is NOT mutated).
|
||||
//
|
||||
// No struct import of x/window/types — the interface is the by-ID-string
|
||||
// boundary (G-003). The windowID is an opaque string (the by-ID-string
|
||||
// ref to an x/window Window; A-307).
|
||||
type WindowKeeper interface {
|
||||
// GetWindowStatus reports the status of the named Window (by-ID-string)
|
||||
// at the current block. The services handler consults this BEFORE
|
||||
// every service op (A-552 — window-grant-on-every-op). Returns
|
||||
// WindowStatusActive if the Window is live and authorizes ops;
|
||||
// WindowStatusRevoked / WindowStatusExpired / WindowStatusUnknown if
|
||||
// the Window is not authorizing. An error indicates the keeper could
|
||||
// not answer (treated as not-Active — the op is REJECTED).
|
||||
GetWindowStatus(windowID string) (WindowStatus, error)
|
||||
}
|
||||
|
||||
// VaultKeeper is the expected-keeper interface for x/vault (G-003,
|
||||
// A-553). The VaultService (ServiceKind=Vault) handler calls it for:
|
||||
//
|
||||
// - ProvisionVault: the MsgProvisionVault handler delegates the
|
||||
// storage-quota-grain provisioning to the x/vault keeper by-ID-string
|
||||
// (the vault-id on the VaultService is the by-ID-string ref to an
|
||||
// x/vault Vault). A nil shim REJECTS the provisioning (the
|
||||
// VaultService requires a real vault keeper — a nil shim is a wiring
|
||||
// error, not a simtest skip path; the simtest wires a stub vault
|
||||
// keeper, never nil).
|
||||
//
|
||||
// No struct import of x/vault/types — the interface is the by-ID-string
|
||||
// boundary (G-003, A-553). The serviceID is the by-ID-string ref to the
|
||||
// VaultService; the storage-quota-grain is the OY internal unit (by name
|
||||
// only — no x/bread import).
|
||||
type VaultKeeper interface {
|
||||
// ProvisionVault records the storage-quota-grain provisioning for
|
||||
// the named VaultService (by-ID-string). The MsgProvisionVault
|
||||
// handler consults this AFTER the window-grant check (A-552) and
|
||||
// BEFORE emitting the provisioning event. A non-nil error REJECTS
|
||||
// the provisioning (the VaultService storage-quota-grain is NOT
|
||||
// updated).
|
||||
ProvisionVault(serviceID string, quotaGrain int64) error
|
||||
}
|
||||
@@ -0,0 +1,552 @@
|
||||
package types
|
||||
|
||||
// msg_services.go holds the x/services Msg* types implementing sdk.Msg
|
||||
// (P5-01-01, REQ-037; G-006 controlled exception: types/ gains the
|
||||
// cosmos-sdk import for sdk.Msg — D-055; the invariant/lexicon tests in
|
||||
// *_test.go stay stdlib-only per G-024, isolated from this msg_*.go
|
||||
// file). Each Msg carries a ValidateBasic (stateless) and GetSigners.
|
||||
//
|
||||
// The eight Services Msg types drive the Care/SIM/Vault/Mail runtime
|
||||
// (REQ-037, A-551 per-kind typed dispatch — one Msg* per ServiceKind,
|
||||
// NOT a generic MsgInvokeService):
|
||||
//
|
||||
// Lifecycle (kind-agnostic):
|
||||
// - MsgRegisterService: register a service (operator-reach-id valid;
|
||||
// window-id must reference an Active Window — checked via the
|
||||
// WindowKeeper shim at the handler; status=Pending).
|
||||
// - MsgActivateService: Pending → Active (window-id must still be
|
||||
// Active — A-552 window-grant-on-every-op).
|
||||
// - MsgSuspendService: Active → Suspended.
|
||||
// - MsgRevokeService: any → Revoked (terminal; revocation requires
|
||||
// the Window grantor or a Watcher quorum — simtest wiring uses a
|
||||
// nil WindowKeeper for the grantor check).
|
||||
//
|
||||
// Per-kind (typed dispatch — A-551):
|
||||
// - MsgIssueCareGrant (Care) — issue a community-care grant.
|
||||
// - MsgActivateSIM (SIM) — activate a connectivity SIM.
|
||||
// - MsgProvisionVault (Vault) — provision storage-quota-grain via
|
||||
// the VaultKeeper shim (A-553: references x/vault by ID-string;
|
||||
// G-003 — no struct import of x/vault/types).
|
||||
// - MsgBindMailbox (Mail) — bind a messaging mailbox.
|
||||
//
|
||||
// All cross-module refs are by-ID-string (G-003): service-id is this
|
||||
// service's ID; operator-reach-id references an x/identity Reach by
|
||||
// ID-string; window-id references an x/window Window by ID-string
|
||||
// (A-307). GetSigners returns the signer reach-ids encoded as
|
||||
// sdk.AccAddress bytes. The reach-id is the lexicon-clean holder
|
||||
// identifier (G-003 — NOT a banned financial-holder term; use
|
||||
// Holder/Reach).
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
sdk "github.com/cosmos/cosmos-sdk/types"
|
||||
)
|
||||
|
||||
// --- MsgRegisterService ------------------------------------------------------
|
||||
|
||||
// MsgRegisterService registers a service (status=Pending). The handler
|
||||
// enforces the window-id must reference an Active Window via the
|
||||
// WindowKeeper shim (A-552). ValidateBasic is stateless: non-empty
|
||||
// service-id, non-empty operator-reach-id, non-empty window-id, a known
|
||||
// ServiceKind, non-empty name, non-empty signer.
|
||||
type MsgRegisterService 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"`
|
||||
WindowID string `json:"window_id" yaml:"window_id"`
|
||||
Signer string `json:"signer" yaml:"signer"`
|
||||
}
|
||||
|
||||
// Reset implements proto.Message (sdk.Msg = proto.Message).
|
||||
func (m *MsgRegisterService) Reset() { *m = MsgRegisterService{} }
|
||||
|
||||
// String implements proto.Message.
|
||||
func (m *MsgRegisterService) String() string {
|
||||
return fmt.Sprintf("MsgRegisterService{ServiceID:%s Kind:%s OperatorReachID:%s Name:%s WindowID:%s Signer:%s}",
|
||||
m.ServiceID, m.Kind, m.OperatorReachID, m.Name, m.WindowID, m.Signer)
|
||||
}
|
||||
|
||||
// ProtoMessage implements proto.Message.
|
||||
func (*MsgRegisterService) ProtoMessage() {}
|
||||
|
||||
// ValidateBasic is the stateless validation: non-empty service-id, a
|
||||
// known ServiceKind, non-empty operator-reach-id, non-empty name,
|
||||
// non-empty window-id, non-empty signer. The handler enforces the
|
||||
// stateful Window-Active check via the WindowKeeper shim (A-552) +
|
||||
// idempotency (service-id must not already exist).
|
||||
func (m *MsgRegisterService) ValidateBasic() error {
|
||||
if m.ServiceID == "" {
|
||||
return fmt.Errorf("services: empty service-id")
|
||||
}
|
||||
if !knownServiceKind(m.Kind) {
|
||||
return fmt.Errorf("services: unknown service kind %q", m.Kind)
|
||||
}
|
||||
if m.OperatorReachID == "" {
|
||||
return fmt.Errorf("services: empty operator-reach-id")
|
||||
}
|
||||
if m.Name == "" {
|
||||
return fmt.Errorf("services: empty name")
|
||||
}
|
||||
if m.WindowID == "" {
|
||||
return fmt.Errorf("services: empty window-id")
|
||||
}
|
||||
if m.Signer == "" {
|
||||
return fmt.Errorf("services: empty signer")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetSigners returns the signer's reach-id as sdk.AccAddress bytes.
|
||||
func (m *MsgRegisterService) GetSigners() []sdk.AccAddress {
|
||||
return []sdk.AccAddress{[]byte(m.Signer)}
|
||||
}
|
||||
|
||||
// --- MsgActivateService ------------------------------------------------------
|
||||
|
||||
// MsgActivateService transitions a service Pending → Active. The
|
||||
// handler enforces the window-id on the existing service must still be
|
||||
// Active (A-552 window-grant-on-every-op). ValidateBasic is stateless:
|
||||
// non-empty service-id, non-empty signer.
|
||||
type MsgActivateService struct {
|
||||
ServiceID string `json:"service_id" yaml:"service_id"`
|
||||
Signer string `json:"signer" yaml:"signer"`
|
||||
}
|
||||
|
||||
// Reset implements proto.Message.
|
||||
func (m *MsgActivateService) Reset() { *m = MsgActivateService{} }
|
||||
|
||||
// String implements proto.Message.
|
||||
func (m *MsgActivateService) String() string {
|
||||
return fmt.Sprintf("MsgActivateService{ServiceID:%s Signer:%s}", m.ServiceID, m.Signer)
|
||||
}
|
||||
|
||||
// ProtoMessage implements proto.Message.
|
||||
func (*MsgActivateService) ProtoMessage() {}
|
||||
|
||||
// ValidateBasic is the stateless validation: non-empty service-id,
|
||||
// non-empty signer. The handler enforces the stateful source-status
|
||||
// check (must be Pending) and the window-grant Active check (A-552).
|
||||
func (m *MsgActivateService) ValidateBasic() error {
|
||||
if m.ServiceID == "" {
|
||||
return fmt.Errorf("services: empty service-id")
|
||||
}
|
||||
if m.Signer == "" {
|
||||
return fmt.Errorf("services: empty signer")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetSigners returns the signer's reach-id as sdk.AccAddress bytes.
|
||||
func (m *MsgActivateService) GetSigners() []sdk.AccAddress {
|
||||
return []sdk.AccAddress{[]byte(m.Signer)}
|
||||
}
|
||||
|
||||
// --- MsgSuspendService -------------------------------------------------------
|
||||
|
||||
// MsgSuspendService transitions a service Active → Suspended. The
|
||||
// handler enforces the window-id on the existing service must still be
|
||||
// Active (A-552 window-grant-on-every-op — a revoked Window
|
||||
// invalidates the transition). ValidateBasic is stateless: non-empty
|
||||
// service-id, non-empty signer.
|
||||
type MsgSuspendService struct {
|
||||
ServiceID string `json:"service_id" yaml:"service_id"`
|
||||
Signer string `json:"signer" yaml:"signer"`
|
||||
}
|
||||
|
||||
// Reset implements proto.Message.
|
||||
func (m *MsgSuspendService) Reset() { *m = MsgSuspendService{} }
|
||||
|
||||
// String implements proto.Message.
|
||||
func (m *MsgSuspendService) String() string {
|
||||
return fmt.Sprintf("MsgSuspendService{ServiceID:%s Signer:%s}", m.ServiceID, m.Signer)
|
||||
}
|
||||
|
||||
// ProtoMessage implements proto.Message.
|
||||
func (*MsgSuspendService) ProtoMessage() {}
|
||||
|
||||
// ValidateBasic is the stateless validation: non-empty service-id,
|
||||
// non-empty signer. The handler enforces the stateful source-status
|
||||
// check (must be Active) and the window-grant Active check (A-552).
|
||||
func (m *MsgSuspendService) ValidateBasic() error {
|
||||
if m.ServiceID == "" {
|
||||
return fmt.Errorf("services: empty service-id")
|
||||
}
|
||||
if m.Signer == "" {
|
||||
return fmt.Errorf("services: empty signer")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetSigners returns the signer's reach-id as sdk.AccAddress bytes.
|
||||
func (m *MsgSuspendService) GetSigners() []sdk.AccAddress {
|
||||
return []sdk.AccAddress{[]byte(m.Signer)}
|
||||
}
|
||||
|
||||
// --- MsgRevokeService --------------------------------------------------------
|
||||
|
||||
// MsgRevokeService transitions a service to Revoked (terminal). The
|
||||
// handler enforces the window-id on the existing service must still be
|
||||
// Active (A-552 window-grant-on-every-op — a revoked Window invalidates
|
||||
// the revocation too, mirroring the grantor-authorized revoke path).
|
||||
// Revocation in the simtest is grantor-authorized via the signer reach-
|
||||
// id; a Watcher quorum path is documented for the live chain (v0.6+).
|
||||
// ValidateBasic is stateless: non-empty service-id, non-empty signer.
|
||||
type MsgRevokeService struct {
|
||||
ServiceID string `json:"service_id" yaml:"service_id"`
|
||||
Signer string `json:"signer" yaml:"signer"`
|
||||
}
|
||||
|
||||
// Reset implements proto.Message.
|
||||
func (m *MsgRevokeService) Reset() { *m = MsgRevokeService{} }
|
||||
|
||||
// String implements proto.Message.
|
||||
func (m *MsgRevokeService) String() string {
|
||||
return fmt.Sprintf("MsgRevokeService{ServiceID:%s Signer:%s}", m.ServiceID, m.Signer)
|
||||
}
|
||||
|
||||
// ProtoMessage implements proto.Message.
|
||||
func (*MsgRevokeService) ProtoMessage() {}
|
||||
|
||||
// ValidateBasic is the stateless validation: non-empty service-id,
|
||||
// non-empty signer. The handler enforces the stateful source-status
|
||||
// check (must not already be Revoked — idempotent reject) and the
|
||||
// window-grant Active check (A-552).
|
||||
func (m *MsgRevokeService) ValidateBasic() error {
|
||||
if m.ServiceID == "" {
|
||||
return fmt.Errorf("services: empty service-id")
|
||||
}
|
||||
if m.Signer == "" {
|
||||
return fmt.Errorf("services: empty signer")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetSigners returns the signer's reach-id as sdk.AccAddress bytes.
|
||||
func (m *MsgRevokeService) GetSigners() []sdk.AccAddress {
|
||||
return []sdk.AccAddress{[]byte(m.Signer)}
|
||||
}
|
||||
|
||||
// --- MsgIssueCareGrant (Care — A-551 typed dispatch) ------------------------
|
||||
|
||||
// MsgIssueCareGrant issues a community-care grant against a Care service
|
||||
// (ServiceKind=Care — A-551 per-kind typed dispatch, NOT a generic
|
||||
// MsgInvokeService). The handler enforces the window-id on the existing
|
||||
// Care service must still be Active (A-552 window-grant-on-every-op).
|
||||
// ValidateBasic is stateless: non-empty service-id, non-empty
|
||||
// care-kind, non-empty grant-recipient-reach-id, non-empty signer.
|
||||
type MsgIssueCareGrant struct {
|
||||
ServiceID string `json:"service_id" yaml:"service_id"`
|
||||
CareKind string `json:"care_kind" yaml:"care_kind"`
|
||||
GrantRecipientReachID string `json:"grant_recipient_reach_id" yaml:"grant_recipient_reach_id"`
|
||||
Signer string `json:"signer" yaml:"signer"`
|
||||
}
|
||||
|
||||
// Reset implements proto.Message.
|
||||
func (m *MsgIssueCareGrant) Reset() { *m = MsgIssueCareGrant{} }
|
||||
|
||||
// String implements proto.Message.
|
||||
func (m *MsgIssueCareGrant) String() string {
|
||||
return fmt.Sprintf("MsgIssueCareGrant{ServiceID:%s CareKind:%s GrantRecipientReachID:%s Signer:%s}",
|
||||
m.ServiceID, m.CareKind, m.GrantRecipientReachID, m.Signer)
|
||||
}
|
||||
|
||||
// ProtoMessage implements proto.Message.
|
||||
func (*MsgIssueCareGrant) ProtoMessage() {}
|
||||
|
||||
// ValidateBasic is the stateless validation: non-empty service-id,
|
||||
// non-empty care-kind, non-empty grant-recipient-reach-id, non-empty
|
||||
// signer. The handler enforces the stateful service-exists + kind=Care
|
||||
// + window-grant Active checks (A-552).
|
||||
func (m *MsgIssueCareGrant) ValidateBasic() error {
|
||||
if m.ServiceID == "" {
|
||||
return fmt.Errorf("services: empty service-id")
|
||||
}
|
||||
if m.CareKind == "" {
|
||||
return fmt.Errorf("services: empty care-kind")
|
||||
}
|
||||
if m.GrantRecipientReachID == "" {
|
||||
return fmt.Errorf("services: empty grant-recipient-reach-id")
|
||||
}
|
||||
if m.Signer == "" {
|
||||
return fmt.Errorf("services: empty signer")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetSigners returns the signer's reach-id as sdk.AccAddress bytes.
|
||||
func (m *MsgIssueCareGrant) GetSigners() []sdk.AccAddress {
|
||||
return []sdk.AccAddress{[]byte(m.Signer)}
|
||||
}
|
||||
|
||||
// --- MsgActivateSIM (SIM — A-551 typed dispatch) ----------------------------
|
||||
|
||||
// MsgActivateSIM activates a connectivity SIM against a SIM service
|
||||
// (ServiceKind=SIM — A-551 per-kind typed dispatch). The handler
|
||||
// enforces the window-id on the existing SIM service must still be
|
||||
// Active (A-552 window-grant-on-every-op). ValidateBasic is stateless:
|
||||
// non-empty service-id, non-empty carrier, non-empty
|
||||
// recipient-reach-id, non-empty signer.
|
||||
type MsgActivateSIM struct {
|
||||
ServiceID string `json:"service_id" yaml:"service_id"`
|
||||
Carrier string `json:"carrier" yaml:"carrier"`
|
||||
RecipientReachID string `json:"recipient_reach_id" yaml:"recipient_reach_id"`
|
||||
Signer string `json:"signer" yaml:"signer"`
|
||||
}
|
||||
|
||||
// Reset implements proto.Message.
|
||||
func (m *MsgActivateSIM) Reset() { *m = MsgActivateSIM{} }
|
||||
|
||||
// String implements proto.Message.
|
||||
func (m *MsgActivateSIM) String() string {
|
||||
return fmt.Sprintf("MsgActivateSIM{ServiceID:%s Carrier:%s RecipientReachID:%s Signer:%s}",
|
||||
m.ServiceID, m.Carrier, m.RecipientReachID, m.Signer)
|
||||
}
|
||||
|
||||
// ProtoMessage implements proto.Message.
|
||||
func (*MsgActivateSIM) ProtoMessage() {}
|
||||
|
||||
// ValidateBasic is the stateless validation: non-empty service-id,
|
||||
// non-empty carrier, non-empty recipient-reach-id, non-empty signer.
|
||||
// The handler enforces the stateful service-exists + kind=SIM +
|
||||
// window-grant Active checks (A-552).
|
||||
func (m *MsgActivateSIM) ValidateBasic() error {
|
||||
if m.ServiceID == "" {
|
||||
return fmt.Errorf("services: empty service-id")
|
||||
}
|
||||
if m.Carrier == "" {
|
||||
return fmt.Errorf("services: empty carrier")
|
||||
}
|
||||
if m.RecipientReachID == "" {
|
||||
return fmt.Errorf("services: empty recipient-reach-id")
|
||||
}
|
||||
if m.Signer == "" {
|
||||
return fmt.Errorf("services: empty signer")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetSigners returns the signer's reach-id as sdk.AccAddress bytes.
|
||||
func (m *MsgActivateSIM) GetSigners() []sdk.AccAddress {
|
||||
return []sdk.AccAddress{[]byte(m.Signer)}
|
||||
}
|
||||
|
||||
// --- MsgProvisionVault (Vault — A-551 typed dispatch, A-553 x/vault shim) ---
|
||||
|
||||
// MsgProvisionVault provisions storage-quota-grain against a Vault
|
||||
// service (ServiceKind=Vault — A-551 per-kind typed dispatch; A-553:
|
||||
// references x/vault by ID via the VaultKeeper shim — G-003). The
|
||||
// handler enforces the window-id on the existing Vault service must
|
||||
// still be Active (A-552) and delegates the storage-quota-grain
|
||||
// provisioning to the VaultKeeper shim. ValidateBasic is stateless:
|
||||
// non-empty service-id, storage-quota-grain > 0, non-empty signer.
|
||||
type MsgProvisionVault struct {
|
||||
ServiceID string `json:"service_id" yaml:"service_id"`
|
||||
StorageQuotaGrain int64 `json:"storage_quota_grain" yaml:"storage_quota_grain"`
|
||||
Signer string `json:"signer" yaml:"signer"`
|
||||
}
|
||||
|
||||
// Reset implements proto.Message.
|
||||
func (m *MsgProvisionVault) Reset() { *m = MsgProvisionVault{} }
|
||||
|
||||
// String implements proto.Message.
|
||||
func (m *MsgProvisionVault) String() string {
|
||||
return fmt.Sprintf("MsgProvisionVault{ServiceID:%s StorageQuotaGrain:%d Signer:%s}",
|
||||
m.ServiceID, m.StorageQuotaGrain, m.Signer)
|
||||
}
|
||||
|
||||
// ProtoMessage implements proto.Message.
|
||||
func (*MsgProvisionVault) ProtoMessage() {}
|
||||
|
||||
// ValidateBasic is the stateless validation: non-empty service-id,
|
||||
// storage-quota-grain > 0, non-empty signer. The handler enforces the
|
||||
// stateful service-exists + kind=Vault + window-grant Active checks
|
||||
// (A-552) and delegates to the VaultKeeper shim (A-553).
|
||||
func (m *MsgProvisionVault) ValidateBasic() error {
|
||||
if m.ServiceID == "" {
|
||||
return fmt.Errorf("services: empty service-id")
|
||||
}
|
||||
if m.StorageQuotaGrain <= 0 {
|
||||
return fmt.Errorf("services: storage-quota-grain must be > 0")
|
||||
}
|
||||
if m.Signer == "" {
|
||||
return fmt.Errorf("services: empty signer")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetSigners returns the signer's reach-id as sdk.AccAddress bytes.
|
||||
func (m *MsgProvisionVault) GetSigners() []sdk.AccAddress {
|
||||
return []sdk.AccAddress{[]byte(m.Signer)}
|
||||
}
|
||||
|
||||
// --- MsgBindMailbox (Mail — A-551 typed dispatch) ---------------------------
|
||||
|
||||
// MsgBindMailbox binds a messaging mailbox against a Mail service
|
||||
// (ServiceKind=Mail — A-551 per-kind typed dispatch). The handler
|
||||
// enforces the window-id on the existing Mail service must still be
|
||||
// Active (A-552 window-grant-on-every-op). ValidateBasic is stateless:
|
||||
// non-empty service-id, non-empty mailbox-id, non-empty
|
||||
// holder-reach-id, non-empty signer.
|
||||
type MsgBindMailbox struct {
|
||||
ServiceID string `json:"service_id" yaml:"service_id"`
|
||||
MailboxID string `json:"mailbox_id" yaml:"mailbox_id"`
|
||||
HolderReachID string `json:"holder_reach_id" yaml:"holder_reach_id"`
|
||||
Signer string `json:"signer" yaml:"signer"`
|
||||
}
|
||||
|
||||
// Reset implements proto.Message.
|
||||
func (m *MsgBindMailbox) Reset() { *m = MsgBindMailbox{} }
|
||||
|
||||
// String implements proto.Message.
|
||||
func (m *MsgBindMailbox) String() string {
|
||||
return fmt.Sprintf("MsgBindMailbox{ServiceID:%s MailboxID:%s HolderReachID:%s Signer:%s}",
|
||||
m.ServiceID, m.MailboxID, m.HolderReachID, m.Signer)
|
||||
}
|
||||
|
||||
// ProtoMessage implements proto.Message.
|
||||
func (*MsgBindMailbox) ProtoMessage() {}
|
||||
|
||||
// ValidateBasic is the stateless validation: non-empty service-id,
|
||||
// non-empty mailbox-id, non-empty holder-reach-id, non-empty signer.
|
||||
// The handler enforces the stateful service-exists + kind=Mail +
|
||||
// window-grant Active checks (A-552).
|
||||
func (m *MsgBindMailbox) ValidateBasic() error {
|
||||
if m.ServiceID == "" {
|
||||
return fmt.Errorf("services: empty service-id")
|
||||
}
|
||||
if m.MailboxID == "" {
|
||||
return fmt.Errorf("services: empty mailbox-id")
|
||||
}
|
||||
if m.HolderReachID == "" {
|
||||
return fmt.Errorf("services: empty holder-reach-id")
|
||||
}
|
||||
if m.Signer == "" {
|
||||
return fmt.Errorf("services: empty signer")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetSigners returns the signer's reach-id as sdk.AccAddress bytes.
|
||||
func (m *MsgBindMailbox) GetSigners() []sdk.AccAddress {
|
||||
return []sdk.AccAddress{[]byte(m.Signer)}
|
||||
}
|
||||
|
||||
// --- MsgServer interface + Response types -----------------------------------
|
||||
|
||||
// MsgServer is the services module's message server interface (one method
|
||||
// per Msg*). The keeper's msg_server.go implements this; module.go's
|
||||
// RegisterServices wires the implementation. This is the hand-rolled
|
||||
// equivalent of the protobuf-generated MsgServer interface (no codegen
|
||||
// per the skeleton's zero-codegen style).
|
||||
type MsgServer interface {
|
||||
RegisterService(ctx interface{}, msg *MsgRegisterService) (*MsgRegisterServiceResponse, error)
|
||||
ActivateService(ctx interface{}, msg *MsgActivateService) (*MsgActivateServiceResponse, error)
|
||||
SuspendService(ctx interface{}, msg *MsgSuspendService) (*MsgSuspendServiceResponse, error)
|
||||
RevokeService(ctx interface{}, msg *MsgRevokeService) (*MsgRevokeServiceResponse, error)
|
||||
IssueCareGrant(ctx interface{}, msg *MsgIssueCareGrant) (*MsgIssueCareGrantResponse, error)
|
||||
ActivateSIM(ctx interface{}, msg *MsgActivateSIM) (*MsgActivateSIMResponse, error)
|
||||
ProvisionVault(ctx interface{}, msg *MsgProvisionVault) (*MsgProvisionVaultResponse, error)
|
||||
BindMailbox(ctx interface{}, msg *MsgBindMailbox) (*MsgBindMailboxResponse, error)
|
||||
}
|
||||
|
||||
// Response types (hand-rolled equivalents of the protobuf-generated
|
||||
// response wrappers; empty bodies — the response is the state mutation +
|
||||
// event).
|
||||
|
||||
// MsgRegisterServiceResponse is the response to MsgRegisterService.
|
||||
type MsgRegisterServiceResponse struct{}
|
||||
|
||||
// Reset implements proto.Message.
|
||||
func (m *MsgRegisterServiceResponse) Reset() { *m = MsgRegisterServiceResponse{} }
|
||||
|
||||
// String implements proto.Message.
|
||||
func (m *MsgRegisterServiceResponse) String() string { return "MsgRegisterServiceResponse{}" }
|
||||
|
||||
// ProtoMessage implements proto.Message.
|
||||
func (*MsgRegisterServiceResponse) ProtoMessage() {}
|
||||
|
||||
// MsgActivateServiceResponse is the response to MsgActivateService.
|
||||
type MsgActivateServiceResponse struct{}
|
||||
|
||||
// Reset implements proto.Message.
|
||||
func (m *MsgActivateServiceResponse) Reset() { *m = MsgActivateServiceResponse{} }
|
||||
|
||||
// String implements proto.Message.
|
||||
func (m *MsgActivateServiceResponse) String() string { return "MsgActivateServiceResponse{}" }
|
||||
|
||||
// ProtoMessage implements proto.Message.
|
||||
func (*MsgActivateServiceResponse) ProtoMessage() {}
|
||||
|
||||
// MsgSuspendServiceResponse is the response to MsgSuspendService.
|
||||
type MsgSuspendServiceResponse struct{}
|
||||
|
||||
// Reset implements proto.Message.
|
||||
func (m *MsgSuspendServiceResponse) Reset() { *m = MsgSuspendServiceResponse{} }
|
||||
|
||||
// String implements proto.Message.
|
||||
func (m *MsgSuspendServiceResponse) String() string { return "MsgSuspendServiceResponse{}" }
|
||||
|
||||
// ProtoMessage implements proto.Message.
|
||||
func (*MsgSuspendServiceResponse) ProtoMessage() {}
|
||||
|
||||
// MsgRevokeServiceResponse is the response to MsgRevokeService.
|
||||
type MsgRevokeServiceResponse struct{}
|
||||
|
||||
// Reset implements proto.Message.
|
||||
func (m *MsgRevokeServiceResponse) Reset() { *m = MsgRevokeServiceResponse{} }
|
||||
|
||||
// String implements proto.Message.
|
||||
func (m *MsgRevokeServiceResponse) String() string { return "MsgRevokeServiceResponse{}" }
|
||||
|
||||
// ProtoMessage implements proto.Message.
|
||||
func (*MsgRevokeServiceResponse) ProtoMessage() {}
|
||||
|
||||
// MsgIssueCareGrantResponse is the response to MsgIssueCareGrant.
|
||||
type MsgIssueCareGrantResponse struct{}
|
||||
|
||||
// Reset implements proto.Message.
|
||||
func (m *MsgIssueCareGrantResponse) Reset() { *m = MsgIssueCareGrantResponse{} }
|
||||
|
||||
// String implements proto.Message.
|
||||
func (m *MsgIssueCareGrantResponse) String() string { return "MsgIssueCareGrantResponse{}" }
|
||||
|
||||
// ProtoMessage implements proto.Message.
|
||||
func (*MsgIssueCareGrantResponse) ProtoMessage() {}
|
||||
|
||||
// MsgActivateSIMResponse is the response to MsgActivateSIM.
|
||||
type MsgActivateSIMResponse struct{}
|
||||
|
||||
// Reset implements proto.Message.
|
||||
func (m *MsgActivateSIMResponse) Reset() { *m = MsgActivateSIMResponse{} }
|
||||
|
||||
// String implements proto.Message.
|
||||
func (m *MsgActivateSIMResponse) String() string { return "MsgActivateSIMResponse{}" }
|
||||
|
||||
// ProtoMessage implements proto.Message.
|
||||
func (*MsgActivateSIMResponse) ProtoMessage() {}
|
||||
|
||||
// MsgProvisionVaultResponse is the response to MsgProvisionVault.
|
||||
type MsgProvisionVaultResponse struct{}
|
||||
|
||||
// Reset implements proto.Message.
|
||||
func (m *MsgProvisionVaultResponse) Reset() { *m = MsgProvisionVaultResponse{} }
|
||||
|
||||
// String implements proto.Message.
|
||||
func (m *MsgProvisionVaultResponse) String() string { return "MsgProvisionVaultResponse{}" }
|
||||
|
||||
// ProtoMessage implements proto.Message.
|
||||
func (*MsgProvisionVaultResponse) ProtoMessage() {}
|
||||
|
||||
// MsgBindMailboxResponse is the response to MsgBindMailbox.
|
||||
type MsgBindMailboxResponse struct{}
|
||||
|
||||
// Reset implements proto.Message.
|
||||
func (m *MsgBindMailboxResponse) Reset() { *m = MsgBindMailboxResponse{} }
|
||||
|
||||
// String implements proto.Message.
|
||||
func (m *MsgBindMailboxResponse) String() string { return "MsgBindMailboxResponse{}" }
|
||||
|
||||
// ProtoMessage implements proto.Message.
|
||||
func (*MsgBindMailboxResponse) ProtoMessage() {}
|
||||
@@ -0,0 +1,69 @@
|
||||
package types
|
||||
|
||||
// service_lifecycle.go holds the v0.5 runtime service lifecycle helpers
|
||||
// (P5-02-01, REQ-037). v0.3 typed the ServiceStatus enum (types.go);
|
||||
// v0.5 promotes it to runtime by adding the lifecycle transition gate
|
||||
// the keeper consults before mutating state. Mirrors
|
||||
// x/partner/types/anchor_credential.go (the v0.5 Anchor credential
|
||||
// lifecycle pattern — A-551 typed dispatch + A-552 window-grant-on-
|
||||
// every-op).
|
||||
//
|
||||
// Lifecycle (REQ-037, RESEARCH v0.5 §2.5):
|
||||
//
|
||||
// RegisterService → Pending (window-id must be Active — A-552)
|
||||
// ActivateService → Pending → Active (window-id still Active)
|
||||
// SuspendService → Active → Suspended (window-id still Active)
|
||||
// RevokeService → any → Revoked (window-id still Active;
|
||||
// terminal)
|
||||
//
|
||||
// Invalid transitions are REJECTED by the handler (the simtest covers
|
||||
// each invalid transition). Revoked is terminal (no transition out of
|
||||
// Revoked — idempotent reject on a second Revoke). The lexicon-clean
|
||||
// holder identifier is "reach-id" (NOT a banned financial-holder term;
|
||||
// use Holder/Reach).
|
||||
|
||||
// AllServiceStatuses returns all four ServiceStatus values in lifecycle
|
||||
// order (Pending, Active, Suspended, Revoked). Locked-const test (the
|
||||
// v0.3 types_test.go) asserts exactly 4 entries.
|
||||
func AllServiceStatuses() []ServiceStatus {
|
||||
return []ServiceStatus{
|
||||
ServicePending,
|
||||
ServiceActive,
|
||||
ServiceSuspended,
|
||||
ServiceRevoked,
|
||||
}
|
||||
}
|
||||
|
||||
// IsTerminalServiceStatus reports whether the service status is terminal
|
||||
// (no further transitions permitted). Revoked is terminal.
|
||||
// Pending/Active/Suspended are non-terminal.
|
||||
func IsTerminalServiceStatus(s ServiceStatus) bool {
|
||||
return s == ServiceRevoked
|
||||
}
|
||||
|
||||
// ValidServiceTransition reports whether the from → to transition is
|
||||
// permitted by the REQ-037 lifecycle:
|
||||
// - Pending → Active (ActivateService)
|
||||
// - Active → Suspended (SuspendService)
|
||||
// - Active → Revoked (RevokeService)
|
||||
// - Suspended → Revoked (RevokeService)
|
||||
// - Pending → Revoked (RevokeService — a Pending service may be
|
||||
// revoked before activation)
|
||||
//
|
||||
// All other transitions are REJECTED. Revoked is terminal (no transition
|
||||
// out). The handler consults this helper before mutating state (the
|
||||
// window-grant Active check A-552 is a SEPARATE gate after this).
|
||||
func ValidServiceTransition(from, to ServiceStatus) bool {
|
||||
switch from {
|
||||
case ServicePending:
|
||||
return to == ServiceActive || to == ServiceRevoked
|
||||
case ServiceActive:
|
||||
return to == ServiceSuspended || to == ServiceRevoked
|
||||
case ServiceSuspended:
|
||||
return to == ServiceRevoked
|
||||
case ServiceRevoked:
|
||||
return false // terminal
|
||||
default:
|
||||
return false // unknown source status
|
||||
}
|
||||
}
|
||||
@@ -169,6 +169,22 @@ func DefaultGenesisState() *GenesisState {
|
||||
}
|
||||
}
|
||||
|
||||
// Reset implements proto.Message (codec.JSONCodec.MustMarshalJSON /
|
||||
// MustUnmarshalJSON require proto.Message; the v0.5 runtime AppModule
|
||||
// calls these — D-055 G-006 controlled exception; the genesis fields +
|
||||
// ValidateGenesis logic are unchanged from v0.3, only the proto.Message
|
||||
// methods are added for the AppModule wiring).
|
||||
func (m *GenesisState) Reset() { *m = GenesisState{} }
|
||||
|
||||
// String implements proto.Message.
|
||||
func (m *GenesisState) String() string {
|
||||
return fmt.Sprintf("GenesisState{ServiceInfos:%d CareServices:%d SIMServices:%d VaultServices:%d MailServices:%d}",
|
||||
len(m.ServiceInfos), len(m.CareServices), len(m.SIMServices), len(m.VaultServices), len(m.MailServices))
|
||||
}
|
||||
|
||||
// ProtoMessage implements proto.Message.
|
||||
func (*GenesisState) ProtoMessage() {}
|
||||
|
||||
// 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.
|
||||
|
||||
Reference in New Issue
Block a user