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 }