Files
openyield/x/services/keeper/msg_server.go
T
cloudinit-bot 6c34650a0d
docs-build / go test ./... (lexicon firewall + all x/* tests) (push) Has been cancelled
docs-build / mkdocs build (docs site artifact) (push) Has been cancelled
Merge milestone/v0.5-bearers-runtime into main (v0.5 Bearers Runtime feature milestone release)
v0.5 Bearers Runtime — 7 runtime REQs (REQ-033..039) shipped as feature.
8 modules promoted to runtime (MsgServer + simtest). cosmos-sdk v0.50.8 +
ibc-go v8.2.1 added (G-006 controlled exception). G-003 + locked-const
firewalls intact. 8 keeper packages ≥80% coverage. 5 GRILL decisions
ratified; 8 binding fixes landed; 5 P1+ flagged for v0.6+.

---ci---
project: oy
phase: 8
milestone: v0.5
status: complete
requirements:
  covered: [REQ-033, REQ-034, REQ-035, REQ-036, REQ-037, REQ-038, REQ-039]
  partial: []
---/ci---
2026-08-18 03:42:01 +00:00

513 lines
20 KiB
Go

package keeper
// msg_server.go implements the services module's MsgServer (P5-02-01,
// REQ-037; G-023 ownership split: cosmos-engineer scaffolds the file
// structure + method signatures; backend-engineer implements the handler
// logic bodies). The MsgServer wraps the Keeper + the WindowKeeper and
// VaultKeeper expected-keeper shims (already on the Keeper).
//
// Each method returns a (*Response, error). Handler state-machine ordering
// is enforced: ValidateBasic → keeper authz (window-grant A-552) → state
// mutation → ctx.EventManager().EmitEvent.
//
// Handler set (REQ-037):
// Lifecycle (kind-agnostic):
// - RegisterService: registers a new ServiceInfo (status=Pending).
// Asserts the window-id references an Active Window via the
// WindowKeeper shim (A-552). Idempotent: service-id must not already
// exist. Persists the ServiceInfo + the per-kind metadata record
// for the ServiceKind on the message.
// - ActivateService: Pending → Active. Window-grant still Active.
// - SuspendService: Active → Suspended. Window-grant still Active.
// - RevokeService: any → Revoked (terminal). Idempotent reject on
// already-Revoked (no double-effect). Window-grant still Active
// (A-552: revocation of a Window-revoked service is also a
// Window-violation).
// Per-kind (A-551 typed dispatch — one Msg per ServiceKind):
// - IssueCareGrant (Care) — window-grant A-552 + kind=Care + persists
// the CareService metadata.
// - ActivateSIM (SIM) — window-grant A-552 + kind=SIM + persists
// the SIMService metadata.
// - ProvisionVault (Vault) — window-grant A-552 + kind=Vault + delegates
// the storage-quota-grain provisioning to the VaultKeeper shim (A-553).
// A nil VaultKeeper shim REJECTS the provisioning.
// - BindMailbox (Mail) — window-grant A-552 + kind=Mail + persists
// the MailService metadata.
//
// Nil-shim behavior (simtest wiring): a nil WindowKeeper shim skips the
// window-grant Active check (the handler still mutates state — the
// simtest documents the wiring contract). A nil VaultKeeper shim REJECTS
// MsgProvisionVault (the VaultService requires a real vault keeper —
// a nil shim is a wiring error, not a simtest skip path).
import (
"fmt"
sdk "github.com/cosmos/cosmos-sdk/types"
"github.com/oy/openyield/x/services/types"
)
// msgServer is the concrete MsgServer implementation wrapping the Keeper.
type msgServer struct {
Keeper
}
// NewMsgServerImpl returns the services 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("services: expected sdk.Context, got %T", ctx))
}
// assertWindowActive consults the WindowKeeper shim to assert the named
// window-id still references an Active Window (A-552 window-grant-on-
// every-op). Returns nil if the window is Active OR the WindowKeeper shim
// is nil (simtest wiring skip); returns an error if the shim is non-nil
// and reports a non-Active status or an error (treated as not-Active).
func (s msgServer) assertWindowActive(windowID, op string) error {
if s.Keeper.windowKeeper == nil {
// Simtest wiring: a nil WindowKeeper shim skips the A-552 check.
return nil
}
status, err := s.Keeper.windowKeeper.GetWindowStatus(windowID)
if err != nil {
return fmt.Errorf("services: window-grant check for %s on window %q failed: %w (A-552)", op, windowID, err)
}
if status != types.WindowStatusActive {
return fmt.Errorf("services: window %q is %q; %s rejected (A-552 window-grant-on-every-op)", windowID, status, op)
}
return nil
}
// --- RegisterService -----------------------------------------------------
// RegisterService registers a new ServiceInfo (status=Pending). The
// handler enforces:
// 1. ValidateBasic (stateless).
// 2. Idempotency: service-id must not already exist.
// 3. A-552: the window-id must reference an Active Window via the
// WindowKeeper shim (the authority boundary; checked on every op,
// not just registration). A nil shim skips the check (simtest
// wiring); a non-nil shim reporting a non-Active status REJECTS the
// registration (the service is NOT created).
// 4. The per-kind metadata record is created for the ServiceKind on
// the message (the kind is fixed at registration; A-551 typed
// dispatch — the per-kind handlers later enforce the kind matches).
//
// On success the ServiceInfo is persisted with status=Pending, the
// per-kind metadata record is created (with empty operational fields
// — the per-kind handlers populate them), and an event is emitted.
func (s msgServer) RegisterService(ctx interface{}, msg *types.MsgRegisterService) (*types.MsgRegisterServiceResponse, error) {
if err := msg.ValidateBasic(); err != nil {
return nil, err
}
sdkCtx := unwrapCtx(ctx)
// Idempotency: service-id must not already exist.
if _, ok := s.Keeper.GetService(sdkCtx, msg.ServiceID); ok {
return nil, fmt.Errorf("services: service %q already exists", msg.ServiceID)
}
// A-552: window-id must reference an Active Window (checked on
// EVERY op, including registration).
if err := s.assertWindowActive(msg.WindowID, "RegisterService"); err != nil {
return nil, err
}
// Persist the ServiceInfo (status=Pending).
info := types.ServiceInfo{
ServiceID: msg.ServiceID,
Kind: msg.Kind,
OperatorReachID: msg.OperatorReachID,
Name: msg.Name,
Status: types.ServicePending,
WindowID: msg.WindowID,
}
s.Keeper.SetService(sdkCtx, info)
// Create the per-kind metadata record (empty operational fields —
// the per-kind handlers populate them).
switch msg.Kind {
case types.KindCare:
s.Keeper.SetCareService(sdkCtx, types.CareService{CareID: msg.ServiceID})
case types.KindSIM:
s.Keeper.SetSIMService(sdkCtx, types.SIMService{SIMID: msg.ServiceID})
case types.KindVault:
s.Keeper.SetVaultService(sdkCtx, types.VaultService{VaultID: msg.ServiceID})
case types.KindMail:
s.Keeper.SetMailService(sdkCtx, types.MailService{MailID: msg.ServiceID})
}
sdkCtx.EventManager().EmitEvent(sdk.NewEvent(
"services.service_registered",
sdk.NewAttribute("service_id", msg.ServiceID),
sdk.NewAttribute("kind", string(msg.Kind)),
sdk.NewAttribute("operator_reach_id", msg.OperatorReachID),
sdk.NewAttribute("window_id", msg.WindowID),
sdk.NewAttribute("status", string(types.ServicePending)),
))
return &types.MsgRegisterServiceResponse{}, nil
}
// --- ActivateService ----------------------------------------------------
// ActivateService transitions a service Pending → Active. The handler
// enforces:
// 1. ValidateBasic (stateless).
// 2. The service must exist.
// 3. The source status must be Pending (ValidServiceTransition(Pending,
// Active) — the lifecycle gate).
// 4. A-552: the window-id on the existing service must still reference
// an Active Window (a revoked/expired Window invalidates the
// activation).
//
// On success the status is transitioned to Active and an event is emitted.
func (s msgServer) ActivateService(ctx interface{}, msg *types.MsgActivateService) (*types.MsgActivateServiceResponse, error) {
if err := msg.ValidateBasic(); err != nil {
return nil, err
}
sdkCtx := unwrapCtx(ctx)
info, ok := s.Keeper.GetService(sdkCtx, msg.ServiceID)
if !ok {
return nil, fmt.Errorf("services: service %q not found", msg.ServiceID)
}
if !types.ValidServiceTransition(info.Status, types.ServiceActive) {
return nil, fmt.Errorf("services: service %q status %q cannot transition to Active (REQ-037 lifecycle)", msg.ServiceID, info.Status)
}
if err := s.assertWindowActive(info.WindowID, "ActivateService"); err != nil {
return nil, err
}
info.Status = types.ServiceActive
s.Keeper.SetService(sdkCtx, info)
sdkCtx.EventManager().EmitEvent(sdk.NewEvent(
"services.service_activated",
sdk.NewAttribute("service_id", msg.ServiceID),
sdk.NewAttribute("status", string(types.ServiceActive)),
))
return &types.MsgActivateServiceResponse{}, nil
}
// --- SuspendService -----------------------------------------------------
// SuspendService transitions a service Active → Suspended. The handler
// enforces:
// 1. ValidateBasic (stateless).
// 2. The service must exist.
// 3. The source status must be Active (ValidServiceTransition(Active,
// Suspended) — the lifecycle gate).
// 4. A-552: the window-id on the existing service must still reference
// an Active Window.
//
// On success the status is transitioned to Suspended and an event is
// emitted.
func (s msgServer) SuspendService(ctx interface{}, msg *types.MsgSuspendService) (*types.MsgSuspendServiceResponse, error) {
if err := msg.ValidateBasic(); err != nil {
return nil, err
}
sdkCtx := unwrapCtx(ctx)
info, ok := s.Keeper.GetService(sdkCtx, msg.ServiceID)
if !ok {
return nil, fmt.Errorf("services: service %q not found", msg.ServiceID)
}
if !types.ValidServiceTransition(info.Status, types.ServiceSuspended) {
return nil, fmt.Errorf("services: service %q status %q cannot transition to Suspended (REQ-037 lifecycle)", msg.ServiceID, info.Status)
}
if err := s.assertWindowActive(info.WindowID, "SuspendService"); err != nil {
return nil, err
}
info.Status = types.ServiceSuspended
s.Keeper.SetService(sdkCtx, info)
sdkCtx.EventManager().EmitEvent(sdk.NewEvent(
"services.service_suspended",
sdk.NewAttribute("service_id", msg.ServiceID),
sdk.NewAttribute("status", string(types.ServiceSuspended)),
))
return &types.MsgSuspendServiceResponse{}, nil
}
// --- RevokeService -----------------------------------------------------
// RevokeService transitions a service to Revoked (terminal). The
// handler enforces:
// 1. ValidateBasic (stateless).
// 2. The service must exist.
// 3. The service must not already be Revoked (idempotent reject — no
// double-effect).
// 4. A-552: the window-id on the existing service must still reference
// an Active Window (a revoked Window invalidates the revocation
// too — mirroring the grantor-authorized revoke path; the simtest
// wiring uses a nil WindowKeeper to skip this check on the
// Watcher-quorum revoke path).
// 5. The transition gate (ValidServiceTransition — any source → Revoked
// is permitted except Revoked itself).
//
// On success the status is transitioned to Revoked (terminal) and an
// event is emitted.
func (s msgServer) RevokeService(ctx interface{}, msg *types.MsgRevokeService) (*types.MsgRevokeServiceResponse, error) {
if err := msg.ValidateBasic(); err != nil {
return nil, err
}
sdkCtx := unwrapCtx(ctx)
info, ok := s.Keeper.GetService(sdkCtx, msg.ServiceID)
if !ok {
return nil, fmt.Errorf("services: service %q not found", msg.ServiceID)
}
// Idempotent reject: a Revoked service cannot be re-revoked.
if info.Status == types.ServiceRevoked {
return nil, fmt.Errorf("services: service %q already revoked (idempotent reject — no double-effect)", msg.ServiceID)
}
if err := s.assertWindowActive(info.WindowID, "RevokeService"); err != nil {
return nil, err
}
if !types.ValidServiceTransition(info.Status, types.ServiceRevoked) {
return nil, fmt.Errorf("services: service %q status %q cannot transition to Revoked (REQ-037 lifecycle)", msg.ServiceID, info.Status)
}
info.Status = types.ServiceRevoked
s.Keeper.SetService(sdkCtx, info)
sdkCtx.EventManager().EmitEvent(sdk.NewEvent(
"services.service_revoked",
sdk.NewAttribute("service_id", msg.ServiceID),
sdk.NewAttribute("status", string(types.ServiceRevoked)),
))
return &types.MsgRevokeServiceResponse{}, nil
}
// --- IssueCareGrant (Care — A-551 typed dispatch) ---------------------
// IssueCareGrant issues a community-care grant against a Care service
// (ServiceKind=Care). The handler enforces:
// 1. ValidateBasic (stateless).
// 2. The service must exist.
// 3. A-551 typed dispatch: the service Kind must be Care (NOT a generic
// dispatch — a kind mismatch is a runtime reject).
// 4. A-552: the window-id on the existing service must still reference
// an Active Window (window-grant-on-every-op; a revoked Window
// invalidates the per-kind op).
// 5. The CareService metadata is updated with the care-kind (the
// per-kind state).
//
// On success the CareService metadata is persisted and an event is
// emitted.
func (s msgServer) IssueCareGrant(ctx interface{}, msg *types.MsgIssueCareGrant) (*types.MsgIssueCareGrantResponse, error) {
if err := msg.ValidateBasic(); err != nil {
return nil, err
}
sdkCtx := unwrapCtx(ctx)
info, ok := s.Keeper.GetService(sdkCtx, msg.ServiceID)
if !ok {
return nil, fmt.Errorf("services: service %q not found", msg.ServiceID)
}
// A-551 typed dispatch: kind must be Care.
if info.Kind != types.KindCare {
return nil, fmt.Errorf("services: service %q kind %q is not Care (IssueCareGrant is the Care typed dispatch — A-551)", msg.ServiceID, info.Kind)
}
if err := s.assertWindowActive(info.WindowID, "IssueCareGrant"); err != nil {
return nil, err
}
// Update the CareService per-kind metadata with the care-kind.
care, _ := s.Keeper.GetCareService(sdkCtx, msg.ServiceID)
care.CareID = msg.ServiceID
care.CareKind = msg.CareKind
s.Keeper.SetCareService(sdkCtx, care)
sdkCtx.EventManager().EmitEvent(sdk.NewEvent(
"services.care_grant_issued",
sdk.NewAttribute("service_id", msg.ServiceID),
sdk.NewAttribute("care_kind", msg.CareKind),
sdk.NewAttribute("grant_recipient_reach_id", msg.GrantRecipientReachID),
))
return &types.MsgIssueCareGrantResponse{}, nil
}
// --- ActivateSIM (SIM — A-551 typed dispatch) -----------------------
// ActivateSIM activates a connectivity SIM against a SIM service
// (ServiceKind=SIM). The handler enforces:
// 1. ValidateBasic (stateless).
// 2. The service must exist.
// 3. A-551 typed dispatch: the service Kind must be SIM.
// 4. A-552: the window-id on the existing service must still reference
// an Active Window.
// 5. The SIMService metadata is updated with the carrier.
//
// On success the SIMService metadata is persisted and an event is
// emitted.
func (s msgServer) ActivateSIM(ctx interface{}, msg *types.MsgActivateSIM) (*types.MsgActivateSIMResponse, error) {
if err := msg.ValidateBasic(); err != nil {
return nil, err
}
sdkCtx := unwrapCtx(ctx)
info, ok := s.Keeper.GetService(sdkCtx, msg.ServiceID)
if !ok {
return nil, fmt.Errorf("services: service %q not found", msg.ServiceID)
}
// A-551 typed dispatch: kind must be SIM.
if info.Kind != types.KindSIM {
return nil, fmt.Errorf("services: service %q kind %q is not SIM (ActivateSIM is the SIM typed dispatch — A-551)", msg.ServiceID, info.Kind)
}
if err := s.assertWindowActive(info.WindowID, "ActivateSIM"); err != nil {
return nil, err
}
// Update the SIMService per-kind metadata with the carrier.
sim, _ := s.Keeper.GetSIMService(sdkCtx, msg.ServiceID)
sim.SIMID = msg.ServiceID
sim.Carrier = msg.Carrier
s.Keeper.SetSIMService(sdkCtx, sim)
sdkCtx.EventManager().EmitEvent(sdk.NewEvent(
"services.sim_activated",
sdk.NewAttribute("service_id", msg.ServiceID),
sdk.NewAttribute("carrier", msg.Carrier),
sdk.NewAttribute("recipient_reach_id", msg.RecipientReachID),
))
return &types.MsgActivateSIMResponse{}, nil
}
// --- ProvisionVault (Vault — A-551 typed dispatch, A-553 VaultKeeper shim) --
// ProvisionVault provisions storage-quota-grain against a Vault service
// (ServiceKind=Vault; A-553: delegates to the VaultKeeper shim). The
// handler enforces:
// 1. ValidateBasic (stateless).
// 2. The service must exist.
// 3. A-551 typed dispatch: the service Kind must be Vault.
// 4. A-552: the window-id on the existing service must still reference
// an Active Window.
// 5. A-553: the VaultKeeper shim must be non-nil (a nil shim is a wiring
// error — the VaultService requires a real vault keeper). The shim
// is delegated the storage-quota-grain provisioning by-ID-string.
// A non-nil error from the shim REJECTS the provisioning (the
// VaultService metadata is NOT updated).
// 6. On shim success, the VaultService metadata is updated with the
// storage-quota-grain.
//
// On success the VaultService metadata is persisted and an event is
// emitted.
func (s msgServer) ProvisionVault(ctx interface{}, msg *types.MsgProvisionVault) (*types.MsgProvisionVaultResponse, error) {
if err := msg.ValidateBasic(); err != nil {
return nil, err
}
sdkCtx := unwrapCtx(ctx)
info, ok := s.Keeper.GetService(sdkCtx, msg.ServiceID)
if !ok {
return nil, fmt.Errorf("services: service %q not found", msg.ServiceID)
}
// A-551 typed dispatch: kind must be Vault.
if info.Kind != types.KindVault {
return nil, fmt.Errorf("services: service %q kind %q is not Vault (ProvisionVault is the Vault typed dispatch — A-551)", msg.ServiceID, info.Kind)
}
if err := s.assertWindowActive(info.WindowID, "ProvisionVault"); err != nil {
return nil, err
}
// A-553: delegate to the VaultKeeper shim. A nil shim is a wiring
// error (the VaultService requires a real vault keeper — a nil shim
// is NOT a simtest skip path; the simtest wires a stub vault keeper).
if s.Keeper.vaultKeeper == nil {
return nil, fmt.Errorf("services: vault keeper not wired (ProvisionVault rejected — A-553 VaultService provisioning requires a real vault keeper)")
}
if err := s.Keeper.vaultKeeper.ProvisionVault(msg.ServiceID, msg.StorageQuotaGrain); err != nil {
return nil, fmt.Errorf("services: vault keeper provisioning for service %q: %w (A-553)", msg.ServiceID, err)
}
// Update the VaultService per-kind metadata with the storage-quota-grain.
vault, _ := s.Keeper.GetVaultService(sdkCtx, msg.ServiceID)
vault.VaultID = msg.ServiceID
vault.StorageQuotaGrain = msg.StorageQuotaGrain
s.Keeper.SetVaultService(sdkCtx, vault)
sdkCtx.EventManager().EmitEvent(sdk.NewEvent(
"services.vault_provisioned",
sdk.NewAttribute("service_id", msg.ServiceID),
sdk.NewAttribute("storage_quota_grain", fmt.Sprintf("%d", msg.StorageQuotaGrain)),
))
return &types.MsgProvisionVaultResponse{}, nil
}
// --- BindMailbox (Mail — A-551 typed dispatch) ----------------------
// BindMailbox binds a messaging mailbox against a Mail service
// (ServiceKind=Mail). The handler enforces:
// 1. ValidateBasic (stateless).
// 2. The service must exist.
// 3. A-551 typed dispatch: the service Kind must be Mail.
// 4. A-552: the window-id on the existing service must still reference
// an Active Window.
// 5. The MailService metadata is updated with the mailbox-id +
// holder-reach-id.
//
// On success the MailService metadata is persisted and an event is
// emitted.
func (s msgServer) BindMailbox(ctx interface{}, msg *types.MsgBindMailbox) (*types.MsgBindMailboxResponse, error) {
if err := msg.ValidateBasic(); err != nil {
return nil, err
}
sdkCtx := unwrapCtx(ctx)
info, ok := s.Keeper.GetService(sdkCtx, msg.ServiceID)
if !ok {
return nil, fmt.Errorf("services: service %q not found", msg.ServiceID)
}
// A-551 typed dispatch: kind must be Mail.
if info.Kind != types.KindMail {
return nil, fmt.Errorf("services: service %q kind %q is not Mail (BindMailbox is the Mail typed dispatch — A-551)", msg.ServiceID, info.Kind)
}
if err := s.assertWindowActive(info.WindowID, "BindMailbox"); err != nil {
return nil, err
}
// Update the MailService per-kind metadata with the mailbox-id +
// holder-reach-id.
mail, _ := s.Keeper.GetMailService(sdkCtx, msg.ServiceID)
mail.MailID = msg.ServiceID
mail.MailboxID = msg.MailboxID
mail.HolderReachID = msg.HolderReachID
s.Keeper.SetMailService(sdkCtx, mail)
sdkCtx.EventManager().EmitEvent(sdk.NewEvent(
"services.mailbox_bound",
sdk.NewAttribute("service_id", msg.ServiceID),
sdk.NewAttribute("mailbox_id", msg.MailboxID),
sdk.NewAttribute("holder_reach_id", msg.HolderReachID),
))
return &types.MsgBindMailboxResponse{}, nil
}