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 }