From a70d6faa5936950f37e3b19f1befa908d502b5a0 Mon Sep 17 00:00:00 2001 From: cloudinit-bot Date: Tue, 18 Aug 2026 00:57:25 +0000 Subject: [PATCH] =?UTF-8?q?Merge=20phase/05=20into=20milestone/v0.5-bearer?= =?UTF-8?q?s-runtime=20(P5=20complete=20=E2=86=92=20v0.4.5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ---ci--- project: oy phase: 5 milestone: v0.5 status: complete requirements: covered: [REQ-037] partial: [] ---/ci--- --- x/services/keeper/keeper.go | 267 +++ x/services/keeper/msg_server.go | 512 ++++++ x/services/keeper/msg_server_simtest_test.go | 1537 ++++++++++++++++++ x/services/module.go | 85 + x/services/types/expected_keepers.go | 120 ++ x/services/types/msg_services.go | 552 +++++++ x/services/types/service_lifecycle.go | 69 + x/services/types/types.go | 16 + 8 files changed, 3158 insertions(+) create mode 100644 x/services/keeper/keeper.go create mode 100644 x/services/keeper/msg_server.go create mode 100644 x/services/keeper/msg_server_simtest_test.go create mode 100644 x/services/module.go create mode 100644 x/services/types/expected_keepers.go create mode 100644 x/services/types/msg_services.go create mode 100644 x/services/types/service_lifecycle.go diff --git a/x/services/keeper/keeper.go b/x/services/keeper/keeper.go new file mode 100644 index 0000000..e5040a0 --- /dev/null +++ b/x/services/keeper/keeper.go @@ -0,0 +1,267 @@ +package keeper + +// keeper.go holds the store-backed Keeper for the services module's +// Care/SIM/Vault/Mail runtime (P5-02-01, REQ-037). +// +// The Keeper wraps an sdk.KVStore via a storeKey. It holds: +// - the registered ServiceInfo records (service-id → ServiceInfo); +// - the per-service-kind metadata records (Care/SIM/Vault/Mail). +// +// The Keeper also holds the two expected-keeper shims (WindowKeeper for +// the window-grant-on-every-op A-552; VaultKeeper for VaultService +// provisioning A-553). The shims are interfaces (G-003 — no struct +// import of x/window/types or x/vault/types); the concrete keepers +// satisfy them structurally. A nil WindowKeeper shim skips the +// window-grant Active check (simtest wiring); 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; the +// simtest wires a stub vault keeper, never nil). +// +// State-machine ordering (vision §7, enforced in every handler): +// ValidateBasic → keeper authz (window-grant A-552) → 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/services/types" +) + +// Keeper is the store-backed services Care/SIM/Vault/Mail keeper. +type Keeper struct { + cdc codec.Codec + storeKey storetypes.StoreKey + windowKeeper types.WindowKeeper + vaultKeeper types.VaultKeeper +} + +// NewKeeper constructs a new store-backed services Keeper. The +// WindowKeeper and VaultKeeper expected-keeper shims are injected +// (nil-able for partial tests). A nil WindowKeeper shim skips the +// window-grant Active check (simtest wiring); a nil VaultKeeper shim +// REJECTS MsgProvisionVault (the VaultService requires a real vault +// keeper). The shims may be re-wired post-construction via SetWindowKeeper +// / SetVaultKeeper (app wiring or test setup). +func NewKeeper(cdc codec.Codec, storeKey storetypes.StoreKey, wk types.WindowKeeper, vk types.VaultKeeper) Keeper { + return Keeper{ + cdc: cdc, + storeKey: storeKey, + windowKeeper: wk, + vaultKeeper: vk, + } +} + +// SetWindowKeeper sets the WindowKeeper expected-keeper shim (for +// post-construction wiring, e.g., app wiring or test setup). This is the +// A-552 window-grant-on-every-op shim: the handler consults it on every +// service op (RegisterService / ActivateService / SuspendService / +// RevokeService / IssueCareGrant / ActivateSIM / ProvisionVault / +// BindMailbox) to assert the service's window-id still references an +// Active Window. +func (k *Keeper) SetWindowKeeper(wk types.WindowKeeper) { k.windowKeeper = wk } + +// SetVaultKeeper sets the VaultKeeper expected-keeper shim (for +// post-construction wiring, e.g., app wiring or test setup). This is +// the A-553 VaultService provisioning shim: the MsgProvisionVault +// handler delegates the storage-quota-grain provisioning to it. +func (k *Keeper) SetVaultKeeper(vk types.VaultKeeper) { k.vaultKeeper = vk } + +// WindowKeeper returns the WindowKeeper expected-keeper shim (for test +// assertion of wiring; the field is unexported to preserve the +// encapsulation of the shim injection). +func (k Keeper) WindowKeeper() types.WindowKeeper { return k.windowKeeper } + +// VaultKeeper returns the VaultKeeper expected-keeper shim (for test +// assertion of wiring). +func (k Keeper) VaultKeeper() types.VaultKeeper { return k.vaultKeeper } + +// --- ServiceInfo store ----------------------------------------------------- + +var serviceKeyPrefix = []byte("svc/") + +func serviceKey(serviceID string) []byte { + return append(serviceKeyPrefix, []byte(serviceID)...) +} + +// GetService loads a registered ServiceInfo by service-id. Returns the +// ServiceInfo and true if found, or zero value + false if not. +func (k Keeper) GetService(ctx sdk.Context, serviceID string) (types.ServiceInfo, bool) { + store := ctx.KVStore(k.storeKey) + bz := store.Get(serviceKey(serviceID)) + if bz == nil { + return types.ServiceInfo{}, false + } + var s types.ServiceInfo + if err := json.Unmarshal(bz, &s); err != nil { + return types.ServiceInfo{}, false + } + return s, true +} + +// SetService persists a registered ServiceInfo by service-id. +func (k Keeper) SetService(ctx sdk.Context, s types.ServiceInfo) { + store := ctx.KVStore(k.storeKey) + bz, err := json.Marshal(s) + if err != nil { + panic(fmt.Sprintf("services: marshal service info %q: %v", s.ServiceID, err)) + } + store.Set(serviceKey(s.ServiceID), bz) +} + +// AllServices returns all registered ServiceInfo records (iteration +// helper, unordered). +func (k Keeper) AllServices(ctx sdk.Context) []types.ServiceInfo { + store := ctx.KVStore(k.storeKey) + iterator := store.Iterator(serviceKeyPrefix, prefixEnd(serviceKeyPrefix)) + defer iterator.Close() + out := []types.ServiceInfo{} + for ; iterator.Valid(); iterator.Next() { + var s types.ServiceInfo + if err := json.Unmarshal(iterator.Value(), &s); err == nil { + out = append(out, s) + } + } + return out +} + +// --- Per-kind metadata stores ---------------------------------------------- + +// Each per-kind metadata record is stored under a kind-specific prefix +// keyed by the service-id (the canonical handle). A given service-id has +// AT MOST one per-kind record (the kind on its ServiceInfo picks the +// kind-specific metadata set). + +var ( + careKeyPrefix = []byte("kind/care/") + simKeyPrefix = []byte("kind/sim/") + vaultKeyPrefix = []byte("kind/vault/") + mailKeyPrefix = []byte("kind/mail/") +) + +func careKey(serviceID string) []byte { return append(careKeyPrefix, []byte(serviceID)...) } +func simKey(serviceID string) []byte { return append(simKeyPrefix, []byte(serviceID)...) } +func vaultKey(serviceID string) []byte { return append(vaultKeyPrefix, []byte(serviceID)...) } +func mailKey(serviceID string) []byte { return append(mailKeyPrefix, []byte(serviceID)...) } + +// GetCareService loads the CareService metadata for the named service-id. +func (k Keeper) GetCareService(ctx sdk.Context, serviceID string) (types.CareService, bool) { + store := ctx.KVStore(k.storeKey) + bz := store.Get(careKey(serviceID)) + if bz == nil { + return types.CareService{}, false + } + var c types.CareService + if err := json.Unmarshal(bz, &c); err != nil { + return types.CareService{}, false + } + return c, true +} + +// SetCareService persists the CareService metadata. +func (k Keeper) SetCareService(ctx sdk.Context, c types.CareService) { + store := ctx.KVStore(k.storeKey) + bz, err := json.Marshal(c) + if err != nil { + panic(fmt.Sprintf("services: marshal care service %q: %v", c.CareID, err)) + } + store.Set(careKey(c.CareID), bz) +} + +// GetSIMService loads the SIMService metadata for the named service-id. +func (k Keeper) GetSIMService(ctx sdk.Context, serviceID string) (types.SIMService, bool) { + store := ctx.KVStore(k.storeKey) + bz := store.Get(simKey(serviceID)) + if bz == nil { + return types.SIMService{}, false + } + var s types.SIMService + if err := json.Unmarshal(bz, &s); err != nil { + return types.SIMService{}, false + } + return s, true +} + +// SetSIMService persists the SIMService metadata. +func (k Keeper) SetSIMService(ctx sdk.Context, s types.SIMService) { + store := ctx.KVStore(k.storeKey) + bz, err := json.Marshal(s) + if err != nil { + panic(fmt.Sprintf("services: marshal sim service %q: %v", s.SIMID, err)) + } + store.Set(simKey(s.SIMID), bz) +} + +// GetVaultService loads the VaultService metadata for the named service-id. +func (k Keeper) GetVaultService(ctx sdk.Context, serviceID string) (types.VaultService, bool) { + store := ctx.KVStore(k.storeKey) + bz := store.Get(vaultKey(serviceID)) + if bz == nil { + return types.VaultService{}, false + } + var v types.VaultService + if err := json.Unmarshal(bz, &v); err != nil { + return types.VaultService{}, false + } + return v, true +} + +// SetVaultService persists the VaultService metadata. +func (k Keeper) SetVaultService(ctx sdk.Context, v types.VaultService) { + store := ctx.KVStore(k.storeKey) + bz, err := json.Marshal(v) + if err != nil { + panic(fmt.Sprintf("services: marshal vault service %q: %v", v.VaultID, err)) + } + store.Set(vaultKey(v.VaultID), bz) +} + +// GetMailService loads the MailService metadata for the named service-id. +func (k Keeper) GetMailService(ctx sdk.Context, serviceID string) (types.MailService, bool) { + store := ctx.KVStore(k.storeKey) + bz := store.Get(mailKey(serviceID)) + if bz == nil { + return types.MailService{}, false + } + var m types.MailService + if err := json.Unmarshal(bz, &m); err != nil { + return types.MailService{}, false + } + return m, true +} + +// SetMailService persists the MailService metadata. +func (k Keeper) SetMailService(ctx sdk.Context, m types.MailService) { + store := ctx.KVStore(k.storeKey) + bz, err := json.Marshal(m) + if err != nil { + panic(fmt.Sprintf("services: marshal mail service %q: %v", m.MailID, err)) + } + store.Set(mailKey(m.MailID), bz) +} + +// --- 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/partner/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/services/keeper/msg_server.go b/x/services/keeper/msg_server.go new file mode 100644 index 0000000..b5f3408 --- /dev/null +++ b/x/services/keeper/msg_server.go @@ -0,0 +1,512 @@ +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 +} diff --git a/x/services/keeper/msg_server_simtest_test.go b/x/services/keeper/msg_server_simtest_test.go new file mode 100644 index 0000000..e4ac51f --- /dev/null +++ b/x/services/keeper/msg_server_simtest_test.go @@ -0,0 +1,1537 @@ +package keeper_test + +// msg_server_simtest_test.go is the x/services keeper simtest (P5-03-01, +// REQ-037). +// +// D-054: simtest-grade — in-memory sdk.Context + dbm in-memory store, no +// real window or vault keepers. The simtest wires the expected-keeper +// shims (WindowKeeper, VaultKeeper) to in-test stubs (G-003 test +// exemption: the test imports x/services/keeper + defines stub types +// that satisfy the interfaces; no production struct imports across +// x//types). G-022: the stub WindowKeeper + VaultKeeper are +// STUBS returning sentinels, NOT real implementations of x/window or +// x/vault (the v0.1 baseline keepers remain empty stubs; v0.5 does not +// promote them). +// +// Coverage (REQ-037): +// Lifecycle (kind-agnostic) — Pending -> Active -> Suspended -> Revoked: +// - Full success lifecycle: Register (Pending) -> Activate (Active) +// -> Suspend (Suspended) -> Revoke (Revoked). +// - Pending -> Revoked (skip Activate/Suspend) is a valid transition. +// - Suspended -> Revoked is a valid transition. +// - Invalid transitions REJECTED: +// - Activate on a non-Pending service (Active/Suspended/Revoked +// source) -> error. +// - Suspend on a non-Active service (Pending/Suspended/Revoked +// source) -> error. +// - Revoke on an already-Revoked service -> idempotent reject +// (no double-effect). +// Per-kind typed dispatch (A-551): +// - IssueCareGrant on a Care service -> CareService metadata updated. +// - IssueCareGrant on a non-Care service -> REJECTED (kind mismatch). +// - ActivateSIM on a SIM service -> SIMService metadata updated. +// - ActivateSIM on a non-SIM service -> REJECTED. +// - ProvisionVault on a Vault service -> VaultService metadata updated +// (via the VaultKeeper stub — A-553; G-003 test exemption). +// - ProvisionVault on a non-Vault service -> REJECTED. +// - ProvisionVault with a nil VaultKeeper shim -> REJECTED (wiring +// error). +// - ProvisionVault with a VaultKeeper shim returning an error -> +// REJECTED. +// - BindMailbox on a Mail service -> MailService metadata updated. +// - BindMailbox on a non-Mail service -> REJECTED. +// Window-grant-on-every-op (A-552): +// - RegisterService against a Revoked Window -> REJECTED. +// - RegisterService against an Expired Window -> REJECTED. +// - RegisterService against an unknown Window -> REJECTED. +// - ActivateService on a service whose window-id went Revoked AFTER +// registration -> REJECTED (window-grant-on-every-op — the check +// is NOT just at registration). +// - IssueCareGrant on a Care service whose window-id went Revoked +// AFTER registration + activation -> REJECTED. +// - ProvisionVault on a Vault service whose window-id went Revoked +// AFTER registration -> REJECTED. +// - Nil WindowKeeper shim -> skips the A-552 check (simtest wiring). +// Idempotency + NotFound: +// - RegisterService on an existing service-id -> REJECTED. +// - ActivateService / SuspendService / RevokeService / per-kind +// handlers on a missing service-id -> REJECTED. +// ValidateBasic: each Msg* ValidateBasic error path. +// +// Coverage target: >=80% on x/services/keeper. + +import ( + "fmt" + "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/services/keeper" + stypes "github.com/oy/openyield/x/services/types" +) + +// --- Stub expected-keepers (G-003 test exemption, G-022 stubs) ------------- + +// stubWindowKeeper satisfies stypes.WindowKeeper for the simtest. It +// records GetWindowStatus calls and returns the configured status per +// window-id (default: Active). G-022: this is a STUB returning a sentinel +// status, NOT a real x/window keeper implementation. +type stubWindowKeeper struct { + calls []string // recorded window-ids + status map[stypes.WindowStatus]bool // status-set to return for all (single-value) + defaultSet bool + singleStatus stypes.WindowStatus + perWindow map[string]stypes.WindowStatus // window-id -> status + err error +} + +func (s *stubWindowKeeper) GetWindowStatus(windowID string) (stypes.WindowStatus, error) { + s.calls = append(s.calls, windowID) + if s.err != nil { + return stypes.WindowStatusUnknown, s.err + } + if s.perWindow != nil { + if st, ok := s.perWindow[windowID]; ok { + return st, nil + } + } + if s.defaultSet { + return s.singleStatus, nil + } + return stypes.WindowStatusActive, nil +} + +// setWindowStatus configures the stub to return the given status for the +// named window-id (overrides the default Active). +func (s *stubWindowKeeper) setWindowStatus(windowID string, status stypes.WindowStatus) { + if s.perWindow == nil { + s.perWindow = map[string]stypes.WindowStatus{} + } + s.perWindow[windowID] = status +} + +// setDefaultStatus configures the stub to return the given status for +// any window-id (default fallback when no per-window override). +func (s *stubWindowKeeper) setDefaultStatus(status stypes.WindowStatus) { + s.defaultSet = true + s.singleStatus = status +} + +// stubVaultKeeper satisfies stypes.VaultKeeper for the simtest. It +// records ProvisionVault calls and returns the configured error (default +// nil = success). G-022: this is a STUB, NOT a real x/vault keeper +// implementation. +type stubVaultKeeper struct { + calls []vaultCall + err error + provisioned map[string]int64 // service-id -> storage-quota-grain +} + +type vaultCall struct { + serviceID string + quotaGrain int64 +} + +func (s *stubVaultKeeper) ProvisionVault(serviceID string, quotaGrain int64) error { + s.calls = append(s.calls, vaultCall{serviceID, quotaGrain}) + if s.err != nil { + return s.err + } + if s.provisioned == nil { + s.provisioned = map[string]int64{} + } + s.provisioned[serviceID] = quotaGrain + return nil +} + +// --- Simtest context helper -------------------------------------------------- + +// newSimtestContext constructs an in-memory sdk.Context with a KVStore +// mounted at the services store key. D-054: in-memory, no real window or +// vault keepers. Returns the ctx, the stub WindowKeeper, the stub +// VaultKeeper, the store key, and the Keeper. +func newSimtestContext(t *testing.T) (sdk.Context, *stubWindowKeeper, *stubVaultKeeper, storetypes.StoreKey, keeper.Keeper) { + t.Helper() + db := dbm.NewMemDB() + cdc := newTestCodec() + storeKey := storetypes.NewKVStoreKey(stypes.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()) + + wk := &stubWindowKeeper{} + vk := &stubVaultKeeper{} + k := keeper.NewKeeper(cdc, storeKey, wk, vk) + return ctx, wk, vk, 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 +} + +// eventAttr returns the value of an attribute on the last event of the +// given type, or "" if not found. +func eventAttr(ctx sdk.Context, eventType, attrKey string) string { + for _, ev := range ctx.EventManager().Events() { + if ev.Type == eventType { + for _, a := range ev.Attributes { + if string(a.Key) == attrKey { + return string(a.Value) + } + } + } + } + return "" +} + +// --- Full success lifecycle: Pending -> Active -> Suspended -> Revoked ------- + +// TestServiceLifecycleFullSuccess asserts the full success lifecycle for a +// Care service: Register (Pending) -> Activate (Active) -> Suspend +// (Suspended) -> Revoke (Revoked). +func TestServiceLifecycleFullSuccess(t *testing.T) { + ctx, _, _, _, k := newSimtestContext(t) + srv := keeper.NewMsgServerImpl(k) + + // Register -> Pending. + if _, err := srv.RegisterService(ctx, &stypes.MsgRegisterService{ + ServiceID: "svc-care-1", Kind: stypes.KindCare, + OperatorReachID: "reach-op-1", Name: "Care Service 1", + WindowID: "window-1", Signer: "reach-op-1", + }); err != nil { + t.Fatalf("RegisterService: %v", err) + } + info, ok := k.GetService(ctx, "svc-care-1") + if !ok { + t.Fatal("service not found after register") + } + if info.Status != stypes.ServicePending { + t.Errorf("status = %q, want Pending", info.Status) + } + if info.Kind != stypes.KindCare { + t.Errorf("kind = %q, want Care", info.Kind) + } + if info.WindowID != "window-1" { + t.Errorf("window-id = %q, want window-1", info.WindowID) + } + if !hasEvent(ctx, "services.service_registered") { + t.Error("service_registered event not emitted") + } + + // Activate -> Active. + if _, err := srv.ActivateService(ctx, &stypes.MsgActivateService{ + ServiceID: "svc-care-1", Signer: "reach-op-1", + }); err != nil { + t.Fatalf("ActivateService: %v", err) + } + info, _ = k.GetService(ctx, "svc-care-1") + if info.Status != stypes.ServiceActive { + t.Errorf("status = %q, want Active", info.Status) + } + if !hasEvent(ctx, "services.service_activated") { + t.Error("service_activated event not emitted") + } + + // Suspend -> Suspended. + if _, err := srv.SuspendService(ctx, &stypes.MsgSuspendService{ + ServiceID: "svc-care-1", Signer: "reach-op-1", + }); err != nil { + t.Fatalf("SuspendService: %v", err) + } + info, _ = k.GetService(ctx, "svc-care-1") + if info.Status != stypes.ServiceSuspended { + t.Errorf("status = %q, want Suspended", info.Status) + } + if !hasEvent(ctx, "services.service_suspended") { + t.Error("service_suspended event not emitted") + } + + // Revoke -> Revoked (terminal). + if _, err := srv.RevokeService(ctx, &stypes.MsgRevokeService{ + ServiceID: "svc-care-1", Signer: "reach-op-1", + }); err != nil { + t.Fatalf("RevokeService: %v", err) + } + info, _ = k.GetService(ctx, "svc-care-1") + if info.Status != stypes.ServiceRevoked { + t.Errorf("status = %q, want Revoked", info.Status) + } + if !hasEvent(ctx, "services.service_revoked") { + t.Error("service_revoked event not emitted") + } +} + +// --- Pending -> Revoked (skip Activate/Suspend) ----------------------------- + +// TestServiceRevokeFromPending asserts a Pending service can be revoked +// directly (Pending -> Revoked is a valid transition). +func TestServiceRevokeFromPending(t *testing.T) { + ctx, _, _, _, k := newSimtestContext(t) + srv := keeper.NewMsgServerImpl(k) + + srv.RegisterService(ctx, &stypes.MsgRegisterService{ + ServiceID: "svc-pend", Kind: stypes.KindSIM, + OperatorReachID: "r", Name: "n", WindowID: "w", Signer: "r", + }) + if _, err := srv.RevokeService(ctx, &stypes.MsgRevokeService{ + ServiceID: "svc-pend", Signer: "r", + }); err != nil { + t.Fatalf("RevokeService from Pending: %v", err) + } + info, _ := k.GetService(ctx, "svc-pend") + if info.Status != stypes.ServiceRevoked { + t.Errorf("status = %q, want Revoked", info.Status) + } +} + +// --- Suspended -> Revoked ---------------------------------------------------- + +// TestServiceRevokeFromSuspended asserts a Suspended service can be +// revoked (Suspended -> Revoked is a valid transition). +func TestServiceRevokeFromSuspended(t *testing.T) { + ctx, _, _, _, k := newSimtestContext(t) + srv := keeper.NewMsgServerImpl(k) + + srv.RegisterService(ctx, &stypes.MsgRegisterService{ + ServiceID: "svc-sus", Kind: stypes.KindMail, + OperatorReachID: "r", Name: "n", WindowID: "w", Signer: "r", + }) + srv.ActivateService(ctx, &stypes.MsgActivateService{ServiceID: "svc-sus", Signer: "r"}) + srv.SuspendService(ctx, &stypes.MsgSuspendService{ServiceID: "svc-sus", Signer: "r"}) + if _, err := srv.RevokeService(ctx, &stypes.MsgRevokeService{ + ServiceID: "svc-sus", Signer: "r", + }); err != nil { + t.Fatalf("RevokeService from Suspended: %v", err) + } + info, _ := k.GetService(ctx, "svc-sus") + if info.Status != stypes.ServiceRevoked { + t.Errorf("status = %q, want Revoked", info.Status) + } +} + +// --- Invalid transitions REJECTED ------------------------------------------- + +// TestActivateRejectsNonPending asserts ActivateService on a non-Pending +// service is REJECTED (lifecycle gate). Covers Active, Suspended, and +// Revoked source statuses. +func TestActivateRejectsNonPending(t *testing.T) { + ctx, _, _, _, k := newSimtestContext(t) + srv := keeper.NewMsgServerImpl(k) + + // Active source -> reject (register + activate first). + srv.RegisterService(ctx, &stypes.MsgRegisterService{ + ServiceID: "a-act", Kind: stypes.KindCare, OperatorReachID: "r", Name: "n", WindowID: "w", Signer: "r", + }) + srv.ActivateService(ctx, &stypes.MsgActivateService{ServiceID: "a-act", Signer: "r"}) + _, err := srv.ActivateService(ctx, &stypes.MsgActivateService{ServiceID: "a-act", Signer: "r"}) + if err == nil { + t.Error("ActivateService on Active service should be rejected (lifecycle gate)") + } + + // Suspended source -> reject. + srv.RegisterService(ctx, &stypes.MsgRegisterService{ + ServiceID: "a-sus", Kind: stypes.KindCare, OperatorReachID: "r", Name: "n", WindowID: "w", Signer: "r", + }) + srv.ActivateService(ctx, &stypes.MsgActivateService{ServiceID: "a-sus", Signer: "r"}) + srv.SuspendService(ctx, &stypes.MsgSuspendService{ServiceID: "a-sus", Signer: "r"}) + _, err = srv.ActivateService(ctx, &stypes.MsgActivateService{ServiceID: "a-sus", Signer: "r"}) + if err == nil { + t.Error("ActivateService on Suspended service should be rejected (lifecycle gate)") + } + + // Revoked source -> reject. + srv.RegisterService(ctx, &stypes.MsgRegisterService{ + ServiceID: "a-rev", Kind: stypes.KindCare, OperatorReachID: "r", Name: "n", WindowID: "w", Signer: "r", + }) + srv.RevokeService(ctx, &stypes.MsgRevokeService{ServiceID: "a-rev", Signer: "r"}) + _, err = srv.ActivateService(ctx, &stypes.MsgActivateService{ServiceID: "a-rev", Signer: "r"}) + if err == nil { + t.Error("ActivateService on Revoked service should be rejected (lifecycle gate)") + } +} + +// TestSuspendRejectsNonActive asserts SuspendService on a non-Active +// service is REJECTED. Covers Pending, Suspended, and Revoked source +// statuses. +func TestSuspendRejectsNonActive(t *testing.T) { + ctx, _, _, _, k := newSimtestContext(t) + srv := keeper.NewMsgServerImpl(k) + + // Pending source -> reject. + srv.RegisterService(ctx, &stypes.MsgRegisterService{ + ServiceID: "s-pend", Kind: stypes.KindCare, OperatorReachID: "r", Name: "n", WindowID: "w", Signer: "r", + }) + _, err := srv.SuspendService(ctx, &stypes.MsgSuspendService{ServiceID: "s-pend", Signer: "r"}) + if err == nil { + t.Error("SuspendService on Pending service should be rejected (lifecycle gate)") + } + + // Suspended source -> reject (suspend an already-suspended). + srv.RegisterService(ctx, &stypes.MsgRegisterService{ + ServiceID: "s-sus2", Kind: stypes.KindCare, OperatorReachID: "r", Name: "n", WindowID: "w", Signer: "r", + }) + srv.ActivateService(ctx, &stypes.MsgActivateService{ServiceID: "s-sus2", Signer: "r"}) + srv.SuspendService(ctx, &stypes.MsgSuspendService{ServiceID: "s-sus2", Signer: "r"}) + _, err = srv.SuspendService(ctx, &stypes.MsgSuspendService{ServiceID: "s-sus2", Signer: "r"}) + if err == nil { + t.Error("SuspendService on Suspended service should be rejected (lifecycle gate)") + } + + // Revoked source -> reject. + srv.RegisterService(ctx, &stypes.MsgRegisterService{ + ServiceID: "s-rev2", Kind: stypes.KindCare, OperatorReachID: "r", Name: "n", WindowID: "w", Signer: "r", + }) + srv.RevokeService(ctx, &stypes.MsgRevokeService{ServiceID: "s-rev2", Signer: "r"}) + _, err = srv.SuspendService(ctx, &stypes.MsgSuspendService{ServiceID: "s-rev2", Signer: "r"}) + if err == nil { + t.Error("SuspendService on Revoked service should be rejected (lifecycle gate)") + } +} + +// TestRevokeRejectsAlreadyRevoked asserts a second Revoke on a Revoked +// service is REJECTED (idempotent reject — no double-effect). +func TestRevokeRejectsAlreadyRevoked(t *testing.T) { + ctx, _, _, _, k := newSimtestContext(t) + srv := keeper.NewMsgServerImpl(k) + + srv.RegisterService(ctx, &stypes.MsgRegisterService{ + ServiceID: "r-rev3", Kind: stypes.KindCare, OperatorReachID: "r", Name: "n", WindowID: "w", Signer: "r", + }) + srv.RevokeService(ctx, &stypes.MsgRevokeService{ServiceID: "r-rev3", Signer: "r"}) + _, err := srv.RevokeService(ctx, &stypes.MsgRevokeService{ServiceID: "r-rev3", Signer: "r"}) + if err == nil { + t.Error("RevokeService on Revoked service should be rejected (idempotent reject — no double-effect)") + } +} + +// --- Per-kind typed dispatch (A-551) ----------------------------------------- + +// TestIssueCareGrantOnCareService asserts IssueCareGrant on a Care +// service SUCCEEDS and updates the CareService metadata. +func TestIssueCareGrantOnCareService(t *testing.T) { + ctx, _, _, _, k := newSimtestContext(t) + srv := keeper.NewMsgServerImpl(k) + + srv.RegisterService(ctx, &stypes.MsgRegisterService{ + ServiceID: "svc-care", Kind: stypes.KindCare, + OperatorReachID: "r", Name: "n", WindowID: "w", Signer: "r", + }) + if _, err := srv.IssueCareGrant(ctx, &stypes.MsgIssueCareGrant{ + ServiceID: "svc-care", CareKind: "mutual-aid", + GrantRecipientReachID: "reach-recipient", Signer: "r", + }); err != nil { + t.Fatalf("IssueCareGrant on Care service: %v", err) + } + care, ok := k.GetCareService(ctx, "svc-care") + if !ok { + t.Fatal("CareService metadata not found") + } + if care.CareKind != "mutual-aid" { + t.Errorf("care-kind = %q, want mutual-aid", care.CareKind) + } + if !hasEvent(ctx, "services.care_grant_issued") { + t.Error("care_grant_issued event not emitted") + } +} + +// TestIssueCareGrantRejectsNonCare asserts IssueCareGrant on a non-Care +// service is REJECTED (A-551 typed dispatch — kind mismatch). +func TestIssueCareGrantRejectsNonCare(t *testing.T) { + ctx, _, _, _, k := newSimtestContext(t) + srv := keeper.NewMsgServerImpl(k) + + srv.RegisterService(ctx, &stypes.MsgRegisterService{ + ServiceID: "svc-sim", Kind: stypes.KindSIM, + OperatorReachID: "r", Name: "n", WindowID: "w", Signer: "r", + }) + _, err := srv.IssueCareGrant(ctx, &stypes.MsgIssueCareGrant{ + ServiceID: "svc-sim", CareKind: "mutual-aid", + GrantRecipientReachID: "x", Signer: "r", + }) + if err == nil { + t.Error("IssueCareGrant on a SIM service should be rejected (A-551 kind mismatch)") + } + if !strings.Contains(err.Error(), "not Care") { + t.Errorf("error = %q, want 'not Care'", err.Error()) + } +} + +// TestActivateSIMOnSIMService asserts ActivateSIM on a SIM service +// SUCCEEDS and updates the SIMService metadata. +func TestActivateSIMOnSIMService(t *testing.T) { + ctx, _, _, _, k := newSimtestContext(t) + srv := keeper.NewMsgServerImpl(k) + + srv.RegisterService(ctx, &stypes.MsgRegisterService{ + ServiceID: "svc-sim2", Kind: stypes.KindSIM, + OperatorReachID: "r", Name: "n", WindowID: "w", Signer: "r", + }) + if _, err := srv.ActivateSIM(ctx, &stypes.MsgActivateSIM{ + ServiceID: "svc-sim2", Carrier: "oy-mobile", + RecipientReachID: "reach-recipient", Signer: "r", + }); err != nil { + t.Fatalf("ActivateSIM on SIM service: %v", err) + } + sim, ok := k.GetSIMService(ctx, "svc-sim2") + if !ok { + t.Fatal("SIMService metadata not found") + } + if sim.Carrier != "oy-mobile" { + t.Errorf("carrier = %q, want oy-mobile", sim.Carrier) + } + if !hasEvent(ctx, "services.sim_activated") { + t.Error("sim_activated event not emitted") + } +} + +// TestActivateSIMRejectsNonSIM asserts ActivateSIM on a non-SIM service is +// REJECTED. +func TestActivateSIMRejectsNonSIM(t *testing.T) { + ctx, _, _, _, k := newSimtestContext(t) + srv := keeper.NewMsgServerImpl(k) + + srv.RegisterService(ctx, &stypes.MsgRegisterService{ + ServiceID: "svc-care2", Kind: stypes.KindCare, + OperatorReachID: "r", Name: "n", WindowID: "w", Signer: "r", + }) + _, err := srv.ActivateSIM(ctx, &stypes.MsgActivateSIM{ + ServiceID: "svc-care2", Carrier: "c", RecipientReachID: "x", Signer: "r", + }) + if err == nil { + t.Error("ActivateSIM on a Care service should be rejected (A-551 kind mismatch)") + } + if !strings.Contains(err.Error(), "not SIM") { + t.Errorf("error = %q, want 'not SIM'", err.Error()) + } +} + +// TestProvisionVaultOnVaultService asserts ProvisionVault on a Vault +// service SUCCEEDS, delegates to the VaultKeeper stub (A-553), and +// updates the VaultService metadata. +func TestProvisionVaultOnVaultService(t *testing.T) { + ctx, _, vk, _, k := newSimtestContext(t) + srv := keeper.NewMsgServerImpl(k) + + srv.RegisterService(ctx, &stypes.MsgRegisterService{ + ServiceID: "svc-vault", Kind: stypes.KindVault, + OperatorReachID: "r", Name: "n", WindowID: "w", Signer: "r", + }) + if _, err := srv.ProvisionVault(ctx, &stypes.MsgProvisionVault{ + ServiceID: "svc-vault", StorageQuotaGrain: 1_000_000, Signer: "r", + }); err != nil { + t.Fatalf("ProvisionVault on Vault service: %v", err) + } + // The VaultKeeper stub was called with the service-id + quota-grain. + if len(vk.calls) != 1 { + t.Fatalf("vault keeper calls = %d, want 1", len(vk.calls)) + } + if vk.calls[0].serviceID != "svc-vault" { + t.Errorf("vault call service-id = %q, want svc-vault", vk.calls[0].serviceID) + } + if vk.calls[0].quotaGrain != 1_000_000 { + t.Errorf("vault call quota = %d, want 1000000", vk.calls[0].quotaGrain) + } + // The VaultService metadata is updated. + vault, ok := k.GetVaultService(ctx, "svc-vault") + if !ok { + t.Fatal("VaultService metadata not found") + } + if vault.StorageQuotaGrain != 1_000_000 { + t.Errorf("storage-quota-grain = %d, want 1000000", vault.StorageQuotaGrain) + } + if !hasEvent(ctx, "services.vault_provisioned") { + t.Error("vault_provisioned event not emitted") + } +} + +// TestProvisionVaultRejectsNonVault asserts ProvisionVault on a non-Vault +// service is REJECTED. +func TestProvisionVaultRejectsNonVault(t *testing.T) { + ctx, _, _, _, k := newSimtestContext(t) + srv := keeper.NewMsgServerImpl(k) + + srv.RegisterService(ctx, &stypes.MsgRegisterService{ + ServiceID: "svc-mail", Kind: stypes.KindMail, + OperatorReachID: "r", Name: "n", WindowID: "w", Signer: "r", + }) + _, err := srv.ProvisionVault(ctx, &stypes.MsgProvisionVault{ + ServiceID: "svc-mail", StorageQuotaGrain: 1000, Signer: "r", + }) + if err == nil { + t.Error("ProvisionVault on a Mail service should be rejected (A-551 kind mismatch)") + } + if !strings.Contains(err.Error(), "not Vault") { + t.Errorf("error = %q, want 'not Vault'", err.Error()) + } +} + +// TestProvisionVaultRejectsNilVaultKeeper asserts ProvisionVault with a +// nil VaultKeeper shim is REJECTED (wiring error — A-553). +func TestProvisionVaultRejectsNilVaultKeeper(t *testing.T) { + ctx, wk, _, sk, _ := newSimtestContext(t) + // Construct a keeper with a nil VaultKeeper, reusing the mounted store key. + k := keeper.NewKeeper(newTestCodec(), sk, wk, nil) + srv := keeper.NewMsgServerImpl(k) + + srv.RegisterService(ctx, &stypes.MsgRegisterService{ + ServiceID: "svc-vault-nil", Kind: stypes.KindVault, + OperatorReachID: "r", Name: "n", WindowID: "w", Signer: "r", + }) + _, err := srv.ProvisionVault(ctx, &stypes.MsgProvisionVault{ + ServiceID: "svc-vault-nil", StorageQuotaGrain: 1000, Signer: "r", + }) + if err == nil { + t.Error("ProvisionVault with nil VaultKeeper shim should be rejected (wiring error)") + } + if !strings.Contains(err.Error(), "vault keeper not wired") { + t.Errorf("error = %q, want 'vault keeper not wired'", err.Error()) + } +} + +// TestProvisionVaultRejectsVaultKeeperError asserts ProvisionVault with a +// VaultKeeper shim returning an error is REJECTED (the VaultService +// metadata is NOT updated). +func TestProvisionVaultRejectsVaultKeeperError(t *testing.T) { + ctx, _, vk, _, k := newSimtestContext(t) + srv := keeper.NewMsgServerImpl(k) + + srv.RegisterService(ctx, &stypes.MsgRegisterService{ + ServiceID: "svc-vault-err", Kind: stypes.KindVault, + OperatorReachID: "r", Name: "n", WindowID: "w", Signer: "r", + }) + // Configure the VaultKeeper stub to return an error. + vk.err = fmt.Errorf("vault quota exceeds capacity") + _, err := srv.ProvisionVault(ctx, &stypes.MsgProvisionVault{ + ServiceID: "svc-vault-err", StorageQuotaGrain: 1_000_000_000, Signer: "r", + }) + if err == nil { + t.Error("ProvisionVault should be rejected when VaultKeeper returns an error") + } + if !strings.Contains(err.Error(), "vault quota exceeds capacity") { + t.Errorf("error = %q, want 'vault quota exceeds capacity'", err.Error()) + } + // The VaultService metadata is NOT updated (storage-quota-grain stays 0). + vault, _ := k.GetVaultService(ctx, "svc-vault-err") + if vault.StorageQuotaGrain != 0 { + t.Errorf("storage-quota-grain = %d, want 0 (provisioning rejected)", vault.StorageQuotaGrain) + } +} + +// TestBindMailboxOnMailService asserts BindMailbox on a Mail service +// SUCCEEDS and updates the MailService metadata. +func TestBindMailboxOnMailService(t *testing.T) { + ctx, _, _, _, k := newSimtestContext(t) + srv := keeper.NewMsgServerImpl(k) + + srv.RegisterService(ctx, &stypes.MsgRegisterService{ + ServiceID: "svc-mail2", Kind: stypes.KindMail, + OperatorReachID: "r", Name: "n", WindowID: "w", Signer: "r", + }) + if _, err := srv.BindMailbox(ctx, &stypes.MsgBindMailbox{ + ServiceID: "svc-mail2", MailboxID: "mbox-1", + HolderReachID: "reach-holder", Signer: "r", + }); err != nil { + t.Fatalf("BindMailbox on Mail service: %v", err) + } + mail, ok := k.GetMailService(ctx, "svc-mail2") + if !ok { + t.Fatal("MailService metadata not found") + } + if mail.MailboxID != "mbox-1" { + t.Errorf("mailbox-id = %q, want mbox-1", mail.MailboxID) + } + if mail.HolderReachID != "reach-holder" { + t.Errorf("holder-reach-id = %q, want reach-holder", mail.HolderReachID) + } + if !hasEvent(ctx, "services.mailbox_bound") { + t.Error("mailbox_bound event not emitted") + } +} + +// TestBindMailboxRejectsNonMail asserts BindMailbox on a non-Mail service +// is REJECTED. +func TestBindMailboxRejectsNonMail(t *testing.T) { + ctx, _, _, _, k := newSimtestContext(t) + srv := keeper.NewMsgServerImpl(k) + + srv.RegisterService(ctx, &stypes.MsgRegisterService{ + ServiceID: "svc-vault2", Kind: stypes.KindVault, + OperatorReachID: "r", Name: "n", WindowID: "w", Signer: "r", + }) + _, err := srv.BindMailbox(ctx, &stypes.MsgBindMailbox{ + ServiceID: "svc-vault2", MailboxID: "m", HolderReachID: "h", Signer: "r", + }) + if err == nil { + t.Error("BindMailbox on a Vault service should be rejected (A-551 kind mismatch)") + } + if !strings.Contains(err.Error(), "not Mail") { + t.Errorf("error = %q, want 'not Mail'", err.Error()) + } +} + +// --- Window-grant-on-every-op (A-552) --------------------------------------- + +// TestRegisterServiceRejectsRevokedWindow asserts RegisterService against +// a Revoked Window is REJECTED (the service is NOT created). +func TestRegisterServiceRejectsRevokedWindow(t *testing.T) { + ctx, wk, _, _, k := newSimtestContext(t) + srv := keeper.NewMsgServerImpl(k) + + wk.setWindowStatus("window-revoked", stypes.WindowStatusRevoked) + _, err := srv.RegisterService(ctx, &stypes.MsgRegisterService{ + ServiceID: "svc-x", Kind: stypes.KindCare, + OperatorReachID: "r", Name: "n", WindowID: "window-revoked", Signer: "r", + }) + if err == nil { + t.Error("RegisterService against a Revoked Window should be rejected (A-552)") + } + if !strings.Contains(err.Error(), "window") { + t.Errorf("error = %q, want 'window'", err.Error()) + } + // The service is NOT created. + if _, ok := k.GetService(ctx, "svc-x"); ok { + t.Error("service should NOT be created when Window is Revoked") + } +} + +// TestRegisterServiceRejectsExpiredWindow asserts RegisterService against +// an Expired Window is REJECTED (A-552). +func TestRegisterServiceRejectsExpiredWindow(t *testing.T) { + ctx, wk, _, _, k := newSimtestContext(t) + srv := keeper.NewMsgServerImpl(k) + + wk.setWindowStatus("window-exp", stypes.WindowStatusExpired) + _, err := srv.RegisterService(ctx, &stypes.MsgRegisterService{ + ServiceID: "svc-y", Kind: stypes.KindCare, + OperatorReachID: "r", Name: "n", WindowID: "window-exp", Signer: "r", + }) + if err == nil { + t.Error("RegisterService against an Expired Window should be rejected (A-552)") + } +} + +// TestRegisterServiceRejectsUnknownWindow asserts RegisterService against +// an unknown Window (status=Unknown) is REJECTED (A-552). +func TestRegisterServiceRejectsUnknownWindow(t *testing.T) { + ctx, wk, _, _, k := newSimtestContext(t) + srv := keeper.NewMsgServerImpl(k) + + wk.setWindowStatus("window-unk", stypes.WindowStatusUnknown) + _, err := srv.RegisterService(ctx, &stypes.MsgRegisterService{ + ServiceID: "svc-z", Kind: stypes.KindCare, + OperatorReachID: "r", Name: "n", WindowID: "window-unk", Signer: "r", + }) + if err == nil { + t.Error("RegisterService against an unknown Window should be rejected (A-552)") + } +} + +// TestActivateServiceRejectsWhenWindowRevokedAfterRegistration asserts +// A-552 window-grant-on-every-op: a service whose window-id went Revoked +// AFTER registration is REJECTED at ActivateService (the check is NOT +// just at registration). +func TestActivateServiceRejectsWhenWindowRevokedAfterRegistration(t *testing.T) { + ctx, wk, _, _, k := newSimtestContext(t) + srv := keeper.NewMsgServerImpl(k) + + // Register against an Active Window -> Pending. + if _, err := srv.RegisterService(ctx, &stypes.MsgRegisterService{ + ServiceID: "svc-window-flip", Kind: stypes.KindCare, + OperatorReachID: "r", Name: "n", WindowID: "window-flip", Signer: "r", + }); err != nil { + t.Fatalf("RegisterService against Active Window: %v", err) + } + // The window-id goes Revoked AFTER registration (the Window was + // Active at RegisterService time; the next op must re-check A-552). + wk.setWindowStatus("window-flip", stypes.WindowStatusRevoked) + _, err := srv.ActivateService(ctx, &stypes.MsgActivateService{ + ServiceID: "svc-window-flip", Signer: "r", + }) + if err == nil { + t.Error("ActivateService should be rejected when Window went Revoked after registration (A-552 window-grant-on-every-op)") + } + if !strings.Contains(err.Error(), "window") { + t.Errorf("error = %q, want 'window'", err.Error()) + } + // The service stays Pending (the rejected activation did not mutate). + info, _ := k.GetService(ctx, "svc-window-flip") + if info.Status != stypes.ServicePending { + t.Errorf("status = %q, want Pending (rejected activation did not mutate)", info.Status) + } +} + +// TestIssueCareGrantRejectsWhenWindowRevokedAfterRegistration asserts +// A-552 window-grant-on-every-op on a per-kind op: IssueCareGrant on a +// Care service whose window-id went Revoked AFTER registration + +// activation is REJECTED. +func TestIssueCareGrantRejectsWhenWindowRevokedAfterRegistration(t *testing.T) { + ctx, wk, _, _, k := newSimtestContext(t) + srv := keeper.NewMsgServerImpl(k) + + srv.RegisterService(ctx, &stypes.MsgRegisterService{ + ServiceID: "svc-care-flip", Kind: stypes.KindCare, + OperatorReachID: "r", Name: "n", WindowID: "window-care-flip", Signer: "r", + }) + srv.ActivateService(ctx, &stypes.MsgActivateService{ServiceID: "svc-care-flip", Signer: "r"}) + // Window goes Revoked AFTER registration + activation. + wk.setWindowStatus("window-care-flip", stypes.WindowStatusRevoked) + _, err := srv.IssueCareGrant(ctx, &stypes.MsgIssueCareGrant{ + ServiceID: "svc-care-flip", CareKind: "mutual-aid", + GrantRecipientReachID: "x", Signer: "r", + }) + if err == nil { + t.Error("IssueCareGrant should be rejected when Window went Revoked (A-552 window-grant-on-every-op)") + } + // The CareService metadata is NOT updated. + care, _ := k.GetCareService(ctx, "svc-care-flip") + if care.CareKind != "" { + t.Errorf("care-kind = %q, want empty (per-kind op rejected — A-552)", care.CareKind) + } +} + +// TestProvisionVaultRejectsWhenWindowRevokedAfterRegistration asserts +// A-552 window-grant-on-every-op on ProvisionVault: the window-grant +// check runs BEFORE the VaultKeeper shim delegation (the provisioning is +// NOT delegated when the Window is Revoked). +func TestProvisionVaultRejectsWhenWindowRevokedAfterRegistration(t *testing.T) { + ctx, wk, vk, _, k := newSimtestContext(t) + srv := keeper.NewMsgServerImpl(k) + + srv.RegisterService(ctx, &stypes.MsgRegisterService{ + ServiceID: "svc-vault-flip", Kind: stypes.KindVault, + OperatorReachID: "r", Name: "n", WindowID: "window-vault-flip", Signer: "r", + }) + // Window goes Revoked AFTER registration. + wk.setWindowStatus("window-vault-flip", stypes.WindowStatusRevoked) + _, err := srv.ProvisionVault(ctx, &stypes.MsgProvisionVault{ + ServiceID: "svc-vault-flip", StorageQuotaGrain: 1000, Signer: "r", + }) + if err == nil { + t.Error("ProvisionVault should be rejected when Window went Revoked (A-552 window-grant-on-every-op)") + } + // The VaultKeeper shim was NOT called (the A-552 check ran before the + // A-553 delegation). + if len(vk.calls) != 0 { + t.Errorf("vault keeper calls = %d, want 0 (A-552 check ran BEFORE A-553 delegation)", len(vk.calls)) + } +} + +// TestNilWindowKeeperSkipsA552Check asserts a nil WindowKeeper shim skips +// the A-552 window-grant Active check (simtest wiring); the handler +// mutates state regardless. This documents the wiring contract for the +// A-552 shim: a real x/window keeper is wired in the live chain; the +// simtest may use a nil shim. +func TestNilWindowKeeperSkipsA552Check(t *testing.T) { + db := dbm.NewMemDB() + cdc := newTestCodec() + storeKey := storetypes.NewKVStoreKey(stypes.StoreKey) + cms := store.NewCommitMultiStore(db, log.NewNopLogger(), nil) + cms.MountStoreWithDB(storeKey, storetypes.StoreTypeDB, nil) + cms.LoadLatestVersion() + ctx := sdk.NewContext(cms, cmtproto.Header{Time: time.Unix(1000, 0)}, false, log.NewNopLogger()) + // Nil WindowKeeper shim (VaultKeeper stub provided so ProvisionVault + // would not fail on a nil VaultKeeper — but this test only registers + // + activates). + k := keeper.NewKeeper(cdc, storeKey, nil, &stubVaultKeeper{}) + srv := keeper.NewMsgServerImpl(k) + + if _, err := srv.RegisterService(ctx, &stypes.MsgRegisterService{ + ServiceID: "svc-nil-wk", Kind: stypes.KindCare, + OperatorReachID: "r", Name: "n", WindowID: "any-window", Signer: "r", + }); err != nil { + t.Fatalf("RegisterService with nil WindowKeeper should succeed (A-552 check skipped): %v", err) + } + info, ok := k.GetService(ctx, "svc-nil-wk") + if !ok { + t.Fatal("service should be registered (nil shim skips A-552)") + } + if info.Status != stypes.ServicePending { + t.Errorf("status = %q, want Pending", info.Status) + } +} + +// --- Idempotency + NotFound ------------------------------------------------- + +// TestRegisterServiceRejectsDuplicate asserts RegisterService on an +// existing service-id returns an error (idempotency). +func TestRegisterServiceRejectsDuplicate(t *testing.T) { + ctx, _, _, _, k := newSimtestContext(t) + srv := keeper.NewMsgServerImpl(k) + srv.RegisterService(ctx, &stypes.MsgRegisterService{ + ServiceID: "dup", Kind: stypes.KindCare, + OperatorReachID: "r", Name: "n", WindowID: "w", Signer: "r", + }) + _, err := srv.RegisterService(ctx, &stypes.MsgRegisterService{ + ServiceID: "dup", Kind: stypes.KindSIM, + OperatorReachID: "r2", Name: "n2", WindowID: "w2", Signer: "r2", + }) + if err == nil { + t.Error("RegisterService should reject a duplicate service-id") + } +} + +// TestActivateNotFound asserts ActivateService on a missing service-id +// returns an error. +func TestActivateNotFound(t *testing.T) { + ctx, _, _, _, k := newSimtestContext(t) + srv := keeper.NewMsgServerImpl(k) + _, err := srv.ActivateService(ctx, &stypes.MsgActivateService{ServiceID: "missing", Signer: "r"}) + if err == nil { + t.Error("ActivateService on missing service-id should error") + } +} + +// TestSuspendNotFound asserts SuspendService on a missing service-id +// returns an error. +func TestSuspendNotFound(t *testing.T) { + ctx, _, _, _, k := newSimtestContext(t) + srv := keeper.NewMsgServerImpl(k) + _, err := srv.SuspendService(ctx, &stypes.MsgSuspendService{ServiceID: "missing", Signer: "r"}) + if err == nil { + t.Error("SuspendService on missing service-id should error") + } +} + +// TestRevokeNotFound asserts RevokeService on a missing service-id +// returns an error. +func TestRevokeNotFound(t *testing.T) { + ctx, _, _, _, k := newSimtestContext(t) + srv := keeper.NewMsgServerImpl(k) + _, err := srv.RevokeService(ctx, &stypes.MsgRevokeService{ServiceID: "missing", Signer: "r"}) + if err == nil { + t.Error("RevokeService on missing service-id should error") + } +} + +// TestPerKindHandlersNotFound asserts each per-kind handler on a missing +// service-id returns an error. +func TestPerKindHandlersNotFound(t *testing.T) { + ctx, _, _, _, k := newSimtestContext(t) + srv := keeper.NewMsgServerImpl(k) + + if _, err := srv.IssueCareGrant(ctx, &stypes.MsgIssueCareGrant{ + ServiceID: "missing", CareKind: "k", GrantRecipientReachID: "g", Signer: "r", + }); err == nil { + t.Error("IssueCareGrant on missing service-id should error") + } + if _, err := srv.ActivateSIM(ctx, &stypes.MsgActivateSIM{ + ServiceID: "missing", Carrier: "c", RecipientReachID: "r", Signer: "r", + }); err == nil { + t.Error("ActivateSIM on missing service-id should error") + } + if _, err := srv.ProvisionVault(ctx, &stypes.MsgProvisionVault{ + ServiceID: "missing", StorageQuotaGrain: 1000, Signer: "r", + }); err == nil { + t.Error("ProvisionVault on missing service-id should error") + } + if _, err := srv.BindMailbox(ctx, &stypes.MsgBindMailbox{ + ServiceID: "missing", MailboxID: "m", HolderReachID: "h", Signer: "r", + }); err == nil { + t.Error("BindMailbox on missing service-id should error") + } +} + +// --- Per-kind round-trip (one per ServiceKind — A-551 typed dispatch) ----- + +// TestPerKindRoundTripCare asserts a full Care round-trip: register +// (Care) -> activate -> IssueCareGrant. +func TestPerKindRoundTripCare(t *testing.T) { + ctx, _, _, _, k := newSimtestContext(t) + srv := keeper.NewMsgServerImpl(k) + + srv.RegisterService(ctx, &stypes.MsgRegisterService{ + ServiceID: "rt-care", Kind: stypes.KindCare, + OperatorReachID: "r", Name: "n", WindowID: "w", Signer: "r", + }) + srv.ActivateService(ctx, &stypes.MsgActivateService{ServiceID: "rt-care", Signer: "r"}) + if _, err := srv.IssueCareGrant(ctx, &stypes.MsgIssueCareGrant{ + ServiceID: "rt-care", CareKind: "mutual-aid", + GrantRecipientReachID: "recipient", Signer: "r", + }); err != nil { + t.Fatalf("IssueCareGrant round-trip: %v", err) + } + care, _ := k.GetCareService(ctx, "rt-care") + if care.CareKind != "mutual-aid" { + t.Errorf("care-kind = %q", care.CareKind) + } +} + +// TestPerKindRoundTripSIM asserts a full SIM round-trip. +func TestPerKindRoundTripSIM(t *testing.T) { + ctx, _, _, _, k := newSimtestContext(t) + srv := keeper.NewMsgServerImpl(k) + + srv.RegisterService(ctx, &stypes.MsgRegisterService{ + ServiceID: "rt-sim", Kind: stypes.KindSIM, + OperatorReachID: "r", Name: "n", WindowID: "w", Signer: "r", + }) + srv.ActivateService(ctx, &stypes.MsgActivateService{ServiceID: "rt-sim", Signer: "r"}) + if _, err := srv.ActivateSIM(ctx, &stypes.MsgActivateSIM{ + ServiceID: "rt-sim", Carrier: "carrier-x", RecipientReachID: "recipient", Signer: "r", + }); err != nil { + t.Fatalf("ActivateSIM round-trip: %v", err) + } + sim, _ := k.GetSIMService(ctx, "rt-sim") + if sim.Carrier != "carrier-x" { + t.Errorf("carrier = %q", sim.Carrier) + } +} + +// TestPerKindRoundTripVault asserts a full Vault round-trip (incl. the +// A-553 VaultKeeper stub delegation). +func TestPerKindRoundTripVault(t *testing.T) { + ctx, _, _, _, k := newSimtestContext(t) + srv := keeper.NewMsgServerImpl(k) + + srv.RegisterService(ctx, &stypes.MsgRegisterService{ + ServiceID: "rt-vault", Kind: stypes.KindVault, + OperatorReachID: "r", Name: "n", WindowID: "w", Signer: "r", + }) + srv.ActivateService(ctx, &stypes.MsgActivateService{ServiceID: "rt-vault", Signer: "r"}) + if _, err := srv.ProvisionVault(ctx, &stypes.MsgProvisionVault{ + ServiceID: "rt-vault", StorageQuotaGrain: 5_000_000, Signer: "r", + }); err != nil { + t.Fatalf("ProvisionVault round-trip: %v", err) + } + vault, _ := k.GetVaultService(ctx, "rt-vault") + if vault.StorageQuotaGrain != 5_000_000 { + t.Errorf("storage-quota-grain = %d", vault.StorageQuotaGrain) + } +} + +// TestPerKindRoundTripMail asserts a full Mail round-trip. +func TestPerKindRoundTripMail(t *testing.T) { + ctx, _, _, _, k := newSimtestContext(t) + srv := keeper.NewMsgServerImpl(k) + + srv.RegisterService(ctx, &stypes.MsgRegisterService{ + ServiceID: "rt-mail", Kind: stypes.KindMail, + OperatorReachID: "r", Name: "n", WindowID: "w", Signer: "r", + }) + srv.ActivateService(ctx, &stypes.MsgActivateService{ServiceID: "rt-mail", Signer: "r"}) + if _, err := srv.BindMailbox(ctx, &stypes.MsgBindMailbox{ + ServiceID: "rt-mail", MailboxID: "mbox-rt", HolderReachID: "holder-rt", Signer: "r", + }); err != nil { + t.Fatalf("BindMailbox round-trip: %v", err) + } + mail, _ := k.GetMailService(ctx, "rt-mail") + if mail.MailboxID != "mbox-rt" { + t.Errorf("mailbox-id = %q", mail.MailboxID) + } + if mail.HolderReachID != "holder-rt" { + t.Errorf("holder-reach-id = %q", mail.HolderReachID) + } +} + +// --- ValidateBasic (Msg types) ----------------------------------------------- + +func TestMsgRegisterServiceValidateBasic(t *testing.T) { + cases := []struct { + name string + msg stypes.MsgRegisterService + ok bool + }{ + {"valid", stypes.MsgRegisterService{ServiceID: "s", Kind: stypes.KindCare, OperatorReachID: "r", Name: "n", WindowID: "w", Signer: "r"}, true}, + {"empty service-id", stypes.MsgRegisterService{ServiceID: "", Kind: stypes.KindCare, OperatorReachID: "r", Name: "n", WindowID: "w", Signer: "r"}, false}, + {"unknown kind", stypes.MsgRegisterService{ServiceID: "s", Kind: stypes.ServiceKind("Bogus"), OperatorReachID: "r", Name: "n", WindowID: "w", Signer: "r"}, false}, + {"empty operator-reach-id", stypes.MsgRegisterService{ServiceID: "s", Kind: stypes.KindCare, OperatorReachID: "", Name: "n", WindowID: "w", Signer: "r"}, false}, + {"empty name", stypes.MsgRegisterService{ServiceID: "s", Kind: stypes.KindCare, OperatorReachID: "r", Name: "", WindowID: "w", Signer: "r"}, false}, + {"empty window-id", stypes.MsgRegisterService{ServiceID: "s", Kind: stypes.KindCare, OperatorReachID: "r", Name: "n", WindowID: "", Signer: "r"}, false}, + {"empty signer", stypes.MsgRegisterService{ServiceID: "s", Kind: stypes.KindCare, OperatorReachID: "r", Name: "n", WindowID: "w", Signer: ""}, false}, + } + for _, c := range cases { + err := c.msg.ValidateBasic() + if c.ok && err != nil { + t.Errorf("%s: expected ok, got %v", c.name, err) + } + if !c.ok && err == nil { + t.Errorf("%s: expected error, got nil", c.name) + } + } +} + +func TestMsgActivateServiceValidateBasic(t *testing.T) { + if err := (&stypes.MsgActivateService{ServiceID: "s", Signer: "r"}).ValidateBasic(); err != nil { + t.Errorf("valid: %v", err) + } + if err := (&stypes.MsgActivateService{ServiceID: "", Signer: "r"}).ValidateBasic(); err == nil { + t.Error("empty service-id should fail") + } + if err := (&stypes.MsgActivateService{ServiceID: "s", Signer: ""}).ValidateBasic(); err == nil { + t.Error("empty signer should fail") + } +} + +func TestMsgSuspendServiceValidateBasic(t *testing.T) { + if err := (&stypes.MsgSuspendService{ServiceID: "s", Signer: "r"}).ValidateBasic(); err != nil { + t.Errorf("valid: %v", err) + } + if err := (&stypes.MsgSuspendService{ServiceID: "", Signer: "r"}).ValidateBasic(); err == nil { + t.Error("empty service-id should fail") + } + if err := (&stypes.MsgSuspendService{ServiceID: "s", Signer: ""}).ValidateBasic(); err == nil { + t.Error("empty signer should fail") + } +} + +func TestMsgRevokeServiceValidateBasic(t *testing.T) { + if err := (&stypes.MsgRevokeService{ServiceID: "s", Signer: "r"}).ValidateBasic(); err != nil { + t.Errorf("valid: %v", err) + } + if err := (&stypes.MsgRevokeService{ServiceID: "", Signer: "r"}).ValidateBasic(); err == nil { + t.Error("empty service-id should fail") + } + if err := (&stypes.MsgRevokeService{ServiceID: "s", Signer: ""}).ValidateBasic(); err == nil { + t.Error("empty signer should fail") + } +} + +func TestMsgIssueCareGrantValidateBasic(t *testing.T) { + cases := []struct { + name string + msg stypes.MsgIssueCareGrant + ok bool + }{ + {"valid", stypes.MsgIssueCareGrant{ServiceID: "s", CareKind: "k", GrantRecipientReachID: "g", Signer: "r"}, true}, + {"empty service-id", stypes.MsgIssueCareGrant{ServiceID: "", CareKind: "k", GrantRecipientReachID: "g", Signer: "r"}, false}, + {"empty care-kind", stypes.MsgIssueCareGrant{ServiceID: "s", CareKind: "", GrantRecipientReachID: "g", Signer: "r"}, false}, + {"empty grant-recipient", stypes.MsgIssueCareGrant{ServiceID: "s", CareKind: "k", GrantRecipientReachID: "", Signer: "r"}, false}, + {"empty signer", stypes.MsgIssueCareGrant{ServiceID: "s", CareKind: "k", GrantRecipientReachID: "g", Signer: ""}, false}, + } + for _, c := range cases { + err := c.msg.ValidateBasic() + if c.ok && err != nil { + t.Errorf("%s: expected ok, got %v", c.name, err) + } + if !c.ok && err == nil { + t.Errorf("%s: expected error, got nil", c.name) + } + } +} + +func TestMsgActivateSIMValidateBasic(t *testing.T) { + cases := []struct { + name string + msg stypes.MsgActivateSIM + ok bool + }{ + {"valid", stypes.MsgActivateSIM{ServiceID: "s", Carrier: "c", RecipientReachID: "r", Signer: "r"}, true}, + {"empty service-id", stypes.MsgActivateSIM{ServiceID: "", Carrier: "c", RecipientReachID: "r", Signer: "r"}, false}, + {"empty carrier", stypes.MsgActivateSIM{ServiceID: "s", Carrier: "", RecipientReachID: "r", Signer: "r"}, false}, + {"empty recipient", stypes.MsgActivateSIM{ServiceID: "s", Carrier: "c", RecipientReachID: "", Signer: "r"}, false}, + {"empty signer", stypes.MsgActivateSIM{ServiceID: "s", Carrier: "c", RecipientReachID: "r", Signer: ""}, false}, + } + for _, c := range cases { + err := c.msg.ValidateBasic() + if c.ok && err != nil { + t.Errorf("%s: expected ok, got %v", c.name, err) + } + if !c.ok && err == nil { + t.Errorf("%s: expected error, got nil", c.name) + } + } +} + +func TestMsgProvisionVaultValidateBasic(t *testing.T) { + cases := []struct { + name string + msg stypes.MsgProvisionVault + ok bool + }{ + {"valid", stypes.MsgProvisionVault{ServiceID: "s", StorageQuotaGrain: 1000, Signer: "r"}, true}, + {"empty service-id", stypes.MsgProvisionVault{ServiceID: "", StorageQuotaGrain: 1000, Signer: "r"}, false}, + {"zero quota", stypes.MsgProvisionVault{ServiceID: "s", StorageQuotaGrain: 0, Signer: "r"}, false}, + {"negative quota", stypes.MsgProvisionVault{ServiceID: "s", StorageQuotaGrain: -1, Signer: "r"}, false}, + {"empty signer", stypes.MsgProvisionVault{ServiceID: "s", StorageQuotaGrain: 1000, Signer: ""}, false}, + } + for _, c := range cases { + err := c.msg.ValidateBasic() + if c.ok && err != nil { + t.Errorf("%s: expected ok, got %v", c.name, err) + } + if !c.ok && err == nil { + t.Errorf("%s: expected error, got nil", c.name) + } + } +} + +func TestMsgBindMailboxValidateBasic(t *testing.T) { + cases := []struct { + name string + msg stypes.MsgBindMailbox + ok bool + }{ + {"valid", stypes.MsgBindMailbox{ServiceID: "s", MailboxID: "m", HolderReachID: "h", Signer: "r"}, true}, + {"empty service-id", stypes.MsgBindMailbox{ServiceID: "", MailboxID: "m", HolderReachID: "h", Signer: "r"}, false}, + {"empty mailbox-id", stypes.MsgBindMailbox{ServiceID: "s", MailboxID: "", HolderReachID: "h", Signer: "r"}, false}, + {"empty holder-reach-id", stypes.MsgBindMailbox{ServiceID: "s", MailboxID: "m", HolderReachID: "", Signer: "r"}, false}, + {"empty signer", stypes.MsgBindMailbox{ServiceID: "s", MailboxID: "m", HolderReachID: "h", Signer: ""}, false}, + } + for _, c := range cases { + err := c.msg.ValidateBasic() + if c.ok && err != nil { + t.Errorf("%s: expected ok, got %v", c.name, err) + } + if !c.ok && err == nil { + t.Errorf("%s: expected error, got nil", c.name) + } + } +} + +// TestServicesMsgGetSigners asserts each Msg* GetSigners returns the +// signer as sdk.AccAddress bytes. +func TestServicesMsgGetSigners(t *testing.T) { + m1 := &stypes.MsgRegisterService{Signer: "reach-op"} + if got := m1.GetSigners(); len(got) != 1 || string(got[0]) != "reach-op" { + t.Errorf("MsgRegisterService GetSigners = %v, want [reach-op]", got) + } + m2 := &stypes.MsgActivateService{Signer: "h2"} + if string(m2.GetSigners()[0]) != "h2" { + t.Errorf("MsgActivateService GetSigners = %v", m2.GetSigners()) + } + m3 := &stypes.MsgSuspendService{Signer: "h3"} + if string(m3.GetSigners()[0]) != "h3" { + t.Errorf("MsgSuspendService GetSigners = %v", m3.GetSigners()) + } + m4 := &stypes.MsgRevokeService{Signer: "h4"} + if string(m4.GetSigners()[0]) != "h4" { + t.Errorf("MsgRevokeService GetSigners = %v", m4.GetSigners()) + } + m5 := &stypes.MsgIssueCareGrant{Signer: "h5"} + if string(m5.GetSigners()[0]) != "h5" { + t.Errorf("MsgIssueCareGrant GetSigners = %v", m5.GetSigners()) + } + m6 := &stypes.MsgActivateSIM{Signer: "h6"} + if string(m6.GetSigners()[0]) != "h6" { + t.Errorf("MsgActivateSIM GetSigners = %v", m6.GetSigners()) + } + m7 := &stypes.MsgProvisionVault{Signer: "h7"} + if string(m7.GetSigners()[0]) != "h7" { + t.Errorf("MsgProvisionVault GetSigners = %v", m7.GetSigners()) + } + m8 := &stypes.MsgBindMailbox{Signer: "h8"} + if string(m8.GetSigners()[0]) != "h8" { + t.Errorf("MsgBindMailbox GetSigners = %v", m8.GetSigners()) + } +} + +// --- Keeper store helpers ---------------------------------------------------- + +// TestSetGetService exercises the exported Keeper accessors that the +// simtest above does not directly hit (SetService direct, AllServices, +// per-kind stores round-trip, marshal-error paths) to push coverage +// >=80%. +func TestSetGetService(t *testing.T) { + ctx, _, _, _, k := newSimtestContext(t) + + // Empty-store accessor. + if got := k.AllServices(ctx); len(got) != 0 { + t.Errorf("AllServices empty = %d, want 0", len(got)) + } + // Direct SetService + read back. + k.SetService(ctx, stypes.ServiceInfo{ServiceID: "direct-1", Kind: stypes.KindCare, Status: stypes.ServiceActive}) + if s, ok := k.GetService(ctx, "direct-1"); !ok || s.Kind != stypes.KindCare { + t.Errorf("GetService = %+v ok=%v", s, ok) + } + if got := k.AllServices(ctx); len(got) != 1 { + t.Errorf("AllServices = %d, want 1", len(got)) + } + // Missing id. + if _, ok := k.GetService(ctx, "missing"); ok { + t.Error("GetService should return false for missing id") + } +} + +// TestPerKindStoreRoundTrip exercises the per-kind store Set/Get accessors. +func TestPerKindStoreRoundTrip(t *testing.T) { + ctx, _, _, _, k := newSimtestContext(t) + + // Care. + k.SetCareService(ctx, stypes.CareService{CareID: "c1", CareKind: "k"}) + if c, ok := k.GetCareService(ctx, "c1"); !ok || c.CareKind != "k" { + t.Errorf("GetCareService = %+v ok=%v", c, ok) + } + if _, ok := k.GetCareService(ctx, "missing"); ok { + t.Error("GetCareService should return false for missing id") + } + // SIM. + k.SetSIMService(ctx, stypes.SIMService{SIMID: "s1", Carrier: "carrier"}) + if s, ok := k.GetSIMService(ctx, "s1"); !ok || s.Carrier != "carrier" { + t.Errorf("GetSIMService = %+v ok=%v", s, ok) + } + if _, ok := k.GetSIMService(ctx, "missing"); ok { + t.Error("GetSIMService should return false for missing id") + } + // Vault. + k.SetVaultService(ctx, stypes.VaultService{VaultID: "v1", StorageQuotaGrain: 1000}) + if v, ok := k.GetVaultService(ctx, "v1"); !ok || v.StorageQuotaGrain != 1000 { + t.Errorf("GetVaultService = %+v ok=%v", v, ok) + } + if _, ok := k.GetVaultService(ctx, "missing"); ok { + t.Error("GetVaultService should return false for missing id") + } + // Mail. + k.SetMailService(ctx, stypes.MailService{MailID: "m1", MailboxID: "mb", HolderReachID: "h"}) + if m, ok := k.GetMailService(ctx, "m1"); !ok || m.MailboxID != "mb" { + t.Errorf("GetMailService = %+v ok=%v", m, ok) + } + if _, ok := k.GetMailService(ctx, "missing"); ok { + t.Error("GetMailService should return false for missing id") + } +} + +// TestStoreMarshalErrorPaths exercises the marshal-error branches on +// each store's Get accessor (corrupt bytes in store). +func TestStoreMarshalErrorPaths(t *testing.T) { + ctx, _, _, sk, k := newSimtestContext(t) + store := ctx.KVStore(sk) + // Corrupt ServiceInfo bytes. + store.Set([]byte("svc/corrupt-svc"), []byte("not-json")) + if _, ok := k.GetService(ctx, "corrupt-svc"); ok { + t.Error("GetService on corrupt bytes should return false") + } + // Corrupt CareService bytes. + store.Set([]byte("kind/care/corrupt-care"), []byte("not-json")) + if _, ok := k.GetCareService(ctx, "corrupt-care"); ok { + t.Error("GetCareService on corrupt bytes should return false") + } + // Corrupt SIMService bytes. + store.Set([]byte("kind/sim/corrupt-sim"), []byte("not-json")) + if _, ok := k.GetSIMService(ctx, "corrupt-sim"); ok { + t.Error("GetSIMService on corrupt bytes should return false") + } + // Corrupt VaultService bytes. + store.Set([]byte("kind/vault/corrupt-vault"), []byte("not-json")) + if _, ok := k.GetVaultService(ctx, "corrupt-vault"); ok { + t.Error("GetVaultService on corrupt bytes should return false") + } + // Corrupt MailService bytes. + store.Set([]byte("kind/mail/corrupt-mail"), []byte("not-json")) + if _, ok := k.GetMailService(ctx, "corrupt-mail"); ok { + t.Error("GetMailService on corrupt bytes should return false") + } +} + +// TestSetWindowKeeperPostConstruction exercises the SetWindowKeeper +// post-construction wiring setter. +func TestSetWindowKeeperPostConstruction(t *testing.T) { + _, _, _, sk, _ := newSimtestContext(t) + // Construct with nil WindowKeeper. + k := keeper.NewKeeper(newTestCodec(), sk, nil, &stubVaultKeeper{}) + // Re-wire post-construction. + wk := &stubWindowKeeper{} + k.SetWindowKeeper(wk) + if k.WindowKeeper() == nil { + t.Error("SetWindowKeeper should wire the shim") + } +} + +// TestSetVaultKeeperPostConstruction exercises the SetVaultKeeper +// post-construction wiring setter. +func TestSetVaultKeeperPostConstruction(t *testing.T) { + _, _, _, sk, _ := newSimtestContext(t) + k := keeper.NewKeeper(newTestCodec(), sk, &stubWindowKeeper{}, nil) + vk := &stubVaultKeeper{} + k.SetVaultKeeper(vk) + if k.VaultKeeper() == nil { + t.Error("SetVaultKeeper should wire the shim") + } +} + +// 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{}).RevokeService("not-a-ctx", + &stypes.MsgRevokeService{ServiceID: "s", Signer: "r"}) +} + +// TestRegisterServicePerKindMetadataCreated asserts RegisterService +// creates the per-kind metadata record for each ServiceKind (the kind is +// fixed at registration; the per-kind handlers later populate the +// operational fields). +func TestRegisterServicePerKindMetadataCreated(t *testing.T) { + ctx, _, _, _, k := newSimtestContext(t) + srv := keeper.NewMsgServerImpl(k) + + // Care. + srv.RegisterService(ctx, &stypes.MsgRegisterService{ + ServiceID: "meta-care", Kind: stypes.KindCare, + OperatorReachID: "r", Name: "n", WindowID: "w", Signer: "r", + }) + if _, ok := k.GetCareService(ctx, "meta-care"); !ok { + t.Error("CareService metadata should be created on RegisterService(Kind=Care)") + } + // SIM. + srv.RegisterService(ctx, &stypes.MsgRegisterService{ + ServiceID: "meta-sim", Kind: stypes.KindSIM, + OperatorReachID: "r", Name: "n", WindowID: "w", Signer: "r", + }) + if _, ok := k.GetSIMService(ctx, "meta-sim"); !ok { + t.Error("SIMService metadata should be created on RegisterService(Kind=SIM)") + } + // Vault. + srv.RegisterService(ctx, &stypes.MsgRegisterService{ + ServiceID: "meta-vault", Kind: stypes.KindVault, + OperatorReachID: "r", Name: "n", WindowID: "w", Signer: "r", + }) + if _, ok := k.GetVaultService(ctx, "meta-vault"); !ok { + t.Error("VaultService metadata should be created on RegisterService(Kind=Vault)") + } + // Mail. + srv.RegisterService(ctx, &stypes.MsgRegisterService{ + ServiceID: "meta-mail", Kind: stypes.KindMail, + OperatorReachID: "r", Name: "n", WindowID: "w", Signer: "r", + }) + if _, ok := k.GetMailService(ctx, "meta-mail"); !ok { + t.Error("MailService metadata should be created on RegisterService(Kind=Mail)") + } +} + +// TestWindowKeeperErrorRejected asserts the WindowKeeper shim returning +// an error is treated as not-Active (the op is REJECTED — A-552). +func TestWindowKeeperErrorRejected(t *testing.T) { + ctx, wk, _, _, k := newSimtestContext(t) + srv := keeper.NewMsgServerImpl(k) + + wk.err = fmt.Errorf("window keeper unavailable") + _, err := srv.RegisterService(ctx, &stypes.MsgRegisterService{ + ServiceID: "svc-err", Kind: stypes.KindCare, + OperatorReachID: "r", Name: "n", WindowID: "w", Signer: "r", + }) + if err == nil { + t.Error("RegisterService should be rejected when WindowKeeper returns an error (A-552)") + } + if !strings.Contains(err.Error(), "window-grant check") { + t.Errorf("error = %q, want 'window-grant check'", err.Error()) + } +} + +// --- ServiceStatus enum helpers (regression firewall — ServiceStatusCount=4) -- + +func TestAllServiceStatusesCount(t *testing.T) { + if len(stypes.AllServiceStatuses()) != stypes.ServiceStatusCount { + t.Errorf("AllServiceStatuses len = %d, want %d", len(stypes.AllServiceStatuses()), stypes.ServiceStatusCount) + } + if stypes.ServiceStatusCount != 4 { + t.Errorf("ServiceStatusCount = %d, want 4 (REQ-025 LOCKED)", stypes.ServiceStatusCount) + } +} + +func TestAllServiceStatusesNames(t *testing.T) { + want := []string{"Pending", "Active", "Suspended", "Revoked"} + all := stypes.AllServiceStatuses() + if len(all) != len(want) { + t.Fatalf("len = %d, want %d", len(all), len(want)) + } + for i, s := range all { + if string(s) != want[i] { + t.Errorf("AllServiceStatuses()[%d] = %q, want %q", i, s, want[i]) + } + } +} + +func TestIsTerminalServiceStatus(t *testing.T) { + if stypes.IsTerminalServiceStatus(stypes.ServicePending) { + t.Error("Pending should not be terminal") + } + if stypes.IsTerminalServiceStatus(stypes.ServiceActive) { + t.Error("Active should not be terminal") + } + if stypes.IsTerminalServiceStatus(stypes.ServiceSuspended) { + t.Error("Suspended should not be terminal") + } + if !stypes.IsTerminalServiceStatus(stypes.ServiceRevoked) { + t.Error("Revoked should be terminal") + } +} + +func TestValidServiceTransition(t *testing.T) { + // Valid transitions. + validCases := []struct { + from, to stypes.ServiceStatus + }{ + {stypes.ServicePending, stypes.ServiceActive}, + {stypes.ServicePending, stypes.ServiceRevoked}, + {stypes.ServiceActive, stypes.ServiceSuspended}, + {stypes.ServiceActive, stypes.ServiceRevoked}, + {stypes.ServiceSuspended, stypes.ServiceRevoked}, + } + for _, c := range validCases { + if !stypes.ValidServiceTransition(c.from, c.to) { + t.Errorf("ValidServiceTransition(%q, %q) = false, want true", c.from, c.to) + } + } + // Invalid transitions. + invalidCases := []struct { + from, to stypes.ServiceStatus + }{ + {stypes.ServiceActive, stypes.ServicePending}, // no backward to Pending + {stypes.ServiceSuspended, stypes.ServiceActive}, // no Suspended -> Active (v0.5 scope) + {stypes.ServiceSuspended, stypes.ServicePending}, // no backward to Pending + {stypes.ServiceRevoked, stypes.ServicePending}, // terminal — no out + {stypes.ServiceRevoked, stypes.ServiceActive}, // terminal — no out + {stypes.ServiceRevoked, stypes.ServiceSuspended}, // terminal — no out + {stypes.ServicePending, stypes.ServiceSuspended}, // must Activate before Suspend + } + for _, c := range invalidCases { + if stypes.ValidServiceTransition(c.from, c.to) { + t.Errorf("ValidServiceTransition(%q, %q) = true, want false", c.from, c.to) + } + } +} + +// --- G-003 import-invariant (test exemption documentation) ----------------- + +// TestG003NoWindowOrVaultTypesImport asserts the services production +// files do NOT import x/window/types or x/vault/types by struct (G-003 +// — the WindowKeeper and VaultKeeper interfaces are the only coupling; +// no struct import). This is a tested invariant. The full project-wide +// G-003 invariant is enforced by the x/window/types G-003 meta-test +// (scans all x/**/*.go including the new x/services files); here we do +// a lightweight assertion: the stub WindowKeeper and VaultKeeper in +// this simtest file satisfy the interfaces by-ID-string (not by struct +// import). +func TestG003NoWindowOrVaultTypesImport(t *testing.T) { + wk := &stubWindowKeeper{} + if st, err := wk.GetWindowStatus("window-by-id"); err != nil || st != stypes.WindowStatusActive { + t.Errorf("stub GetWindowStatus by-ID-string should return Active; got %q err=%v", st, err) + } + if len(wk.calls) != 1 { + t.Errorf("expected 1 window call recorded, got %d", len(wk.calls)) + } + vk := &stubVaultKeeper{} + if err := vk.ProvisionVault("svc-by-id", 1000); err != nil { + t.Errorf("stub ProvisionVault by-ID-string should succeed: %v", err) + } + if len(vk.calls) != 1 { + t.Errorf("expected 1 vault call recorded, got %d", len(vk.calls)) + } +} diff --git a/x/services/module.go b/x/services/module.go new file mode 100644 index 0000000..b85bbc5 --- /dev/null +++ b/x/services/module.go @@ -0,0 +1,85 @@ +package services + +// module.go holds the services module's AppModule + RegisterServices +// (P5-02-01, REQ-037). +// +// The AppModule wraps the services 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 WindowKeeper and VaultKeeper expected-keeper shims are injected +// at construction (nil-able for partial tests). The WindowKeeper shim +// is the A-552 window-grant-on-every-op authority boundary; the +// VaultKeeper shim is the A-553 VaultService provisioning boundary. + +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/services/keeper" + "github.com/oy/openyield/x/services/types" +) + +// ConsensusVersion is the services module's consensus version (AppModule). +const ConsensusVersion = 1 + +// AppModule is the services application module (simtest-grade — D-054). +type AppModule struct { + keeper keeper.Keeper +} + +// NewAppModule constructs a new services AppModule. The WindowKeeper +// and VaultKeeper expected-keeper shims are injected (nil-able for +// partial tests). The WindowKeeper shim is the A-552 window-grant-on- +// every-op authority boundary; the VaultKeeper shim is the A-553 +// VaultService provisioning boundary. +func NewAppModule(cdc codec.Codec, storeKey storetypes.StoreKey, wk types.WindowKeeper, vk types.VaultKeeper) AppModule { + k := keeper.NewKeeper(cdc, storeKey, wk, vk) + return AppModule{keeper: k} +} + +// RegisterServices registers the services 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 services 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 services 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) + _ = gs +} + +// 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/services/types/expected_keepers.go b/x/services/types/expected_keepers.go new file mode 100644 index 0000000..e980060 --- /dev/null +++ b/x/services/types/expected_keepers.go @@ -0,0 +1,120 @@ +package types + +// expected_keepers.go holds the Go INTERFACES for the cross-module keepers +// x/services depends on at runtime (P5-01-01, REQ-037; G-003 firewall — +// ibc-go expected-keepers convention; mirrors x/partner/types/expected_keepers.go +// and x/hub/types/expected_keepers.go). +// +// The services runtime (REQ-037) depends on TWO cross-module keepers: +// +// 1. x/window (WindowKeeper) — the service-grant authority boundary. A +// service-grant opens a Window on the holder's behalf (A-307); the +// Window's status is the service's authority. The handler consults +// WindowKeeper.GetWindowStatus on EVERY service operation (A-552: +// window-grant-on-every-op — not just at registration); a Window that +// is not Active (Revoked / Expired / unknown) invalidates the op. This +// is the runtime echo of the v0.3 ServiceInfo.window-id by-ID-string +// field: the field stays a string (G-003), and the interface is the +// runtime validity boundary. +// +// 2. x/vault (VaultKeeper) — the VaultService (ServiceKind=Vault) +// provisioning shim. The MsgProvisionVault handler delegates the +// storage-quota provisioning to the x/vault keeper by-ID-string +// (A-553: VaultService references x/vault by ID via the shim — G-003). +// The v0.3 VaultService struct (types.go) named the x/vault collision +// conceptually (the ServiceKind "Vault" is a service kind, NOT a +// struct import); v0.5 wires the runtime provisioning via this +// interface (no struct import of x/vault/types — G-003 intact). +// +// Both dependencies are expressed as INTERFACES defined HERE (in +// x/services/types), NOT as struct imports of x/window/types or +// x/vault/types. The concrete keepers satisfy these interfaces +// structurally (the P5 simtest wires stub implementations — G-003 test +// exemption); the handler depends on the interface, preserving G-003's +// intent (no cross-module struct coupling, no import cycles). +// +// Test-only cross-package imports (the G-003 test exemption) remain +// exempt: the simtest imports x/services/keeper + defines stub types +// that satisfy the interfaces (no production struct imports across +// x//types). +// +// Lexicon note (REQ-012): "Window", "Vault", "service", "grant", +// "provisioning" are all lexicon-clean. The holder identifier is +// "reach-id" (NOT a banned financial-holder term; use Holder/Reach). + +// WindowStatus is the local redefinition of the x/window Window status +// the services runtime cares about (G-003 — no struct import of +// x/window/types; the status string crosses the interface boundary by +// value). Only the Active status authorizes a service operation; any +// other status (Revoked, Expired, unknown) invalidates the op (A-552). +type WindowStatus string + +const ( + // WindowStatusActive is the only status that authorizes a service + // operation. The handler consults WindowKeeper.GetWindowStatus on + // every op and REJECTS the op if the status is not Active (A-552). + WindowStatusActive WindowStatus = "Active" + // WindowStatusRevoked is a permanently-revoked Window (invalidates + // the service op — A-552). + WindowStatusRevoked WindowStatus = "Revoked" + // WindowStatusExpired is an expired Window (invalidates the service + // op — A-552: an op after the Window expired is a Window-violation). + WindowStatusExpired WindowStatus = "Expired" + // WindowStatusUnknown is the sentinel for a Window the keeper does + // not know about (treated as not-Active — the op is REJECTED). + WindowStatusUnknown WindowStatus = "Unknown" +) + +// WindowKeeper is the expected-keeper interface for x/window (G-003). The +// services handler consults it on EVERY service operation (A-552): +// +// - RegisterService: the window-id on the new service must reference an +// Active Window BEFORE the service is created; a non-Active Window +// REJECTS the registration (the service is not created). +// - ActivateService / SuspendService / RevokeService: the window-id on +// the existing service must still be Active BEFORE the transition; +// a revoked/expired Window invalidates the op (the service stays in +// its pre-op status). +// - Per-kind handlers (IssueCareGrant, ActivateSIM, ProvisionVault, +// BindMailbox): the window-id on the service must still be Active +// BEFORE the per-kind op; a revoked/expired Window REJECTS the op +// (the per-kind state is NOT mutated). +// +// No struct import of x/window/types — the interface is the by-ID-string +// boundary (G-003). The windowID is an opaque string (the by-ID-string +// ref to an x/window Window; A-307). +type WindowKeeper interface { + // GetWindowStatus reports the status of the named Window (by-ID-string) + // at the current block. The services handler consults this BEFORE + // every service op (A-552 — window-grant-on-every-op). Returns + // WindowStatusActive if the Window is live and authorizes ops; + // WindowStatusRevoked / WindowStatusExpired / WindowStatusUnknown if + // the Window is not authorizing. An error indicates the keeper could + // not answer (treated as not-Active — the op is REJECTED). + GetWindowStatus(windowID string) (WindowStatus, error) +} + +// VaultKeeper is the expected-keeper interface for x/vault (G-003, +// A-553). The VaultService (ServiceKind=Vault) handler calls it for: +// +// - ProvisionVault: the MsgProvisionVault handler delegates the +// storage-quota-grain provisioning to the x/vault keeper by-ID-string +// (the vault-id on the VaultService is the by-ID-string ref to an +// x/vault Vault). A nil shim REJECTS the provisioning (the +// VaultService requires a real vault keeper — a nil shim is a wiring +// error, not a simtest skip path; the simtest wires a stub vault +// keeper, never nil). +// +// No struct import of x/vault/types — the interface is the by-ID-string +// boundary (G-003, A-553). The serviceID is the by-ID-string ref to the +// VaultService; the storage-quota-grain is the OY internal unit (by name +// only — no x/bread import). +type VaultKeeper interface { + // ProvisionVault records the storage-quota-grain provisioning for + // the named VaultService (by-ID-string). The MsgProvisionVault + // handler consults this AFTER the window-grant check (A-552) and + // BEFORE emitting the provisioning event. A non-nil error REJECTS + // the provisioning (the VaultService storage-quota-grain is NOT + // updated). + ProvisionVault(serviceID string, quotaGrain int64) error +} diff --git a/x/services/types/msg_services.go b/x/services/types/msg_services.go new file mode 100644 index 0000000..f3949d1 --- /dev/null +++ b/x/services/types/msg_services.go @@ -0,0 +1,552 @@ +package types + +// msg_services.go holds the x/services Msg* types implementing sdk.Msg +// (P5-01-01, REQ-037; G-006 controlled exception: types/ gains the +// cosmos-sdk import for sdk.Msg — D-055; the invariant/lexicon tests in +// *_test.go stay stdlib-only per G-024, isolated from this msg_*.go +// file). Each Msg carries a ValidateBasic (stateless) and GetSigners. +// +// The eight Services Msg types drive the Care/SIM/Vault/Mail runtime +// (REQ-037, A-551 per-kind typed dispatch — one Msg* per ServiceKind, +// NOT a generic MsgInvokeService): +// +// Lifecycle (kind-agnostic): +// - MsgRegisterService: register a service (operator-reach-id valid; +// window-id must reference an Active Window — checked via the +// WindowKeeper shim at the handler; status=Pending). +// - MsgActivateService: Pending → Active (window-id must still be +// Active — A-552 window-grant-on-every-op). +// - MsgSuspendService: Active → Suspended. +// - MsgRevokeService: any → Revoked (terminal; revocation requires +// the Window grantor or a Watcher quorum — simtest wiring uses a +// nil WindowKeeper for the grantor check). +// +// Per-kind (typed dispatch — A-551): +// - MsgIssueCareGrant (Care) — issue a community-care grant. +// - MsgActivateSIM (SIM) — activate a connectivity SIM. +// - MsgProvisionVault (Vault) — provision storage-quota-grain via +// the VaultKeeper shim (A-553: references x/vault by ID-string; +// G-003 — no struct import of x/vault/types). +// - MsgBindMailbox (Mail) — bind a messaging mailbox. +// +// All cross-module refs are by-ID-string (G-003): service-id is this +// service's ID; operator-reach-id references an x/identity Reach by +// ID-string; window-id references an x/window Window by ID-string +// (A-307). GetSigners returns the signer reach-ids encoded as +// sdk.AccAddress bytes. The reach-id is the lexicon-clean holder +// identifier (G-003 — NOT a banned financial-holder term; use +// Holder/Reach). + +import ( + "fmt" + + sdk "github.com/cosmos/cosmos-sdk/types" +) + +// --- MsgRegisterService ------------------------------------------------------ + +// MsgRegisterService registers a service (status=Pending). The handler +// enforces the window-id must reference an Active Window via the +// WindowKeeper shim (A-552). ValidateBasic is stateless: non-empty +// service-id, non-empty operator-reach-id, non-empty window-id, a known +// ServiceKind, non-empty name, non-empty signer. +type MsgRegisterService struct { + ServiceID string `json:"service_id" yaml:"service_id"` + Kind ServiceKind `json:"kind" yaml:"kind"` + OperatorReachID string `json:"operator_reach_id" yaml:"operator_reach_id"` + Name string `json:"name" yaml:"name"` + WindowID string `json:"window_id" yaml:"window_id"` + Signer string `json:"signer" yaml:"signer"` +} + +// Reset implements proto.Message (sdk.Msg = proto.Message). +func (m *MsgRegisterService) Reset() { *m = MsgRegisterService{} } + +// String implements proto.Message. +func (m *MsgRegisterService) String() string { + return fmt.Sprintf("MsgRegisterService{ServiceID:%s Kind:%s OperatorReachID:%s Name:%s WindowID:%s Signer:%s}", + m.ServiceID, m.Kind, m.OperatorReachID, m.Name, m.WindowID, m.Signer) +} + +// ProtoMessage implements proto.Message. +func (*MsgRegisterService) ProtoMessage() {} + +// ValidateBasic is the stateless validation: non-empty service-id, a +// known ServiceKind, non-empty operator-reach-id, non-empty name, +// non-empty window-id, non-empty signer. The handler enforces the +// stateful Window-Active check via the WindowKeeper shim (A-552) + +// idempotency (service-id must not already exist). +func (m *MsgRegisterService) ValidateBasic() error { + if m.ServiceID == "" { + return fmt.Errorf("services: empty service-id") + } + if !knownServiceKind(m.Kind) { + return fmt.Errorf("services: unknown service kind %q", m.Kind) + } + if m.OperatorReachID == "" { + return fmt.Errorf("services: empty operator-reach-id") + } + if m.Name == "" { + return fmt.Errorf("services: empty name") + } + if m.WindowID == "" { + return fmt.Errorf("services: empty window-id") + } + if m.Signer == "" { + return fmt.Errorf("services: empty signer") + } + return nil +} + +// GetSigners returns the signer's reach-id as sdk.AccAddress bytes. +func (m *MsgRegisterService) GetSigners() []sdk.AccAddress { + return []sdk.AccAddress{[]byte(m.Signer)} +} + +// --- MsgActivateService ------------------------------------------------------ + +// MsgActivateService transitions a service Pending → Active. The +// handler enforces the window-id on the existing service must still be +// Active (A-552 window-grant-on-every-op). ValidateBasic is stateless: +// non-empty service-id, non-empty signer. +type MsgActivateService struct { + ServiceID string `json:"service_id" yaml:"service_id"` + Signer string `json:"signer" yaml:"signer"` +} + +// Reset implements proto.Message. +func (m *MsgActivateService) Reset() { *m = MsgActivateService{} } + +// String implements proto.Message. +func (m *MsgActivateService) String() string { + return fmt.Sprintf("MsgActivateService{ServiceID:%s Signer:%s}", m.ServiceID, m.Signer) +} + +// ProtoMessage implements proto.Message. +func (*MsgActivateService) ProtoMessage() {} + +// ValidateBasic is the stateless validation: non-empty service-id, +// non-empty signer. The handler enforces the stateful source-status +// check (must be Pending) and the window-grant Active check (A-552). +func (m *MsgActivateService) ValidateBasic() error { + if m.ServiceID == "" { + return fmt.Errorf("services: empty service-id") + } + if m.Signer == "" { + return fmt.Errorf("services: empty signer") + } + return nil +} + +// GetSigners returns the signer's reach-id as sdk.AccAddress bytes. +func (m *MsgActivateService) GetSigners() []sdk.AccAddress { + return []sdk.AccAddress{[]byte(m.Signer)} +} + +// --- MsgSuspendService ------------------------------------------------------- + +// MsgSuspendService transitions a service Active → Suspended. The +// handler enforces the window-id on the existing service must still be +// Active (A-552 window-grant-on-every-op — a revoked Window +// invalidates the transition). ValidateBasic is stateless: non-empty +// service-id, non-empty signer. +type MsgSuspendService struct { + ServiceID string `json:"service_id" yaml:"service_id"` + Signer string `json:"signer" yaml:"signer"` +} + +// Reset implements proto.Message. +func (m *MsgSuspendService) Reset() { *m = MsgSuspendService{} } + +// String implements proto.Message. +func (m *MsgSuspendService) String() string { + return fmt.Sprintf("MsgSuspendService{ServiceID:%s Signer:%s}", m.ServiceID, m.Signer) +} + +// ProtoMessage implements proto.Message. +func (*MsgSuspendService) ProtoMessage() {} + +// ValidateBasic is the stateless validation: non-empty service-id, +// non-empty signer. The handler enforces the stateful source-status +// check (must be Active) and the window-grant Active check (A-552). +func (m *MsgSuspendService) ValidateBasic() error { + if m.ServiceID == "" { + return fmt.Errorf("services: empty service-id") + } + if m.Signer == "" { + return fmt.Errorf("services: empty signer") + } + return nil +} + +// GetSigners returns the signer's reach-id as sdk.AccAddress bytes. +func (m *MsgSuspendService) GetSigners() []sdk.AccAddress { + return []sdk.AccAddress{[]byte(m.Signer)} +} + +// --- MsgRevokeService -------------------------------------------------------- + +// MsgRevokeService transitions a service to Revoked (terminal). The +// handler enforces the window-id on the existing service must still be +// Active (A-552 window-grant-on-every-op — a revoked Window invalidates +// the revocation too, mirroring the grantor-authorized revoke path). +// Revocation in the simtest is grantor-authorized via the signer reach- +// id; a Watcher quorum path is documented for the live chain (v0.6+). +// ValidateBasic is stateless: non-empty service-id, non-empty signer. +type MsgRevokeService struct { + ServiceID string `json:"service_id" yaml:"service_id"` + Signer string `json:"signer" yaml:"signer"` +} + +// Reset implements proto.Message. +func (m *MsgRevokeService) Reset() { *m = MsgRevokeService{} } + +// String implements proto.Message. +func (m *MsgRevokeService) String() string { + return fmt.Sprintf("MsgRevokeService{ServiceID:%s Signer:%s}", m.ServiceID, m.Signer) +} + +// ProtoMessage implements proto.Message. +func (*MsgRevokeService) ProtoMessage() {} + +// ValidateBasic is the stateless validation: non-empty service-id, +// non-empty signer. The handler enforces the stateful source-status +// check (must not already be Revoked — idempotent reject) and the +// window-grant Active check (A-552). +func (m *MsgRevokeService) ValidateBasic() error { + if m.ServiceID == "" { + return fmt.Errorf("services: empty service-id") + } + if m.Signer == "" { + return fmt.Errorf("services: empty signer") + } + return nil +} + +// GetSigners returns the signer's reach-id as sdk.AccAddress bytes. +func (m *MsgRevokeService) GetSigners() []sdk.AccAddress { + return []sdk.AccAddress{[]byte(m.Signer)} +} + +// --- MsgIssueCareGrant (Care — A-551 typed dispatch) ------------------------ + +// MsgIssueCareGrant issues a community-care grant against a Care service +// (ServiceKind=Care — A-551 per-kind typed dispatch, NOT a generic +// MsgInvokeService). The handler enforces the window-id on the existing +// Care service must still be Active (A-552 window-grant-on-every-op). +// ValidateBasic is stateless: non-empty service-id, non-empty +// care-kind, non-empty grant-recipient-reach-id, non-empty signer. +type MsgIssueCareGrant struct { + ServiceID string `json:"service_id" yaml:"service_id"` + CareKind string `json:"care_kind" yaml:"care_kind"` + GrantRecipientReachID string `json:"grant_recipient_reach_id" yaml:"grant_recipient_reach_id"` + Signer string `json:"signer" yaml:"signer"` +} + +// Reset implements proto.Message. +func (m *MsgIssueCareGrant) Reset() { *m = MsgIssueCareGrant{} } + +// String implements proto.Message. +func (m *MsgIssueCareGrant) String() string { + return fmt.Sprintf("MsgIssueCareGrant{ServiceID:%s CareKind:%s GrantRecipientReachID:%s Signer:%s}", + m.ServiceID, m.CareKind, m.GrantRecipientReachID, m.Signer) +} + +// ProtoMessage implements proto.Message. +func (*MsgIssueCareGrant) ProtoMessage() {} + +// ValidateBasic is the stateless validation: non-empty service-id, +// non-empty care-kind, non-empty grant-recipient-reach-id, non-empty +// signer. The handler enforces the stateful service-exists + kind=Care +// + window-grant Active checks (A-552). +func (m *MsgIssueCareGrant) ValidateBasic() error { + if m.ServiceID == "" { + return fmt.Errorf("services: empty service-id") + } + if m.CareKind == "" { + return fmt.Errorf("services: empty care-kind") + } + if m.GrantRecipientReachID == "" { + return fmt.Errorf("services: empty grant-recipient-reach-id") + } + if m.Signer == "" { + return fmt.Errorf("services: empty signer") + } + return nil +} + +// GetSigners returns the signer's reach-id as sdk.AccAddress bytes. +func (m *MsgIssueCareGrant) GetSigners() []sdk.AccAddress { + return []sdk.AccAddress{[]byte(m.Signer)} +} + +// --- MsgActivateSIM (SIM — A-551 typed dispatch) ---------------------------- + +// MsgActivateSIM activates a connectivity SIM against a SIM service +// (ServiceKind=SIM — A-551 per-kind typed dispatch). The handler +// enforces the window-id on the existing SIM service must still be +// Active (A-552 window-grant-on-every-op). ValidateBasic is stateless: +// non-empty service-id, non-empty carrier, non-empty +// recipient-reach-id, non-empty signer. +type MsgActivateSIM struct { + ServiceID string `json:"service_id" yaml:"service_id"` + Carrier string `json:"carrier" yaml:"carrier"` + RecipientReachID string `json:"recipient_reach_id" yaml:"recipient_reach_id"` + Signer string `json:"signer" yaml:"signer"` +} + +// Reset implements proto.Message. +func (m *MsgActivateSIM) Reset() { *m = MsgActivateSIM{} } + +// String implements proto.Message. +func (m *MsgActivateSIM) String() string { + return fmt.Sprintf("MsgActivateSIM{ServiceID:%s Carrier:%s RecipientReachID:%s Signer:%s}", + m.ServiceID, m.Carrier, m.RecipientReachID, m.Signer) +} + +// ProtoMessage implements proto.Message. +func (*MsgActivateSIM) ProtoMessage() {} + +// ValidateBasic is the stateless validation: non-empty service-id, +// non-empty carrier, non-empty recipient-reach-id, non-empty signer. +// The handler enforces the stateful service-exists + kind=SIM + +// window-grant Active checks (A-552). +func (m *MsgActivateSIM) ValidateBasic() error { + if m.ServiceID == "" { + return fmt.Errorf("services: empty service-id") + } + if m.Carrier == "" { + return fmt.Errorf("services: empty carrier") + } + if m.RecipientReachID == "" { + return fmt.Errorf("services: empty recipient-reach-id") + } + if m.Signer == "" { + return fmt.Errorf("services: empty signer") + } + return nil +} + +// GetSigners returns the signer's reach-id as sdk.AccAddress bytes. +func (m *MsgActivateSIM) GetSigners() []sdk.AccAddress { + return []sdk.AccAddress{[]byte(m.Signer)} +} + +// --- MsgProvisionVault (Vault — A-551 typed dispatch, A-553 x/vault shim) --- + +// MsgProvisionVault provisions storage-quota-grain against a Vault +// service (ServiceKind=Vault — A-551 per-kind typed dispatch; A-553: +// references x/vault by ID via the VaultKeeper shim — G-003). The +// handler enforces the window-id on the existing Vault service must +// still be Active (A-552) and delegates the storage-quota-grain +// provisioning to the VaultKeeper shim. ValidateBasic is stateless: +// non-empty service-id, storage-quota-grain > 0, non-empty signer. +type MsgProvisionVault struct { + ServiceID string `json:"service_id" yaml:"service_id"` + StorageQuotaGrain int64 `json:"storage_quota_grain" yaml:"storage_quota_grain"` + Signer string `json:"signer" yaml:"signer"` +} + +// Reset implements proto.Message. +func (m *MsgProvisionVault) Reset() { *m = MsgProvisionVault{} } + +// String implements proto.Message. +func (m *MsgProvisionVault) String() string { + return fmt.Sprintf("MsgProvisionVault{ServiceID:%s StorageQuotaGrain:%d Signer:%s}", + m.ServiceID, m.StorageQuotaGrain, m.Signer) +} + +// ProtoMessage implements proto.Message. +func (*MsgProvisionVault) ProtoMessage() {} + +// ValidateBasic is the stateless validation: non-empty service-id, +// storage-quota-grain > 0, non-empty signer. The handler enforces the +// stateful service-exists + kind=Vault + window-grant Active checks +// (A-552) and delegates to the VaultKeeper shim (A-553). +func (m *MsgProvisionVault) ValidateBasic() error { + if m.ServiceID == "" { + return fmt.Errorf("services: empty service-id") + } + if m.StorageQuotaGrain <= 0 { + return fmt.Errorf("services: storage-quota-grain must be > 0") + } + if m.Signer == "" { + return fmt.Errorf("services: empty signer") + } + return nil +} + +// GetSigners returns the signer's reach-id as sdk.AccAddress bytes. +func (m *MsgProvisionVault) GetSigners() []sdk.AccAddress { + return []sdk.AccAddress{[]byte(m.Signer)} +} + +// --- MsgBindMailbox (Mail — A-551 typed dispatch) --------------------------- + +// MsgBindMailbox binds a messaging mailbox against a Mail service +// (ServiceKind=Mail — A-551 per-kind typed dispatch). The handler +// enforces the window-id on the existing Mail service must still be +// Active (A-552 window-grant-on-every-op). ValidateBasic is stateless: +// non-empty service-id, non-empty mailbox-id, non-empty +// holder-reach-id, non-empty signer. +type MsgBindMailbox struct { + ServiceID string `json:"service_id" yaml:"service_id"` + MailboxID string `json:"mailbox_id" yaml:"mailbox_id"` + HolderReachID string `json:"holder_reach_id" yaml:"holder_reach_id"` + Signer string `json:"signer" yaml:"signer"` +} + +// Reset implements proto.Message. +func (m *MsgBindMailbox) Reset() { *m = MsgBindMailbox{} } + +// String implements proto.Message. +func (m *MsgBindMailbox) String() string { + return fmt.Sprintf("MsgBindMailbox{ServiceID:%s MailboxID:%s HolderReachID:%s Signer:%s}", + m.ServiceID, m.MailboxID, m.HolderReachID, m.Signer) +} + +// ProtoMessage implements proto.Message. +func (*MsgBindMailbox) ProtoMessage() {} + +// ValidateBasic is the stateless validation: non-empty service-id, +// non-empty mailbox-id, non-empty holder-reach-id, non-empty signer. +// The handler enforces the stateful service-exists + kind=Mail + +// window-grant Active checks (A-552). +func (m *MsgBindMailbox) ValidateBasic() error { + if m.ServiceID == "" { + return fmt.Errorf("services: empty service-id") + } + if m.MailboxID == "" { + return fmt.Errorf("services: empty mailbox-id") + } + if m.HolderReachID == "" { + return fmt.Errorf("services: empty holder-reach-id") + } + if m.Signer == "" { + return fmt.Errorf("services: empty signer") + } + return nil +} + +// GetSigners returns the signer's reach-id as sdk.AccAddress bytes. +func (m *MsgBindMailbox) GetSigners() []sdk.AccAddress { + return []sdk.AccAddress{[]byte(m.Signer)} +} + +// --- MsgServer interface + Response types ----------------------------------- + +// MsgServer is the services module's message server interface (one method +// per Msg*). The keeper's msg_server.go implements this; module.go's +// RegisterServices wires the implementation. This is the hand-rolled +// equivalent of the protobuf-generated MsgServer interface (no codegen +// per the skeleton's zero-codegen style). +type MsgServer interface { + RegisterService(ctx interface{}, msg *MsgRegisterService) (*MsgRegisterServiceResponse, error) + ActivateService(ctx interface{}, msg *MsgActivateService) (*MsgActivateServiceResponse, error) + SuspendService(ctx interface{}, msg *MsgSuspendService) (*MsgSuspendServiceResponse, error) + RevokeService(ctx interface{}, msg *MsgRevokeService) (*MsgRevokeServiceResponse, error) + IssueCareGrant(ctx interface{}, msg *MsgIssueCareGrant) (*MsgIssueCareGrantResponse, error) + ActivateSIM(ctx interface{}, msg *MsgActivateSIM) (*MsgActivateSIMResponse, error) + ProvisionVault(ctx interface{}, msg *MsgProvisionVault) (*MsgProvisionVaultResponse, error) + BindMailbox(ctx interface{}, msg *MsgBindMailbox) (*MsgBindMailboxResponse, error) +} + +// Response types (hand-rolled equivalents of the protobuf-generated +// response wrappers; empty bodies — the response is the state mutation + +// event). + +// MsgRegisterServiceResponse is the response to MsgRegisterService. +type MsgRegisterServiceResponse struct{} + +// Reset implements proto.Message. +func (m *MsgRegisterServiceResponse) Reset() { *m = MsgRegisterServiceResponse{} } + +// String implements proto.Message. +func (m *MsgRegisterServiceResponse) String() string { return "MsgRegisterServiceResponse{}" } + +// ProtoMessage implements proto.Message. +func (*MsgRegisterServiceResponse) ProtoMessage() {} + +// MsgActivateServiceResponse is the response to MsgActivateService. +type MsgActivateServiceResponse struct{} + +// Reset implements proto.Message. +func (m *MsgActivateServiceResponse) Reset() { *m = MsgActivateServiceResponse{} } + +// String implements proto.Message. +func (m *MsgActivateServiceResponse) String() string { return "MsgActivateServiceResponse{}" } + +// ProtoMessage implements proto.Message. +func (*MsgActivateServiceResponse) ProtoMessage() {} + +// MsgSuspendServiceResponse is the response to MsgSuspendService. +type MsgSuspendServiceResponse struct{} + +// Reset implements proto.Message. +func (m *MsgSuspendServiceResponse) Reset() { *m = MsgSuspendServiceResponse{} } + +// String implements proto.Message. +func (m *MsgSuspendServiceResponse) String() string { return "MsgSuspendServiceResponse{}" } + +// ProtoMessage implements proto.Message. +func (*MsgSuspendServiceResponse) ProtoMessage() {} + +// MsgRevokeServiceResponse is the response to MsgRevokeService. +type MsgRevokeServiceResponse struct{} + +// Reset implements proto.Message. +func (m *MsgRevokeServiceResponse) Reset() { *m = MsgRevokeServiceResponse{} } + +// String implements proto.Message. +func (m *MsgRevokeServiceResponse) String() string { return "MsgRevokeServiceResponse{}" } + +// ProtoMessage implements proto.Message. +func (*MsgRevokeServiceResponse) ProtoMessage() {} + +// MsgIssueCareGrantResponse is the response to MsgIssueCareGrant. +type MsgIssueCareGrantResponse struct{} + +// Reset implements proto.Message. +func (m *MsgIssueCareGrantResponse) Reset() { *m = MsgIssueCareGrantResponse{} } + +// String implements proto.Message. +func (m *MsgIssueCareGrantResponse) String() string { return "MsgIssueCareGrantResponse{}" } + +// ProtoMessage implements proto.Message. +func (*MsgIssueCareGrantResponse) ProtoMessage() {} + +// MsgActivateSIMResponse is the response to MsgActivateSIM. +type MsgActivateSIMResponse struct{} + +// Reset implements proto.Message. +func (m *MsgActivateSIMResponse) Reset() { *m = MsgActivateSIMResponse{} } + +// String implements proto.Message. +func (m *MsgActivateSIMResponse) String() string { return "MsgActivateSIMResponse{}" } + +// ProtoMessage implements proto.Message. +func (*MsgActivateSIMResponse) ProtoMessage() {} + +// MsgProvisionVaultResponse is the response to MsgProvisionVault. +type MsgProvisionVaultResponse struct{} + +// Reset implements proto.Message. +func (m *MsgProvisionVaultResponse) Reset() { *m = MsgProvisionVaultResponse{} } + +// String implements proto.Message. +func (m *MsgProvisionVaultResponse) String() string { return "MsgProvisionVaultResponse{}" } + +// ProtoMessage implements proto.Message. +func (*MsgProvisionVaultResponse) ProtoMessage() {} + +// MsgBindMailboxResponse is the response to MsgBindMailbox. +type MsgBindMailboxResponse struct{} + +// Reset implements proto.Message. +func (m *MsgBindMailboxResponse) Reset() { *m = MsgBindMailboxResponse{} } + +// String implements proto.Message. +func (m *MsgBindMailboxResponse) String() string { return "MsgBindMailboxResponse{}" } + +// ProtoMessage implements proto.Message. +func (*MsgBindMailboxResponse) ProtoMessage() {} diff --git a/x/services/types/service_lifecycle.go b/x/services/types/service_lifecycle.go new file mode 100644 index 0000000..d8fd132 --- /dev/null +++ b/x/services/types/service_lifecycle.go @@ -0,0 +1,69 @@ +package types + +// service_lifecycle.go holds the v0.5 runtime service lifecycle helpers +// (P5-02-01, REQ-037). v0.3 typed the ServiceStatus enum (types.go); +// v0.5 promotes it to runtime by adding the lifecycle transition gate +// the keeper consults before mutating state. Mirrors +// x/partner/types/anchor_credential.go (the v0.5 Anchor credential +// lifecycle pattern — A-551 typed dispatch + A-552 window-grant-on- +// every-op). +// +// Lifecycle (REQ-037, RESEARCH v0.5 §2.5): +// +// RegisterService → Pending (window-id must be Active — A-552) +// ActivateService → Pending → Active (window-id still Active) +// SuspendService → Active → Suspended (window-id still Active) +// RevokeService → any → Revoked (window-id still Active; +// terminal) +// +// Invalid transitions are REJECTED by the handler (the simtest covers +// each invalid transition). Revoked is terminal (no transition out of +// Revoked — idempotent reject on a second Revoke). The lexicon-clean +// holder identifier is "reach-id" (NOT a banned financial-holder term; +// use Holder/Reach). + +// AllServiceStatuses returns all four ServiceStatus values in lifecycle +// order (Pending, Active, Suspended, Revoked). Locked-const test (the +// v0.3 types_test.go) asserts exactly 4 entries. +func AllServiceStatuses() []ServiceStatus { + return []ServiceStatus{ + ServicePending, + ServiceActive, + ServiceSuspended, + ServiceRevoked, + } +} + +// IsTerminalServiceStatus reports whether the service status is terminal +// (no further transitions permitted). Revoked is terminal. +// Pending/Active/Suspended are non-terminal. +func IsTerminalServiceStatus(s ServiceStatus) bool { + return s == ServiceRevoked +} + +// ValidServiceTransition reports whether the from → to transition is +// permitted by the REQ-037 lifecycle: +// - Pending → Active (ActivateService) +// - Active → Suspended (SuspendService) +// - Active → Revoked (RevokeService) +// - Suspended → Revoked (RevokeService) +// - Pending → Revoked (RevokeService — a Pending service may be +// revoked before activation) +// +// All other transitions are REJECTED. Revoked is terminal (no transition +// out). The handler consults this helper before mutating state (the +// window-grant Active check A-552 is a SEPARATE gate after this). +func ValidServiceTransition(from, to ServiceStatus) bool { + switch from { + case ServicePending: + return to == ServiceActive || to == ServiceRevoked + case ServiceActive: + return to == ServiceSuspended || to == ServiceRevoked + case ServiceSuspended: + return to == ServiceRevoked + case ServiceRevoked: + return false // terminal + default: + return false // unknown source status + } +} diff --git a/x/services/types/types.go b/x/services/types/types.go index 1678703..38b5bf9 100644 --- a/x/services/types/types.go +++ b/x/services/types/types.go @@ -169,6 +169,22 @@ func DefaultGenesisState() *GenesisState { } } +// Reset implements proto.Message (codec.JSONCodec.MustMarshalJSON / +// MustUnmarshalJSON require proto.Message; the v0.5 runtime AppModule +// calls these — D-055 G-006 controlled exception; the genesis fields + +// ValidateGenesis logic are unchanged from v0.3, only the proto.Message +// methods are added for the AppModule wiring). +func (m *GenesisState) Reset() { *m = GenesisState{} } + +// String implements proto.Message. +func (m *GenesisState) String() string { + return fmt.Sprintf("GenesisState{ServiceInfos:%d CareServices:%d SIMServices:%d VaultServices:%d MailServices:%d}", + len(m.ServiceInfos), len(m.CareServices), len(m.SIMServices), len(m.VaultServices), len(m.MailServices)) +} + +// ProtoMessage implements proto.Message. +func (*GenesisState) ProtoMessage() {} + // ValidateGenesis performs ID-uniqueness checks (A-212 upgrade from v0.1 // no-op): rejects duplicate or empty service-ids in the registry, and unknown // ServiceKind / ServiceStatus values.