diff --git a/x/guild/keeper/keeper.go b/x/guild/keeper/keeper.go new file mode 100644 index 0000000..125989e --- /dev/null +++ b/x/guild/keeper/keeper.go @@ -0,0 +1,275 @@ +package keeper + +// keeper.go holds the store-backed Keeper for the guild module's Guild +// Charter + Chapter Federation + Household + Confederation runtime (P3, +// REQ-051, REQ-053, REQ-057, REQ-058). +// +// The Keeper wraps an sdk.KVStore via a storeKey. It holds: +// - the Guild records (guild-id -> Guild; both Parent Guilds and Chapters +// are stored here — a Chapter is a Guild with IsChapter=true); +// - the Lien records (guild-id + lien-idx -> Lien; the AddLien handler +// appends here with SecuredAtFounding=false; founding-locked liens +// (SecuredAtFounding=true) are stored on the Guild itself at creation); +// - the Confederation Voice delegation records +// (confederation-stand-id + member-stand-id -> ConfederationVoice). +// +// The Keeper also holds the two expected-keeper shims (StandKeeper for the +// Household/Confederation type check; StashKeeper for the asset return on +// Household one-tap exit). The shims are interfaces (G-003 — no struct +// import of x/stand/types or x/stash/types); the concrete keepers (or +// simtest stubs) satisfy them structurally. +// +// State-machine ordering (vision §7, enforced in every handler): +// ValidateBasic -> handler authz/gate -> state mutation -> ctx.EventManager().EmitEvent + +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/guild/types" +) + +// Keeper is the store-backed guild Keeper. +type Keeper struct { + cdc codec.Codec + storeKey storetypes.StoreKey + standKeeper types.StandKeeper + stashKeeper types.StashKeeper + paramsHolder types.Params +} + +// NewKeeper constructs a new store-backed guild Keeper. The StandKeeper + +// StashKeeper expected-keeper shims are injected (StandKeeper is nil-able +// for partial wiring — the OneTapExitStand + DelegateConfederationVoice +// handlers REJECT on a nil StandKeeper (the type check is load-bearing); +// StashKeeper is nil-able — a nil StashKeeper skips the asset return on +// one-tap exit (simtest wiring)). +func NewKeeper(cdc codec.Codec, storeKey storetypes.StoreKey, sk types.StandKeeper, stashK types.StashKeeper) Keeper { + return Keeper{ + cdc: cdc, + storeKey: storeKey, + standKeeper: sk, + stashKeeper: stashK, + paramsHolder: types.DefaultParams(), + } +} + +// SetStandKeeper sets the StandKeeper expected-keeper shim (for +// post-construction wiring, e.g., app wiring or test setup). +func (k *Keeper) SetStandKeeper(sk types.StandKeeper) { k.standKeeper = sk } + +// SetStashKeeper sets the StashKeeper expected-keeper shim. +func (k *Keeper) SetStashKeeper(stashK types.StashKeeper) { k.stashKeeper = stashK } + +// SetParams sets the Params (simtest-grade override; a future version will +// load from the params store). +func (k *Keeper) SetParams(p types.Params) { k.paramsHolder = p } + +// Params returns the effective Params. +func (k Keeper) Params() types.Params { return k.paramsHolder } + +// StoreKey returns the keeper's store key (exported for simtest access to +// the underlying KVStore, e.g., to inject corrupt bytes for marshal-error +// coverage). Mirrors the x/cover simtest pattern. +func (k Keeper) StoreKey() storetypes.StoreKey { return k.storeKey } + +// --- Guild store -------------------------------------------------------------- + +var guildKeyPrefix = []byte("guild/") + +func guildKey(guildID string) []byte { + return append(guildKeyPrefix, []byte(guildID)...) +} + +// GetGuild loads a Guild by guild-id. Returns the Guild and true if found, +// or zero value + false if not. Both Parent Guilds and Chapters are stored +// here (a Chapter is a Guild with IsChapter=true). +func (k Keeper) GetGuild(ctx sdk.Context, guildID string) (types.Guild, bool) { + store := ctx.KVStore(k.storeKey) + bz := store.Get(guildKey(guildID)) + if bz == nil { + return types.Guild{}, false + } + var g types.Guild + if err := json.Unmarshal(bz, &g); err != nil { + return types.Guild{}, false + } + return g, true +} + +// SetGuild persists a Guild by guild-id. +func (k Keeper) SetGuild(ctx sdk.Context, g types.Guild) { + store := ctx.KVStore(k.storeKey) + bz, err := json.Marshal(g) + if err != nil { + panic(fmt.Sprintf("guild: marshal guild %q: %v", g.GuildID, err)) + } + store.Set(guildKey(g.GuildID), bz) +} + +// AllGuilds returns all persisted Guild records (iteration helper, +// unordered). Both Parent Guilds and Chapters are returned. +func (k Keeper) AllGuilds(ctx sdk.Context) []types.Guild { + store := ctx.KVStore(k.storeKey) + iterator := store.Iterator(guildKeyPrefix, prefixEnd(guildKeyPrefix)) + defer iterator.Close() + out := []types.Guild{} + for ; iterator.Valid(); iterator.Next() { + var g types.Guild + if err := json.Unmarshal(iterator.Value(), &g); err == nil { + out = append(out, g) + } + } + return out +} + +// --- Lien store --------------------------------------------------------------- +// +// The Lien store is keyed by guild-id + lien-idx. The AddLien handler +// appends here with SecuredAtFounding=false. Founding-locked liens +// (SecuredAtFounding=true) are stored on the Guild itself at creation +// (GoodStandingLiens slice); the AddLien handler rejects any new +// SecuredAtFounding=true lien (founding is a one-time event — REQ-053). + +var lienKeyPrefix = []byte("lien/") + +func lienKey(guildID string, idx uint32) []byte { + return append(lienKeyPrefix, []byte(fmt.Sprintf("%s/%d", guildID, idx))...) +} + +// GetLien loads a Lien by guild-id + lien-idx. Returns the Lien and true if +// found, or zero value + false if not. +func (k Keeper) GetLien(ctx sdk.Context, guildID string, idx uint32) (types.Lien, bool) { + store := ctx.KVStore(k.storeKey) + bz := store.Get(lienKey(guildID, idx)) + if bz == nil { + return types.Lien{}, false + } + var l types.Lien + if err := json.Unmarshal(bz, &l); err != nil { + return types.Lien{}, false + } + return l, true +} + +// SetLien persists a Lien by guild-id + lien-idx. +func (k Keeper) SetLien(ctx sdk.Context, guildID string, idx uint32, l types.Lien) { + store := ctx.KVStore(k.storeKey) + bz, err := json.Marshal(l) + if err != nil { + panic(fmt.Sprintf("guild: marshal lien %s/%d: %v", guildID, idx, err)) + } + store.Set(lienKey(guildID, idx), bz) +} + +// AllLiens returns all persisted Lien records for a guild (iteration helper, +// unordered — the idx ordering is NOT preserved across iterations; the +// simtest asserts count + content, not order). +func (k Keeper) AllLiens(ctx sdk.Context, guildID string) []types.Lien { + prefix := append(lienKeyPrefix, []byte(guildID+"/")...) + store := ctx.KVStore(k.storeKey) + iterator := store.Iterator(prefix, prefixEnd(prefix)) + defer iterator.Close() + out := []types.Lien{} + for ; iterator.Valid(); iterator.Next() { + var l types.Lien + if err := json.Unmarshal(iterator.Value(), &l); err == nil { + out = append(out, l) + } + } + return out +} + +// NextLienIdx returns the next lien-idx for a guild (the count of existing +// liens — the AddLien handler uses this to assign the new lien's idx). The +// founding-locked liens on the Guild's GoodStandingLiens slice do NOT +// consume an idx in this store (they are stored on the Guild itself); only +// post-founding liens (SecuredAtFounding=false) added via AddLien consume an +// idx here. +func (k Keeper) NextLienIdx(ctx sdk.Context, guildID string) uint32 { + return uint32(len(k.AllLiens(ctx, guildID))) +} + +// --- Confederation Voice delegation store -------------------------------------- +// +// The delegation store is keyed by confederation-stand-id + member-stand-id. +// The DelegateConfederationVoice handler records one delegation per member +// Stand (a duplicate delegation from the same MemberStandID is REJECTED). +// One-Stand-one-Vote: each member Stand gets exactly 1 Voice in the +// Confederation's aggregate, regardless of size. + +var delegationKeyPrefix = []byte("delegation/") + +func delegationKey(confederationStandID, memberStandID string) []byte { + return append(delegationKeyPrefix, []byte(fmt.Sprintf("%s/%s", confederationStandID, memberStandID))...) +} + +// GetDelegation loads a ConfederationVoice delegation by confederation-stand-id +// + member-stand-id. Returns the ConfederationVoice (from x/guild/types) and +// true if found, or zero value + false if not. +func (k Keeper) GetDelegation(ctx sdk.Context, confederationStandID, memberStandID string) (types.ConfederationVoice, bool) { + store := ctx.KVStore(k.storeKey) + bz := store.Get(delegationKey(confederationStandID, memberStandID)) + if bz == nil { + return types.ConfederationVoice{}, false + } + var v types.ConfederationVoice + if err := json.Unmarshal(bz, &v); err != nil { + return types.ConfederationVoice{}, false + } + return v, true +} + +// SetDelegation persists a ConfederationVoice delegation by confederation- +// stand-id + member-stand-id. +func (k Keeper) SetDelegation(ctx sdk.Context, v types.ConfederationVoice) { + store := ctx.KVStore(k.storeKey) + bz, err := json.Marshal(v) + if err != nil { + panic(fmt.Sprintf("guild: marshal delegation %s/%s: %v", v.ConfederationStandID, v.MemberStandID, err)) + } + store.Set(delegationKey(v.ConfederationStandID, v.MemberStandID), bz) +} + +// AllDelegations returns all persisted ConfederationVoice delegations for a +// Confederation Stand (iteration helper, unordered). +func (k Keeper) AllDelegations(ctx sdk.Context, confederationStandID string) []types.ConfederationVoice { + prefix := append(delegationKeyPrefix, []byte(confederationStandID+"/")...) + store := ctx.KVStore(k.storeKey) + iterator := store.Iterator(prefix, prefixEnd(prefix)) + defer iterator.Close() + out := []types.ConfederationVoice{} + for ; iterator.Valid(); iterator.Next() { + var v types.ConfederationVoice + if err := json.Unmarshal(iterator.Value(), &v); err == nil { + out = append(out, v) + } + } + return out +} + +// --- prefixEnd helper --------------------------------------------------------- + +// 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. Mirrors x/hub/keeper/keeper.go + x/cover/keeper/keeper.go. +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/guild/keeper/msg_server.go b/x/guild/keeper/msg_server.go new file mode 100644 index 0000000..e340be6 --- /dev/null +++ b/x/guild/keeper/msg_server.go @@ -0,0 +1,333 @@ +package keeper + +// msg_server.go implements the guild module's MsgServer (P3, REQ-051, +// REQ-053, REQ-057, REQ-058, REQ-061). The MsgServer wraps the Keeper + the +// StandKeeper + StashKeeper expected-keeper shims (already on the Keeper). +// +// Each method returns a (*Response, error). Handler state-machine ordering +// is enforced: ValidateBasic -> handler authz/gate -> state mutation -> +// ctx.EventManager().EmitEvent. +// +// Handler set: +// - CreateGuild (REQ-051): validate, idempotency, persist Guild with +// CommonBondHash + PublicProfile, surface a jurisdictional disclaimer +// (REQ-061). +// - CreateChapter (REQ-053): validate, idempotency, load Parent Guild, +// pin SecessionTermsHash, set IsChapter=true + ParentGuildID, record +// GoodStandingLiens (SecuredAtFounding=true), reject cooling below the +// protocol minimum, persist, surface a disclaimer (REQ-061). +// - OneTapExitStand (REQ-057): validate, assert Stand type is Household +// via StandKeeper shim (nil REJECTS), dissolve the Stand + return assets +// to the Holder's Stash via StashKeeper shim (nil skips the return, +// still emits the dissolution event), emit event. +// - DelegateConfederationVoice (REQ-058): validate, assert Confederation +// Stand type via StandKeeper shim, record one delegation per member +// Stand (duplicate REJECTED), emit event. +// - AddLien (REQ-053): validate, load Guild, REJECT any new +// SecuredAtFounding=true lien (founding is one-time — REQ-053/REQ-081), +// persist the lien, emit event. +// +// Nil-shim behavior (simtest wiring): a nil StandKeeper REJECTS the +// OneTapExitStand + DelegateConfederationVoice handlers (the Household / +// Confederation type check is load-bearing — it cannot be skipped). A nil +// StashKeeper skips the asset return on one-tap exit (the handler still +// emits the dissolution event — the asset return is a side-effect the +// simtest stub records). + +import ( + "fmt" + + sdk "github.com/cosmos/cosmos-sdk/types" + + "github.com/oy/openyield/x/guild/types" +) + +// DisclaimerJurisdictional is the jurisdictional disclaimer surfaced at +// every charter signing (REQ-061). NOT session-bounded — surfaced at every +// CreateGuild + CreateChapter. The disclaimer is a fixed string (the live +// jurisdictional overlay lands in a later phase; the simtest asserts the +// Disclaimer field is non-empty). +const DisclaimerJurisdictional = "OpenYield Guilds are self-governed mesh collectives; the protocol does not provide legal, tax, or fiduciary advice. Signers affirm they have reviewed the Common Bond + jurisdictional obligations before signing." + +// msgServer is the concrete MsgServer implementation wrapping the Keeper. +type msgServer struct { + Keeper +} + +// NewMsgServerImpl returns the guild 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("guild: expected sdk.Context, got %T", ctx)) +} + +// --- CreateGuild (REQ-051, REQ-061) ------------------------------------------- + +// CreateGuild creates a Guild with a Common Bond hash + Public Profile +// (REQ-051). The handler enforces: +// 1. ValidateBasic (stateless — non-empty fields + non-empty +// CommonBondHash). +// 2. Idempotency: guild-id must not already exist. +// 3. Persist the Guild with CommonBondHash + PublicProfile (the Common +// Bond is hash-pinned at creation — immutable; the handler does NOT +// store the bond text, only the hash). +// 4. Surface a jurisdictional disclaimer (REQ-061) in the response. +// +// On success the Guild is persisted and an event is emitted. +func (s msgServer) CreateGuild(ctx interface{}, msg *types.MsgCreateGuild) (*types.MsgCreateGuildResponse, error) { + if err := msg.ValidateBasic(); err != nil { + return nil, err + } + sdkCtx := unwrapCtx(ctx) + + // Idempotency: guild-id must not already exist. + if _, ok := s.Keeper.GetGuild(sdkCtx, msg.GuildID); ok { + return nil, fmt.Errorf("guild: guild %q already exists", msg.GuildID) + } + + g := types.Guild{ + GuildID: msg.GuildID, + Name: msg.Name, + FounderReach: msg.FounderReach, + CreatedAt: sdkCtx.BlockTime().Unix(), + StandAffiliationID: msg.StandAffiliationID, + CommonBondHash: msg.CommonBondHash, + PublicProfile: msg.PublicProfile, + IsChapter: false, + ParentGuildID: "", + } + s.Keeper.SetGuild(sdkCtx, g) + + sdkCtx.EventManager().EmitEvent(sdk.NewEvent( + "guild.guild_created", + sdk.NewAttribute("guild_id", msg.GuildID), + sdk.NewAttribute("founder_reach", msg.FounderReach), + )) + return &types.MsgCreateGuildResponse{Disclaimer: DisclaimerJurisdictional}, nil +} + +// --- CreateChapter (REQ-053, REQ-061) ----------------------------------------- + +// CreateChapter creates a Chapter under a Parent Guild (REQ-053). The +// handler enforces: +// 1. ValidateBasic (stateless — non-empty fields, SecessionTerms valid + +// protocol-minimum-bounded, each GoodStandingLien is SecuredAtFounding). +// 2. Idempotency: chapter guild-id must not already exist. +// 3. Load the Parent Guild (must exist; must NOT itself be a Chapter — a +// Chapter cannot have a Chapter parent). +// 4. Pin the SecessionTerms hash (HashSecessionTerms — immutable; no +// handler to amend it). +// 5. Set IsChapter=true + ParentGuildID + GoodStandingLiens (each with +// SecuredAtFounding=true — ValidateBasic already enforced this). +// 6. Persist the Chapter. +// 7. Surface a jurisdictional disclaimer (REQ-061) in the response. +// +// On success the Chapter is persisted and an event is emitted. +func (s msgServer) CreateChapter(ctx interface{}, msg *types.MsgCreateChapter) (*types.MsgCreateChapterResponse, error) { + if err := msg.ValidateBasic(); err != nil { + return nil, err + } + sdkCtx := unwrapCtx(ctx) + + // Idempotency: chapter guild-id must not already exist. + if _, ok := s.Keeper.GetGuild(sdkCtx, msg.GuildID); ok { + return nil, fmt.Errorf("guild: chapter %q already exists", msg.GuildID) + } + + // Load the Parent Guild (must exist; must NOT itself be a Chapter). + parent, ok := s.Keeper.GetGuild(sdkCtx, msg.ParentGuildID) + if !ok { + return nil, fmt.Errorf("guild: parent guild %q not found (REQ-053)", msg.ParentGuildID) + } + if parent.IsChapter { + return nil, fmt.Errorf("guild: parent %q is itself a Chapter (a Chapter cannot have a Chapter parent — REQ-053)", msg.ParentGuildID) + } + + // Pin the SecessionTerms hash (immutable — no handler to amend it). + termsHash := types.HashSecessionTerms(msg.SecessionTerms) + + // GoodStandingLiens are recorded with SecuredAtFounding=true + // (ValidateBasic already enforced this — founding-locked liens). + liens := make([]types.Lien, len(msg.GoodStandingLiens)) + copy(liens, msg.GoodStandingLiens) + + chapter := types.Guild{ + GuildID: msg.GuildID, + Name: msg.Name, + FounderReach: msg.FounderReach, + CreatedAt: sdkCtx.BlockTime().Unix(), + CommonBondHash: parent.CommonBondHash, // a Chapter inherits the Parent's Common Bond hash + PublicProfile: parent.PublicProfile, // a Chapter inherits the Parent's Public Profile + IsChapter: true, + ParentGuildID: msg.ParentGuildID, + SecessionTermsHash: termsHash, + GoodStandingLiens: liens, + } + s.Keeper.SetGuild(sdkCtx, chapter) + + sdkCtx.EventManager().EmitEvent(sdk.NewEvent( + "guild.chapter_created", + sdk.NewAttribute("guild_id", msg.GuildID), + sdk.NewAttribute("parent_guild_id", msg.ParentGuildID), + )) + return &types.MsgCreateChapterResponse{Disclaimer: DisclaimerJurisdictional}, nil +} + +// --- OneTapExitStand (REQ-057) ------------------------------------------------ + +// OneTapExitStand one-tap exits a Household Stand (REQ-057). The handler +// enforces: +// 1. ValidateBasic (stateless). +// 2. StandKeeper shim must be non-nil (the Household type check is +// load-bearing — a nil shim is a wiring error, REJECTED). +// 3. The Stand must exist + its type must be "Household" (one-tap exit is +// Household-only — a Crew / Entity / etc. Stand is REJECTED). +// 4. StashKeeper shim: if non-nil, call ReturnAssetsToHolder to return the +// dissolved Stand's assets to the Holder's Stash (a nil shim skips the +// return — simtest wiring; the dissolution event is still emitted). A +// non-nil error from ReturnAssetsToHolder REJECTS the dissolution (the +// asset return is load-bearing — a failed return leaves the Stand +// intact). +// 5. Emit the dissolution event. +// +// The signer is treated as the Holder (the Reach the assets are returned +// to). The live authz (signer must be the Stand's admin-reach) is deferred +// (simtest grade). +func (s msgServer) OneTapExitStand(ctx interface{}, msg *types.MsgOneTapExitStand) (*types.MsgOneTapExitStandResponse, error) { + if err := msg.ValidateBasic(); err != nil { + return nil, err + } + sdkCtx := unwrapCtx(ctx) + + // StandKeeper shim must be non-nil (the type check is load-bearing). + if s.Keeper.standKeeper == nil { + return nil, fmt.Errorf("guild: StandKeeper not wired (OneTapExitStand rejected — Household type check is load-bearing)") + } + + // The Stand must exist + be a Household (one-tap exit is Household-only). + standType, exists := s.Keeper.standKeeper.GetStand(msg.StandID) + if !exists { + return nil, fmt.Errorf("guild: stand %q not found (OneTapExitStand rejected)", msg.StandID) + } + if standType != "Household" { + return nil, fmt.Errorf("guild: stand %q type %q is not a Household (one-tap exit is Household-only — REQ-057)", msg.StandID, standType) + } + + // StashKeeper: return the dissolved Stand's assets to the Holder's Stash. + // A nil shim skips the return (simtest wiring); a non-nil error REJECTS + // (the asset return is load-bearing). + if s.Keeper.stashKeeper != nil { + if err := s.Keeper.stashKeeper.ReturnAssetsToHolder(msg.Signer, msg.StandID); err != nil { + return nil, fmt.Errorf("guild: return assets to holder %q for stand %q: %w", msg.Signer, msg.StandID, err) + } + } + + sdkCtx.EventManager().EmitEvent(sdk.NewEvent( + "guild.one_tap_exit", + sdk.NewAttribute("stand_id", msg.StandID), + sdk.NewAttribute("holder_reach", msg.Signer), + )) + return &types.MsgOneTapExitStandResponse{}, nil +} + +// --- DelegateConfederationVoice (REQ-058) ------------------------------------- + +// DelegateConfederationVoice delegates a member Stand's Voice in a +// Confederation (REQ-058). The handler enforces: +// 1. ValidateBasic (stateless). +// 2. StandKeeper shim must be non-nil (the Confederation type check is +// load-bearing — a nil shim is a wiring error, REJECTED). +// 3. The Confederation Stand must exist + its type must be "Confederation". +// 4. One delegation per member Stand: a duplicate delegation from the same +// MemberStandID is REJECTED (one-Stand-one-Vote — each member Stand gets +// exactly 1 Voice in the Confederation's aggregate, regardless of size). +// 5. Persist the delegation + emit the event. +func (s msgServer) DelegateConfederationVoice(ctx interface{}, msg *types.MsgDelegateConfederationVoice) (*types.MsgDelegateConfederationVoiceResponse, error) { + if err := msg.ValidateBasic(); err != nil { + return nil, err + } + sdkCtx := unwrapCtx(ctx) + + // StandKeeper shim must be non-nil (the type check is load-bearing). + if s.Keeper.standKeeper == nil { + return nil, fmt.Errorf("guild: StandKeeper not wired (DelegateConfederationVoice rejected — Confederation type check is load-bearing)") + } + + // The Confederation Stand must exist + be a Confederation. + standType, exists := s.Keeper.standKeeper.GetStand(msg.ConfederationStandID) + if !exists { + return nil, fmt.Errorf("guild: confederation stand %q not found", msg.ConfederationStandID) + } + if standType != "Confederation" { + return nil, fmt.Errorf("guild: stand %q type %q is not a Confederation (REQ-058)", msg.ConfederationStandID, standType) + } + + // One delegation per member Stand: a duplicate is REJECTED. + if _, ok := s.Keeper.GetDelegation(sdkCtx, msg.ConfederationStandID, msg.MemberStandID); ok { + return nil, fmt.Errorf("guild: member stand %q already delegates in confederation %q (one-Stand-one-Vote — duplicate REJECTED — REQ-058)", msg.MemberStandID, msg.ConfederationStandID) + } + + v := types.ConfederationVoice{ + ConfederationStandID: msg.ConfederationStandID, + MemberStandID: msg.MemberStandID, + DelegateReachID: msg.DelegateReachID, + DelegatedAt: sdkCtx.BlockTime().Unix(), + } + s.Keeper.SetDelegation(sdkCtx, v) + + sdkCtx.EventManager().EmitEvent(sdk.NewEvent( + "guild.confederation_voice_delegated", + sdk.NewAttribute("confederation_stand_id", msg.ConfederationStandID), + sdk.NewAttribute("member_stand_id", msg.MemberStandID), + sdk.NewAttribute("delegate_reach_id", msg.DelegateReachID), + )) + return &types.MsgDelegateConfederationVoiceResponse{}, nil +} + +// --- AddLien (REQ-053) -------------------------------------------------------- + +// AddLien adds a Good-Standing Lien to a Guild (REQ-053). The handler +// enforces: +// 1. ValidateBasic (stateless — non-empty fields, Lien Amount > 0). +// 2. The Guild must exist. +// 3. REJECT any new SecuredAtFounding=true lien (founding is a one-time +// event — REQ-053/REQ-081; post-founding liens added via AddLien MUST +// be SecuredAtFounding=false). +// 4. Persist the lien (assigned the next lien-idx) + emit the event. +func (s msgServer) AddLien(ctx interface{}, msg *types.MsgAddLien) (*types.MsgAddLienResponse, error) { + if err := msg.ValidateBasic(); err != nil { + return nil, err + } + sdkCtx := unwrapCtx(ctx) + + // The Guild must exist. + if _, ok := s.Keeper.GetGuild(sdkCtx, msg.GuildID); !ok { + return nil, fmt.Errorf("guild: guild %q not found (AddLien rejected)", msg.GuildID) + } + + // REJECT any new SecuredAtFounding=true lien (founding is one-time — + // REQ-053/REQ-081). + if msg.Lien.SecuredAtFounding { + return nil, fmt.Errorf("guild: AddLien rejects SecuredAtFounding=true liens (founding is a one-time event — REQ-053/REQ-081; post-founding liens must be SecuredAtFounding=false)") + } + + idx := s.Keeper.NextLienIdx(sdkCtx, msg.GuildID) + s.Keeper.SetLien(sdkCtx, msg.GuildID, idx, msg.Lien) + + sdkCtx.EventManager().EmitEvent(sdk.NewEvent( + "guild.lien_added", + sdk.NewAttribute("guild_id", msg.GuildID), + sdk.NewAttribute("lien_idx", fmt.Sprintf("%d", idx)), + sdk.NewAttribute("creditor_reach_id", msg.Lien.CreditorReachID), + sdk.NewAttribute("amount", fmt.Sprintf("%d", msg.Lien.Amount)), + )) + return &types.MsgAddLienResponse{}, nil +} diff --git a/x/guild/keeper/msg_server_simtest_test.go b/x/guild/keeper/msg_server_simtest_test.go new file mode 100644 index 0000000..3e4890e --- /dev/null +++ b/x/guild/keeper/msg_server_simtest_test.go @@ -0,0 +1,978 @@ +package keeper_test + +// msg_server_simtest_test.go is the x/guild keeper simtest (P3, REQ-051, +// REQ-053, REQ-057, REQ-058, REQ-061). +// +// D-054: simtest-grade — in-memory sdk.Context + dbm in-memory store, no +// real Stand keeper (the StandKeeper shim is a stub; G-003 test exemption), +// no real Stash keeper (the StashKeeper shim is a simtest-local stub that +// records ReturnAssetsToHolder calls for assertion). The simtest exercises: +// +// CreateGuild (REQ-051 + REQ-061 disclaimer): +// - (a) successful Guild creation with Common Bond hash + Public Profile +// (MasonCount disclosed). +// - (b) successful Guild creation with MasonCountPrivate=true (count not +// disclosed — MasonCount is 0). +// - idempotency: a second CreateGuild on the same guild-id is REJECTED. +// - (g) Disclaimer surfaced at every signing (the response Disclaimer is +// non-empty). +// +// CreateChapter (REQ-053 + REQ-061 disclaimer): +// - (c) successful Chapter creation with secession terms hash-pinned + +// good-standing liens (SecuredAtFounding=true). +// - (d) Chapter inherits Parent policy + tightens (longer cooling allowed) +// + loosens (shorter cooling REJECTED at ValidateBasic). +// - rejected on non-existent Parent Guild. +// - rejected when Parent is itself a Chapter. +// - (g) Disclaimer surfaced at every signing. +// +// OneTapExitStand (REQ-057): +// - (e) Household one-tap exit succeeds (Stand type Household + StandKeeper +// stub returns "Household" + StashKeeper stub records the call). +// - (e) Crew one-tap exit REJECTED (one-tap is Household-only). +// - rejected on non-existent Stand. +// - rejected on nil StandKeeper (the type check is load-bearing). +// +// DelegateConfederationVoice (REQ-058): +// - (f) Confederation Voice delegation succeeds (one-per-Stand). +// - (f) duplicate delegation REJECTED (one-Stand-one-Vote). +// - rejected on non-Confederation Stand type. +// - rejected on nil StandKeeper. +// +// AddLien (REQ-053): +// - (h) post-founding lien with SecuredAtFounding=false succeeds. +// - (h) post-founding lien with SecuredAtFounding=true REJECTED (founding +// is one-time — REQ-053/REQ-081). +// - rejected on non-existent Guild. +// +// Coverage target: >=80% on x/guild/keeper. + +import ( + "strings" + "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/guild/keeper" + "github.com/oy/openyield/x/guild/types" +) + +// --- Stub expected-keepers (G-003 test exemption) --------------------------- + +// stubStandKeeper satisfies types.StandKeeper for the simtest. It returns a +// configurable stand-type per stand-id (a missing key returns ("", false) — +// the non-existent Stand case). +type stubStandKeeper struct { + stands map[string]string // stand-id -> stand-type +} + +func (s *stubStandKeeper) GetStand(standID string) (string, bool) { + if s.stands == nil { + return "", false + } + t, ok := s.stands[standID] + return t, ok +} + +// stubStashKeeper satisfies types.StashKeeper for the simtest. It records +// every ReturnAssetsToHolder call for assertion (the one-tap exit simtest +// asserts the call was made with the right holder + stand-id). +type stubStashKeeper struct { + calls []struct { + holderReachID string + standID string + } + err error +} + +func (s *stubStashKeeper) ReturnAssetsToHolder(holderReachID string, standID string) error { + if s.err != nil { + return s.err + } + s.calls = append(s.calls, struct { + holderReachID string + standID string + }{holderReachID, standID}) + return nil +} + +// --- Simtest context helper -------------------------------------------------- + +// newSimtestContext constructs an in-memory sdk.Context with a KVStore +// mounted at the guild store key. Returns the ctx, the two stub keepers, +// the store key, and the Keeper. +func newSimtestContext(t *testing.T) (sdk.Context, *stubStandKeeper, *stubStashKeeper, storetypes.StoreKey, 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) + } + ctx := sdk.NewContext(cms, cmtproto.Header{Time: time.Unix(1000, 0)}, false, log.NewNopLogger()) + + sk := &stubStandKeeper{} + stashK := &stubStashKeeper{} + k := keeper.NewKeeper(cdc, storeKey, sk, stashK) + return ctx, sk, stashK, storeKey, k +} + +// newSimtestContextNilStand constructs an in-memory ctx with a nil +// StandKeeper (for the nil-shim reject-path coverage). +func newSimtestContextNilStand(t *testing.T) (sdk.Context, storetypes.StoreKey, 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) + } + ctx := sdk.NewContext(cms, cmtproto.Header{Time: time.Unix(1000, 0)}, false, log.NewNopLogger()) + k := keeper.NewKeeper(cdc, storeKey, nil, nil) + return ctx, storeKey, 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 +} + +// validTerms returns SecessionTerms at the protocol minimums. +func validTerms() types.SecessionTerms { + return types.SecessionTerms{ + CoolingCoverActiveDays: types.CoolingSecessionCoverActiveDays, + CoolingNonCoverDays: types.CoolingSecessionNonCoverDays, + LienAuditRequired: true, + CovenantClearanceRequired: true, + } +} + +// createParentGuild is a helper that creates a Parent Guild for the Chapter +// simtest cases. +func createParentGuild(t *testing.T, srv types.MsgServer, ctx sdk.Context, guildID string) { + t.Helper() + _, err := srv.CreateGuild(ctx, &types.MsgCreateGuild{ + GuildID: guildID, + Name: "Parent", + FounderReach: "reach:founder", + CommonBondHash: []byte{0xAA, 0xBB, 0xCC}, + PublicProfile: types.GuildPublicProfile{ + BondSummary: "bond-summary", + MasonCount: 10, + }, + Signer: "reach:founder", + }) + if err != nil { + t.Fatalf("createParentGuild %q: %v", guildID, err) + } +} + +// --- CreateGuild (REQ-051, REQ-061) ------------------------------------------ + +// TestCreateGuildSuccess (case a) asserts a successful Guild creation with +// Common Bond hash + Public Profile (MasonCount disclosed). +func TestCreateGuildSuccess(t *testing.T) { + ctx, _, _, _, k := newSimtestContext(t) + srv := keeper.NewMsgServerImpl(k) + + resp, err := srv.CreateGuild(ctx, &types.MsgCreateGuild{ + GuildID: "g-1", + Name: "Task Guild", + FounderReach: "reach:founder", + CommonBondHash: []byte{1, 2, 3}, + PublicProfile: types.GuildPublicProfile{ + BondSummary: "a bond summary", + Disclaimers: []string{"d1"}, + MasonCount: 42, + }, + Signer: "reach:founder", + }) + if err != nil { + t.Fatalf("CreateGuild: %v", err) + } + g, ok := k.GetGuild(ctx, "g-1") + if !ok { + t.Fatal("Guild not persisted") + } + if g.IsChapter { + t.Error("IsChapter should be false for a Parent Guild") + } + if g.ParentGuildID != "" { + t.Errorf("ParentGuildID = %q, want empty for a Parent Guild", g.ParentGuildID) + } + if len(g.CommonBondHash) != 3 { + t.Errorf("CommonBondHash = %v, want 3 bytes", g.CommonBondHash) + } + if g.PublicProfile.MasonCount != 42 { + t.Errorf("MasonCount = %d, want 42", g.PublicProfile.MasonCount) + } + if g.PublicProfile.MasonCountPrivate { + t.Error("MasonCountPrivate should be false when count is disclosed") + } + if !hasEvent(ctx, "guild.guild_created") { + t.Error("guild.guild_created event not emitted") + } + // (g) Disclaimer surfaced. + if resp.Disclaimer == "" { + t.Error("CreateGuild response Disclaimer is empty (REQ-061)") + } +} + +// TestCreateGuildMasonCountPrivate (case b) asserts a Guild creation with +// MasonCountPrivate=true (count not disclosed — MasonCount is 0). +func TestCreateGuildMasonCountPrivate(t *testing.T) { + ctx, _, _, _, k := newSimtestContext(t) + srv := keeper.NewMsgServerImpl(k) + + _, err := srv.CreateGuild(ctx, &types.MsgCreateGuild{ + GuildID: "g-priv", + Name: "Private Count Guild", + FounderReach: "reach:f", + CommonBondHash: []byte{1}, + PublicProfile: types.GuildPublicProfile{ + BondSummary: "private count", + MasonCount: 0, + MasonCountPrivate: true, + }, + Signer: "reach:f", + }) + if err != nil { + t.Fatalf("CreateGuild: %v", err) + } + g, _ := k.GetGuild(ctx, "g-priv") + if !g.PublicProfile.MasonCountPrivate { + t.Error("MasonCountPrivate should be true") + } + if g.PublicProfile.MasonCount != 0 { + t.Errorf("MasonCount = %d, want 0 (not disclosed)", g.PublicProfile.MasonCount) + } +} + +// TestCreateGuildIdempotentReject asserts a second CreateGuild on the same +// guild-id is REJECTED. +func TestCreateGuildIdempotentReject(t *testing.T) { + ctx, _, _, _, k := newSimtestContext(t) + srv := keeper.NewMsgServerImpl(k) + + first := &types.MsgCreateGuild{ + GuildID: "g-dup", Name: "n", FounderReach: "reach:f", + CommonBondHash: []byte{1}, Signer: "reach:f", + } + if _, err := srv.CreateGuild(ctx, first); err != nil { + t.Fatalf("first CreateGuild: %v", err) + } + _, err := srv.CreateGuild(ctx, first) + if err == nil { + t.Error("second CreateGuild on same guild-id should be rejected (idempotent)") + } +} + +// TestCreateGuildValidateBasicReject asserts a CreateGuild with empty +// CommonBondHash is REJECTED at ValidateBasic. +func TestCreateGuildValidateBasicReject(t *testing.T) { + ctx, _, _, _, k := newSimtestContext(t) + srv := keeper.NewMsgServerImpl(k) + + _, err := srv.CreateGuild(ctx, &types.MsgCreateGuild{ + GuildID: "g-bad", Name: "n", FounderReach: "reach:f", + CommonBondHash: nil, Signer: "reach:f", + }) + if err == nil { + t.Error("CreateGuild with empty CommonBondHash should be rejected at ValidateBasic") + } +} + +// --- CreateChapter (REQ-053, REQ-061) ---------------------------------------- + +// TestCreateChapterSuccess (case c) asserts a successful Chapter creation +// with secession terms hash-pinned + good-standing liens +// (SecuredAtFounding=true). +func TestCreateChapterSuccess(t *testing.T) { + ctx, _, _, _, k := newSimtestContext(t) + srv := keeper.NewMsgServerImpl(k) + createParentGuild(t, srv, ctx, "g-parent") + + resp, err := srv.CreateChapter(ctx, &types.MsgCreateChapter{ + GuildID: "g-chapter", + Name: "Chapter", + ParentGuildID: "g-parent", + FounderReach: "reach:founder", + SecessionTerms: types.SecessionTerms{ + CoolingCoverActiveDays: types.CoolingSecessionCoverActiveDays, + CoolingNonCoverDays: types.CoolingSecessionNonCoverDays, + LienAuditRequired: true, + CovenantClearanceRequired: true, + }, + GoodStandingLiens: []types.Lien{ + {Amount: 1000, CreditorReachID: "reach:cred", SecuredAtFounding: true, CoverPoolCovenantRef: "covenant-1"}, + }, + Signer: "reach:founder", + }) + if err != nil { + t.Fatalf("CreateChapter: %v", err) + } + c, ok := k.GetGuild(ctx, "g-chapter") + if !ok { + t.Fatal("Chapter not persisted") + } + if !c.IsChapter { + t.Error("IsChapter should be true for a Chapter") + } + if c.ParentGuildID != "g-parent" { + t.Errorf("ParentGuildID = %q, want g-parent", c.ParentGuildID) + } + // SecessionTermsHash is pinned (non-empty). + if len(c.SecessionTermsHash) == 0 { + t.Error("SecessionTermsHash should be pinned (non-empty)") + } + // The pinned hash matches HashSecessionTerms. + expected := types.HashSecessionTerms(types.SecessionTerms{ + CoolingCoverActiveDays: types.CoolingSecessionCoverActiveDays, + CoolingNonCoverDays: types.CoolingSecessionNonCoverDays, + LienAuditRequired: true, + CovenantClearanceRequired: true, + }) + if string(c.SecessionTermsHash) != string(expected) { + t.Errorf("SecessionTermsHash mismatch: got %x, want %x", c.SecessionTermsHash, expected) + } + // Good-standing liens recorded with SecuredAtFounding=true. + if len(c.GoodStandingLiens) != 1 || !c.GoodStandingLiens[0].SecuredAtFounding { + t.Errorf("GoodStandingLiens = %v", c.GoodStandingLiens) + } + if c.GoodStandingLiens[0].CoverPoolCovenantRef != "covenant-1" { + t.Errorf("CoverPoolCovenantRef = %q", c.GoodStandingLiens[0].CoverPoolCovenantRef) + } + // Chapter inherits Parent's Common Bond hash + Public Profile. + parent, _ := k.GetGuild(ctx, "g-parent") + if string(c.CommonBondHash) != string(parent.CommonBondHash) { + t.Errorf("Chapter CommonBondHash = %x, want parent's %x", c.CommonBondHash, parent.CommonBondHash) + } + if !hasEvent(ctx, "guild.chapter_created") { + t.Error("guild.chapter_created event not emitted") + } + // (g) Disclaimer surfaced. + if resp.Disclaimer == "" { + t.Error("CreateChapter response Disclaimer is empty (REQ-061)") + } +} + +// TestCreateChapterTightenCoolingAllowed (case d) asserts a Chapter MAY +// tighten the cooling (longer than the protocol minimum is allowed). +func TestCreateChapterTightenCoolingAllowed(t *testing.T) { + ctx, _, _, _, k := newSimtestContext(t) + srv := keeper.NewMsgServerImpl(k) + createParentGuild(t, srv, ctx, "g-p-tight") + + _, err := srv.CreateChapter(ctx, &types.MsgCreateChapter{ + GuildID: "g-c-tight", + Name: "Tight Chapter", + ParentGuildID: "g-p-tight", + FounderReach: "reach:f", + SecessionTerms: types.SecessionTerms{ + CoolingCoverActiveDays: types.CoolingSecessionCoverActiveDays + 10, // tighter (longer) + CoolingNonCoverDays: types.CoolingSecessionNonCoverDays + 5, // tighter (longer) + }, + GoodStandingLiens: []types.Lien{ + {Amount: 100, CreditorReachID: "reach:c", SecuredAtFounding: true}, + }, + Signer: "reach:f", + }) + if err != nil { + t.Fatalf("CreateChapter with tighter cooling should succeed: %v", err) + } + if _, ok := k.GetGuild(ctx, "g-c-tight"); !ok { + t.Error("tighter Chapter not persisted") + } +} + +// TestCreateChapterLoosenCoolingRejected (case d) asserts a Chapter MAY NOT +// loosen the cooling (shorter than the protocol minimum is REJECTED at +// ValidateBasic). +func TestCreateChapterLoosenCoolingRejected(t *testing.T) { + ctx, _, _, _, k := newSimtestContext(t) + srv := keeper.NewMsgServerImpl(k) + createParentGuild(t, srv, ctx, "g-p-loose") + + _, err := srv.CreateChapter(ctx, &types.MsgCreateChapter{ + GuildID: "g-c-loose", + Name: "Loose Chapter", + ParentGuildID: "g-p-loose", + FounderReach: "reach:f", + SecessionTerms: types.SecessionTerms{ + CoolingCoverActiveDays: types.CoolingSecessionCoverActiveDays - 1, // looser (shorter) — REJECT + CoolingNonCoverDays: types.CoolingSecessionNonCoverDays, + }, + GoodStandingLiens: []types.Lien{ + {Amount: 100, CreditorReachID: "reach:c", SecuredAtFounding: true}, + }, + Signer: "reach:f", + }) + if err == nil { + t.Fatal("CreateChapter with looser cooling (shorter) should be rejected (Chapter may tighten but not loosen — REQ-053/REQ-064)") + } + if !strings.Contains(err.Error(), "minimum") { + t.Errorf("error = %q, want 'minimum'", err.Error()) + } + // The Chapter was NOT persisted. + if _, ok := k.GetGuild(ctx, "g-c-loose"); ok { + t.Error("loose Chapter should NOT be persisted on reject") + } +} + +// TestCreateChapterNonExistentParent asserts a CreateChapter with a non- +// existent Parent Guild is REJECTED. +func TestCreateChapterNonExistentParent(t *testing.T) { + ctx, _, _, _, k := newSimtestContext(t) + srv := keeper.NewMsgServerImpl(k) + + _, err := srv.CreateChapter(ctx, &types.MsgCreateChapter{ + GuildID: "g-c-noparent", + Name: "n", + ParentGuildID: "no-such-parent", + FounderReach: "reach:f", + SecessionTerms: validTerms(), + GoodStandingLiens: []types.Lien{ + {Amount: 100, CreditorReachID: "reach:c", SecuredAtFounding: true}, + }, + Signer: "reach:f", + }) + if err == nil { + t.Fatal("CreateChapter with non-existent parent should be rejected") + } + if !strings.Contains(err.Error(), "not found") { + t.Errorf("error = %q, want 'not found'", err.Error()) + } +} + +// TestCreateChapterParentIsChapter asserts a CreateChapter whose Parent is +// itself a Chapter is REJECTED (a Chapter cannot have a Chapter parent). +func TestCreateChapterParentIsChapter(t *testing.T) { + ctx, _, _, _, k := newSimtestContext(t) + srv := keeper.NewMsgServerImpl(k) + createParentGuild(t, srv, ctx, "g-real-parent") + // Create a first Chapter. + _, err := srv.CreateChapter(ctx, &types.MsgCreateChapter{ + GuildID: "g-chapter-1", + Name: "Chapter1", + ParentGuildID: "g-real-parent", + FounderReach: "reach:f", + SecessionTerms: validTerms(), + GoodStandingLiens: []types.Lien{ + {Amount: 100, CreditorReachID: "reach:c", SecuredAtFounding: true}, + }, + Signer: "reach:f", + }) + if err != nil { + t.Fatalf("first CreateChapter: %v", err) + } + // Attempt to create a second Chapter under the first Chapter (a Chapter + // parent) — REJECTED. + _, err = srv.CreateChapter(ctx, &types.MsgCreateChapter{ + GuildID: "g-chapter-2", + Name: "Chapter2", + ParentGuildID: "g-chapter-1", + FounderReach: "reach:f", + SecessionTerms: validTerms(), + GoodStandingLiens: []types.Lien{ + {Amount: 100, CreditorReachID: "reach:c", SecuredAtFounding: true}, + }, + Signer: "reach:f", + }) + if err == nil { + t.Fatal("CreateChapter with a Chapter parent should be rejected") + } + if !strings.Contains(err.Error(), "Chapter") { + t.Errorf("error = %q, want 'Chapter'", err.Error()) + } +} + +// TestCreateChapterIdempotentReject asserts a second CreateChapter on the +// same chapter guild-id is REJECTED. +func TestCreateChapterIdempotentReject(t *testing.T) { + ctx, _, _, _, k := newSimtestContext(t) + srv := keeper.NewMsgServerImpl(k) + createParentGuild(t, srv, ctx, "g-p-dup") + + first := &types.MsgCreateChapter{ + GuildID: "g-c-dup", + Name: "n", + ParentGuildID: "g-p-dup", + FounderReach: "reach:f", + SecessionTerms: validTerms(), + GoodStandingLiens: []types.Lien{ + {Amount: 100, CreditorReachID: "reach:c", SecuredAtFounding: true}, + }, + Signer: "reach:f", + } + if _, err := srv.CreateChapter(ctx, first); err != nil { + t.Fatalf("first CreateChapter: %v", err) + } + _, err := srv.CreateChapter(ctx, first) + if err == nil { + t.Error("second CreateChapter on same guild-id should be rejected (idempotent)") + } +} + +// --- OneTapExitStand (REQ-057) ----------------------------------------------- + +// TestOneTapExitStandHouseholdSuccess (case e) asserts a Household one-tap +// exit succeeds (Stand type Household + StashKeeper stub records the call). +func TestOneTapExitStandHouseholdSuccess(t *testing.T) { + ctx, sk, stashK, _, k := newSimtestContext(t) + sk.stands = map[string]string{"stand-hh": "Household"} + srv := keeper.NewMsgServerImpl(k) + + _, err := srv.OneTapExitStand(ctx, &types.MsgOneTapExitStand{ + StandID: "stand-hh", + Signer: "reach:holder", + }) + if err != nil { + t.Fatalf("OneTapExitStand: %v", err) + } + if !hasEvent(ctx, "guild.one_tap_exit") { + t.Error("guild.one_tap_exit event not emitted") + } + // StashKeeper recorded the asset return. + if len(stashK.calls) != 1 { + t.Fatalf("StashKeeper calls = %d, want 1", len(stashK.calls)) + } + if stashK.calls[0].holderReachID != "reach:holder" || stashK.calls[0].standID != "stand-hh" { + t.Errorf("StashKeeper call = %+v", stashK.calls[0]) + } +} + +// TestOneTapExitStandCrewRejected (case e) asserts a Crew Stand one-tap exit +// is REJECTED (one-tap is Household-only). +func TestOneTapExitStandCrewRejected(t *testing.T) { + ctx, sk, _, _, k := newSimtestContext(t) + sk.stands = map[string]string{"stand-crew": "Crew"} + srv := keeper.NewMsgServerImpl(k) + + _, err := srv.OneTapExitStand(ctx, &types.MsgOneTapExitStand{ + StandID: "stand-crew", + Signer: "reach:holder", + }) + if err == nil { + t.Fatal("OneTapExitStand on a Crew Stand should be rejected (one-tap is Household-only — REQ-057)") + } + if !strings.Contains(err.Error(), "Household") { + t.Errorf("error = %q, want 'Household'", err.Error()) + } +} + +// TestOneTapExitStandNonExistent asserts a one-tap exit on a non-existent +// Stand is REJECTED. +func TestOneTapExitStandNonExistent(t *testing.T) { + ctx, sk, _, _, k := newSimtestContext(t) + sk.stands = map[string]string{} + srv := keeper.NewMsgServerImpl(k) + + _, err := srv.OneTapExitStand(ctx, &types.MsgOneTapExitStand{ + StandID: "no-such-stand", + Signer: "reach:holder", + }) + if err == nil { + t.Fatal("OneTapExitStand on non-existent Stand should be rejected") + } + if !strings.Contains(err.Error(), "not found") { + t.Errorf("error = %q, want 'not found'", err.Error()) + } +} + +// TestOneTapExitStandNilStandKeeperReject asserts a nil StandKeeper REJECTS +// the one-tap exit (the type check is load-bearing). +func TestOneTapExitStandNilStandKeeperReject(t *testing.T) { + ctx, _, k := newSimtestContextNilStand(t) + srv := keeper.NewMsgServerImpl(k) + + _, err := srv.OneTapExitStand(ctx, &types.MsgOneTapExitStand{ + StandID: "any-stand", + Signer: "reach:holder", + }) + if err == nil { + t.Fatal("OneTapExitStand with nil StandKeeper should be rejected (type check is load-bearing)") + } + if !strings.Contains(err.Error(), "StandKeeper") { + t.Errorf("error = %q, want 'StandKeeper'", err.Error()) + } +} + +// TestOneTapExitStandNilStashKeeperSkip asserts a nil StashKeeper skips the +// asset return (the dissolution event is still emitted). +func TestOneTapExitStandNilStashKeeperSkip(t *testing.T) { + ctx, sk, _, _, k := newSimtestContext(t) + sk.stands = map[string]string{"stand-hh2": "Household"} + // Wire a nil StashKeeper via the setter (the keeper was constructed with + // a non-nil stub; override to nil for this case). + k.SetStashKeeper(nil) + srv := keeper.NewMsgServerImpl(k) + + _, err := srv.OneTapExitStand(ctx, &types.MsgOneTapExitStand{ + StandID: "stand-hh2", + Signer: "reach:holder", + }) + if err != nil { + t.Fatalf("OneTapExitStand with nil StashKeeper should skip asset return: %v", err) + } + if !hasEvent(ctx, "guild.one_tap_exit") { + t.Error("guild.one_tap_exit event should still be emitted with nil StashKeeper") + } +} + +// TestOneTapExitStandStashErrorReject asserts a StashKeeper error REJECTS +// the one-tap exit (the asset return is load-bearing). +func TestOneTapExitStandStashErrorReject(t *testing.T) { + ctx, sk, stashK, _, k := newSimtestContext(t) + sk.stands = map[string]string{"stand-hh-err": "Household"} + stashK.err = sentinelErr("stash return failed (simtest)") + srv := keeper.NewMsgServerImpl(k) + + _, err := srv.OneTapExitStand(ctx, &types.MsgOneTapExitStand{ + StandID: "stand-hh-err", + Signer: "reach:holder", + }) + if err == nil { + t.Fatal("OneTapExitStand with StashKeeper error should be rejected") + } + if !strings.Contains(err.Error(), "return assets") { + t.Errorf("error = %q, want 'return assets'", err.Error()) + } +} + +// --- DelegateConfederationVoice (REQ-058) ------------------------------------ + +// TestDelegateConfederationVoiceSuccess (case f) asserts a Confederation +// Voice delegation succeeds (one-per-Stand). +func TestDelegateConfederationVoiceSuccess(t *testing.T) { + ctx, sk, _, _, k := newSimtestContext(t) + sk.stands = map[string]string{"conf-1": "Confederation"} + srv := keeper.NewMsgServerImpl(k) + + _, err := srv.DelegateConfederationVoice(ctx, &types.MsgDelegateConfederationVoice{ + ConfederationStandID: "conf-1", + MemberStandID: "mem-1", + DelegateReachID: "reach:delegate", + Signer: "reach:s", + }) + if err != nil { + t.Fatalf("DelegateConfederationVoice: %v", err) + } + v, ok := k.GetDelegation(ctx, "conf-1", "mem-1") + if !ok { + t.Fatal("delegation not persisted") + } + if v.DelegateReachID != "reach:delegate" { + t.Errorf("DelegateReachID = %q, want reach:delegate", v.DelegateReachID) + } + if !hasEvent(ctx, "guild.confederation_voice_delegated") { + t.Error("guild.confederation_voice_delegated event not emitted") + } +} + +// TestDelegateConfederationVoiceDuplicateRejected (case f) asserts a +// duplicate delegation from the same MemberStandID is REJECTED (one-Stand- +// one-Vote). +func TestDelegateConfederationVoiceDuplicateRejected(t *testing.T) { + ctx, sk, _, _, k := newSimtestContext(t) + sk.stands = map[string]string{"conf-dup": "Confederation"} + srv := keeper.NewMsgServerImpl(k) + + first := &types.MsgDelegateConfederationVoice{ + ConfederationStandID: "conf-dup", + MemberStandID: "mem-dup", + DelegateReachID: "reach:d1", + Signer: "reach:s", + } + if _, err := srv.DelegateConfederationVoice(ctx, first); err != nil { + t.Fatalf("first delegation: %v", err) + } + // A second delegation from the same MemberStandID (even to a different + // delegate) is REJECTED. + _, err := srv.DelegateConfederationVoice(ctx, &types.MsgDelegateConfederationVoice{ + ConfederationStandID: "conf-dup", + MemberStandID: "mem-dup", + DelegateReachID: "reach:d2", + Signer: "reach:s", + }) + if err == nil { + t.Fatal("duplicate delegation from the same MemberStandID should be rejected (one-Stand-one-Vote — REQ-058)") + } + if !strings.Contains(err.Error(), "duplicate") { + t.Errorf("error = %q, want 'duplicate'", err.Error()) + } +} + +// TestDelegateConfederationVoiceNonConfederationRejected asserts a +// delegation where the named Confederation Stand is NOT a Confederation type +// is REJECTED. +func TestDelegateConfederationVoiceNonConfederationRejected(t *testing.T) { + ctx, sk, _, _, k := newSimtestContext(t) + sk.stands = map[string]string{"not-conf": "Crew"} + srv := keeper.NewMsgServerImpl(k) + + _, err := srv.DelegateConfederationVoice(ctx, &types.MsgDelegateConfederationVoice{ + ConfederationStandID: "not-conf", + MemberStandID: "mem-1", + DelegateReachID: "reach:d", + Signer: "reach:s", + }) + if err == nil { + t.Fatal("DelegateConfederationVoice on a non-Confederation Stand should be rejected") + } + if !strings.Contains(err.Error(), "Confederation") { + t.Errorf("error = %q, want 'Confederation'", err.Error()) + } +} + +// TestDelegateConfederationVoiceNonExistent asserts a delegation on a non- +// existent Stand is REJECTED. +func TestDelegateConfederationVoiceNonExistent(t *testing.T) { + ctx, sk, _, _, k := newSimtestContext(t) + sk.stands = map[string]string{} + srv := keeper.NewMsgServerImpl(k) + + _, err := srv.DelegateConfederationVoice(ctx, &types.MsgDelegateConfederationVoice{ + ConfederationStandID: "no-such-conf", + MemberStandID: "mem-1", + DelegateReachID: "reach:d", + Signer: "reach:s", + }) + if err == nil { + t.Fatal("DelegateConfederationVoice on non-existent Stand should be rejected") + } + if !strings.Contains(err.Error(), "not found") { + t.Errorf("error = %q, want 'not found'", err.Error()) + } +} + +// TestDelegateConfederationVoiceNilStandKeeperReject asserts a nil +// StandKeeper REJECTS the delegation (the type check is load-bearing). +func TestDelegateConfederationVoiceNilStandKeeperReject(t *testing.T) { + ctx, _, k := newSimtestContextNilStand(t) + srv := keeper.NewMsgServerImpl(k) + + _, err := srv.DelegateConfederationVoice(ctx, &types.MsgDelegateConfederationVoice{ + ConfederationStandID: "any", + MemberStandID: "mem", + DelegateReachID: "reach:d", + Signer: "reach:s", + }) + if err == nil { + t.Fatal("DelegateConfederationVoice with nil StandKeeper should be rejected (type check is load-bearing)") + } + if !strings.Contains(err.Error(), "StandKeeper") { + t.Errorf("error = %q, want 'StandKeeper'", err.Error()) + } +} + +// --- AddLien (REQ-053) ------------------------------------------------------- + +// TestAddLienPostFoundingSuccess (case h) asserts a post-founding lien with +// SecuredAtFounding=false succeeds. +func TestAddLienPostFoundingSuccess(t *testing.T) { + ctx, _, _, _, k := newSimtestContext(t) + srv := keeper.NewMsgServerImpl(k) + createParentGuild(t, srv, ctx, "g-lien") + + _, err := srv.AddLien(ctx, &types.MsgAddLien{ + GuildID: "g-lien", + Lien: types.Lien{ + Amount: 500, + CreditorReachID: "reach:cred", + SecuredAtFounding: false, + CoverPoolCovenantRef: "covenant-2", + }, + Signer: "reach:s", + }) + if err != nil { + t.Fatalf("AddLien: %v", err) + } + // The lien is persisted at idx 0. + l, ok := k.GetLien(ctx, "g-lien", 0) + if !ok { + t.Fatal("lien not persisted") + } + if l.Amount != 500 || l.SecuredAtFounding { + t.Errorf("lien = %+v", l) + } + if !hasEvent(ctx, "guild.lien_added") { + t.Error("guild.lien_added event not emitted") + } + if got := k.AllLiens(ctx, "g-lien"); len(got) != 1 { + t.Errorf("AllLiens = %d, want 1", len(got)) + } +} + +// TestAddLienSecuredAtFoundingRejected (case h) asserts a post-founding lien +// with SecuredAtFounding=true is REJECTED (founding is a one-time event — +// REQ-053/REQ-081). +func TestAddLienSecuredAtFoundingRejected(t *testing.T) { + ctx, _, _, _, k := newSimtestContext(t) + srv := keeper.NewMsgServerImpl(k) + createParentGuild(t, srv, ctx, "g-lien-reject") + + _, err := srv.AddLien(ctx, &types.MsgAddLien{ + GuildID: "g-lien-reject", + Lien: types.Lien{ + Amount: 500, + CreditorReachID: "reach:cred", + SecuredAtFounding: true, // REJECTED — founding is one-time + }, + Signer: "reach:s", + }) + if err == nil { + t.Fatal("AddLien with SecuredAtFounding=true post-founding should be rejected (founding is one-time — REQ-053/REQ-081)") + } + if !strings.Contains(err.Error(), "SecuredAtFounding") { + t.Errorf("error = %q, want 'SecuredAtFounding'", err.Error()) + } + // The lien was NOT persisted. + if got := k.AllLiens(ctx, "g-lien-reject"); len(got) != 0 { + t.Errorf("AllLiens = %d, want 0 (rejected lien not persisted)", len(got)) + } +} + +// TestAddLienNonExistentGuild asserts an AddLien on a non-existent Guild is +// REJECTED. +func TestAddLienNonExistentGuild(t *testing.T) { + ctx, _, _, _, k := newSimtestContext(t) + srv := keeper.NewMsgServerImpl(k) + + _, err := srv.AddLien(ctx, &types.MsgAddLien{ + GuildID: "no-such-guild", + Lien: types.Lien{ + Amount: 100, CreditorReachID: "reach:c", SecuredAtFounding: false, + }, + Signer: "reach:s", + }) + if err == nil { + t.Fatal("AddLien on non-existent Guild should be rejected") + } + if !strings.Contains(err.Error(), "not found") { + t.Errorf("error = %q, want 'not found'", err.Error()) + } +} + +// --- unwrapCtx panic -------------------------------------------------------- + +// TestUnwrapCtxPanic asserts unwrapCtx panics on a non-sdk.Context value. +func TestUnwrapCtxPanic(t *testing.T) { + defer func() { + if r := recover(); r == nil { + t.Error("unwrapCtx on non-sdk.Context should panic") + } + }() + _, _ = keeper.NewMsgServerImpl(keeper.Keeper{}).AddLien("not-a-ctx", + &types.MsgAddLien{GuildID: "g", Lien: types.Lien{Amount: 1, CreditorReachID: "c"}, Signer: "s"}) +} + +// --- Keeper accessors (coverage) -------------------------------------------- + +// TestKeeperAccessors exercises the exported Keeper accessors that the +// simtest above does not directly hit (AllGuilds, GetLien on empty, +// AllDelegations, the marshal-error paths, the setters) to push coverage +// >=80%. +func TestKeeperAccessors(t *testing.T) { + ctx, sk, _, storeKey, k := newSimtestContext(t) + _ = sk + + // Empty-store accessors return empty (not nil) slices. + if got := k.AllGuilds(ctx); len(got) != 0 { + t.Errorf("AllGuilds empty = %d, want 0", len(got)) + } + if got := k.AllLiens(ctx, "nobody"); len(got) != 0 { + t.Errorf("AllLiens empty = %d, want 0", len(got)) + } + if got := k.AllDelegations(ctx, "nobody"); len(got) != 0 { + t.Errorf("AllDelegations empty = %d, want 0", len(got)) + } + if _, ok := k.GetLien(ctx, "nobody", 0); ok { + t.Error("GetLien on empty store should return false") + } + if _, ok := k.GetDelegation(ctx, "nobody", "nobody"); ok { + t.Error("GetDelegation on empty store should return false") + } + + // Populate + read back. + k.SetGuild(ctx, types.Guild{GuildID: "g-a", Name: "n", FounderReach: "reach:f"}) + if g, ok := k.GetGuild(ctx, "g-a"); !ok || g.Name != "n" { + t.Errorf("GetGuild = %+v ok=%v", g, ok) + } + if got := k.AllGuilds(ctx); len(got) != 1 { + t.Errorf("AllGuilds = %d, want 1", len(got)) + } + + k.SetLien(ctx, "g-a", 0, types.Lien{Amount: 1, CreditorReachID: "reach:c"}) + if l, ok := k.GetLien(ctx, "g-a", 0); !ok || l.Amount != 1 { + t.Errorf("GetLien = %+v ok=%v", l, ok) + } + if got := k.AllLiens(ctx, "g-a"); len(got) != 1 { + t.Errorf("AllLiens = %d, want 1", len(got)) + } + if idx := k.NextLienIdx(ctx, "g-a"); idx != 1 { + t.Errorf("NextLienIdx = %d, want 1", idx) + } + + k.SetDelegation(ctx, types.ConfederationVoice{ + ConfederationStandID: "conf-a", MemberStandID: "mem-a", + DelegateReachID: "reach:d", DelegatedAt: 1, + }) + if v, ok := k.GetDelegation(ctx, "conf-a", "mem-a"); !ok || v.DelegateReachID != "reach:d" { + t.Errorf("GetDelegation = %+v ok=%v", v, ok) + } + if got := k.AllDelegations(ctx, "conf-a"); len(got) != 1 { + t.Errorf("AllDelegations = %d, want 1", len(got)) + } + + // Marshal-error paths (corrupt bytes in store). + store := ctx.KVStore(storeKey) + store.Set([]byte("guild/corrupt"), []byte("not-json")) + if _, ok := k.GetGuild(ctx, "corrupt"); ok { + t.Error("GetGuild on corrupt bytes should return false") + } + store.Set([]byte("lien/corrupt/0"), []byte("not-json")) + if _, ok := k.GetLien(ctx, "corrupt", 0); ok { + t.Error("GetLien on corrupt bytes should return false") + } + store.Set([]byte("delegation/corrupt/m"), []byte("not-json")) + if _, ok := k.GetDelegation(ctx, "corrupt", "m"); ok { + t.Error("GetDelegation on corrupt bytes should return false") + } + + // Post-construction setters (coverage). + k.SetStandKeeper(&stubStandKeeper{stands: map[string]string{"s": "Household"}}) + k.SetStashKeeper(&stubStashKeeper{}) + k.SetParams(types.DefaultParams()) + if k.Params().DefaultCoolingCoverActiveDays != types.CoolingSecessionCoverActiveDays { + t.Errorf("Params DefaultCoolingCoverActiveDays = %d", k.Params().DefaultCoolingCoverActiveDays) + } +} + +// --- sentinel error helper --------------------------------------------------- + +type sentinelErr string + +func (e sentinelErr) Error() string { return string(e) } diff --git a/x/guild/module.go b/x/guild/module.go new file mode 100644 index 0000000..28b6821 --- /dev/null +++ b/x/guild/module.go @@ -0,0 +1,89 @@ +package guild + +// module.go holds the guild module's AppModule + RegisterServices (P3, +// REQ-051, REQ-053, REQ-057, REQ-058). +// +// The AppModule wraps the guild 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 StandKeeper + StashKeeper expected-keeper shims are injected at +// construction (StandKeeper nil-able — the OneTapExitStand + +// DelegateConfederationVoice handlers REJECT on a nil StandKeeper; the type +// check is load-bearing. StashKeeper nil-able — a nil StashKeeper skips the +// asset return on one-tap exit; the dissolution event is still emitted). + +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/guild/keeper" + "github.com/oy/openyield/x/guild/types" +) + +// ConsensusVersion is the guild module's consensus version (AppModule). +const ConsensusVersion = 1 + +// AppModule is the guild application module (simtest-grade — D-054). +type AppModule struct { + keeper keeper.Keeper +} + +// NewAppModule constructs a new guild AppModule. The StandKeeper + StashKeeper +// expected-keeper shims are injected (StandKeeper nil-able — the +// OneTapExitStand + DelegateConfederationVoice handlers REJECT on a nil +// StandKeeper; StashKeeper nil-able — a nil StashKeeper skips the asset +// return on one-tap exit). +func NewAppModule(cdc codec.Codec, storeKey storetypes.StoreKey, sk types.StandKeeper, stashK types.StashKeeper) AppModule { + k := keeper.NewKeeper(cdc, storeKey, sk, stashK) + return AppModule{keeper: k} +} + +// RegisterServices registers the guild 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 guild 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 guild module (simtest- +// grade no-op — the runtime stores are created at handler time; genesis +// init of runtime-promoted stores is deferred to the live chain v0.6+). +func (am AppModule) InitGenesis(ctx sdk.Context, cdc codec.JSONCodec, data json.RawMessage) { + var gs types.GenesisState + cdc.MustUnmarshalJSON(data, &gs) + for _, g := range gs.Guilds { + am.keeper.SetGuild(ctx, g) + } + for _, c := range gs.Chapters { + am.keeper.SetGuild(ctx, c) + } +} + +// ExportGenesis returns the exported genesis state as raw bytes (simtest- +// grade: returns an empty genesis; live chain export deferred to v0.6+). +func (am AppModule) ExportGenesis(ctx sdk.Context, cdc codec.JSONCodec) json.RawMessage { + gs := types.DefaultGenesisState() + 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/guild/module_test.go b/x/guild/module_test.go new file mode 100644 index 0000000..0e2820f --- /dev/null +++ b/x/guild/module_test.go @@ -0,0 +1,104 @@ +package guild_test + +// module_test.go exercises the x/guild AppModule (D-054 simtest-grade). +// The AppModule wraps the Keeper + exposes the MsgServer; this test +// constructs an AppModule with nil shims + asserts Name, ConsensusVersion, +// MsgServer, InitGenesis, ExportGenesis. Coverage target: the module.go +// surface. + +import ( + "encoding/json" + "testing" + + "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/guild" + "github.com/oy/openyield/x/guild/types" +) + +func newModuleTestContext(t *testing.T) (sdk.Context, guild.AppModule, codec.Codec) { + t.Helper() + db := dbm.NewMemDB() + cdc := newModuleTestCodec() + 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{}, false, log.NewNopLogger()) + am := guild.NewAppModule(cdc, storeKey, nil, nil) + return ctx, am, cdc +} + +func newModuleTestCodec() codec.Codec { + registry := codectypes.NewInterfaceRegistry() + return codec.NewProtoCodec(registry) +} + +// TestAppModuleName asserts the module name. +func TestAppModuleName(t *testing.T) { + _, am, _ := newModuleTestContext(t) + if am.Name() != types.ModuleName { + t.Errorf("Name = %q, want %q", am.Name(), types.ModuleName) + } +} + +// TestAppModuleConsensusVersion asserts ConsensusVersion == 1. +func TestAppModuleConsensusVersion(t *testing.T) { + _, am, _ := newModuleTestContext(t) + if am.ConsensusVersion() != guild.ConsensusVersion { + t.Errorf("ConsensusVersion = %d, want %d", am.ConsensusVersion(), guild.ConsensusVersion) + } + if guild.ConsensusVersion != 1 { + t.Errorf("ConsensusVersion const = %d, want 1", guild.ConsensusVersion) + } +} + +// TestAppModuleMsgServer asserts MsgServer returns a non-nil MsgServer. +func TestAppModuleMsgServer(t *testing.T) { + _, am, _ := newModuleTestContext(t) + srv := am.MsgServer() + if srv == nil { + t.Fatal("MsgServer() returned nil") + } +} + +// TestAppModuleInitExportGenesis asserts InitGenesis + ExportGenesis round- +// trip an empty genesis. +func TestAppModuleInitExportGenesis(t *testing.T) { + ctx, am, cdc := newModuleTestContext(t) + + empty := types.DefaultGenesisState() + data := cdc.MustMarshalJSON(empty) + am.InitGenesis(ctx, cdc, data) + + exported := am.ExportGenesis(ctx, cdc) + if len(exported) == 0 { + t.Fatal("ExportGenesis returned empty bytes") + } + var gs types.GenesisState + if err := json.Unmarshal(exported, &gs); err != nil { + t.Fatalf("ExportGenesis bytes not valid JSON: %v", err) + } +} + +// TestAppModuleRegisterServicesNoPanic asserts RegisterServices does not +// panic with a nil configurator (simtest-grade — the method is a no-op stub +// for the hand-rolled MsgServer wiring). +func TestAppModuleRegisterServicesNoPanic(t *testing.T) { + _, am, _ := newModuleTestContext(t) + defer func() { + if r := recover(); r != nil { + t.Errorf("RegisterServices panicked: %v", r) + } + }() + am.RegisterServices(nil) +} diff --git a/x/guild/types/expected_keepers.go b/x/guild/types/expected_keepers.go new file mode 100644 index 0000000..a822525 --- /dev/null +++ b/x/guild/types/expected_keepers.go @@ -0,0 +1,75 @@ +package types + +// expected_keepers.go holds the Go INTERFACES for the cross-module keepers +// x/guild depends on (G-003 firewall — ibc-go expected-keepers convention). +// +// The guild runtime (REQ-051, REQ-053, REQ-057, REQ-058) depends on TWO +// cross-module keepers: +// +// 1. x/stand (StandKeeper) — the OneTapExitStand handler asserts the named +// Stand is a Household (REQ-057) before dissolving it; the +// DelegateConfederationVoice handler asserts the named Stand is a +// Confederation (REQ-058) before recording the delegation. The handler +// queries GetStand for the Stand type (an opaque string — "Household" or +// "Confederation") and compares. This is the v0.7 P3 household-edge: the +// Guild module references a Stand by ID-string (G-003 — no struct import +// of x/stand/types). +// +// 2. x/stash (StashKeeper) — the OneTapExitStand handler returns the +// dissolved Household Stand's assets to the Holder's Stash (REQ-057). +// The handler calls ReturnAssetsToHolder; the simtest stub records the +// call for assertion (no actual asset transfer in simtest). +// +// Both dependencies are expressed as INTERFACES defined HERE (in +// x/guild/types), NOT as struct imports of any x//types. The +// concrete keepers (or simtest stubs) satisfy these interfaces structurally +// (the P3 simtest wires stubs per G-003 test exemption); the handler depends +// on the interface, preserving G-003's intent (no cross-module struct +// coupling, no import cycles). +// +// Lexicon note (REQ-012): "Guild", "Chapter", "Stand", "Household", +// "Confederation", "Stash", "Holder", "Reach", "Voice" are all lexicon-clean. +// The project-wide 10 banned terms NEVER appear (enforced by lexicon_meta + +// the per-package lexicon assertion in types_test.go). + +// StandKeeper is the expected-keeper interface for x/stand (G-003). The +// OneTapExitStand handler calls GetStand to assert the Stand type is +// "Household" (REQ-057 — one-tap exit is Household-only). The +// DelegateConfederationVoice handler calls GetStand to assert the Stand type +// is "Confederation" (REQ-058). The standType string is the opaque Stand +// type name (cross-doc to x/stand.StandType — "Household", "Confederation", +// etc.); the handler compares the string. +// +// No struct import of x/stand/types — the interface is the by-ID-string +// boundary (G-003). The standID is an opaque string. A nil StandKeeper +// REJECTS the OneTapExitStand + DelegateConfederationVoice handlers (the +// type check is load-bearing — a nil shim is a wiring error, NOT a simtest +// skip path; the household/confederation type check cannot be skipped). +type StandKeeper interface { + // GetStand returns the Stand type string + exists flag for the named + // Stand (by-ID-string). The OneTapExitStand handler compares the + // returned type against "Household"; the + // DelegateConfederationVoice handler compares against "Confederation". + // A non-existent Stand returns ("", false) — the handler REJECTS. + GetStand(standID string) (standType string, exists bool) +} + +// StashKeeper is the expected-keeper interface for x/stash (G-003). The +// OneTapExitStand handler calls ReturnAssetsToHolder to return the dissolved +// Household Stand's assets to the Holder's Stash (REQ-057). The simtest stub +// records the call for assertion (no actual asset transfer in simtest — the +// simtest documents the wiring contract). +// +// No struct import of x/stash/types — the interface is the by-ID-string +// boundary (G-003). The holderReachID + standID are opaque strings. A nil +// StashKeeper skips the asset return (simtest wiring — the handler still +// emits the dissolution event; the asset return is a side-effect the simtest +// stub records). +type StashKeeper interface { + // ReturnAssetsToHolder returns the named Stand's assets to the named + // Holder's Stash. The OneTapExitStand handler calls this on a Household + // dissolution (REQ-057). A non-nil error REJECTS the dissolution (the + // asset return is load-bearing — a failed return leaves the Stand + // intact). + ReturnAssetsToHolder(holderReachID string, standID string) error +} diff --git a/x/guild/types/msg_guild.go b/x/guild/types/msg_guild.go new file mode 100644 index 0000000..3832003 --- /dev/null +++ b/x/guild/types/msg_guild.go @@ -0,0 +1,421 @@ +package types + +// msg_guild.go holds the x/guild Msg* types implementing sdk.Msg (REQ-051, +// REQ-053, REQ-057, REQ-058). G-006 controlled exception: types/ gains the +// cosmos-sdk import for sdk.Msg (mirrors x/cover/types/msg_cover.go — D-055; +// the invariant/lexicon tests in *_test.go stay stdlib-only per G-024, +// isolated from this msg_*.go file). +// +// The five P3 Guild Msg types drive the Guild Charter + Chapter Federation + +// Household + Confederation runtime: +// - MsgCreateGuild: create a Guild with a Common Bond hash + Public Profile +// (REQ-051). The handler persists the Guild + surfaces a jurisdictional +// disclaimer (REQ-061). +// - MsgCreateChapter: create a Chapter under a Parent Guild (REQ-053). The +// handler pins the SecessionTerms hash + records the Good-Standing Liens +// (SecuredAtFounding=true) + rejects cooling below the protocol minimum +// + surfaces a jurisdictional disclaimer (REQ-061). +// - MsgOneTapExitStand: one-tap exit a Household Stand (REQ-057). The +// handler asserts the Stand type is Household via the StandKeeper shim + +// dissolves the Stand + returns assets to the Holder's Stash. +// - MsgDelegateConfederationVoice: delegate a member Stand's Voice in a +// Confederation (REQ-058). The handler asserts the Stand type is +// Confederation via the StandKeeper shim + records the delegation (one +// delegation per member Stand — duplicate REJECTED). +// - MsgAddLien: add a Good-Standing Lien to a Guild (REQ-053). The handler +// rejects any new SecuredAtFounding=true lien (founding is a one-time +// event — REQ-053/REQ-081). +// +// All cross-module refs are by-ID-string (G-003): founder-reach refs an +// x/identity Reach; stand-id refs an x/stand Stand; parent-guild-id refs a +// Guild; cover-pool-covenant-ref refs a Cover Pool covenant. No struct +// imports of x/stand/types or x/stash/types (the shims are interfaces +// defined in expected_keepers.go — G-003 preserved). +// +// Lexicon note (REQ-012): the message names + field names use the safe Guild +// vocabulary EXCLUSIVELY. "Guild", "Chapter", "Parent Guild", "Common Bond", +// "Public Profile", "Good-Standing Lien", "Secession Terms", "Household", +// "Confederation", "Hand-Pass" are the clean names; the project-wide 10 +// banned terms NEVER appear (enforced by lexicon_meta + the per-package +// lexicon assertion in types_test.go). + +import ( + "fmt" + + sdk "github.com/cosmos/cosmos-sdk/types" +) + +// --- MsgCreateGuild ----------------------------------------------------------- + +// MsgCreateGuild creates a Guild with a Common Bond hash + Public Profile +// (REQ-051). The handler persists the Guild + surfaces a jurisdictional +// disclaimer (REQ-061 — the Disclaimer string is in the response). +// +// ValidateBasic is stateless: non-empty fields + non-empty CommonBondHash. +type MsgCreateGuild struct { + GuildID string `json:"guild_id" yaml:"guild_id"` + Name string `json:"name" yaml:"name"` + FounderReach string `json:"founder_reach" yaml:"founder_reach"` + StandAffiliationID string `json:"stand_affiliation_id,omitempty" yaml:"stand_affiliation_id,omitempty"` + CommonBondHash []byte `json:"common_bond_hash" yaml:"common_bond_hash"` + PublicProfile GuildPublicProfile `json:"public_profile" yaml:"public_profile"` + Signer string `json:"signer" yaml:"signer"` +} + +// Reset implements proto.Message. +func (m *MsgCreateGuild) Reset() { *m = MsgCreateGuild{} } + +// String implements proto.Message. +func (m *MsgCreateGuild) String() string { + return fmt.Sprintf("MsgCreateGuild{GuildID:%s Name:%s FounderReach:%s StandAffiliationID:%s Signer:%s}", + m.GuildID, m.Name, m.FounderReach, m.StandAffiliationID, m.Signer) +} + +// ProtoMessage implements proto.Message. +func (*MsgCreateGuild) ProtoMessage() {} + +// ValidateBasic is the stateless validation: non-empty guild-id, name, +// founder-reach, signer, non-empty CommonBondHash. +func (m *MsgCreateGuild) ValidateBasic() error { + if m.GuildID == "" { + return fmt.Errorf("guild: empty guild-id") + } + if m.Name == "" { + return fmt.Errorf("guild: empty name") + } + if m.FounderReach == "" { + return fmt.Errorf("guild: empty founder-reach") + } + if m.Signer == "" { + return fmt.Errorf("guild: empty signer") + } + if len(m.CommonBondHash) == 0 { + return fmt.Errorf("guild: empty common-bond-hash (REQ-051 — hash-pinned at creation)") + } + return nil +} + +// GetSigners returns the signer's reach-id as sdk.AccAddress bytes. +func (m *MsgCreateGuild) GetSigners() []sdk.AccAddress { + return []sdk.AccAddress{[]byte(m.Signer)} +} + +// --- MsgCreateChapter --------------------------------------------------------- + +// MsgCreateChapter creates a Chapter under a Parent Guild (REQ-053). The +// handler pins the SecessionTerms hash (HashSecessionTerms) + records the +// Good-Standing Liens (SecuredAtFounding=true) + rejects cooling below the +// protocol minimum (CoolingSecessionCoverActiveDays / NonCoverDays) + +// surfaces a jurisdictional disclaimer (REQ-061). +// +// ValidateBasic is stateless: non-empty fields, non-empty ParentGuildID, +// SecessionTerms valid (non-zero + protocol-minimum-bounded via +// SecessionTerms.Validate), each GoodStandingLien has SecuredAtFounding=true +// + non-empty CreditorReachID + Amount > 0. +type MsgCreateChapter struct { + GuildID string `json:"guild_id" yaml:"guild_id"` + Name string `json:"name" yaml:"name"` + ParentGuildID string `json:"parent_guild_id" yaml:"parent_guild_id"` + FounderReach string `json:"founder_reach" yaml:"founder_reach"` + SecessionTerms SecessionTerms `json:"secession_terms" yaml:"secession_terms"` + GoodStandingLiens []Lien `json:"good_standing_liens" yaml:"good_standing_liens"` + Signer string `json:"signer" yaml:"signer"` +} + +// Reset implements proto.Message. +func (m *MsgCreateChapter) Reset() { *m = MsgCreateChapter{} } + +// String implements proto.Message. +func (m *MsgCreateChapter) String() string { + return fmt.Sprintf("MsgCreateChapter{GuildID:%s Name:%s ParentGuildID:%s FounderReach:%s SecessionTerms:%+v Liens:%d Signer:%s}", + m.GuildID, m.Name, m.ParentGuildID, m.FounderReach, m.SecessionTerms, len(m.GoodStandingLiens), m.Signer) +} + +// ProtoMessage implements proto.Message. +func (*MsgCreateChapter) ProtoMessage() {} + +// ValidateBasic is the stateless validation: non-empty fields, non-empty +// ParentGuildID, SecessionTerms valid, each GoodStandingLien is +// SecuredAtFounding=true with non-empty CreditorReachID + Amount > 0 +// (founding-locked liens are recorded ONCE at founding — REQ-053). +func (m *MsgCreateChapter) ValidateBasic() error { + if m.GuildID == "" { + return fmt.Errorf("guild: empty chapter guild-id") + } + if m.Name == "" { + return fmt.Errorf("guild: empty chapter name") + } + if m.ParentGuildID == "" { + return fmt.Errorf("guild: empty parent-guild-id (REQ-053 — Chapter requires a Parent)") + } + if m.ParentGuildID == m.GuildID { + return fmt.Errorf("guild: Chapter %q cannot be its own parent", m.GuildID) + } + if m.FounderReach == "" { + return fmt.Errorf("guild: empty founder-reach") + } + if m.Signer == "" { + return fmt.Errorf("guild: empty signer") + } + if err := m.SecessionTerms.Validate(); err != nil { + return fmt.Errorf("guild: secession terms: %w", err) + } + for i, l := range m.GoodStandingLiens { + if !l.SecuredAtFounding { + return fmt.Errorf("guild: GoodStandingLien[%d] has SecuredAtFounding=false (founding liens must be secured at founding — REQ-053)", i) + } + if l.CreditorReachID == "" { + return fmt.Errorf("guild: GoodStandingLien[%d] has empty CreditorReachID", i) + } + if l.Amount <= 0 { + return fmt.Errorf("guild: GoodStandingLien[%d] Amount %d <= 0", i, l.Amount) + } + } + return nil +} + +// GetSigners returns the signer's reach-id as sdk.AccAddress bytes. +func (m *MsgCreateChapter) GetSigners() []sdk.AccAddress { + return []sdk.AccAddress{[]byte(m.Signer)} +} + +// --- MsgOneTapExitStand ------------------------------------------------------- + +// MsgOneTapExitStand one-tap exits a Household Stand (REQ-057). The handler +// asserts the Stand type is Household via the StandKeeper shim + dissolves +// the Stand + returns assets to the Holder's Stash via the StashKeeper shim. +// One-tap exit is the Household dispute path (no Council vote required — +// Household skips the formal-Council requirement). +// +// ValidateBasic is stateless: non-empty stand-id + signer. +type MsgOneTapExitStand struct { + StandID string `json:"stand_id" yaml:"stand_id"` + Signer string `json:"signer" yaml:"signer"` +} + +// Reset implements proto.Message. +func (m *MsgOneTapExitStand) Reset() { *m = MsgOneTapExitStand{} } + +// String implements proto.Message. +func (m *MsgOneTapExitStand) String() string { + return fmt.Sprintf("MsgOneTapExitStand{StandID:%s Signer:%s}", m.StandID, m.Signer) +} + +// ProtoMessage implements proto.Message. +func (*MsgOneTapExitStand) ProtoMessage() {} + +// ValidateBasic is the stateless validation: non-empty stand-id + signer. +func (m *MsgOneTapExitStand) ValidateBasic() error { + if m.StandID == "" { + return fmt.Errorf("guild: empty stand-id") + } + if m.Signer == "" { + return fmt.Errorf("guild: empty signer") + } + return nil +} + +// GetSigners returns the signer's reach-id as sdk.AccAddress bytes. +func (m *MsgOneTapExitStand) GetSigners() []sdk.AccAddress { + return []sdk.AccAddress{[]byte(m.Signer)} +} + +// --- MsgDelegateConfederationVoice -------------------------------------------- + +// MsgDelegateConfederationVoice delegates a member Stand's Voice in a +// Confederation (REQ-058). The handler asserts the ConfederationStandID +// references a Confederation Stand via the StandKeeper shim + records the +// delegation (one delegation per member Stand — a duplicate delegation from +// the same MemberStandID is REJECTED). One-Stand-one-Vote: each member Stand +// gets exactly 1 Voice in the Confederation's aggregate, regardless of size. +// +// ValidateBasic is stateless: non-empty fields. +type MsgDelegateConfederationVoice struct { + ConfederationStandID string `json:"confederation_stand_id" yaml:"confederation_stand_id"` + MemberStandID string `json:"member_stand_id" yaml:"member_stand_id"` + DelegateReachID string `json:"delegate_reach_id" yaml:"delegate_reach_id"` + Signer string `json:"signer" yaml:"signer"` +} + +// Reset implements proto.Message. +func (m *MsgDelegateConfederationVoice) Reset() { *m = MsgDelegateConfederationVoice{} } + +// String implements proto.Message. +func (m *MsgDelegateConfederationVoice) String() string { + return fmt.Sprintf("MsgDelegateConfederationVoice{ConfederationStandID:%s MemberStandID:%s DelegateReachID:%s Signer:%s}", + m.ConfederationStandID, m.MemberStandID, m.DelegateReachID, m.Signer) +} + +// ProtoMessage implements proto.Message. +func (*MsgDelegateConfederationVoice) ProtoMessage() {} + +// ValidateBasic is the stateless validation: non-empty fields. +func (m *MsgDelegateConfederationVoice) ValidateBasic() error { + if m.ConfederationStandID == "" { + return fmt.Errorf("guild: empty confederation-stand-id") + } + if m.MemberStandID == "" { + return fmt.Errorf("guild: empty member-stand-id") + } + if m.DelegateReachID == "" { + return fmt.Errorf("guild: empty delegate-reach-id") + } + if m.Signer == "" { + return fmt.Errorf("guild: empty signer") + } + if m.ConfederationStandID == m.MemberStandID { + return fmt.Errorf("guild: ConfederationStandID %q cannot delegate to itself", m.ConfederationStandID) + } + return nil +} + +// GetSigners returns the signer's reach-id as sdk.AccAddress bytes. +func (m *MsgDelegateConfederationVoice) GetSigners() []sdk.AccAddress { + return []sdk.AccAddress{[]byte(m.Signer)} +} + +// --- MsgAddLien --------------------------------------------------------------- + +// MsgAddLien adds a Good-Standing Lien to a Guild (REQ-053). The handler +// rejects any new SecuredAtFounding=true lien (founding is a one-time event — +// REQ-053/REQ-081; post-founding liens are SecuredAtFounding=false). The +// handler loads the Guild + persists the lien. +// +// ValidateBasic is stateless: non-empty guild-id, non-empty signer, Lien +// Amount > 0, non-empty CreditorReachID. +type MsgAddLien struct { + GuildID string `json:"guild_id" yaml:"guild_id"` + Lien Lien `json:"lien" yaml:"lien"` + Signer string `json:"signer" yaml:"signer"` +} + +// Reset implements proto.Message. +func (m *MsgAddLien) Reset() { *m = MsgAddLien{} } + +// String implements proto.Message. +func (m *MsgAddLien) String() string { + return fmt.Sprintf("MsgAddLien{GuildID:%s Lien:{Amount:%d CreditorReachID:%s SecuredAtFounding:%v} Signer:%s}", + m.GuildID, m.Lien.Amount, m.Lien.CreditorReachID, m.Lien.SecuredAtFounding, m.Signer) +} + +// ProtoMessage implements proto.Message. +func (*MsgAddLien) ProtoMessage() {} + +// ValidateBasic is the stateless validation: non-empty guild-id + signer, +// Lien Amount > 0, non-empty CreditorReachID. +func (m *MsgAddLien) ValidateBasic() error { + if m.GuildID == "" { + return fmt.Errorf("guild: empty guild-id") + } + if m.Signer == "" { + return fmt.Errorf("guild: empty signer") + } + if m.Lien.Amount <= 0 { + return fmt.Errorf("guild: lien Amount %d <= 0", m.Lien.Amount) + } + if m.Lien.CreditorReachID == "" { + return fmt.Errorf("guild: empty lien CreditorReachID") + } + return nil +} + +// GetSigners returns the signer's reach-id as sdk.AccAddress bytes. +func (m *MsgAddLien) GetSigners() []sdk.AccAddress { + return []sdk.AccAddress{[]byte(m.Signer)} +} + +// --- MsgServer interface + Response types ------------------------------------- + +// MsgServer is the guild module's message server interface (one method per +// Msg*). The keeper's msg_server.go implements this; module.go's +// RegisterServices wires the implementation. Hand-rolled (no protobuf +// codegen per the skeleton's zero-codegen style). +type MsgServer interface { + CreateGuild(ctx interface{}, msg *MsgCreateGuild) (*MsgCreateGuildResponse, error) + CreateChapter(ctx interface{}, msg *MsgCreateChapter) (*MsgCreateChapterResponse, error) + OneTapExitStand(ctx interface{}, msg *MsgOneTapExitStand) (*MsgOneTapExitStandResponse, error) + DelegateConfederationVoice(ctx interface{}, msg *MsgDelegateConfederationVoice) (*MsgDelegateConfederationVoiceResponse, error) + AddLien(ctx interface{}, msg *MsgAddLien) (*MsgAddLienResponse, error) +} + +// --- Response types ----------------------------------------------------------- +// +// Hand-rolled (no protobuf codegen). The CreateGuild + CreateChapter +// responses carry a Disclaimer string (REQ-061 — the jurisdictional +// disclaimer surfaced at every charter signing). The other responses are +// empty bodies (the response is the state mutation + event). + +// MsgCreateGuildResponse is the response to MsgCreateGuild. Disclaimer is +// the jurisdictional disclaimer surfaced at signing (REQ-061). +type MsgCreateGuildResponse struct { + Disclaimer string `json:"disclaimer" yaml:"disclaimer"` +} + +// Reset implements proto.Message. +func (m *MsgCreateGuildResponse) Reset() { *m = MsgCreateGuildResponse{} } + +// String implements proto.Message. +func (m *MsgCreateGuildResponse) String() string { + return fmt.Sprintf("MsgCreateGuildResponse{Disclaimer:%s}", m.Disclaimer) +} + +// ProtoMessage implements proto.Message. +func (*MsgCreateGuildResponse) ProtoMessage() {} + +// MsgCreateChapterResponse is the response to MsgCreateChapter. Disclaimer +// is the jurisdictional disclaimer surfaced at signing (REQ-061). +type MsgCreateChapterResponse struct { + Disclaimer string `json:"disclaimer" yaml:"disclaimer"` +} + +// Reset implements proto.Message. +func (m *MsgCreateChapterResponse) Reset() { *m = MsgCreateChapterResponse{} } + +// String implements proto.Message. +func (m *MsgCreateChapterResponse) String() string { + return fmt.Sprintf("MsgCreateChapterResponse{Disclaimer:%s}", m.Disclaimer) +} + +// ProtoMessage implements proto.Message. +func (*MsgCreateChapterResponse) ProtoMessage() {} + +// MsgOneTapExitStandResponse is the response to MsgOneTapExitStand. +type MsgOneTapExitStandResponse struct{} + +// Reset implements proto.Message. +func (m *MsgOneTapExitStandResponse) Reset() { *m = MsgOneTapExitStandResponse{} } + +// String implements proto.Message. +func (m *MsgOneTapExitStandResponse) String() string { return "MsgOneTapExitStandResponse{}" } + +// ProtoMessage implements proto.Message. +func (*MsgOneTapExitStandResponse) ProtoMessage() {} + +// MsgDelegateConfederationVoiceResponse is the response to +// MsgDelegateConfederationVoice. +type MsgDelegateConfederationVoiceResponse struct{} + +// Reset implements proto.Message. +func (m *MsgDelegateConfederationVoiceResponse) Reset() { + *m = MsgDelegateConfederationVoiceResponse{} +} + +// String implements proto.Message. +func (m *MsgDelegateConfederationVoiceResponse) String() string { + return "MsgDelegateConfederationVoiceResponse{}" +} + +// ProtoMessage implements proto.Message. +func (*MsgDelegateConfederationVoiceResponse) ProtoMessage() {} + +// MsgAddLienResponse is the response to MsgAddLien. +type MsgAddLienResponse struct{} + +// Reset implements proto.Message. +func (m *MsgAddLienResponse) Reset() { *m = MsgAddLienResponse{} } + +// String implements proto.Message. +func (m *MsgAddLienResponse) String() string { return "MsgAddLienResponse{}" } + +// ProtoMessage implements proto.Message. +func (*MsgAddLienResponse) ProtoMessage() {} diff --git a/x/guild/types/msg_guild_test.go b/x/guild/types/msg_guild_test.go new file mode 100644 index 0000000..8f0c36e --- /dev/null +++ b/x/guild/types/msg_guild_test.go @@ -0,0 +1,305 @@ +package types + +// msg_guild_test.go holds the Msg* method coverage tests for x/guild/types +// (REQ-051, REQ-053, REQ-057, REQ-058). The Msg* Reset/String/ProtoMessage/ +// ValidateBasic/GetSigners methods are exercised here so the types package +// coverage is >=80% (the keeper simtest exercises the handlers but its +// coverage counts toward the keeper package, not types). +// +// G-024: this file imports cosmos-sdk for GetSigners (sdk.AccAddress) — +// this is a Msg-method test, NOT an invariant/lexicon test, so the G-024 +// stdlib-only constraint does not apply (the invariant + lexicon assertions +// live in types_test.go, which stays stdlib + lexicon-only). + +import ( + "strings" + "testing" + + sdk "github.com/cosmos/cosmos-sdk/types" +) + +// --- MsgCreateGuild methods --------------------------------------------------- + +func TestMsgCreateGuildMethods(t *testing.T) { + m := &MsgCreateGuild{ + GuildID: "g1", Name: "Guild", FounderReach: "reach:f", + CommonBondHash: []byte{1, 2, 3}, + PublicProfile: GuildPublicProfile{BondSummary: "s", MasonCount: 7}, + Signer: "reach:f", + } + if err := m.ValidateBasic(); err != nil { + t.Errorf("valid MsgCreateGuild ValidateBasic: %v", err) + } + if !strings.Contains(m.String(), "g1") { + t.Errorf("MsgCreateGuild String = %q, want to contain g1", m.String()) + } + m.Reset() + if m.GuildID != "" || len(m.CommonBondHash) != 0 { + t.Errorf("MsgCreateGuild Reset did not zero: %+v", m) + } + m.ProtoMessage() // no-op coverage + m2 := &MsgCreateGuild{Signer: "reach:s"} + if got := m2.GetSigners(); len(got) != 1 || string(got[0]) != "reach:s" { + t.Errorf("MsgCreateGuild GetSigners = %v, want [reach:s]", got) + } + var _ []sdk.AccAddress = m2.GetSigners() +} + +func TestMsgCreateGuildValidateBasicErrors(t *testing.T) { + cases := []struct { + name string + mut func(*MsgCreateGuild) + }{ + {"empty guild-id", func(m *MsgCreateGuild) { m.GuildID = "" }}, + {"empty name", func(m *MsgCreateGuild) { m.Name = "" }}, + {"empty founder-reach", func(m *MsgCreateGuild) { m.FounderReach = "" }}, + {"empty signer", func(m *MsgCreateGuild) { m.Signer = "" }}, + {"empty common-bond-hash", func(m *MsgCreateGuild) { m.CommonBondHash = nil }}, + } + for _, c := range cases { + m := &MsgCreateGuild{GuildID: "g", Name: "n", FounderReach: "r", CommonBondHash: []byte{1}, Signer: "s"} + c.mut(m) + if err := m.ValidateBasic(); err == nil { + t.Errorf("MsgCreateGuild %s: expected error, got nil", c.name) + } + } +} + +// --- MsgCreateChapter methods ------------------------------------------------- + +func TestMsgCreateChapterMethods(t *testing.T) { + m := &MsgCreateChapter{ + GuildID: "c1", Name: "Chapter", ParentGuildID: "g1", FounderReach: "reach:f", + SecessionTerms: SecessionTerms{ + CoolingCoverActiveDays: CoolingSecessionCoverActiveDays, + CoolingNonCoverDays: CoolingSecessionNonCoverDays, + }, + GoodStandingLiens: []Lien{{Amount: 100, CreditorReachID: "reach:c", SecuredAtFounding: true}}, + Signer: "reach:f", + } + if err := m.ValidateBasic(); err != nil { + t.Errorf("valid MsgCreateChapter ValidateBasic: %v", err) + } + if !strings.Contains(m.String(), "c1") || !strings.Contains(m.String(), "g1") { + t.Errorf("MsgCreateChapter String = %q", m.String()) + } + m.Reset() + if m.GuildID != "" || m.ParentGuildID != "" { + t.Errorf("MsgCreateChapter Reset did not zero: %+v", m) + } + m.ProtoMessage() + m2 := &MsgCreateChapter{Signer: "reach:s"} + if got := m2.GetSigners(); len(got) != 1 || string(got[0]) != "reach:s" { + t.Errorf("MsgCreateChapter GetSigners = %v", got) + } +} + +func TestMsgCreateChapterValidateBasicErrors(t *testing.T) { + validTerms := SecessionTerms{ + CoolingCoverActiveDays: CoolingSecessionCoverActiveDays, + CoolingNonCoverDays: CoolingSecessionNonCoverDays, + } + validLiens := []Lien{{Amount: 100, CreditorReachID: "reach:c", SecuredAtFounding: true}} + cases := []struct { + name string + mut func(*MsgCreateChapter) + }{ + {"empty guild-id", func(m *MsgCreateChapter) { m.GuildID = "" }}, + {"empty name", func(m *MsgCreateChapter) { m.Name = "" }}, + {"empty parent-guild-id", func(m *MsgCreateChapter) { m.ParentGuildID = "" }}, + {"self parent", func(m *MsgCreateChapter) { m.ParentGuildID = m.GuildID }}, + {"empty founder-reach", func(m *MsgCreateChapter) { m.FounderReach = "" }}, + {"empty signer", func(m *MsgCreateChapter) { m.Signer = "" }}, + {"loose cooling (cover)", func(m *MsgCreateChapter) { + m.SecessionTerms.CoolingCoverActiveDays = CoolingSecessionCoverActiveDays - 1 + }}, + {"loose cooling (non-cover)", func(m *MsgCreateChapter) { + m.SecessionTerms.CoolingNonCoverDays = CoolingSecessionNonCoverDays - 1 + }}, + {"zero cooling (cover)", func(m *MsgCreateChapter) { m.SecessionTerms.CoolingCoverActiveDays = 0 }}, + {"lien not secured at founding", func(m *MsgCreateChapter) { + m.GoodStandingLiens = []Lien{{Amount: 100, CreditorReachID: "reach:c", SecuredAtFounding: false}} + }}, + {"lien empty creditor", func(m *MsgCreateChapter) { + m.GoodStandingLiens = []Lien{{Amount: 100, CreditorReachID: "", SecuredAtFounding: true}} + }}, + {"lien zero amount", func(m *MsgCreateChapter) { + m.GoodStandingLiens = []Lien{{Amount: 0, CreditorReachID: "reach:c", SecuredAtFounding: true}} + }}, + } + for _, c := range cases { + m := &MsgCreateChapter{ + GuildID: "c", Name: "n", ParentGuildID: "g", FounderReach: "r", + SecessionTerms: validTerms, GoodStandingLiens: validLiens, Signer: "s", + } + c.mut(m) + if err := m.ValidateBasic(); err == nil { + t.Errorf("MsgCreateChapter %s: expected error, got nil", c.name) + } + } +} + +// --- MsgOneTapExitStand methods ----------------------------------------------- + +func TestMsgOneTapExitStandMethods(t *testing.T) { + m := &MsgOneTapExitStand{StandID: "s1", Signer: "reach:h"} + if err := m.ValidateBasic(); err != nil { + t.Errorf("valid MsgOneTapExitStand ValidateBasic: %v", err) + } + if !strings.Contains(m.String(), "s1") { + t.Errorf("MsgOneTapExitStand String = %q", m.String()) + } + m.Reset() + if m.StandID != "" { + t.Errorf("MsgOneTapExitStand Reset did not zero: %+v", m) + } + m.ProtoMessage() + m2 := &MsgOneTapExitStand{Signer: "reach:s"} + if got := m2.GetSigners(); len(got) != 1 || string(got[0]) != "reach:s" { + t.Errorf("MsgOneTapExitStand GetSigners = %v", got) + } +} + +func TestMsgOneTapExitStandValidateBasicErrors(t *testing.T) { + if err := (&MsgOneTapExitStand{}).ValidateBasic(); err == nil { + t.Error("empty MsgOneTapExitStand should fail ValidateBasic") + } + if err := (&MsgOneTapExitStand{StandID: "s"}).ValidateBasic(); err == nil { + t.Error("MsgOneTapExitStand with empty signer should fail ValidateBasic") + } + if err := (&MsgOneTapExitStand{Signer: "s"}).ValidateBasic(); err == nil { + t.Error("MsgOneTapExitStand with empty stand-id should fail ValidateBasic") + } +} + +// --- MsgDelegateConfederationVoice methods ------------------------------------ + +func TestMsgDelegateConfederationVoiceMethods(t *testing.T) { + m := &MsgDelegateConfederationVoice{ + ConfederationStandID: "conf-1", MemberStandID: "mem-1", + DelegateReachID: "reach:d", Signer: "reach:s", + } + if err := m.ValidateBasic(); err != nil { + t.Errorf("valid MsgDelegateConfederationVoice ValidateBasic: %v", err) + } + if !strings.Contains(m.String(), "conf-1") { + t.Errorf("MsgDelegateConfederationVoice String = %q", m.String()) + } + m.Reset() + if m.ConfederationStandID != "" { + t.Errorf("MsgDelegateConfederationVoice Reset did not zero: %+v", m) + } + m.ProtoMessage() + m2 := &MsgDelegateConfederationVoice{Signer: "reach:s"} + if got := m2.GetSigners(); len(got) != 1 || string(got[0]) != "reach:s" { + t.Errorf("MsgDelegateConfederationVoice GetSigners = %v", got) + } +} + +func TestMsgDelegateConfederationVoiceValidateBasicErrors(t *testing.T) { + cases := []struct { + name string + mut func(*MsgDelegateConfederationVoice) + }{ + {"empty confederation", func(m *MsgDelegateConfederationVoice) { m.ConfederationStandID = "" }}, + {"empty member", func(m *MsgDelegateConfederationVoice) { m.MemberStandID = "" }}, + {"empty delegate", func(m *MsgDelegateConfederationVoice) { m.DelegateReachID = "" }}, + {"empty signer", func(m *MsgDelegateConfederationVoice) { m.Signer = "" }}, + {"self-delegate", func(m *MsgDelegateConfederationVoice) { m.MemberStandID = m.ConfederationStandID }}, + } + for _, c := range cases { + m := &MsgDelegateConfederationVoice{ + ConfederationStandID: "c", MemberStandID: "m", + DelegateReachID: "d", Signer: "s", + } + c.mut(m) + if err := m.ValidateBasic(); err == nil { + t.Errorf("MsgDelegateConfederationVoice %s: expected error", c.name) + } + } +} + +// --- MsgAddLien methods ------------------------------------------------------- + +func TestMsgAddLienMethods(t *testing.T) { + m := &MsgAddLien{ + GuildID: "g1", + Lien: Lien{Amount: 100, CreditorReachID: "reach:c", SecuredAtFounding: false}, + Signer: "reach:s", + } + if err := m.ValidateBasic(); err != nil { + t.Errorf("valid MsgAddLien ValidateBasic: %v", err) + } + if !strings.Contains(m.String(), "g1") { + t.Errorf("MsgAddLien String = %q", m.String()) + } + m.Reset() + if m.GuildID != "" { + t.Errorf("MsgAddLien Reset did not zero: %+v", m) + } + m.ProtoMessage() + m2 := &MsgAddLien{Signer: "reach:s"} + if got := m2.GetSigners(); len(got) != 1 || string(got[0]) != "reach:s" { + t.Errorf("MsgAddLien GetSigners = %v", got) + } +} + +func TestMsgAddLienValidateBasicErrors(t *testing.T) { + cases := []struct { + name string + mut func(*MsgAddLien) + }{ + {"empty guild-id", func(m *MsgAddLien) { m.GuildID = "" }}, + {"empty signer", func(m *MsgAddLien) { m.Signer = "" }}, + {"zero amount", func(m *MsgAddLien) { m.Lien.Amount = 0 }}, + {"negative amount", func(m *MsgAddLien) { m.Lien.Amount = -1 }}, + {"empty creditor", func(m *MsgAddLien) { m.Lien.CreditorReachID = "" }}, + } + for _, c := range cases { + m := &MsgAddLien{ + GuildID: "g", Lien: Lien{Amount: 100, CreditorReachID: "reach:c"}, Signer: "s", + } + c.mut(m) + if err := m.ValidateBasic(); err == nil { + t.Errorf("MsgAddLien %s: expected error", c.name) + } + } +} + +// --- Response type methods ---------------------------------------------------- + +func TestResponseMethods(t *testing.T) { + r1 := &MsgCreateGuildResponse{Disclaimer: "d"} + if !strings.Contains(r1.String(), "d") { + t.Errorf("MsgCreateGuildResponse String = %q", r1.String()) + } + r1.Reset() + if r1.Disclaimer != "" { + t.Errorf("MsgCreateGuildResponse Reset did not zero: %+v", r1) + } + r1.ProtoMessage() + + r2 := &MsgCreateChapterResponse{Disclaimer: "d"} + if !strings.Contains(r2.String(), "d") { + t.Errorf("MsgCreateChapterResponse String = %q", r2.String()) + } + r2.Reset() + if r2.Disclaimer != "" { + t.Errorf("MsgCreateChapterResponse Reset did not zero: %+v", r2) + } + r2.ProtoMessage() + + for _, r := range []interface { + Reset() + String() string + ProtoMessage() + }{ + &MsgOneTapExitStandResponse{}, + &MsgDelegateConfederationVoiceResponse{}, + &MsgAddLienResponse{}, + } { + r.ProtoMessage() + _ = r.String() + r.Reset() + } +} diff --git a/x/guild/types/types.go b/x/guild/types/types.go index 745f7da..e1d2953 100644 --- a/x/guild/types/types.go +++ b/x/guild/types/types.go @@ -1,6 +1,7 @@ package types import ( + "crypto/sha256" "encoding/json" "fmt" ) @@ -17,18 +18,162 @@ const ( // (v0.1 already encodes HandPassGuild as a 0-fee waiver reason). v0.2's Guild // module references that waiver, doesn't redefine the fee. HandPassFeeBps = 0 + + // PierCarriesVoice is the 12th locked const (GRILL D-087, FR-VOICE-6): + // the Pier wrapper does NOT carry Voice, regardless of fiduciary role. + // This is a mission-locked invariant: a Chapter retains mesh-level Voice + // (the const enforces that the optional Pier-Routed Legal Wrapper does + // NOT carry Voice). Locked-const regression in types_test.go. + PierCarriesVoice = false + + // CoolingSecessionCoverActiveDays is the LOCKED protocol minimum (REQ-064) + // for a Cover-active Chapter's secession cooling period: 21 Mesh-days. A + // Chapter's SecessionTerms MAY specify a longer cooling but NOT shorter + // (the CreateChapter handler rejects shorter). Locked-const regression in + // types_test.go. + CoolingSecessionCoverActiveDays = uint32(21) + + // CoolingSecessionNonCoverDays is the LOCKED protocol minimum (REQ-064) + // for a non-Cover-active Chapter's secession cooling period: 14 Mesh-days. + // A Chapter's SecessionTerms MAY specify a longer cooling but NOT shorter + // (the CreateChapter handler rejects shorter). Locked-const regression in + // types_test.go. + CoolingSecessionNonCoverDays = uint32(14) ) // Guild is a task-oriented collective (vision §16, REQ-017). A Guild may // optionally affiliate with a Stand (stand-affiliation-id references x/stand // by ID string — G-003 by-ID-string invariant). founder-reach references // x/identity Reach by string. +// +// P3 extension (REQ-051, REQ-053): the Guild carries a Common Bond +// (hash-pinned at creation — CommonBondHash) + a Public Profile +// (GuildPublicProfile). A Parent Guild (IsChapter=false, ParentGuildID="") +// may have Chapters (IsChapter=true, ParentGuildID by-ID-string). A Chapter +// pins its SecessionTerms at creation (SecessionTermsHash — the hash of the +// JSON-encoded SecessionTerms; immutable — no handler to amend it). A +// Chapter's Good-Standing Liens (GoodStandingLiens) are recorded at founding +// with SecuredAtFounding=true; post-founding liens are SecuredAtFounding=false +// (the AddLien handler rejects any new SecuredAtFounding=true lien — founding +// is a one-time event). type Guild struct { - GuildID string `json:"guild_id" yaml:"guild_id"` - Name string `json:"name" yaml:"name"` - FounderReach string `json:"founder_reach" yaml:"founder_reach"` - CreatedAt int64 `json:"created_at" yaml:"created_at"` - StandAffiliationID string `json:"stand_affiliation_id,omitempty" yaml:"stand_affiliation_id,omitempty"` + GuildID string `json:"guild_id" yaml:"guild_id"` + Name string `json:"name" yaml:"name"` + FounderReach string `json:"founder_reach" yaml:"founder_reach"` + CreatedAt int64 `json:"created_at" yaml:"created_at"` + StandAffiliationID string `json:"stand_affiliation_id,omitempty" yaml:"stand_affiliation_id,omitempty"` + CommonBondHash []byte `json:"common_bond_hash,omitempty" yaml:"common_bond_hash,omitempty"` + PublicProfile GuildPublicProfile `json:"public_profile,omitempty" yaml:"public_profile,omitempty"` + ParentGuildID string `json:"parent_guild_id,omitempty" yaml:"parent_guild_id,omitempty"` + IsChapter bool `json:"is_chapter,omitempty" yaml:"is_chapter,omitempty"` + SecessionTermsHash []byte `json:"secession_terms_hash,omitempty" yaml:"secession_terms_hash,omitempty"` + GoodStandingLiens []Lien `json:"good_standing_liens,omitempty" yaml:"good_standing_liens,omitempty"` +} + +// GuildPublicProfile is a Guild's published profile (REQ-051). BondSummary is +// a short, human-readable summary of the Common Bond (the protocol does NOT +// parse it — FR-CHTR-5). Disclaimers is the list of jurisdictional +// disclaimers the Guild publishes. MasonCount is the member count when +// disclosed; MasonCountPrivate=true means the count is NOT disclosed +// (MasonCount is 0; consumers check the bool). PierWrapperID references a +// Pier wrapper by-ID-string (G-003); empty means no Pier wrapper (the §5 +// default-no-wrapper — D-087: the Pier wrapper does NOT carry Voice). +type GuildPublicProfile struct { + BondSummary string `json:"bond_summary" yaml:"bond_summary"` + Disclaimers []string `json:"disclaimers" yaml:"disclaimers"` + MasonCount uint32 `json:"mason_count" yaml:"mason_count"` + MasonCountPrivate bool `json:"mason_count_private" yaml:"mason_count_private"` + PierWrapperID string `json:"pier_wrapper_id,omitempty" yaml:"pier_wrapper_id,omitempty"` +} + +// Lien is a Good-Standing Lien on a Guild (REQ-053). Amount is the lien +// amount in Grain. CreditorReachID references the creditor's Reach by string +// (G-003). SecuredAtFounding=true marks a founding-locked lien (recorded at +// Guild/Chapter creation; NOT freely increasable post-founding — the AddLien +// handler rejects any new SecuredAtFounding=true lien). CoverPoolCovenantRef +// references a Cover Pool covenant by-ID-string (G-003); empty for a lien +// with no Cover Pool covenant backing. +type Lien struct { + Amount int64 `json:"amount" yaml:"amount"` + CreditorReachID string `json:"creditor_reach_id" yaml:"creditor_reach_id"` + SecuredAtFounding bool `json:"secured_at_founding" yaml:"secured_at_founding"` + CoverPoolCovenantRef string `json:"cover_pool_covenant_ref,omitempty" yaml:"cover_pool_covenant_ref,omitempty"` +} + +// SecessionTerms is a Chapter's secession cooling terms (REQ-053, REQ-064). +// Hash-pinned at Guild creation (the SecessionTermsHash on the Guild is the +// SHA-256 of this struct's JSON; immutable — no handler to amend it). The +// cooling periods are protocol-minimum-bounded: the Chapter MAY specify +// longer but NOT shorter than CoolingSecessionCoverActiveDays / +// CoolingSecessionNonCoverDays (the CreateChapter handler rejects shorter). +// LienAuditRequired marks whether a lien audit must pass before secession +// completes. CovenantClearanceRequired marks whether Cover Call / Bond +// covenant clearance must pass before secession completes. +type SecessionTerms struct { + CoolingCoverActiveDays uint32 `json:"cooling_cover_active_days" yaml:"cooling_cover_active_days"` + CoolingNonCoverDays uint32 `json:"cooling_non_cover_days" yaml:"cooling_non_cover_days"` + LienAuditRequired bool `json:"lien_audit_required" yaml:"lien_audit_required"` + CovenantClearanceRequired bool `json:"covenant_clearance_required" yaml:"covenant_clearance_required"` +} + +// HashSecessionTerms returns the SHA-256 hash of the JSON-encoded +// SecessionTerms. This is the value stored on Guild.SecessionTermsHash at +// Chapter creation (immutable). The handler pins the hash, NOT the terms +// themselves (the terms are recoverable from genesis; the hash pins them +// against amendment — REQ-053 immutability). +func HashSecessionTerms(t SecessionTerms) []byte { + bz, err := json.Marshal(t) + if err != nil { + // SecessionTerms is a plain struct with only uint32/bool fields; + // json.Marshal never errors here. Panic is the defensive path. + panic(fmt.Sprintf("guild: marshal secession terms: %v", err)) + } + sum := sha256.Sum256(bz) + return sum[:] +} + +// ConfederationVoice is a Confederation Voice delegation record (REQ-058). +// One-Stand-one-Vote: each member Stand gets exactly 1 Voice in the +// Confederation's aggregate, regardless of size. ConfederationStandID + +// MemberStandID reference x/stand Stands by-ID-string (G-003). +// DelegateReachID references the Reach the member Stand's Voice is delegated +// to. DelegatedAt is the delegation timestamp (block time). The guild +// keeper persists this (the DelegateConfederationVoice handler records one +// delegation per member Stand — a duplicate is REJECTED). +// +// NOTE: x/stand/types defines a type-level ConfederationVoice struct too +// (the type-level addition); this guild-side struct is the persisted record +// (the guild keeper owns the delegation store). The two structs share the +// same JSON field names so a value of one round-trips through the other +// (the simtest asserts against this struct; the x/stand/types struct is the +// type-level scaffold for the aggregation logic landing in a later phase). +type ConfederationVoice struct { + ConfederationStandID string `json:"confederation_stand_id" yaml:"confederation_stand_id"` + MemberStandID string `json:"member_stand_id" yaml:"member_stand_id"` + DelegateReachID string `json:"delegate_reach_id" yaml:"delegate_reach_id"` + DelegatedAt int64 `json:"delegated_at" yaml:"delegated_at"` +} + +// Validate asserts a SecessionTerms is non-zero + protocol-minimum-bounded +// (the Chapter MAY tighten the cooling but NOT loosen it below +// CoolingSecessionCoverActiveDays / CoolingSecessionNonCoverDays). The +// CreateChapter handler calls this BEFORE pinning the hash. +func (t SecessionTerms) Validate() error { + if t.CoolingCoverActiveDays == 0 { + return fmt.Errorf("guild: CoolingCoverActiveDays must be non-zero") + } + if t.CoolingNonCoverDays == 0 { + return fmt.Errorf("guild: CoolingNonCoverDays must be non-zero") + } + if t.CoolingCoverActiveDays < CoolingSecessionCoverActiveDays { + return fmt.Errorf("guild: CoolingCoverActiveDays %d < protocol minimum %d (Chapter may tighten but not loosen — REQ-053/REQ-064)", + t.CoolingCoverActiveDays, CoolingSecessionCoverActiveDays) + } + if t.CoolingNonCoverDays < CoolingSecessionNonCoverDays { + return fmt.Errorf("guild: CoolingNonCoverDays %d < protocol minimum %d (Chapter may tighten but not loosen — REQ-053/REQ-064)", + t.CoolingNonCoverDays, CoolingSecessionNonCoverDays) + } + return nil } // HandPass is a free (0% protocol fee) Pass-Act issued by a Guild (REQ-017). @@ -60,17 +205,38 @@ func IssueHandPass(passID, guildID, issuerReach, recipientReach string, amountGr } } -// Params for the guild module (skeleton — no tunables in v0.2). -type Params struct{} +// Params for the guild module (P3 extension — REQ-064 cooling defaults). +// DefaultCoolingCoverActiveDays + DefaultCoolingNonCoverDays are the +// protocol-default cooling periods for a Chapter with no SecessionTerms +// override (the Chapter's own SecessionTerms MAY specify longer but NOT +// shorter than the protocol minimums CoolingSecessionCoverActiveDays / +// CoolingSecessionNonCoverDays). +type Params struct { + DefaultCoolingCoverActiveDays uint32 `json:"default_cooling_cover_active_days" yaml:"default_cooling_cover_active_days"` + DefaultCoolingNonCoverDays uint32 `json:"default_cooling_non_cover_days" yaml:"default_cooling_non_cover_days"` +} -func DefaultParams() Params { return Params{} } +// DefaultParams returns the Params with the protocol-minimum cooling defaults +// (CoolingSecessionCoverActiveDays / CoolingSecessionNonCoverDays — the +// Chapter MAY tighten but NOT loosen). +func DefaultParams() Params { + return Params{ + DefaultCoolingCoverActiveDays: CoolingSecessionCoverActiveDays, + DefaultCoolingNonCoverDays: CoolingSecessionNonCoverDays, + } +} -// GenesisState defines the guild module genesis state (REQ-017). -// Guilds + HandPasses are the two top-level sets; ValidateGenesis enforces -// guild-id uniqueness and pass-id uniqueness. +// GenesisState defines the guild module genesis state (REQ-017, REQ-053). +// Guilds + HandPasses + Chapters are the three top-level sets; Chapters is a +// separate slice for genesis validation clarity (a Chapter is a Guild with +// IsChapter=true — the separate slice makes the Chapter→ParentGuildID +// reference check unambiguous). ValidateGenesis enforces guild-id + pass-id +// uniqueness + the Chapter→ParentGuildID reference check (a Chapter's +// ParentGuildID must reference an existing Guild in the genesis — REQ-053). type GenesisState struct { Params Params `json:"params" yaml:"params"` Guilds []Guild `json:"guilds" yaml:"guilds"` + Chapters []Guild `json:"chapters,omitempty" yaml:"chapters,omitempty"` HandPasses []HandPass `json:"hand_passes" yaml:"hand_passes"` } @@ -78,19 +244,40 @@ func DefaultGenesisState() *GenesisState { return &GenesisState{ Params: DefaultParams(), Guilds: []Guild{}, + Chapters: []Guild{}, HandPasses: []HandPass{}, } } +// Reset implements proto.Message (required by codec.JSONCodec for +// InitGenesis/ExportGenesis). +func (m *GenesisState) Reset() { *m = GenesisState{} } + +// String implements proto.Message. +func (m *GenesisState) String() string { + return fmt.Sprintf("GenesisState{Guilds:%d Chapters:%d HandPasses:%d}", + len(m.Guilds), len(m.Chapters), len(m.HandPasses)) +} + +// ProtoMessage implements proto.Message. +func (*GenesisState) ProtoMessage() {} + // ValidateGenesis performs ID-uniqueness checks (A-212 upgrade from v0.1 // no-op): rejects duplicate guild-ids and duplicate pass-ids. Also enforces -// the 0-fee covenant on genesis HandPasses (FeeGrain must be 0). +// the 0-fee covenant on genesis HandPasses (FeeGrain must be 0). P3 +// extension (REQ-053): a Chapter (Guild with IsChapter=true, in either the +// Guilds or Chapters slice) must have a non-empty ParentGuildID referencing +// an existing Guild in the genesis (the parent must be a non-Chapter Guild). func ValidateGenesis(bz json.RawMessage) error { var gs GenesisState if err := json.Unmarshal(bz, &gs); err != nil { return fmt.Errorf("guild: invalid genesis: %w", err) } - seenGuild := make(map[string]bool, len(gs.Guilds)) + // Index all guild-ids across the Guilds + Chapters slices for the + // Chapter→ParentGuildID reference check. Reject duplicate guild-ids + // across BOTH slices (a Chapter may not share a guild-id with a Parent + // Guild). + seenGuild := make(map[string]bool, len(gs.Guilds)+len(gs.Chapters)) for _, g := range gs.Guilds { if g.GuildID == "" { return fmt.Errorf("guild: empty guild-id") @@ -99,6 +286,32 @@ func ValidateGenesis(bz json.RawMessage) error { return fmt.Errorf("guild: duplicate guild-id %q", g.GuildID) } seenGuild[g.GuildID] = true + // A Guild in the Guilds slice with IsChapter=true is rejected (a + // Chapter must live in the Chapters slice — the split is for genesis + // validation clarity). + if g.IsChapter { + return fmt.Errorf("guild: Guild %q has IsChapter=true but is in the Guilds slice (move to Chapters)", g.GuildID) + } + } + for _, c := range gs.Chapters { + if c.GuildID == "" { + return fmt.Errorf("guild: empty chapter guild-id") + } + if seenGuild[c.GuildID] { + return fmt.Errorf("guild: duplicate guild-id %q (Chapter)", c.GuildID) + } + seenGuild[c.GuildID] = true + // REQ-053: a Chapter must have IsChapter=true + a non-empty + // ParentGuildID referencing an existing Guild. + if !c.IsChapter { + return fmt.Errorf("guild: Chapter %q has IsChapter=false (Chapters slice requires IsChapter=true)", c.GuildID) + } + if c.ParentGuildID == "" { + return fmt.Errorf("guild: Chapter %q has empty ParentGuildID (REQ-053)", c.GuildID) + } + if !seenGuild[c.ParentGuildID] { + return fmt.Errorf("guild: Chapter %q ParentGuildID %q not found in genesis (REQ-053)", c.GuildID, c.ParentGuildID) + } } seenPass := make(map[string]bool, len(gs.HandPasses)) for _, p := range gs.HandPasses { diff --git a/x/guild/types/types_test.go b/x/guild/types/types_test.go index 51f4710..39e2672 100644 --- a/x/guild/types/types_test.go +++ b/x/guild/types/types_test.go @@ -221,14 +221,16 @@ func TestDefaultParams(t *testing.T) { // --- Lexicon assertion (REQ-012) ------------------------------------------------- // TestLexiconNoBannedTermsInGuildPackage scans every non-test .go file in -// the guild/types package directory for the 9 banned terms (case-insensitive). -// Production files only — the test file contains the banned terms as the list -// of things to forbid (standard lexicon-test bootstrapping pattern). +// the x/guild module tree (types + keeper + module.go) for the 10 banned +// terms (case-insensitive). Production files only — the test file contains +// the banned terms as the list of things to forbid (standard lexicon-test +// bootstrapping pattern). The scan walks x/guild/**/*.go (the spec's +// `x/guild/**/*.go` lexicon assertion for P3). func TestLexiconNoBannedTermsInGuildPackage(t *testing.T) { - pkgDir := packageDir(t, "github.com/oy/openyield/x/guild/types") - files, err := filepath.Glob(filepath.Join(pkgDir, "*.go")) + guildDir := packageDir(t, "github.com/oy/openyield/x/guild") + files, err := walkGoFiles(guildDir) if err != nil { - t.Fatalf("glob: %v", err) + t.Fatalf("walk: %v", err) } prodFiles := []string{} for _, f := range files { @@ -238,7 +240,7 @@ func TestLexiconNoBannedTermsInGuildPackage(t *testing.T) { prodFiles = append(prodFiles, f) } if len(prodFiles) == 0 { - t.Fatal("no production .go files found in guild/types") + t.Fatal("no production .go files found in x/guild") } for _, f := range prodFiles { bz, err := os.ReadFile(f) @@ -251,6 +253,321 @@ func TestLexiconNoBannedTermsInGuildPackage(t *testing.T) { } } +// walkGoFiles returns all .go files under dir (recursively). +func walkGoFiles(dir string) ([]string, error) { + var out []string + err := filepath.Walk(dir, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + if info.IsDir() { + return nil + } + if strings.HasSuffix(path, ".go") { + out = append(out, path) + } + return nil + }) + return out, err +} + +// --- P3 locked-const regression (REQ-064, D-087) ------------------------------- + +// TestPierCarriesVoiceLockedConst asserts D-087: PierCarriesVoice == false +// (FR-VOICE-6: the Pier wrapper does NOT carry Voice, regardless of +// fiduciary role — mission-locked invariant). A regression firewall: +// changing PierCarriesVoice to true breaks this test. +func TestPierCarriesVoiceLockedConst(t *testing.T) { + if types.PierCarriesVoice { + t.Errorf("PierCarriesVoice = true, expected false (D-087 FR-VOICE-6: Pier does NOT carry Voice)") + } +} + +// TestCoolingSecessionLockedConsts asserts REQ-064: the protocol-minimum +// cooling periods for Chapter secession (21d Cover-active, 14d non-Cover). +// A Chapter's SecessionTerms MAY specify longer but NOT shorter (the +// CreateChapter handler rejects shorter). Regression firewall: changing +// these consts breaks this test. +func TestCoolingSecessionLockedConsts(t *testing.T) { + if types.CoolingSecessionCoverActiveDays != 21 { + t.Errorf("CoolingSecessionCoverActiveDays = %d, expected 21 (REQ-064 LOCKED)", + types.CoolingSecessionCoverActiveDays) + } + if types.CoolingSecessionNonCoverDays != 14 { + t.Errorf("CoolingSecessionNonCoverDays = %d, expected 14 (REQ-064 LOCKED)", + types.CoolingSecessionNonCoverDays) + } +} + +// --- P3 Guild struct extension (REQ-051, REQ-053) ------------------------------ + +// TestGuildP3Fields asserts the Guild struct carries the P3 extension fields +// (CommonBondHash, PublicProfile, ParentGuildID, IsChapter, +// SecessionTermsHash, GoodStandingLiens) — a compile-time + runtime +// regression firewall (removing any field breaks this test). +func TestGuildP3Fields(t *testing.T) { + g := types.Guild{ + GuildID: "g1", + Name: "Parent", + FounderReach: "reach:f", + CommonBondHash: []byte{1, 2, 3}, + PublicProfile: types.GuildPublicProfile{BondSummary: "sum", MasonCount: 7}, + ParentGuildID: "", + IsChapter: false, + SecessionTermsHash: nil, + GoodStandingLiens: []types.Lien{{Amount: 100, CreditorReachID: "reach:c", SecuredAtFounding: true}}, + } + if g.CommonBondHash == nil || len(g.CommonBondHash) != 3 { + t.Errorf("CommonBondHash = %v, want 3 bytes", g.CommonBondHash) + } + if g.PublicProfile.BondSummary != "sum" || g.PublicProfile.MasonCount != 7 { + t.Errorf("PublicProfile = %+v", g.PublicProfile) + } + if g.IsChapter { + t.Errorf("IsChapter = true, want false for a Parent Guild") + } + if g.ParentGuildID != "" { + t.Errorf("ParentGuildID = %q, want empty for a Parent Guild", g.ParentGuildID) + } + if len(g.GoodStandingLiens) != 1 || !g.GoodStandingLiens[0].SecuredAtFounding { + t.Errorf("GoodStandingLiens = %v", g.GoodStandingLiens) + } + + // Chapter variant. + c := types.Guild{ + GuildID: "c1", + Name: "Chapter", + FounderReach: "reach:f", + ParentGuildID: "g1", + IsChapter: true, + SecessionTermsHash: []byte{9, 9, 9}, + GoodStandingLiens: []types.Lien{{Amount: 50, CreditorReachID: "reach:c2", SecuredAtFounding: true}}, + } + if !c.IsChapter || c.ParentGuildID != "g1" { + t.Errorf("Chapter fields: IsChapter=%v ParentGuildID=%q", c.IsChapter, c.ParentGuildID) + } + if len(c.SecessionTermsHash) != 3 { + t.Errorf("SecessionTermsHash = %v, want 3 bytes", c.SecessionTermsHash) + } +} + +// TestGuildPublicProfileMasonCountPrivate asserts the MasonCountPrivate bool: +// when true, the MasonCount is NOT disclosed (the field is 0; consumers +// check the bool). +func TestGuildPublicProfileMasonCountPrivate(t *testing.T) { + disclosed := types.GuildPublicProfile{BondSummary: "s", MasonCount: 42, MasonCountPrivate: false} + if disclosed.MasonCountPrivate || disclosed.MasonCount != 42 { + t.Errorf("disclosed profile: %+v", disclosed) + } + private := types.GuildPublicProfile{BondSummary: "s", MasonCount: 0, MasonCountPrivate: true} + if !private.MasonCountPrivate { + t.Errorf("private profile: MasonCountPrivate = false, want true") + } + if private.MasonCount != 0 { + t.Errorf("private profile: MasonCount = %d, want 0 (not disclosed)", private.MasonCount) + } +} + +// TestLienStruct asserts the Lien struct carries the four required fields +// (Amount, CreditorReachID, SecuredAtFounding, CoverPoolCovenantRef). +func TestLienStruct(t *testing.T) { + l := types.Lien{ + Amount: 1000, + CreditorReachID: "reach:cred", + SecuredAtFounding: true, + CoverPoolCovenantRef: "covenant-1", + } + if l.Amount != 1000 || l.CreditorReachID != "reach:cred" || + !l.SecuredAtFounding || l.CoverPoolCovenantRef != "covenant-1" { + t.Errorf("Lien fields: %+v", l) + } + // A lien with no Cover Pool covenant backing (empty ref) is valid. + l2 := types.Lien{Amount: 500, CreditorReachID: "reach:c", SecuredAtFounding: false} + if l2.CoverPoolCovenantRef != "" { + t.Errorf("Lien2 CoverPoolCovenantRef = %q, want empty", l2.CoverPoolCovenantRef) + } +} + +// TestSecessionTermsStruct asserts the SecessionTerms struct + its Validate +// method (non-zero + protocol-minimum-bounded). +func TestSecessionTermsStruct(t *testing.T) { + // Valid: exactly the protocol minimums. + valid := types.SecessionTerms{ + CoolingCoverActiveDays: types.CoolingSecessionCoverActiveDays, + CoolingNonCoverDays: types.CoolingSecessionNonCoverDays, + LienAuditRequired: true, + CovenantClearanceRequired: true, + } + if err := valid.Validate(); err != nil { + t.Errorf("valid SecessionTerms Validate: %v", err) + } + // Valid: tighter than the protocol minimum (longer cooling allowed). + tighter := types.SecessionTerms{ + CoolingCoverActiveDays: types.CoolingSecessionCoverActiveDays + 10, + CoolingNonCoverDays: types.CoolingSecessionNonCoverDays + 5, + } + if err := tighter.Validate(); err != nil { + t.Errorf("tighter SecessionTerms Validate: %v", err) + } + // Invalid: zero CoolingCoverActiveDays. + if err := (types.SecessionTerms{CoolingNonCoverDays: 14}).Validate(); err == nil { + t.Error("SecessionTerms with zero CoolingCoverActiveDays should fail Validate") + } + // Invalid: zero CoolingNonCoverDays. + if err := (types.SecessionTerms{CoolingCoverActiveDays: 21}).Validate(); err == nil { + t.Error("SecessionTerms with zero CoolingNonCoverDays should fail Validate") + } + // Invalid: CoolingCoverActiveDays below protocol minimum (looser). + loose := types.SecessionTerms{ + CoolingCoverActiveDays: types.CoolingSecessionCoverActiveDays - 1, + CoolingNonCoverDays: types.CoolingSecessionNonCoverDays, + } + if err := loose.Validate(); err == nil { + t.Error("SecessionTerms with CoolingCoverActiveDays below minimum should fail Validate (Chapter may tighten but not loosen)") + } + // Invalid: CoolingNonCoverDays below protocol minimum (looser). + loose2 := types.SecessionTerms{ + CoolingCoverActiveDays: types.CoolingSecessionCoverActiveDays, + CoolingNonCoverDays: types.CoolingSecessionNonCoverDays - 1, + } + if err := loose2.Validate(); err == nil { + t.Error("SecessionTerms with CoolingNonCoverDays below minimum should fail Validate (Chapter may tighten but not loosen)") + } +} + +// TestHashSecessionTermsDeterministic asserts HashSecessionTerms is +// deterministic (the same terms produce the same hash; different terms +// produce a different hash). This is the immutability pin: the +// SecessionTermsHash on a Chapter is the hash of its SecessionTerms JSON. +func TestHashSecessionTermsDeterministic(t *testing.T) { + t1 := types.SecessionTerms{CoolingCoverActiveDays: 21, CoolingNonCoverDays: 14} + t2 := types.SecessionTerms{CoolingCoverActiveDays: 21, CoolingNonCoverDays: 14} + if !bytesEqual(types.HashSecessionTerms(t1), types.HashSecessionTerms(t2)) { + t.Error("HashSecessionTerms not deterministic for equal terms") + } + t3 := types.SecessionTerms{CoolingCoverActiveDays: 31, CoolingNonCoverDays: 14} + if bytesEqual(types.HashSecessionTerms(t1), types.HashSecessionTerms(t3)) { + t.Error("HashSecessionTerms collided for different terms") + } +} + +// bytesEqual is a stdlib-only byte-slice equality helper (the types_test.go +// stays stdlib-only per G-024 — no bytes import needed for this trivial +// comparison). +func bytesEqual(a, b []byte) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} + +// --- P3 Params + GenesisState extension --------------------------------------- + +// TestDefaultParamsCooling asserts DefaultParams returns the protocol-minimum +// cooling defaults (CoolingSecessionCoverActiveDays / NonCoverDays). +func TestDefaultParamsCooling(t *testing.T) { + p := types.DefaultParams() + if p.DefaultCoolingCoverActiveDays != types.CoolingSecessionCoverActiveDays { + t.Errorf("DefaultCoolingCoverActiveDays = %d, want %d", + p.DefaultCoolingCoverActiveDays, types.CoolingSecessionCoverActiveDays) + } + if p.DefaultCoolingNonCoverDays != types.CoolingSecessionNonCoverDays { + t.Errorf("DefaultCoolingNonCoverDays = %d, want %d", + p.DefaultCoolingNonCoverDays, types.CoolingSecessionNonCoverDays) + } +} + +// TestDefaultGenesisStateChapters asserts DefaultGenesisState returns a +// non-nil empty Chapters slice. +func TestDefaultGenesisStateChapters(t *testing.T) { + gs := types.DefaultGenesisState() + if gs.Chapters == nil || len(gs.Chapters) != 0 { + t.Errorf("Default Chapters should be non-nil empty slice, got %v", gs.Chapters) + } +} + +// TestValidateGenesisRejectsChapterMissingParent asserts REQ-053: a Chapter +// (in the Chapters slice) with an empty ParentGuildID is REJECTED. +func TestValidateGenesisRejectsChapterMissingParent(t *testing.T) { + gs := types.GenesisState{ + Guilds: []types.Guild{{GuildID: "g1"}}, + Chapters: []types.Guild{{GuildID: "c1", IsChapter: true, ParentGuildID: ""}}, + } + bz, _ := json.Marshal(gs) + if err := types.ValidateGenesis(bz); err == nil { + t.Error("ValidateGenesis should reject Chapter with empty ParentGuildID (REQ-053)") + } +} + +// TestValidateGenesisRejectsChapterParentNotFound asserts REQ-053: a Chapter +// whose ParentGuildID does not reference an existing Guild is REJECTED. +func TestValidateGenesisRejectsChapterParentNotFound(t *testing.T) { + gs := types.GenesisState{ + Chapters: []types.Guild{{GuildID: "c1", IsChapter: true, ParentGuildID: "no-such-parent"}}, + } + bz, _ := json.Marshal(gs) + if err := types.ValidateGenesis(bz); err == nil { + t.Error("ValidateGenesis should reject Chapter with ParentGuildID not in genesis (REQ-053)") + } +} + +// TestValidateGenesisAcceptsChapterWithParent asserts a Chapter with a +// valid ParentGuildID (referencing an existing Guild) is accepted. +func TestValidateGenesisAcceptsChapterWithParent(t *testing.T) { + gs := types.GenesisState{ + Guilds: []types.Guild{{GuildID: "g1"}}, + Chapters: []types.Guild{{GuildID: "c1", IsChapter: true, ParentGuildID: "g1"}}, + HandPasses: []types.HandPass{{PassID: "p1", GuildID: "g1", FeeGrain: 0}}, + } + bz, _ := json.Marshal(gs) + if err := types.ValidateGenesis(bz); err != nil { + t.Errorf("ValidateGenesis should accept Chapter with valid parent, got: %v", err) + } +} + +// TestValidateGenesisRejectsChapterInGuildsSlice asserts a Guild in the +// Guilds slice with IsChapter=true is REJECTED (a Chapter must live in the +// Chapters slice — the split is for genesis validation clarity). +func TestValidateGenesisRejectsChapterInGuildsSlice(t *testing.T) { + gs := types.GenesisState{ + Guilds: []types.Guild{{GuildID: "g1", IsChapter: true}}, + } + bz, _ := json.Marshal(gs) + if err := types.ValidateGenesis(bz); err == nil { + t.Error("ValidateGenesis should reject a Chapter in the Guilds slice (move to Chapters)") + } +} + +// TestValidateGenesisRejectsNonChapterInChaptersSlice asserts a Guild in +// the Chapters slice with IsChapter=false is REJECTED. +func TestValidateGenesisRejectsNonChapterInChaptersSlice(t *testing.T) { + gs := types.GenesisState{ + Chapters: []types.Guild{{GuildID: "c1", IsChapter: false, ParentGuildID: "g1"}}, + } + bz, _ := json.Marshal(gs) + if err := types.ValidateGenesis(bz); err == nil { + t.Error("ValidateGenesis should reject a non-Chapter in the Chapters slice") + } +} + +// TestValidateGenesisRejectsDupChapterID asserts a duplicate guild-id across +// the Guilds + Chapters slices is REJECTED. +func TestValidateGenesisRejectsDupChapterID(t *testing.T) { + gs := types.GenesisState{ + Guilds: []types.Guild{{GuildID: "g1"}}, + Chapters: []types.Guild{{GuildID: "g1", IsChapter: true, ParentGuildID: "g1"}}, + } + bz, _ := json.Marshal(gs) + if err := types.ValidateGenesis(bz); err == nil { + t.Error("ValidateGenesis should reject duplicate guild-id across Guilds + Chapters") + } +} + // packageDir resolves a Go import path to its filesystem directory. func packageDir(t *testing.T, importPath string) string { t.Helper() diff --git a/x/stand/types/types.go b/x/stand/types/types.go index c5d0287..8c305be 100644 --- a/x/stand/types/types.go +++ b/x/stand/types/types.go @@ -50,6 +50,40 @@ func AllStandTypes() []StandType { } } +// IsHousehold reports whether a StandType is a Household (REQ-057). The +// x/guild OneTapExitStand handler (via the StandKeeper shim) consults this +// to assert one-tap exit is Household-only. By-ID-string boundary (G-003): +// the handler compares the stand-type string against "Household"; this +// helper is the type-level scaffold. +func IsHousehold(t StandType) bool { return t == StandHousehold } + +// IsConfederation reports whether a StandType is a Confederation (REQ-058). +// The x/guild DelegateConfederationVoice handler (via the StandKeeper shim) +// consults this to assert the named Stand is a Confederation before +// recording a delegation. By-ID-string boundary (G-003): the handler +// compares the stand-type string against "Confederation"; this helper is the +// type-level scaffold. +func IsConfederation(t StandType) bool { return t == StandConfederation } + +// ConfederationVoice is a Confederation Voice delegation record (REQ-058). +// One-Stand-one-Vote: each member Stand gets exactly 1 Voice in the +// Confederation's aggregate, regardless of size. ConfederationStandID + +// MemberStandID reference Stands by-ID-string (G-003). DelegateReachID +// references the Reach the member Stand's Voice is delegated to. +// DelegatedAt is the delegation timestamp (block time). +// +// NOTE: the x/guild keeper owns the persisted delegation record (the +// x/guild/types.ConfederationVoice struct is the persisted shape — same JSON +// field names so a value of one round-trips through the other). This +// x/stand/types struct is the type-level scaffold for the Confederation +// Voice aggregation logic landing in a later phase. +type ConfederationVoice struct { + ConfederationStandID string `json:"confederation_stand_id" yaml:"confederation_stand_id"` + MemberStandID string `json:"member_stand_id" yaml:"member_stand_id"` + DelegateReachID string `json:"delegate_reach_id" yaml:"delegate_reach_id"` + DelegatedAt int64 `json:"delegated_at" yaml:"delegated_at"` +} + // Stand is a governed group holding a Vault (vision §11, REQ-016). // Modeled on Cosmos SDK x/group (a group of members with a decision policy // governing a Vault). admin-reach references a Reach ID (by-ID-string, G-003); diff --git a/x/stand/types/types_test.go b/x/stand/types/types_test.go index 3841e96..5469de9 100644 --- a/x/stand/types/types_test.go +++ b/x/stand/types/types_test.go @@ -249,6 +249,55 @@ func TestDefaultParams(t *testing.T) { _ = types.DefaultParams() // no panics } +// --- P3 Household / Confederation helpers (REQ-057, REQ-058) ------------------ + +// TestIsHousehold asserts IsHousehold returns true only for StandHousehold. +func TestIsHousehold(t *testing.T) { + if !types.IsHousehold(types.StandHousehold) { + t.Error("IsHousehold(Household) should be true") + } + for _, s := range types.AllStandTypes() { + if s == types.StandHousehold { + continue + } + if types.IsHousehold(s) { + t.Errorf("IsHousehold(%q) should be false", s) + } + } +} + +// TestIsConfederation asserts IsConfederation returns true only for +// StandConfederation. +func TestIsConfederation(t *testing.T) { + if !types.IsConfederation(types.StandConfederation) { + t.Error("IsConfederation(Confederation) should be true") + } + for _, s := range types.AllStandTypes() { + if s == types.StandConfederation { + continue + } + if types.IsConfederation(s) { + t.Errorf("IsConfederation(%q) should be false", s) + } + } +} + +// TestConfederationVoiceStruct asserts the ConfederationVoice struct carries +// the four required fields (ConfederationStandID, MemberStandID, +// DelegateReachID, DelegatedAt — REQ-058). +func TestConfederationVoiceStruct(t *testing.T) { + v := types.ConfederationVoice{ + ConfederationStandID: "conf-1", + MemberStandID: "mem-1", + DelegateReachID: "reach:delegate", + DelegatedAt: 12345, + } + if v.ConfederationStandID != "conf-1" || v.MemberStandID != "mem-1" || + v.DelegateReachID != "reach:delegate" || v.DelegatedAt != 12345 { + t.Errorf("ConfederationVoice fields: %+v", v) + } +} + // --- Lexicon assertion (REQ-012) ------------------------------------------------- // TestLexiconNoBannedTermsInStandPackage scans every non-test .go file in