6c34650a0d
v0.5 Bearers Runtime — 7 runtime REQs (REQ-033..039) shipped as feature. 8 modules promoted to runtime (MsgServer + simtest). cosmos-sdk v0.50.8 + ibc-go v8.2.1 added (G-006 controlled exception). G-003 + locked-const firewalls intact. 8 keeper packages ≥80% coverage. 5 GRILL decisions ratified; 8 binding fixes landed; 5 P1+ flagged for v0.6+. ---ci--- project: oy phase: 8 milestone: v0.5 status: complete requirements: covered: [REQ-033, REQ-034, REQ-035, REQ-036, REQ-037, REQ-038, REQ-039] partial: [] ---/ci---
385 lines
16 KiB
Go
385 lines
16 KiB
Go
package keeper
|
|
|
|
import (
|
|
"fmt"
|
|
|
|
sdk "github.com/cosmos/cosmos-sdk/types"
|
|
|
|
"github.com/oy/openyield/x/council/types"
|
|
)
|
|
|
|
// msg_server.go implements the council module's Proposal-lifecycle MsgServer
|
|
// (P7-02-01, REQ-039, D-060; G-023 ownership split: cosmos-engineer
|
|
// scaffolds the file structure + method signatures; backend-engineer
|
|
// implements the handler logic bodies). The MsgServer wraps the Keeper +
|
|
// the WatcherKeeper, StandKeeper, and GuildKeeper expected-keeper shims
|
|
// (already on the Keeper).
|
|
//
|
|
// Each method returns a (*Response, error). Handler state-machine ordering
|
|
// is enforced: ValidateBasic → keeper authz → state mutation →
|
|
// ctx.EventManager().EmitEvent.
|
|
//
|
|
// Lifecycle (REQ-039, D-060, vision §13):
|
|
// - SubmitProposal → creates a Proposal status=Pending (ValidateBasic
|
|
// already rejected MissionLockAmendment-Rejected
|
|
// per D-064 — the handler never sees that kind).
|
|
// - Vote → records a VoteOption; Veto requires Watcher authz
|
|
// via the WatcherKeeper shim (single-Veto-no-block;
|
|
// the Veto quorum check is at TALLY, not at VOTE).
|
|
// Vote on a non-Active proposal REJECTED. Vote after
|
|
// the voting-deadline REJECTED.
|
|
// - TallyProposal → closes the voting deadline, computes the tally,
|
|
// transitions Succeeded/Failed. Veto semantics: a
|
|
// single Veto does NOT block (anti-greed, vision
|
|
// §19); the proposal transitions to Failed only if
|
|
// NoWithVeto >= WatcherVetoQuorum (default 6,
|
|
// D-065/A-574). The v0.2 TallyResult.NoWithVeto
|
|
// field (zero-locked in v0.2) is now POPULATED by
|
|
// Watcher Vetos.
|
|
//
|
|
// Proposal EXECUTION (auto-executing a passed proposal) is NOT in v0.5 —
|
|
// the handler records the tally result but does NOT auto-execute (a
|
|
// v0.6+ concern; the Executed status exists in the enum but the handler
|
|
// does not transition to it).
|
|
//
|
|
// Nil-shim behavior (simtest wiring): a nil WatcherKeeper shim skips the
|
|
// Veto authz (the handler still records the Veto — the simtest documents
|
|
// the wiring contract). A nil StandKeeper / GuildKeeper shim skips the
|
|
// proposal-target validation (the handler still creates the Proposal — the
|
|
// simtest documents the wiring contract).
|
|
|
|
// msgServer is the concrete MsgServer implementation wrapping the Keeper.
|
|
type msgServer struct {
|
|
Keeper
|
|
}
|
|
|
|
// NewMsgServerImpl returns the council 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("council: expected sdk.Context, got %T", ctx))
|
|
}
|
|
|
|
// nowUnix returns the current block time as unix seconds from the ctx.
|
|
func nowUnix(ctx sdk.Context) int64 {
|
|
return ctx.BlockTime().Unix()
|
|
}
|
|
|
|
// --- SubmitProposal (creates Proposal status=Pending) ----------------------
|
|
|
|
// SubmitProposal creates a Proposal (status=Pending). The handler
|
|
// enforces:
|
|
// 1. ValidateBasic (stateless — MissionLockAmendment-Rejected is
|
|
// REJECTED here per D-064/A-572; the message never reaches this
|
|
// handler with that kind).
|
|
// 2. Idempotency: proposal-id must not already exist.
|
|
// 3. The Council must exist in the runtime store.
|
|
// 4. Proposal-target validation via the StandKeeper / GuildKeeper shim:
|
|
// a Stand-kind Proposal requires the Council's stand-id-ref to
|
|
// reference a real Stand; a Guild-kind Proposal requires the
|
|
// Council's guild-id-ref to reference a real Guild. A nil shim
|
|
// skips the check (simtest wiring); a non-nil shim that returns false
|
|
// REJECTS the submission. A Mesh-kind Proposal has no target ref.
|
|
//
|
|
// On success the Proposal is persisted with status=Pending and an event
|
|
// is emitted.
|
|
func (s msgServer) SubmitProposal(ctx interface{}, msg *types.MsgSubmitProposal) (*types.MsgSubmitProposalResponse, error) {
|
|
if err := msg.ValidateBasic(); err != nil {
|
|
return nil, err
|
|
}
|
|
sdkCtx := unwrapCtx(ctx)
|
|
|
|
// Idempotency: proposal-id must not already exist.
|
|
if _, ok := s.Keeper.GetProposal(sdkCtx, msg.ProposalID); ok {
|
|
return nil, fmt.Errorf("council: proposal %q already exists", msg.ProposalID)
|
|
}
|
|
|
|
// The Council must exist in the runtime store.
|
|
council, ok := s.Keeper.GetCouncil(sdkCtx, msg.CouncilID)
|
|
if !ok {
|
|
return nil, fmt.Errorf("council: council %q not found", msg.CouncilID)
|
|
}
|
|
|
|
// Proposal-target validation via the StandKeeper / GuildKeeper shim.
|
|
// The kind must be consistent with the Council's kind (a Stand-kind
|
|
// Proposal targets a Stand Council; a Guild-kind Proposal targets a
|
|
// Guild Council; a Mesh-kind Proposal targets a Mesh Council). A nil
|
|
// shim skips the check (simtest wiring).
|
|
switch msg.Kind {
|
|
case types.ProposalKindStand:
|
|
if council.Kind != types.CouncilStand {
|
|
return nil, fmt.Errorf("council: Stand-kind proposal targets a non-Stand council %q (kind %q)", msg.CouncilID, council.Kind)
|
|
}
|
|
if s.Keeper.standKeeper != nil {
|
|
if !s.Keeper.standKeeper.StandExists(council.StandIDRef) {
|
|
return nil, fmt.Errorf("council: stand %q does not exist (SubmitProposal rejected — stand-target validation)", council.StandIDRef)
|
|
}
|
|
}
|
|
case types.ProposalKindGuild:
|
|
if council.Kind != types.CouncilGuild {
|
|
return nil, fmt.Errorf("council: Guild-kind proposal targets a non-Guild council %q (kind %q)", msg.CouncilID, council.Kind)
|
|
}
|
|
if s.Keeper.guildKeeper != nil {
|
|
if !s.Keeper.guildKeeper.GuildExists(council.GuildIDRef) {
|
|
return nil, fmt.Errorf("council: guild %q does not exist (SubmitProposal rejected — guild-target validation)", council.GuildIDRef)
|
|
}
|
|
}
|
|
case types.ProposalKindMesh:
|
|
if council.Kind != types.CouncilMesh {
|
|
return nil, fmt.Errorf("council: Mesh-kind proposal targets a non-Mesh council %q (kind %q)", msg.CouncilID, council.Kind)
|
|
}
|
|
// Mesh Council has no target ref.
|
|
default:
|
|
// ProposalMissionLockAmendmentRejected never reaches here
|
|
// (ValidateBasic rejects it — D-064). The default is defence in
|
|
// depth.
|
|
return nil, fmt.Errorf("council: proposal kind %q not valid for submission (D-064 — MissionLockAmendment-Rejected rejected at ValidateBasic)", msg.Kind)
|
|
}
|
|
|
|
proposal := types.Proposal{
|
|
ProposalID: msg.ProposalID,
|
|
CouncilID: msg.CouncilID,
|
|
Kind: msg.Kind,
|
|
ProposerReach: msg.ProposerReach,
|
|
SubmitTime: msg.SubmitTime,
|
|
VotingDeadline: msg.VotingDeadline,
|
|
Status: types.ProposalStatusPending,
|
|
Tally: types.TallyResult{}, // zero-value: Yes=0, No=0, Abstain=0, NoWithVeto=0
|
|
}
|
|
s.Keeper.SetProposal(sdkCtx, proposal)
|
|
|
|
sdkCtx.EventManager().EmitEvent(sdk.NewEvent(
|
|
"council.proposal_submitted",
|
|
sdk.NewAttribute("proposal_id", msg.ProposalID),
|
|
sdk.NewAttribute("council_id", msg.CouncilID),
|
|
sdk.NewAttribute("kind", string(msg.Kind)),
|
|
sdk.NewAttribute("proposer_reach", msg.ProposerReach),
|
|
sdk.NewAttribute("status", string(types.ProposalStatusPending)),
|
|
))
|
|
return &types.MsgSubmitProposalResponse{}, nil
|
|
}
|
|
|
|
// --- Vote (records a VoteOption; Veto requires Watcher authz) --------------
|
|
|
|
// Vote records a Vote on a Proposal. The handler enforces:
|
|
// 1. ValidateBasic (stateless).
|
|
// 2. Idempotency: vote-id must not already exist.
|
|
// 3. The Proposal must exist.
|
|
// 4. The Proposal must be Active (vote-on-non-Active REJECTED — the
|
|
// simtest transitions Pending → Active before voting).
|
|
// 5. The voting deadline must not have passed (vote-after-deadline
|
|
// REJECTED).
|
|
// 6. Veto authz via the WatcherKeeper shim: if Option == VoteOptionVeto,
|
|
// the voter-reach must be a Watcher (IsWatcher). A nil shim skips the
|
|
// authz (simtest wiring); a non-nil shim that returns false REJECTS
|
|
// the Veto (the Vote is NOT recorded). The Veto quorum check is at
|
|
// TALLY, not at VOTE — the single-Veto-no-block rule (anti-greed,
|
|
// vision §19) means a single Veto is recorded but does NOT block;
|
|
// the quorum (default 6 per D-065/A-574) must be met at tally to FAIL
|
|
// the proposal.
|
|
//
|
|
// On success the Vote is persisted, the Proposal's Tally is updated
|
|
// (Yes/No/Abstain/NoWithVeto counts incremented), and an event is emitted.
|
|
func (s msgServer) Vote(ctx interface{}, msg *types.MsgVote) (*types.MsgVoteResponse, error) {
|
|
if err := msg.ValidateBasic(); err != nil {
|
|
return nil, err
|
|
}
|
|
sdkCtx := unwrapCtx(ctx)
|
|
|
|
// Idempotency: vote-id must not already exist.
|
|
if _, ok := s.Keeper.GetVote(sdkCtx, msg.VoteID); ok {
|
|
return nil, fmt.Errorf("council: vote %q already exists", msg.VoteID)
|
|
}
|
|
|
|
// The Proposal must exist.
|
|
proposal, ok := s.Keeper.GetProposal(sdkCtx, msg.ProposalID)
|
|
if !ok {
|
|
return nil, fmt.Errorf("council: proposal %q not found", msg.ProposalID)
|
|
}
|
|
|
|
// The Proposal must be Active (vote-on-non-Active REJECTED).
|
|
if proposal.Status != types.ProposalStatusActive {
|
|
return nil, fmt.Errorf("council: proposal %q status %q is not Active (vote rejected)", msg.ProposalID, proposal.Status)
|
|
}
|
|
|
|
// The voting deadline must not have passed (vote-after-deadline
|
|
// REJECTED). now = block time; if now >= VotingDeadline, the window
|
|
// is closed.
|
|
now := nowUnix(sdkCtx)
|
|
if now >= proposal.VotingDeadline {
|
|
return nil, fmt.Errorf("council: proposal %q voting deadline %d has passed (now %d) — vote rejected", msg.ProposalID, proposal.VotingDeadline, now)
|
|
}
|
|
|
|
// Veto authz via the WatcherKeeper shim. If Option == VoteOptionVeto,
|
|
// the voter-reach must be a Watcher. A nil shim skips the authz
|
|
// (simtest wiring); a non-nil shim that returns false REJECTS the
|
|
// Veto (the Vote is NOT recorded). The Veto quorum check is at
|
|
// TALLY, not at VOTE.
|
|
if msg.Option == types.VoteOptionVeto && s.Keeper.watcherKeeper != nil {
|
|
if !s.Keeper.watcherKeeper.IsWatcher(msg.VoterReach) {
|
|
return nil, fmt.Errorf("council: voter %q is not a Watcher (Veto requires Watcher authz — D-065/A-574)", msg.VoterReach)
|
|
}
|
|
}
|
|
|
|
// Record the Vote.
|
|
vote := types.Vote{
|
|
VoteID: msg.VoteID,
|
|
ProposalID: msg.ProposalID,
|
|
VoterReach: msg.VoterReach,
|
|
Option: msg.Option,
|
|
Timestamp: now,
|
|
}
|
|
s.Keeper.SetVote(sdkCtx, vote)
|
|
|
|
// Update the Proposal's running Tally.
|
|
switch msg.Option {
|
|
case types.VoteOptionYes:
|
|
proposal.Tally.Yes++
|
|
case types.VoteOptionNo:
|
|
proposal.Tally.No++
|
|
case types.VoteOptionAbstain:
|
|
proposal.Tally.Abstain++
|
|
case types.VoteOptionVeto:
|
|
// NoWithVeto is POPULATED by Watcher Vetos (D-060 — the v0.2
|
|
// zero-locked field is now populated; G-017 reconciles the v0.2
|
|
// regression: the DEFAULT tally has NoWithVeto=0, but a tally
|
|
// after a Watcher Veto quorum has NoWithVeto > 0).
|
|
proposal.Tally.NoWithVeto++
|
|
}
|
|
proposal.Tally.Total++
|
|
s.Keeper.SetProposal(sdkCtx, proposal)
|
|
|
|
sdkCtx.EventManager().EmitEvent(sdk.NewEvent(
|
|
"council.vote_cast",
|
|
sdk.NewAttribute("vote_id", msg.VoteID),
|
|
sdk.NewAttribute("proposal_id", msg.ProposalID),
|
|
sdk.NewAttribute("voter_reach", msg.VoterReach),
|
|
sdk.NewAttribute("option", string(msg.Option)),
|
|
))
|
|
return &types.MsgVoteResponse{}, nil
|
|
}
|
|
|
|
// --- TallyProposal (close voting, compute tally, transition) ---------------
|
|
|
|
// TallyProposal tallies a Proposal: closes the voting deadline, computes
|
|
// the Yes/No/Abstain/Veto tally, and transitions the Proposal to Succeeded
|
|
// (Yes quorum met, Veto quorum NOT met) or Failed (No quorum OR Veto
|
|
// quorum met — D-065/A-574). The handler enforces:
|
|
// 1. ValidateBasic (stateless).
|
|
// 2. The Proposal must exist.
|
|
// 3. The voting deadline must have passed (tally-before-deadline
|
|
// REJECTED — the tally closes the window).
|
|
// 4. The Proposal must be Active (tally-on-non-Active REJECTED — a
|
|
// Pending proposal has not opened voting; a Succeeded/Failed/
|
|
// Executed proposal is already tallied).
|
|
//
|
|
// Veto semantics (D-065/A-574): a single Veto does NOT block (anti-greed,
|
|
// vision §19); the proposal transitions to Failed only if
|
|
// NoWithVeto >= WatcherVetoQuorum (default 6). The handler reads the
|
|
// WatcherVetoQuorum from the Params (the Keeper holds the Params); the
|
|
// simtest overrides the Params to test the quorum boundary.
|
|
//
|
|
// On success the Proposal's Tally is finalized (the running tally is
|
|
// already maintained by Vote; the handler recomputes from the Vote
|
|
// store for defence in depth), the Status transitions to Succeeded or
|
|
// Failed, and an event is emitted. No auto-execution (the Executed
|
|
// status exists in the enum but the handler does not transition to it —
|
|
// execution is v0.6+).
|
|
func (s msgServer) TallyProposal(ctx interface{}, msg *types.MsgTallyProposal) (*types.MsgTallyProposalResponse, error) {
|
|
if err := msg.ValidateBasic(); err != nil {
|
|
return nil, err
|
|
}
|
|
sdkCtx := unwrapCtx(ctx)
|
|
|
|
// The Proposal must exist.
|
|
proposal, ok := s.Keeper.GetProposal(sdkCtx, msg.ProposalID)
|
|
if !ok {
|
|
return nil, fmt.Errorf("council: proposal %q not found", msg.ProposalID)
|
|
}
|
|
|
|
// The Proposal must be Active (tally-on-non-Active REJECTED).
|
|
if proposal.Status != types.ProposalStatusActive {
|
|
return nil, fmt.Errorf("council: proposal %q status %q is not Active (tally rejected)", msg.ProposalID, proposal.Status)
|
|
}
|
|
|
|
// The voting deadline must have passed (tally-before-deadline
|
|
// REJECTED). now = block time; if now < VotingDeadline, the window
|
|
// is still open.
|
|
now := nowUnix(sdkCtx)
|
|
if now < proposal.VotingDeadline {
|
|
return nil, fmt.Errorf("council: proposal %q voting deadline %d not yet reached (now %d) — tally rejected", msg.ProposalID, proposal.VotingDeadline, now)
|
|
}
|
|
|
|
// Recompute the tally from the Vote store (defence in depth — the
|
|
// running tally in proposal.Tally should already match, but the
|
|
// handler recomputes to guard against any drift).
|
|
votes := s.Keeper.VotesForProposal(sdkCtx, msg.ProposalID)
|
|
tally := types.TallyResult{}
|
|
for _, v := range votes {
|
|
switch v.Option {
|
|
case types.VoteOptionYes:
|
|
tally.Yes++
|
|
case types.VoteOptionNo:
|
|
tally.No++
|
|
case types.VoteOptionAbstain:
|
|
tally.Abstain++
|
|
case types.VoteOptionVeto:
|
|
tally.NoWithVeto++
|
|
}
|
|
tally.Total++
|
|
}
|
|
|
|
// Veto quorum check (D-065/A-574). The WatcherVetoQuorum is from the
|
|
// Params (default 6). A single Veto does NOT block (anti-greed,
|
|
// vision §19); the proposal transitions to Failed only if
|
|
// NoWithVeto >= WatcherVetoQuorum.
|
|
vetoQuorum := s.Keeper.GetParams().WatcherVetoQuorum
|
|
if vetoQuorum == 0 {
|
|
// Defence in depth: a zero quorum (e.g., from a zero-value Params
|
|
// not set via DefaultParams) would block on any Veto, violating
|
|
// the single-Veto-no-block rule. Fall back to the default (6).
|
|
vetoQuorum = types.WatcherVetoQuorumDefault
|
|
}
|
|
|
|
// Determine the outcome.
|
|
// - Veto quorum met → Failed (D-065/A-574).
|
|
// - Else: Yes > No (Abstain excluded) → Succeeded; else → Failed.
|
|
// A tie (Yes == No) → Failed (the proposal does not pass).
|
|
vetoQuorumMet := tally.NoWithVeto >= uint64(vetoQuorum)
|
|
var newStatus types.ProposalStatus
|
|
if vetoQuorumMet {
|
|
newStatus = types.ProposalStatusFailed
|
|
} else if tally.Yes > tally.No {
|
|
newStatus = types.ProposalStatusSucceeded
|
|
} else {
|
|
newStatus = types.ProposalStatusFailed
|
|
}
|
|
|
|
// Finalize the tally on the Proposal.
|
|
proposal.Tally = tally
|
|
proposal.Tally.QuorumMet = (tally.Yes + tally.No + tally.Abstain + tally.NoWithVeto) > 0
|
|
proposal.Status = newStatus
|
|
s.Keeper.SetProposal(sdkCtx, proposal)
|
|
|
|
sdkCtx.EventManager().EmitEvent(sdk.NewEvent(
|
|
"council.proposal_tallied",
|
|
sdk.NewAttribute("proposal_id", msg.ProposalID),
|
|
sdk.NewAttribute("yes", fmt.Sprintf("%d", tally.Yes)),
|
|
sdk.NewAttribute("no", fmt.Sprintf("%d", tally.No)),
|
|
sdk.NewAttribute("abstain", fmt.Sprintf("%d", tally.Abstain)),
|
|
sdk.NewAttribute("nowithveto", fmt.Sprintf("%d", tally.NoWithVeto)),
|
|
sdk.NewAttribute("total", fmt.Sprintf("%d", tally.Total)),
|
|
sdk.NewAttribute("veto_quorum", fmt.Sprintf("%d", vetoQuorum)),
|
|
sdk.NewAttribute("status", string(newStatus)),
|
|
))
|
|
return &types.MsgTallyProposalResponse{}, nil
|
|
}
|