3c52aa1bb2
---ci--- project: oy phase: 4 milestone: v0.5 status: complete requirements: covered: [REQ-036] partial: [] ---/ci---
326 lines
14 KiB
Go
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
|
|
}
|