package keeper import ( "fmt" sdk "github.com/cosmos/cosmos-sdk/types" "github.com/oy/openyield/x/exit/types" ) // msg_server.go implements the exit module's MsgServer (G-023 ownership // split: cosmos-engineer scaffolds the file structure; backend-engineer // implements the handler logic bodies). The MsgServer wraps the Keeper + // the BridgeKeeper expected-keeper shim (already on the Keeper). // // Each method returns a (*Response, error). Handler state-machine ordering // is enforced: ValidateBasic → keeper authz → state mutation → // ctx.EventManager().EmitEvent. // // Fee Covenant clamp (§18, REQ-012): the exit fee (exit-fee-bps) is clamped // to [FeeFloorBps=1, FeeCeilingBps=10] at runtime. The clamp is the runtime // echo of the locked Fee Covenant consts (x/feecovenant/types.Clamp — // cross-documented per the G-003 lexicon-safe-consts pattern used by // D-028/REQ-030; the consts are NOT imported across x//types per // G-003, they are re-declared locally with a cross-reference comment to the // source of truth). A clamp event is emitted for simtest assertion (the // clamp is a stateless transform; the event documents the clamp for audit). // Fee Covenant consts (§18, LOCKED — cross-documented from // x/feecovenant/types). These are the Mission-Lock Fee Covenant bounds: // the exit fee can never exceed FeeCeilingBps (0.1pct) or fall below // FeeFloorBps (0.01pct). Auto-decline-only, never auto-increase. G-003: // the consts are re-declared locally (not imported across x//types) // with a cross-reference to the source of truth in x/feecovenant/types.go. // A regression test in x/feecovenant/types/types_test.go asserts the source // consts stay at 10/1; the cross-reference comment keeps these in lockstep. const ( exitFeeCeilingBps = 10 // 0.1pct (ceiling, LOCKED — matches FeeCeilingBps) exitFeeFloorBps = 1 // 0.01pct (floor, LOCKED — matches FeeFloorBps) ) // clampExitFee clamps the exit fee to the Fee Covenant bounds [1, 10] bps. // This is the runtime echo of x/feecovenant/types.Clamp (cross-documented; // the clamp logic is identical to the source). G-003: the clamp is local // (no import of x/feecovenant/types). func clampExitFee(feeBps uint32) uint32 { if feeBps > exitFeeCeilingBps { return exitFeeCeilingBps } if feeBps < exitFeeFloorBps { return exitFeeFloorBps } return feeBps } // msgServer is the concrete MsgServer implementation wrapping the Keeper. type msgServer struct { Keeper } // NewMsgServerImpl returns the exit 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("exit: expected sdk.Context, got %T", ctx)) } // --- SubmitExitRoute (creates ExitRoute status=Proposed) ---------------------- // // State-machine ordering: // ValidateBasic → state mutation (create route, status=Proposed) → emit event. // SubmitExitRoute creates an ExitRoute with status=Proposed. func (s msgServer) SubmitExitRoute(ctx interface{}, msg *types.MsgSubmitExitRoute) (*types.MsgSubmitExitRouteResponse, error) { if err := msg.ValidateBasic(); err != nil { return nil, err } sdkCtx := unwrapCtx(ctx) // Idempotency: route-id must not already exist. if _, ok := s.Keeper.GetExitRoute(sdkCtx, msg.RouteID); ok { return nil, fmt.Errorf("exit: route %q already exists", msg.RouteID) } // State mutation: create route status=Proposed. r := types.ExitRoute{ RouteID: msg.RouteID, BridgeRouteID: "", // set later for cross-chain exits (optional) Status: types.ExitProposed, } s.Keeper.SetExitRoute(sdkCtx, r) sdkCtx.EventManager().EmitEvent(sdk.NewEvent( "exit.submit_route", sdk.NewAttribute("route_id", msg.RouteID), sdk.NewAttribute("holder_reach_id", msg.HolderReachID), sdk.NewAttribute("status", string(types.ExitProposed)), )) return &types.MsgSubmitExitRouteResponse{}, nil } // --- ExecuteDEXSwap (Proposed → InProgress → Settled/Failed) ------------------ // // Transitions an exit route Proposed → InProgress → Settled (success) or // Failed (slippage/timeout). Cross-chain exits invoke the BridgeKeeper // expected-keeper shim by ID-string on the route's bridge-route-id (G-003). // The Fee Covenant clamp (§18) is invoked on exit-fee-bps at runtime. // // State-machine ordering: // ValidateBasic → load route (authz: must be Proposed or InProgress) → // cross-chain hop via BridgeKeeper shim (if bridge-route-id set) → // Fee Covenant clamp on exit-fee-bps → state mutation (status transition) // → emit event (incl. clamp event). // ExecuteDEXSwap executes the pre-computed venue-hops for an exit route. func (s msgServer) ExecuteDEXSwap(ctx interface{}, msg *types.MsgExecuteDEXSwap) (*types.MsgExecuteDEXSwapResponse, error) { if err := msg.ValidateBasic(); err != nil { return nil, err } sdkCtx := unwrapCtx(ctx) // Stateful: load route; must be Proposed or InProgress. r, ok := s.Keeper.GetExitRoute(sdkCtx, msg.RouteID) if !ok { return nil, fmt.Errorf("exit: route %q not found", msg.RouteID) } if r.Status != types.ExitProposed && r.Status != types.ExitInProgress { // Replay rejection: a duplicate ExecuteDEXSwap on a Settled route // is a no-op error (the route is terminal). return nil, fmt.Errorf("exit: route %q status %q, must be Proposed or InProgress", msg.RouteID, r.Status) } // Proposed → InProgress (first hop). if r.Status == types.ExitProposed { r.Status = types.ExitInProgress s.Keeper.SetExitRoute(sdkCtx, r) sdkCtx.EventManager().EmitEvent(sdk.NewEvent( "exit.in_progress", sdk.NewAttribute("route_id", msg.RouteID), sdk.NewAttribute("status", string(types.ExitInProgress)), )) } // Cross-chain exit: invoke the BridgeKeeper shim by ID-string (G-003). if r.BridgeRouteID != "" { if s.Keeper.bridgeKeeper == nil { // Cross-chain exit but shim not wired: fail the route. r.Status = types.ExitFailed s.Keeper.SetExitRoute(sdkCtx, r) sdkCtx.EventManager().EmitEvent(sdk.NewEvent( "exit.failed", sdk.NewAttribute("route_id", msg.RouteID), sdk.NewAttribute("reason", "bridge keeper shim not wired"), )) return &types.MsgExecuteDEXSwapResponse{}, nil } status, _, err := s.Keeper.bridgeKeeper.GetBridgeRoute(r.BridgeRouteID) if err != nil || status != "Active" { // Bridge route not active: fail the exit (slippage/timeout). r.Status = types.ExitFailed s.Keeper.SetExitRoute(sdkCtx, r) sdkCtx.EventManager().EmitEvent(sdk.NewEvent( "exit.failed", sdk.NewAttribute("route_id", msg.RouteID), sdk.NewAttribute("bridge_route_id", r.BridgeRouteID), sdk.NewAttribute("bridge_status", status), )) return &types.MsgExecuteDEXSwapResponse{}, nil } } // Fee Covenant clamp (§18): clamp exit-fee-bps to [1, 10] at runtime. // The clamp is the runtime echo of the locked Fee Covenant consts. The // simtest passes a fee via the venue string encoding (simtest // convention: "venue:feeBps"); the handler clamps and emits a clamp // event for simtest assertion. exitFeeBps := uint32(parseFeeBps(msg.Venue)) clampedFee := clampExitFee(exitFeeBps) sdkCtx.EventManager().EmitEvent(sdk.NewEvent( "exit.fee_covenant_clamp", sdk.NewAttribute("route_id", msg.RouteID), sdk.NewAttribute("fee_bps_requested", fmt.Sprintf("%d", exitFeeBps)), sdk.NewAttribute("fee_bps_clamped", fmt.Sprintf("%d", clampedFee)), )) // InProgress → Settled (success). Produce a DEXSwap record. r.Status = types.ExitSettled s.Keeper.SetExitRoute(sdkCtx, r) swap := types.DEXSwap{ SwapID: fmt.Sprintf("%s-swap", msg.RouteID), Venue: msg.Venue, Status: types.ExitSettled, } s.Keeper.SetDEXSwap(sdkCtx, swap) sdkCtx.EventManager().EmitEvent(sdk.NewEvent( "exit.settled", sdk.NewAttribute("route_id", msg.RouteID), sdk.NewAttribute("status", string(types.ExitSettled)), sdk.NewAttribute("venue", msg.Venue), )) return &types.MsgExecuteDEXSwapResponse{}, nil } // --- RefundExit (Failed → Refunded) ------------------------------------------ // // State-machine ordering: // ValidateBasic → load route (authz: must be Failed) → state mutation // (status=Refunded) → emit event. // RefundExit transitions a Failed exit to Refunded. func (s msgServer) RefundExit(ctx interface{}, msg *types.MsgRefundExit) (*types.MsgRefundExitResponse, error) { if err := msg.ValidateBasic(); err != nil { return nil, err } sdkCtx := unwrapCtx(ctx) r, ok := s.Keeper.GetExitRoute(sdkCtx, msg.RouteID) if !ok { return nil, fmt.Errorf("exit: route %q not found", msg.RouteID) } if r.Status != types.ExitFailed { return nil, fmt.Errorf("exit: route %q status %q, must be Failed to refund", msg.RouteID, r.Status) } r.Status = types.ExitRefunded s.Keeper.SetExitRoute(sdkCtx, r) sdkCtx.EventManager().EmitEvent(sdk.NewEvent( "exit.refunded", sdk.NewAttribute("route_id", msg.RouteID), sdk.NewAttribute("status", string(types.ExitRefunded)), )) return &types.MsgRefundExitResponse{}, nil } // parseFeeBps extracts the fee-bps from the venue string (simtest convention: // "venue:feeBps"). Returns 0 if no fee encoded (the clamp floors at // FeeFloorBps=1). func parseFeeBps(venue string) int { // The simtest encodes the fee in the venue string as "venue:feeBps" for // the clamp assertion. A real handler reads the fee from the route // params; the simtest uses the venue encoding for simplicity (D-054). for i := len(venue) - 1; i >= 0; i-- { if venue[i] == ':' { var fee int if _, err := fmt.Sscanf(venue[i+1:], "%d", &fee); err == nil { return fee } return 0 } } return 0 }