9abda8d01e
P5 (final execution phase) of v0.7 delivers REQ-056, REQ-059, REQ-064, REQ-066: x/cover — Bill of Rights ceremony (REQ-056) + Pier Selection (REQ-066): MsgCounselReviewBillOfRights (bonded Counsel, Staked=true gate); MsgSelectPier (Guild Council + GuildKeeper shim + PierSelectionIndex); MsgRevokePierSelection (supermajority + Counsel witness); PierSelectionIndex + PierSelectionRecord structs; bill_review/pier_selection/pier_index stores. x/guild — Secession cooling (REQ-064) + Stand->Pier (REQ-059, D-074): MsgInitiateSecession (SecessionStartedAt + lien audit); MsgCompleteSecession (21d Cover-active / 14d non-Cover cooling + lien audit + covenant clearance + pro-rata settlement event); MsgEscalateStandToPier (10M Grain-cents D-074 threshold, soft upgrade); MsgAcceptPierInvitation; Guild.SecessionStartedAt + SecededAt + Lien.Cleared additive; LOCAL StandPierEscalationAnnualPass VolumeCents const (G-003 local-const mirror of x/stand canonical). x/stand — StandPierEscalationAnnualPassVolumeCents=10000000 canonical const. Coverage: cover/keeper 94.6%, guild/keeper 94.1%, stand/types 100%. G-006/G-028 intact. go.mod/go.sum diff EMPTY. go vet clean. Lexicon green. ---ci--- project: oy phase: 5 milestone: v0.7 status: execute ---/ci---
1102 lines
47 KiB
Go
1102 lines
47 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
|
||
}
|
||
|
||
// --- v0.7 P4: Voucher + Dissolution handlers (REQ-055, REQ-063, D-090(2)) ------
|
||
//
|
||
// (Cover Claims Voucher registration + Cover Call adjudication + Voucher
|
||
// slash + Pool dissolution waterfall). The four handlers exercise the
|
||
// D-090(2) cold-start bond fallback, the FR-CPCV-2 no-self-adjudication
|
||
// gate, the cross-Pool slash via StandingKeeper.RecordSlash, and the
|
||
// FR-MAB-4 seniority chain (Cover-Fee contributors > MAB > Bread holders).
|
||
|
||
// RegisterCoverClaimsVoucher registers a Cover Claims Voucher for a Pool
|
||
// (REQ-055, D-090(2)). The handler enforces:
|
||
// 1. ValidateBasic (stateless).
|
||
// 2. The referenced Pool must exist.
|
||
// 3. Idempotency: no duplicate Voucher for the same VoucherReachID +
|
||
// PoolID (a Voucher is registered per-Pool; a second registration for
|
||
// the same composite key is REJECTED).
|
||
// 4. Compute bond: max(CoverClaimsVoucherBondMultipleAvgCall ×
|
||
// GetAvgCallSize(poolID), Params.MinimumVoucherBond). D-090(2) cold-
|
||
// start: when no Calls exist, GetAvgCallSize returns 0 -> bond =
|
||
// MinimumVoucherBond (NOT zero).
|
||
// 5. Persist the Voucher + emit cover.voucher_registered.
|
||
func (s msgServer) RegisterCoverClaimsVoucher(ctx interface{}, msg *types.MsgRegisterCoverClaimsVoucher) (*types.MsgRegisterCoverClaimsVoucherResponse, error) {
|
||
if err := msg.ValidateBasic(); err != nil {
|
||
return nil, err
|
||
}
|
||
sdkCtx := unwrapCtx(ctx)
|
||
|
||
// The referenced Pool must exist.
|
||
if _, ok := s.Keeper.GetCoverPool(sdkCtx, msg.PoolID); !ok {
|
||
return nil, fmt.Errorf("cover: pool %q not found (RegisterCoverClaimsVoucher rejected)", msg.PoolID)
|
||
}
|
||
|
||
// Idempotency: no duplicate Voucher for the same VoucherReachID + PoolID.
|
||
if _, ok := s.Keeper.GetCoverClaimsVoucher(sdkCtx, msg.VoucherReachID, msg.PoolID); ok {
|
||
return nil, fmt.Errorf("cover: voucher %q already registered for pool %q (RegisterCoverClaimsVoucher rejected)", msg.VoucherReachID, msg.PoolID)
|
||
}
|
||
|
||
// D-090(2) bond computation: max(multiple × avgCallSize,
|
||
// MinimumVoucherBond). When no Calls exist, avgCallSize = 0 -> bond =
|
||
// MinimumVoucherBond (NOT zero — the cold-start fix).
|
||
avgCallSize := s.Keeper.GetAvgCallSize(sdkCtx, msg.PoolID)
|
||
multipleBond := int64(types.CoverClaimsVoucherBondMultipleAvgCall) * avgCallSize
|
||
minBond := s.Keeper.Params().MinimumVoucherBond
|
||
bond := multipleBond
|
||
if bond < minBond {
|
||
bond = minBond
|
||
}
|
||
|
||
v := types.CoverClaimsVoucher{
|
||
VoucherReachID: msg.VoucherReachID,
|
||
PoolID: msg.PoolID,
|
||
BondAmount: bond,
|
||
BondMultipleAvgCall: types.CoverClaimsVoucherBondMultipleAvgCall,
|
||
RegisteredAt: sdkCtx.BlockTime().Unix(),
|
||
}
|
||
s.Keeper.SetCoverClaimsVoucher(sdkCtx, v)
|
||
|
||
sdkCtx.EventManager().EmitEvent(sdk.NewEvent(
|
||
"cover.voucher_registered",
|
||
sdk.NewAttribute("voucher_reach_id", msg.VoucherReachID),
|
||
sdk.NewAttribute("pool_id", msg.PoolID),
|
||
sdk.NewAttribute("bond_amount", fmt.Sprintf("%d", bond)),
|
||
sdk.NewAttribute("avg_call_size", fmt.Sprintf("%d", avgCallSize)),
|
||
))
|
||
return &types.MsgRegisterCoverClaimsVoucherResponse{BondAmount: bond}, nil
|
||
}
|
||
|
||
// AdjudicateCoverCall adjudicates a Cover Call (REQ-055, FR-CPCV-2). The
|
||
// handler enforces:
|
||
// 1. ValidateBasic (stateless).
|
||
// 2. The CoverCall must exist.
|
||
// 3. FR-CPCV-2 no self-adjudication: reject if VoucherReachID ==
|
||
// CoverCall.ClaimantReachID (the Voucher cannot adjudicate their own
|
||
// Call).
|
||
// 4. The Voucher must be registered for the Call's Pool.
|
||
// 5. Record the adjudication result on the CoverCall (AdjudicationResult +
|
||
// AdjudicatedBy + AdjudicatedAt). Persist. Emit
|
||
// cover.cover_call_adjudicated.
|
||
func (s msgServer) AdjudicateCoverCall(ctx interface{}, msg *types.MsgAdjudicateCoverCall) (*types.MsgAdjudicateCoverCallResponse, error) {
|
||
if err := msg.ValidateBasic(); err != nil {
|
||
return nil, err
|
||
}
|
||
sdkCtx := unwrapCtx(ctx)
|
||
|
||
call, ok := s.Keeper.GetCoverCall(sdkCtx, msg.CallID)
|
||
if !ok {
|
||
return nil, fmt.Errorf("cover: call %q not found (AdjudicateCoverCall rejected)", msg.CallID)
|
||
}
|
||
|
||
// FR-CPCV-2 no self-adjudication: the Voucher cannot adjudicate their
|
||
// own Call.
|
||
if msg.VoucherReachID == call.ClaimantReachID {
|
||
return nil, fmt.Errorf("cover: FR-CPCV-2 no self-adjudication — voucher %q == call %q claimant %q (AdjudicateCoverCall rejected)",
|
||
msg.VoucherReachID, msg.CallID, call.ClaimantReachID)
|
||
}
|
||
|
||
// The Voucher must be registered for the Call's Pool.
|
||
if _, ok := s.Keeper.GetCoverClaimsVoucher(sdkCtx, msg.VoucherReachID, call.PoolID); !ok {
|
||
return nil, fmt.Errorf("cover: voucher %q not registered for pool %q (AdjudicateCoverCall rejected)", msg.VoucherReachID, call.PoolID)
|
||
}
|
||
|
||
// Record the adjudication result on the CoverCall (additive fields).
|
||
call.AdjudicationResult = msg.AdjudicationResult
|
||
call.AdjudicatedBy = msg.VoucherReachID
|
||
call.AdjudicatedAt = sdkCtx.BlockTime().Unix()
|
||
s.Keeper.SetCoverCall(sdkCtx, call)
|
||
|
||
sdkCtx.EventManager().EmitEvent(sdk.NewEvent(
|
||
"cover.cover_call_adjudicated",
|
||
sdk.NewAttribute("call_id", msg.CallID),
|
||
sdk.NewAttribute("pool_id", call.PoolID),
|
||
sdk.NewAttribute("voucher_reach_id", msg.VoucherReachID),
|
||
sdk.NewAttribute("adjudication_result", msg.AdjudicationResult),
|
||
))
|
||
return &types.MsgAdjudicateCoverCallResponse{}, nil
|
||
}
|
||
|
||
// SlashCoverClaimsVoucher slashes a Cover Claims Voucher for a fraudulent
|
||
// Cover Call adjudication (REQ-055). The handler enforces:
|
||
// 1. ValidateBasic (stateless — Reason must == SlashReasonFraudulentCoverCall).
|
||
// 2. The Voucher must exist (look up by VoucherReachID across all Pools —
|
||
// a Voucher may be registered for multiple Pools; the slash drops the
|
||
// Standing bucket, which is cross-Pool).
|
||
// 3. Invoke StandingKeeper.RecordSlash(voucherReachID, amount, reason,
|
||
// attester) — the slash drops the Voucher's Standing bucket (cross-Pool
|
||
// applicability — the bucket drop disqualifies them from other Pools'
|
||
// Standing gates). A nil StandingKeeper is a wiring error -> REJECT.
|
||
// 4. Emit cover.voucher_slashed.
|
||
func (s msgServer) SlashCoverClaimsVoucher(ctx interface{}, msg *types.MsgSlashCoverClaimsVoucher) (*types.MsgSlashCoverClaimsVoucherResponse, error) {
|
||
if err := msg.ValidateBasic(); err != nil {
|
||
return nil, err
|
||
}
|
||
sdkCtx := unwrapCtx(ctx)
|
||
|
||
// The Voucher must exist (look up by VoucherReachID across all Pools).
|
||
vouchers := s.Keeper.AllCoverClaimsVouchers(sdkCtx)
|
||
var found *types.CoverClaimsVoucher
|
||
for i := range vouchers {
|
||
if vouchers[i].VoucherReachID == msg.VoucherReachID {
|
||
found = &vouchers[i]
|
||
break
|
||
}
|
||
}
|
||
if found == nil {
|
||
return nil, fmt.Errorf("cover: voucher %q not found (SlashCoverClaimsVoucher rejected)", msg.VoucherReachID)
|
||
}
|
||
|
||
// StandingKeeper.RecordSlash — the slash drops the Voucher's Standing
|
||
// bucket (cross-Pool applicability). A nil StandingKeeper is a wiring
|
||
// error -> REJECT (the slash cannot be recorded).
|
||
if s.Keeper.standingKeeper == nil {
|
||
return nil, fmt.Errorf("cover: StandingKeeper shim not wired (SlashCoverClaimsVoucher cannot record the slash — REQ-055 cross-Pool applicability)")
|
||
}
|
||
if err := s.Keeper.standingKeeper.RecordSlash(msg.VoucherReachID, float64(found.BondAmount), msg.Reason, msg.Signer); err != nil {
|
||
return nil, fmt.Errorf("cover: StandingKeeper.RecordSlash for voucher %q: %w (REQ-055)", msg.VoucherReachID, err)
|
||
}
|
||
|
||
sdkCtx.EventManager().EmitEvent(sdk.NewEvent(
|
||
"cover.voucher_slashed",
|
||
sdk.NewAttribute("voucher_reach_id", msg.VoucherReachID),
|
||
sdk.NewAttribute("call_id", msg.CallID),
|
||
sdk.NewAttribute("reason", msg.Reason),
|
||
sdk.NewAttribute("bond_amount", fmt.Sprintf("%d", found.BondAmount)),
|
||
))
|
||
return &types.MsgSlashCoverClaimsVoucherResponse{}, nil
|
||
}
|
||
|
||
// DissolveCoverPool dissolves a Cover Pool (REQ-063, FR-MAB-4). The handler
|
||
// enforces:
|
||
// 1. ValidateBasic (stateless).
|
||
// 2. The Pool must exist.
|
||
// 3. Compute the PoolDissolutionWaterfall (FR-MAB-4 seniority chain):
|
||
// Tier 1 = Cover-Fee contributors (the Pool's reserve — a simtest-grade
|
||
// placeholder amount; the real reserve balance is a v0.8+ concern),
|
||
// Tier 2 = MAB holders (query BondKeeper.GetMABsForPool for the Pool's
|
||
// outstanding MABs; sum the PrincipalGrain), Tier 3 = Bread holders
|
||
// (the remainder — simtest-grade placeholder). MAB holders have NO
|
||
// Voice in the dissolution decision (REQ-063 — the PoolCouncil from P2
|
||
// already excludes them; the waterfall only determines the payout
|
||
// order).
|
||
// 4. Emit cover.pool_dissolved with the waterfall tiers.
|
||
func (s msgServer) DissolveCoverPool(ctx interface{}, msg *types.MsgDissolveCoverPool) (*types.MsgDissolveCoverPoolResponse, 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 (DissolveCoverPool rejected)", msg.PoolID)
|
||
}
|
||
|
||
// FR-MAB-4 waterfall. Tier 1 = Cover-Fee contributors (the Pool's
|
||
// reserve — simtest-grade placeholder; the real reserve balance is a
|
||
// v0.8+ concern, so we use a deterministic placeholder derived from
|
||
// the pool's ReserveAnnualContribRatio for the simtest assertion).
|
||
coverFeeContributors := int64(pool.ReserveAnnualContribRatio * 1_000_000)
|
||
|
||
// Tier 2 = MAB holders (sum the outstanding MAB principal via
|
||
// BondKeeper.GetMABsForPool). A nil BondKeeper returns an empty slice
|
||
// -> Tier 2 amount = 0.
|
||
mabHolders := int64(0)
|
||
if s.Keeper.bondKeeper != nil {
|
||
for _, m := range s.Keeper.bondKeeper.GetMABsForPool(msg.PoolID) {
|
||
mabHolders += m.PrincipalGrain
|
||
}
|
||
}
|
||
|
||
// Tier 3 = Bread holders (the remainder — simtest-grade placeholder;
|
||
// the real Bread-holder balance is a v0.8+ concern, so we use a
|
||
// deterministic placeholder for the simtest assertion).
|
||
breadHolders := coverFeeContributors / 4
|
||
|
||
waterfall := []types.PoolDissolutionWaterfall{
|
||
{Tier: types.PoolDissolutionWaterfallTierCoverFeeContributors, AmountGrain: coverFeeContributors},
|
||
{Tier: types.PoolDissolutionWaterfallTierMABHolders, AmountGrain: mabHolders},
|
||
{Tier: types.PoolDissolutionWaterfallTierBreadHolders, AmountGrain: breadHolders},
|
||
}
|
||
|
||
sdkCtx.EventManager().EmitEvent(sdk.NewEvent(
|
||
"cover.pool_dissolved",
|
||
sdk.NewAttribute("pool_id", msg.PoolID),
|
||
sdk.NewAttribute("tier_1_cover_fee_contributors", fmt.Sprintf("%d", coverFeeContributors)),
|
||
sdk.NewAttribute("tier_2_mab_holders", fmt.Sprintf("%d", mabHolders)),
|
||
sdk.NewAttribute("tier_3_bread_holders", fmt.Sprintf("%d", breadHolders)),
|
||
))
|
||
return &types.MsgDissolveCoverPoolResponse{Waterfall: waterfall}, nil
|
||
}
|
||
|
||
// --- v0.7 P5: Bill of Rights ceremony + Pier Selection handlers (REQ-056, REQ-066) --
|
||
//
|
||
// (REQ-056 §7 acceptance ceremony, REQ-066 Pier Selection.) The three
|
||
// handlers exercise the bonded-Counsel review gate, the Pier selection
|
||
// persistence + index accumulation, and the Cover-Pool-supermajority +
|
||
// Counsel-witness revocation gate.
|
||
|
||
// CounselReviewBillOfRights records a bonded Counsel's review of the
|
||
// Anti-Capture Bill of Rights (REQ-056, vision §7, §8.2). The handler
|
||
// enforces:
|
||
// 1. ValidateBasic (stateless — includes the §7 "bonded Counsel" gate:
|
||
// Staked must be true).
|
||
// 2. Idempotency: ReviewID must not already exist.
|
||
// 3. Handler re-check of Staked (defense in depth — the §7 acceptance is
|
||
// load-bearing; the handler re-checks in case of a future
|
||
// ValidateBasic-bypass).
|
||
// 4. Persist the BillOfRightsReview (the bill_review/ store: ReviewID ->
|
||
// review record). Emit cover.bill_of_rights_reviewed.
|
||
func (s msgServer) CounselReviewBillOfRights(ctx interface{}, msg *types.MsgCounselReviewBillOfRights) (*types.MsgCounselReviewBillOfRightsResponse, error) {
|
||
if err := msg.ValidateBasic(); err != nil {
|
||
return nil, err
|
||
}
|
||
sdkCtx := unwrapCtx(ctx)
|
||
|
||
// Idempotency: review-id must not already exist.
|
||
if _, ok := s.Keeper.GetBillOfRightsReview(sdkCtx, msg.ReviewID); ok {
|
||
return nil, fmt.Errorf("cover: bill-of-rights review %q already exists", msg.ReviewID)
|
||
}
|
||
|
||
// Handler re-check of Staked (defense in depth — the §7 "bonded Counsel"
|
||
// acceptance is load-bearing; re-check in case of a future
|
||
// ValidateBasic-bypass).
|
||
if !msg.Staked {
|
||
return nil, fmt.Errorf("cover: REQ-056 §7 acceptance: handler re-check — Staked must be true (bonded Counsel)")
|
||
}
|
||
|
||
review := BillOfRightsReview{
|
||
ReviewID: msg.ReviewID,
|
||
CounselReachID: msg.CounselReachID,
|
||
Staked: msg.Staked,
|
||
ReviewResult: msg.ReviewResult,
|
||
ReviewedAt: sdkCtx.BlockTime().Unix(),
|
||
}
|
||
s.Keeper.SetBillOfRightsReview(sdkCtx, review)
|
||
|
||
sdkCtx.EventManager().EmitEvent(sdk.NewEvent(
|
||
"cover.bill_of_rights_reviewed",
|
||
sdk.NewAttribute("review_id", msg.ReviewID),
|
||
sdk.NewAttribute("counsel_reach_id", msg.CounselReachID),
|
||
sdk.NewAttribute("staked", fmt.Sprintf("%v", msg.Staked)),
|
||
sdk.NewAttribute("review_result", msg.ReviewResult),
|
||
))
|
||
return &types.MsgCounselReviewBillOfRightsResponse{}, nil
|
||
}
|
||
|
||
// SelectPier records a Guild Council's selection of a Pier at formation
|
||
// (REQ-066). The handler enforces:
|
||
// 1. ValidateBasic (stateless).
|
||
// 2. Idempotency: a PierSelectionRecord for the same GuildID must not
|
||
// already exist (a Guild selects exactly one Pier; a second selection
|
||
// is REJECTED — use MsgRevokePierSelection first).
|
||
// 3. Guild existence check via the GuildKeeper shim (a nil shim skips —
|
||
// simtest wiring; a non-nil shim with exists=false REJECTS).
|
||
// 4. Persist the PierSelectionRecord (pier_selection/ store: GuildID ->
|
||
// record). Create or update the PierSelectionIndex entry for the
|
||
// PierID (pier_index/ store: PierID -> index — accumulate scores from
|
||
// successive selections). Emit cover.pier_selected.
|
||
func (s msgServer) SelectPier(ctx interface{}, msg *types.MsgSelectPier) (*types.MsgSelectPierResponse, error) {
|
||
if err := msg.ValidateBasic(); err != nil {
|
||
return nil, err
|
||
}
|
||
sdkCtx := unwrapCtx(ctx)
|
||
|
||
// Idempotency: a Guild selects exactly one Pier; a second selection is
|
||
// REJECTED (use MsgRevokePierSelection first to re-select).
|
||
if _, ok := s.Keeper.GetPierSelectionRecord(sdkCtx, msg.GuildID); ok {
|
||
return nil, fmt.Errorf("cover: guild %q already has a Pier selection (use RevokePierSelection first to re-select — REQ-066)", msg.GuildID)
|
||
}
|
||
|
||
// Guild existence check via the GuildKeeper shim (a nil shim skips —
|
||
// simtest wiring; a non-nil shim with exists=false REJECTS).
|
||
if s.Keeper.guildKeeper != nil {
|
||
if !s.Keeper.guildKeeper.GetGuild(msg.GuildID) {
|
||
return nil, fmt.Errorf("cover: guild %q not found (SelectPier rejected — REQ-066)", msg.GuildID)
|
||
}
|
||
}
|
||
|
||
record := types.PierSelectionRecord{
|
||
GuildID: msg.GuildID,
|
||
PierID: msg.PierID,
|
||
SelectedAt: sdkCtx.BlockTime().Unix(),
|
||
SelectedBy: msg.Signer,
|
||
}
|
||
s.Keeper.SetPierSelectionRecord(sdkCtx, record)
|
||
|
||
// Create or update the PierSelectionIndex entry for the PierID
|
||
// (accumulate scores from successive selections — a fresh entry gets
|
||
// the default scores; an existing entry keeps its scores but the
|
||
// OverallScore is recomputed for non-decreasing assertion). The live
|
||
// mesh oracle is a v0.8+ concern; the simtest uses deterministic
|
||
// defaults so the index is non-empty on first selection.
|
||
idx, ok := s.Keeper.GetPierSelectionIndex(sdkCtx, msg.PierID)
|
||
if !ok {
|
||
idx = types.PierSelectionIndex{
|
||
PierID: msg.PierID,
|
||
JurisdictionalReliabilityScore: DefaultPierJurisdictionalReliabilityScore,
|
||
IntegrationQualityScore: DefaultPierIntegrationQualityScore,
|
||
}
|
||
}
|
||
idx.OverallScore = DefaultPierOverallScore(idx.JurisdictionalReliabilityScore, idx.IntegrationQualityScore, idx.FiduciaryRecordHash)
|
||
s.Keeper.SetPierSelectionIndex(sdkCtx, idx)
|
||
|
||
sdkCtx.EventManager().EmitEvent(sdk.NewEvent(
|
||
"cover.pier_selected",
|
||
sdk.NewAttribute("guild_id", msg.GuildID),
|
||
sdk.NewAttribute("pier_id", msg.PierID),
|
||
sdk.NewAttribute("selected_by", msg.Signer),
|
||
sdk.NewAttribute("overall_score", fmt.Sprintf("%.4f", idx.OverallScore)),
|
||
))
|
||
return &types.MsgSelectPierResponse{}, nil
|
||
}
|
||
|
||
// RevokePierSelection revokes a Guild's Pier selection (REQ-066). The
|
||
// revocation is reversible by a Cover Pool supermajority + a Counsel
|
||
// witness. The handler enforces:
|
||
// 1. ValidateBasic (stateless).
|
||
// 2. The PierSelectionRecord for the GuildID must exist (nothing to
|
||
// revoke -> REJECT).
|
||
// 3. Authorization: RevocationApproved must be true + CounselWitness must
|
||
// be non-empty (the Cover Pool supermajority + Counsel witness gate —
|
||
// a revocation without either is REJECTED).
|
||
// 4. Remove the PierSelectionRecord. Emit cover.pier_selection_revoked.
|
||
func (s msgServer) RevokePierSelection(ctx interface{}, msg *types.MsgRevokePierSelection) (*types.MsgRevokePierSelectionResponse, error) {
|
||
if err := msg.ValidateBasic(); err != nil {
|
||
return nil, err
|
||
}
|
||
sdkCtx := unwrapCtx(ctx)
|
||
|
||
// The PierSelectionRecord for the GuildID must exist.
|
||
record, ok := s.Keeper.GetPierSelectionRecord(sdkCtx, msg.GuildID)
|
||
if !ok {
|
||
return nil, fmt.Errorf("cover: guild %q has no Pier selection to revoke (REQ-066)", msg.GuildID)
|
||
}
|
||
|
||
// Authorization: RevocationApproved must be true + CounselWitness must
|
||
// be non-empty (the Cover Pool supermajority + Counsel witness gate).
|
||
if !msg.RevocationApproved {
|
||
return nil, fmt.Errorf("cover: REQ-066 revocation requires RevocationApproved=true (Cover Pool supermajority not secured)")
|
||
}
|
||
if msg.CounselWitness == "" {
|
||
return nil, fmt.Errorf("cover: REQ-066 revocation requires a non-empty CounselWitness (Counsel witness not secured)")
|
||
}
|
||
|
||
removed := s.Keeper.RemovePierSelectionRecord(sdkCtx, msg.GuildID)
|
||
if !removed {
|
||
return nil, fmt.Errorf("cover: PierSelectionRecord for guild %q was not removed (internal error — REQ-066)", msg.GuildID)
|
||
}
|
||
|
||
sdkCtx.EventManager().EmitEvent(sdk.NewEvent(
|
||
"cover.pier_selection_revoked",
|
||
sdk.NewAttribute("guild_id", msg.GuildID),
|
||
sdk.NewAttribute("pier_id", record.PierID),
|
||
sdk.NewAttribute("counsel_witness", msg.CounselWitness),
|
||
))
|
||
return &types.MsgRevokePierSelectionResponse{}, nil
|
||
}
|