9abda8d01e
P5 (final execution phase) of v0.7 delivers REQ-056, REQ-059, REQ-064, REQ-066: x/cover — Bill of Rights ceremony (REQ-056) + Pier Selection (REQ-066): MsgCounselReviewBillOfRights (bonded Counsel, Staked=true gate); MsgSelectPier (Guild Council + GuildKeeper shim + PierSelectionIndex); MsgRevokePierSelection (supermajority + Counsel witness); PierSelectionIndex + PierSelectionRecord structs; bill_review/pier_selection/pier_index stores. x/guild — Secession cooling (REQ-064) + Stand->Pier (REQ-059, D-074): MsgInitiateSecession (SecessionStartedAt + lien audit); MsgCompleteSecession (21d Cover-active / 14d non-Cover cooling + lien audit + covenant clearance + pro-rata settlement event); MsgEscalateStandToPier (10M Grain-cents D-074 threshold, soft upgrade); MsgAcceptPierInvitation; Guild.SecessionStartedAt + SecededAt + Lien.Cleared additive; LOCAL StandPierEscalationAnnualPass VolumeCents const (G-003 local-const mirror of x/stand canonical). x/stand — StandPierEscalationAnnualPassVolumeCents=10000000 canonical const. Coverage: cover/keeper 94.6%, guild/keeper 94.1%, stand/types 100%. G-006/G-028 intact. go.mod/go.sum diff EMPTY. go vet clean. Lexicon green. ---ci--- project: oy phase: 5 milestone: v0.7 status: execute ---/ci---
547 lines
24 KiB
Go
547 lines
24 KiB
Go
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
|
|
}
|
|
|
|
// --- v0.7 P5: Secession + Stand→Pier escalation handlers (REQ-064, REQ-059) ---
|
|
//
|
|
// (REQ-064 secession cooling enforcement, REQ-059/D-074 Stand→Pier boundary.)
|
|
// The four handlers exercise the secession lifecycle (initiate + complete
|
|
// with the Cover-active 21d / non-Cover 14d cooling) + the Stand→Pier
|
|
// escalation soft-upgrade (eligibility flag + acceptance).
|
|
|
|
// InitiateSecession initiates a Chapter's secession (REQ-064). The handler
|
|
// enforces:
|
|
// 1. ValidateBasic (stateless).
|
|
// 2. The Guild must exist + be a Chapter (IsChapter=true). A non-Chapter
|
|
// Guild REJECTS (a Parent Guild does not secede).
|
|
// 3. The Chapter must not have already initiated (SecessionStartedAt == 0;
|
|
// a second initiation REJECTS — use CompleteSecession or reset).
|
|
// 4. Set SecessionStartedAt = now. Persist the Chapter.
|
|
// 5. Invoke the lien audit (CheckLiensCleared — true if every lien has
|
|
// Cleared=true or Amount=0). The audit result is returned in the
|
|
// response (LienAuditPassed) for simtest assertion; the handler still
|
|
// records SecessionStartedAt so the cooling clock starts regardless
|
|
// (the lien audit is re-checked at completion — an outstanding lien at
|
|
// completion REJECTS).
|
|
// 6. Emit guild.secession_initiated.
|
|
func (s msgServer) InitiateSecession(ctx interface{}, msg *types.MsgInitiateSecession) (*types.MsgInitiateSecessionResponse, error) {
|
|
if err := msg.ValidateBasic(); err != nil {
|
|
return nil, err
|
|
}
|
|
sdkCtx := unwrapCtx(ctx)
|
|
|
|
g, ok := s.Keeper.GetGuild(sdkCtx, msg.GuildID)
|
|
if !ok {
|
|
return nil, fmt.Errorf("guild: guild %q not found (InitiateSecession rejected)", msg.GuildID)
|
|
}
|
|
if !g.IsChapter {
|
|
return nil, fmt.Errorf("guild: guild %q is not a Chapter (a Parent Guild does not secede — REQ-064)", msg.GuildID)
|
|
}
|
|
if g.SecessionStartedAt > 0 {
|
|
return nil, fmt.Errorf("guild: chapter %q already initiated secession (SecessionStartedAt=%d — use CompleteSecession — REQ-064)", msg.GuildID, g.SecessionStartedAt)
|
|
}
|
|
|
|
g.SecessionStartedAt = sdkCtx.BlockTime().Unix()
|
|
s.Keeper.SetGuild(sdkCtx, g)
|
|
|
|
lienAuditPassed := s.Keeper.CheckLiensCleared(sdkCtx, msg.GuildID)
|
|
|
|
sdkCtx.EventManager().EmitEvent(sdk.NewEvent(
|
|
"guild.secession_initiated",
|
|
sdk.NewAttribute("guild_id", msg.GuildID),
|
|
sdk.NewAttribute("secession_started_at", fmt.Sprintf("%d", g.SecessionStartedAt)),
|
|
sdk.NewAttribute("lien_audit_passed", fmt.Sprintf("%v", lienAuditPassed)),
|
|
))
|
|
return &types.MsgInitiateSecessionResponse{LienAuditPassed: lienAuditPassed}, nil
|
|
}
|
|
|
|
// CompleteSecession completes a Chapter's secession (REQ-064). The handler
|
|
// enforces:
|
|
// 1. ValidateBasic (stateless).
|
|
// 2. The Guild must exist + be a Chapter.
|
|
// 3. SecessionStartedAt > 0 (secession was initiated).
|
|
// 4. Cooling check (REQ-064): compute coolingSeconds based on whether the
|
|
// Chapter is Cover-active (any lien references a Cover Pool covenant).
|
|
// Cover-active: CoolingSecessionCoverActiveDays*86400 (21d). Non-Cover:
|
|
// CoolingSecessionNonCoverDays*86400 (14d). Check now >=
|
|
// SecessionStartedAt + coolingSeconds. If not, REJECT with "secession
|
|
// cooling not elapsed".
|
|
// 5. Lien audit: CheckLiensCleared must return true (all liens Cleared or
|
|
// Amount=0). If not, REJECT.
|
|
// 6. Covenant clearance: the Msg's CovenantClearancePassed must be true
|
|
// (simtest-grade — the live Cover Pool covenant clearance is a v0.8+
|
|
// concern). If not, REJECT.
|
|
// 7. Pro-rata Cover-Fee settlement: emit guild.pro_rata_settlement with
|
|
// the ProRataSettlementGrain from the Msg (the actual settlement is a
|
|
// v0.8+ Grain-ledger concern).
|
|
// 8. Set SecededAt = now. Persist the Chapter. Emit
|
|
// guild.secession_completed.
|
|
func (s msgServer) CompleteSecession(ctx interface{}, msg *types.MsgCompleteSecession) (*types.MsgCompleteSecessionResponse, error) {
|
|
if err := msg.ValidateBasic(); err != nil {
|
|
return nil, err
|
|
}
|
|
sdkCtx := unwrapCtx(ctx)
|
|
|
|
g, ok := s.Keeper.GetGuild(sdkCtx, msg.GuildID)
|
|
if !ok {
|
|
return nil, fmt.Errorf("guild: guild %q not found (CompleteSecession rejected)", msg.GuildID)
|
|
}
|
|
if !g.IsChapter {
|
|
return nil, fmt.Errorf("guild: guild %q is not a Chapter (a Parent Guild does not secede — REQ-064)", msg.GuildID)
|
|
}
|
|
if g.SecessionStartedAt == 0 {
|
|
return nil, fmt.Errorf("guild: chapter %q has not initiated secession (SecessionStartedAt=0 — call InitiateSecession first — REQ-064)", msg.GuildID)
|
|
}
|
|
|
|
// Cooling check (REQ-064): compute coolingSeconds based on whether the
|
|
// Chapter is Cover-active (any lien references a Cover Pool covenant).
|
|
// Cover-active: 21d. Non-Cover: 14d.
|
|
var coolingSeconds int64
|
|
coverActive := s.Keeper.ChapterIsCoverActive(sdkCtx, msg.GuildID)
|
|
if coverActive {
|
|
coolingSeconds = int64(types.CoolingSecessionCoverActiveDays) * 24 * 60 * 60
|
|
} else {
|
|
coolingSeconds = int64(types.CoolingSecessionNonCoverDays) * 24 * 60 * 60
|
|
}
|
|
now := sdkCtx.BlockTime().Unix()
|
|
if now-g.SecessionStartedAt < coolingSeconds {
|
|
return nil, fmt.Errorf("guild: secession cooling not elapsed (now=%d, SecessionStartedAt=%d, cooling=%d seconds, elapsed=%d — REQ-064)",
|
|
now, g.SecessionStartedAt, coolingSeconds, now-g.SecessionStartedAt)
|
|
}
|
|
|
|
// Lien audit: all liens must be Cleared (or Amount=0).
|
|
if !s.Keeper.CheckLiensCleared(sdkCtx, msg.GuildID) {
|
|
return nil, fmt.Errorf("guild: lien audit failed — outstanding liens remain (REQ-064 — all Good-Standing Liens must be cleared before secession completes)")
|
|
}
|
|
|
|
// Covenant clearance: the Msg's CovenantClearancePassed must be true
|
|
// (simtest-grade — the live Cover Pool covenant clearance is a v0.8+
|
|
// concern).
|
|
if !msg.CovenantClearancePassed {
|
|
return nil, fmt.Errorf("guild: covenant clearance failed (CovenantClearancePassed=false — REQ-064 — all Cover Pool covenants must be cleared before secession completes)")
|
|
}
|
|
|
|
// Pro-rata Cover-Fee settlement: emit the event with the settlement
|
|
// amount (the actual settlement is a v0.8+ Grain-ledger concern — the
|
|
// simtest-grade ProRataSettlementGrain on the Msg carries the amount).
|
|
sdkCtx.EventManager().EmitEvent(sdk.NewEvent(
|
|
"guild.pro_rata_settlement",
|
|
sdk.NewAttribute("guild_id", msg.GuildID),
|
|
sdk.NewAttribute("pro_rata_settlement_grain", fmt.Sprintf("%d", msg.ProRataSettlementGrain)),
|
|
))
|
|
|
|
// Mark the Chapter as seceded.
|
|
g.SecededAt = now
|
|
s.Keeper.SetGuild(sdkCtx, g)
|
|
|
|
sdkCtx.EventManager().EmitEvent(sdk.NewEvent(
|
|
"guild.secession_completed",
|
|
sdk.NewAttribute("guild_id", msg.GuildID),
|
|
sdk.NewAttribute("seceded_at", fmt.Sprintf("%d", g.SecededAt)),
|
|
sdk.NewAttribute("cooling_seconds", fmt.Sprintf("%d", coolingSeconds)),
|
|
sdk.NewAttribute("cover_active", fmt.Sprintf("%v", coverActive)),
|
|
))
|
|
return &types.MsgCompleteSecessionResponse{
|
|
CoolingSeconds: coolingSeconds,
|
|
ProRataSettlementGrain: msg.ProRataSettlementGrain,
|
|
}, nil
|
|
}
|
|
|
|
// EscalateStandToPier escalates a Stand to Pier-eligibility (REQ-059,
|
|
// D-074). The handler enforces:
|
|
// 1. ValidateBasic (stateless — non-empty StandID + AnnualPassVolumeCents
|
|
// > 0).
|
|
// 2. Compare AnnualPassVolumeCents against
|
|
// StandPierEscalationAnnualPassVolumeCents (the D-074 const, imported
|
|
// from x/stand/types — G-003-clean: consts are not structs).
|
|
// 3. If AnnualPassVolumeCents > the const: set the Stand-Pier-eligible
|
|
// flag (pier_eligible/ store: StandID -> true). Soft upgrade, not a
|
|
// ban — the Stand may decline (the flag is set + the event is emitted,
|
|
// but no enforcement follows; the Stand must separately accept via
|
|
// MsgAcceptPierInvitation). If the volume does NOT exceed the const:
|
|
// the flag is NOT set (the response PierEligible=false; the event is
|
|
// still emitted for observability).
|
|
// 4. Emit guild.stand_pier_eligible (with PierEligible=true) OR
|
|
// guild.stand_pier_escalation_below_threshold (with PierEligible=false).
|
|
func (s msgServer) EscalateStandToPier(ctx interface{}, msg *types.MsgEscalateStandToPier) (*types.MsgEscalateStandToPierResponse, error) {
|
|
if err := msg.ValidateBasic(); err != nil {
|
|
return nil, err
|
|
}
|
|
sdkCtx := unwrapCtx(ctx)
|
|
|
|
pierEligible := msg.AnnualPassVolumeCents > types.StandPierEscalationAnnualPassVolumeCents
|
|
if pierEligible {
|
|
s.Keeper.SetStandPierEligible(sdkCtx, msg.StandID, true)
|
|
sdkCtx.EventManager().EmitEvent(sdk.NewEvent(
|
|
"guild.stand_pier_eligible",
|
|
sdk.NewAttribute("stand_id", msg.StandID),
|
|
sdk.NewAttribute("annual_pass_volume_cents", fmt.Sprintf("%d", msg.AnnualPassVolumeCents)),
|
|
sdk.NewAttribute("threshold_cents", fmt.Sprintf("%d", types.StandPierEscalationAnnualPassVolumeCents)),
|
|
))
|
|
} else {
|
|
sdkCtx.EventManager().EmitEvent(sdk.NewEvent(
|
|
"guild.stand_pier_escalation_below_threshold",
|
|
sdk.NewAttribute("stand_id", msg.StandID),
|
|
sdk.NewAttribute("annual_pass_volume_cents", fmt.Sprintf("%d", msg.AnnualPassVolumeCents)),
|
|
sdk.NewAttribute("threshold_cents", fmt.Sprintf("%d", types.StandPierEscalationAnnualPassVolumeCents)),
|
|
))
|
|
}
|
|
return &types.MsgEscalateStandToPierResponse{PierEligible: pierEligible}, nil
|
|
}
|
|
|
|
// AcceptPierInvitation records a Stand's acceptance of a Pier invitation
|
|
// (REQ-059, D-074). The handler enforces:
|
|
// 1. ValidateBasic (stateless).
|
|
// 2. The Stand must be Pier-eligible (the flag set by
|
|
// MsgEscalateStandToPier). If not, REJECT.
|
|
// 3. Record the acceptance (pier_accepted/ store: StandID -> true). Emit
|
|
// guild.stand_pier_accepted.
|
|
func (s msgServer) AcceptPierInvitation(ctx interface{}, msg *types.MsgAcceptPierInvitation) (*types.MsgAcceptPierInvitationResponse, error) {
|
|
if err := msg.ValidateBasic(); err != nil {
|
|
return nil, err
|
|
}
|
|
sdkCtx := unwrapCtx(ctx)
|
|
|
|
if !s.Keeper.GetStandPierEligible(sdkCtx, msg.StandID) {
|
|
return nil, fmt.Errorf("guild: stand %q is not Pier-eligible (call EscalateStandToPier first — REQ-059/D-074)", msg.StandID)
|
|
}
|
|
|
|
s.Keeper.SetStandPierAccepted(sdkCtx, msg.StandID, true)
|
|
|
|
sdkCtx.EventManager().EmitEvent(sdk.NewEvent(
|
|
"guild.stand_pier_accepted",
|
|
sdk.NewAttribute("stand_id", msg.StandID),
|
|
))
|
|
return &types.MsgAcceptPierInvitationResponse{}, nil
|
|
}
|