Files
openyield/x/hub/keeper/msg_server.go
T
cloudinit-bot 3c52aa1bb2
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 phase/04 into milestone/v0.5-bearers-runtime (P4 complete → v0.4.4)
---ci---
project: oy
phase: 4
milestone: v0.5
status: complete
requirements:
  covered: [REQ-036]
  partial: []
---/ci---
2026-08-18 00:49:16 +00:00

326 lines
14 KiB
Go

package keeper
// msg_server.go implements the hub module's MsgServer (P4-04-01, REQ-036;
// G-023 ownership split: cosmos-engineer scaffolds the file structure +
// method signatures; backend-engineer implements the handler logic bodies;
// security-engineer reviews the compliance-before-custody ordering A-544
// + the CustodyKeyring rotation contract D-058). The MsgServer wraps the
// Keeper + the PartnerKeeper expected-keeper shim (already on the Keeper)
// + the CustodyKeyring (already on the Keeper).
//
// Each method returns a (*Response, error). Handler state-machine ordering
// is enforced: ValidateBasic → keeper authz → state mutation →
// ctx.EventManager().EmitEvent.
//
// Handler set (REQ-036):
// - RegisterCustodyService: operator must be Onboarded Anchor (PartnerKeeper
// shim). Persists the custody service.
// - CustodyReceiveAsset: delegates signing to CustodyKeyring (D-058);
// records custody entry + sig ref + key version.
// - CustodyReleaseAsset: COMPLIANCE-BEFORE-CUSTODY (A-544) — checks
// IsCompliant via the ComplianceKeeper shim (the Keeper satisfies it)
// BEFORE the custody debit. Authz: signer must be the holder-reach-id
// on the custody entry (Window grantee check deferred).
// - RecordLendingPrimitive: CLAMPS coupon to [0, 800] bps at runtime
// (A-543); emits clamp event for simtest.
// - RecordComplianceAttestation: records attestation-ref against partner
// (the store the ComplianceKeeper shim's IsCompliant reads — A-544).
//
// Nil-shim behavior (simtest wiring): a nil PartnerKeeper shim skips the
// IsAnchorOnboarded check (the handler still mutates state — the simtest
// documents the wiring contract). A nil CustodyKeyring REJECTS custody
// receive/release (signing is load-bearing — a nil keyring is a wiring
// error, not a simtest skip path).
import (
"context"
"fmt"
sdk "github.com/cosmos/cosmos-sdk/types"
"github.com/oy/openyield/x/hub/types"
)
// msgServer is the concrete MsgServer implementation wrapping the Keeper.
type msgServer struct {
Keeper
}
// NewMsgServerImpl returns the hub 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("hub: expected sdk.Context, got %T", ctx))
}
// receivePayload is the byte payload the CustodyKeyring signs over for a
// CustodyReceiveAsset. It binds the asset-id + partner-id + holder-reach-id
// to the custody signature (a signature over a different payload does not
// authorize this custody-receive). D-058: the keyring signs per-operation
// (no cross-block caching).
func receivePayload(msg *types.MsgCustodyReceiveAsset) []byte {
return []byte(fmt.Sprintf("hub.custody.receive:%s:%s:%s", msg.AssetID, msg.PartnerID, msg.HolderReachID))
}
// --- RegisterCustodyService --------------------------------------------------
// RegisterCustodyService registers a Hub custody service. The handler
// enforces:
// 1. ValidateBasic (stateless).
// 2. Idempotency: service-id must not already exist.
// 3. PartnerKeeper shim: the operator-partner-id must reference an
// Onboarded Anchor Partner (P3→P4 edge). A nil shim skips this check
// (simtest wiring); a non-nil shim that returns false REJECTS the
// registration (the service is not created).
//
// On success the custody service is persisted and an event is emitted.
func (s msgServer) RegisterCustodyService(ctx interface{}, msg *types.MsgRegisterCustodyService) (*types.MsgRegisterCustodyServiceResponse, error) {
if err := msg.ValidateBasic(); err != nil {
return nil, err
}
sdkCtx := unwrapCtx(ctx)
// Idempotency: service-id must not already exist.
if _, ok := s.Keeper.GetCustodyService(sdkCtx, msg.ServiceID); ok {
return nil, fmt.Errorf("hub: custody service %q already exists", msg.ServiceID)
}
// PartnerKeeper: operator must be Onboarded Anchor (P3→P4 edge).
// A nil shim skips the check (simtest wiring); a non-nil shim that
// returns false REJECTS the registration.
if s.Keeper.partnerKeeper != nil {
if !s.Keeper.partnerKeeper.IsAnchorOnboarded(msg.OperatorPartnerID) {
return nil, fmt.Errorf("hub: operator-partner %q is not an Onboarded Anchor (RegisterCustodyService rejected)", msg.OperatorPartnerID)
}
}
svc := types.CustodyService{
CustodyID: msg.ServiceID,
OperatorPartnerID: msg.OperatorPartnerID,
AssetRef: msg.AssetsSupported[0], // first asset as the canonical asset-ref
}
s.Keeper.SetCustodyService(sdkCtx, svc)
sdkCtx.EventManager().EmitEvent(sdk.NewEvent(
"hub.custody_service_registered",
sdk.NewAttribute("service_id", msg.ServiceID),
sdk.NewAttribute("operator_partner_id", msg.OperatorPartnerID),
))
return &types.MsgRegisterCustodyServiceResponse{}, nil
}
// --- CustodyReceiveAsset (D-058 keyring signing) -----------------------------
// CustodyReceiveAsset custody-receives an asset (A-542: safe inbound custody
// name — the banned storage term is NOT used). The handler enforces:
// 1. ValidateBasic (stateless).
// 2. Idempotency: asset-id must not already be in custody (Held or
// Released — a second receive on the same asset-id is REJECTED; the
// asset is one-per-entry for the simtest grade).
// 3. CustodyKeyring: the keyring must be non-nil (signing is load-bearing
// — a nil keyring is a wiring error, REJECTED). The keyring signs the
// receive payload (D-058); the sig + key version are recorded on the
// custody entry (rotation safety).
//
// On success the custody entry is persisted with status=Held + the sig ref
// + key version, and an event is emitted.
func (s msgServer) CustodyReceiveAsset(ctx interface{}, msg *types.MsgCustodyReceiveAsset) (*types.MsgCustodyReceiveAssetResponse, error) {
if err := msg.ValidateBasic(); err != nil {
return nil, err
}
sdkCtx := unwrapCtx(ctx)
// Idempotency: asset-id must not already be in custody.
if _, ok := s.Keeper.custody.getCustodyEntry(sdkCtx, msg.AssetID); ok {
return nil, fmt.Errorf("hub: asset %q already in custody (idempotent reject — no double-receive)", msg.AssetID)
}
// CustodyKeyring signing (D-058). A nil keyring is a wiring error.
if s.Keeper.keyring == nil {
return nil, fmt.Errorf("hub: custody keyring not wired (CustodyReceiveAsset rejected — signing is load-bearing)")
}
sig, err := s.Keeper.keyring.Sign(context.Background(), msg.AssetID, receivePayload(msg))
if err != nil {
return nil, fmt.Errorf("hub: custody keyring sign for asset %q: %w", msg.AssetID, err)
}
_, keyVersion, err := s.Keeper.keyring.Status(context.Background(), msg.AssetID)
if err != nil {
return nil, fmt.Errorf("hub: custody keyring status for asset %q: %w", msg.AssetID, err)
}
entry := CustodyEntry{
AssetID: msg.AssetID,
HolderReachID: msg.HolderReachID,
PartnerID: msg.PartnerID,
SigRef: sig,
KeyVersion: keyVersion,
CustodyStatus: CustodyHeld,
}
s.Keeper.custody.setCustodyEntry(sdkCtx, entry)
sdkCtx.EventManager().EmitEvent(sdk.NewEvent(
"hub.custody_receive_asset",
sdk.NewAttribute("asset_id", msg.AssetID),
sdk.NewAttribute("partner_id", msg.PartnerID),
sdk.NewAttribute("holder_reach_id", msg.HolderReachID),
sdk.NewAttribute("key_version", fmt.Sprintf("%d", keyVersion)),
))
return &types.MsgCustodyReceiveAssetResponse{SigRef: sig}, nil
}
// --- CustodyReleaseAsset (A-544 compliance-before-custody) -------------------
// CustodyReleaseAsset custody-releases an asset (A-542: safe outbound
// custody name — the banned withdrawal term is NOT used;
// A-544: compliance-BEFORE-custody). The handler enforces:
// 1. ValidateBasic (stateless).
// 2. The custody entry must exist.
// 3. The custody entry must be Held (not already Released — idempotent
// reject; no double-effect).
// 4. Authz: the signer must be the holder-reach-id on the custody entry
// (Window grantee check deferred — simtest grade).
// 5. COMPLIANCE-BEFORE-CUSTODY (A-544): the partner-id on the custody
// entry must be IsCompliant via the ComplianceKeeper shim (the Keeper
// satisfies it). A non-compliant partner REJECTS the release (the
// asset stays in custody). The check is BEFORE the custody debit (the
// status transition to Released), so a rejected release does not
// mutate the custody entry.
//
// On success the custody entry is transitioned to Released (retained for
// audit) and an event is emitted.
func (s msgServer) CustodyReleaseAsset(ctx interface{}, msg *types.MsgCustodyReleaseAsset) (*types.MsgCustodyReleaseAssetResponse, error) {
if err := msg.ValidateBasic(); err != nil {
return nil, err
}
sdkCtx := unwrapCtx(ctx)
entry, ok := s.Keeper.custody.getCustodyEntry(sdkCtx, msg.AssetID)
if !ok {
return nil, fmt.Errorf("hub: custody entry %q not found (CustodyReleaseAsset rejected)", msg.AssetID)
}
// Idempotent reject: a Released entry cannot be re-released.
if entry.CustodyStatus == CustodyReleased {
return nil, fmt.Errorf("hub: asset %q already released (idempotent reject — no double-effect)", msg.AssetID)
}
// Authz: signer must be the holder-reach-id on the custody entry.
if msg.Signer != entry.HolderReachID {
return nil, fmt.Errorf("hub: signer %q not authorized to release asset %q (holder is %q)", msg.Signer, msg.AssetID, entry.HolderReachID)
}
// COMPLIANCE-BEFORE-CUSTODY (A-544): the partner on the custody entry
// must be IsCompliant BEFORE the custody debit. The Keeper satisfies
// the ComplianceKeeper shim (IsCompliant reads the attestation store
// the RecordComplianceAttestation handler populates). A non-compliant
// partner REJECTS the release (the asset stays in custody — Held).
if !s.Keeper.IsCompliant(sdkCtx, entry.PartnerID) {
return nil, fmt.Errorf("hub: partner %q not compliant (CustodyReleaseAsset rejected — A-544 compliance-before-custody; asset %q stays Held)", entry.PartnerID, msg.AssetID)
}
// Custody debit: transition to Released (retained for audit).
entry.CustodyStatus = CustodyReleased
s.Keeper.custody.setCustodyEntry(sdkCtx, entry)
sdkCtx.EventManager().EmitEvent(sdk.NewEvent(
"hub.custody_release_asset",
sdk.NewAttribute("asset_id", msg.AssetID),
sdk.NewAttribute("partner_id", entry.PartnerID),
sdk.NewAttribute("holder_reach_id", entry.HolderReachID),
sdk.NewAttribute("status", string(CustodyReleased)),
))
return &types.MsgCustodyReleaseAssetResponse{}, nil
}
// --- RecordLendingPrimitive (A-543 coupon clamp at runtime) ------------------
// RecordLendingPrimitive records a lending primitive (A-543: coupon clamp
// at runtime). The handler enforces:
// 1. ValidateBasic (stateless).
// 2. Idempotency: loan-id must not already exist.
// 3. Coupon clamp: the coupon-bps is CLAMPED to
// [LendingCouponFloorBps=0, LendingCouponCapBps=800] at runtime via
// ClampLendingCoupon (A-543 runtime echo of D-028/REQ-030). The
// clamped value is recorded (NOT the original); a clamp event is
// emitted so the simtest can assert the clamp ran.
//
// On success the lending primitive is persisted with the clamped coupon
// and a clamp event is emitted.
func (s msgServer) RecordLendingPrimitive(ctx interface{}, msg *types.MsgRecordLendingPrimitive) (*types.MsgRecordLendingPrimitiveResponse, error) {
if err := msg.ValidateBasic(); err != nil {
return nil, err
}
sdkCtx := unwrapCtx(ctx)
// Idempotency: loan-id must not already exist.
if _, ok := s.Keeper.GetLendingPrimitive(sdkCtx, msg.LoanID); ok {
return nil, fmt.Errorf("hub: lending primitive %q already exists", msg.LoanID)
}
// A-543: coupon clamp at runtime. The clamp is authoritative; the
// clamped value (NOT the original) is recorded. A clamp event is
// emitted if the original was out-of-band (so the simtest can assert
// the clamp ran).
original := msg.CouponBps
clamped := types.ClampLendingCoupon(msg.CouponBps)
lp := types.LendingPrimitive{
LoanID: msg.LoanID,
PrincipalGrain: msg.PrincipalGrain,
CouponBps: clamped,
TermDays: msg.TermDays,
}
s.Keeper.SetLendingPrimitive(sdkCtx, lp)
if clamped != original {
sdkCtx.EventManager().EmitEvent(sdk.NewEvent(
"hub.lending_coupon_clamped",
sdk.NewAttribute("loan_id", msg.LoanID),
sdk.NewAttribute("original_coupon_bps", fmt.Sprintf("%d", original)),
sdk.NewAttribute("clamped_coupon_bps", fmt.Sprintf("%d", clamped)),
))
}
sdkCtx.EventManager().EmitEvent(sdk.NewEvent(
"hub.lending_primitive_recorded",
sdk.NewAttribute("loan_id", msg.LoanID),
sdk.NewAttribute("coupon_bps", fmt.Sprintf("%d", clamped)),
))
return &types.MsgRecordLendingPrimitiveResponse{ClampedCouponBps: clamped}, nil
}
// --- RecordComplianceAttestation (A-544) --------------------------------------
// RecordComplianceAttestation records a compliance attestation against a
// partner (A-544). The handler enforces:
// 1. ValidateBasic (stateless).
// 2. Persists the attestation-ref against the partner-id (overwrites
// prior attestations; the latest is the one IsCompliant reads).
//
// On success the attestation is recorded and an event is emitted. This is
// the store the ComplianceKeeper shim's IsCompliant reads (A-544
// compliance-before-custody: CustodyReleaseAsset consults IsCompliant
// BEFORE the custody debit).
func (s msgServer) RecordComplianceAttestation(ctx interface{}, msg *types.MsgRecordComplianceAttestation) (*types.MsgRecordComplianceAttestationResponse, error) {
if err := msg.ValidateBasic(); err != nil {
return nil, err
}
sdkCtx := unwrapCtx(ctx)
s.Keeper.SetComplianceAttestation(sdkCtx, msg.PartnerID, msg.AttestationRef)
sdkCtx.EventManager().EmitEvent(sdk.NewEvent(
"hub.compliance_attestation_recorded",
sdk.NewAttribute("partner_id", msg.PartnerID),
sdk.NewAttribute("attestation_ref", msg.AttestationRef),
))
return &types.MsgRecordComplianceAttestationResponse{}, nil
}