From 5299b8dc64378de1777c90ef9209857a7fd7d7b8 Mon Sep 17 00:00:00 2001 From: cloudinit-bot Date: Tue, 18 Aug 2026 01:15:45 +0000 Subject: [PATCH] =?UTF-8?q?Merge=20phase/07=20into=20milestone/v0.5-bearer?= =?UTF-8?q?s-runtime=20(P7=20complete=20=E2=86=92=20v0.4.7)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ---ci--- project: oy phase: 7 milestone: v0.5 status: complete requirements: covered: [REQ-039] partial: [] ---/ci--- --- x/council/keeper/keeper.go | 244 +++++ x/council/keeper/msg_server.go | 384 ++++++++ x/council/keeper/msg_server_simtest_test.go | 966 ++++++++++++++++++++ x/council/module.go | 101 ++ x/council/types/expected_keepers.go | 106 +++ x/council/types/genesis.go | 68 ++ x/council/types/msg.go | 260 ++++++ x/council/types/types.go | 297 +++++- x/council/types/types_test.go | 485 +++++++++- 9 files changed, 2888 insertions(+), 23 deletions(-) create mode 100644 x/council/keeper/keeper.go create mode 100644 x/council/keeper/msg_server.go create mode 100644 x/council/keeper/msg_server_simtest_test.go create mode 100644 x/council/module.go create mode 100644 x/council/types/expected_keepers.go create mode 100644 x/council/types/msg.go diff --git a/x/council/keeper/keeper.go b/x/council/keeper/keeper.go new file mode 100644 index 0000000..7c7f7b5 --- /dev/null +++ b/x/council/keeper/keeper.go @@ -0,0 +1,244 @@ +package keeper + +import ( + "encoding/json" + "fmt" + + storetypes "cosmossdk.io/store/types" + "github.com/cosmos/cosmos-sdk/codec" + sdk "github.com/cosmos/cosmos-sdk/types" + + "github.com/oy/openyield/x/council/types" +) + +// keeper.go holds the store-backed Keeper for the council module's +// Proposal-lifecycle runtime (P7-02-01, REQ-039, D-060). +// +// The Keeper wraps an sdk.KVStore via a storeKey. It holds the Proposal +// records (by proposal-id) and the Vote records (by vote-id). The v0.2 +// skeleton had NO keeper (only types/); v0.5 (P7) promotes the council +// module to runtime by adding the store-backed Keeper + MsgServer. +// +// The Keeper also holds the three expected-keeper shims (WatcherKeeper +// for Veto authz; StandKeeper + GuildKeeper for proposal-target +// validation). The shims are interfaces (G-003 — no struct import of +// x/watcher/types, x/stand/types, or x/guild/types); the concrete +// keepers satisfy them structurally. +// +// State-machine ordering (vision §7, enforced in every handler): +// ValidateBasic → keeper authz → state mutation → ctx.EventManager().EmitEvent + +// Keeper is the store-backed council Proposal-lifecycle keeper. +type Keeper struct { + cdc codec.Codec + storeKey storetypes.StoreKey + watcherKeeper types.WatcherKeeper + standKeeper types.StandKeeper + guildKeeper types.GuildKeeper + params types.Params +} + +// NewKeeper constructs a new store-backed council Proposal-lifecycle +// Keeper. The WatcherKeeper, StandKeeper, and GuildKeeper expected-keeper +// shims are injected (nil-able for partial tests; the handlers guard nil +// shims and skip the corresponding authz/validity check, still mutating +// state — the simtest wiring documents this). The Params default is set +// here; the simtest can override via SetParams. +func NewKeeper(cdc codec.Codec, storeKey storetypes.StoreKey, wk types.WatcherKeeper, sk types.StandKeeper, gk types.GuildKeeper) Keeper { + return Keeper{ + cdc: cdc, + storeKey: storeKey, + watcherKeeper: wk, + standKeeper: sk, + guildKeeper: gk, + params: types.DefaultParams(), + } +} + +// SetWatcherKeeper sets the WatcherKeeper expected-keeper shim (for +// post-construction wiring, e.g., app wiring or test setup). +func (k *Keeper) SetWatcherKeeper(wk types.WatcherKeeper) { k.watcherKeeper = wk } + +// SetStandKeeper sets the StandKeeper expected-keeper shim (for +// post-construction wiring). +func (k *Keeper) SetStandKeeper(sk types.StandKeeper) { k.standKeeper = sk } + +// SetGuildKeeper sets the GuildKeeper expected-keeper shim (for +// post-construction wiring). +func (k *Keeper) SetGuildKeeper(gk types.GuildKeeper) { k.guildKeeper = gk } + +// SetParams sets the council Params (the simtest overrides +// WatcherVetoQuorum for the quorum-Veto-fails test). +func (k *Keeper) SetParams(p types.Params) { k.params = p } + +// GetParams returns the council Params. +func (k Keeper) GetParams() types.Params { return k.params } + +// --- Proposal store -------------------------------------------------------- + +var proposalKeyPrefix = []byte("proposal/") + +func proposalKey(proposalID string) []byte { + return append(proposalKeyPrefix, []byte(proposalID)...) +} + +// GetProposal loads a Proposal by proposal-id. Returns the Proposal and +// true if found, or zero value + false if not. +func (k Keeper) GetProposal(ctx sdk.Context, proposalID string) (types.Proposal, bool) { + store := ctx.KVStore(k.storeKey) + bz := store.Get(proposalKey(proposalID)) + if bz == nil { + return types.Proposal{}, false + } + var p types.Proposal + if err := json.Unmarshal(bz, &p); err != nil { + return types.Proposal{}, false + } + return p, true +} + +// SetProposal persists a Proposal by proposal-id. +func (k Keeper) SetProposal(ctx sdk.Context, p types.Proposal) { + store := ctx.KVStore(k.storeKey) + bz, err := json.Marshal(p) + if err != nil { + panic(fmt.Sprintf("council: marshal proposal %q: %v", p.ProposalID, err)) + } + store.Set(proposalKey(p.ProposalID), bz) +} + +// AllProposals returns all persisted Proposal records (iteration helper). +func (k Keeper) AllProposals(ctx sdk.Context) []types.Proposal { + store := ctx.KVStore(k.storeKey) + iterator := store.Iterator(proposalKeyPrefix, prefixEnd(proposalKeyPrefix)) + defer iterator.Close() + out := []types.Proposal{} + for ; iterator.Valid(); iterator.Next() { + var p types.Proposal + if err := json.Unmarshal(iterator.Value(), &p); err == nil { + out = append(out, p) + } + } + return out +} + +// --- Vote store ------------------------------------------------------------ + +var voteKeyPrefix = []byte("vote/") + +func voteKey(voteID string) []byte { + return append(voteKeyPrefix, []byte(voteID)...) +} + +// GetVote loads a Vote by vote-id. Returns the Vote and true if found, +// or zero value + false if not. +func (k Keeper) GetVote(ctx sdk.Context, voteID string) (types.Vote, bool) { + store := ctx.KVStore(k.storeKey) + bz := store.Get(voteKey(voteID)) + if bz == nil { + return types.Vote{}, false + } + var v types.Vote + if err := json.Unmarshal(bz, &v); err != nil { + return types.Vote{}, false + } + return v, true +} + +// SetVote persists a Vote by vote-id. +func (k Keeper) SetVote(ctx sdk.Context, v types.Vote) { + store := ctx.KVStore(k.storeKey) + bz, err := json.Marshal(v) + if err != nil { + panic(fmt.Sprintf("council: marshal vote %q: %v", v.VoteID, err)) + } + store.Set(voteKey(v.VoteID), bz) +} + +// AllVotes returns all persisted Vote records (iteration helper). +func (k Keeper) AllVotes(ctx sdk.Context) []types.Vote { + store := ctx.KVStore(k.storeKey) + iterator := store.Iterator(voteKeyPrefix, prefixEnd(voteKeyPrefix)) + defer iterator.Close() + out := []types.Vote{} + for ; iterator.Valid(); iterator.Next() { + var v types.Vote + if err := json.Unmarshal(iterator.Value(), &v); err == nil { + out = append(out, v) + } + } + return out +} + +// VotesForProposal returns all persisted Vote records for a given +// proposal-id (iteration + filter helper; used by the TallyProposal +// handler to compute the tally). +func (k Keeper) VotesForProposal(ctx sdk.Context, proposalID string) []types.Vote { + all := k.AllVotes(ctx) + out := []types.Vote{} + for _, v := range all { + if v.ProposalID == proposalID { + out = append(out, v) + } + } + return out +} + +// --- Council store (for SubmitProposal target validation) ------------------ + +var councilKeyPrefix = []byte("council/") + +func councilKey(councilID string) []byte { + return append(councilKeyPrefix, []byte(councilID)...) +} + +// GetCouncil loads a Council by council-id from the runtime store. +// Returns the Council and true if found, or zero value + false if not. +// The Council store is the runtime home for the v0.2 skeleton Council +// struct (the v0.2 skeleton had Council only in genesis; v0.5 promotes +// it to the runtime store so the SubmitProposal handler can validate the +// proposal-target against the Council's stand-id-ref / guild-id-ref). +func (k Keeper) GetCouncil(ctx sdk.Context, councilID string) (types.Council, bool) { + store := ctx.KVStore(k.storeKey) + bz := store.Get(councilKey(councilID)) + if bz == nil { + return types.Council{}, false + } + var c types.Council + if err := json.Unmarshal(bz, &c); err != nil { + return types.Council{}, false + } + return c, true +} + +// SetCouncil persists a Council by council-id (runtime store home for the +// v0.2 skeleton Council struct; the simtest seeds a Council for the +// SubmitProposal target validation). +func (k Keeper) SetCouncil(ctx sdk.Context, c types.Council) { + store := ctx.KVStore(k.storeKey) + bz, err := json.Marshal(c) + if err != nil { + panic(fmt.Sprintf("council: marshal council %q: %v", c.CouncilID, err)) + } + store.Set(councilKey(c.CouncilID), bz) +} + +// prefixEnd returns the key that sorts immediately after all keys sharing +// the given prefix (the standard prefix-iteration end key: increment the +// last byte, drop overflow). Used for store.Iterator(start, prefixEnd(start)) +// prefix scans. +func prefixEnd(prefix []byte) []byte { + if len(prefix) == 0 { + return nil + } + end := make([]byte, len(prefix)) + copy(end, prefix) + for i := len(end) - 1; i >= 0; i-- { + end[i]++ + if end[i] != 0 { + return end + } + } + // All bytes were 0xFF; return nil (iterate to end of store). + return nil +} diff --git a/x/council/keeper/msg_server.go b/x/council/keeper/msg_server.go new file mode 100644 index 0000000..5f9336e --- /dev/null +++ b/x/council/keeper/msg_server.go @@ -0,0 +1,384 @@ +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 +} diff --git a/x/council/keeper/msg_server_simtest_test.go b/x/council/keeper/msg_server_simtest_test.go new file mode 100644 index 0000000..2abf1ed --- /dev/null +++ b/x/council/keeper/msg_server_simtest_test.go @@ -0,0 +1,966 @@ +package keeper_test + +// msg_server_simtest_test.go is the x/council keeper simtest (P7-04-01, +// REQ-039, D-060). +// +// D-054: simtest-grade — in-memory sdk.Context + dbm in-memory store, no +// real watcher/stand/guild keepers. The simtest wires the expected-keeper +// shims (WatcherKeeper, StandKeeper, GuildKeeper) to in-test stubs +// (G-003 test exemption: the test imports x/council/keeper + defines stub +// types that satisfy the interfaces; no production struct imports across +// x//types). +// +// Coverage (REQ-039 lifecycle Pending → Active → Vote → Tally → +// Succeeded/Failed): +// - Full success lifecycle: Submit (Pending) → Active → Vote (Yes) → +// Tally → Succeeded. +// - MissionLockAmendment-Rejected kind REJECTED at ValidateBasic +// (D-064/A-572 — the message never reaches the handler; the keeper +// Proposal store stays empty). +// - Veto semantics (D-065/A-574): +// - Single Veto does NOT block (anti-greed, vision §19): a single +// Veto + majority Yes → Succeeded. +// - Veto quorum (default 6) → Failed: 6 Vetos → Failed. +// - Quorum boundary: quorum-1 = 5 Vetos (below default 6) + majority +// Yes → Succeeded; quorum-6 = 6 Vetos → Failed. +// - Watcher authz for Veto: a non-Watcher casting Veto is REJECTED +// (the Vote is NOT recorded). +// - Vote-on-non-Active REJECTED (vote on a Pending proposal → error). +// - Vote-after-deadline REJECTED (now >= VotingDeadline → error). +// - Tally-before-deadline REJECTED (now < VotingDeadline → error). +// - Tally-on-non-Active REJECTED (tally on a Pending proposal → error). +// - Idempotency: duplicate proposal-id + duplicate vote-id → error. +// - NotFound: Vote/Tally on a missing proposal-id → error. +// - Proposal-target validation: Stand-kind Proposal on a non-Stand +// Council REJECTED; Guild-kind Proposal on a non-Guild Council +// REJECTED; Stand-kind Proposal with a non-existent stand-id-ref +// REJECTED (via the StandKeeper stub). +// - ValidateBasic: each Msg* ValidateBasic error path. +// +// Coverage target: ≥80% on x/council/keeper. + +import ( + "testing" + "time" + + "cosmossdk.io/log" + "cosmossdk.io/store" + storetypes "cosmossdk.io/store/types" + cmtproto "github.com/cometbft/cometbft/proto/tendermint/types" + dbm "github.com/cosmos/cosmos-db" + "github.com/cosmos/cosmos-sdk/codec" + codectypes "github.com/cosmos/cosmos-sdk/codec/types" + sdk "github.com/cosmos/cosmos-sdk/types" + + "github.com/oy/openyield/x/council/keeper" + "github.com/oy/openyield/x/council/types" +) + +// --- Stub expected-keepers (G-003 test exemption) --------------------------- + +// stubWatcherKeeper satisfies types.WatcherKeeper for the simtest. It +// records IsWatcher + CountWatchers calls for assertion and returns the +// configured watcher-set + per-reach-id watcher membership. +type stubWatcherKeeper struct { + isWatcher map[string]bool // reach-id → is-watcher + watcherCount int // total Watcher set size (default 9 per REQ-004) + calls []string // recorded IsWatcher reach-ids +} + +func (s *stubWatcherKeeper) IsWatcher(reachID string) bool { + s.calls = append(s.calls, reachID) + if s.isWatcher != nil { + return s.isWatcher[reachID] + } + return true // default: all are Watchers (simtest wiring) +} + +func (s *stubWatcherKeeper) CountWatchers() int { + if s.watcherCount == 0 { + return 9 // REQ-004: 9 Watchers + } + return s.watcherCount +} + +// stubStandKeeper satisfies types.StandKeeper for the simtest. Returns +// the configured existence per stand-id (default: exists=true). +type stubStandKeeper struct { + exists map[string]bool +} + +func (s *stubStandKeeper) StandExists(standID string) bool { + if s.exists != nil { + return s.exists[standID] + } + return true // default: exists (simtest wiring) +} + +// stubGuildKeeper satisfies types.GuildKeeper for the simtest. +type stubGuildKeeper struct { + exists map[string]bool +} + +func (s *stubGuildKeeper) GuildExists(guildID string) bool { + if s.exists != nil { + return s.exists[guildID] + } + return true // default: exists (simtest wiring) +} + +// --- Simtest context helper -------------------------------------------------- + +// newSimtestContext constructs an in-memory sdk.Context with a KVStore +// mounted at the council store key. D-054: in-memory, no real +// watcher/stand/guild keepers. Returns the ctx, the stub WatcherKeeper, +// the stub StandKeeper, the stub GuildKeeper, and the Keeper. +func newSimtestContext(t *testing.T) (sdk.Context, *stubWatcherKeeper, *stubStandKeeper, *stubGuildKeeper, keeper.Keeper) { + t.Helper() + db := dbm.NewMemDB() + cdc := newTestCodec() + storeKey := storetypes.NewKVStoreKey(types.StoreKey) + cms := store.NewCommitMultiStore(db, log.NewNopLogger(), nil) + cms.MountStoreWithDB(storeKey, storetypes.StoreTypeDB, nil) + if err := cms.LoadLatestVersion(); err != nil { + t.Fatalf("load latest version: %v", err) + } + // Block time set to a fixed unix second so lifecycle timestamps are + // deterministic (now = 1000). + ctx := sdk.NewContext(cms, cmtproto.Header{Time: time.Unix(1000, 0)}, false, log.NewNopLogger()) + + wk := &stubWatcherKeeper{} + sk := &stubStandKeeper{} + gk := &stubGuildKeeper{} + k := keeper.NewKeeper(cdc, storeKey, wk, sk, gk) + return ctx, wk, sk, gk, k +} + +// newTestCodec constructs a minimal codec for the simtest. +func newTestCodec() codec.Codec { + registry := codectypes.NewInterfaceRegistry() + return codec.NewProtoCodec(registry) +} + +// hasEvent reports whether ctx emitted an event of the given type. +func hasEvent(ctx sdk.Context, eventType string) bool { + for _, ev := range ctx.EventManager().Events() { + if ev.Type == eventType { + return true + } + } + return false +} + +// eventAttr returns the value of an attribute on the last event of the +// given type, or "" if not found. +func eventAttr(ctx sdk.Context, eventType, attrKey string) string { + for _, ev := range ctx.EventManager().Events() { + if ev.Type == eventType { + for _, a := range ev.Attributes { + if string(a.Key) == attrKey { + return string(a.Value) + } + } + } + } + return "" +} + +// seedCouncil seeds a Council into the runtime store for the SubmitProposal +// target validation. Returns the Council. +func seedCouncil(k keeper.Keeper, ctx sdk.Context, councilID string, kind types.CouncilKind, standRef, guildRef string) types.Council { + c := types.Council{ + CouncilID: councilID, + Kind: kind, + StandIDRef: standRef, + GuildIDRef: guildRef, + Members: []types.CouncilMember{{ReachID: "reach:member-1", VoiceWeight: 1, JoinedAt: 0}}, + VoiceThreshold: 1, + } + k.SetCouncil(ctx, c) + return c +} + +// activateProposal transitions a Pending Proposal to Active (the simtest +// helper — the v0.5 keeper does not expose an Activate message; the +// handler creates Pending and the tally closes Active; the Pending → +// Active transition is the voting-window-open transition, which in a +// real chain would be triggered by the block height crossing the +// submit-time. For the simtest, the helper flips the status directly to +// enable voting). +func activateProposal(k keeper.Keeper, ctx sdk.Context, proposalID string) types.Proposal { + p, ok := k.GetProposal(ctx, proposalID) + if !ok { + panic("activateProposal: proposal not found: " + proposalID) + } + p.Status = types.ProposalStatusActive + k.SetProposal(ctx, p) + return p +} + +// newSubmitMsg returns a valid MsgSubmitProposal for a Mesh Council. +func newSubmitMsg(proposalID, councilID string, kind types.ProposalKind, deadline int64) *types.MsgSubmitProposal { + return &types.MsgSubmitProposal{ + ProposalID: proposalID, + CouncilID: councilID, + Kind: kind, + ProposerReach: "reach:prop", + SubmitTime: 500, + VotingDeadline: deadline, + Signer: "reach:prop", + } +} + +// --- Full success lifecycle: Pending → Active → Vote → Tally → Succeeded ------- + +// TestProposalLifecycleFullSuccess asserts the full success lifecycle: +// Submit (Pending) → Active → Vote (Yes majority) → Tally → Succeeded. +func TestProposalLifecycleFullSuccess(t *testing.T) { + ctx, _, _, _, k := newSimtestContext(t) + srv := keeper.NewMsgServerImpl(k) + seedCouncil(k, ctx, "cm", types.CouncilMesh, "", "") + + // Submit → Pending. + if _, err := srv.SubmitProposal(ctx, newSubmitMsg("p1", "cm", types.ProposalKindMesh, 2000)); err != nil { + t.Fatalf("SubmitProposal: %v", err) + } + p, ok := k.GetProposal(ctx, "p1") + if !ok { + t.Fatal("proposal not found after submit") + } + if p.Status != types.ProposalStatusPending { + t.Errorf("status = %q, want Pending", p.Status) + } + if p.Kind != types.ProposalKindMesh { + t.Errorf("kind = %q, want Mesh", p.Kind) + } + if !hasEvent(ctx, "council.proposal_submitted") { + t.Error("proposal_submitted event not emitted") + } + + // Pending → Active (simtest helper). + activateProposal(k, ctx, "p1") + + // Vote (3 Yes, 1 No → Yes majority → Succeeded on tally). + for i, voter := range []string{"reach:a", "reach:b", "reach:c"} { + if _, err := srv.Vote(ctx, &types.MsgVote{ + VoteID: "v-yes-" + string(rune('A'+i)), + ProposalID: "p1", VoterReach: voter, Option: types.VoteOptionYes, Signer: voter, + }); err != nil { + t.Fatalf("Vote[%d]: %v", i, err) + } + } + if _, err := srv.Vote(ctx, &types.MsgVote{ + VoteID: "v-no-1", ProposalID: "p1", VoterReach: "reach:d", Option: types.VoteOptionNo, Signer: "reach:d", + }); err != nil { + t.Fatalf("Vote No: %v", err) + } + if !hasEvent(ctx, "council.vote_cast") { + t.Error("vote_cast event not emitted") + } + + // Advance block time past the voting deadline (now=1000 < 2000; need + // now >= 2000 to tally). Re-create the ctx with a later block time. + ctx = ctx.WithBlockTime(time.Unix(3000, 0)) + + // Tally → Succeeded (Yes=3 > No=1, no Vetos). + if _, err := srv.TallyProposal(ctx, &types.MsgTallyProposal{ProposalID: "p1", Signer: "reach:tally"}); err != nil { + t.Fatalf("TallyProposal: %v", err) + } + p, _ = k.GetProposal(ctx, "p1") + if p.Status != types.ProposalStatusSucceeded { + t.Errorf("status = %q, want Succeeded (Yes=3 > No=1)", p.Status) + } + if p.Tally.Yes != 3 || p.Tally.No != 1 || p.Tally.Abstain != 0 || p.Tally.NoWithVeto != 0 || p.Tally.Total != 4 { + t.Errorf("tally = %+v, want Yes=3 No=1 Abstain=0 NoWithVeto=0 Total=4", p.Tally) + } + if !p.Tally.QuorumMet { + t.Error("QuorumMet should be true (Total > 0)") + } + if !hasEvent(ctx, "council.proposal_tallied") { + t.Error("proposal_tallied event not emitted") + } + if eventAttr(ctx, "council.proposal_tallied", "status") != string(types.ProposalStatusSucceeded) { + t.Errorf("tally event status = %q, want Succeeded", eventAttr(ctx, "council.proposal_tallied", "status")) + } +} + +// --- MissionLockAmendment-Rejected REJECTED at ValidateBasic (D-064) -------- + +// TestMissionLockAmendmentRejectedAtValidateBasic asserts the +// MissionLockAmendment-Rejected kind is REJECTED at ValidateBasic +// (D-064/A-572 — the message never reaches the handler; the keeper +// Proposal store stays empty). +func TestMissionLockAmendmentRejectedAtValidateBasic(t *testing.T) { + ctx, _, _, _, k := newSimtestContext(t) + srv := keeper.NewMsgServerImpl(k) + seedCouncil(k, ctx, "cm", types.CouncilMesh, "", "") + + msg := newSubmitMsg("p-mla", "cm", types.ProposalMissionLockAmendmentRejected, 2000) + _, err := srv.SubmitProposal(ctx, msg) + if err == nil { + t.Fatal("SubmitProposal with MissionLockAmendment-Rejected kind should be rejected at ValidateBasic (D-064)") + } + // The keeper Proposal store stays empty (the handler was never + // invoked with this kind — ValidateBasic rejected it). + if _, ok := k.GetProposal(ctx, "p-mla"); ok { + t.Error("Proposal store should be empty — the MissionLockAmendment-Rejected message never reaches the handler (D-064)") + } + if !hasEvent(ctx, "council.proposal_submitted") { + // no event emitted (the rejection is at ValidateBasic, before + // the handler emits any event) — this is correct. + } +} + +// --- Veto semantics (D-065/A-574) -------------------------------------------- + +// TestVetoSingleDoesNotBlock asserts a single Veto does NOT block +// (anti-greed, vision §19, D-065): a single Veto + majority Yes → +// Succeeded. The Veto quorum (default 6) must be met to FAIL. +func TestVetoSingleDoesNotBlock(t *testing.T) { + ctx, _, _, _, k := newSimtestContext(t) + srv := keeper.NewMsgServerImpl(k) + seedCouncil(k, ctx, "cm", types.CouncilMesh, "", "") + if _, err := srv.SubmitProposal(ctx, newSubmitMsg("p-veto-1", "cm", types.ProposalKindMesh, 2000)); err != nil { + t.Fatalf("SubmitProposal: %v", err) + } + activateProposal(k, ctx, "p-veto-1") + + // 3 Yes + 1 Veto → Yes majority, single Veto does NOT block → Succeeded. + for i, voter := range []string{"reach:a", "reach:b", "reach:c"} { + srv.Vote(ctx, &types.MsgVote{ + VoteID: "vy" + string(rune('A'+i)), ProposalID: "p-veto-1", VoterReach: voter, Option: types.VoteOptionYes, Signer: voter, + }) + } + // 1 Veto (watcher-1 is a Watcher via the default stub). + srv.Vote(ctx, &types.MsgVote{ + VoteID: "vv1", ProposalID: "p-veto-1", VoterReach: "reach:watcher-1", Option: types.VoteOptionVeto, Signer: "reach:watcher-1", + }) + + ctx = ctx.WithBlockTime(time.Unix(3000, 0)) + if _, err := srv.TallyProposal(ctx, &types.MsgTallyProposal{ProposalID: "p-veto-1", Signer: "reach:tally"}); err != nil { + t.Fatalf("TallyProposal: %v", err) + } + p, _ := k.GetProposal(ctx, "p-veto-1") + if p.Status != types.ProposalStatusSucceeded { + t.Errorf("status = %q, want Succeeded (single Veto does NOT block — D-065 anti-greed; Yes=3 > No=0)", p.Status) + } + if p.Tally.NoWithVeto != 1 { + t.Errorf("NoWithVeto = %d, want 1 (single Veto recorded but does NOT block)", p.Tally.NoWithVeto) + } +} + +// TestVetoQuorumBlocks asserts the Veto quorum (default 6) FAILS the +// proposal: 6 Vetos → Failed (D-065/A-574). +func TestVetoQuorumBlocks(t *testing.T) { + ctx, _, _, _, k := newSimtestContext(t) + srv := keeper.NewMsgServerImpl(k) + seedCouncil(k, ctx, "cm", types.CouncilMesh, "", "") + if _, err := srv.SubmitProposal(ctx, newSubmitMsg("p-veto-q", "cm", types.ProposalKindMesh, 2000)); err != nil { + t.Fatalf("SubmitProposal: %v", err) + } + activateProposal(k, ctx, "p-veto-q") + + // 2 Yes + 6 Vetos → Veto quorum met → Failed. + srv.Vote(ctx, &types.MsgVote{VoteID: "vy1", ProposalID: "p-veto-q", VoterReach: "reach:a", Option: types.VoteOptionYes, Signer: "reach:a"}) + srv.Vote(ctx, &types.MsgVote{VoteID: "vy2", ProposalID: "p-veto-q", VoterReach: "reach:b", Option: types.VoteOptionYes, Signer: "reach:b"}) + for i := 0; i < 6; i++ { + voter := "reach:watcher-" + string(rune('A'+i)) + srv.Vote(ctx, &types.MsgVote{ + VoteID: "vv" + string(rune('A'+i)), ProposalID: "p-veto-q", VoterReach: voter, Option: types.VoteOptionVeto, Signer: voter, + }) + } + + ctx = ctx.WithBlockTime(time.Unix(3000, 0)) + if _, err := srv.TallyProposal(ctx, &types.MsgTallyProposal{ProposalID: "p-veto-q", Signer: "reach:tally"}); err != nil { + t.Fatalf("TallyProposal: %v", err) + } + p, _ := k.GetProposal(ctx, "p-veto-q") + if p.Status != types.ProposalStatusFailed { + t.Errorf("status = %q, want Failed (Veto quorum met — 6 Vetos >= default 6 per D-065/A-574)", p.Status) + } + if p.Tally.NoWithVeto != 6 { + t.Errorf("NoWithVeto = %d, want 6 (quorum)", p.Tally.NoWithVeto) + } +} + +// TestVetoQuorumBoundary asserts the quorum boundary: 5 Vetos (below the +// default 6) + majority Yes → Succeeded; 6 Vetos → Failed. +func TestVetoQuorumBoundary(t *testing.T) { + ctx, _, _, _, k := newSimtestContext(t) + srv := keeper.NewMsgServerImpl(k) + seedCouncil(k, ctx, "cm", types.CouncilMesh, "", "") + if _, err := srv.SubmitProposal(ctx, newSubmitMsg("p-bnd", "cm", types.ProposalKindMesh, 2000)); err != nil { + t.Fatalf("SubmitProposal: %v", err) + } + activateProposal(k, ctx, "p-bnd") + + // 3 Yes + 5 Vetos (below default quorum 6) → Succeeded. + srv.Vote(ctx, &types.MsgVote{VoteID: "vy1", ProposalID: "p-bnd", VoterReach: "reach:a", Option: types.VoteOptionYes, Signer: "reach:a"}) + srv.Vote(ctx, &types.MsgVote{VoteID: "vy2", ProposalID: "p-bnd", VoterReach: "reach:b", Option: types.VoteOptionYes, Signer: "reach:b"}) + srv.Vote(ctx, &types.MsgVote{VoteID: "vy3", ProposalID: "p-bnd", VoterReach: "reach:c", Option: types.VoteOptionYes, Signer: "reach:c"}) + for i := 0; i < 5; i++ { + voter := "reach:watcher-" + string(rune('A'+i)) + srv.Vote(ctx, &types.MsgVote{ + VoteID: "vv" + string(rune('A'+i)), ProposalID: "p-bnd", VoterReach: voter, Option: types.VoteOptionVeto, Signer: voter, + }) + } + + ctx = ctx.WithBlockTime(time.Unix(3000, 0)) + if _, err := srv.TallyProposal(ctx, &types.MsgTallyProposal{ProposalID: "p-bnd", Signer: "reach:tally"}); err != nil { + t.Fatalf("TallyProposal (5 Vetos, below quorum): %v", err) + } + p, _ := k.GetProposal(ctx, "p-bnd") + if p.Status != types.ProposalStatusSucceeded { + t.Errorf("status = %q, want Succeeded (5 Vetos < default quorum 6 — single-Veto-no-block quorum rule; Yes=3 > No=0)", p.Status) + } + if p.Tally.NoWithVeto != 5 { + t.Errorf("NoWithVeto = %d, want 5 (below quorum)", p.Tally.NoWithVeto) + } +} + +// TestVetoQuorumCustom asserts the WatcherVetoQuorum Params field is +// honored: setting the quorum to 3 makes 3 Vetos FAIL the proposal. The +// Params must be set BEFORE constructing the MsgServer (the server embeds +// the Keeper by value). +func TestVetoQuorumCustom(t *testing.T) { + ctx, _, _, _, k := newSimtestContext(t) + // Override the quorum to 3 BEFORE constructing the MsgServer. + k.SetParams(types.Params{WatcherVetoQuorum: 3}) + srv := keeper.NewMsgServerImpl(k) + seedCouncil(k, ctx, "cm", types.CouncilMesh, "", "") + + if _, err := srv.SubmitProposal(ctx, newSubmitMsg("p-cq", "cm", types.ProposalKindMesh, 2000)); err != nil { + t.Fatalf("SubmitProposal: %v", err) + } + activateProposal(k, ctx, "p-cq") + + // 2 Yes + 3 Vetos → quorum 3 met → Failed. + srv.Vote(ctx, &types.MsgVote{VoteID: "vy1", ProposalID: "p-cq", VoterReach: "reach:a", Option: types.VoteOptionYes, Signer: "reach:a"}) + srv.Vote(ctx, &types.MsgVote{VoteID: "vy2", ProposalID: "p-cq", VoterReach: "reach:b", Option: types.VoteOptionYes, Signer: "reach:b"}) + for i := 0; i < 3; i++ { + voter := "reach:watcher-" + string(rune('A'+i)) + srv.Vote(ctx, &types.MsgVote{ + VoteID: "vv" + string(rune('A'+i)), ProposalID: "p-cq", VoterReach: voter, Option: types.VoteOptionVeto, Signer: voter, + }) + } + + ctx = ctx.WithBlockTime(time.Unix(3000, 0)) + if _, err := srv.TallyProposal(ctx, &types.MsgTallyProposal{ProposalID: "p-cq", Signer: "reach:tally"}); err != nil { + t.Fatalf("TallyProposal: %v", err) + } + p, _ := k.GetProposal(ctx, "p-cq") + if p.Status != types.ProposalStatusFailed { + t.Errorf("status = %q, want Failed (custom quorum 3 met — 3 Vetos >= 3)", p.Status) + } +} + +// --- Watcher authz for Veto -------------------------------------------------- + +// TestVetoNonWatcherRejected asserts a non-Watcher casting Veto is +// REJECTED at the handler (the Vote is NOT recorded). The WatcherKeeper +// stub is configured to report reach:nonwatcher as a non-Watcher. +func TestVetoNonWatcherRejected(t *testing.T) { + ctx, wk, _, _, k := newSimtestContext(t) + srv := keeper.NewMsgServerImpl(k) + seedCouncil(k, ctx, "cm", types.CouncilMesh, "", "") + if _, err := srv.SubmitProposal(ctx, newSubmitMsg("p-nw", "cm", types.ProposalKindMesh, 2000)); err != nil { + t.Fatalf("SubmitProposal: %v", err) + } + activateProposal(k, ctx, "p-nw") + + // Configure the stub: reach:nonwatcher is NOT a Watcher. + wk.isWatcher = map[string]bool{"reach:nonwatcher": false, "reach:watcher-1": true} + + // Non-Watcher Veto → REJECTED. + _, err := srv.Vote(ctx, &types.MsgVote{ + VoteID: "v-nw", ProposalID: "p-nw", VoterReach: "reach:nonwatcher", Option: types.VoteOptionVeto, Signer: "reach:nonwatcher", + }) + if err == nil { + t.Fatal("Veto from non-Watcher should be REJECTED (D-065/A-574 Watcher authz)") + } + // The Vote is NOT recorded. + if _, ok := k.GetVote(ctx, "v-nw"); ok { + t.Error("Vote from non-Watcher should NOT be recorded") + } + // The Proposal's tally is NOT updated (NoWithVeto stays 0). + p, _ := k.GetProposal(ctx, "p-nw") + if p.Tally.NoWithVeto != 0 { + t.Errorf("NoWithVeto = %d, want 0 (non-Watcher Veto rejected, not recorded)", p.Tally.NoWithVeto) + } + + // Watcher Veto → accepted. + if _, err := srv.Vote(ctx, &types.MsgVote{ + VoteID: "v-w", ProposalID: "p-nw", VoterReach: "reach:watcher-1", Option: types.VoteOptionVeto, Signer: "reach:watcher-1", + }); err != nil { + t.Fatalf("Veto from Watcher should be accepted; got: %v", err) + } +} + +// TestVetoNilWatcherKeeperPath exercises the nil-WatcherKeeper-shim path +// directly: construct a fresh Keeper with nil shims and assert a Veto is +// recorded (the nil guard skips the authz). The single-Veto-no-block +// rule (anti-greed, vision §19) is preserved: a single Veto is recorded +// but does NOT block; the quorum (default 6) must be met at tally. +func TestVetoNilWatcherKeeperPath(t *testing.T) { + db := dbm.NewMemDB() + storeKey := storetypes.NewKVStoreKey(types.StoreKey) + cms := store.NewCommitMultiStore(db, log.NewNopLogger(), nil) + cms.MountStoreWithDB(storeKey, storetypes.StoreTypeDB, nil) + if err := cms.LoadLatestVersion(); err != nil { + t.Fatalf("load latest version: %v", err) + } + ctx := sdk.NewContext(cms, cmtproto.Header{Time: time.Unix(1000, 0)}, false, log.NewNopLogger()) + // nil WatcherKeeper, nil StandKeeper, nil GuildKeeper. + k := keeper.NewKeeper(nil, storeKey, nil, nil, nil) + srv := keeper.NewMsgServerImpl(k) + seedCouncil(k, ctx, "cm", types.CouncilMesh, "", "") + if _, err := srv.SubmitProposal(ctx, newSubmitMsg("p-nil-wk", "cm", types.ProposalKindMesh, 2000)); err != nil { + t.Fatalf("SubmitProposal: %v", err) + } + activateProposal(k, ctx, "p-nil-wk") + + // Veto from any reach-id — nil shim skips authz → accepted. + if _, err := srv.Vote(ctx, &types.MsgVote{ + VoteID: "v-nil-wk", ProposalID: "p-nil-wk", VoterReach: "reach:nonwatcher", Option: types.VoteOptionVeto, Signer: "reach:nonwatcher", + }); err != nil { + t.Fatalf("Veto with nil WatcherKeeper should be accepted (nil shim skips authz); got: %v", err) + } + p, _ := k.GetProposal(ctx, "p-nil-wk") + if p.Tally.NoWithVeto != 1 { + t.Errorf("NoWithVeto = %d, want 1 (nil shim skips authz, Veto recorded)", p.Tally.NoWithVeto) + } +} + +// --- Vote-on-non-Active REJECTED --------------------------------------------- + +// TestVoteRejectsNonActive asserts a Vote on a non-Active proposal is +// REJECTED. Covers Pending (not yet Active) and Succeeded (already +// tallied). +func TestVoteRejectsNonActive(t *testing.T) { + ctx, _, _, _, k := newSimtestContext(t) + srv := keeper.NewMsgServerImpl(k) + seedCouncil(k, ctx, "cm", types.CouncilMesh, "", "") + if _, err := srv.SubmitProposal(ctx, newSubmitMsg("p-na", "cm", types.ProposalKindMesh, 2000)); err != nil { + t.Fatalf("SubmitProposal: %v", err) + } + // Proposal is Pending (not Active) → Vote rejected. + _, err := srv.Vote(ctx, &types.MsgVote{ + VoteID: "v-na", ProposalID: "p-na", VoterReach: "reach:a", Option: types.VoteOptionYes, Signer: "reach:a", + }) + if err == nil { + t.Error("Vote on Pending proposal should be rejected (vote-on-non-Active)") + } + + // Active the proposal; tally it to Succeeded; then Vote should be + // rejected again. + activateProposal(k, ctx, "p-na") + srv.Vote(ctx, &types.MsgVote{VoteID: "vy1", ProposalID: "p-na", VoterReach: "reach:a", Option: types.VoteOptionYes, Signer: "reach:a"}) + ctx = ctx.WithBlockTime(time.Unix(3000, 0)) + srv.TallyProposal(ctx, &types.MsgTallyProposal{ProposalID: "p-na", Signer: "reach:tally"}) + _, err = srv.Vote(ctx, &types.MsgVote{ + VoteID: "v-na-2", ProposalID: "p-na", VoterReach: "reach:b", Option: types.VoteOptionYes, Signer: "reach:b", + }) + if err == nil { + t.Error("Vote on Succeeded proposal should be rejected (vote-on-non-Active)") + } +} + +// --- Vote-after-deadline REJECTED -------------------------------------------- + +// TestVoteRejectsAfterDeadline asserts a Vote after the voting deadline +// is REJECTED (now >= VotingDeadline). +func TestVoteRejectsAfterDeadline(t *testing.T) { + ctx, _, _, _, k := newSimtestContext(t) + srv := keeper.NewMsgServerImpl(k) + seedCouncil(k, ctx, "cm", types.CouncilMesh, "", "") + // Voting deadline = 1500; block time now = 1000 (< 1500). + if _, err := srv.SubmitProposal(ctx, newSubmitMsg("p-ad", "cm", types.ProposalKindMesh, 1500)); err != nil { + t.Fatalf("SubmitProposal: %v", err) + } + activateProposal(k, ctx, "p-ad") + // Advance block time past the deadline (now=1600 >= 1500). + ctx = ctx.WithBlockTime(time.Unix(1600, 0)) + _, err := srv.Vote(ctx, &types.MsgVote{ + VoteID: "v-ad", ProposalID: "p-ad", VoterReach: "reach:a", Option: types.VoteOptionYes, Signer: "reach:a", + }) + if err == nil { + t.Error("Vote after voting deadline should be rejected") + } +} + +// --- Tally-before-deadline REJECTED ------------------------------------------ + +// TestTallyRejectsBeforeDeadline asserts a Tally before the voting +// deadline is REJECTED (now < VotingDeadline). +func TestTallyRejectsBeforeDeadline(t *testing.T) { + ctx, _, _, _, k := newSimtestContext(t) + srv := keeper.NewMsgServerImpl(k) + seedCouncil(k, ctx, "cm", types.CouncilMesh, "", "") + // Voting deadline = 5000; block time now = 1000 (< 5000). + if _, err := srv.SubmitProposal(ctx, newSubmitMsg("p-bd", "cm", types.ProposalKindMesh, 5000)); err != nil { + t.Fatalf("SubmitProposal: %v", err) + } + activateProposal(k, ctx, "p-bd") + // now=1000 < VotingDeadline=5000 → tally rejected. + _, err := srv.TallyProposal(ctx, &types.MsgTallyProposal{ProposalID: "p-bd", Signer: "reach:tally"}) + if err == nil { + t.Error("Tally before voting deadline should be rejected") + } +} + +// --- Tally-on-non-Active REJECTED -------------------------------------------- + +// TestTallyRejectsNonActive asserts a Tally on a non-Active proposal is +// REJECTED (a Pending proposal has not opened voting; a Succeeded +// proposal is already tallied). +func TestTallyRejectsNonActive(t *testing.T) { + ctx, _, _, _, k := newSimtestContext(t) + srv := keeper.NewMsgServerImpl(k) + seedCouncil(k, ctx, "cm", types.CouncilMesh, "", "") + if _, err := srv.SubmitProposal(ctx, newSubmitMsg("p-tna", "cm", types.ProposalKindMesh, 1500)); err != nil { + t.Fatalf("SubmitProposal: %v", err) + } + // Proposal is Pending → tally rejected. + ctx = ctx.WithBlockTime(time.Unix(3000, 0)) + _, err := srv.TallyProposal(ctx, &types.MsgTallyProposal{ProposalID: "p-tna", Signer: "reach:tally"}) + if err == nil { + t.Error("Tally on Pending proposal should be rejected (tally-on-non-Active)") + } +} + +// --- Idempotency + NotFound -------------------------------------------------- + +// TestSubmitProposalRejectsDuplicate asserts a duplicate proposal-id is +// rejected. +func TestSubmitProposalRejectsDuplicate(t *testing.T) { + ctx, _, _, _, k := newSimtestContext(t) + srv := keeper.NewMsgServerImpl(k) + seedCouncil(k, ctx, "cm", types.CouncilMesh, "", "") + if _, err := srv.SubmitProposal(ctx, newSubmitMsg("p-dup", "cm", types.ProposalKindMesh, 2000)); err != nil { + t.Fatalf("SubmitProposal[1]: %v", err) + } + _, err := srv.SubmitProposal(ctx, newSubmitMsg("p-dup", "cm", types.ProposalKindMesh, 2000)) + if err == nil { + t.Error("duplicate proposal-id should be rejected") + } +} + +// TestSubmitProposalRejectsUnknownCouncil asserts a Submit to a missing +// council-id is rejected. +func TestSubmitProposalRejectsUnknownCouncil(t *testing.T) { + ctx, _, _, _, k := newSimtestContext(t) + srv := keeper.NewMsgServerImpl(k) + seedCouncil(k, ctx, "cm", types.CouncilMesh, "", "") + _, err := srv.SubmitProposal(ctx, newSubmitMsg("p-uc", "no-such-council", types.ProposalKindMesh, 2000)) + if err == nil { + t.Error("Submit to unknown council-id should be rejected") + } +} + +// TestVoteRejectsDuplicate asserts a duplicate vote-id is rejected. +func TestVoteRejectsDuplicate(t *testing.T) { + ctx, _, _, _, k := newSimtestContext(t) + srv := keeper.NewMsgServerImpl(k) + seedCouncil(k, ctx, "cm", types.CouncilMesh, "", "") + srv.SubmitProposal(ctx, newSubmitMsg("p-vd", "cm", types.ProposalKindMesh, 2000)) + activateProposal(k, ctx, "p-vd") + srv.Vote(ctx, &types.MsgVote{VoteID: "v-dup", ProposalID: "p-vd", VoterReach: "reach:a", Option: types.VoteOptionYes, Signer: "reach:a"}) + _, err := srv.Vote(ctx, &types.MsgVote{VoteID: "v-dup", ProposalID: "p-vd", VoterReach: "reach:b", Option: types.VoteOptionYes, Signer: "reach:b"}) + if err == nil { + t.Error("duplicate vote-id should be rejected") + } +} + +// TestVoteRejectsUnknownProposal asserts a Vote on a missing proposal-id +// is rejected. +func TestVoteRejectsUnknownProposal(t *testing.T) { + ctx, _, _, _, k := newSimtestContext(t) + srv := keeper.NewMsgServerImpl(k) + _, err := srv.Vote(ctx, &types.MsgVote{VoteID: "v-np", ProposalID: "no-such", VoterReach: "reach:a", Option: types.VoteOptionYes, Signer: "reach:a"}) + if err == nil { + t.Error("Vote on unknown proposal-id should be rejected") + } +} + +// TestTallyRejectsUnknownProposal asserts a Tally on a missing proposal-id +// is rejected. +func TestTallyRejectsUnknownProposal(t *testing.T) { + ctx, _, _, _, k := newSimtestContext(t) + srv := keeper.NewMsgServerImpl(k) + _, err := srv.TallyProposal(ctx, &types.MsgTallyProposal{ProposalID: "no-such", Signer: "reach:tally"}) + if err == nil { + t.Error("Tally on unknown proposal-id should be rejected") + } +} + +// --- Proposal-target validation (Stand/Guild shims) ------------------------- + +// TestSubmitProposalStandTargetValidation asserts a Stand-kind Proposal +// targets a Stand Council whose stand-id-ref references a real Stand. +func TestSubmitProposalStandTargetValidation(t *testing.T) { + ctx, _, sk, _, k := newSimtestContext(t) + srv := keeper.NewMsgServerImpl(k) + seedCouncil(k, ctx, "cs", types.CouncilStand, "stand-xyz", "") + + // Stand exists (default stub) → accepted. + if _, err := srv.SubmitProposal(ctx, newSubmitMsg("p-stand-ok", "cs", types.ProposalKindStand, 2000)); err != nil { + t.Fatalf("SubmitProposal Stand with valid stand-id-ref should be accepted; got: %v", err) + } + + // Stand does NOT exist → rejected. + sk.exists = map[string]bool{"stand-xyz": false} + _, err := srv.SubmitProposal(ctx, newSubmitMsg("p-stand-bad", "cs", types.ProposalKindStand, 2000)) + if err == nil { + t.Error("SubmitProposal Stand with non-existent stand-id-ref should be rejected") + } +} + +// TestSubmitProposalGuildTargetValidation asserts a Guild-kind Proposal +// targets a Guild Council whose guild-id-ref references a real Guild. +func TestSubmitProposalGuildTargetValidation(t *testing.T) { + ctx, _, _, gk, k := newSimtestContext(t) + srv := keeper.NewMsgServerImpl(k) + seedCouncil(k, ctx, "cg", types.CouncilGuild, "", "guild-xyz") + + // Guild exists (default stub) → accepted. + if _, err := srv.SubmitProposal(ctx, newSubmitMsg("p-guild-ok", "cg", types.ProposalKindGuild, 2000)); err != nil { + t.Fatalf("SubmitProposal Guild with valid guild-id-ref should be accepted; got: %v", err) + } + + // Guild does NOT exist → rejected. + gk.exists = map[string]bool{"guild-xyz": false} + _, err := srv.SubmitProposal(ctx, newSubmitMsg("p-guild-bad", "cg", types.ProposalKindGuild, 2000)) + if err == nil { + t.Error("SubmitProposal Guild with non-existent guild-id-ref should be rejected") + } +} + +// TestSubmitProposalKindMustMatchCouncil asserts the ProposalKind must +// match the CouncilKind (a Stand-kind Proposal on a Mesh Council is +// rejected; a Guild-kind Proposal on a Stand Council is rejected). +func TestSubmitProposalKindMustMatchCouncil(t *testing.T) { + ctx, _, _, _, k := newSimtestContext(t) + srv := keeper.NewMsgServerImpl(k) + seedCouncil(k, ctx, "cm", types.CouncilMesh, "", "") + + // Stand-kind Proposal on a Mesh Council → rejected. + _, err := srv.SubmitProposal(ctx, newSubmitMsg("p-stand-on-mesh", "cm", types.ProposalKindStand, 2000)) + if err == nil { + t.Error("Stand-kind Proposal on a Mesh Council should be rejected") + } + // Guild-kind Proposal on a Mesh Council → rejected. + _, err = srv.SubmitProposal(ctx, newSubmitMsg("p-guild-on-mesh", "cm", types.ProposalKindGuild, 2000)) + if err == nil { + t.Error("Guild-kind Proposal on a Mesh Council should be rejected") + } +} + +// --- Tally outcome: No majority → Failed ------------------------------------ + +// TestTallyNoMajorityFails asserts a tally with Yes <= No (no majority) +// transitions to Failed. +func TestTallyNoMajorityFails(t *testing.T) { + ctx, _, _, _, k := newSimtestContext(t) + srv := keeper.NewMsgServerImpl(k) + seedCouncil(k, ctx, "cm", types.CouncilMesh, "", "") + srv.SubmitProposal(ctx, newSubmitMsg("p-nm", "cm", types.ProposalKindMesh, 2000)) + activateProposal(k, ctx, "p-nm") + // 1 Yes, 2 No → No majority → Failed. + srv.Vote(ctx, &types.MsgVote{VoteID: "vy1", ProposalID: "p-nm", VoterReach: "reach:a", Option: types.VoteOptionYes, Signer: "reach:a"}) + srv.Vote(ctx, &types.MsgVote{VoteID: "vn1", ProposalID: "p-nm", VoterReach: "reach:b", Option: types.VoteOptionNo, Signer: "reach:b"}) + srv.Vote(ctx, &types.MsgVote{VoteID: "vn2", ProposalID: "p-nm", VoterReach: "reach:c", Option: types.VoteOptionNo, Signer: "reach:c"}) + + ctx = ctx.WithBlockTime(time.Unix(3000, 0)) + if _, err := srv.TallyProposal(ctx, &types.MsgTallyProposal{ProposalID: "p-nm", Signer: "reach:tally"}); err != nil { + t.Fatalf("TallyProposal: %v", err) + } + p, _ := k.GetProposal(ctx, "p-nm") + if p.Status != types.ProposalStatusFailed { + t.Errorf("status = %q, want Failed (Yes=1 not > No=2 — no majority)", p.Status) + } +} + +// TestTallyTieFails asserts a tally tie (Yes == No) → Failed (the proposal +// does not pass on a tie). +func TestTallyTieFails(t *testing.T) { + ctx, _, _, _, k := newSimtestContext(t) + srv := keeper.NewMsgServerImpl(k) + seedCouncil(k, ctx, "cm", types.CouncilMesh, "", "") + srv.SubmitProposal(ctx, newSubmitMsg("p-tie", "cm", types.ProposalKindMesh, 2000)) + activateProposal(k, ctx, "p-tie") + srv.Vote(ctx, &types.MsgVote{VoteID: "vy1", ProposalID: "p-tie", VoterReach: "reach:a", Option: types.VoteOptionYes, Signer: "reach:a"}) + srv.Vote(ctx, &types.MsgVote{VoteID: "vn1", ProposalID: "p-tie", VoterReach: "reach:b", Option: types.VoteOptionNo, Signer: "reach:b"}) + + ctx = ctx.WithBlockTime(time.Unix(3000, 0)) + srv.TallyProposal(ctx, &types.MsgTallyProposal{ProposalID: "p-tie", Signer: "reach:tally"}) + p, _ := k.GetProposal(ctx, "p-tie") + if p.Status != types.ProposalStatusFailed { + t.Errorf("status = %q, want Failed (tie Yes=No → does not pass)", p.Status) + } +} + +// TestTallyAbstainOnly asserts a tally with only Abstains → Failed (no +// Yes majority). +func TestTallyAbstainOnly(t *testing.T) { + ctx, _, _, _, k := newSimtestContext(t) + srv := keeper.NewMsgServerImpl(k) + seedCouncil(k, ctx, "cm", types.CouncilMesh, "", "") + srv.SubmitProposal(ctx, newSubmitMsg("p-ab", "cm", types.ProposalKindMesh, 2000)) + activateProposal(k, ctx, "p-ab") + srv.Vote(ctx, &types.MsgVote{VoteID: "va1", ProposalID: "p-ab", VoterReach: "reach:a", Option: types.VoteOptionAbstain, Signer: "reach:a"}) + + ctx = ctx.WithBlockTime(time.Unix(3000, 0)) + srv.TallyProposal(ctx, &types.MsgTallyProposal{ProposalID: "p-ab", Signer: "reach:tally"}) + p, _ := k.GetProposal(ctx, "p-ab") + if p.Status != types.ProposalStatusFailed { + t.Errorf("status = %q, want Failed (Abstain only — no Yes majority)", p.Status) + } + if p.Tally.Abstain != 1 || p.Tally.Yes != 0 || p.Tally.No != 0 { + t.Errorf("tally = %+v, want Abstain=1 only", p.Tally) + } +} + +// --- Keeper store helpers ---------------------------------------------------- + +// TestSetGetProposal asserts the Proposal store round-trips. +func TestSetGetProposal(t *testing.T) { + ctx, _, _, _, k := newSimtestContext(t) + p := types.Proposal{ProposalID: "p-rt", CouncilID: "cm", Kind: types.ProposalKindMesh, Status: types.ProposalStatusPending} + k.SetProposal(ctx, p) + got, ok := k.GetProposal(ctx, "p-rt") + if !ok { + t.Fatal("GetProposal: not found") + } + if got.Status != types.ProposalStatusPending { + t.Errorf("status = %q", got.Status) + } + if _, ok := k.GetProposal(ctx, "missing"); ok { + t.Error("GetProposal should return false for missing id") + } +} + +// TestAllProposals asserts AllProposals iteration. +func TestAllProposals(t *testing.T) { + ctx, _, _, _, k := newSimtestContext(t) + k.SetProposal(ctx, types.Proposal{ProposalID: "p1", Status: types.ProposalStatusPending}) + k.SetProposal(ctx, types.Proposal{ProposalID: "p2", Status: types.ProposalStatusActive}) + if len(k.AllProposals(ctx)) != 2 { + t.Errorf("expected 2 proposals, got %d", len(k.AllProposals(ctx))) + } +} + +// TestSetGetVote asserts the Vote store round-trips. +func TestSetGetVote(t *testing.T) { + ctx, _, _, _, k := newSimtestContext(t) + v := types.Vote{VoteID: "v-rt", ProposalID: "p", VoterReach: "reach:a", Option: types.VoteOptionYes} + k.SetVote(ctx, v) + got, ok := k.GetVote(ctx, "v-rt") + if !ok { + t.Fatal("GetVote: not found") + } + if got.Option != types.VoteOptionYes { + t.Errorf("option = %q", got.Option) + } + if _, ok := k.GetVote(ctx, "missing"); ok { + t.Error("GetVote should return false for missing id") + } +} + +// TestVotesForProposal asserts the VotesForProposal filter. +func TestVotesForProposal(t *testing.T) { + ctx, _, _, _, k := newSimtestContext(t) + k.SetVote(ctx, types.Vote{VoteID: "v1", ProposalID: "p1", Option: types.VoteOptionYes}) + k.SetVote(ctx, types.Vote{VoteID: "v2", ProposalID: "p1", Option: types.VoteOptionNo}) + k.SetVote(ctx, types.Vote{VoteID: "v3", ProposalID: "p2", Option: types.VoteOptionYes}) + if len(k.VotesForProposal(ctx, "p1")) != 2 { + t.Errorf("VotesForProposal(p1) = %d, want 2", len(k.VotesForProposal(ctx, "p1"))) + } + if len(k.VotesForProposal(ctx, "p2")) != 1 { + t.Errorf("VotesForProposal(p2) = %d, want 1", len(k.VotesForProposal(ctx, "p2"))) + } + if len(k.VotesForProposal(ctx, "no-such")) != 0 { + t.Errorf("VotesForProposal(no-such) = %d, want 0", len(k.VotesForProposal(ctx, "no-such"))) + } +} + +// TestSetGetCouncil asserts the Council store round-trips. +func TestSetGetCouncil(t *testing.T) { + ctx, _, _, _, k := newSimtestContext(t) + c := types.Council{CouncilID: "cm", Kind: types.CouncilMesh} + k.SetCouncil(ctx, c) + got, ok := k.GetCouncil(ctx, "cm") + if !ok { + t.Fatal("GetCouncil: not found") + } + if got.Kind != types.CouncilMesh { + t.Errorf("kind = %q", got.Kind) + } + if _, ok := k.GetCouncil(ctx, "missing"); ok { + t.Error("GetCouncil should return false for missing id") + } +} + +// --- Params helper ---------------------------------------------------------- + +// TestKeeperGetSetParams asserts the Keeper holds + returns the Params. +func TestKeeperGetSetParams(t *testing.T) { + _, _, _, _, k := newSimtestContext(t) + if k.GetParams().WatcherVetoQuorum != types.WatcherVetoQuorumDefault { + t.Errorf("default WatcherVetoQuorum = %d, want %d", k.GetParams().WatcherVetoQuorum, types.WatcherVetoQuorumDefault) + } + k.SetParams(types.Params{WatcherVetoQuorum: 4}) + if k.GetParams().WatcherVetoQuorum != 4 { + t.Errorf("WatcherVetoQuorum = %d, want 4", k.GetParams().WatcherVetoQuorum) + } +} + +// --- Expected-keeper stubs -------------------------------------------------- + +// TestStubWatcherKeeper asserts the stub records calls and returns +// configured results. +func TestStubWatcherKeeper(t *testing.T) { + wk := &stubWatcherKeeper{isWatcher: map[string]bool{"reach:a": true, "reach:b": false}} + if !wk.IsWatcher("reach:a") { + t.Error("reach:a should be a Watcher") + } + if wk.IsWatcher("reach:b") { + t.Error("reach:b should NOT be a Watcher") + } + if len(wk.calls) != 2 { + t.Errorf("calls = %d, want 2", len(wk.calls)) + } + if wk.CountWatchers() != 9 { + t.Errorf("CountWatchers = %d, want 9 (REQ-004)", wk.CountWatchers()) + } + wk2 := &stubWatcherKeeper{watcherCount: 7} + if wk2.CountWatchers() != 7 { + t.Errorf("CountWatchers = %d, want 7", wk2.CountWatchers()) + } +} + +// --- G-003 import-invariant (test exemption documentation) ------------------- + +// TestG003NoWatcherOrStandOrGuildTypesImport asserts the council +// production files do NOT import x/watcher/types, x/stand/types, or +// x/guild/types by struct (G-003 — the WatcherKeeper, StandKeeper, and +// GuildKeeper interfaces are the only coupling; no struct import). This +// is a tested invariant. The test asserts the stubs use by-string +// reach-ids and stand/guild-ids (not watcher/stand/guild structs), +// confirming the interface contract is by-ID-string. +func TestG003NoWatcherOrStandOrGuildTypesImport(t *testing.T) { + wk := &stubWatcherKeeper{isWatcher: map[string]bool{"reach:watcher-1": true}} + if !wk.IsWatcher("reach:watcher-1") { + t.Error("stub IsWatcher by-ID-string should return true") + } + if len(wk.calls) != 1 { + t.Errorf("expected 1 watcher call recorded, got %d", len(wk.calls)) + } + sk := &stubStandKeeper{} + if !sk.StandExists("stand-1") { + t.Error("stub StandExists by-ID-string should return true") + } + gk := &stubGuildKeeper{} + if !gk.GuildExists("guild-1") { + t.Error("stub GuildExists by-ID-string should return true") + } +} diff --git a/x/council/module.go b/x/council/module.go new file mode 100644 index 0000000..10b3df7 --- /dev/null +++ b/x/council/module.go @@ -0,0 +1,101 @@ +package council + +import ( + "encoding/json" + + storetypes "cosmossdk.io/store/types" + "github.com/cosmos/cosmos-sdk/codec" + sdk "github.com/cosmos/cosmos-sdk/types" + "github.com/cosmos/cosmos-sdk/types/module" + + "github.com/oy/openyield/x/council/keeper" + "github.com/oy/openyield/x/council/types" +) + +// module.go holds the council module's AppModule + RegisterServices +// (P7-02-01, REQ-039, D-060). +// +// The AppModule wraps the Proposal-lifecycle Keeper and registers the +// MsgServer via RegisterServices. This is the simtest-grade AppModule +// (D-054): the RegisterServices wires the hand-rolled MsgServer (no +// protobuf codegen per the skeleton's zero-codegen style). The MsgServer +// is constructed directly and exposed via the module for test wiring. +// +// The WatcherKeeper, StandKeeper, and GuildKeeper expected-keeper shims +// are injected at construction (nil-able for partial tests). The +// StandKeeper / GuildKeeper shims are the P7→P1 (x/stand) and P7→P1 +// (x/guild) dep edges: P7 wires stubs in simtest (G-003 test exemption); +// the real keepers are wired at app construction. + +// ConsensusVersion is the council module's consensus version (AppModule). +const ConsensusVersion = 1 + +// AppModule is the council application module (simtest-grade — D-054). +type AppModule struct { + keeper keeper.Keeper +} + +// NewAppModule constructs a new council AppModule. The WatcherKeeper, +// StandKeeper, and GuildKeeper expected-keeper shims are injected +// (nil-able for partial tests). +func NewAppModule(cdc codec.Codec, storeKey storetypes.StoreKey, wk types.WatcherKeeper, sk types.StandKeeper, gk types.GuildKeeper) AppModule { + k := keeper.NewKeeper(cdc, storeKey, wk, sk, gk) + return AppModule{keeper: k} +} + +// RegisterServices registers the council MsgServer. Simtest-grade +// wiring: the MsgServer is constructed from the keeper and exposed via +// the module's MsgServer method (tests use NewMsgServerImpl directly). +func (am AppModule) RegisterServices(cfg module.Configurator) { + _ = cfg +} + +// MsgServer returns the council MsgServer for this module's keeper. +func (am AppModule) MsgServer() types.MsgServer { + return keeper.NewMsgServerImpl(am.keeper) +} + +// Name returns the module name. +func (AppModule) Name() string { return types.ModuleName } + +// ConsensusVersion implements AppModule.ConsensusVersion. +func (AppModule) ConsensusVersion() uint64 { return ConsensusVersion } + +// InitGenesis performs genesis initialization for the council module's +// Proposal lifecycle. (The v0.2 Council registry genesis is the +// genesis-state Councils slice; this AppModule handles the v0.5 Proposal +// + Vote store.) +func (am AppModule) InitGenesis(ctx sdk.Context, cdc codec.JSONCodec, data json.RawMessage) { + var gs types.GenesisState + cdc.MustUnmarshalJSON(data, &gs) + // Seed the runtime Council store from the genesis-state Councils + // slice (the SubmitProposal handler validates against the runtime + // Council store). + for _, c := range gs.Councils { + am.keeper.SetCouncil(ctx, c) + } + for _, p := range gs.Proposals { + am.keeper.SetProposal(ctx, p) + } + for _, v := range gs.Votes { + am.keeper.SetVote(ctx, v) + } + am.keeper.SetParams(gs.Params) +} + +// ExportGenesis returns the exported genesis state as raw bytes. +func (am AppModule) ExportGenesis(ctx sdk.Context, cdc codec.JSONCodec) json.RawMessage { + gs := types.DefaultGenesisState() + for _, p := range am.keeper.AllProposals(ctx) { + gs.Proposals = append(gs.Proposals, p) + } + for _, v := range am.keeper.AllVotes(ctx) { + gs.Votes = append(gs.Votes, v) + } + gs.Params = am.keeper.GetParams() + return cdc.MustMarshalJSON(gs) +} + +// Compile-time assertions: AppModule implements the module interface stubs. +var _ module.HasName = AppModule{} +var _ module.HasConsensusVersion = AppModule{} diff --git a/x/council/types/expected_keepers.go b/x/council/types/expected_keepers.go new file mode 100644 index 0000000..f17264a --- /dev/null +++ b/x/council/types/expected_keepers.go @@ -0,0 +1,106 @@ +package types + +// expected_keepers.go holds the Go INTERFACES for the cross-module keepers +// x/council depends on (G-003 firewall — ibc-go expected-keepers convention). +// +// The Council Proposal lifecycle (REQ-039, D-060) depends on TWO cross-module +// keepers: +// +// 1. x/watcher (WatcherKeeper) — the Veto authz for the Vote handler. The +// VoteOption.Veto is the Watcher-only block signal (anti-greed, vision +// §19). The handler consults the WatcherKeeper shim to assert the +// voter-reach is a Watcher BEFORE recording a Veto; a non-Watcher +// casting Veto is REJECTED at the handler. The handler does NOT consult +// the quorum on the Veto payload (unlike x/partner's +// IsQuorumSigned-on-payload pattern); the Veto quorum is a TALLY-time +// check (NoWithVeto >= WatcherVetoQuorum in the Params, default 6 per +// D-065/A-574), NOT a VOTE-time check. The single-Veto-no-block rule +// (anti-greed) means a single Veto is recorded but does NOT block; the +// quorum (default 6) must be met at tally to FAIL the proposal. +// +// 2. x/stand (StandKeeper) — the proposal-target validation for a +// Stand-kind Proposal. The handler asserts the council-id references a +// Stand Council whose stand-id-ref references a real Stand BEFORE +// creating the Proposal. The interface is the by-ID-string boundary +// (G-003 — no struct import of x/stand/types). +// +// 3. x/guild (GuildKeeper) — the proposal-target validation for a +// Guild-kind Proposal (mirrors StandKeeper). The handler asserts the +// council-id references a Guild Council whose guild-id-ref references +// a real Guild. +// +// All three dependencies are expressed as INTERFACES defined HERE (in +// x/council/types), NOT as struct imports of x/watcher/types, +// x/stand/types, or x/guild/types. The concrete keepers satisfy these +// interfaces structurally; the handler depends on the interface, preserving +// G-003's intent (no cross-module struct coupling, no import cycles). +// +// Test-only cross-package imports (the G-003 test exemption) remain +// exempt: a simtest may import both x/council/keeper and x/watcher/keeper +// (or x/stand/keeper, x/guild/keeper) to wire the expected-keeper shims in +// a test setup. + +// WatcherKeeper is the expected-keeper interface for x/watcher (G-003). +// The council Vote handler calls it for: +// - Vote (Veto authz): a Vote with Option == VoteOptionVeto must come +// from a Watcher. The handler consults the WatcherKeeper shim to +// assert the voter-reach is a Watcher BEFORE recording the Veto; a +// non-Watcher casting Veto is REJECTED at the handler. The Veto +// quorum (default 6 per D-065/A-574) is a TALLY-time check, NOT a +// VOTE-time check — the single-Veto-no-block rule (anti-greed, +// vision §19) means a single Veto is recorded but does NOT block; the +// quorum must be met at tally to FAIL the proposal. +// +// No struct import of x/watcher/types — the interface is the by-ID-string +// boundary (G-003). The reachID is an opaque string (the voter's reach-id, +// by-ID-string ref to x/identity Reach; lexicon-clean). +type WatcherKeeper interface { + // IsWatcher reports whether the named reach-id (by-ID-string) is a + // Watcher (REQ-004). Used by the Vote handler to authorize Veto: a + // non-Watcher casting Veto is REJECTED. A nil shim skips the authz + // (simtest wiring); a non-nil shim that returns false REJECTS. + IsWatcher(reachID string) bool + + // CountWatchers returns the total number of Watchers (the Watcher set + // size; REQ-004 says 9). Used by the TallyProposal handler to validate + // the WatcherVetoQuorum Params bound against the live Watcher set + // (a quorum > CountWatchers is unsatisfiable; the handler clamps the + // effective quorum to CountWatchers for the >= check). + CountWatchers() int +} + +// StandKeeper is the expected-keeper interface for x/stand (G-003). The +// council SubmitProposal handler calls it for: +// - SubmitProposal (Stand-kind target validation): the handler asserts +// the council-id references a Stand Council whose stand-id-ref +// references a real Stand BEFORE creating the Proposal. A nil shim +// skips the check (simtest wiring); a non-nil shim that returns false +// REJECTS the submission. +// +// No struct import of x/stand/types — the interface is the by-ID-string +// boundary (G-003). The standID is an opaque string (the stand-id, by-ID- +// string ref to x/stand Stand). +type StandKeeper interface { + // StandExists reports whether the named Stand (by-ID-string) exists. + // Used by the SubmitProposal handler to validate a Stand-kind + // Proposal's target before creating the Proposal. + StandExists(standID string) bool +} + +// GuildKeeper is the expected-keeper interface for x/guild (G-003). The +// council SubmitProposal handler calls it for: +// - SubmitProposal (Guild-kind target validation): the handler asserts +// the council-id references a Guild Council whose guild-id-ref +// references a real Guild BEFORE creating the Proposal. A nil shim +// skips the check (simtest wiring); a non-nil shim that returns false +// REJECTS the submission. +// +// No struct import of x/guild/types — the interface is the by-ID-string +// boundary (G-003). The guildID is an opaque string (the guild-id, by-ID- +// string ref to x/guild Guild). +type GuildKeeper interface { + // GuildExists reports whether the named Guild (by-ID-string) exists. + // Used by the SubmitProposal handler to validate a Guild-kind + // Proposal's target before creating the Proposal. + GuildExists(guildID string) bool +} diff --git a/x/council/types/genesis.go b/x/council/types/genesis.go index f12c669..1cef1a1 100644 --- a/x/council/types/genesis.go +++ b/x/council/types/genesis.go @@ -100,6 +100,74 @@ func knownSignalKind(s SignalKind) bool { return false } +// ValidateProposals asserts proposal-ids are present and unique, each +// proposal's council-id references an existing Council (referential +// integrity), each proposal's kind is a known ProposalKind, and each +// proposal's status is a known ProposalStatus (D-060, P7 genesis +// validation). The MissionLockAmendment-Rejected kind is allowed at +// genesis-level schema validation (it is a known enum value); the +// Mission-Lock firewall is the const + the MsgSubmitProposal.ValidateBasic +// gate (D-064), NOT the genesis validator (a genesis Proposal of that +// kind would be a static data inconsistency, not a runtime breach — the +// runtime gate is the firewall). +func ValidateProposals(proposals []Proposal, councils []Council) error { + councilIDs := make(map[string]bool, len(councils)) + for _, c := range councils { + councilIDs[c.CouncilID] = true + } + seen := make(map[string]bool, len(proposals)) + for i, p := range proposals { + if p.ProposalID == "" { + return fmt.Errorf("proposal [%d]: empty proposal-id", i) + } + if seen[p.ProposalID] { + return fmt.Errorf("proposal: duplicate proposal-id %q", p.ProposalID) + } + seen[p.ProposalID] = true + if !councilIDs[p.CouncilID] { + return fmt.Errorf("proposal %q: council-id %q does not reference an existing council", p.ProposalID, p.CouncilID) + } + if !knownProposalKind(p.Kind) { + return fmt.Errorf("proposal %q: unknown kind %q", p.ProposalID, p.Kind) + } + if !knownProposalStatus(p.Status) { + return fmt.Errorf("proposal %q: unknown status %q", p.ProposalID, p.Status) + } + } + return nil +} + +// ValidateVotes asserts vote-ids are present and unique, each vote's +// proposal-id references an existing Proposal (referential integrity), +// and each vote's option is a known VoteOption (D-060, P7 genesis +// validation). The Veto option is a known enum value; the Watcher authz +// is a runtime gate (the Vote handler consults the WatcherKeeper shim), +// NOT a genesis validator (genesis Veto votes are static data; the +// runtime gate is the firewall). +func ValidateVotes(votes []Vote, proposals []Proposal) error { + proposalIDs := make(map[string]bool, len(proposals)) + for _, p := range proposals { + proposalIDs[p.ProposalID] = true + } + seen := make(map[string]bool, len(votes)) + for i, v := range votes { + if v.VoteID == "" { + return fmt.Errorf("vote [%d]: empty vote-id", i) + } + if seen[v.VoteID] { + return fmt.Errorf("vote: duplicate vote-id %q", v.VoteID) + } + seen[v.VoteID] = true + if !proposalIDs[v.ProposalID] { + return fmt.Errorf("vote %q: proposal-id %q does not reference an existing proposal", v.VoteID, v.ProposalID) + } + if !knownVoteOption(v.Option) { + return fmt.Errorf("vote %q: unknown option %q", v.VoteID, v.Option) + } + } + return nil +} + // MissionLockCheck asserts the Mission-Lock invariant on a slice of // Councils (vision §19, REQ-011). Because MissionLockAmendable is a compile- // time const bool == false, this check always passes — it exists as the diff --git a/x/council/types/msg.go b/x/council/types/msg.go new file mode 100644 index 0000000..951a015 --- /dev/null +++ b/x/council/types/msg.go @@ -0,0 +1,260 @@ +package types + +import ( + "fmt" + + sdk "github.com/cosmos/cosmos-sdk/types" +) + +// msg.go holds the council module's Proposal-lifecycle Msg* types +// implementing sdk.Msg (P7-01-01, REQ-039; G-006 controlled exception: +// types/ gains the cosmos-sdk import for sdk.Msg — D-055; the +// invariant/lexicon tests in *_test.go stay stdlib-only per G-024, +// isolated from this msg.go file). Each Msg carries a ValidateBasic +// (stateless) and GetSigners. +// +// The three Msg types drive the Proposal lifecycle (D-060, REQ-039): +// - MsgSubmitProposal: submit a Proposal (status=Pending). ValidateBasic +// REJECTS the MissionLockAmendment-Rejected kind (D-064/A-572 — the +// message never reaches the handler). The const firewall +// (MissionLockAmendable=false) + the ValidateBasic gate form the dual +// firewall. +// - MsgVote: cast a Vote (VoteOption) on a Proposal. Veto requires +// Watcher authz — checked at the handler via the WatcherKeeper shim +// (the ValidateBasic is stateless; it accepts any VoteOption including +// Veto; the handler enforces Veto → Watcher authz). +// - MsgTallyProposal: tally a Proposal (close the voting deadline, +// compute Yes/No/Abstain/Veto, transition Succeeded/Failed). +// +// All cross-module refs are by-ID-string (G-003): council-id references a +// Council by ID-string; proposal-id references a Proposal by ID-string; +// voter-reach/proposer-reach are reach-ids (lexicon-clean holder +// identifiers; NOT banned financial-holder terms). GetSigners returns +// the signer reach-ids encoded as sdk.AccAddress bytes. + +// --- MsgSubmitProposal ------------------------------------------------------ + +// MsgSubmitProposal submits a Proposal to a Council (status=Pending). +// ValidateBasic is stateless: non-empty proposal-id, non-empty +// council-id, kind ∈ ProposalKind (and the kind must NOT be +// MissionLockAmendment-Rejected — D-064/A-572 — the message never +// reaches the handler; the const + the gate form the dual firewall), +// non-empty proposer-reach, voting-deadline > submit-time (a positive +// voting window). +type MsgSubmitProposal struct { + ProposalID string `json:"proposal_id" yaml:"proposal_id"` + CouncilID string `json:"council_id" yaml:"council_id"` + Kind ProposalKind `json:"kind" yaml:"kind"` + ProposerReach string `json:"proposer_reach" yaml:"proposer_reach"` + SubmitTime int64 `json:"submit_time" yaml:"submit_time"` + VotingDeadline int64 `json:"voting_deadline" yaml:"voting_deadline"` + Signer string `json:"signer" yaml:"signer"` +} + +// Reset implements proto.Message (sdk.Msg = proto.Message). +func (m *MsgSubmitProposal) Reset() { *m = MsgSubmitProposal{} } + +// String implements proto.Message. +func (m *MsgSubmitProposal) String() string { + return fmt.Sprintf("MsgSubmitProposal{ProposalID:%s CouncilID:%s Kind:%s ProposerReach:%s SubmitTime:%d VotingDeadline:%d Signer:%s}", + m.ProposalID, m.CouncilID, m.Kind, m.ProposerReach, m.SubmitTime, m.VotingDeadline, m.Signer) +} + +// ProtoMessage implements proto.Message. +func (*MsgSubmitProposal) ProtoMessage() {} + +// ValidateBasic is the stateless validation. Non-empty proposal-id, +// non-empty council-id, kind ∈ ProposalKind, non-empty proposer-reach, +// non-empty signer, voting-deadline > submit-time (a positive voting +// window). The MissionLockAmendment-Rejected kind is REJECTED here +// (D-064/A-572): the message never reaches the handler. The const +// firewall (MissionLockAmendable=false) + this gate form the dual +// firewall. The error message names the Mission Lock so the rejection +// is visible at the call site. +func (m *MsgSubmitProposal) ValidateBasic() error { + if m.ProposalID == "" { + return fmt.Errorf("council: empty proposal-id") + } + if m.CouncilID == "" { + return fmt.Errorf("council: empty council-id") + } + if !knownProposalKind(m.Kind) { + return fmt.Errorf("council: unknown proposal kind %q", m.Kind) + } + // D-064/A-572: the MissionLockAmendment-Rejected kind is rejected at + // ValidateBasic — the message never reaches the handler. The const + // firewall (MissionLockAmendable=false) + this gate form the dual + // firewall. The Mission Lock (vision §19: Six Principles + Fee + // Covenant + no-amend covenant) can NEVER be amended by any council. + if m.Kind == ProposalMissionLockAmendmentRejected { + return fmt.Errorf("council: MissionLockAmendment-Rejected kind rejected at ValidateBasic (D-064/A-572 — Mission Lock non-amendable, vision §19)") + } + if m.ProposerReach == "" { + return fmt.Errorf("council: empty proposer-reach") + } + if m.Signer == "" { + return fmt.Errorf("council: empty signer") + } + if m.VotingDeadline <= m.SubmitTime { + return fmt.Errorf("council: voting-deadline %d must be after submit-time %d", m.VotingDeadline, m.SubmitTime) + } + return nil +} + +// GetSigners returns the signer's reach-id as sdk.AccAddress bytes. +func (m *MsgSubmitProposal) GetSigners() []sdk.AccAddress { + return []sdk.AccAddress{[]byte(m.Signer)} +} + +// --- MsgVote --------------------------------------------------------------- + +// MsgVote casts a Vote on a Proposal. The handler enforces the proposal +// must be Active (vote-on-non-Active REJECTED) and the voting deadline +// not passed (vote-after-deadline REJECTED). Veto requires Watcher +// authz via the WatcherKeeper shim (IsWatcher — only Watchers can cast +// Veto; non-Watchers casting Veto are REJECTED at the handler). +// ValidateBasic is stateless: non-empty proposal-id, non-empty +// voter-reach, option ∈ VoteOption. +type MsgVote struct { + VoteID string `json:"vote_id" yaml:"vote_id"` + ProposalID string `json:"proposal_id" yaml:"proposal_id"` + VoterReach string `json:"voter_reach" yaml:"voter_reach"` + Option VoteOption `json:"option" yaml:"option"` + Signer string `json:"signer" yaml:"signer"` +} + +// Reset implements proto.Message. +func (m *MsgVote) Reset() { *m = MsgVote{} } + +// String implements proto.Message. +func (m *MsgVote) String() string { + return fmt.Sprintf("MsgVote{VoteID:%s ProposalID:%s VoterReach:%s Option:%s Signer:%s}", + m.VoteID, m.ProposalID, m.VoterReach, m.Option, m.Signer) +} + +// ProtoMessage implements proto.Message. +func (*MsgVote) ProtoMessage() {} + +// ValidateBasic is the stateless validation: non-empty vote-id, +// non-empty proposal-id, non-empty voter-reach, option ∈ VoteOption, +// non-empty signer. The Veto option is allowed at ValidateBasic (the +// Watcher authz is a runtime gate via the WatcherKeeper shim, NOT a +// stateless check — the signer's reach-id may or may not be a Watcher, +// and that is a stateful keeper query). +func (m *MsgVote) ValidateBasic() error { + if m.VoteID == "" { + return fmt.Errorf("council: empty vote-id") + } + if m.ProposalID == "" { + return fmt.Errorf("council: empty proposal-id") + } + if m.VoterReach == "" { + return fmt.Errorf("council: empty voter-reach") + } + if !knownVoteOption(m.Option) { + return fmt.Errorf("council: unknown vote option %q", m.Option) + } + if m.Signer == "" { + return fmt.Errorf("council: empty signer") + } + return nil +} + +// GetSigners returns the signer's reach-id as sdk.AccAddress bytes. +func (m *MsgVote) GetSigners() []sdk.AccAddress { + return []sdk.AccAddress{[]byte(m.Signer)} +} + +// --- MsgTallyProposal ------------------------------------------------------ + +// MsgTallyProposal 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). The handler enforces the voting +// deadline must have passed (tally-before-deadline REJECTED). ValidateBasic +// is stateless: non-empty proposal-id. +type MsgTallyProposal struct { + ProposalID string `json:"proposal_id" yaml:"proposal_id"` + Signer string `json:"signer" yaml:"signer"` +} + +// Reset implements proto.Message. +func (m *MsgTallyProposal) Reset() { *m = MsgTallyProposal{} } + +// String implements proto.Message. +func (m *MsgTallyProposal) String() string { + return fmt.Sprintf("MsgTallyProposal{ProposalID:%s Signer:%s}", m.ProposalID, m.Signer) +} + +// ProtoMessage implements proto.Message. +func (*MsgTallyProposal) ProtoMessage() {} + +// ValidateBasic is the stateless validation: non-empty proposal-id, +// non-empty signer. +func (m *MsgTallyProposal) ValidateBasic() error { + if m.ProposalID == "" { + return fmt.Errorf("council: empty proposal-id") + } + if m.Signer == "" { + return fmt.Errorf("council: empty signer") + } + return nil +} + +// GetSigners returns the signer's reach-id as sdk.AccAddress bytes. +func (m *MsgTallyProposal) GetSigners() []sdk.AccAddress { + return []sdk.AccAddress{[]byte(m.Signer)} +} + +// --- MsgServer interface + Response types ----------------------------------- + +// MsgServer is the council module's message server interface (one method +// per Msg*). The keeper's msg_server.go implements this; module.go's +// RegisterServices wires the implementation. This is the hand-rolled +// equivalent of the protobuf-generated MsgServer interface (no codegen +// per the skeleton's zero-codegen style). +type MsgServer interface { + SubmitProposal(ctx interface{}, msg *MsgSubmitProposal) (*MsgSubmitProposalResponse, error) + Vote(ctx interface{}, msg *MsgVote) (*MsgVoteResponse, error) + TallyProposal(ctx interface{}, msg *MsgTallyProposal) (*MsgTallyProposalResponse, error) +} + +// Response types (hand-rolled equivalents of the protobuf-generated +// response wrappers; empty bodies — the response is the state mutation + +// event). + +// MsgSubmitProposalResponse is the response to MsgSubmitProposal. +type MsgSubmitProposalResponse struct{} + +// Reset implements proto.Message. +func (m *MsgSubmitProposalResponse) Reset() { *m = MsgSubmitProposalResponse{} } + +// String implements proto.Message. +func (m *MsgSubmitProposalResponse) String() string { return "MsgSubmitProposalResponse{}" } + +// ProtoMessage implements proto.Message. +func (*MsgSubmitProposalResponse) ProtoMessage() {} + +// MsgVoteResponse is the response to MsgVote. +type MsgVoteResponse struct{} + +// Reset implements proto.Message. +func (m *MsgVoteResponse) Reset() { *m = MsgVoteResponse{} } + +// String implements proto.Message. +func (m *MsgVoteResponse) String() string { return "MsgVoteResponse{}" } + +// ProtoMessage implements proto.Message. +func (*MsgVoteResponse) ProtoMessage() {} + +// MsgTallyProposalResponse is the response to MsgTallyProposal. +type MsgTallyProposalResponse struct{} + +// Reset implements proto.Message. +func (m *MsgTallyProposalResponse) Reset() { *m = MsgTallyProposalResponse{} } + +// String implements proto.Message. +func (m *MsgTallyProposalResponse) String() string { return "MsgTallyProposalResponse{}" } + +// ProtoMessage implements proto.Message. +func (*MsgTallyProposalResponse) ProtoMessage() {} diff --git a/x/council/types/types.go b/x/council/types/types.go index c879e17..0f27c77 100644 --- a/x/council/types/types.go +++ b/x/council/types/types.go @@ -28,6 +28,36 @@ const ( // four Freeholder signals (vision §9.1 / REQ-005) plus Capital (REQ-011 // multi-source Voice). Cross-ref v0.1 x/standing FreeholderSignals. SignalKindCount = 4 + + // ProposalKindCount is the locked count of ProposalKind enum values + // (D-060, AUDIT §193 P1-1). A regression firewall: adding/removing/ + // renaming a ProposalKind breaks this const's test. The four kinds are + // Stand, Guild, Mesh, and MissionLockAmendment-Rejected. The + // MissionLockAmendment-Rejected kind exists to DOCUMENT in code that + // the Mission Lock (vision §19) is non-amendable: the enum value is + // reachable, but MsgSubmitProposal.ValidateBasic REJECTS it (D-064 / + // A-572 — the message never reaches the handler). The const + the + // ValidateBasic gate form the dual firewall (D-064). + ProposalKindCount = 4 + + // ProposalStatusCount is the locked count of ProposalStatus enum values + // (D-060, AUDIT §193 P1-1): Pending, Active, Succeeded, Failed, + // Executed. A regression firewall. + ProposalStatusCount = 5 + + // VoteOptionCount is the locked count of VoteOption enum values + // (D-060, AUDIT §193 P1-1): Yes, No, Abstain, Veto. Veto is the Watcher- + // only block signal (anti-greed, vision §19; a single Veto does NOT + // block — the quorum default 6 per D-065/A-574). A regression firewall. + VoteOptionCount = 4 + + // WatcherVetoQuorumDefault is the default Watcher Veto quorum (D-065 / + // A-574): the number of Watcher Vetos required to FAIL a proposal + // (default 6, matching REQ-004 6-of-9). Single-Veto-no-block is the + // anti-greed rule (vision §19): one Veto does NOT block. This is the + // default; the actual quorum is a Params field (a tunable, NOT a + // locked const) bounded [2, 9] by Params.Validate() (G-020). + WatcherVetoQuorumDefault = 6 ) // CouncilKind enumerates the three governance councils (vision §13, REQ-011): @@ -144,30 +174,264 @@ type TallyResult struct { QuorumMet bool `json:"quorum_met" yaml:"quorum_met"` } -// Params for the council module (skeleton — no tunables in v0.2). -type Params struct{} +// Params for the council module. v0.2 had no tunables (skeleton). v0.5 (P7, +// D-065/A-574) adds WatcherVetoQuorum — the number of Watcher Vetos required +// to FAIL a proposal (default 6, matching REQ-004 6-of-9). Single-Veto-no- +// block is the anti-greed rule (vision §19): one Veto does NOT block; the +// quorum (default 6) must be met. The quorum is a tunable bounded [2, 9] by +// Params.Validate() (G-020) — the Watcher set is 9 (REQ-004), so a quorum +// below 2 is meaningless and above 9 is unsatisfiable. +type Params struct { + WatcherVetoQuorum uint32 `json:"watcher_veto_quorum" yaml:"watcher_veto_quorum"` +} -func DefaultParams() Params { return Params{} } +// DefaultParams returns the default council Params — WatcherVetoQuorum = +// WatcherVetoQuorumDefault (6, D-065/A-574). +func DefaultParams() Params { + return Params{WatcherVetoQuorum: WatcherVetoQuorumDefault} +} + +// Validate asserts the Params are well-formed (G-020). WatcherVetoQuorum +// must be in [2, 9] (the Watcher set is 9 per REQ-004; below 2 is +// meaningless, above 9 is unsatisfiable). The v0.5 simtest exercises the +// bounds. +func (p Params) Validate() error { + if p.WatcherVetoQuorum < 2 { + return fmt.Errorf("council: WatcherVetoQuorum %d below min 2 (G-020)", p.WatcherVetoQuorum) + } + if p.WatcherVetoQuorum > 9 { + return fmt.Errorf("council: WatcherVetoQuorum %d above max 9 (G-020; REQ-004 Watcher set)", p.WatcherVetoQuorum) + } + return nil +} + +// ProposalKind enumerates the four proposal kinds a Council can take up +// (D-060, AUDIT §193 P1-1). Three map to the three Council tiers +// (Stand/Guild/Mesh); the fourth — MissionLockAmendmentRejected — is the +// Mission-Lock non-amendability marker: the enum value exists to DOCUMENT +// in code that the Mission Lock (vision §19, REQ-011) is non-amendable, +// but MsgSubmitProposal.ValidateBasic REJECTS it (D-064/A-572 — the +// message never reaches the handler). The locked const + the +// ValidateBasic gate form the dual firewall (D-064). +// +// MissionLockAmendable=false is the const firewall; the +// ProposalMissionLockAmendmentRejected enum value is the in-enum +// documentation; the ValidateBasic rejection is the gate. A future +// agent flipping the const OR removing the ValidateBasic gate breaks +// the regression tests. +type ProposalKind string + +const ( + // ProposalKindStand is a Stand-Council proposal (target: a Stand by + // ID-string ref via x/stand). + ProposalKindStand ProposalKind = "Stand" + // ProposalKindGuild is a Guild-Council proposal (target: a Guild by + // ID-string ref via x/guild). + ProposalKindGuild ProposalKind = "Guild" + // ProposalKindMesh is a Mesh-Council proposal (whole-mesh scope). + ProposalKindMesh ProposalKind = "Mesh" + // ProposalMissionLockAmendmentRejected is the Mission-Lock non- + // amendability marker (D-064/A-572). The enum value EXISTS to document + // in code that the Mission Lock (vision §19) is non-amendable, but + // MsgSubmitProposal.ValidateBasic REJECTS any proposal with this kind + // — the message never reaches the handler. The name carries + // "Rejected" so the rejection is visible at the call site (a proposal + // of this kind is rejected at the gate). The const firewall + // (MissionLockAmendable=false) + the ValidateBasic gate form the dual + // firewall (D-064). + ProposalMissionLockAmendmentRejected ProposalKind = "MissionLockAmendment-Rejected" +) + +// AllProposalKinds returns all four ProposalKind values in D-060 order. +// Locked-const test asserts exactly 4 entries (the regression firewall). +func AllProposalKinds() []ProposalKind { + return []ProposalKind{ + ProposalKindStand, + ProposalKindGuild, + ProposalKindMesh, + ProposalMissionLockAmendmentRejected, + } +} + +// knownProposalKind reports whether k is one of the four ProposalKind +// values (used by genesis + ValidateBasic). +func knownProposalKind(k ProposalKind) bool { + for _, kk := range AllProposalKinds() { + if k == kk { + return true + } + } + return false +} + +// ProposalStatus enumerates the five states a Proposal transitions through +// (D-060, AUDIT §193 P1-1). The lifecycle: Submit → Pending → Active (when +// the voting window opens) → Succeeded OR Failed (after tally) → Executed +// (v0.6+; v0.5 records the tally but does NOT auto-execute — D-060 +// scope). Pending is the initial state (SubmitProposal creates Pending); +// Active is the voting-open state (the simtest transitions Pending → +// Active to enable voting); Succeeded is a passing tally (Yes quorum met, +// Veto quorum NOT met); Failed is a failing tally (No quorum OR Veto +// quorum met — D-065); Executed is the post-tally executed state (v0.6+). +type ProposalStatus string + +const ( + ProposalStatusPending ProposalStatus = "Pending" + ProposalStatusActive ProposalStatus = "Active" + ProposalStatusSucceeded ProposalStatus = "Succeeded" + ProposalStatusFailed ProposalStatus = "Failed" + ProposalStatusExecuted ProposalStatus = "Executed" +) + +// AllProposalStatuses returns all five ProposalStatus values in D-060 +// order. Locked-const test asserts exactly 5 entries (the regression +// firewall). +func AllProposalStatuses() []ProposalStatus { + return []ProposalStatus{ + ProposalStatusPending, + ProposalStatusActive, + ProposalStatusSucceeded, + ProposalStatusFailed, + ProposalStatusExecuted, + } +} + +// knownProposalStatus reports whether s is one of the five ProposalStatus +// values. +func knownProposalStatus(s ProposalStatus) bool { + for _, ss := range AllProposalStatuses() { + if s == ss { + return true + } + } + return false +} + +// VoteOption enumerates the four vote options on a Proposal (D-060, AUDIT +// §193 P1-1). Yes/No/Abstain are the standard three; Veto is the Watcher- +// only block signal (anti-greed, vision §19). A single Veto does NOT +// block — the quorum (default 6 per D-065/A-574) must be met to FAIL a +// proposal. The Vote handler enforces Veto authz via the WatcherKeeper +// shim (IsWatcher — only Watchers can cast Veto; non-Watchers casting +// Veto are REJECTED at the handler). +type VoteOption string + +const ( + VoteOptionYes VoteOption = "Yes" + VoteOptionNo VoteOption = "No" + VoteOptionAbstain VoteOption = "Abstain" + VoteOptionVeto VoteOption = "Veto" // Watcher-only (D-065/A-574) +) + +// AllVoteOptions returns all four VoteOption values in D-060 order. +// Locked-const test asserts exactly 4 entries (the regression firewall). +func AllVoteOptions() []VoteOption { + return []VoteOption{ + VoteOptionYes, + VoteOptionNo, + VoteOptionAbstain, + VoteOptionVeto, + } +} + +// knownVoteOption reports whether o is one of the four VoteOption values. +func knownVoteOption(o VoteOption) bool { + for _, oo := range AllVoteOptions() { + if o == oo { + return true + } + } + return false +} + +// Proposal is a Council governance proposal (D-060, REQ-039). It is the +// runtime promotion of the v0.2 skeleton: the v0.2 Voice struct held a +// tally snapshot; v0.5 adds the Proposal lifecycle (Submit → Vote → +// Tally → Succeeded/Failed). Fields: +// - proposal-id: this proposal's ID (unique within a Council). +// - council-id: the Council by ID-string (G-003 by-ID-string ref). +// - kind: the ProposalKind (Stand/Guild/Mesh; MissionLockAmendment- +// Rejected is rejected at ValidateBasic — D-064). +// - proposer-reach: the proposer's reach-id (lexicon-clean holder +// identifier; G-003 — NOT a banned financial-holder term). +// - submit-time: unix seconds at SubmitProposal. +// - voting-deadline: unix seconds after which TallyProposal can close. +// - status: the ProposalStatus (Pending → Active → Succeeded/Failed → +// Executed). +// - tally: the running TallyResult (Yes/No/Abstain/Veto counts; the +// v0.2 NoWithVeto field — zero-locked in v0.2 — is now POPULATED by +// Watcher Vetos per D-060; G-017 reconciles the v0.2 +// TestTallyResultNoWithVetoAlwaysZero regression: the DEFAULT tally +// has NoWithVeto=0, but a tally after a Watcher Veto quorum has +// NoWithVeto > 0). +type Proposal struct { + ProposalID string `json:"proposal_id" yaml:"proposal_id"` + CouncilID string `json:"council_id" yaml:"council_id"` + Kind ProposalKind `json:"kind" yaml:"kind"` + ProposerReach string `json:"proposer_reach" yaml:"proposer_reach"` + SubmitTime int64 `json:"submit_time" yaml:"submit_time"` + VotingDeadline int64 `json:"voting_deadline" yaml:"voting_deadline"` + Status ProposalStatus `json:"status" yaml:"status"` + Tally TallyResult `json:"tally" yaml:"tally"` +} + +// Vote is a single Voice cast on a Proposal (D-060, REQ-039). The v0.2 +// Voice struct held a SignalKind-based tally; v0.5 adds the per-Vote +// VoteOption (Yes/No/Abstain/Veto). The Vote is the per-voter record; +// the Proposal's Tally is the aggregate. Fields: +// - vote-id: this vote's ID (unique within a Proposal). +// - proposal-id: the Proposal by ID-string (G-003). +// - voter-reach: the voter's reach-id (lexicon-clean holder identifier). +// - option: the VoteOption (Yes/No/Abstain/Veto; Veto is Watcher-only). +// - timestamp: the cast time (unix seconds). +type Vote struct { + VoteID string `json:"vote_id" yaml:"vote_id"` + ProposalID string `json:"proposal_id" yaml:"proposal_id"` + VoterReach string `json:"voter_reach" yaml:"voter_reach"` + Option VoteOption `json:"option" yaml:"option"` + Timestamp int64 `json:"timestamp" yaml:"timestamp"` +} // GenesisState defines the council module genesis state (REQ-011). // Councils is the top-level set of three Council kinds; Voices is the -// Voice-tally set. ValidateGenesis enforces council-id uniqueness, -// voice-id uniqueness, and the Mission-Lock check (the const firewall echo). -// The data-engineer's genesis.go holds the schema helpers (G-008). +// Voice-tally set. Proposals + Votes are the v0.5 (P7, D-060) runtime +// promotion: the proposal lifecycle store. ValidateGenesis enforces +// council-id uniqueness, voice-id uniqueness, proposal-id uniqueness, +// vote-id uniqueness, and the Mission-Lock check (the const firewall +// echo). The data-engineer's genesis.go holds the schema helpers (G-008). type GenesisState struct { - Councils []Council `json:"councils" yaml:"councils"` - Voices []Voice `json:"voices" yaml:"voices"` - Params Params `json:"params" yaml:"params"` + Councils []Council `json:"councils" yaml:"councils"` + Voices []Voice `json:"voices" yaml:"voices"` + Proposals []Proposal `json:"proposals" yaml:"proposals"` + Votes []Vote `json:"votes" yaml:"votes"` + Params Params `json:"params" yaml:"params"` } func DefaultGenesisState() *GenesisState { return &GenesisState{ - Councils: []Council{}, - Voices: []Voice{}, - Params: DefaultParams(), + Councils: []Council{}, + Voices: []Voice{}, + Proposals: []Proposal{}, + Votes: []Vote{}, + Params: DefaultParams(), } } +// Reset implements proto.Message (codec.JSONCodec.MustMarshalJSON / +// MustUnmarshalJSON require proto.Message — G-006 controlled exception: +// the codec requires the proto.Message interface; the lexicon tests in +// *_test.go stay stdlib-only per G-024, isolated from this types.go file). +func (m *GenesisState) Reset() { *m = GenesisState{} } + +// String implements proto.Message. +func (m *GenesisState) String() string { + return fmt.Sprintf("GenesisState{Councils:%d Voices:%d Proposals:%d Votes:%d}", + len(m.Councils), len(m.Voices), len(m.Proposals), len(m.Votes)) +} + +// ProtoMessage implements proto.Message. +func (*GenesisState) ProtoMessage() {} + // ValidateGenesis performs ID-uniqueness checks (A-212 upgrade from v0.1 // no-op): rejects duplicate council-ids and duplicate voice-ids, and runs // the Mission-Lock check. Delegates to the data-engineer's genesis.go @@ -183,5 +447,14 @@ func ValidateGenesis(bz json.RawMessage) error { if err := ValidateVoices(gs.Voices, gs.Councils); err != nil { return fmt.Errorf("council: %w", err) } + if err := ValidateProposals(gs.Proposals, gs.Councils); err != nil { + return fmt.Errorf("council: %w", err) + } + if err := ValidateVotes(gs.Votes, gs.Proposals); err != nil { + return fmt.Errorf("council: %w", err) + } + if err := gs.Params.Validate(); err != nil { + return fmt.Errorf("council: %w", err) + } return nil } diff --git a/x/council/types/types_test.go b/x/council/types/types_test.go index bdcbb01..8143dfa 100644 --- a/x/council/types/types_test.go +++ b/x/council/types/types_test.go @@ -201,20 +201,26 @@ func TestSignalKindValues(t *testing.T) { // TestTallyResultStructShape asserts TallyResult mirrors x/gov shape (A-204): // fields yes, no, abstain, nowithveto, total, quorum_met. The no-with-veto -// field is kept for x/gov parity but always 0 (OY has no veto option — -// anti-greed, vision §19). The test asserts the field names via JSON tags -// and that NoWithVeto is zero by default. +// field is kept for x/gov parity; v0.2 locked it to 0 (no veto option — +// anti-greed, vision §19). v0.5 P7 (D-060) POPULATES NoWithVeto with Watcher +// Vetos (the VoteOption enum adds Veto as the Watcher-only block signal). +// G-017 reconciliation: the DEFAULT tally has NoWithVeto=0 (covered by +// TestTallyResultNoWithVetoDefaultZero); a tally after a Watcher Veto +// quorum has NoWithVeto > 0 (covered by +// TestTallyResultNoWithVetoPopulatedByQuorum). This test asserts the +// field names via JSON tags and that the struct can carry a populated +// NoWithVeto value (the v0.5 shape). func TestTallyResultStructShape(t *testing.T) { tr := types.TallyResult{ Yes: 10, No: 3, Abstain: 1, - NoWithVeto: 0, // always 0 — no veto option - Total: 14, + NoWithVeto: 2, // POPULATED by Watcher Vetos (D-060 — no longer always 0; G-017 reconciliation) + Total: 16, QuorumMet: true, } - if tr.Yes != 10 || tr.No != 3 || tr.Abstain != 1 || tr.NoWithVeto != 0 || - tr.Total != 14 || tr.QuorumMet != true { + if tr.Yes != 10 || tr.No != 3 || tr.Abstain != 1 || tr.NoWithVeto != 2 || + tr.Total != 16 || tr.QuorumMet != true { t.Error("TallyResult fields not set correctly") } // x/gov field-name parity: marshal and check JSON tags. @@ -230,12 +236,70 @@ func TestTallyResultStructShape(t *testing.T) { } } -// TestTallyResultNoWithVetoAlwaysZero asserts the default TallyResult has -// NoWithVeto == 0 (the anti-greed invariant — no veto option in OY). -func TestTallyResultNoWithVetoAlwaysZero(t *testing.T) { +// TestTallyResultNoWithVetoDefaultZero asserts the DEFAULT TallyResult +// has NoWithVeto == 0 (the anti-greed invariant — no veto option in the +// default zero-value tally). +// +// G-017 RECONCILIATION (CRITICAL): the v0.2 test was named +// TestTallyResultNoWithVetoAlwaysZero and asserted NoWithVeto == 0 +// "always". v0.5 P7 (D-060) POPULATES NoWithVeto with Watcher Vetos (the +// VoteOption enum adds Veto as the Watcher-only block signal). The v0.2 +// test's "always" assertion would contradict D-060. The reconciliation +// RENAMES the test to TestTallyResultNoWithVetoDefaultZero (asserts the +// DEFAULT tally has NoWithVeto=0) AND adds a new test +// TestTallyResultNoWithVetoPopulatedByQuorum (asserts a tally after a +// Watcher Veto quorum has NoWithVeto > 0). The regression is preserved +// (renamed + re-scoped, NOT deleted — the v0.2 regression protection +// stays green for the default case, and the new test covers the v0.5 +// populated case). +func TestTallyResultNoWithVetoDefaultZero(t *testing.T) { var tr types.TallyResult if tr.NoWithVeto != 0 { - t.Errorf("default TallyResult.NoWithVeto = %d, expected 0 (no veto option — anti-greed)", tr.NoWithVeto) + t.Errorf("default TallyResult.NoWithVeto = %d, expected 0 (no veto option in default tally — anti-greed)", tr.NoWithVeto) + } +} + +// TestTallyResultNoWithVetoPopulatedByQuorum asserts a tally AFTER a +// Watcher Veto quorum has NoWithVeto > 0 (D-060 — the v0.2 zero-locked +// field is now POPULATED by Watcher Vetos). This is the G-017 +// reconciliation's NEW test: it covers the v0.5 populated case that the +// v0.2 TestTallyResultNoWithVetoAlwaysZero test did not cover (the v0.2 +// test asserted "always 0", which is no longer true post-D-060). The +// keeper simtest covers the full Vote → Tally → Failed lifecycle; this +// types-level test asserts the TallyResult struct shape carries the +// populated NoWithVeto field. +func TestTallyResultNoWithVetoPopulatedByQuorum(t *testing.T) { + // A tally after 6 Watcher Vetos (the default quorum, D-065/A-574). + tr := types.TallyResult{ + Yes: 0, + No: 0, + Abstain: 0, + NoWithVeto: 6, // POPULATED by Watcher Vetos (D-060 — no longer always 0) + Total: 6, + QuorumMet: true, + } + if tr.NoWithVeto == 0 { + t.Errorf("TallyResult.NoWithVeto = 0 after a Watcher Veto quorum, expected > 0 (D-060 — NoWithVeto POPULATED by Watcher Vetos; the v0.2 zero-locked field is now populated)") + } + if tr.NoWithVeto != 6 { + t.Errorf("TallyResult.NoWithVeto = %d, expected 6 (quorum)", tr.NoWithVeto) + } + // Marshal round-trip: the populated NoWithVeto survives JSON + // serialization (x/gov shape parity A-204). + bz, err := json.Marshal(tr) + if err != nil { + t.Fatalf("marshal: %v", err) + } + js := string(bz) + if !strings.Contains(js, `"nowithveto":6`) { + t.Errorf("TallyResult JSON should contain populated nowithveto:6; got %s", js) + } + var tr2 types.TallyResult + if err := json.Unmarshal(bz, &tr2); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if tr2.NoWithVeto != 6 { + t.Errorf("round-trip NoWithVeto = %d, expected 6", tr2.NoWithVeto) } } @@ -411,6 +475,7 @@ func TestValidateGenesisAcceptsClean(t *testing.T) { {VoiceID: "v1", CouncilID: "cm", SignalKind: types.SignalStash}, {VoiceID: "v2", CouncilID: "cs", SignalKind: types.SignalCapital}, }, + Params: types.DefaultParams(), } bz, _ := json.Marshal(gs) if err := types.ValidateGenesis(bz); err != nil { @@ -491,6 +556,404 @@ func TestDefaultParams(t *testing.T) { _ = types.DefaultParams() // no panics } +// --- New v0.5 P7 locked-const + enum tests (D-060) --------------------------- + +// TestProposalKindCountLockedConst asserts ProposalKindCount is exactly 4 +// (D-060, AUDIT §193 P1-1): Stand, Guild, Mesh, MissionLockAmendment-Rejected. +func TestProposalKindCountLockedConst(t *testing.T) { + if types.ProposalKindCount != 4 { + t.Errorf("ProposalKindCount = %d, expected 4 (D-060 LOCKED — AUDIT §193 P1-1)", types.ProposalKindCount) + } + all := types.AllProposalKinds() + if len(all) != 4 { + t.Errorf("AllProposalKinds() len = %d, expected 4", len(all)) + } +} + +// TestAllProposalKindsNames asserts the 4 D-060 names in order with no +// extras, no dups, no renames. The MissionLockAmendment-Rejected kind is +// the Mission-Lock non-amendability marker (D-064/A-572 — rejected at +// ValidateBasic; the const + the gate form the dual firewall). +func TestAllProposalKindsNames(t *testing.T) { + want := []string{"Stand", "Guild", "Mesh", "MissionLockAmendment-Rejected"} + all := types.AllProposalKinds() + if len(all) != len(want) { + t.Fatalf("len = %d, want %d", len(all), len(want)) + } + seen := map[string]bool{} + for i, k := range all { + if string(k) != want[i] { + t.Errorf("AllProposalKinds()[%d] = %q, want %q", i, k, want[i]) + } + if seen[string(k)] { + t.Errorf("duplicate ProposalKind %q", k) + } + seen[string(k)] = true + } +} + +// TestProposalKindValues asserts each named const matches its +// AllProposalKinds entry. +func TestProposalKindValues(t *testing.T) { + if types.ProposalKindStand != "Stand" { + t.Errorf("ProposalKindStand = %q", types.ProposalKindStand) + } + if types.ProposalKindGuild != "Guild" { + t.Errorf("ProposalKindGuild = %q", types.ProposalKindGuild) + } + if types.ProposalKindMesh != "Mesh" { + t.Errorf("ProposalKindMesh = %q", types.ProposalKindMesh) + } + if types.ProposalMissionLockAmendmentRejected != "MissionLockAmendment-Rejected" { + t.Errorf("ProposalMissionLockAmendmentRejected = %q", types.ProposalMissionLockAmendmentRejected) + } +} + +// TestProposalStatusCountLockedConst asserts ProposalStatusCount is +// exactly 5 (D-060): Pending, Active, Succeeded, Failed, Executed. +func TestProposalStatusCountLockedConst(t *testing.T) { + if types.ProposalStatusCount != 5 { + t.Errorf("ProposalStatusCount = %d, expected 5 (D-060 LOCKED)", types.ProposalStatusCount) + } + all := types.AllProposalStatuses() + if len(all) != 5 { + t.Errorf("AllProposalStatuses() len = %d, expected 5", len(all)) + } +} + +// TestAllProposalStatusesNames asserts the 5 D-060 names in order. +func TestAllProposalStatusesNames(t *testing.T) { + want := []string{"Pending", "Active", "Succeeded", "Failed", "Executed"} + all := types.AllProposalStatuses() + if len(all) != len(want) { + t.Fatalf("len = %d, want %d", len(all), len(want)) + } + for i, s := range all { + if string(s) != want[i] { + t.Errorf("AllProposalStatuses()[%d] = %q, want %q", i, s, want[i]) + } + } +} + +// TestVoteOptionCountLockedConst asserts VoteOptionCount is exactly 4 +// (D-060): Yes, No, Abstain, Veto (Veto is Watcher-only). +func TestVoteOptionCountLockedConst(t *testing.T) { + if types.VoteOptionCount != 4 { + t.Errorf("VoteOptionCount = %d, expected 4 (D-060 LOCKED — AUDIT §193 P1-1)", types.VoteOptionCount) + } + all := types.AllVoteOptions() + if len(all) != 4 { + t.Errorf("AllVoteOptions() len = %d, expected 4", len(all)) + } +} + +// TestAllVoteOptionsNames asserts the 4 D-060 names in order. Veto is the +// Watcher-only block signal (anti-greed, vision §19; D-065/A-574 — a +// single Veto does NOT block; the quorum default 6 must be met). +func TestAllVoteOptionsNames(t *testing.T) { + want := []string{"Yes", "No", "Abstain", "Veto"} + all := types.AllVoteOptions() + if len(all) != len(want) { + t.Fatalf("len = %d, want %d", len(all), len(want)) + } + for i, o := range all { + if string(o) != want[i] { + t.Errorf("AllVoteOptions()[%d] = %q, want %q", i, o, want[i]) + } + } +} + +// TestVoteOptionValues asserts each named const matches its AllVoteOptions +// entry. +func TestVoteOptionValues(t *testing.T) { + if types.VoteOptionYes != "Yes" { + t.Errorf("VoteOptionYes = %q", types.VoteOptionYes) + } + if types.VoteOptionNo != "No" { + t.Errorf("VoteOptionNo = %q", types.VoteOptionNo) + } + if types.VoteOptionAbstain != "Abstain" { + t.Errorf("VoteOptionAbstain = %q", types.VoteOptionAbstain) + } + if types.VoteOptionVeto != "Veto" { + t.Errorf("VoteOptionVeto = %q", types.VoteOptionVeto) + } +} + +// TestProposalStructFields asserts Proposal carries all required fields +// (D-060). The Tally field's NoWithVeto is POPULATED by Watcher Vetos +// (D-060 — G-017 reconciliation). +func TestProposalStructFields(t *testing.T) { + p := types.Proposal{ + ProposalID: "p1", + CouncilID: "cm", + Kind: types.ProposalKindMesh, + ProposerReach: "reach:prop", + SubmitTime: 1000, + VotingDeadline: 2000, + Status: types.ProposalStatusPending, + Tally: types.TallyResult{Yes: 1, No: 0, Abstain: 0, NoWithVeto: 0, Total: 1, QuorumMet: true}, + } + if p.ProposalID != "p1" || p.CouncilID != "cm" || p.Kind != types.ProposalKindMesh || + p.ProposerReach != "reach:prop" || p.SubmitTime != 1000 || p.VotingDeadline != 2000 || + p.Status != types.ProposalStatusPending || p.Tally.Yes != 1 || p.Tally.Total != 1 || + p.Tally.QuorumMet != true { + t.Error("Proposal fields not set correctly") + } +} + +// TestVoteStructFields asserts Vote carries all required fields (D-060). +func TestVoteStructFields(t *testing.T) { + v := types.Vote{ + VoteID: "v1", + ProposalID: "p1", + VoterReach: "reach:voter", + Option: types.VoteOptionVeto, + Timestamp: 1500, + } + if v.VoteID != "v1" || v.ProposalID != "p1" || v.VoterReach != "reach:voter" || + v.Option != types.VoteOptionVeto || v.Timestamp != 1500 { + t.Error("Vote fields not set correctly") + } +} + +// TestWatcherVetoQuorumDefault asserts the default WatcherVetoQuorum is 6 +// (D-065/A-574 — matching REQ-004 6-of-9). +func TestWatcherVetoQuorumDefault(t *testing.T) { + if types.WatcherVetoQuorumDefault != 6 { + t.Errorf("WatcherVetoQuorumDefault = %d, expected 6 (D-065/A-574)", types.WatcherVetoQuorumDefault) + } + p := types.DefaultParams() + if p.WatcherVetoQuorum != 6 { + t.Errorf("DefaultParams().WatcherVetoQuorum = %d, expected 6 (D-065/A-574)", p.WatcherVetoQuorum) + } +} + +// TestParamsValidateBounds asserts Params.Validate() bounds WatcherVetoQuorum +// to [2, 9] (G-020 — the Watcher set is 9 per REQ-004; below 2 is +// meaningless, above 9 is unsatisfiable). +func TestParamsValidateBounds(t *testing.T) { + // Below min (2) → rejected. + if err := (types.Params{WatcherVetoQuorum: 1}).Validate(); err == nil { + t.Error("WatcherVetoQuorum=1 should be rejected (G-020 min 2)") + } + if err := (types.Params{WatcherVetoQuorum: 0}).Validate(); err == nil { + t.Error("WatcherVetoQuorum=0 should be rejected (G-020 min 2)") + } + // Above max (9) → rejected. + if err := (types.Params{WatcherVetoQuorum: 10}).Validate(); err == nil { + t.Error("WatcherVetoQuorum=10 should be rejected (G-020 max 9)") + } + // Bounds [2, 9] → accepted. + for q := uint32(2); q <= 9; q++ { + if err := (types.Params{WatcherVetoQuorum: q}).Validate(); err != nil { + t.Errorf("WatcherVetoQuorum=%d should be accepted (G-020 bounds [2,9]), got: %v", q, err) + } + } +} + +// TestMsgSubmitProposalValidateBasicRejectsMissionLockAmendment asserts +// the MissionLockAmendment-Rejected kind is REJECTED at ValidateBasic +// (D-064/A-572 — the message never reaches the handler; the const + +// ValidateBasic dual firewall). The keeper Proposal store stays empty +// (the handler is never invoked with this kind). +func TestMsgSubmitProposalValidateBasicRejectsMissionLockAmendment(t *testing.T) { + msg := &types.MsgSubmitProposal{ + ProposalID: "p1", + CouncilID: "cm", + Kind: types.ProposalMissionLockAmendmentRejected, + ProposerReach: "reach:prop", + SubmitTime: 1000, + VotingDeadline: 2000, + Signer: "reach:prop", + } + err := msg.ValidateBasic() + if err == nil { + t.Fatal("MsgSubmitProposal with MissionLockAmendment-Rejected kind should be rejected at ValidateBasic (D-064/A-572)") + } + if !strings.Contains(err.Error(), "MissionLockAmendment") { + t.Errorf("error should reference the Mission Lock; got: %v", err) + } +} + +// TestMsgSubmitProposalValidateBasicAcceptsValid asserts the valid kinds +// (Stand, Guild, Mesh) pass ValidateBasic. +func TestMsgSubmitProposalValidateBasicAcceptsValid(t *testing.T) { + for _, kind := range []types.ProposalKind{types.ProposalKindStand, types.ProposalKindGuild, types.ProposalKindMesh} { + msg := &types.MsgSubmitProposal{ + ProposalID: "p1", + CouncilID: "cm", + Kind: kind, + ProposerReach: "reach:prop", + SubmitTime: 1000, + VotingDeadline: 2000, + Signer: "reach:prop", + } + if err := msg.ValidateBasic(); err != nil { + t.Errorf("kind %q should pass ValidateBasic; got: %v", kind, err) + } + } +} + +// TestMsgSubmitProposalValidateBasicErrorPaths asserts the other +// ValidateBasic error paths (empty fields, bad deadline). +func TestMsgSubmitProposalValidateBasicErrorPaths(t *testing.T) { + // empty proposal-id + if err := (&types.MsgSubmitProposal{CouncilID: "cm", Kind: types.ProposalKindMesh, ProposerReach: "r", VotingDeadline: 2, SubmitTime: 1, Signer: "r"}).ValidateBasic(); err == nil { + t.Error("empty proposal-id should be rejected") + } + // empty council-id + if err := (&types.MsgSubmitProposal{ProposalID: "p", Kind: types.ProposalKindMesh, ProposerReach: "r", VotingDeadline: 2, SubmitTime: 1, Signer: "r"}).ValidateBasic(); err == nil { + t.Error("empty council-id should be rejected") + } + // unknown kind + if err := (&types.MsgSubmitProposal{ProposalID: "p", CouncilID: "cm", Kind: types.ProposalKind("Bogus"), ProposerReach: "r", VotingDeadline: 2, SubmitTime: 1, Signer: "r"}).ValidateBasic(); err == nil { + t.Error("unknown kind should be rejected") + } + // empty proposer-reach + if err := (&types.MsgSubmitProposal{ProposalID: "p", CouncilID: "cm", Kind: types.ProposalKindMesh, VotingDeadline: 2, SubmitTime: 1, Signer: "r"}).ValidateBasic(); err == nil { + t.Error("empty proposer-reach should be rejected") + } + // empty signer + if err := (&types.MsgSubmitProposal{ProposalID: "p", CouncilID: "cm", Kind: types.ProposalKindMesh, ProposerReach: "r", VotingDeadline: 2, SubmitTime: 1}).ValidateBasic(); err == nil { + t.Error("empty signer should be rejected") + } + // voting-deadline <= submit-time + if err := (&types.MsgSubmitProposal{ProposalID: "p", CouncilID: "cm", Kind: types.ProposalKindMesh, ProposerReach: "r", VotingDeadline: 1, SubmitTime: 2, Signer: "r"}).ValidateBasic(); err == nil { + t.Error("voting-deadline <= submit-time should be rejected") + } + if err := (&types.MsgSubmitProposal{ProposalID: "p", CouncilID: "cm", Kind: types.ProposalKindMesh, ProposerReach: "r", VotingDeadline: 1, SubmitTime: 1, Signer: "r"}).ValidateBasic(); err == nil { + t.Error("voting-deadline == submit-time should be rejected") + } +} + +// TestMsgVoteValidateBasic asserts MsgVote ValidateBasic error paths. +func TestMsgVoteValidateBasic(t *testing.T) { + // valid + if err := (&types.MsgVote{VoteID: "v", ProposalID: "p", VoterReach: "r", Option: types.VoteOptionYes, Signer: "r"}).ValidateBasic(); err != nil { + t.Errorf("valid MsgVote should pass; got: %v", err) + } + // Veto is allowed at ValidateBasic (Watcher authz is a runtime gate). + if err := (&types.MsgVote{VoteID: "v", ProposalID: "p", VoterReach: "r", Option: types.VoteOptionVeto, Signer: "r"}).ValidateBasic(); err != nil { + t.Errorf("MsgVote with Veto should pass ValidateBasic (Watcher authz is a runtime gate); got: %v", err) + } + // empty vote-id + if err := (&types.MsgVote{ProposalID: "p", VoterReach: "r", Option: types.VoteOptionYes, Signer: "r"}).ValidateBasic(); err == nil { + t.Error("empty vote-id should be rejected") + } + // empty proposal-id + if err := (&types.MsgVote{VoteID: "v", VoterReach: "r", Option: types.VoteOptionYes, Signer: "r"}).ValidateBasic(); err == nil { + t.Error("empty proposal-id should be rejected") + } + // empty voter-reach + if err := (&types.MsgVote{VoteID: "v", ProposalID: "p", Option: types.VoteOptionYes, Signer: "r"}).ValidateBasic(); err == nil { + t.Error("empty voter-reach should be rejected") + } + // unknown option + if err := (&types.MsgVote{VoteID: "v", ProposalID: "p", VoterReach: "r", Option: types.VoteOption("Bogus"), Signer: "r"}).ValidateBasic(); err == nil { + t.Error("unknown option should be rejected") + } + // empty signer + if err := (&types.MsgVote{VoteID: "v", ProposalID: "p", VoterReach: "r", Option: types.VoteOptionYes}).ValidateBasic(); err == nil { + t.Error("empty signer should be rejected") + } +} + +// TestMsgTallyProposalValidateBasic asserts MsgTallyProposal ValidateBasic. +func TestMsgTallyProposalValidateBasic(t *testing.T) { + // valid + if err := (&types.MsgTallyProposal{ProposalID: "p", Signer: "r"}).ValidateBasic(); err != nil { + t.Errorf("valid MsgTallyProposal should pass; got: %v", err) + } + // empty proposal-id + if err := (&types.MsgTallyProposal{Signer: "r"}).ValidateBasic(); err == nil { + t.Error("empty proposal-id should be rejected") + } + // empty signer + if err := (&types.MsgTallyProposal{ProposalID: "p"}).ValidateBasic(); err == nil { + t.Error("empty signer should be rejected") + } +} + +// TestValidateGenesisRejectsDupProposalIDs asserts A-212: duplicate +// proposal-ids are rejected (P7 genesis validation). +func TestValidateGenesisRejectsDupProposalIDs(t *testing.T) { + gs := types.GenesisState{ + Councils: []types.Council{{CouncilID: "c1", Kind: types.CouncilMesh}}, + Proposals: []types.Proposal{ + {ProposalID: "p1", CouncilID: "c1", Kind: types.ProposalKindMesh, Status: types.ProposalStatusPending}, + {ProposalID: "p1", CouncilID: "c1", Kind: types.ProposalKindMesh, Status: types.ProposalStatusActive}, + }, + Params: types.DefaultParams(), + } + bz, _ := json.Marshal(gs) + if err := types.ValidateGenesis(bz); err == nil { + t.Error("ValidateGenesis should reject duplicate proposal-ids") + } +} + +// TestValidateGenesisRejectsProposalWithUnknownCouncil asserts referential +// integrity: a Proposal whose council-id does not reference an existing +// Council is rejected (P7 genesis validation). +func TestValidateGenesisRejectsProposalWithUnknownCouncil(t *testing.T) { + gs := types.GenesisState{ + Councils: []types.Council{{CouncilID: "c1", Kind: types.CouncilMesh}}, + Proposals: []types.Proposal{{ProposalID: "p1", CouncilID: "no-such", Kind: types.ProposalKindMesh, Status: types.ProposalStatusPending}}, + Params: types.DefaultParams(), + } + bz, _ := json.Marshal(gs) + if err := types.ValidateGenesis(bz); err == nil { + t.Error("ValidateGenesis should reject Proposal with unknown council-id") + } +} + +// TestValidateGenesisRejectsProposalWithUnknownKind asserts an unknown +// ProposalKind is rejected at genesis. +func TestValidateGenesisRejectsProposalWithUnknownKind(t *testing.T) { + gs := types.GenesisState{ + Councils: []types.Council{{CouncilID: "c1", Kind: types.CouncilMesh}}, + Proposals: []types.Proposal{{ProposalID: "p1", CouncilID: "c1", Kind: types.ProposalKind("Bogus"), Status: types.ProposalStatusPending}}, + Params: types.DefaultParams(), + } + bz, _ := json.Marshal(gs) + if err := types.ValidateGenesis(bz); err == nil { + t.Error("ValidateGenesis should reject Proposal with unknown kind") + } +} + +// TestValidateGenesisRejectsBadParams asserts a Params with an out-of- +// bounds WatcherVetoQuorum is rejected (G-020). +func TestValidateGenesisRejectsBadParams(t *testing.T) { + gs := types.GenesisState{ + Councils: []types.Council{{CouncilID: "c1", Kind: types.CouncilMesh}}, + Params: types.Params{WatcherVetoQuorum: 0}, // below min 2 + } + bz, _ := json.Marshal(gs) + if err := types.ValidateGenesis(bz); err == nil { + t.Error("ValidateGenesis should reject Params with WatcherVetoQuorum=0 (G-020)") + } +} + +// TestValidateGenesisAcceptsProposalAndVotes asserts a clean genesis with +// Proposals + Votes validates. +func TestValidateGenesisAcceptsProposalAndVotes(t *testing.T) { + gs := types.GenesisState{ + Councils: []types.Council{{CouncilID: "cm", Kind: types.CouncilMesh}}, + Proposals: []types.Proposal{ + {ProposalID: "p1", CouncilID: "cm", Kind: types.ProposalKindMesh, Status: types.ProposalStatusActive}, + }, + Votes: []types.Vote{ + {VoteID: "v1", ProposalID: "p1", Option: types.VoteOptionYes}, + {VoteID: "v2", ProposalID: "p1", Option: types.VoteOptionVeto}, + }, + Params: types.DefaultParams(), + } + bz, _ := json.Marshal(gs) + if err := types.ValidateGenesis(bz); err != nil { + t.Errorf("ValidateGenesis should accept clean proposal+vote genesis, got: %v", err) + } +} + // --- Lexicon assertion (REQ-012) ------------------------------------------------- // TestLexiconNoBannedTermsInCouncilPackage scans every non-test .go file in