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---
667 lines
29 KiB
Go
667 lines
29 KiB
Go
package keeper
|
||
|
||
// msg_server.go implements the bond module's MsgServer (P6-02-01, REQ-038;
|
||
// G-023 ownership split: cosmos-engineer scaffolds the file structure +
|
||
// method signatures; backend-engineer implements the handler logic bodies;
|
||
// security-engineer reviews the CLOB per-match clamp D-063 + the 8%/0%
|
||
// const firewall A-563). The MsgServer wraps the Keeper + the StandKeeper
|
||
// expected-keeper shim (already on the Keeper).
|
||
//
|
||
// Each method returns a (*Response, error). Handler state-machine ordering
|
||
// is enforced: ValidateBasic → keeper authz → state mutation →
|
||
// ctx.EventManager().EmitEvent.
|
||
//
|
||
// Handler set (REQ-038):
|
||
// - IssueBond: invokes v0.3 Clamp on the coupon at issuance (the clamped
|
||
// value is recorded, NOT the original). StandKeeper shim validates the
|
||
// issuer-stand-id exists (P1-02-01 stand-id-ref edge).
|
||
// - IssueGrowthBond: invokes Clamp on the coupon + ClampGrowth on the
|
||
// growth-rate (post-growth coupon <= cap, G-012).
|
||
// - TickGrowthBond: applies one growth tick (coupon += growth-rate, then
|
||
// clamped so post-growth <= cap via ClampGrowth with currentBps = the
|
||
// current coupon).
|
||
// - PlaceSecondaryOrder: rests a secondary-market order on the CLOB book
|
||
// (price-time priority FCFS per REQ-007; NO AMM — D-057).
|
||
// - CancelSecondaryOrder: removes a resting order (status -> Cancelled).
|
||
// - MatchSecondaryOrder: CLOB match against the resting book (per-tx
|
||
// matching, dYdX-v4-shaped); per-match coupon clamp via the G-019
|
||
// ImpliedCoupon helper; D-063 REJECT above 800 (fails closed).
|
||
//
|
||
// Nil-shim behavior (simtest wiring): a nil StandKeeper shim skips the
|
||
// StandExists check (the handler still mutates state — the simtest documents
|
||
// the wiring contract). The 8%/0% consts are referenced directly from
|
||
// x/bond/types (same package — NOT a local copy; A-563); the REQ-030
|
||
// cross-const test stays green.
|
||
//
|
||
// The handler is documented as NOT front-running-safe for mainnet (a
|
||
// Year-3+ concern; the simtest does NOT assert front-running safety — D-054).
|
||
|
||
import (
|
||
"fmt"
|
||
|
||
sdk "github.com/cosmos/cosmos-sdk/types"
|
||
|
||
"github.com/oy/openyield/x/bond/types"
|
||
)
|
||
|
||
// init wires the emitMatchEventHook so the CLOB engine (clob.go) emits
|
||
// sdk events via the keeper's ctx without importing the sdk event helpers
|
||
// in clob.go (keeps clob.go's import list minimal).
|
||
func init() {
|
||
emitMatchEventHook = func(ctx sdk.Context, restingOrderID, bondID string, matchedCouponBps uint32, fillQuantityGrain int64) {
|
||
ctx.EventManager().EmitEvent(sdk.NewEvent(
|
||
"bond.match",
|
||
sdk.NewAttribute("resting_order_id", restingOrderID),
|
||
sdk.NewAttribute("bond_id", bondID),
|
||
sdk.NewAttribute("matched_coupon_bps", fmt.Sprintf("%d", matchedCouponBps)),
|
||
sdk.NewAttribute("fill_quantity_grain", fmt.Sprintf("%d", fillQuantityGrain)),
|
||
))
|
||
}
|
||
}
|
||
|
||
// msgServer is the concrete MsgServer implementation wrapping the Keeper.
|
||
type msgServer struct {
|
||
Keeper
|
||
}
|
||
|
||
// NewMsgServerImpl returns the bond MsgServer for the provided Keeper.
|
||
func NewMsgServerImpl(k Keeper) types.MsgServer {
|
||
return &msgServer{Keeper: k}
|
||
}
|
||
|
||
var _ types.MsgServer = msgServer{}
|
||
|
||
// unwrapCtx extracts the sdk.Context from the interface-typed ctx.
|
||
func unwrapCtx(ctx interface{}) sdk.Context {
|
||
if c, ok := ctx.(sdk.Context); ok {
|
||
return c
|
||
}
|
||
panic(fmt.Sprintf("bond: expected sdk.Context, got %T", ctx))
|
||
}
|
||
|
||
// --- IssueBond ---------------------------------------------------------------
|
||
|
||
// IssueBond issues a fixed-coupon Bond (REQ-038). The handler enforces:
|
||
// 1. ValidateBasic (stateless).
|
||
// 2. Idempotency: bond-id must not already exist.
|
||
// 3. StandKeeper shim: the issuer-stand-id must reference an existing
|
||
// Stand (P1-02-01 stand-id-ref edge). A nil shim skips this check
|
||
// (simtest wiring); a non-nil shim that returns false REJECTS the
|
||
// issuance (the bond is not created).
|
||
// 4. Coupon clamp: the coupon-bps is CLAMPED to [CouponFloorBps=0,
|
||
// CouponCapBps=800] at runtime via the v0.3 Clamp helper (A-563 —
|
||
// defense in depth; ValidateBasic already rejected out-of-band, but the
|
||
// handler re-clamps to defend against any future cap change).
|
||
//
|
||
// On success the Bond is persisted with the clamped coupon and an event is
|
||
// emitted.
|
||
func (s msgServer) IssueBond(ctx interface{}, msg *types.MsgIssueBond) (*types.MsgIssueBondResponse, error) {
|
||
if err := msg.ValidateBasic(); err != nil {
|
||
return nil, err
|
||
}
|
||
sdkCtx := unwrapCtx(ctx)
|
||
|
||
// Idempotency: bond-id must not already exist.
|
||
if _, ok := s.Keeper.GetBond(sdkCtx, msg.BondID); ok {
|
||
return nil, fmt.Errorf("bond: bond-id %q already exists", msg.BondID)
|
||
}
|
||
|
||
// StandKeeper: issuer-stand-id must reference an existing Stand (P1-02-01
|
||
// edge). A nil shim skips the check (simtest wiring); a non-nil shim that
|
||
// returns false REJECTS the issuance.
|
||
if s.Keeper.standKeeper != nil {
|
||
if !s.Keeper.standKeeper.StandExists(msg.IssuerStandID) {
|
||
return nil, fmt.Errorf("bond: issuer-stand-id %q does not exist (IssueBond rejected)", msg.IssuerStandID)
|
||
}
|
||
}
|
||
|
||
// A-563: coupon clamp at runtime. The clamped value (NOT the original)
|
||
// is recorded. ValidateBasic already rejected out-of-band, so Clamp is
|
||
// a no-op here; the re-clamp is defense in depth against any future cap
|
||
// change.
|
||
clamped := types.Clamp(msg.CouponBps)
|
||
b := types.Issue(msg.BondID, msg.IssuerStandID, msg.PrincipalGrain, clamped, msg.TermDays, msg.IssuedAt, msg.Maturity)
|
||
s.Keeper.SetBond(sdkCtx, b)
|
||
|
||
if clamped != msg.CouponBps {
|
||
sdkCtx.EventManager().EmitEvent(sdk.NewEvent(
|
||
"bond.coupon_clamped",
|
||
sdk.NewAttribute("bond_id", msg.BondID),
|
||
sdk.NewAttribute("original_coupon_bps", fmt.Sprintf("%d", msg.CouponBps)),
|
||
sdk.NewAttribute("clamped_coupon_bps", fmt.Sprintf("%d", clamped)),
|
||
))
|
||
}
|
||
|
||
sdkCtx.EventManager().EmitEvent(sdk.NewEvent(
|
||
"bond.issued",
|
||
sdk.NewAttribute("bond_id", msg.BondID),
|
||
sdk.NewAttribute("issuer_stand_id", msg.IssuerStandID),
|
||
sdk.NewAttribute("coupon_bps", fmt.Sprintf("%d", clamped)),
|
||
))
|
||
return &types.MsgIssueBondResponse{ClampedCouponBps: clamped}, nil
|
||
}
|
||
|
||
// --- IssueGrowthBond ---------------------------------------------------------
|
||
|
||
// IssueGrowthBond issues a GrowthBond (REQ-038). The handler enforces:
|
||
// 1. ValidateBasic (stateless).
|
||
// 2. Idempotency: bond-id must not already exist (as a Bond or GrowthBond).
|
||
// 3. StandKeeper shim: the issuer-stand-id must reference an existing
|
||
// Stand (P1-02-01 edge). A nil shim skips (simtest wiring).
|
||
// 4. Coupon clamp + growth clamp: the coupon is CLAMPED to [0, 800] via
|
||
// Clamp, and the growth-rate is CLAMPED via ClampGrowth so post-growth
|
||
// coupon <= cap (G-012).
|
||
//
|
||
// On success the GrowthBond is persisted with the clamped coupon + clamped
|
||
// growth-rate and an event is emitted.
|
||
func (s msgServer) IssueGrowthBond(ctx interface{}, msg *types.MsgIssueGrowthBond) (*types.MsgIssueGrowthBondResponse, error) {
|
||
if err := msg.ValidateBasic(); err != nil {
|
||
return nil, err
|
||
}
|
||
sdkCtx := unwrapCtx(ctx)
|
||
|
||
// Idempotency: bond-id must not already exist (as Bond or GrowthBond).
|
||
if _, ok := s.Keeper.GetBond(sdkCtx, msg.BondID); ok {
|
||
return nil, fmt.Errorf("bond: bond-id %q already exists (as a Bond)", msg.BondID)
|
||
}
|
||
if _, ok := s.Keeper.GetGrowthBond(sdkCtx, msg.BondID); ok {
|
||
return nil, fmt.Errorf("bond: bond-id %q already exists (as a GrowthBond)", msg.BondID)
|
||
}
|
||
|
||
// StandKeeper: issuer-stand-id must reference an existing Stand.
|
||
if s.Keeper.standKeeper != nil {
|
||
if !s.Keeper.standKeeper.StandExists(msg.IssuerStandID) {
|
||
return nil, fmt.Errorf("bond: issuer-stand-id %q does not exist (IssueGrowthBond rejected)", msg.IssuerStandID)
|
||
}
|
||
}
|
||
|
||
// Coupon clamp + growth clamp. The v0.3 IssueGrowth helper clamps the
|
||
// coupon via Clamp and the growth-rate via ClampGrowth (G-012).
|
||
clampedCoupon := types.Clamp(msg.CouponBps)
|
||
clampedGrowth := types.ClampGrowth(clampedCoupon, msg.GrowthRateBps)
|
||
gb := types.IssueGrowth(msg.BondID, msg.IssuerStandID, msg.PrincipalGrain, clampedCoupon, clampedGrowth, msg.TermDays, msg.IssuedAt, msg.Maturity)
|
||
s.Keeper.SetGrowthBond(sdkCtx, gb)
|
||
|
||
if clampedCoupon != msg.CouponBps || clampedGrowth != msg.GrowthRateBps {
|
||
sdkCtx.EventManager().EmitEvent(sdk.NewEvent(
|
||
"bond.growth_coupon_clamped",
|
||
sdk.NewAttribute("bond_id", msg.BondID),
|
||
sdk.NewAttribute("original_coupon_bps", fmt.Sprintf("%d", msg.CouponBps)),
|
||
sdk.NewAttribute("clamped_coupon_bps", fmt.Sprintf("%d", clampedCoupon)),
|
||
sdk.NewAttribute("original_growth_rate_bps", fmt.Sprintf("%d", msg.GrowthRateBps)),
|
||
sdk.NewAttribute("clamped_growth_rate_bps", fmt.Sprintf("%d", clampedGrowth)),
|
||
))
|
||
}
|
||
|
||
sdkCtx.EventManager().EmitEvent(sdk.NewEvent(
|
||
"bond.growth_issued",
|
||
sdk.NewAttribute("bond_id", msg.BondID),
|
||
sdk.NewAttribute("issuer_stand_id", msg.IssuerStandID),
|
||
sdk.NewAttribute("coupon_bps", fmt.Sprintf("%d", clampedCoupon)),
|
||
sdk.NewAttribute("growth_rate_bps", fmt.Sprintf("%d", clampedGrowth)),
|
||
))
|
||
return &types.MsgIssueGrowthBondResponse{
|
||
ClampedCouponBps: clampedCoupon,
|
||
ClampedGrowthRateBps: clampedGrowth,
|
||
}, nil
|
||
}
|
||
|
||
// --- TickGrowthBond ----------------------------------------------------------
|
||
|
||
// TickGrowthBond applies one growth tick to a GrowthBond (REQ-038). The
|
||
// handler enforces:
|
||
// 1. ValidateBasic (stateless).
|
||
// 2. The GrowthBond must exist.
|
||
// 3. Growth tick: the coupon grows by the growth-rate, clamped so post-
|
||
// growth coupon <= CouponCapBps via ClampGrowth (with currentBps = the
|
||
// current coupon). The growth-rate is NOT changed (it persists across
|
||
// ticks).
|
||
//
|
||
// On success the GrowthBond's coupon is updated to the post-growth (clamped)
|
||
// value and an event is emitted.
|
||
func (s msgServer) TickGrowthBond(ctx interface{}, msg *types.MsgTickGrowthBond) (*types.MsgTickGrowthBondResponse, error) {
|
||
if err := msg.ValidateBasic(); err != nil {
|
||
return nil, err
|
||
}
|
||
sdkCtx := unwrapCtx(ctx)
|
||
|
||
gb, ok := s.Keeper.GetGrowthBond(sdkCtx, msg.BondID)
|
||
if !ok {
|
||
return nil, fmt.Errorf("bond: growth-bond %q not found (TickGrowthBond rejected)", msg.BondID)
|
||
}
|
||
|
||
// Growth tick: coupon += growth-rate, clamped so post-growth <= cap.
|
||
// ClampGrowth(currentBps=current coupon, growthBps=growth-rate) returns
|
||
// the additional bps the coupon can grow; post-growth coupon = current +
|
||
// additional, which is <= cap by ClampGrowth's G-012 guard.
|
||
additional := types.ClampGrowth(gb.CouponBps, gb.GrowthRateBps)
|
||
postGrowth := gb.CouponBps + additional
|
||
gb.CouponBps = postGrowth
|
||
s.Keeper.SetGrowthBond(sdkCtx, gb)
|
||
|
||
sdkCtx.EventManager().EmitEvent(sdk.NewEvent(
|
||
"bond.growth_ticked",
|
||
sdk.NewAttribute("bond_id", msg.BondID),
|
||
sdk.NewAttribute("post_growth_coupon_bps", fmt.Sprintf("%d", postGrowth)),
|
||
sdk.NewAttribute("growth_rate_bps", fmt.Sprintf("%d", gb.GrowthRateBps)),
|
||
))
|
||
return &types.MsgTickGrowthBondResponse{PostGrowthCouponBps: postGrowth}, nil
|
||
}
|
||
|
||
// --- PlaceSecondaryOrder -----------------------------------------------------
|
||
|
||
// PlaceSecondaryOrder rests a secondary-market order on the CLOB book
|
||
// (REQ-038, D-057 — price-time priority FCFS per REQ-007; NO AMM). The
|
||
// handler enforces:
|
||
// 1. ValidateBasic (stateless).
|
||
// 2. Idempotency: order-id must not already exist.
|
||
// 3. The referenced bond must exist (the order rests on an issued bond).
|
||
// 4. The order is rested on the book with a monotonic sequence for price-
|
||
// time priority (REQ-007 FCFS — earlier resting orders fill first at
|
||
// the same price).
|
||
//
|
||
// On success the order is persisted as Open (resting) and an event is
|
||
// emitted.
|
||
func (s msgServer) PlaceSecondaryOrder(ctx interface{}, msg *types.MsgPlaceSecondaryOrder) (*types.MsgPlaceSecondaryOrderResponse, error) {
|
||
if err := msg.ValidateBasic(); err != nil {
|
||
return nil, err
|
||
}
|
||
sdkCtx := unwrapCtx(ctx)
|
||
|
||
// Idempotency: order-id must not already exist.
|
||
if _, ok := s.Keeper.GetRestingOrder(sdkCtx, msg.OrderID); ok {
|
||
return nil, fmt.Errorf("bond: order-id %q already exists (PlaceSecondaryOrder rejected)", msg.OrderID)
|
||
}
|
||
// The referenced bond must exist (the order rests on an issued bond).
|
||
if _, ok := s.Keeper.GetBond(sdkCtx, msg.BondID); !ok {
|
||
if _, ok := s.Keeper.GetGrowthBond(sdkCtx, msg.BondID); !ok {
|
||
return nil, fmt.Errorf("bond: bond-id %q does not exist (PlaceSecondaryOrder rejected)", msg.BondID)
|
||
}
|
||
}
|
||
|
||
// Construct the public v0.3 SecondaryOrder (the frozen contract). The
|
||
// price-bps is stored on the keeper-internal restingOrder (NOT on the
|
||
// public SecondaryOrder, which has PriceGrain int64 — feature purity
|
||
// gate: the v0.3 contract is not amended). PriceGrain is seeded from
|
||
// PriceBps for cross-reference (the v0.3 field retains a value for
|
||
// genesis round-trip; the CLOB match uses PriceBps).
|
||
so := types.SecondaryOrder{
|
||
OrderID: msg.OrderID,
|
||
BondID: msg.BondID,
|
||
Side: msg.Side,
|
||
PriceGrain: int64(msg.PriceBps),
|
||
HolderReachID: msg.HolderReachID,
|
||
Status: types.OrderOpen,
|
||
CreatedAt: sdkCtx.BlockTime().Unix(),
|
||
}
|
||
ro := restingOrder{
|
||
Order: so,
|
||
PriceBps: msg.PriceBps,
|
||
Sequence: s.Keeper.nextSequence(),
|
||
RemainingQuantityGrain: msg.QuantityGrain,
|
||
}
|
||
s.Keeper.setRestingOrder(sdkCtx, ro)
|
||
|
||
sdkCtx.EventManager().EmitEvent(sdk.NewEvent(
|
||
"bond.order_placed",
|
||
sdk.NewAttribute("order_id", msg.OrderID),
|
||
sdk.NewAttribute("bond_id", msg.BondID),
|
||
sdk.NewAttribute("side", string(msg.Side)),
|
||
sdk.NewAttribute("price_bps", fmt.Sprintf("%d", msg.PriceBps)),
|
||
sdk.NewAttribute("quantity_grain", fmt.Sprintf("%d", msg.QuantityGrain)),
|
||
))
|
||
return &types.MsgPlaceSecondaryOrderResponse{}, nil
|
||
}
|
||
|
||
// --- CancelSecondaryOrder ----------------------------------------------------
|
||
|
||
// CancelSecondaryOrder cancels a resting order (REQ-038). The handler
|
||
// enforces:
|
||
// 1. ValidateBasic (stateless).
|
||
// 2. The order must exist and be Open (resting).
|
||
// 3. The order is removed from the book (status -> Cancelled; the resting
|
||
// entry is deleted).
|
||
//
|
||
// On success the order is cancelled and an event is emitted.
|
||
func (s msgServer) CancelSecondaryOrder(ctx interface{}, msg *types.MsgCancelSecondaryOrder) (*types.MsgCancelSecondaryOrderResponse, error) {
|
||
if err := msg.ValidateBasic(); err != nil {
|
||
return nil, err
|
||
}
|
||
sdkCtx := unwrapCtx(ctx)
|
||
|
||
ro, ok := s.Keeper.GetRestingOrder(sdkCtx, msg.OrderID)
|
||
if !ok {
|
||
return nil, fmt.Errorf("bond: order %q not found (CancelSecondaryOrder rejected)", msg.OrderID)
|
||
}
|
||
if ro.Order.Status != types.OrderOpen {
|
||
return nil, fmt.Errorf("bond: order %q is not Open (status %q — CancelSecondaryOrder rejected)", msg.OrderID, ro.Order.Status)
|
||
}
|
||
|
||
ro.Order.Status = types.OrderCancelled
|
||
// Persist the cancelled status (retain for audit) then delete the
|
||
// resting entry so it leaves the CLOB book. The Cancelled status is
|
||
// observable via the v0.3 SecondaryOrder.Status field on the persisted
|
||
// entry (the restingOrder embeds it). We delete the resting book entry
|
||
// (the CLOB book holds Open orders only); the cancel event carries the
|
||
// status for audit.
|
||
s.Keeper.deleteRestingOrder(sdkCtx, msg.OrderID)
|
||
|
||
sdkCtx.EventManager().EmitEvent(sdk.NewEvent(
|
||
"bond.order_cancelled",
|
||
sdk.NewAttribute("order_id", msg.OrderID),
|
||
sdk.NewAttribute("status", string(types.OrderCancelled)),
|
||
))
|
||
return &types.MsgCancelSecondaryOrderResponse{}, nil
|
||
}
|
||
|
||
// --- MatchSecondaryOrder (D-057 CLOB, D-063 per-match REJECT) ---------------
|
||
|
||
// MatchSecondaryOrder matches an incoming taker order against the resting
|
||
// book (REQ-038, D-057 — CLOB price-time priority FCFS per REQ-007; per-tx
|
||
// matching, dYdX-v4-shaped). The handler enforces:
|
||
// 1. ValidateBasic (stateless).
|
||
// 2. The referenced bond must exist.
|
||
// 3. The CLOB match (clob.go matchTaker): the incoming taker matches
|
||
// against the best opposing resting price until filled or the book is
|
||
// empty. Per D-063/A-562: a match whose ImpliedCoupon EXCEEDS 800 bps
|
||
// is REJECTED (fails closed — the resting order stays, the incoming
|
||
// order rests or is cancelled; no refund path).
|
||
//
|
||
// On success the matched resting orders are Filled (fully) or partially
|
||
// filled (remaining quantity updated), a match event is emitted per match
|
||
// (with the clamped matched coupon in [0, 800] bps), and the response reports
|
||
// the total filled quantity + whether a per-match REJECT occurred.
|
||
//
|
||
// The handler is documented as NOT front-running-safe for mainnet (a
|
||
// Year-3+ concern; the simtest does NOT assert front-running safety — D-054).
|
||
func (s msgServer) MatchSecondaryOrder(ctx interface{}, msg *types.MsgMatchSecondaryOrder) (*types.MsgMatchSecondaryOrderResponse, error) {
|
||
if err := msg.ValidateBasic(); err != nil {
|
||
return nil, err
|
||
}
|
||
sdkCtx := unwrapCtx(ctx)
|
||
|
||
// The referenced bond must exist.
|
||
if _, ok := s.Keeper.GetBond(sdkCtx, msg.BondID); !ok {
|
||
if _, ok := s.Keeper.GetGrowthBond(sdkCtx, msg.BondID); !ok {
|
||
return nil, fmt.Errorf("bond: bond-id %q does not exist (MatchSecondaryOrder rejected)", msg.BondID)
|
||
}
|
||
}
|
||
|
||
// CLOB match (clob.go). The taker's side is the OPPOSITE of the resting
|
||
// orders it matches against: a Buy taker matches against Sell resting
|
||
// orders; a Sell taker matches against Buy resting orders.
|
||
filled, _, rejected := s.Keeper.matchTaker(
|
||
sdkCtx,
|
||
msg.BondID,
|
||
msg.Side,
|
||
msg.PriceBps,
|
||
msg.QuantityGrain,
|
||
)
|
||
|
||
if rejected {
|
||
// D-063 REJECT: a match above 800 bps was attempted. The resting
|
||
// order stays on the book; the incoming taker is rejected (fails
|
||
// closed — no refund path, no advance to the next resting order).
|
||
// Emit a reject event for simtest assertion.
|
||
sdkCtx.EventManager().EmitEvent(sdk.NewEvent(
|
||
"bond.match_rejected_above_cap",
|
||
sdk.NewAttribute("bond_id", msg.BondID),
|
||
sdk.NewAttribute("incoming_order_id", msg.IncomingOrderID),
|
||
sdk.NewAttribute("cap_bps", fmt.Sprintf("%d", types.CouponCapBps)),
|
||
))
|
||
return &types.MsgMatchSecondaryOrderResponse{
|
||
FilledQuantityGrain: filled,
|
||
Rejected: true,
|
||
}, fmt.Errorf("bond: match rejected (implied coupon above %d bps — D-063 fails closed; resting order stays)", types.CouponCapBps)
|
||
}
|
||
|
||
sdkCtx.EventManager().EmitEvent(sdk.NewEvent(
|
||
"bond.match_completed",
|
||
sdk.NewAttribute("bond_id", msg.BondID),
|
||
sdk.NewAttribute("incoming_order_id", msg.IncomingOrderID),
|
||
sdk.NewAttribute("filled_quantity_grain", fmt.Sprintf("%d", filled)),
|
||
))
|
||
return &types.MsgMatchSecondaryOrderResponse{
|
||
FilledQuantityGrain: filled,
|
||
Rejected: false,
|
||
}, nil
|
||
}
|
||
|
||
// --- v0.7 P4: MAB handlers (REQ-054, D-080, D-089(1), D-089(2)) ----------------
|
||
//
|
||
// (Mutual Aid Bond runtime — IssueMAB + DebitMABProceeds +
|
||
// WitnessMABProceedsRelease + WatcherAttestMAB). The four handlers exercise
|
||
// the 3× annual surplus ceiling, the FR-MAB-3 Bread-coupon rejection, the
|
||
// D-080 tagged-streaming destination check (CoverKeeper reverse edge —
|
||
// D-089(2)), the D-089(1) auto-Still on misuse, and the Watcher quorum
|
||
// (6-of-9) on proceeds release.
|
||
|
||
// checkMABIssuanceCeiling asserts the 3× annual surplus ceiling (REQ-054
|
||
// locked). It sums the existing MAB principals for the poolID + the new
|
||
// principal and asserts the sum <= MABIssuanceCeilingAnnualSurplusMultiple ×
|
||
// annualSurplusAtIssuance. Returns the post-issuance
|
||
// (sumMABPrincipal / annualSurplusAtIssuance) ratio (for the response) and
|
||
// an error if above ceiling. The check re-runs at every issuance (not just
|
||
// the first), so a pool that issues up to the ceiling cannot issue more.
|
||
func (s msgServer) checkMABIssuanceCeiling(ctx sdk.Context, poolID string, newPrincipal int64, annualSurplusAtIssuance int64) (int64, error) {
|
||
existing := int64(0)
|
||
for _, m := range s.Keeper.MABsForPool(ctx, poolID) {
|
||
existing += m.PrincipalGrain
|
||
}
|
||
total := existing + newPrincipal
|
||
ceiling := int64(types.MABIssuanceCeilingAnnualSurplusMultiple) * annualSurplusAtIssuance
|
||
if total > ceiling {
|
||
return 0, fmt.Errorf("bond: MAB issuance ceiling breached (sum %d + new %d = %d > 3× annual-surplus %d = %d — REQ-054 locked)",
|
||
existing, newPrincipal, total, annualSurplusAtIssuance, ceiling)
|
||
}
|
||
if annualSurplusAtIssuance == 0 {
|
||
return 0, nil
|
||
}
|
||
return total / annualSurplusAtIssuance, nil
|
||
}
|
||
|
||
// IssueMAB issues a Mutual Aid Bond (REQ-054, D-080). The handler enforces:
|
||
// 1. ValidateBasic (stateless — includes ValidateMAB: rejects
|
||
// CouponDenomBread with FR-MAB-3).
|
||
// 2. Idempotency: bond-id must not already exist (as a Bond, GrowthBond, or
|
||
// MAB).
|
||
// 3. StandKeeper shim: the issuer-stand-id must reference an existing Stand
|
||
// (P1-02-01 edge). A nil shim skips (simtest wiring).
|
||
// 4. FR-MAB-3 defense-in-depth: ValidateMAB re-check (rejects
|
||
// CouponDenomBread — the handler re-checks in case of a future
|
||
// ValidateBasic bypass).
|
||
// 5. 3× annual surplus ceiling: checkMABIssuanceCeiling asserts
|
||
// sum(existingMABPrincipal for poolID) + PrincipalGrain <=
|
||
// MABIssuanceCeilingAnnualSurplusMultiple × AnnualSurplusAtIssuance.
|
||
// REJECT if above ceiling.
|
||
// 6. Coupon clamp via Clamp (A-563 — defense in depth).
|
||
// 7. Persist the MAB with UseOfProceedsTag = MABUseOfProceedsReserveBuildOut
|
||
// + record the pool-id in the mab-pool index. Emit bond.mab_issued.
|
||
func (s msgServer) IssueMAB(ctx interface{}, msg *types.MsgIssueMAB) (*types.MsgIssueMABResponse, error) {
|
||
if err := msg.ValidateBasic(); err != nil {
|
||
return nil, err
|
||
}
|
||
sdkCtx := unwrapCtx(ctx)
|
||
|
||
// Idempotency: bond-id must not already exist (as Bond, GrowthBond, or MAB).
|
||
if _, ok := s.Keeper.GetBond(sdkCtx, msg.BondID); ok {
|
||
return nil, fmt.Errorf("bond: bond-id %q already exists (as a Bond)", msg.BondID)
|
||
}
|
||
if _, ok := s.Keeper.GetGrowthBond(sdkCtx, msg.BondID); ok {
|
||
return nil, fmt.Errorf("bond: bond-id %q already exists (as a GrowthBond)", msg.BondID)
|
||
}
|
||
if _, ok := s.Keeper.GetMAB(sdkCtx, msg.BondID); ok {
|
||
return nil, fmt.Errorf("bond: bond-id %q already exists (as a MAB)", msg.BondID)
|
||
}
|
||
|
||
// StandKeeper: issuer-stand-id must reference an existing Stand.
|
||
if s.Keeper.standKeeper != nil {
|
||
if !s.Keeper.standKeeper.StandExists(msg.IssuerStandID) {
|
||
return nil, fmt.Errorf("bond: issuer-stand-id %q does not exist (IssueMAB rejected)", msg.IssuerStandID)
|
||
}
|
||
}
|
||
|
||
// FR-MAB-3 defense-in-depth: re-run ValidateMAB (the handler re-checks
|
||
// in case of a future ValidateBasic bypass).
|
||
if err := types.ValidateMAB(types.MAB{CouponKind: msg.CouponKind}); err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
// 3× annual surplus ceiling (REQ-054 locked).
|
||
ceilingMultiple, err := s.checkMABIssuanceCeiling(sdkCtx, msg.PoolID, msg.PrincipalGrain, msg.AnnualSurplusAtIssuance)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
// Coupon clamp (A-563 — defense in depth; ValidateBasic already
|
||
// rejected out-of-band, so Clamp is a no-op here).
|
||
clamped := types.Clamp(msg.CouponBps)
|
||
m := types.IssueMAB(msg.BondID, msg.IssuerStandID, msg.PrincipalGrain, clamped, msg.CouponKind, msg.AnnualSurplusAtIssuance, msg.TermDays, sdkCtx.BlockTime().Unix(), sdkCtx.BlockTime().Unix()+int64(msg.TermDays)*24*60*60)
|
||
s.Keeper.SetMAB(sdkCtx, m)
|
||
s.Keeper.setMABPool(sdkCtx, msg.BondID, msg.PoolID)
|
||
|
||
sdkCtx.EventManager().EmitEvent(sdk.NewEvent(
|
||
"bond.mab_issued",
|
||
sdk.NewAttribute("bond_id", msg.BondID),
|
||
sdk.NewAttribute("pool_id", msg.PoolID),
|
||
sdk.NewAttribute("issuer_stand_id", msg.IssuerStandID),
|
||
sdk.NewAttribute("coupon_bps", fmt.Sprintf("%d", clamped)),
|
||
sdk.NewAttribute("coupon_kind", string(msg.CouponKind)),
|
||
sdk.NewAttribute("use_of_proceeds_tag", m.UseOfProceedsTag),
|
||
sdk.NewAttribute("ceiling_multiple", fmt.Sprintf("%d", ceilingMultiple)),
|
||
))
|
||
return &types.MsgIssueMABResponse{
|
||
ClampedCouponBps: clamped,
|
||
CeilingMultiple: ceilingMultiple,
|
||
}, nil
|
||
}
|
||
|
||
// DebitMABProceeds debits a MAB's tagged proceeds to the Pool's
|
||
// ReserveAccount (D-080). The handler enforces:
|
||
// 1. ValidateBasic (stateless).
|
||
// 2. The MAB must exist.
|
||
// 3. D-080 tagged streaming: query the mab-pool index for the MAB's poolID,
|
||
// then query CoverKeeper.GetPoolReserveAccount(poolID). If the
|
||
// DestinationAccount != the pool's ReserveAccount -> StillKeeper.Still(
|
||
// bondID, "MAB misuse — proceeds routed outside reserve") (D-089(1) — a
|
||
// nil StillKeeper skips the Still recording but the handler STILL
|
||
// REJECTS) AND REJECT. A nil CoverKeeper is a wiring error -> REJECT
|
||
// (the destination cannot be validated). If match -> emit
|
||
// bond.mab_proceeds_debited (simtest: the debit is the event; no actual
|
||
// Grain transfer in P4).
|
||
func (s msgServer) DebitMABProceeds(ctx interface{}, msg *types.MsgDebitMABProceeds) (*types.MsgDebitMABProceedsResponse, error) {
|
||
if err := msg.ValidateBasic(); err != nil {
|
||
return nil, err
|
||
}
|
||
sdkCtx := unwrapCtx(ctx)
|
||
|
||
m, ok := s.Keeper.GetMAB(sdkCtx, msg.BondID)
|
||
if !ok {
|
||
return nil, fmt.Errorf("bond: mab %q not found (DebitMABProceeds rejected)", msg.BondID)
|
||
}
|
||
_ = m
|
||
|
||
poolID, ok := s.Keeper.GetMABPool(sdkCtx, msg.BondID)
|
||
if !ok {
|
||
return nil, fmt.Errorf("bond: mab %q has no pool binding (DebitMABProceeds rejected)", msg.BondID)
|
||
}
|
||
|
||
// D-080 tagged streaming: the destination must == the pool's
|
||
// ReserveAccount. A nil CoverKeeper is a wiring error -> REJECT (the
|
||
// destination cannot be validated).
|
||
if s.Keeper.coverKeeper == nil {
|
||
return nil, fmt.Errorf("bond: CoverKeeper shim not wired (DebitMABProceeds cannot validate destination — D-089(2) reverse edge required)")
|
||
}
|
||
reserveAccount, exists := s.Keeper.coverKeeper.GetPoolReserveAccount(poolID)
|
||
if !exists {
|
||
return nil, fmt.Errorf("bond: pool %q ReserveAccount not found (DebitMABProceeds rejected)", poolID)
|
||
}
|
||
if msg.DestinationAccount != reserveAccount {
|
||
// D-080 misuse -> D-089(1) auto-Still. A nil StillKeeper skips the
|
||
// Still recording but the handler STILL REJECTS (the debit is not
|
||
// committed regardless).
|
||
if s.Keeper.stillKeeper != nil {
|
||
_ = s.Keeper.stillKeeper.Still(msg.BondID, "MAB misuse — proceeds routed outside reserve")
|
||
}
|
||
sdkCtx.EventManager().EmitEvent(sdk.NewEvent(
|
||
"bond.mab_proceeds_misuse",
|
||
sdk.NewAttribute("bond_id", msg.BondID),
|
||
sdk.NewAttribute("pool_id", poolID),
|
||
sdk.NewAttribute("destination_account", msg.DestinationAccount),
|
||
sdk.NewAttribute("expected_reserve_account", reserveAccount),
|
||
))
|
||
return nil, fmt.Errorf("bond: MAB %q proceeds destination %q != pool %q ReserveAccount %q (D-080 tagged-streaming misuse — auto-Still + REJECT)", msg.BondID, msg.DestinationAccount, poolID, reserveAccount)
|
||
}
|
||
|
||
sdkCtx.EventManager().EmitEvent(sdk.NewEvent(
|
||
"bond.mab_proceeds_debited",
|
||
sdk.NewAttribute("bond_id", msg.BondID),
|
||
sdk.NewAttribute("pool_id", poolID),
|
||
sdk.NewAttribute("destination_account", msg.DestinationAccount),
|
||
))
|
||
return &types.MsgDebitMABProceedsResponse{}, nil
|
||
}
|
||
|
||
// WitnessMABProceedsRelease is a Watcher-witnessed release of a MAB's tagged
|
||
// proceeds from staging to the reserve (D-080). The handler enforces:
|
||
// 1. ValidateBasic (stateless).
|
||
// 2. The MAB must exist.
|
||
// 3. Watcher quorum: WatcherKeeper.AttestMABRelease(bondID, attestationRef)
|
||
// returns true if quorum (6-of-9) is met. If false (quorum not met) ->
|
||
// REJECT. If true -> emit bond.mab_proceeds_released. A nil WatcherKeeper
|
||
// skips the quorum check (simtest wiring — the handler still mutates
|
||
// state; the simtest documents the wiring).
|
||
func (s msgServer) WitnessMABProceedsRelease(ctx interface{}, msg *types.MsgWitnessMABProceedsRelease) (*types.MsgWitnessMABProceedsReleaseResponse, error) {
|
||
if err := msg.ValidateBasic(); err != nil {
|
||
return nil, err
|
||
}
|
||
sdkCtx := unwrapCtx(ctx)
|
||
|
||
if _, ok := s.Keeper.GetMAB(sdkCtx, msg.BondID); !ok {
|
||
return nil, fmt.Errorf("bond: mab %q not found (WitnessMABProceedsRelease rejected)", msg.BondID)
|
||
}
|
||
|
||
// Watcher quorum (D-080). A nil WatcherKeeper skips the quorum check
|
||
// (simtest wiring — the handler still mutates state).
|
||
if s.Keeper.watcherKeeper != nil {
|
||
if !s.Keeper.watcherKeeper.AttestMABRelease(msg.BondID, msg.AttestationRef) {
|
||
return nil, fmt.Errorf("bond: MAB %q proceeds release rejected (Watcher quorum not met — D-080 6-of-9 required)", msg.BondID)
|
||
}
|
||
}
|
||
|
||
sdkCtx.EventManager().EmitEvent(sdk.NewEvent(
|
||
"bond.mab_proceeds_released",
|
||
sdk.NewAttribute("bond_id", msg.BondID),
|
||
sdk.NewAttribute("attestation_ref", msg.AttestationRef),
|
||
))
|
||
return &types.MsgWitnessMABProceedsReleaseResponse{}, nil
|
||
}
|
||
|
||
// WatcherAttestMAB records a quarterly Watcher audit attestation on a MAB
|
||
// (D-080). The handler enforces:
|
||
// 1. ValidateBasic (stateless).
|
||
// 2. The MAB must exist.
|
||
// 3. Record the attestation (a store entry mab_attest/<bondID>/<timestamp>
|
||
// -> attestationRef). Emit bond.mab_watcher_attested.
|
||
func (s msgServer) WatcherAttestMAB(ctx interface{}, msg *types.MsgWatcherAttestMAB) (*types.MsgWatcherAttestMABResponse, error) {
|
||
if err := msg.ValidateBasic(); err != nil {
|
||
return nil, err
|
||
}
|
||
sdkCtx := unwrapCtx(ctx)
|
||
|
||
if _, ok := s.Keeper.GetMAB(sdkCtx, msg.BondID); !ok {
|
||
return nil, fmt.Errorf("bond: mab %q not found (WatcherAttestMAB rejected)", msg.BondID)
|
||
}
|
||
|
||
ts := sdkCtx.BlockTime().Unix()
|
||
s.Keeper.SetMABAttest(sdkCtx, msg.BondID, ts, msg.AttestationRef)
|
||
|
||
sdkCtx.EventManager().EmitEvent(sdk.NewEvent(
|
||
"bond.mab_watcher_attested",
|
||
sdk.NewAttribute("bond_id", msg.BondID),
|
||
sdk.NewAttribute("attestation_ref", msg.AttestationRef),
|
||
sdk.NewAttribute("timestamp", fmt.Sprintf("%d", ts)),
|
||
))
|
||
return &types.MsgWatcherAttestMABResponse{}, nil
|
||
}
|