6d63482c48
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---
355 lines
15 KiB
Go
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
|
|
}
|