package keeper import ( "fmt" sdk "github.com/cosmos/cosmos-sdk/types" "github.com/oy/openyield/x/partner/types" ) // msg_server.go implements the partner module's Anchor-credential MsgServer // (P3-02-01, REQ-035; 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 WatcherKeeper // and HubKeeper expected-keeper shims (already on the Keeper). // // Each method returns a (*Response, error). Handler state-machine ordering // is enforced: ValidateBasic → keeper authz → state mutation → // ctx.EventManager().EmitEvent. // // Lifecycle (REQ-035, vision §13): // - IssueAnchorCredential → Pending (Watcher 6-of-9 quorum authz) // - OnboardAnchor → Pending → Onboarded (HubKeeper custody- // provider-id validity check) // - SuspendAnchorCredential → Onboarded → Suspended // - RevokeAnchorCredential → any → Revoked (Watcher 6-of-9 quorum authz) // // Invalid transitions are REJECTED (the simtest covers each). Revoked is // terminal (idempotent reject on a second Revoke — NOT double-effect). // // Nil-shim behavior (simtest wiring): a nil WatcherKeeper shim skips the // Watcher quorum authz (the handler still mutates state — the simtest // documents the wiring contract). A nil HubKeeper shim skips the // custody-service-exists check (the OnboardAnchor still transitions — the // simtest documents the wiring contract). The P3→P4 hub dep edge: in P3 // simtest, the HubKeeper shim is wired to a stub (G-003 test exemption); // the real hub keeper is wired in P4. // msgServer is the concrete MsgServer implementation wrapping the Keeper. type msgServer struct { Keeper } // NewMsgServerImpl returns the partner 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("partner: expected sdk.Context, got %T", ctx)) } // nowUnix returns the current block time as unix seconds from the ctx. func nowUnix(ctx sdk.Context) int64 { return ctx.BlockTime().Unix() } // issuePayload is the byte payload the Watcher quorum signs over for an // IssueAnchorCredential. It binds the anchor-id + credential-uri + issuer // to the quorum signature (a quorum signature over a different payload // does not authorize this issuance). func issuePayload(msg *types.MsgIssueAnchorCredential) []byte { return []byte(fmt.Sprintf("partner.issue:%s:%s:%s", msg.AnchorID, msg.CredentialURI, msg.Issuer)) } // revokePayload is the byte payload the Watcher quorum signs over for a // RevokeAnchorCredential. It binds the anchor-id + signer to the quorum // signature (a quorum signature over a different payload does not // authorize this revocation). func revokePayload(msg *types.MsgRevokeAnchorCredential) []byte { return []byte(fmt.Sprintf("partner.revoke:%s:%s", msg.AnchorID, msg.Signer)) } // --- IssueAnchorCredential (creates credential status=Pending) --------------- // IssueAnchorCredential issues an Anchor credential (status=Pending). // The handler enforces: // 1. ValidateBasic (stateless). // 2. Idempotency: anchor-id must not already exist. // 3. Watcher 6-of-9 quorum authz (REQ-004) via the WatcherKeeper shim // on the issuance payload. A nil shim skips this check (simtest // wiring); a non-nil shim that returns false REJECTS the issuance. // // On success the credential is persisted with status=Pending and an // event is emitted. func (s msgServer) IssueAnchorCredential(ctx interface{}, msg *types.MsgIssueAnchorCredential) (*types.MsgIssueAnchorCredentialResponse, error) { if err := msg.ValidateBasic(); err != nil { return nil, err } sdkCtx := unwrapCtx(ctx) // Idempotency: anchor-id must not already exist. if _, ok := s.Keeper.GetAnchorCredential(sdkCtx, msg.AnchorID); ok { return nil, fmt.Errorf("partner: anchor credential %q already exists", msg.AnchorID) } // Watcher 6-of-9 quorum authz (REQ-004). A nil shim skips the authz // (simtest wiring); a non-nil shim that returns false REJECTS. if s.Keeper.watcherKeeper != nil { if !s.Keeper.watcherKeeper.IsQuorumSigned(msg.WatcherQuorumID, issuePayload(msg)) { return nil, fmt.Errorf("partner: watcher quorum %q did not authorize issuance of anchor %q (REQ-004 6-of-9)", msg.WatcherQuorumID, msg.AnchorID) } } cred := types.AnchorCredential{ AnchorID: msg.AnchorID, CustodyProviderID: "", // empty — set on OnboardAnchor CredentialURI: msg.CredentialURI, AttestationCount: 0, Status: types.AnchorPending, } s.Keeper.SetAnchorCredential(sdkCtx, cred) sdkCtx.EventManager().EmitEvent(sdk.NewEvent( "partner.anchor_credential_issued", sdk.NewAttribute("anchor_id", msg.AnchorID), sdk.NewAttribute("credential_uri", msg.CredentialURI), sdk.NewAttribute("watcher_quorum_id", msg.WatcherQuorumID), sdk.NewAttribute("issuer", msg.Issuer), sdk.NewAttribute("status", string(types.AnchorPending)), )) return &types.MsgIssueAnchorCredentialResponse{}, nil } // --- OnboardAnchor (Pending → Onboarded) ------------------------------------- // OnboardAnchor transitions an Anchor credential Pending → Onboarded. // The handler enforces: // 1. ValidateBasic (stateless). // 2. The credential must exist. // 3. The source status must be Pending (ValidAnchorTransition(Pending, // Onboarded) — the lifecycle gate). // 4. The custody-provider-id must reference a LIVE Hub custody service // via the HubKeeper shim (the P3→P4 hub dep edge). A nil shim skips // this check (simtest wiring); a non-nil shim that returns false // REJECTS the onboarding (the credential stays Pending). // 5. The custody-provider-id on the credential is set from the msg // (the msg carries the custody-provider-id to bind to). // // On success the credential's CustodyProviderID is set, the status is // transitioned to Onboarded, and an event is emitted. func (s msgServer) OnboardAnchor(ctx interface{}, msg *types.MsgOnboardAnchor) (*types.MsgOnboardAnchorResponse, error) { if err := msg.ValidateBasic(); err != nil { return nil, err } sdkCtx := unwrapCtx(ctx) cred, ok := s.Keeper.GetAnchorCredential(sdkCtx, msg.AnchorID) if !ok { return nil, fmt.Errorf("partner: anchor credential %q not found", msg.AnchorID) } // Lifecycle gate: Pending → Onboarded is the only valid transition // into Onboarded. if !types.ValidAnchorTransition(cred.Status, types.AnchorOnboarded) { return nil, fmt.Errorf("partner: anchor %q status %q cannot transition to Onboarded (REQ-035 lifecycle)", msg.AnchorID, cred.Status) } // HubKeeper custody-service-exists check (the P3→P4 hub dep edge). // A nil shim skips the check (simtest wiring); a non-nil shim that // returns false REJECTS the onboarding (the credential stays Pending). if s.Keeper.hubKeeper != nil { if !s.Keeper.hubKeeper.CustodyServiceExists(msg.CustodyProviderID) { return nil, fmt.Errorf("partner: custody service %q does not exist (OnboardAnchor rejected — anchor %q stays Pending)", msg.CustodyProviderID, msg.AnchorID) } } // Transition: set custody-provider-id + status=Onboarded. cred.CustodyProviderID = msg.CustodyProviderID cred.Status = types.AnchorOnboarded s.Keeper.SetAnchorCredential(sdkCtx, cred) sdkCtx.EventManager().EmitEvent(sdk.NewEvent( "partner.anchor_onboarded", sdk.NewAttribute("anchor_id", msg.AnchorID), sdk.NewAttribute("custody_provider_id", msg.CustodyProviderID), sdk.NewAttribute("status", string(types.AnchorOnboarded)), )) return &types.MsgOnboardAnchorResponse{}, nil } // --- SuspendAnchorCredential (Onboarded → Suspended) ------------------------ // SuspendAnchorCredential transitions an Anchor credential // Onboarded → Suspended. The handler enforces: // 1. ValidateBasic (stateless). // 2. The credential must exist. // 3. The source status must be Onboarded (ValidAnchorTransition(Onboarded, // Suspended) — the lifecycle gate). // // On success the status is transitioned to Suspended and an event is // emitted. func (s msgServer) SuspendAnchorCredential(ctx interface{}, msg *types.MsgSuspendAnchorCredential) (*types.MsgSuspendAnchorCredentialResponse, error) { if err := msg.ValidateBasic(); err != nil { return nil, err } sdkCtx := unwrapCtx(ctx) cred, ok := s.Keeper.GetAnchorCredential(sdkCtx, msg.AnchorID) if !ok { return nil, fmt.Errorf("partner: anchor credential %q not found", msg.AnchorID) } if !types.ValidAnchorTransition(cred.Status, types.AnchorSuspended) { return nil, fmt.Errorf("partner: anchor %q status %q cannot transition to Suspended (REQ-035 lifecycle)", msg.AnchorID, cred.Status) } cred.Status = types.AnchorSuspended s.Keeper.SetAnchorCredential(sdkCtx, cred) sdkCtx.EventManager().EmitEvent(sdk.NewEvent( "partner.anchor_credential_suspended", sdk.NewAttribute("anchor_id", msg.AnchorID), sdk.NewAttribute("status", string(types.AnchorSuspended)), )) return &types.MsgSuspendAnchorCredentialResponse{}, nil } // --- RevokeAnchorCredential (any → Revoked, Watcher quorum authz) ------------ // RevokeAnchorCredential transitions an Anchor credential to Revoked // (terminal). The handler enforces: // 1. ValidateBasic (stateless). // 2. The credential must exist. // 3. The credential must not already be Revoked (idempotent reject — a // second Revoke returns an error; NOT double-effect). // 4. Watcher 6-of-9 quorum authz (REQ-004) via the WatcherKeeper shim // on the revocation payload. A nil shim skips this check (simtest // wiring); a non-nil shim that returns false REJECTS the revocation. // // On success the status is transitioned to Revoked (terminal) and an // event is emitted. func (s msgServer) RevokeAnchorCredential(ctx interface{}, msg *types.MsgRevokeAnchorCredential) (*types.MsgRevokeAnchorCredentialResponse, error) { if err := msg.ValidateBasic(); err != nil { return nil, err } sdkCtx := unwrapCtx(ctx) cred, ok := s.Keeper.GetAnchorCredential(sdkCtx, msg.AnchorID) if !ok { return nil, fmt.Errorf("partner: anchor credential %q not found", msg.AnchorID) } // Idempotent reject: a Revoked credential cannot be re-revoked. if cred.Status == types.AnchorRevoked { return nil, fmt.Errorf("partner: anchor %q already revoked (idempotent reject — no double-effect)", msg.AnchorID) } // Watcher 6-of-9 quorum authz (REQ-004). A nil shim skips the authz // (simtest wiring); a non-nil shim that returns false REJECTS. if s.Keeper.watcherKeeper != nil { if !s.Keeper.watcherKeeper.IsQuorumSigned(msg.WatcherQuorumID, revokePayload(msg)) { return nil, fmt.Errorf("partner: watcher quorum %q did not authorize revocation of anchor %q (REQ-004 6-of-9)", msg.WatcherQuorumID, msg.AnchorID) } } cred.Status = types.AnchorRevoked s.Keeper.SetAnchorCredential(sdkCtx, cred) sdkCtx.EventManager().EmitEvent(sdk.NewEvent( "partner.anchor_credential_revoked", sdk.NewAttribute("anchor_id", msg.AnchorID), sdk.NewAttribute("watcher_quorum_id", msg.WatcherQuorumID), sdk.NewAttribute("status", string(types.AnchorRevoked)), )) return &types.MsgRevokeAnchorCredentialResponse{}, nil }