6c34650a0d
v0.5 Bearers Runtime — 7 runtime REQs (REQ-033..039) shipped as feature. 8 modules promoted to runtime (MsgServer + simtest). cosmos-sdk v0.50.8 + ibc-go v8.2.1 added (G-006 controlled exception). G-003 + locked-const firewalls intact. 8 keeper packages ≥80% coverage. 5 GRILL decisions ratified; 8 binding fixes landed; 5 P1+ flagged for v0.6+. ---ci--- project: oy phase: 8 milestone: v0.5 status: complete requirements: covered: [REQ-033, REQ-034, REQ-035, REQ-036, REQ-037, REQ-038, REQ-039] partial: [] ---/ci---
1295 lines
52 KiB
Go
1295 lines
52 KiB
Go
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<room).
|
|
_, err := srv.IssueGrowthBond(ctx, &btypes.MsgIssueGrowthBond{
|
|
BondID: "gb3", 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)
|
|
}
|
|
|
|
// Tick: 500 + 200 = 700 (<= cap 800).
|
|
resp, err := srv.TickGrowthBond(ctx, &btypes.MsgTickGrowthBond{BondID: "gb3", Signer: "stand-1"})
|
|
if err != nil {
|
|
t.Fatalf("TickGrowthBond: %v", err)
|
|
}
|
|
if resp.PostGrowthCouponBps != 700 {
|
|
t.Errorf("PostGrowthCouponBps = %d, want 700", resp.PostGrowthCouponBps)
|
|
}
|
|
// Tick again: 700 + 200 = 900, but ClampGrowth(700, 200) = 100 (room=100);
|
|
// post-growth = 700 + 100 = 800 (== cap).
|
|
resp2, err := srv.TickGrowthBond(ctx, &btypes.MsgTickGrowthBond{BondID: "gb3", Signer: "stand-1"})
|
|
if err != nil {
|
|
t.Fatalf("second TickGrowthBond: %v", err)
|
|
}
|
|
if resp2.PostGrowthCouponBps != 800 {
|
|
t.Errorf("PostGrowthCouponBps after second tick = %d, want 800 (clamped to cap)", resp2.PostGrowthCouponBps)
|
|
}
|
|
// Tick again: 800 + 200 -> 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)
|
|
}
|
|
}
|