Files
openyield/x/cover/keeper/msg_server.go
T
cloudinit-bot 6d63482c48 feat(cover): P1 v0.7 Cover Pool foundation + Anti-Crowding-Out firewall
Add the new x/cover module (Cover Pool runtime) implementing P1 of the
v0.7 milestone: CoverPool/CoverFeeTag/CoverCall types with the 4 GRILL-
ratified locked consts (CoverReserveFloorAnnualContribX=1.5,
CoverReserveCeilingAnnualContribX=2.5, CoverStandingGateTrusted=4.0,
CoverStandingGatePreferred=4.5), the 8-category/3-phase CoverCategory
enum with D-086 FactoryAllowedPhases=[Phase2]-only default, three Msg*
types (LaunchCoverPool/RouteCoverFee/FileCoverCall) with full sdk.Msg
impls, store-backed Keeper with 4 G-003 expected-keeper shims
(StandingKeeper/WatcherKeeper/BondKeeper/StillKeeper), and three
handlers enforcing the D-077 Standing gate, D-086 category phase check,
REQ-047 reserve floor + below-floor auto-pause (D-089(1) Still
invocation), and REQ-050 category-tag match.

Add the x/cover/firewall subpackage (Anti-Crowding-Out firewall, D-079/
D-088): a stdlib-only leaf checker enforcing RightNoTaxOnPersonalStash
by rejecting Cover-Fee routing to the Root-Pool operating-expenses
destination (defense in depth with the lexicon meta-test).

Add the lexicon_meta_cover meta-test (4th lexicon firewall, D-088):
scans x/cover/**/*.go for both lexicon.FindBannedTerm (10 project-wide
terms) AND lexicon.FindCoverBannedTerm (4 Cover-specific terms), with
G-013 walk-coverage + G-009 self-test tables.

Add lexicon.CoverBannedTerms()/FindCoverBannedTerm()/
SyntheticCoverBannedStrings() helpers (additive to the existing
project-wide BannedTerms — no changes to existing helpers).

Apply D-088(3) optional doc-fix: replace 'insurance-like' with
'Cover-like' in x/pact/types docstrings.

Coverage: x/cover/types 97.8%, x/cover/keeper 94.1%, x/cover/firewall
100.0%. go.mod/go.sum unchanged (G-006/G-028). All existing tests pass.

REQs: REQ-046, REQ-047, REQ-049, REQ-050

---ci---
project: oy
phase: 1
milestone: v0.7
status: execute
---/ci---
2026-08-19 01:52:58 +00:00

355 lines
15 KiB
Go

package keeper
// msg_server.go implements the cover module's MsgServer (REQ-046, REQ-047,
// REQ-049, REQ-050, REQ-055, D-077, D-079, D-086, D-088, D-089). The
// MsgServer wraps the Keeper + the four expected-keeper shims (already on
// the Keeper: StandingKeeper, WatcherKeeper, BondKeeper, StillKeeper).
//
// Each method returns a (*Response, error). Handler state-machine ordering
// is enforced: ValidateBasic -> handler authz/gate -> state mutation ->
// ctx.EventManager().EmitEvent.
//
// Handler set:
// - LaunchCoverPool: D-086 category phase check + D-077 Standing gate +
// reserve floor + Watcher attestation; persists the CoverPool.
// - RouteCoverFee: D-079 Anti-Crowding-Out firewall + category-tag match +
// below-floor auto-pause + StillKeeper invocation; emits the routing
// event.
// - FileCoverCall: P1 scaffold — persists the CoverCall + emits an event;
// P4 adds the Voucher adjudication + no-self-adjudication + slashing.
//
// Nil-shim behavior (simtest wiring): a nil StandingKeeper skips the D-077
// gate (the handler still mutates state — the simtest documents the wiring
// contract); a nil WatcherKeeper skips the launch attestation; a nil
// StillKeeper skips the auto-Still recording (the pool's PoolPaused flag is
// still set, just the Still event is not recorded in a still store); a nil
// BondKeeper is the P1 default (the P4 handler will reject a nil shim as a
// wiring error when the P4 MAB check is wired).
import (
"fmt"
sdk "github.com/cosmos/cosmos-sdk/types"
"github.com/oy/openyield/x/cover/firewall"
"github.com/oy/openyield/x/cover/types"
)
// msgServer is the concrete MsgServer implementation wrapping the Keeper.
type msgServer struct {
Keeper
}
// NewMsgServerImpl returns the cover 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("cover: expected sdk.Context, got %T", ctx))
}
// gateForCategory returns the locked Standing gate floor for a Cover
// category (D-077). HealthMCS demands the Preferred gate (4.5); Travel +
// IncomePause use the Trusted gate (4.0) as the default. Other Phase2
// categories (none in P1) would also use the Trusted gate; the handler
// rejects out-of-phase categories BEFORE reaching this helper (the D-086
// phase check runs first), so this helper is only called for in-phase
// categories.
func gateForCategory(cat types.CoverCategory) float64 {
if cat == types.CatHealthMCS {
return types.CoverStandingGatePreferred
}
return types.CoverStandingGateTrusted
}
// bucketMeetsGate reports whether a Standing bucket string + score meet the
// locked gate floor (D-077). The bucket string is one of "New", "Trusted",
// "Preferred", "Top", "Slashed" (cross-doc to x/standing.StandingBucket).
// "Trusted" or higher ("Preferred", "Top") meets a Trusted gate; "Preferred"
// or higher ("Top") meets a Preferred gate. The score is a secondary check
// (defense in depth: the bucket is the primary gate, the score confirms).
// "New" or "Slashed" never meets either gate.
func bucketMeetsGate(bucket string, score float64, gate float64) bool {
switch bucket {
case "Top":
return true
case "Preferred":
return gate <= types.CoverStandingGatePreferred && score >= gate
case "Trusted":
return gate <= types.CoverStandingGateTrusted && score >= gate
}
return false
}
// --- LaunchCoverPool ----------------------------------------------------------
// LaunchCoverPool launches a Cover Pool (REQ-046, REQ-047, REQ-049, D-077,
// D-086). The handler enforces:
// 1. ValidateBasic (stateless — floor check on ReserveAnnualContribRatio).
// 2. Idempotency: pool-id must not already exist.
// 3. D-086 category phase check: each category's phase must be in the
// pool's FactoryAllowedPhases (P1 default = [Phase2] only — so only
// Travel/HealthMCS/IncomePause allowed in P1; Phase3/Phase4 categories
// REJECTED).
// 4. D-090(3) dual gate check: the Params.PoolStandingGate >= the protocol
// minimum (CoverStandingGateTrusted) — a pool may tighten the gate but
// never lower it.
// 5. D-077 Standing gate: for each category, query
// StandingKeeper.GetStandingBucket(hostReachID, category). Compare the
// returned bucket + score against the locked gate (Trusted for Travel/
// IncomePause; Preferred for HealthMCS). A nil StandingKeeper skips
// the gate check (simtest wiring).
// 6. Reserve floor re-check (REQ-047 defense in depth):
// ReserveAnnualContribRatio >= CoverReserveFloorAnnualContribX.
// 7. Watcher attestation (REQ-046): WatcherKeeper.Attest(poolID, payload).
// A nil WatcherKeeper skips (simtest).
// 8. Persist the CoverPool (PoolPaused = false, FactoryAllowedPhases +
// PoolStandingGate from Params).
//
// On success an event is emitted.
func (s msgServer) LaunchCoverPool(ctx interface{}, msg *types.MsgLaunchCoverPool) (*types.MsgLaunchCoverPoolResponse, error) {
if err := msg.ValidateBasic(); err != nil {
return nil, err
}
sdkCtx := unwrapCtx(ctx)
// Idempotency: pool-id must not already exist.
if _, ok := s.Keeper.GetCoverPool(sdkCtx, msg.PoolID); ok {
return nil, fmt.Errorf("cover: pool %q already exists", msg.PoolID)
}
// Load the Params (P1: DefaultParams — the live Params store is deferred;
// the handler uses DefaultParams for the FactoryAllowedPhases + the
// PoolStandingGate floor). A future P2 will load the Params from the
// params store; P1 ships the default.
params := types.DefaultParams()
if err := params.Validate(); err != nil {
return nil, fmt.Errorf("cover: params invalid: %w", err)
}
// D-086 category phase check: each category's phase must be in the
// FactoryAllowedPhases (P1 default = [Phase2] only).
allowed := make(map[types.CoverCategoryPhase]bool, len(params.FactoryAllowedPhases))
for _, ph := range params.FactoryAllowedPhases {
allowed[ph] = true
}
for _, cat := range msg.Categories {
ph := types.CoverCategoryPhaseFor(cat)
if ph == "" {
return nil, fmt.Errorf("cover: unknown category %q (D-086 phase check)", cat)
}
if !allowed[ph] {
return nil, fmt.Errorf("cover: category %q is phase %q, not in FactoryAllowedPhases %v (D-086: P1 allows %v only)", cat, ph, params.FactoryAllowedPhases, params.FactoryAllowedPhases)
}
}
// D-077 Standing gate: for each category, query the host's Standing
// bucket + score and compare against the locked gate. A nil
// StandingKeeper skips the gate check (simtest wiring — documented).
if s.Keeper.standingKeeper != nil {
for _, cat := range msg.Categories {
gate := gateForCategory(cat)
bucket, score, err := s.Keeper.standingKeeper.GetStandingBucket(msg.HostReachID, string(cat))
if err != nil {
return nil, fmt.Errorf("cover: Standing lookup for host %q category %q: %w (D-077 gate)", msg.HostReachID, cat, err)
}
if !bucketMeetsGate(bucket, score, gate) {
return nil, fmt.Errorf("cover: host %q Standing bucket %q score %.2f for category %q does not meet the locked gate %.2f (D-077)", msg.HostReachID, bucket, score, cat, gate)
}
}
}
// Reserve floor re-check (defense in depth — ValidateBasic already
// checked this statelessly).
if msg.ReserveAnnualContribRatio < types.CoverReserveFloorAnnualContribX {
return nil, fmt.Errorf("cover: ReserveAnnualContribRatio %.2f < floor %.2f (REQ-047 handler re-check)", msg.ReserveAnnualContribRatio, types.CoverReserveFloorAnnualContribX)
}
// Watcher attestation (REQ-046). A nil WatcherKeeper skips (simtest).
if s.Keeper.watcherKeeper != nil {
payload := []byte(fmt.Sprintf("cover.launch:%s:%s:%v:%.2f", msg.PoolID, msg.HostReachID, msg.Categories, msg.ReserveAnnualContribRatio))
if _, err := s.Keeper.watcherKeeper.Attest(msg.PoolID, payload); err != nil {
return nil, fmt.Errorf("cover: Watcher attestation for pool %q: %w (REQ-046)", msg.PoolID, err)
}
}
pool := types.CoverPool{
PoolID: msg.PoolID,
HostReachID: msg.HostReachID,
Categories: msg.Categories,
ReserveAnnualContribRatio: msg.ReserveAnnualContribRatio,
ReserveAccount: msg.ReserveAccount,
PoolPaused: false,
CharterHash: msg.CharterHash,
FactoryAllowedPhases: params.FactoryAllowedPhases,
PoolStandingGate: params.PoolStandingGate,
CreatedAt: sdkCtx.BlockHeight(),
}
s.Keeper.SetCoverPool(sdkCtx, pool)
sdkCtx.EventManager().EmitEvent(sdk.NewEvent(
"cover.pool_launched",
sdk.NewAttribute("pool_id", msg.PoolID),
sdk.NewAttribute("host_reach_id", msg.HostReachID),
sdk.NewAttribute("reserve_annual_contrib_ratio", fmt.Sprintf("%.2f", msg.ReserveAnnualContribRatio)),
))
return &types.MsgLaunchCoverPoolResponse{}, nil
}
// --- RouteCoverFee ------------------------------------------------------------
// RouteCoverFee routes a Cover-Fee into a pool's reserve (REQ-050, D-079
// firewall, REQ-047 below-floor auto-pause). The handler enforces:
// 1. ValidateBasic (stateless).
// 2. Load the CoverPool. If not found, REJECT.
// 3. Below-floor pause check (REQ-047): if pool.PoolPaused == true, REJECT
// with "pool paused (below reserve floor)".
// 4. D-079 Anti-Crowding-Out firewall: call
// firewall.CheckCoverFeeRouting(pool.ReserveAccount). If the firewall
// rejects (the destination is NOT permitted — e.g. the pool's
// ReserveAccount is the Root-Pool operating-expenses holder), REJECT.
// 5. Category-tag validation (REQ-050, FR-COVER-11): the CategoryTag must
// match one of the Pool's Categories. Mismatch -> REJECT.
// 6. Reserve floor check (REQ-047): if pool.ReserveAnnualContribRatio <
// floor, REJECT the routing AND set pool.PoolPaused = true (auto-pause)
// AND invoke StillKeeper.Still(poolID, "below reserve floor") (D-089(1)
// — nil StillKeeper skips). Persist the paused pool. Emit
// cover.pool_below_floor.
// 7. Otherwise: emit cover.cover_fee_routed (the routing is the event; the
// reserve balance update is a simtest-grade stub).
func (s msgServer) RouteCoverFee(ctx interface{}, msg *types.MsgRouteCoverFee) (*types.MsgRouteCoverFeeResponse, error) {
if err := msg.ValidateBasic(); err != nil {
return nil, err
}
sdkCtx := unwrapCtx(ctx)
pool, ok := s.Keeper.GetCoverPool(sdkCtx, msg.PoolID)
if !ok {
return nil, fmt.Errorf("cover: pool %q not found (RouteCoverFee rejected)", msg.PoolID)
}
// Below-floor pause check: a paused pool rejects all routing.
if pool.PoolPaused {
return nil, fmt.Errorf("cover: pool %q paused (below reserve floor) — routing rejected", msg.PoolID)
}
// D-079 Anti-Crowding-Out firewall: the destination (the pool's
// ReserveAccount) must be a permitted routing destination. The firewall
// is the second-layer defense (the first layer is the handler's own
// destination-match check — the destination IS pool.ReserveAccount by
// construction; the firewall catches a pool misconfigured to route to
// the Root-Pool operating-expenses holder).
if err := firewall.CheckCoverFeeRouting(pool.ReserveAccount); err != nil {
return nil, fmt.Errorf("cover: %w (pool %q ReserveAccount %q)", err, msg.PoolID, pool.ReserveAccount)
}
// Category-tag validation (REQ-050, FR-COVER-11): the CategoryTag must
// match one of the Pool's Categories.
tagMatched := false
for _, cat := range pool.Categories {
if string(cat) == msg.CategoryTag {
tagMatched = true
break
}
}
if !tagMatched {
return nil, fmt.Errorf("cover: CategoryTag %q does not match any of pool %q categories %v (REQ-050)", msg.CategoryTag, msg.PoolID, pool.Categories)
}
// Reserve floor check (REQ-047): if the pool's ReserveAnnualContribRatio
// is below the floor, REJECT the routing AND auto-pause the pool AND
// invoke StillKeeper.Still (D-089(1)). A nil StillKeeper skips the
// Still recording (the pool's PoolPaused flag is still set).
if pool.ReserveAnnualContribRatio < types.CoverReserveFloorAnnualContribX {
pool.PoolPaused = true
s.Keeper.SetCoverPool(sdkCtx, pool)
if s.Keeper.stillKeeper != nil {
if err := s.Keeper.stillKeeper.Still(msg.PoolID, "below reserve floor"); err != nil {
return nil, fmt.Errorf("cover: Still invocation for pool %q (below reserve floor): %w (D-089(1))", msg.PoolID, err)
}
}
sdkCtx.EventManager().EmitEvent(sdk.NewEvent(
"cover.pool_below_floor",
sdk.NewAttribute("pool_id", msg.PoolID),
sdk.NewAttribute("reserve_annual_contrib_ratio", fmt.Sprintf("%.2f", pool.ReserveAnnualContribRatio)),
sdk.NewAttribute("floor", fmt.Sprintf("%.2f", types.CoverReserveFloorAnnualContribX)),
))
return nil, fmt.Errorf("cover: pool %q below reserve floor (%.2f < %.2f) — routing rejected, pool auto-paused (REQ-047)", msg.PoolID, pool.ReserveAnnualContribRatio, types.CoverReserveFloorAnnualContribX)
}
// Success: the routing is the event (the reserve balance update is a
// simtest-grade stub — P2 may add a CoverFeeRouting record).
sdkCtx.EventManager().EmitEvent(sdk.NewEvent(
"cover.cover_fee_routed",
sdk.NewAttribute("pool_id", msg.PoolID),
sdk.NewAttribute("category_tag", msg.CategoryTag),
sdk.NewAttribute("grain_amount", fmt.Sprintf("%d", msg.GrainAmount)),
sdk.NewAttribute("reserve_account", pool.ReserveAccount),
))
return &types.MsgRouteCoverFeeResponse{}, nil
}
// --- FileCoverCall ------------------------------------------------------------
// FileCoverCall files a Cover Call against a pool's category (REQ-055 P1
// scaffold — the Voucher adjudication lands in P4). The handler enforces:
// 1. ValidateBasic (stateless).
// 2. Load the CoverPool. If not found, REJECT.
// 3. The category must match one of the Pool's Categories.
// 4. Persist the CoverCall. Emit cover.cover_call_filed.
//
// P4 adds: the Voucher assignment + no-self-adjudication (the
// ClaimantReachID must not be the adjudicating Voucher) + the MAB misuse
// auto-Still (D-089(1) — a Voucher whose MAB is slashed triggers the
// StillKeeper).
func (s msgServer) FileCoverCall(ctx interface{}, msg *types.MsgFileCoverCall) (*types.MsgFileCoverCallResponse, error) {
if err := msg.ValidateBasic(); err != nil {
return nil, err
}
sdkCtx := unwrapCtx(ctx)
pool, ok := s.Keeper.GetCoverPool(sdkCtx, msg.PoolID)
if !ok {
return nil, fmt.Errorf("cover: pool %q not found (FileCoverCall rejected)", msg.PoolID)
}
// The category must match one of the Pool's Categories.
catMatched := false
for _, cat := range pool.Categories {
if cat == msg.Category {
catMatched = true
break
}
}
if !catMatched {
return nil, fmt.Errorf("cover: category %q does not match any of pool %q categories %v", msg.Category, msg.PoolID, pool.Categories)
}
call := types.CoverCall{
CallID: msg.CallID,
PoolID: msg.PoolID,
ClaimantReachID: msg.ClaimantReachID,
Category: msg.Category,
AmountGrain: msg.AmountGrain,
FiledAt: sdkCtx.BlockHeight(),
}
s.Keeper.SetCoverCall(sdkCtx, call)
sdkCtx.EventManager().EmitEvent(sdk.NewEvent(
"cover.cover_call_filed",
sdk.NewAttribute("call_id", msg.CallID),
sdk.NewAttribute("pool_id", msg.PoolID),
sdk.NewAttribute("claimant_reach_id", msg.ClaimantReachID),
sdk.NewAttribute("category", string(msg.Category)),
sdk.NewAttribute("amount_grain", fmt.Sprintf("%d", msg.AmountGrain)),
))
return &types.MsgFileCoverCallResponse{}, nil
}