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

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

274 lines
11 KiB
Go

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
}