Files
openyield/x/council/keeper/msg_server.go
T
cloudinit-bot 6c34650a0d
docs-build / go test ./... (lexicon firewall + all x/* tests) (push) Has been cancelled
docs-build / mkdocs build (docs site artifact) (push) Has been cancelled
Merge milestone/v0.5-bearers-runtime into main (v0.5 Bearers Runtime feature milestone release)
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---
2026-08-18 03:42:01 +00:00

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
}