diff --git a/x/bond/keeper/clob.go b/x/bond/keeper/clob.go new file mode 100644 index 0000000..cc8e398 --- /dev/null +++ b/x/bond/keeper/clob.go @@ -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) diff --git a/x/bond/keeper/keeper.go b/x/bond/keeper/keeper.go new file mode 100644 index 0000000..7d0bb76 --- /dev/null +++ b/x/bond/keeper/keeper.go @@ -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 +} diff --git a/x/bond/keeper/msg_server.go b/x/bond/keeper/msg_server.go new file mode 100644 index 0000000..ba355e4 --- /dev/null +++ b/x/bond/keeper/msg_server.go @@ -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 +} diff --git a/x/bond/keeper/msg_server_simtest_test.go b/x/bond/keeper/msg_server_simtest_test.go new file mode 100644 index 0000000..3f93197 --- /dev/null +++ b/x/bond/keeper/msg_server_simtest_test.go @@ -0,0 +1,1294 @@ +package keeper_test + +// msg_server_simtest_test.go is the x/bond keeper simtest (P6-03-01, +// REQ-038). +// +// D-054: simtest-grade — in-memory sdk.Context + dbm in-memory store, no +// real Stand keeper (the StandKeeper shim is wired to a stub; G-003 test +// exemption). The simtest exercises: +// +// Bond issuance (coupon clamp at issuance): +// - IssueBond with coupon in-band (e.g., 500) -> recorded unchanged; no +// clamp event. +// - IssueBond with coupon above 800 (e.g., 1200) -> ValidateBasic REJECTS +// (stateless guard; the handler re-clamps at runtime — defense in +// depth, but ValidateBasic is the first gate). +// - IssueBond on a non-existent Stand (StandKeeper stub reports false) -> +// REJECTED (the bond is not created). +// - IssueBond on an existing bond-id -> idempotent reject. +// - Nil StandKeeper shim -> skips the StandExists check (simtest wiring). +// +// GrowthBond issuance + tick (growth clamp): +// - IssueGrowthBond with coupon + growth in-band -> recorded unchanged. +// - IssueGrowthBond with growth that would push post-growth above cap -> +// growth clamped to room (G-012). +// - TickGrowthBond -> coupon grows by growth-rate, clamped so post-growth +// <= cap. +// - TickGrowthBond on a non-GrowthBond -> REJECTED. +// +// CLOB matching (D-057 — price-time priority FCFS per REQ-007; NO AMM): +// - PlaceSecondaryOrder rests an order on the book. +// - MatchSecondaryOrder full fill: taker fills the resting order +// completely; resting order -> Filled (deleted from book). +// - MatchSecondaryOrder partial fill + rest: taker partially fills the +// resting order; resting order's remaining quantity is updated; taker +// is not rested (simplification — the taker is a one-shot match). +// - MatchSecondaryOrder no-match: taker price does not cross any resting +// order -> filled quantity 0; the resting book is unchanged. +// - CancelSecondaryOrder: resting order removed from book (Cancelled). +// - Price-time priority FCFS: at the same price, the earlier resting +// order fills first (by sequence). +// +// Per-match coupon clamp (D-063 REJECT above 800 — G-019 ImpliedCoupon): +// - A match within [0, 800] bps clears (clamp event emitted; the matched +// coupon is within band). +// - A match whose implied coupon EXCEEDS 800 bps (resting price-bps < +// 9200) is REJECTED (fails closed — D-063; the resting order stays on +// the book; the incoming taker is rejected; no refund path). +// +// G-019 ImpliedCoupon boundary unit test (800/801/799 bps): +// - price-bps 9200 -> ImpliedCoupon 800 (== cap, in-band, clears). +// - price-bps 9199 -> ImpliedCoupon 801 (> cap, REJECTED). +// - price-bps 9201 -> ImpliedCoupon 799 (< cap, in-band, clears). +// +// D-028 regression: CouponCapBps=800, CouponFloorBps=0 unchanged. +// REQ-030 cross-const test green (run in x/hub/types/cross_const_test.go; +// this simtest asserts the bond consts are the mission-locked values). +// G-003 import-invariant green (the production firewall test in +// x/window/types scans all x/ production files; this simtest is a test +// file, G-003-exempt). +// +// Coverage target: >=80% on x/bond/keeper. + +import ( + "strings" + "testing" + "time" + + "cosmossdk.io/log" + "cosmossdk.io/store" + storetypes "cosmossdk.io/store/types" + cmtproto "github.com/cometbft/cometbft/proto/tendermint/types" + dbm "github.com/cosmos/cosmos-db" + "github.com/cosmos/cosmos-sdk/codec" + codectypes "github.com/cosmos/cosmos-sdk/codec/types" + sdk "github.com/cosmos/cosmos-sdk/types" + + "github.com/oy/openyield/x/bond/keeper" + btypes "github.com/oy/openyield/x/bond/types" +) + +// --- Stub expected-keepers (G-003 test exemption) --------------------------- + +// stubStandKeeper satisfies btypes.StandKeeper for the simtest. It returns +// the configured StandExists result per stand-id (default: exists=true). +type stubStandKeeper struct { + exists map[string]bool + existsAll bool +} + +func (s *stubStandKeeper) StandExists(standID string) bool { + if s.exists != nil { + return s.exists[standID] + } + return s.existsAll +} + +// --- Simtest context helper -------------------------------------------------- + +// newSimtestContext constructs an in-memory sdk.Context with a KVStore +// mounted at the bond store key. D-054: in-memory, no real Stand keeper. +// Returns the ctx, the stub StandKeeper, and the Keeper. +func newSimtestContext(t *testing.T) (sdk.Context, *stubStandKeeper, keeper.Keeper) { + t.Helper() + db := dbm.NewMemDB() + cdc := newTestCodec() + storeKey := storetypes.NewKVStoreKey(btypes.StoreKey) + cms := store.NewCommitMultiStore(db, log.NewNopLogger(), nil) + cms.MountStoreWithDB(storeKey, storetypes.StoreTypeDB, nil) + if err := cms.LoadLatestVersion(); err != nil { + t.Fatalf("load latest version: %v", err) + } + ctx := sdk.NewContext(cms, cmtproto.Header{Time: time.Unix(1000, 0)}, false, log.NewNopLogger()) + sk := &stubStandKeeper{existsAll: true} + k := keeper.NewKeeper(cdc, storeKey, sk) + return ctx, sk, k +} + +// newTestCodec constructs a minimal codec for the simtest. +func newTestCodec() codec.Codec { + registry := codectypes.NewInterfaceRegistry() + return codec.NewProtoCodec(registry) +} + +// hasEvent reports whether ctx emitted an event of the given type. +func hasEvent(ctx sdk.Context, eventType string) bool { + for _, ev := range ctx.EventManager().Events() { + if ev.Type == eventType { + return true + } + } + return false +} + +// eventAttr returns the value of an attribute on the last event of the +// given type, or "" if not found. +func eventAttr(ctx sdk.Context, eventType, attrKey string) string { + for _, ev := range ctx.EventManager().Events() { + if ev.Type == eventType { + for _, a := range ev.Attributes { + if string(a.Key) == attrKey { + return string(a.Value) + } + } + } + } + return "" +} + +// eventCount returns the number of events of the given type emitted on ctx. +func eventCount(ctx sdk.Context, eventType string) int { + n := 0 + for _, ev := range ctx.EventManager().Events() { + if ev.Type == eventType { + n++ + } + } + return n +} + +// freshCtx returns a fresh ctx (no prior events) on the same multi-store, +// so event assertions per-test are isolated. The keeper is shared (state +// persists across calls within a test; tests that need a fresh store call +// newSimtestContext instead). +func freshCtx(t *testing.T) (sdk.Context, *stubStandKeeper, keeper.Keeper) { + return newSimtestContext(t) +} + +// --- Bond issuance (coupon clamp at issuance) -------------------------------- + +// TestIssueBondInBand asserts an in-band coupon (500) is recorded unchanged +// and the bond.issued event is emitted. +func TestIssueBondInBand(t *testing.T) { + ctx, _, k := newSimtestContext(t) + srv := keeper.NewMsgServerImpl(k) + + resp, err := srv.IssueBond(ctx, &btypes.MsgIssueBond{ + BondID: "b1", IssuerStandID: "stand-1", PrincipalGrain: 1_000_000, + CouponBps: 500, TermDays: 365, IssuedAt: 1000, Maturity: 1365, Signer: "stand-1", + }) + if err != nil { + t.Fatalf("IssueBond: %v", err) + } + if resp.ClampedCouponBps != 500 { + t.Errorf("ClampedCouponBps = %d, want 500 (in-band, unchanged)", resp.ClampedCouponBps) + } + if !hasEvent(ctx, "bond.issued") { + t.Error("bond.issued event not emitted") + } + // Read it back. + b, ok := k.GetBond(ctx, "b1") + if !ok { + t.Fatal("bond not persisted") + } + if b.CouponBps != 500 { + t.Errorf("persisted CouponBps = %d, want 500", b.CouponBps) + } + if b.Status != btypes.BondIssued { + t.Errorf("Status = %q, want BondIssued", b.Status) + } +} + +// TestIssueBondAboveCapRejectedAtValidateBasic asserts an above-cap coupon +// (1200) is REJECTED at ValidateBasic (the stateless guard; D-028). +func TestIssueBondAboveCapRejectedAtValidateBasic(t *testing.T) { + ctx, _, k := newSimtestContext(t) + srv := keeper.NewMsgServerImpl(k) + + _, err := srv.IssueBond(ctx, &btypes.MsgIssueBond{ + BondID: "b2", IssuerStandID: "stand-1", PrincipalGrain: 1_000_000, + CouponBps: 1200, TermDays: 365, IssuedAt: 1000, Maturity: 1365, Signer: "stand-1", + }) + if err == nil { + t.Error("IssueBond with above-cap coupon should be REJECTED at ValidateBasic") + } + if !strings.Contains(err.Error(), "out of band") { + t.Errorf("err = %q, want 'out of band'", err.Error()) + } +} + +// TestIssueBondNonExistentStandRejected asserts a non-existent Stand +// REJECTS the issuance (the StandKeeper shim reports false). +func TestIssueBondNonExistentStandRejected(t *testing.T) { + ctx, sk, k := newSimtestContext(t) + sk.exists = map[string]bool{"stand-1": false} + sk.existsAll = false + srv := keeper.NewMsgServerImpl(k) + + _, err := srv.IssueBond(ctx, &btypes.MsgIssueBond{ + BondID: "b3", IssuerStandID: "no-such-stand", PrincipalGrain: 1_000_000, + CouponBps: 500, TermDays: 365, IssuedAt: 1000, Maturity: 1365, Signer: "stand-1", + }) + if err == nil { + t.Error("IssueBond on non-existent Stand should be REJECTED") + } + if !strings.Contains(err.Error(), "does not exist") { + t.Errorf("err = %q, want 'does not exist'", err.Error()) + } +} + +// TestIssueBondIdempotentReject asserts issuing the same bond-id twice +// REJECTS the second issuance. +func TestIssueBondIdempotentReject(t *testing.T) { + ctx, _, k := newSimtestContext(t) + srv := keeper.NewMsgServerImpl(k) + + _, err := srv.IssueBond(ctx, &btypes.MsgIssueBond{ + BondID: "b4", IssuerStandID: "stand-1", PrincipalGrain: 1_000_000, + CouponBps: 500, TermDays: 365, IssuedAt: 1000, Maturity: 1365, Signer: "stand-1", + }) + if err != nil { + t.Fatalf("first IssueBond: %v", err) + } + _, err = srv.IssueBond(ctx, &btypes.MsgIssueBond{ + BondID: "b4", IssuerStandID: "stand-1", PrincipalGrain: 1_000_000, + CouponBps: 600, TermDays: 365, IssuedAt: 1000, Maturity: 1365, Signer: "stand-1", + }) + if err == nil { + t.Error("second IssueBond on same bond-id should be REJECTED") + } + if !strings.Contains(err.Error(), "already exists") { + t.Errorf("err = %q, want 'already exists'", err.Error()) + } +} + +// TestIssueBondNilStandKeeperSkipsCheck asserts a nil StandKeeper shim skips +// the StandExists check (simtest wiring — the handler still mutates state). +func TestIssueBondNilStandKeeperSkipsCheck(t *testing.T) { + ctx, _, k := newSimtestContext(t) + k.SetStandKeeper(nil) // nil shim — skip StandExists check + srv := keeper.NewMsgServerImpl(k) + + _, err := srv.IssueBond(ctx, &btypes.MsgIssueBond{ + BondID: "b5", IssuerStandID: "any-stand", PrincipalGrain: 1_000_000, + CouponBps: 500, TermDays: 365, IssuedAt: 1000, Maturity: 1365, Signer: "stand-1", + }) + if err != nil { + t.Fatalf("IssueBond with nil StandKeeper should skip the check, got: %v", err) + } +} + +// --- GrowthBond issuance + tick ---------------------------------------------- + +// TestIssueGrowthBondInBand asserts an in-band coupon + growth are recorded +// unchanged. +func TestIssueGrowthBondInBand(t *testing.T) { + ctx, _, k := newSimtestContext(t) + srv := keeper.NewMsgServerImpl(k) + + resp, err := srv.IssueGrowthBond(ctx, &btypes.MsgIssueGrowthBond{ + BondID: "gb1", IssuerStandID: "stand-1", PrincipalGrain: 1_000_000, + CouponBps: 500, GrowthRateBps: 200, TermDays: 365, IssuedAt: 1000, Maturity: 1365, Signer: "stand-1", + }) + if err != nil { + t.Fatalf("IssueGrowthBond: %v", err) + } + if resp.ClampedCouponBps != 500 { + t.Errorf("ClampedCouponBps = %d, want 500", resp.ClampedCouponBps) + } + if resp.ClampedGrowthRateBps != 200 { + t.Errorf("ClampedGrowthRateBps = %d, want 200", resp.ClampedGrowthRateBps) + } + if !hasEvent(ctx, "bond.growth_issued") { + t.Error("bond.growth_issued event not emitted") + } +} + +// TestIssueGrowthBondGrowthClampedToRoom asserts a growth-rate that would +// push post-growth above cap is clamped to the room-to-cap (G-012). +func TestIssueGrowthBondGrowthClampedToRoom(t *testing.T) { + ctx, _, k := newSimtestContext(t) + srv := keeper.NewMsgServerImpl(k) + + // coupon=500, cap=800, room=300. growth=400 -> clamped to 300. + resp, err := srv.IssueGrowthBond(ctx, &btypes.MsgIssueGrowthBond{ + BondID: "gb2", IssuerStandID: "stand-1", PrincipalGrain: 1_000_000, + CouponBps: 500, GrowthRateBps: 400, TermDays: 365, IssuedAt: 1000, Maturity: 1365, Signer: "stand-1", + }) + if err != nil { + t.Fatalf("IssueGrowthBond: %v", err) + } + if resp.ClampedCouponBps != 500 { + t.Errorf("ClampedCouponBps = %d, want 500", resp.ClampedCouponBps) + } + if resp.ClampedGrowthRateBps != 300 { + t.Errorf("ClampedGrowthRateBps = %d, want 300 (room=300, G-012)", resp.ClampedGrowthRateBps) + } + if !hasEvent(ctx, "bond.growth_coupon_clamped") { + t.Error("bond.growth_coupon_clamped event not emitted (growth was clamped)") + } +} + +// TestTickGrowthBond asserts a growth tick grows the coupon by the growth- +// rate, clamped so post-growth <= cap. +func TestTickGrowthBond(t *testing.T) { + ctx, _, k := newSimtestContext(t) + srv := keeper.NewMsgServerImpl(k) + + // Issue a GrowthBond: coupon=500, growth=200 (room=300; growth ClampGrowth(800, 200) = 0 (at cap, no room); + // post-growth = 800 + 0 = 800. + resp3, err := srv.TickGrowthBond(ctx, &btypes.MsgTickGrowthBond{BondID: "gb3", Signer: "stand-1"}) + if err != nil { + t.Fatalf("third TickGrowthBond: %v", err) + } + if resp3.PostGrowthCouponBps != 800 { + t.Errorf("PostGrowthCouponBps after third tick = %d, want 800 (at cap, no room)", resp3.PostGrowthCouponBps) + } +} + +// TestTickGrowthBondNotFound asserts TickGrowthBond on a non-GrowthBond is +// REJECTED. +func TestTickGrowthBondNotFound(t *testing.T) { + ctx, _, k := newSimtestContext(t) + srv := keeper.NewMsgServerImpl(k) + + _, err := srv.TickGrowthBond(ctx, &btypes.MsgTickGrowthBond{BondID: "no-such-bond", Signer: "stand-1"}) + if err == nil { + t.Error("TickGrowthBond on non-existent bond should be REJECTED") + } +} + +// --- CLOB matching: Place + Match full fill -------------------------------- + +// TestPlaceSecondaryOrder rests an order on the book. +func TestPlaceSecondaryOrder(t *testing.T) { + ctx, _, k := newSimtestContext(t) + srv := keeper.NewMsgServerImpl(k) + + // Issue a bond first (the order rests on an issued bond). + _, err := srv.IssueBond(ctx, &btypes.MsgIssueBond{ + BondID: "b10", IssuerStandID: "stand-1", PrincipalGrain: 1_000_000, + CouponBps: 500, TermDays: 365, IssuedAt: 1000, Maturity: 1365, Signer: "stand-1", + }) + if err != nil { + t.Fatalf("IssueBond: %v", err) + } + + _, err = srv.PlaceSecondaryOrder(ctx, &btypes.MsgPlaceSecondaryOrder{ + OrderID: "o1", BondID: "b10", Side: btypes.OrderSell, PriceBps: 9500, + QuantityGrain: 100, HolderReachID: "holder-1", Signer: "holder-1", + }) + if err != nil { + t.Fatalf("PlaceSecondaryOrder: %v", err) + } + if !hasEvent(ctx, "bond.order_placed") { + t.Error("bond.order_placed event not emitted") + } + // The order is on the book. + ro, ok := k.GetRestingOrder(ctx, "o1") + if !ok { + t.Fatal("resting order not persisted") + } + if ro.Order.Status != btypes.OrderOpen { + t.Errorf("Status = %q, want Open", ro.Order.Status) + } + if ro.PriceBps != 9500 { + t.Errorf("PriceBps = %d, want 9500", ro.PriceBps) + } + if ro.RemainingQuantityGrain != 100 { + t.Errorf("RemainingQuantityGrain = %d, want 100", ro.RemainingQuantityGrain) + } +} + +// TestPlaceSecondaryOrderNonExistentBondRejected asserts placing an order on +// a non-existent bond is REJECTED. +func TestPlaceSecondaryOrderNonExistentBondRejected(t *testing.T) { + ctx, _, k := newSimtestContext(t) + srv := keeper.NewMsgServerImpl(k) + + _, err := srv.PlaceSecondaryOrder(ctx, &btypes.MsgPlaceSecondaryOrder{ + OrderID: "o2", BondID: "no-such-bond", Side: btypes.OrderSell, PriceBps: 9500, + QuantityGrain: 100, HolderReachID: "holder-1", Signer: "holder-1", + }) + if err == nil { + t.Error("PlaceSecondaryOrder on non-existent bond should be REJECTED") + } +} + +// TestPlaceSecondaryOrderIdempotentReject asserts placing the same order-id +// twice REJECTS the second. +func TestPlaceSecondaryOrderIdempotentReject(t *testing.T) { + ctx, _, k := newSimtestContext(t) + srv := keeper.NewMsgServerImpl(k) + + _, _ = srv.IssueBond(ctx, &btypes.MsgIssueBond{ + BondID: "b11", IssuerStandID: "stand-1", PrincipalGrain: 1_000_000, + CouponBps: 500, TermDays: 365, IssuedAt: 1000, Maturity: 1365, Signer: "stand-1", + }) + _, err := srv.PlaceSecondaryOrder(ctx, &btypes.MsgPlaceSecondaryOrder{ + OrderID: "o3", BondID: "b11", Side: btypes.OrderSell, PriceBps: 9500, + QuantityGrain: 100, HolderReachID: "holder-1", Signer: "holder-1", + }) + if err != nil { + t.Fatalf("first PlaceSecondaryOrder: %v", err) + } + _, err = srv.PlaceSecondaryOrder(ctx, &btypes.MsgPlaceSecondaryOrder{ + OrderID: "o3", BondID: "b11", Side: btypes.OrderSell, PriceBps: 9600, + QuantityGrain: 100, HolderReachID: "holder-1", Signer: "holder-1", + }) + if err == nil { + t.Error("second PlaceSecondaryOrder on same order-id should be REJECTED") + } +} + +// TestMatchSecondaryOrderFullFill asserts a taker fully fills a resting +// order; the resting order is removed from the book (Filled). +func TestMatchSecondaryOrderFullFill(t *testing.T) { + ctx, _, k := newSimtestContext(t) + srv := keeper.NewMsgServerImpl(k) + + _, _ = srv.IssueBond(ctx, &btypes.MsgIssueBond{ + BondID: "b20", IssuerStandID: "stand-1", PrincipalGrain: 1_000_000, + CouponBps: 500, TermDays: 365, IssuedAt: 1000, Maturity: 1365, Signer: "stand-1", + }) + // Rest a Sell order at price 9500 (implied coupon 500 bps, in-band). + _, _ = srv.PlaceSecondaryOrder(ctx, &btypes.MsgPlaceSecondaryOrder{ + OrderID: "sell-1", BondID: "b20", Side: btypes.OrderSell, PriceBps: 9500, + QuantityGrain: 100, HolderReachID: "holder-sell", Signer: "holder-sell", + }) + // Buy taker at price 9500 (willing to pay up to 9500; matches the Sell). + resp, err := srv.MatchSecondaryOrder(ctx, &btypes.MsgMatchSecondaryOrder{ + IncomingOrderID: "buy-1", BondID: "b20", Side: btypes.OrderBuy, PriceBps: 9500, + QuantityGrain: 100, HolderReachID: "holder-buy", Signer: "holder-buy", + }) + if err != nil { + t.Fatalf("MatchSecondaryOrder: %v", err) + } + if resp.Rejected { + t.Error("Rejected = true, want false (in-band match)") + } + if resp.FilledQuantityGrain != 100 { + t.Errorf("FilledQuantityGrain = %d, want 100 (full fill)", resp.FilledQuantityGrain) + } + // The resting order is removed (Filled). + if _, ok := k.GetRestingOrder(ctx, "sell-1"); ok { + t.Error("resting order should be removed after full fill") + } + // A match event was emitted. + if !hasEvent(ctx, "bond.match") { + t.Error("bond.match event not emitted") + } + if !hasEvent(ctx, "bond.match_completed") { + t.Error("bond.match_completed event not emitted") + } +} + +// TestMatchSecondaryOrderPartialFillRest asserts a taker partially fills a +// resting order; the resting order's remaining quantity is updated. +func TestMatchSecondaryOrderPartialFillRest(t *testing.T) { + ctx, _, k := newSimtestContext(t) + srv := keeper.NewMsgServerImpl(k) + + _, _ = srv.IssueBond(ctx, &btypes.MsgIssueBond{ + BondID: "b21", IssuerStandID: "stand-1", PrincipalGrain: 1_000_000, + CouponBps: 500, TermDays: 365, IssuedAt: 1000, Maturity: 1365, Signer: "stand-1", + }) + // Rest a Sell order at 9500 for 100. + _, _ = srv.PlaceSecondaryOrder(ctx, &btypes.MsgPlaceSecondaryOrder{ + OrderID: "sell-2", BondID: "b21", Side: btypes.OrderSell, PriceBps: 9500, + QuantityGrain: 100, HolderReachID: "holder-sell", Signer: "holder-sell", + }) + // Buy taker at 9500 for 40 (partial fill). + resp, err := srv.MatchSecondaryOrder(ctx, &btypes.MsgMatchSecondaryOrder{ + IncomingOrderID: "buy-2", BondID: "b21", Side: btypes.OrderBuy, PriceBps: 9500, + QuantityGrain: 40, HolderReachID: "holder-buy", Signer: "holder-buy", + }) + if err != nil { + t.Fatalf("MatchSecondaryOrder: %v", err) + } + if resp.FilledQuantityGrain != 40 { + t.Errorf("FilledQuantityGrain = %d, want 40 (partial fill)", resp.FilledQuantityGrain) + } + // The resting order is still on the book with 60 remaining. + ro, ok := k.GetRestingOrder(ctx, "sell-2") + if !ok { + t.Fatal("resting order should still be on the book after partial fill") + } + if ro.RemainingQuantityGrain != 60 { + t.Errorf("RemainingQuantityGrain = %d, want 60 (100 - 40)", ro.RemainingQuantityGrain) + } +} + +// TestMatchSecondaryOrderNoMatch asserts a taker whose price does not cross +// any resting order results in filled quantity 0 (the resting book is +// unchanged). +func TestMatchSecondaryOrderNoMatch(t *testing.T) { + ctx, _, k := newSimtestContext(t) + srv := keeper.NewMsgServerImpl(k) + + _, _ = srv.IssueBond(ctx, &btypes.MsgIssueBond{ + BondID: "b22", IssuerStandID: "stand-1", PrincipalGrain: 1_000_000, + CouponBps: 500, TermDays: 365, IssuedAt: 1000, Maturity: 1365, Signer: "stand-1", + }) + // Rest a Sell order at 9500. + _, _ = srv.PlaceSecondaryOrder(ctx, &btypes.MsgPlaceSecondaryOrder{ + OrderID: "sell-3", BondID: "b22", Side: btypes.OrderSell, PriceBps: 9500, + QuantityGrain: 100, HolderReachID: "holder-sell", Signer: "holder-sell", + }) + // Buy taker at 9400 (below the Sell price — no cross). + resp, err := srv.MatchSecondaryOrder(ctx, &btypes.MsgMatchSecondaryOrder{ + IncomingOrderID: "buy-3", BondID: "b22", Side: btypes.OrderBuy, PriceBps: 9400, + QuantityGrain: 100, HolderReachID: "holder-buy", Signer: "holder-buy", + }) + if err != nil { + t.Fatalf("MatchSecondaryOrder: %v", err) + } + if resp.FilledQuantityGrain != 0 { + t.Errorf("FilledQuantityGrain = %d, want 0 (no cross)", resp.FilledQuantityGrain) + } + // The resting order is unchanged. + ro, ok := k.GetRestingOrder(ctx, "sell-3") + if !ok { + t.Fatal("resting order should still be on the book (no match)") + } + if ro.RemainingQuantityGrain != 100 { + t.Errorf("RemainingQuantityGrain = %d, want 100 (unchanged)", ro.RemainingQuantityGrain) + } +} + +// --- CancelSecondaryOrder --------------------------------------------------- + +// TestCancelSecondaryOrder asserts cancelling a resting order removes it +// from the book. +func TestCancelSecondaryOrder(t *testing.T) { + ctx, _, k := newSimtestContext(t) + srv := keeper.NewMsgServerImpl(k) + + _, _ = srv.IssueBond(ctx, &btypes.MsgIssueBond{ + BondID: "b30", IssuerStandID: "stand-1", PrincipalGrain: 1_000_000, + CouponBps: 500, TermDays: 365, IssuedAt: 1000, Maturity: 1365, Signer: "stand-1", + }) + _, _ = srv.PlaceSecondaryOrder(ctx, &btypes.MsgPlaceSecondaryOrder{ + OrderID: "o-cancel", BondID: "b30", Side: btypes.OrderSell, PriceBps: 9500, + QuantityGrain: 100, HolderReachID: "holder-sell", Signer: "holder-sell", + }) + + _, err := srv.CancelSecondaryOrder(ctx, &btypes.MsgCancelSecondaryOrder{OrderID: "o-cancel", Signer: "holder-sell"}) + if err != nil { + t.Fatalf("CancelSecondaryOrder: %v", err) + } + if !hasEvent(ctx, "bond.order_cancelled") { + t.Error("bond.order_cancelled event not emitted") + } + // The order is removed from the book. + if _, ok := k.GetRestingOrder(ctx, "o-cancel"); ok { + t.Error("resting order should be removed after cancel") + } +} + +// TestCancelSecondaryOrderNotFound asserts cancelling a non-existent order +// is REJECTED. +func TestCancelSecondaryOrderNotFound(t *testing.T) { + ctx, _, k := newSimtestContext(t) + srv := keeper.NewMsgServerImpl(k) + + _, err := srv.CancelSecondaryOrder(ctx, &btypes.MsgCancelSecondaryOrder{OrderID: "no-such-order", Signer: "holder-sell"}) + if err == nil { + t.Error("CancelSecondaryOrder on non-existent order should be REJECTED") + } +} + +// --- Price-time priority FCFS (REQ-007) -------------------------------------- + +// TestPriceTimePriorityFCFS asserts at the same price, the earlier resting +// order fills first (by sequence). Two Sell orders at the same price 9500; +// a Buy taker at 9500 for 50 fills the FIRST resting order (lower sequence) +// completely, leaving the second untouched. +func TestPriceTimePriorityFCFS(t *testing.T) { + ctx, _, k := newSimtestContext(t) + srv := keeper.NewMsgServerImpl(k) + + _, _ = srv.IssueBond(ctx, &btypes.MsgIssueBond{ + BondID: "b40", IssuerStandID: "stand-1", PrincipalGrain: 1_000_000, + CouponBps: 500, TermDays: 365, IssuedAt: 1000, Maturity: 1365, Signer: "stand-1", + }) + // Rest two Sell orders at the SAME price 9500 (implied coupon 500, in-band). + _, _ = srv.PlaceSecondaryOrder(ctx, &btypes.MsgPlaceSecondaryOrder{ + OrderID: "sell-first", BondID: "b40", Side: btypes.OrderSell, PriceBps: 9500, + QuantityGrain: 100, HolderReachID: "h1", Signer: "h1", + }) + _, _ = srv.PlaceSecondaryOrder(ctx, &btypes.MsgPlaceSecondaryOrder{ + OrderID: "sell-second", BondID: "b40", Side: btypes.OrderSell, PriceBps: 9500, + QuantityGrain: 100, HolderReachID: "h2", Signer: "h2", + }) + + // Buy taker at 9500 for 50 — should fill the FIRST resting order (lower + // sequence). + resp, err := srv.MatchSecondaryOrder(ctx, &btypes.MsgMatchSecondaryOrder{ + IncomingOrderID: "buy-fcfs", BondID: "b40", Side: btypes.OrderBuy, PriceBps: 9500, + QuantityGrain: 50, HolderReachID: "hb", Signer: "hb", + }) + if err != nil { + t.Fatalf("MatchSecondaryOrder: %v", err) + } + if resp.FilledQuantityGrain != 50 { + t.Errorf("FilledQuantityGrain = %d, want 50", resp.FilledQuantityGrain) + } + // The FIRST resting order has 50 remaining (100 - 50); the SECOND is + // untouched at 100. + ro1, ok := k.GetRestingOrder(ctx, "sell-first") + if !ok { + t.Fatal("sell-first should still be on the book (partial fill)") + } + if ro1.RemainingQuantityGrain != 50 { + t.Errorf("sell-first RemainingQuantityGrain = %d, want 50 (FCFS — first fills first)", ro1.RemainingQuantityGrain) + } + ro2, ok := k.GetRestingOrder(ctx, "sell-second") + if !ok { + t.Fatal("sell-second should still be on the book (untouched)") + } + if ro2.RemainingQuantityGrain != 100 { + t.Errorf("sell-second RemainingQuantityGrain = %d, want 100 (untouched — FCFS)", ro2.RemainingQuantityGrain) + } +} + +// TestPriceTimePriorityBestPriceFirst asserts the best price fills first +// (lowest Sell price for a Buy taker). A Sell at 9400 fills before a Sell at +// 9500 for a Buy taker. +func TestPriceTimePriorityBestPriceFirst(t *testing.T) { + ctx, _, k := newSimtestContext(t) + srv := keeper.NewMsgServerImpl(k) + + _, _ = srv.IssueBond(ctx, &btypes.MsgIssueBond{ + BondID: "b41", IssuerStandID: "stand-1", PrincipalGrain: 1_000_000, + CouponBps: 500, TermDays: 365, IssuedAt: 1000, Maturity: 1365, Signer: "stand-1", + }) + // Rest a Sell at 9500 (implied coupon 500) FIRST (lower sequence). + _, _ = srv.PlaceSecondaryOrder(ctx, &btypes.MsgPlaceSecondaryOrder{ + OrderID: "sell-9500", BondID: "b41", Side: btypes.OrderSell, PriceBps: 9500, + QuantityGrain: 100, HolderReachID: "h1", Signer: "h1", + }) + // Rest a Sell at 9400 (implied coupon 600 — better price for the buyer) + // SECOND (higher sequence). + _, _ = srv.PlaceSecondaryOrder(ctx, &btypes.MsgPlaceSecondaryOrder{ + OrderID: "sell-9400", BondID: "b41", Side: btypes.OrderSell, PriceBps: 9400, + QuantityGrain: 100, HolderReachID: "h2", Signer: "h2", + }) + + // Buy taker at 9500 for 50 — should fill the 9400 Sell FIRST (best price, + // even though it has a higher sequence — price beats sequence). + resp, err := srv.MatchSecondaryOrder(ctx, &btypes.MsgMatchSecondaryOrder{ + IncomingOrderID: "buy-best", BondID: "b41", Side: btypes.OrderBuy, PriceBps: 9500, + QuantityGrain: 50, HolderReachID: "hb", Signer: "hb", + }) + if err != nil { + t.Fatalf("MatchSecondaryOrder: %v", err) + } + if resp.FilledQuantityGrain != 50 { + t.Errorf("FilledQuantityGrain = %d, want 50", resp.FilledQuantityGrain) + } + // The 9400 Sell has 50 remaining (filled first — best price); the 9500 + // Sell is untouched at 100. + ro9400, ok := k.GetRestingOrder(ctx, "sell-9400") + if !ok { + t.Fatal("sell-9400 should still be on the book (partial fill)") + } + if ro9400.RemainingQuantityGrain != 50 { + t.Errorf("sell-9400 RemainingQuantityGrain = %d, want 50 (best price fills first)", ro9400.RemainingQuantityGrain) + } + ro9500, ok := k.GetRestingOrder(ctx, "sell-9500") + if !ok { + t.Fatal("sell-9500 should still be on the book (untouched — worse price)") + } + if ro9500.RemainingQuantityGrain != 100 { + t.Errorf("sell-9500 RemainingQuantityGrain = %d, want 100 (untouched — worse price)", ro9500.RemainingQuantityGrain) + } +} + +// --- Per-match coupon clamp (D-063 REJECT above 800 — G-019 ImpliedCoupon) --- + +// TestMatchInBandClears asserts a match within [0, 800] bps clears (the +// matched coupon is within band; clamp event emitted). +func TestMatchInBandClears(t *testing.T) { + ctx, _, k := newSimtestContext(t) + srv := keeper.NewMsgServerImpl(k) + + _, _ = srv.IssueBond(ctx, &btypes.MsgIssueBond{ + BondID: "b50", IssuerStandID: "stand-1", PrincipalGrain: 1_000_000, + CouponBps: 500, TermDays: 365, IssuedAt: 1000, Maturity: 1365, Signer: "stand-1", + }) + // Rest a Sell at 9250 (implied coupon 750 bps, in-band). + _, _ = srv.PlaceSecondaryOrder(ctx, &btypes.MsgPlaceSecondaryOrder{ + OrderID: "sell-750", BondID: "b50", Side: btypes.OrderSell, PriceBps: 9250, + QuantityGrain: 100, HolderReachID: "h1", Signer: "h1", + }) + // Buy taker at 9250 (matches). + resp, err := srv.MatchSecondaryOrder(ctx, &btypes.MsgMatchSecondaryOrder{ + IncomingOrderID: "buy-750", BondID: "b50", Side: btypes.OrderBuy, PriceBps: 9250, + QuantityGrain: 100, HolderReachID: "hb", Signer: "hb", + }) + if err != nil { + t.Fatalf("MatchSecondaryOrder: %v", err) + } + if resp.Rejected { + t.Error("Rejected = true, want false (in-band 750 bps clears)") + } + if resp.FilledQuantityGrain != 100 { + t.Errorf("FilledQuantityGrain = %d, want 100", resp.FilledQuantityGrain) + } + // The match event carries the clamped coupon (750, in-band). + attr := eventAttr(ctx, "bond.match", "matched_coupon_bps") + if attr != "750" { + t.Errorf("matched_coupon_bps = %q, want 750 (in-band)", attr) + } +} + +// TestMatchAboveCapRejected asserts a match whose implied coupon EXCEEDS 800 +// bps (resting price-bps < 9200) is REJECTED (fails closed — D-063). The +// resting order stays on the book; the incoming taker is rejected. +func TestMatchAboveCapRejected(t *testing.T) { + ctx, _, k := newSimtestContext(t) + srv := keeper.NewMsgServerImpl(k) + + _, _ = srv.IssueBond(ctx, &btypes.MsgIssueBond{ + BondID: "b51", IssuerStandID: "stand-1", PrincipalGrain: 1_000_000, + CouponBps: 500, TermDays: 365, IssuedAt: 1000, Maturity: 1365, Signer: "stand-1", + }) + // Rest a Sell at 9000 (implied coupon 1000 bps, ABOVE cap 800). + // PlaceSecondaryOrder does NOT reject (a resting order may rest at any + // price; the REJECT is at MATCH time per D-063). + _, _ = srv.PlaceSecondaryOrder(ctx, &btypes.MsgPlaceSecondaryOrder{ + OrderID: "sell-1000", BondID: "b51", Side: btypes.OrderSell, PriceBps: 9000, + QuantityGrain: 100, HolderReachID: "h1", Signer: "h1", + }) + // Buy taker at 9000 (matches the price, but the implied coupon is above + // cap -> REJECTED per D-063). + resp, err := srv.MatchSecondaryOrder(ctx, &btypes.MsgMatchSecondaryOrder{ + IncomingOrderID: "buy-1000", BondID: "b51", Side: btypes.OrderBuy, PriceBps: 9000, + QuantityGrain: 100, HolderReachID: "hb", Signer: "hb", + }) + if err == nil { + t.Error("MatchSecondaryOrder above cap should be REJECTED (D-063)") + } + if !resp.Rejected { + t.Error("Rejected = false, want true (above-cap match — D-063 fails closed)") + } + if resp.FilledQuantityGrain != 0 { + t.Errorf("FilledQuantityGrain = %d, want 0 (rejected — no fill)", resp.FilledQuantityGrain) + } + // The resting order STAYS on the book (D-063 — the resting order is not + // consumed by a rejected match). + ro, ok := k.GetRestingOrder(ctx, "sell-1000") + if !ok { + t.Fatal("resting order should STAY on the book after D-063 reject") + } + if ro.RemainingQuantityGrain != 100 { + t.Errorf("RemainingQuantityGrain = %d, want 100 (resting order unchanged)", ro.RemainingQuantityGrain) + } + // The reject event was emitted. + if !hasEvent(ctx, "bond.match_rejected_above_cap") { + t.Error("bond.match_rejected_above_cap event not emitted") + } +} + +// --- G-019 ImpliedCoupon boundary unit test (800/801/799 bps) --------------- + +// TestImpliedCouponBoundary asserts the G-019 ImpliedCoupon helper at the +// 800-bps cap boundary: +// - 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). +// +// This is the G-019 BINDING boundary unit test — a single helper + boundary +// test closing the formula ambiguity in the D-063 REJECT threshold. +func TestImpliedCouponBoundary(t *testing.T) { + cases := []struct { + priceBps uint32 + wantCoupon uint32 + description string + }{ + {9200, 800, "at cap (800) — in-band, clears"}, + {9199, 801, "above cap (801) — REJECTED per D-063"}, + {9201, 799, "below cap (799) — in-band, clears"}, + {10000, 0, "par — 0 implied coupon"}, + {10500, 0, "premium — 0 implied coupon (floored at 0)"}, + {9000, 1000, "deep discount — 1000 bps implied coupon"}, + {0, 10000, "zero price — 10000 bps implied coupon"}, + } + for _, c := range cases { + got := keeper.ImpliedCoupon(c.priceBps, 0) + if got != c.wantCoupon { + t.Errorf("ImpliedCoupon(%d, 0) = %d, want %d (%s)", c.priceBps, got, c.wantCoupon, c.description) + } + } +} + +// TestImpliedCouponBoundaryAtCapClears asserts a match at exactly the cap +// (800 bps, price-bps 9200) clears (in-band — the cap is inclusive; the +// REJECT is strictly above 800 per D-063). +func TestImpliedCouponBoundaryAtCapClears(t *testing.T) { + ctx, _, k := newSimtestContext(t) + srv := keeper.NewMsgServerImpl(k) + + _, _ = srv.IssueBond(ctx, &btypes.MsgIssueBond{ + BondID: "b60", IssuerStandID: "stand-1", PrincipalGrain: 1_000_000, + CouponBps: 500, TermDays: 365, IssuedAt: 1000, Maturity: 1365, Signer: "stand-1", + }) + // Rest a Sell at 9200 (implied coupon 800, == cap). + _, _ = srv.PlaceSecondaryOrder(ctx, &btypes.MsgPlaceSecondaryOrder{ + OrderID: "sell-800", BondID: "b60", Side: btypes.OrderSell, PriceBps: 9200, + QuantityGrain: 100, HolderReachID: "h1", Signer: "h1", + }) + resp, err := srv.MatchSecondaryOrder(ctx, &btypes.MsgMatchSecondaryOrder{ + IncomingOrderID: "buy-800", BondID: "b60", Side: btypes.OrderBuy, PriceBps: 9200, + QuantityGrain: 100, HolderReachID: "hb", Signer: "hb", + }) + if err != nil { + t.Fatalf("MatchSecondaryOrder at cap: %v", err) + } + if resp.Rejected { + t.Error("Rejected = true, want false (at-cap 800 bps clears — D-063 rejects strictly above 800)") + } +} + +// TestImpliedCouponBoundaryAboveCapRejected asserts a match at 801 bps +// (price-bps 9199) is REJECTED (D-063). +func TestImpliedCouponBoundaryAboveCapRejected(t *testing.T) { + ctx, _, k := newSimtestContext(t) + srv := keeper.NewMsgServerImpl(k) + + _, _ = srv.IssueBond(ctx, &btypes.MsgIssueBond{ + BondID: "b61", IssuerStandID: "stand-1", PrincipalGrain: 1_000_000, + CouponBps: 500, TermDays: 365, IssuedAt: 1000, Maturity: 1365, Signer: "stand-1", + }) + // Rest a Sell at 9199 (implied coupon 801, ABOVE cap). + _, _ = srv.PlaceSecondaryOrder(ctx, &btypes.MsgPlaceSecondaryOrder{ + OrderID: "sell-801", BondID: "b61", Side: btypes.OrderSell, PriceBps: 9199, + QuantityGrain: 100, HolderReachID: "h1", Signer: "h1", + }) + resp, err := srv.MatchSecondaryOrder(ctx, &btypes.MsgMatchSecondaryOrder{ + IncomingOrderID: "buy-801", BondID: "b61", Side: btypes.OrderBuy, PriceBps: 9199, + QuantityGrain: 100, HolderReachID: "hb", Signer: "hb", + }) + if err == nil { + t.Error("MatchSecondaryOrder at 801 bps should be REJECTED (D-063)") + } + if !resp.Rejected { + t.Error("Rejected = false, want true (801 bps > cap 800 — D-063)") + } +} + +// --- D-028 regression: 8%/0% consts unchanged -------------------------------- + +// TestCouponCapBpsUnchanged asserts CouponCapBps is 800 (D-028 — the 8% +// mission-locked cap is unchanged by the P6 runtime promotion). +func TestCouponCapBpsUnchanged(t *testing.T) { + if btypes.CouponCapBps != 800 { + t.Errorf("CouponCapBps = %d, want 800 (D-028 mission-locked 8pct — unchanged by P6)", btypes.CouponCapBps) + } +} + +// TestCouponFloorBpsUnchanged asserts CouponFloorBps is 0 (D-028 — the 0% +// mission-locked floor is unchanged by the P6 runtime promotion). +func TestCouponFloorBpsUnchanged(t *testing.T) { + if btypes.CouponFloorBps != 0 { + t.Errorf("CouponFloorBps = %d, want 0 (D-028 mission-locked 0pct — unchanged by P6)", btypes.CouponFloorBps) + } +} + +// TestOrderSideCountUnchanged asserts OrderSideCount is 2 (locked-const +// regression — the P6 runtime does not change the v0.3 OrderSide enum). +func TestOrderSideCountUnchanged(t *testing.T) { + if btypes.OrderSideCount != 2 { + t.Errorf("OrderSideCount = %d, want 2 (A-313 locked-const — unchanged by P6)", btypes.OrderSideCount) + } +} + +// TestOrderStatusCountUnchanged asserts OrderStatusCount is 3 (locked-const +// regression — the P6 runtime does not change the v0.3 OrderStatus enum). +func TestOrderStatusCountUnchanged(t *testing.T) { + if btypes.OrderStatusCount != 3 { + t.Errorf("OrderStatusCount = %d, want 3 (A-313 locked-const — unchanged by P6)", btypes.OrderStatusCount) + } +} + +// --- MatchSecondaryOrder on a GrowthBond + non-existent bond ---------------- + +// TestMatchSecondaryOrderOnGrowthBond asserts a match works on a GrowthBond +// (the order rests on an issued GrowthBond too). +func TestMatchSecondaryOrderOnGrowthBond(t *testing.T) { + ctx, _, k := newSimtestContext(t) + srv := keeper.NewMsgServerImpl(k) + + _, _ = srv.IssueGrowthBond(ctx, &btypes.MsgIssueGrowthBond{ + BondID: "gb50", IssuerStandID: "stand-1", PrincipalGrain: 1_000_000, + CouponBps: 500, GrowthRateBps: 100, TermDays: 365, IssuedAt: 1000, Maturity: 1365, Signer: "stand-1", + }) + _, _ = srv.PlaceSecondaryOrder(ctx, &btypes.MsgPlaceSecondaryOrder{ + OrderID: "sell-gb", BondID: "gb50", Side: btypes.OrderSell, PriceBps: 9500, + QuantityGrain: 100, HolderReachID: "h1", Signer: "h1", + }) + resp, err := srv.MatchSecondaryOrder(ctx, &btypes.MsgMatchSecondaryOrder{ + IncomingOrderID: "buy-gb", BondID: "gb50", Side: btypes.OrderBuy, PriceBps: 9500, + QuantityGrain: 100, HolderReachID: "hb", Signer: "hb", + }) + if err != nil { + t.Fatalf("MatchSecondaryOrder on GrowthBond: %v", err) + } + if resp.FilledQuantityGrain != 100 { + t.Errorf("FilledQuantityGrain = %d, want 100", resp.FilledQuantityGrain) + } +} + +// TestMatchSecondaryOrderNonExistentBondRejected asserts a match on a non- +// existent bond is REJECTED. +func TestMatchSecondaryOrderNonExistentBondRejected(t *testing.T) { + ctx, _, k := newSimtestContext(t) + srv := keeper.NewMsgServerImpl(k) + + _, err := srv.MatchSecondaryOrder(ctx, &btypes.MsgMatchSecondaryOrder{ + IncomingOrderID: "buy-x", BondID: "no-such-bond", Side: btypes.OrderBuy, PriceBps: 9500, + QuantityGrain: 100, HolderReachID: "hb", Signer: "hb", + }) + if err == nil { + t.Error("MatchSecondaryOrder on non-existent bond should be REJECTED") + } +} + +// --- ValidateBasic error paths (coverage) ----------------------------------- + +// TestValidateBasicErrorPaths exercises each Msg* ValidateBasic error path +// to push coverage >=80%. +func TestValidateBasicErrorPaths(t *testing.T) { + // MsgIssueBond + if err := (&btypes.MsgIssueBond{}).ValidateBasic(); err == nil { + t.Error("empty MsgIssueBond should fail ValidateBasic") + } + if err := (&btypes.MsgIssueBond{BondID: "x", IssuerStandID: "s", PrincipalGrain: 1, CouponBps: 900}).ValidateBasic(); err == nil { + t.Error("above-cap MsgIssueBond should fail ValidateBasic") + } + // MsgIssueGrowthBond + if err := (&btypes.MsgIssueGrowthBond{}).ValidateBasic(); err == nil { + t.Error("empty MsgIssueGrowthBond should fail ValidateBasic") + } + // MsgTickGrowthBond + if err := (&btypes.MsgTickGrowthBond{}).ValidateBasic(); err == nil { + t.Error("empty MsgTickGrowthBond should fail ValidateBasic") + } + // MsgPlaceSecondaryOrder + if err := (&btypes.MsgPlaceSecondaryOrder{}).ValidateBasic(); err == nil { + t.Error("empty MsgPlaceSecondaryOrder should fail ValidateBasic") + } + if err := (&btypes.MsgPlaceSecondaryOrder{OrderID: "x", BondID: "b", Side: "Bogus", QuantityGrain: 1, Signer: "s"}).ValidateBasic(); err == nil { + t.Error("bad-side MsgPlaceSecondaryOrder should fail ValidateBasic") + } + if err := (&btypes.MsgPlaceSecondaryOrder{OrderID: "x", BondID: "b", Side: btypes.OrderBuy, QuantityGrain: 0, Signer: "s"}).ValidateBasic(); err == nil { + t.Error("zero-quantity MsgPlaceSecondaryOrder should fail ValidateBasic") + } + // MsgCancelSecondaryOrder + if err := (&btypes.MsgCancelSecondaryOrder{}).ValidateBasic(); err == nil { + t.Error("empty MsgCancelSecondaryOrder should fail ValidateBasic") + } + // MsgMatchSecondaryOrder + if err := (&btypes.MsgMatchSecondaryOrder{}).ValidateBasic(); err == nil { + t.Error("empty MsgMatchSecondaryOrder should fail ValidateBasic") + } + if err := (&btypes.MsgMatchSecondaryOrder{IncomingOrderID: "x", BondID: "b", Side: "Bogus", QuantityGrain: 1, Signer: "s"}).ValidateBasic(); err == nil { + t.Error("bad-side MsgMatchSecondaryOrder should fail ValidateBasic") + } +} + +// --- Keeper accessors (coverage) -------------------------------------------- + +// TestKeeperAccessors exercises the exported Keeper accessors that the +// simtest above does not directly hit (AllBonds, AllGrowthBonds, +// AllRestingOrders empty paths; SetStandKeeper) to push coverage >=80%. +func TestKeeperAccessors(t *testing.T) { + ctx, sk, k := newSimtestContext(t) + _ = sk + + // Empty-store accessors return empty (not nil) slices. + if got := k.AllBonds(ctx); len(got) != 0 { + t.Errorf("AllBonds empty = %d, want 0", len(got)) + } + if got := k.AllGrowthBonds(ctx); len(got) != 0 { + t.Errorf("AllGrowthBonds empty = %d, want 0", len(got)) + } + if got := k.AllRestingOrders(ctx); len(got) != 0 { + t.Errorf("AllRestingOrders empty = %d, want 0", len(got)) + } + + // Populate + read back. + srv := keeper.NewMsgServerImpl(k) + _, _ = srv.IssueBond(ctx, &btypes.MsgIssueBond{ + BondID: "acc-b", IssuerStandID: "stand-1", PrincipalGrain: 1_000_000, + CouponBps: 500, TermDays: 365, IssuedAt: 1000, Maturity: 1365, Signer: "stand-1", + }) + _, _ = srv.IssueGrowthBond(ctx, &btypes.MsgIssueGrowthBond{ + BondID: "acc-gb", IssuerStandID: "stand-1", PrincipalGrain: 1_000_000, + CouponBps: 500, GrowthRateBps: 100, TermDays: 365, IssuedAt: 1000, Maturity: 1365, Signer: "stand-1", + }) + if got := k.AllBonds(ctx); len(got) != 1 { + t.Errorf("AllBonds = %d, want 1", len(got)) + } + if got := k.AllGrowthBonds(ctx); len(got) != 1 { + t.Errorf("AllGrowthBonds = %d, want 1", len(got)) + } + + // Marshal-error path on GetBond (corrupt bytes in store). + // Use the ctx's existing KVStore (the mounted store key) — creating a + // new store key here would panic (not mounted on the multi-store). + rawStore := ctx.KVStore(k.StoreKey()) + rawStore.Set([]byte("bond/corrupt"), []byte("not-json")) + if _, ok := k.GetBond(ctx, "corrupt"); ok { + t.Error("GetBond on corrupt bytes should return false") + } + // Marshal-error path on GetGrowthBond (corrupt bytes). + rawStore.Set([]byte("growth/corrupt-gb"), []byte("not-json")) + if _, ok := k.GetGrowthBond(ctx, "corrupt-gb"); ok { + t.Error("GetGrowthBond on corrupt bytes should return false") + } + // Marshal-error path on GetRestingOrder (corrupt bytes). + rawStore.Set([]byte("order/corrupt-order"), []byte("not-json")) + if _, ok := k.GetRestingOrder(ctx, "corrupt-order"); ok { + t.Error("GetRestingOrder on corrupt bytes should return false") + } + + // SetStandKeeper post-construction wiring coverage. + k.SetStandKeeper(nil) +} + +// --- UnwrapCtx panic (coverage) --------------------------------------------- + +// TestUnwrapCtxPanic asserts unwrapCtx panics on a non-sdk.Context value. +func TestUnwrapCtxPanic(t *testing.T) { + defer func() { + if r := recover(); r == nil { + t.Error("unwrapCtx on non-sdk.Context should panic") + } + }() + _, _ = keeper.NewMsgServerImpl(keeper.Keeper{}).IssueBond("not-a-ctx", + &btypes.MsgIssueBond{BondID: "x", IssuerStandID: "s", PrincipalGrain: 1, CouponBps: 500, Signer: "s"}) +} + +// --- IssueGrowthBond idempotency + non-existent Stand ------------------------ + +// TestIssueGrowthBondIdempotentReject asserts issuing the same growth-bond-id +// twice REJECTS the second. +func TestIssueGrowthBondIdempotentReject(t *testing.T) { + ctx, _, k := newSimtestContext(t) + srv := keeper.NewMsgServerImpl(k) + + _, err := srv.IssueGrowthBond(ctx, &btypes.MsgIssueGrowthBond{ + BondID: "gb-dup", IssuerStandID: "stand-1", PrincipalGrain: 1_000_000, + CouponBps: 500, GrowthRateBps: 100, TermDays: 365, IssuedAt: 1000, Maturity: 1365, Signer: "stand-1", + }) + if err != nil { + t.Fatalf("first IssueGrowthBond: %v", err) + } + _, err = srv.IssueGrowthBond(ctx, &btypes.MsgIssueGrowthBond{ + BondID: "gb-dup", IssuerStandID: "stand-1", PrincipalGrain: 1_000_000, + CouponBps: 600, GrowthRateBps: 100, TermDays: 365, IssuedAt: 1000, Maturity: 1365, Signer: "stand-1", + }) + if err == nil { + t.Error("second IssueGrowthBond on same id should be REJECTED") + } +} + +// TestIssueGrowthBondNonExistentStandRejected asserts a non-existent Stand +// REJECTS the GrowthBond issuance. +func TestIssueGrowthBondNonExistentStandRejected(t *testing.T) { + ctx, sk, k := newSimtestContext(t) + sk.exists = map[string]bool{"stand-1": false} + sk.existsAll = false + srv := keeper.NewMsgServerImpl(k) + + _, err := srv.IssueGrowthBond(ctx, &btypes.MsgIssueGrowthBond{ + BondID: "gb-stand", IssuerStandID: "no-such-stand", PrincipalGrain: 1_000_000, + CouponBps: 500, GrowthRateBps: 100, TermDays: 365, IssuedAt: 1000, Maturity: 1365, Signer: "stand-1", + }) + if err == nil { + t.Error("IssueGrowthBond on non-existent Stand should be REJECTED") + } +} + +// --- Sell taker against Buy resting orders (coverage of the Sell side) ------ + +// TestSellTakerMatchesBuyResting asserts a Sell taker matches against Buy +// resting orders (the opposite side of the Buy-taker tests above). +func TestSellTakerMatchesBuyResting(t *testing.T) { + ctx, _, k := newSimtestContext(t) + srv := keeper.NewMsgServerImpl(k) + + _, _ = srv.IssueBond(ctx, &btypes.MsgIssueBond{ + BondID: "b70", IssuerStandID: "stand-1", PrincipalGrain: 1_000_000, + CouponBps: 500, TermDays: 365, IssuedAt: 1000, Maturity: 1365, Signer: "stand-1", + }) + // Rest a Buy order at 9500 (implied coupon 500, in-band). A Buy bid is + // willing to pay UP TO 9500; a Sell taker at 9500 matches. + _, _ = srv.PlaceSecondaryOrder(ctx, &btypes.MsgPlaceSecondaryOrder{ + OrderID: "buy-rest", BondID: "b70", Side: btypes.OrderBuy, PriceBps: 9500, + QuantityGrain: 100, HolderReachID: "h1", Signer: "h1", + }) + // Sell taker at 9500 (matches the Buy bid). + resp, err := srv.MatchSecondaryOrder(ctx, &btypes.MsgMatchSecondaryOrder{ + IncomingOrderID: "sell-taker", BondID: "b70", Side: btypes.OrderSell, PriceBps: 9500, + QuantityGrain: 100, HolderReachID: "hb", Signer: "hb", + }) + if err != nil { + t.Fatalf("MatchSecondaryOrder Sell taker: %v", err) + } + if resp.FilledQuantityGrain != 100 { + t.Errorf("FilledQuantityGrain = %d, want 100", resp.FilledQuantityGrain) + } +} + +// TestSellTakerNoCross asserts a Sell taker whose price does not cross the +// Buy resting order results in filled 0. +func TestSellTakerNoCross(t *testing.T) { + ctx, _, k := newSimtestContext(t) + srv := keeper.NewMsgServerImpl(k) + + _, _ = srv.IssueBond(ctx, &btypes.MsgIssueBond{ + BondID: "b71", IssuerStandID: "stand-1", PrincipalGrain: 1_000_000, + CouponBps: 500, TermDays: 365, IssuedAt: 1000, Maturity: 1365, Signer: "stand-1", + }) + // Rest a Buy at 9400 (bid — willing to pay up to 9400). + _, _ = srv.PlaceSecondaryOrder(ctx, &btypes.MsgPlaceSecondaryOrder{ + OrderID: "buy-9400", BondID: "b71", Side: btypes.OrderBuy, PriceBps: 9400, + QuantityGrain: 100, HolderReachID: "h1", Signer: "h1", + }) + // Sell taker at 9500 (above the Buy bid — no cross; the seller wants more + // than the buyer bids). + resp, err := srv.MatchSecondaryOrder(ctx, &btypes.MsgMatchSecondaryOrder{ + IncomingOrderID: "sell-taker", BondID: "b71", Side: btypes.OrderSell, PriceBps: 9500, + QuantityGrain: 100, HolderReachID: "hb", Signer: "hb", + }) + if err != nil { + t.Fatalf("MatchSecondaryOrder Sell taker no-cross: %v", err) + } + if resp.FilledQuantityGrain != 0 { + t.Errorf("FilledQuantityGrain = %d, want 0 (no cross)", resp.FilledQuantityGrain) + } +} + +// --- Multiple matches in one taker (coverage) -------------------------------- + +// TestMatchTakerMultipleResting asserts a taker matches against multiple +// resting orders (filling against the best price first, then the next). +func TestMatchTakerMultipleResting(t *testing.T) { + ctx, _, k := newSimtestContext(t) + srv := keeper.NewMsgServerImpl(k) + + _, _ = srv.IssueBond(ctx, &btypes.MsgIssueBond{ + BondID: "b80", IssuerStandID: "stand-1", PrincipalGrain: 1_000_000, + CouponBps: 500, TermDays: 365, IssuedAt: 1000, Maturity: 1365, Signer: "stand-1", + }) + // Rest two Sell orders: one at 9400 (implied coupon 600, in-band) for 50, + // and one at 9500 (implied coupon 500, in-band) for 50. + _, _ = srv.PlaceSecondaryOrder(ctx, &btypes.MsgPlaceSecondaryOrder{ + OrderID: "sell-9400", BondID: "b80", Side: btypes.OrderSell, PriceBps: 9400, + QuantityGrain: 50, HolderReachID: "h1", Signer: "h1", + }) + _, _ = srv.PlaceSecondaryOrder(ctx, &btypes.MsgPlaceSecondaryOrder{ + OrderID: "sell-9500", BondID: "b80", Side: btypes.OrderSell, PriceBps: 9500, + QuantityGrain: 50, HolderReachID: "h2", Signer: "h2", + }) + // Buy taker at 9500 for 100 — fills 50 at 9400 (best price, first) + 50 + // at 9500 (next). Total filled = 100. + resp, err := srv.MatchSecondaryOrder(ctx, &btypes.MsgMatchSecondaryOrder{ + IncomingOrderID: "buy-multi", BondID: "b80", Side: btypes.OrderBuy, PriceBps: 9500, + QuantityGrain: 100, HolderReachID: "hb", Signer: "hb", + }) + if err != nil { + t.Fatalf("MatchSecondaryOrder multi: %v", err) + } + if resp.FilledQuantityGrain != 100 { + t.Errorf("FilledQuantityGrain = %d, want 100", resp.FilledQuantityGrain) + } + // Two match events emitted (one per resting fill). + if got := eventCount(ctx, "bond.match"); got != 2 { + t.Errorf("bond.match events = %d, want 2 (one per resting fill)", got) + } + // Both resting orders are removed (Filled). + if _, ok := k.GetRestingOrder(ctx, "sell-9400"); ok { + t.Error("sell-9400 should be removed (filled)") + } + if _, ok := k.GetRestingOrder(ctx, "sell-9500"); ok { + t.Error("sell-9500 should be removed (filled)") + } +} + +// --- D-063 reject advances to no further resting (fails closed) -------------- + +// TestMatchAboveCapRejectStopsMatching asserts a D-063 REJECT on the best +// resting order STOPS matching (fails closed — the taker does not advance to +// the next resting order even if it is in-band). This is the mission-lock- +// true choice: the 8% cap is a hard invariant. +func TestMatchAboveCapRejectStopsMatching(t *testing.T) { + ctx, _, k := newSimtestContext(t) + srv := keeper.NewMsgServerImpl(k) + + _, _ = srv.IssueBond(ctx, &btypes.MsgIssueBond{ + BondID: "b90", IssuerStandID: "stand-1", PrincipalGrain: 1_000_000, + CouponBps: 500, TermDays: 365, IssuedAt: 1000, Maturity: 1365, Signer: "stand-1", + }) + // Rest a Sell at 9000 (implied coupon 1000, ABOVE cap) — the BEST price + // for a Buy taker (lowest Sell price). + _, _ = srv.PlaceSecondaryOrder(ctx, &btypes.MsgPlaceSecondaryOrder{ + OrderID: "sell-above", BondID: "b90", Side: btypes.OrderSell, PriceBps: 9000, + QuantityGrain: 50, HolderReachID: "h1", Signer: "h1", + }) + // Rest a Sell at 9500 (implied coupon 500, in-band) — the WORSE price. + _, _ = srv.PlaceSecondaryOrder(ctx, &btypes.MsgPlaceSecondaryOrder{ + OrderID: "sell-inband", BondID: "b90", Side: btypes.OrderSell, PriceBps: 9500, + QuantityGrain: 50, HolderReachID: "h2", Signer: "h2", + }) + // Buy taker at 9500 for 100 — the best resting (9000) is ABOVE cap -> + // REJECTED (fails closed). The taker does NOT advance to the in-band + // 9500 order. + resp, err := srv.MatchSecondaryOrder(ctx, &btypes.MsgMatchSecondaryOrder{ + IncomingOrderID: "buy-reject", BondID: "b90", Side: btypes.OrderBuy, PriceBps: 9500, + QuantityGrain: 100, HolderReachID: "hb", Signer: "hb", + }) + if err == nil { + t.Error("MatchSecondaryOrder with above-cap best resting should be REJECTED (D-063)") + } + if !resp.Rejected { + t.Error("Rejected = false, want true (D-063 fails closed on the best resting)") + } + // The in-band 9500 order is UNTOUCHED (fails closed — no advance). + ro, ok := k.GetRestingOrder(ctx, "sell-inband") + if !ok { + t.Fatal("sell-inband should STAY on the book (D-063 fails closed — no advance)") + } + if ro.RemainingQuantityGrain != 50 { + t.Errorf("sell-inband RemainingQuantityGrain = %d, want 50 (untouched)", ro.RemainingQuantityGrain) + } +} diff --git a/x/bond/module.go b/x/bond/module.go new file mode 100644 index 0000000..2e692d8 --- /dev/null +++ b/x/bond/module.go @@ -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{} diff --git a/x/bond/types/expected_keepers.go b/x/bond/types/expected_keepers.go new file mode 100644 index 0000000..1f2d3f9 --- /dev/null +++ b/x/bond/types/expected_keepers.go @@ -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 +} diff --git a/x/bond/types/msg_bond.go b/x/bond/types/msg_bond.go new file mode 100644 index 0000000..789d5db --- /dev/null +++ b/x/bond/types/msg_bond.go @@ -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() {}