9e7fc403f5
v0.7 P4 (REQ-054, REQ-055, REQ-060, REQ-063, D-080, D-089, D-090): x/bond (Mutual Aid Bond): MAB struct (Bond anonymous embed) mirroring GrowthBond; CouponDenom enum (CoverCall/MutualAidCredit/Bread-rejected); MABIssuanceCeilingAnnualSurplusMultiple=3 locked const; ValidateMAB rejects CouponDenomBread (FR-MAB-3 dual firewall); D-080 tagged streaming (reserve_build_out); 4 handlers (IssueMAB with 3x ceiling check, DebitMABProceeds with auto-Still on misuse, WitnessMABProceedsRelease with Watcher quorum, WatcherAttestMAB); CoverKeeper reverse edge (D-089). x/cover (Cover Claims Voucher + dissolution): CoverClaimsVoucher struct; D-090(2) cold-start bond = max(10x avgCallSize, MinimumVoucherBond); 4 handlers (RegisterCoverClaimsVoucher, AdjudicateCoverCall with FR-CPCV-2 no self-adjudication, SlashCoverClaimsVoucher with cross-Pool bucket drop, DissolveCoverPool with FR-MAB-4 waterfall Cover-Fee > MAB > Bread); MAB holders have NO Voice (REQ-063). x/standing (Shadow vouch): Vouch.IsShadow field; ShadowVouchWeightMultiplier =0.5 locked const (REQ-060); GetVoucherWeight extended with isShadow param (post-step 0.5x multiplier; all call sites updated); SlashReasonFraudulent CoverCall const (REQ-055). Coverage: bond/keeper 92.1%, cover/keeper 95.0%, standing/types 90.3%. G-006/G-028 intact. go.mod/go.sum diff EMPTY. go vet clean. Lexicon green. ---ci--- project: oy phase: 4 milestone: v0.7 status: execute ---/ci---
1836 lines
72 KiB
Go
1836 lines
72 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
|
||
}
|
||
|
||
// stubCoverKeeper satisfies btypes.CoverKeeper for the v0.7 P4 MAB simtest
|
||
// (D-089(2) reverse edge). It returns the configured ReserveAccount per
|
||
// pool-id.
|
||
type stubCoverKeeper struct {
|
||
reserveAccounts map[string]string
|
||
}
|
||
|
||
func (s *stubCoverKeeper) GetPoolReserveAccount(poolID string) (string, bool) {
|
||
if s.reserveAccounts == nil {
|
||
return "", false
|
||
}
|
||
acc, ok := s.reserveAccounts[poolID]
|
||
return acc, ok
|
||
}
|
||
|
||
// stubWatcherKeeperBond satisfies btypes.WatcherKeeper for the v0.7 P4 MAB
|
||
// simtest. It returns a configurable quorum-met bool per
|
||
// AttestMABRelease call.
|
||
type stubWatcherKeeperBond struct {
|
||
quorumMet bool
|
||
}
|
||
|
||
func (s *stubWatcherKeeperBond) AttestMABRelease(bondID string, attestationRef string) bool {
|
||
return s.quorumMet
|
||
}
|
||
|
||
// stubStillKeeperBond satisfies btypes.StillKeeper for the v0.7 P4 MAB
|
||
// simtest (D-089(1)). It records every Still() call for assertion (the
|
||
// tagged-streaming misuse simtest asserts Still was called with the right
|
||
// bond-id + reason).
|
||
type stubStillKeeperBond struct {
|
||
calls []struct {
|
||
bondID string
|
||
reason string
|
||
}
|
||
}
|
||
|
||
func (s *stubStillKeeperBond) Still(bondID string, reason string) error {
|
||
s.calls = append(s.calls, struct {
|
||
bondID string
|
||
reason string
|
||
}{bondID, reason})
|
||
return nil
|
||
}
|
||
|
||
// --- 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)
|
||
}
|
||
|
||
// newMABSimtestContext constructs an in-memory sdk.Context with the MAB
|
||
// shims (CoverKeeper + WatcherKeeper + StillKeeper) wired for the v0.7 P4
|
||
// MAB simtest (D-089(1) + D-089(2)). Returns the ctx, the four stubs, and
|
||
// the Keeper.
|
||
func newMABSimtestContext(t *testing.T) (sdk.Context, *stubStandKeeper, *stubCoverKeeper, *stubWatcherKeeperBond, *stubStillKeeperBond, 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}
|
||
ck := &stubCoverKeeper{reserveAccounts: map[string]string{"pool-1": "reserve-acc-1"}}
|
||
wk := &stubWatcherKeeperBond{quorumMet: true}
|
||
stK := &stubStillKeeperBond{}
|
||
k := keeper.NewKeeper(cdc, storeKey, sk)
|
||
k.SetCoverKeeper(ck)
|
||
k.SetWatcherKeeper(wk)
|
||
k.SetStillKeeper(stK)
|
||
return ctx, sk, ck, wk, stK, k
|
||
}
|
||
|
||
// --- 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)
|
||
}
|
||
}
|
||
|
||
// --- v0.7 P4: MAB simtest (REQ-054, D-080, D-089(1), D-089(2)) ----------------
|
||
//
|
||
// (Mutual Aid Bond runtime — issuance + Bread-coupon rejection + 3× annual
|
||
// surplus ceiling + tagged-streaming misuse -> auto-Still + Watcher-witnessed
|
||
// release + quarterly attestation).
|
||
|
||
// TestMABIssuanceValidCoverCallCoupons (case a) asserts a MAB issuance with
|
||
// valid Cover-Call coupons (CouponDenomCoverCall) succeeds + the
|
||
// bond.mab_issued event is emitted + the UseOfProceedsTag is locked to
|
||
// "reserve_build_out".
|
||
func TestMABIssuanceValidCoverCallCoupons(t *testing.T) {
|
||
ctx, _, _, _, _, k := newMABSimtestContext(t)
|
||
srv := keeper.NewMsgServerImpl(k)
|
||
|
||
resp, err := srv.IssueMAB(ctx, &btypes.MsgIssueMAB{
|
||
BondID: "mab-1", PoolID: "pool-1", IssuerStandID: "stand-1",
|
||
PrincipalGrain: 1_000_000, CouponBps: 500, CouponKind: btypes.CouponDenomCoverCall,
|
||
AnnualSurplusAtIssuance: 5_000_000, TermDays: 365, Signer: "stand-1",
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("IssueMAB: %v", err)
|
||
}
|
||
if resp.ClampedCouponBps != 500 {
|
||
t.Errorf("ClampedCouponBps = %d, want 500", resp.ClampedCouponBps)
|
||
}
|
||
if !hasEvent(ctx, "bond.mab_issued") {
|
||
t.Error("bond.mab_issued event not emitted")
|
||
}
|
||
// Read it back.
|
||
m, ok := k.GetMAB(ctx, "mab-1")
|
||
if !ok {
|
||
t.Fatal("MAB not persisted")
|
||
}
|
||
if m.CouponKind != btypes.CouponDenomCoverCall {
|
||
t.Errorf("CouponKind = %q, want CoverCall", m.CouponKind)
|
||
}
|
||
if m.UseOfProceedsTag != btypes.MABUseOfProceedsReserveBuildOut {
|
||
t.Errorf("UseOfProceedsTag = %q, want %q (D-080 lock)", m.UseOfProceedsTag, btypes.MABUseOfProceedsReserveBuildOut)
|
||
}
|
||
// The mab-pool index recorded the pool binding.
|
||
poolID, ok := k.GetMABPool(ctx, "mab-1")
|
||
if !ok {
|
||
t.Fatal("mab-pool index not recorded")
|
||
}
|
||
if poolID != "pool-1" {
|
||
t.Errorf("mab-pool index = %q, want pool-1", poolID)
|
||
}
|
||
}
|
||
|
||
// TestMABIssuanceBreadCouponsRejected (case b) asserts a MAB issuance with
|
||
// Bread coupons (CouponDenomBread) is REJECTED at ValidateBasic (FR-MAB-3).
|
||
func TestMABIssuanceBreadCouponsRejected(t *testing.T) {
|
||
ctx, _, _, _, _, k := newMABSimtestContext(t)
|
||
srv := keeper.NewMsgServerImpl(k)
|
||
|
||
_, err := srv.IssueMAB(ctx, &btypes.MsgIssueMAB{
|
||
BondID: "mab-bad", PoolID: "pool-1", IssuerStandID: "stand-1",
|
||
PrincipalGrain: 1_000_000, CouponBps: 500, CouponKind: btypes.CouponDenomBread,
|
||
AnnualSurplusAtIssuance: 5_000_000, TermDays: 365, Signer: "stand-1",
|
||
})
|
||
if err == nil {
|
||
t.Fatal("IssueMAB with CouponDenomBread should be REJECTED (FR-MAB-3)")
|
||
}
|
||
if !strings.Contains(err.Error(), "FR-MAB-3") {
|
||
t.Errorf("err = %q, want 'FR-MAB-3'", err.Error())
|
||
}
|
||
// The MAB was NOT persisted.
|
||
if _, ok := k.GetMAB(ctx, "mab-bad"); ok {
|
||
t.Error("MAB with Bread coupons should NOT be persisted")
|
||
}
|
||
}
|
||
|
||
// TestMABIssuanceAboveCeilingRejected (case c) asserts a MAB issuance that
|
||
// would push the total outstanding MAB principal above the 3× annual
|
||
// surplus ceiling is REJECTED (REQ-054 locked). Issue two MABs that
|
||
// together + a third exceed 3× annual surplus.
|
||
func TestMABIssuanceAboveCeilingRejected(t *testing.T) {
|
||
ctx, _, _, _, _, k := newMABSimtestContext(t)
|
||
srv := keeper.NewMsgServerImpl(k)
|
||
|
||
// Annual surplus = 5M -> ceiling = 15M. Issue two MABs at 7M each
|
||
// (sum = 14M, within ceiling). A third at 2M would push the sum to
|
||
// 16M > 15M -> REJECT.
|
||
_, err := srv.IssueMAB(ctx, &btypes.MsgIssueMAB{
|
||
BondID: "mab-c1", PoolID: "pool-1", IssuerStandID: "stand-1",
|
||
PrincipalGrain: 7_000_000, CouponBps: 500, CouponKind: btypes.CouponDenomCoverCall,
|
||
AnnualSurplusAtIssuance: 5_000_000, TermDays: 365, Signer: "stand-1",
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("first IssueMAB: %v", err)
|
||
}
|
||
_, err = srv.IssueMAB(ctx, &btypes.MsgIssueMAB{
|
||
BondID: "mab-c2", PoolID: "pool-1", IssuerStandID: "stand-1",
|
||
PrincipalGrain: 7_000_000, CouponBps: 500, CouponKind: btypes.CouponDenomMutualAidCredit,
|
||
AnnualSurplusAtIssuance: 5_000_000, TermDays: 365, Signer: "stand-1",
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("second IssueMAB: %v", err)
|
||
}
|
||
// Third at 2M -> sum 16M > 15M ceiling -> REJECT.
|
||
_, err = srv.IssueMAB(ctx, &btypes.MsgIssueMAB{
|
||
BondID: "mab-c3", PoolID: "pool-1", IssuerStandID: "stand-1",
|
||
PrincipalGrain: 2_000_000, CouponBps: 500, CouponKind: btypes.CouponDenomCoverCall,
|
||
AnnualSurplusAtIssuance: 5_000_000, TermDays: 365, Signer: "stand-1",
|
||
})
|
||
if err == nil {
|
||
t.Fatal("third IssueMAB above 3× ceiling should be REJECTED")
|
||
}
|
||
if !strings.Contains(err.Error(), "ceiling breached") {
|
||
t.Errorf("err = %q, want 'ceiling breached'", err.Error())
|
||
}
|
||
}
|
||
|
||
// TestMABDebitProceedsMisuseAutoStill (case d) asserts a MAB proceeds debit
|
||
// with a destination != the Pool's ReserveAccount triggers the auto-Still
|
||
// (D-089(1)) AND is REJECTED (D-080 tagged-streaming misuse).
|
||
func TestMABDebitProceedsMisuseAutoStill(t *testing.T) {
|
||
ctx, _, _, _, stK, k := newMABSimtestContext(t)
|
||
srv := keeper.NewMsgServerImpl(k)
|
||
|
||
// Issue a MAB for pool-1 (whose ReserveAccount is "reserve-acc-1").
|
||
_, err := srv.IssueMAB(ctx, &btypes.MsgIssueMAB{
|
||
BondID: "mab-d1", PoolID: "pool-1", IssuerStandID: "stand-1",
|
||
PrincipalGrain: 1_000_000, CouponBps: 500, CouponKind: btypes.CouponDenomCoverCall,
|
||
AnnualSurplusAtIssuance: 5_000_000, TermDays: 365, Signer: "stand-1",
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("IssueMAB: %v", err)
|
||
}
|
||
|
||
// Debit to a WRONG destination -> auto-Still + REJECT.
|
||
_, err = srv.DebitMABProceeds(ctx, &btypes.MsgDebitMABProceeds{
|
||
BondID: "mab-d1", DestinationAccount: "wrong-destination", Signer: "stand-1",
|
||
})
|
||
if err == nil {
|
||
t.Fatal("DebitMABProceeds with wrong destination should be REJECTED")
|
||
}
|
||
if !strings.Contains(err.Error(), "tagged-streaming misuse") {
|
||
t.Errorf("err = %q, want 'tagged-streaming misuse'", err.Error())
|
||
}
|
||
// The StillKeeper was called with the right bond-id + reason.
|
||
if len(stK.calls) != 1 {
|
||
t.Fatalf("StillKeeper.Still calls = %d, want 1", len(stK.calls))
|
||
}
|
||
if stK.calls[0].bondID != "mab-d1" {
|
||
t.Errorf("Still bondID = %q, want mab-d1", stK.calls[0].bondID)
|
||
}
|
||
if !strings.Contains(stK.calls[0].reason, "MAB misuse") {
|
||
t.Errorf("Still reason = %q, want 'MAB misuse'", stK.calls[0].reason)
|
||
}
|
||
// The misuse event was emitted.
|
||
if !hasEvent(ctx, "bond.mab_proceeds_misuse") {
|
||
t.Error("bond.mab_proceeds_misuse event not emitted")
|
||
}
|
||
}
|
||
|
||
// TestMABDebitProceedsMatchSucceeds asserts a MAB proceeds debit with the
|
||
// destination == the Pool's ReserveAccount succeeds + the
|
||
// bond.mab_proceeds_debited event is emitted.
|
||
func TestMABDebitProceedsMatchSucceeds(t *testing.T) {
|
||
ctx, _, _, _, _, k := newMABSimtestContext(t)
|
||
srv := keeper.NewMsgServerImpl(k)
|
||
|
||
_, err := srv.IssueMAB(ctx, &btypes.MsgIssueMAB{
|
||
BondID: "mab-d2", PoolID: "pool-1", IssuerStandID: "stand-1",
|
||
PrincipalGrain: 1_000_000, CouponBps: 500, CouponKind: btypes.CouponDenomCoverCall,
|
||
AnnualSurplusAtIssuance: 5_000_000, TermDays: 365, Signer: "stand-1",
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("IssueMAB: %v", err)
|
||
}
|
||
|
||
// Debit to the CORRECT destination (reserve-acc-1) -> succeeds.
|
||
_, err = srv.DebitMABProceeds(ctx, &btypes.MsgDebitMABProceeds{
|
||
BondID: "mab-d2", DestinationAccount: "reserve-acc-1", Signer: "stand-1",
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("DebitMABProceeds with matching destination: %v", err)
|
||
}
|
||
if !hasEvent(ctx, "bond.mab_proceeds_debited") {
|
||
t.Error("bond.mab_proceeds_debited event not emitted")
|
||
}
|
||
}
|
||
|
||
// TestMABWitnessProceedsReleaseQuorumPresent (case e) asserts a MAB
|
||
// proceeds release with Watcher quorum present succeeds + the
|
||
// bond.mab_proceeds_released event is emitted.
|
||
func TestMABWitnessProceedsReleaseQuorumPresent(t *testing.T) {
|
||
ctx, _, _, wk, _, k := newMABSimtestContext(t)
|
||
wk.quorumMet = true
|
||
srv := keeper.NewMsgServerImpl(k)
|
||
|
||
_, err := srv.IssueMAB(ctx, &btypes.MsgIssueMAB{
|
||
BondID: "mab-w1", PoolID: "pool-1", IssuerStandID: "stand-1",
|
||
PrincipalGrain: 1_000_000, CouponBps: 500, CouponKind: btypes.CouponDenomCoverCall,
|
||
AnnualSurplusAtIssuance: 5_000_000, TermDays: 365, Signer: "stand-1",
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("IssueMAB: %v", err)
|
||
}
|
||
|
||
_, err = srv.WitnessMABProceedsRelease(ctx, &btypes.MsgWitnessMABProceedsRelease{
|
||
BondID: "mab-w1", AttestationRef: "oy:attest:mab-w1", Signer: "watcher-1",
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("WitnessMABProceedsRelease with quorum: %v", err)
|
||
}
|
||
if !hasEvent(ctx, "bond.mab_proceeds_released") {
|
||
t.Error("bond.mab_proceeds_released event not emitted")
|
||
}
|
||
}
|
||
|
||
// TestMABWitnessProceedsReleaseQuorumAbsent asserts a MAB proceeds release
|
||
// with Watcher quorum NOT met is REJECTED (D-080 — 6-of-9 required).
|
||
func TestMABWitnessProceedsReleaseQuorumAbsent(t *testing.T) {
|
||
ctx, _, _, wk, _, k := newMABSimtestContext(t)
|
||
wk.quorumMet = false
|
||
srv := keeper.NewMsgServerImpl(k)
|
||
|
||
_, err := srv.IssueMAB(ctx, &btypes.MsgIssueMAB{
|
||
BondID: "mab-w2", PoolID: "pool-1", IssuerStandID: "stand-1",
|
||
PrincipalGrain: 1_000_000, CouponBps: 500, CouponKind: btypes.CouponDenomCoverCall,
|
||
AnnualSurplusAtIssuance: 5_000_000, TermDays: 365, Signer: "stand-1",
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("IssueMAB: %v", err)
|
||
}
|
||
|
||
_, err = srv.WitnessMABProceedsRelease(ctx, &btypes.MsgWitnessMABProceedsRelease{
|
||
BondID: "mab-w2", AttestationRef: "oy:attest:mab-w2", Signer: "watcher-1",
|
||
})
|
||
if err == nil {
|
||
t.Fatal("WitnessMABProceedsRelease without quorum should be REJECTED")
|
||
}
|
||
if !strings.Contains(err.Error(), "quorum not met") {
|
||
t.Errorf("err = %q, want 'quorum not met'", err.Error())
|
||
}
|
||
}
|
||
|
||
// TestMABWatcherAttest (case f) asserts a quarterly Watcher attestation on
|
||
// a MAB is recorded + the bond.mab_watcher_attested event is emitted.
|
||
func TestMABWatcherAttest(t *testing.T) {
|
||
ctx, _, _, _, _, k := newMABSimtestContext(t)
|
||
srv := keeper.NewMsgServerImpl(k)
|
||
|
||
_, err := srv.IssueMAB(ctx, &btypes.MsgIssueMAB{
|
||
BondID: "mab-a1", PoolID: "pool-1", IssuerStandID: "stand-1",
|
||
PrincipalGrain: 1_000_000, CouponBps: 500, CouponKind: btypes.CouponDenomCoverCall,
|
||
AnnualSurplusAtIssuance: 5_000_000, TermDays: 365, Signer: "stand-1",
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("IssueMAB: %v", err)
|
||
}
|
||
|
||
_, err = srv.WatcherAttestMAB(ctx, &btypes.MsgWatcherAttestMAB{
|
||
BondID: "mab-a1", AttestationRef: "oy:attest:quarterly:mab-a1", Signer: "watcher-1",
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("WatcherAttestMAB: %v", err)
|
||
}
|
||
if !hasEvent(ctx, "bond.mab_watcher_attested") {
|
||
t.Error("bond.mab_watcher_attested event not emitted")
|
||
}
|
||
// The attestation was recorded.
|
||
atts := k.AllMABAttests(ctx, "mab-a1")
|
||
if len(atts) != 1 {
|
||
t.Fatalf("AllMABAttests = %d, want 1", len(atts))
|
||
}
|
||
if atts[0] != "oy:attest:quarterly:mab-a1" {
|
||
t.Errorf("attestation ref = %q, want oy:attest:quarterly:mab-a1", atts[0])
|
||
}
|
||
}
|
||
|
||
// TestMABIssueIdempotentReject asserts issuing the same MAB bond-id twice
|
||
// REJECTS the second.
|
||
func TestMABIssueIdempotentReject(t *testing.T) {
|
||
ctx, _, _, _, _, k := newMABSimtestContext(t)
|
||
srv := keeper.NewMsgServerImpl(k)
|
||
|
||
_, err := srv.IssueMAB(ctx, &btypes.MsgIssueMAB{
|
||
BondID: "mab-i1", PoolID: "pool-1", IssuerStandID: "stand-1",
|
||
PrincipalGrain: 1_000_000, CouponBps: 500, CouponKind: btypes.CouponDenomCoverCall,
|
||
AnnualSurplusAtIssuance: 5_000_000, TermDays: 365, Signer: "stand-1",
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("first IssueMAB: %v", err)
|
||
}
|
||
_, err = srv.IssueMAB(ctx, &btypes.MsgIssueMAB{
|
||
BondID: "mab-i1", PoolID: "pool-1", IssuerStandID: "stand-1",
|
||
PrincipalGrain: 2_000_000, CouponBps: 600, CouponKind: btypes.CouponDenomMutualAidCredit,
|
||
AnnualSurplusAtIssuance: 5_000_000, TermDays: 365, Signer: "stand-1",
|
||
})
|
||
if err == nil {
|
||
t.Fatal("second IssueMAB on same bond-id should be REJECTED")
|
||
}
|
||
}
|
||
|
||
// TestMABIssueNonExistentStandRejected asserts a MAB issuance on a non-
|
||
// existent Stand is REJECTED (the StandKeeper stub reports false).
|
||
func TestMABIssueNonExistentStandRejected(t *testing.T) {
|
||
ctx, sk, _, _, _, k := newMABSimtestContext(t)
|
||
sk.exists = map[string]bool{"stand-1": false}
|
||
sk.existsAll = false
|
||
srv := keeper.NewMsgServerImpl(k)
|
||
|
||
_, err := srv.IssueMAB(ctx, &btypes.MsgIssueMAB{
|
||
BondID: "mab-s1", PoolID: "pool-1", IssuerStandID: "no-such-stand",
|
||
PrincipalGrain: 1_000_000, CouponBps: 500, CouponKind: btypes.CouponDenomCoverCall,
|
||
AnnualSurplusAtIssuance: 5_000_000, TermDays: 365, Signer: "stand-1",
|
||
})
|
||
if err == nil {
|
||
t.Fatal("IssueMAB on non-existent Stand should be REJECTED")
|
||
}
|
||
}
|
||
|
||
// TestMABDebitProceedsNotFound asserts a debit on a non-existent MAB is
|
||
// REJECTED.
|
||
func TestMABDebitProceedsNotFound(t *testing.T) {
|
||
ctx, _, _, _, _, k := newMABSimtestContext(t)
|
||
srv := keeper.NewMsgServerImpl(k)
|
||
|
||
_, err := srv.DebitMABProceeds(ctx, &btypes.MsgDebitMABProceeds{
|
||
BondID: "no-such-mab", DestinationAccount: "reserve-acc-1", Signer: "stand-1",
|
||
})
|
||
if err == nil {
|
||
t.Error("DebitMABProceeds on non-existent MAB should be REJECTED")
|
||
}
|
||
}
|
||
|
||
// TestMABWitnessProceedsReleaseNotFound asserts a release on a non-existent
|
||
// MAB is REJECTED.
|
||
func TestMABWitnessProceedsReleaseNotFound(t *testing.T) {
|
||
ctx, _, _, _, _, k := newMABSimtestContext(t)
|
||
srv := keeper.NewMsgServerImpl(k)
|
||
|
||
_, err := srv.WitnessMABProceedsRelease(ctx, &btypes.MsgWitnessMABProceedsRelease{
|
||
BondID: "no-such-mab", AttestationRef: "ref", Signer: "watcher-1",
|
||
})
|
||
if err == nil {
|
||
t.Error("WitnessMABProceedsRelease on non-existent MAB should be REJECTED")
|
||
}
|
||
}
|
||
|
||
// TestMABWatcherAttestNotFound asserts an attestation on a non-existent MAB
|
||
// is REJECTED.
|
||
func TestMABWatcherAttestNotFound(t *testing.T) {
|
||
ctx, _, _, _, _, k := newMABSimtestContext(t)
|
||
srv := keeper.NewMsgServerImpl(k)
|
||
|
||
_, err := srv.WatcherAttestMAB(ctx, &btypes.MsgWatcherAttestMAB{
|
||
BondID: "no-such-mab", AttestationRef: "ref", Signer: "watcher-1",
|
||
})
|
||
if err == nil {
|
||
t.Error("WatcherAttestMAB on non-existent MAB should be REJECTED")
|
||
}
|
||
}
|
||
|
||
// TestMABDebitProceedsNilCoverKeeperRejected asserts a debit with a nil
|
||
// CoverKeeper shim (wiring error) is REJECTED (the destination cannot be
|
||
// validated — D-089(2) reverse edge required).
|
||
func TestMABDebitProceedsNilCoverKeeperRejected(t *testing.T) {
|
||
ctx, _, _, _, _, k := newMABSimtestContext(t)
|
||
k.SetCoverKeeper(nil) // nil CoverKeeper — wiring error
|
||
srv := keeper.NewMsgServerImpl(k)
|
||
|
||
_, err := srv.IssueMAB(ctx, &btypes.MsgIssueMAB{
|
||
BondID: "mab-n1", PoolID: "pool-1", IssuerStandID: "stand-1",
|
||
PrincipalGrain: 1_000_000, CouponBps: 500, CouponKind: btypes.CouponDenomCoverCall,
|
||
AnnualSurplusAtIssuance: 5_000_000, TermDays: 365, Signer: "stand-1",
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("IssueMAB: %v", err)
|
||
}
|
||
_, err = srv.DebitMABProceeds(ctx, &btypes.MsgDebitMABProceeds{
|
||
BondID: "mab-n1", DestinationAccount: "reserve-acc-1", Signer: "stand-1",
|
||
})
|
||
if err == nil {
|
||
t.Error("DebitMABProceeds with nil CoverKeeper should be REJECTED (wiring error)")
|
||
}
|
||
if !strings.Contains(err.Error(), "CoverKeeper shim not wired") {
|
||
t.Errorf("err = %q, want 'CoverKeeper shim not wired'", err.Error())
|
||
}
|
||
}
|
||
|
||
// TestMABMsgValidateBasicErrorPaths exercises each MAB Msg* ValidateBasic
|
||
// error path for coverage.
|
||
func TestMABMsgValidateBasicErrorPaths(t *testing.T) {
|
||
// MsgIssueMAB empty.
|
||
if err := (&btypes.MsgIssueMAB{}).ValidateBasic(); err == nil {
|
||
t.Error("empty MsgIssueMAB should fail ValidateBasic")
|
||
}
|
||
// MsgIssueMAB with Bread coupons -> FR-MAB-3.
|
||
if err := (&btypes.MsgIssueMAB{
|
||
BondID: "x", PoolID: "p", IssuerStandID: "s", PrincipalGrain: 1,
|
||
CouponBps: 500, CouponKind: btypes.CouponDenomBread,
|
||
AnnualSurplusAtIssuance: 1, TermDays: 365, Signer: "s",
|
||
}).ValidateBasic(); err == nil {
|
||
t.Error("MsgIssueMAB with Bread coupons should fail ValidateBasic (FR-MAB-3)")
|
||
}
|
||
// MsgIssueMAB with above-cap coupon.
|
||
if err := (&btypes.MsgIssueMAB{
|
||
BondID: "x", PoolID: "p", IssuerStandID: "s", PrincipalGrain: 1,
|
||
CouponBps: 1200, CouponKind: btypes.CouponDenomCoverCall,
|
||
AnnualSurplusAtIssuance: 1, TermDays: 365, Signer: "s",
|
||
}).ValidateBasic(); err == nil {
|
||
t.Error("above-cap MsgIssueMAB should fail ValidateBasic")
|
||
}
|
||
// MsgIssueMAB with zero principal.
|
||
if err := (&btypes.MsgIssueMAB{
|
||
BondID: "x", PoolID: "p", IssuerStandID: "s", PrincipalGrain: 0,
|
||
CouponBps: 500, CouponKind: btypes.CouponDenomCoverCall,
|
||
AnnualSurplusAtIssuance: 1, TermDays: 365, Signer: "s",
|
||
}).ValidateBasic(); err == nil {
|
||
t.Error("zero-principal MsgIssueMAB should fail ValidateBasic")
|
||
}
|
||
// MsgDebitMABProceeds empty.
|
||
if err := (&btypes.MsgDebitMABProceeds{}).ValidateBasic(); err == nil {
|
||
t.Error("empty MsgDebitMABProceeds should fail ValidateBasic")
|
||
}
|
||
// MsgWitnessMABProceedsRelease empty.
|
||
if err := (&btypes.MsgWitnessMABProceedsRelease{}).ValidateBasic(); err == nil {
|
||
t.Error("empty MsgWitnessMABProceedsRelease should fail ValidateBasic")
|
||
}
|
||
// MsgWatcherAttestMAB empty.
|
||
if err := (&btypes.MsgWatcherAttestMAB{}).ValidateBasic(); err == nil {
|
||
t.Error("empty MsgWatcherAttestMAB should fail ValidateBasic")
|
||
}
|
||
}
|
||
|
||
// TestMABKeeperAccessors exercises the MAB keeper accessors (AllMABs,
|
||
// MABsForPool, AllMABAttests) for coverage.
|
||
func TestMABKeeperAccessors(t *testing.T) {
|
||
ctx, _, _, _, _, k := newMABSimtestContext(t)
|
||
srv := keeper.NewMsgServerImpl(k)
|
||
|
||
// Empty-store accessors return empty (not nil) slices.
|
||
if got := k.AllMABs(ctx); len(got) != 0 {
|
||
t.Errorf("AllMABs empty = %d, want 0", len(got))
|
||
}
|
||
if got := k.MABsForPool(ctx, "pool-1"); len(got) != 0 {
|
||
t.Errorf("MABsForPool empty = %d, want 0", len(got))
|
||
}
|
||
if got := k.AllMABAttests(ctx, "mab-x"); len(got) != 0 {
|
||
t.Errorf("AllMABAttests empty = %d, want 0", len(got))
|
||
}
|
||
|
||
// Issue + read back.
|
||
_, _ = srv.IssueMAB(ctx, &btypes.MsgIssueMAB{
|
||
BondID: "mab-acc-1", PoolID: "pool-1", IssuerStandID: "stand-1",
|
||
PrincipalGrain: 1_000_000, CouponBps: 500, CouponKind: btypes.CouponDenomCoverCall,
|
||
AnnualSurplusAtIssuance: 5_000_000, TermDays: 365, Signer: "stand-1",
|
||
})
|
||
if got := k.AllMABs(ctx); len(got) != 1 {
|
||
t.Errorf("AllMABs = %d, want 1", len(got))
|
||
}
|
||
if got := k.MABsForPool(ctx, "pool-1"); len(got) != 1 {
|
||
t.Errorf("MABsForPool pool-1 = %d, want 1", len(got))
|
||
}
|
||
if got := k.MABsForPool(ctx, "other-pool"); len(got) != 0 {
|
||
t.Errorf("MABsForPool other-pool = %d, want 0", len(got))
|
||
}
|
||
|
||
// Marshal-error path on GetMAB (corrupt bytes in store).
|
||
rawStore := ctx.KVStore(k.StoreKey())
|
||
rawStore.Set([]byte("mab/corrupt"), []byte("not-json"))
|
||
if _, ok := k.GetMAB(ctx, "corrupt"); ok {
|
||
t.Error("GetMAB on corrupt bytes should return false")
|
||
}
|
||
}
|