907dc66d12
P2 of v0.7 extends x/cover with Cover-Charter + Pool governance hybrid + category staging + the Anti-Capture Bill of Rights types (D-090(1) temporal-gap fix — Bill of Rights types land HERE, not P5, so the dual firewall is in place before any Charter can be signed). New (x/cover/types/rights.go): RightID type + 13 Right* consts + AntiCaptureBillOfRightsCount=13 locked const + 13 Waivable* bool consts (all false) + RightIsWaivable() always false + AllRights()/AllWaivableFlags(). New structs: CoverCharter (REQ-052) + CharterAmendment (7-day cooling) + PoolCouncil (REQ-062 — 3 Masons + Watcher observer; NO Anchor/MAB seat) + CoverCallVote (majority requires Watcher observer present). CoverPool extended with CharterRef + CouncilRef. D-086 DefaultParams [Phase2] -> [Phase2, Phase3, Phase4]. New Msg*: MsgSignCoverCharter (D-090(1) WaivedRights gate at ValidateBasic — mirrors MissionLockAmendmentRejected D-064), MsgAmendCoverCharter, MsgElectPoolMason, MsgVoteCoverCall, MsgAmendPoolStandingGate (D-090(3) dual check: floor at ValidateBasic + handler), MsgEscalateReserveCeiling (12-month age check). New handlers: SignCoverCharter, AmendCoverCharter (Proposed + ProposedAt), ElectPoolMason (max 3), VoteCoverCall (Yes requires observer), AmendPoolStandingGate (D-090(3) re-check), EscalateReserveCeiling, CoolCharterAmendment + RatifyCharterAmendment lifecycle helpers. New stores: charter/ council/ vote/ amendment/ + SetParamsOverride/Params(). Simtest cases (a)-(h): Charter signing + D-090(1) WaivedRights reject + 7-day cooling + election + vote observer + D-086 out-of-phase + ceiling escalation + D-090(3) below-floor reject. Lexicon: rights.go + msg_charter.go lexicon-clean (initial 'policy' hit fixed -> 'invariant'). .lexicon_fixture SkipDir guard added to 3 lexicon walks (fixes pre-existing cross-package test-isolation race). G-003/G-006/G-028/G-024 intact. go.mod/go.sum diff EMPTY. Coverage: types 98.9%, keeper 95.1%, firewall 100.0%. REQs: REQ-048, REQ-052, REQ-062, REQ-065 (D-090(1) Bill of Rights types for REQ-056 land here; P5 adds the ceremony) ---ci--- project: oy phase: 2 milestone: v0.7 status: execute ---/ci---
707 lines
30 KiB
Go
707 lines
30 KiB
Go
package keeper
|
|
|
|
// msg_server.go implements the cover module's MsgServer (REQ-046, REQ-047,
|
|
// REQ-049, REQ-050, REQ-052, REQ-055, REQ-056, REQ-062, REQ-048, D-077,
|
|
// D-079, D-086, D-088, D-089, D-090). 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.
|
|
//
|
|
// P1 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.
|
|
//
|
|
// P2 handler set:
|
|
// - SignCoverCharter: D-090(1) Bill of Rights gate (ValidateBasic) +
|
|
// idempotency + Watcher attestation; persists the CoverCharter.
|
|
// - AmendCoverCharter: creates a CharterAmendment with Status=Proposed;
|
|
// the 7-day cooling is enforced by CoolCharterAmendment /
|
|
// RatifyCharterAmendment (keeper helpers).
|
|
// - ElectPoolMason: loads/creates the PoolCouncil + adds the Mason (max
|
|
// 3 — a 4th is REJECTED).
|
|
// - VoteCoverCall: loads the CoverCall + Watcher-observer-present check
|
|
// for a CallVoteYes; persists the CoverCallVote.
|
|
// - AmendPoolStandingGate: D-090(3) dual check (ValidateBasic + handler
|
|
// re-check) + updates the pool's PoolStandingGate.
|
|
// - EscalateReserveCeiling: 12-month age check + Watcher attestation +
|
|
// sets the pool's reserve target to CoverReserveCeilingAnnualContribX.
|
|
//
|
|
// 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/charter/escalation
|
|
// 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 (the effective Params: the override if set, else
|
|
// DefaultParams). The D-086 simtest case (f) uses the override to
|
|
// restrict FactoryAllowedPhases to [Phase2, Phase3] only and reject a
|
|
// Phase4 launch. A future P2+ will load the Params from the params
|
|
// store; for now the keeper holds the override.
|
|
params := s.Keeper.Params()
|
|
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.BlockTime().Unix(),
|
|
}
|
|
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
|
|
}
|
|
|
|
// --- P2: SignCoverCharter -----------------------------------------------------
|
|
|
|
// SignCoverCharter signs a Cover-Charter for a Pool (REQ-052, REQ-056,
|
|
// D-090(1)). The handler enforces:
|
|
// 1. ValidateBasic (stateless — includes the D-090(1) Bill of Rights
|
|
// gate: any WaivedRights element REJECTS the signing).
|
|
// 2. Idempotency: CharterID must not already exist.
|
|
// 3. The referenced Pool must exist (the charter binds to a pool).
|
|
// 4. WatcherKeeper.Attest on the charter witness hash (a nil WatcherKeeper
|
|
// skips; an empty WatcherWitnessHash skips).
|
|
// 5. Persist the CoverCharter + link the pool's CharterRef.
|
|
// 6. Emit cover.charter_signed.
|
|
func (s msgServer) SignCoverCharter(ctx interface{}, msg *types.MsgSignCoverCharter) (*types.MsgSignCoverCharterResponse, error) {
|
|
if err := msg.ValidateBasic(); err != nil {
|
|
return nil, err
|
|
}
|
|
sdkCtx := unwrapCtx(ctx)
|
|
|
|
// Idempotency: charter-id must not already exist.
|
|
if _, ok := s.Keeper.GetCoverCharter(sdkCtx, msg.CharterID); ok {
|
|
return nil, fmt.Errorf("cover: charter %q already exists", msg.CharterID)
|
|
}
|
|
|
|
// The referenced pool must exist (the charter binds to a pool).
|
|
pool, ok := s.Keeper.GetCoverPool(sdkCtx, msg.PoolID)
|
|
if !ok {
|
|
return nil, fmt.Errorf("cover: pool %q not found (SignCoverCharter rejected)", msg.PoolID)
|
|
}
|
|
|
|
// Watcher attestation over the witness hash (REQ-052). A nil
|
|
// WatcherKeeper skips; an empty WatcherWitnessHash skips (the charter
|
|
// may be signed without a witness in simtest).
|
|
if s.Keeper.watcherKeeper != nil && len(msg.WatcherWitnessHash) > 0 {
|
|
if _, err := s.Keeper.watcherKeeper.Attest(msg.PoolID, msg.WatcherWitnessHash); err != nil {
|
|
return nil, fmt.Errorf("cover: Watcher attestation for charter %q: %w (REQ-052)", msg.CharterID, err)
|
|
}
|
|
}
|
|
|
|
charter := types.CoverCharter{
|
|
CharterID: msg.CharterID,
|
|
PoolID: msg.PoolID,
|
|
StatementOfBeliefsHash: msg.StatementOfBeliefsHash,
|
|
DisputePath: msg.DisputePath,
|
|
Gate: msg.Gate,
|
|
HoldingPeriodDays: msg.HoldingPeriodDays,
|
|
HostReachID: msg.HostReachID,
|
|
WatcherWitnessHash: msg.WatcherWitnessHash,
|
|
Amendments: []types.CharterAmendment{},
|
|
WaivedRights: msg.WaivedRights,
|
|
}
|
|
s.Keeper.SetCoverCharter(sdkCtx, charter)
|
|
|
|
// Link the pool's CharterRef.
|
|
pool.CharterRef = msg.CharterID
|
|
s.Keeper.SetCoverPool(sdkCtx, pool)
|
|
|
|
sdkCtx.EventManager().EmitEvent(sdk.NewEvent(
|
|
"cover.charter_signed",
|
|
sdk.NewAttribute("charter_id", msg.CharterID),
|
|
sdk.NewAttribute("pool_id", msg.PoolID),
|
|
sdk.NewAttribute("host_reach_id", msg.HostReachID),
|
|
))
|
|
return &types.MsgSignCoverCharterResponse{}, nil
|
|
}
|
|
|
|
// --- P2: AmendCoverCharter ----------------------------------------------------
|
|
|
|
// AmendCoverCharter files a Charter amendment (REQ-052). The handler
|
|
// enforces:
|
|
// 1. ValidateBasic (stateless).
|
|
// 2. The referenced charter must exist.
|
|
// 3. Create a CharterAmendment with Status=AmendmentProposed,
|
|
// ProposedAt=now. Persist the amendment + append to the charter's
|
|
// Amendments slice.
|
|
// 4. Emit cover.charter_amend_proposed.
|
|
//
|
|
// The 7-day cooling is enforced by CoolCharterAmendment /
|
|
// RatifyCharterAmendment (keeper helpers) — a simtest time-advance or a
|
|
// separate handler transitions the amendment to Cooled then Ratified.
|
|
func (s msgServer) AmendCoverCharter(ctx interface{}, msg *types.MsgAmendCoverCharter) (*types.MsgAmendCoverCharterResponse, error) {
|
|
if err := msg.ValidateBasic(); err != nil {
|
|
return nil, err
|
|
}
|
|
sdkCtx := unwrapCtx(ctx)
|
|
|
|
charter, ok := s.Keeper.GetCoverCharter(sdkCtx, msg.CharterID)
|
|
if !ok {
|
|
return nil, fmt.Errorf("cover: charter %q not found (AmendCoverCharter rejected)", msg.CharterID)
|
|
}
|
|
|
|
// Idempotency: amendment-id must not already exist.
|
|
if _, ok := s.Keeper.GetCharterAmendment(sdkCtx, msg.AmendmentID); ok {
|
|
return nil, fmt.Errorf("cover: amendment %q already exists", msg.AmendmentID)
|
|
}
|
|
|
|
amendment := types.CharterAmendment{
|
|
AmendmentID: msg.AmendmentID,
|
|
Description: msg.Description,
|
|
Status: types.AmendmentProposed,
|
|
ProposedAt: sdkCtx.BlockTime().Unix(),
|
|
}
|
|
s.Keeper.SetCharterAmendment(sdkCtx, amendment)
|
|
|
|
// Append the amendment to the charter's Amendments slice + persist.
|
|
charter.Amendments = append(charter.Amendments, amendment)
|
|
s.Keeper.SetCoverCharter(sdkCtx, charter)
|
|
|
|
sdkCtx.EventManager().EmitEvent(sdk.NewEvent(
|
|
"cover.charter_amend_proposed",
|
|
sdk.NewAttribute("charter_id", msg.CharterID),
|
|
sdk.NewAttribute("amendment_id", msg.AmendmentID),
|
|
))
|
|
return &types.MsgAmendCoverCharterResponse{}, nil
|
|
}
|
|
|
|
// --- P2: ElectPoolMason -------------------------------------------------------
|
|
|
|
// ElectPoolMason elects a Mason to the Pool Council (REQ-062). The
|
|
// handler enforces:
|
|
// 1. ValidateBasic (stateless).
|
|
// 2. The referenced pool must exist.
|
|
// 3. Load or create the PoolCouncil. Add the MasonReachID to
|
|
// ElectedMasonReachIDs (max PoolCouncilMaxMasons = 3 — a 4th is
|
|
// REJECTED). Reject a duplicate MasonReachID (already elected).
|
|
// 4. Persist the PoolCouncil + link the pool's CouncilRef.
|
|
// 5. Emit cover.pool_mason_elected.
|
|
func (s msgServer) ElectPoolMason(ctx interface{}, msg *types.MsgElectPoolMason) (*types.MsgElectPoolMasonResponse, 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 (ElectPoolMason rejected)", msg.PoolID)
|
|
}
|
|
|
|
council, exists := s.Keeper.GetPoolCouncil(sdkCtx, msg.PoolID)
|
|
if !exists {
|
|
council = types.PoolCouncil{
|
|
PoolID: msg.PoolID,
|
|
HostReachID: pool.HostReachID,
|
|
ElectedMasonReachIDs: [3]string{},
|
|
}
|
|
}
|
|
|
|
// Reject a duplicate MasonReachID (already elected).
|
|
for _, m := range council.ElectedMasonReachIDs {
|
|
if m == msg.MasonReachID {
|
|
return nil, fmt.Errorf("cover: mason %q already elected to pool %q council (REQ-062)", msg.MasonReachID, msg.PoolID)
|
|
}
|
|
}
|
|
|
|
// Find the first empty slot; if all 3 are filled, REJECT (max
|
|
// PoolCouncilMaxMasons).
|
|
slotIdx := -1
|
|
for i, m := range council.ElectedMasonReachIDs {
|
|
if m == "" {
|
|
slotIdx = i
|
|
break
|
|
}
|
|
}
|
|
if slotIdx == -1 {
|
|
return nil, fmt.Errorf("cover: pool %q council already has %d masons (REQ-062 max %d)", msg.PoolID, types.PoolCouncilMaxMasons, types.PoolCouncilMaxMasons)
|
|
}
|
|
council.ElectedMasonReachIDs[slotIdx] = msg.MasonReachID
|
|
s.Keeper.SetPoolCouncil(sdkCtx, council)
|
|
|
|
// Link the pool's CouncilRef (the council is keyed by pool-id, so the
|
|
// ref is the pool-id itself).
|
|
pool.CouncilRef = msg.PoolID
|
|
s.Keeper.SetCoverPool(sdkCtx, pool)
|
|
|
|
sdkCtx.EventManager().EmitEvent(sdk.NewEvent(
|
|
"cover.pool_mason_elected",
|
|
sdk.NewAttribute("pool_id", msg.PoolID),
|
|
sdk.NewAttribute("mason_reach_id", msg.MasonReachID),
|
|
sdk.NewAttribute("slot", fmt.Sprintf("%d", slotIdx)),
|
|
))
|
|
return &types.MsgElectPoolMasonResponse{}, nil
|
|
}
|
|
|
|
// --- P2: VoteCoverCall --------------------------------------------------------
|
|
|
|
// VoteCoverCall votes on a Cover Call (REQ-062). The handler enforces:
|
|
// 1. ValidateBasic (stateless — includes the valid VoteOption check).
|
|
// 2. The referenced CoverCall must exist.
|
|
// 3. The Watcher-observer-present check: if VoteOption == CallVoteYes and
|
|
// WatcherObserverPresent == false, REJECT (majority requires observer
|
|
// present — REQ-062).
|
|
// 4. Idempotency: VoteID must not already exist.
|
|
// 5. Persist the CoverCallVote. Emit cover.cover_call_voted.
|
|
func (s msgServer) VoteCoverCall(ctx interface{}, msg *types.MsgVoteCoverCall) (*types.MsgVoteCoverCallResponse, error) {
|
|
if err := msg.ValidateBasic(); err != nil {
|
|
return nil, err
|
|
}
|
|
sdkCtx := unwrapCtx(ctx)
|
|
|
|
// The referenced CoverCall must exist.
|
|
if _, ok := s.Keeper.GetCoverCall(sdkCtx, msg.CallID); !ok {
|
|
return nil, fmt.Errorf("cover: call %q not found (VoteCoverCall rejected)", msg.CallID)
|
|
}
|
|
|
|
// The Watcher-observer-present check (REQ-062): a CallVoteYes requires
|
|
// the Watcher observer to be present. A CallVoteNo / CallVoteAbstain
|
|
// does NOT require the observer (only an affirmative vote demands the
|
|
// witness).
|
|
if msg.VoteOption == types.CallVoteYes && !msg.WatcherObserverPresent {
|
|
return nil, fmt.Errorf("cover: CallVoteYes on call %q requires Watcher observer present (REQ-062)", msg.CallID)
|
|
}
|
|
|
|
// Idempotency: vote-id must not already exist.
|
|
if _, ok := s.Keeper.GetCoverCallVote(sdkCtx, msg.VoteID); ok {
|
|
return nil, fmt.Errorf("cover: vote %q already exists", msg.VoteID)
|
|
}
|
|
|
|
vote := types.CoverCallVote{
|
|
VoteID: msg.VoteID,
|
|
CallID: msg.CallID,
|
|
PoolID: msg.PoolID,
|
|
VoterReachID: msg.VoterReachID,
|
|
VoteOption: msg.VoteOption,
|
|
WatcherObserverPresent: msg.WatcherObserverPresent,
|
|
VotedAt: sdkCtx.BlockTime().Unix(),
|
|
}
|
|
s.Keeper.SetCoverCallVote(sdkCtx, vote)
|
|
|
|
sdkCtx.EventManager().EmitEvent(sdk.NewEvent(
|
|
"cover.cover_call_voted",
|
|
sdk.NewAttribute("vote_id", msg.VoteID),
|
|
sdk.NewAttribute("call_id", msg.CallID),
|
|
sdk.NewAttribute("pool_id", msg.PoolID),
|
|
sdk.NewAttribute("voter_reach_id", msg.VoterReachID),
|
|
sdk.NewAttribute("vote_option", string(msg.VoteOption)),
|
|
))
|
|
return &types.MsgVoteCoverCallResponse{}, nil
|
|
}
|
|
|
|
// --- P2: AmendPoolStandingGate ------------------------------------------------
|
|
|
|
// AmendPoolStandingGate amends a Pool's Standing gate (D-090(3)). The
|
|
// handler enforces:
|
|
// 1. ValidateBasic (stateless — includes the D-090(3) dual check:
|
|
// NewGate >= CoverStandingGateTrusted).
|
|
// 2. The referenced pool must exist.
|
|
// 3. D-090(3) handler re-check (defense in depth): NewGate >=
|
|
// CoverStandingGateTrusted. ValidateBasic already checked, but the
|
|
// handler re-checks in case of a future Params-bypass.
|
|
// 4. Update the pool's PoolStandingGate. Persist.
|
|
// 5. Emit cover.pool_standing_gate_amended.
|
|
func (s msgServer) AmendPoolStandingGate(ctx interface{}, msg *types.MsgAmendPoolStandingGate) (*types.MsgAmendPoolStandingGateResponse, 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 (AmendPoolStandingGate rejected)", msg.PoolID)
|
|
}
|
|
|
|
// D-090(3) handler re-check (defense in depth — ValidateBasic already
|
|
// checked, but the handler re-checks in case of a future Params-bypass).
|
|
if msg.NewGate < types.CoverStandingGateTrusted {
|
|
return nil, fmt.Errorf("cover: NewGate %.2f < CoverStandingGateTrusted %.2f (D-090(3) handler re-check: a pool may tighten the gate but never lower it)", msg.NewGate, types.CoverStandingGateTrusted)
|
|
}
|
|
|
|
pool.PoolStandingGate = msg.NewGate
|
|
s.Keeper.SetCoverPool(sdkCtx, pool)
|
|
|
|
sdkCtx.EventManager().EmitEvent(sdk.NewEvent(
|
|
"cover.pool_standing_gate_amended",
|
|
sdk.NewAttribute("pool_id", msg.PoolID),
|
|
sdk.NewAttribute("new_gate", fmt.Sprintf("%.2f", msg.NewGate)),
|
|
))
|
|
return &types.MsgAmendPoolStandingGateResponse{}, nil
|
|
}
|
|
|
|
// --- P2: EscalateReserveCeiling -----------------------------------------------
|
|
|
|
// EscalateReserveCeiling escalates a Pool's reserve target to the
|
|
// CoverReserveCeilingAnnualContribX (REQ-048). The handler enforces:
|
|
// 1. ValidateBasic (stateless).
|
|
// 2. The referenced pool must exist.
|
|
// 3. 12-month age check: now - pool.CreatedAt >= ReserveCeilingAgeSeconds
|
|
// (365 days). A fresh pool is REJECTED. NOTE: pool.CreatedAt is set to
|
|
// sdkCtx.BlockHeight() at launch in P1; for the age check we use
|
|
// BlockTime().Unix() - pool.CreatedAt where pool.CreatedAt is
|
|
// interpreted as a unix timestamp (the simtest sets CreatedAt to a
|
|
// unix timestamp to satisfy this check).
|
|
// 4. Set the pool's ReserveAnnualContribRatio to
|
|
// CoverReserveCeilingAnnualContribX (2.5).
|
|
// 5. WatcherKeeper.Attest (a nil WatcherKeeper skips).
|
|
// 6. Persist the updated pool. Emit cover.reserve_ceiling_escalated.
|
|
func (s msgServer) EscalateReserveCeiling(ctx interface{}, msg *types.MsgEscalateReserveCeiling) (*types.MsgEscalateReserveCeilingResponse, 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 (EscalateReserveCeiling rejected)", msg.PoolID)
|
|
}
|
|
|
|
// 12-month age check (REQ-048): the pool must have >= 365 days of
|
|
// operating history before the reserve target can be escalated to the
|
|
// ceiling. pool.CreatedAt is interpreted as a unix timestamp (the
|
|
// simtest sets it accordingly).
|
|
now := sdkCtx.BlockTime().Unix()
|
|
if now-pool.CreatedAt < types.ReserveCeilingAgeSeconds {
|
|
return nil, fmt.Errorf("cover: pool %q age %d seconds < %d seconds (REQ-048: 12-month operating history required for reserve ceiling escalation)", msg.PoolID, now-pool.CreatedAt, types.ReserveCeilingAgeSeconds)
|
|
}
|
|
|
|
// Set the pool's reserve target to the ceiling.
|
|
pool.ReserveAnnualContribRatio = types.CoverReserveCeilingAnnualContribX
|
|
|
|
// Watcher attestation (REQ-048). A nil WatcherKeeper skips.
|
|
if s.Keeper.watcherKeeper != nil {
|
|
payload := []byte(fmt.Sprintf("cover.escalate:%s:%.2f", msg.PoolID, types.CoverReserveCeilingAnnualContribX))
|
|
if _, err := s.Keeper.watcherKeeper.Attest(msg.PoolID, payload); err != nil {
|
|
return nil, fmt.Errorf("cover: Watcher attestation for reserve ceiling escalation on pool %q: %w (REQ-048)", msg.PoolID, err)
|
|
}
|
|
}
|
|
|
|
s.Keeper.SetCoverPool(sdkCtx, pool)
|
|
|
|
sdkCtx.EventManager().EmitEvent(sdk.NewEvent(
|
|
"cover.reserve_ceiling_escalated",
|
|
sdk.NewAttribute("pool_id", msg.PoolID),
|
|
sdk.NewAttribute("reserve_annual_contrib_ratio", fmt.Sprintf("%.2f", types.CoverReserveCeilingAnnualContribX)),
|
|
))
|
|
return &types.MsgEscalateReserveCeilingResponse{}, nil
|
|
}
|