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 }