package keeper_test // msg_server_simtest_test.go is the x/exit keeper simtest (P1-06-01). // // D-054: simtest-grade — in-memory sdk.Context + dbm in-memory store, no // real IBC light clients. The simtest wires the expected-keeper shim // (BridgeKeeper) to an in-test stub (G-003 test exemption: the test imports // x/exit/keeper + defines a stub BridgeKeeper that satisfies the interface; // no production struct imports across x//types). // // Coverage (A-513, G-021): // - ExitStatus lifecycle: Proposed → InProgress → Settled; Failed → Refunded. // - Cross-chain exit via BridgeKeeper shim (G-003 test exemption — wired to // a stub that returns Active status; the simtest asserts the shim is called). // - Fee Covenant clamp event (exit-fee-bps clamped to [1, 10] bps). // - Replay rejection (duplicate MsgExecuteDEXSwap on a Settled route is an // error — the route is terminal). import ( "encoding/json" "testing" "cosmossdk.io/log" "cosmossdk.io/store" storetypes "cosmossdk.io/store/types" cmtproto "github.com/cometbft/cometbft/proto/tendermint/types" dbm "github.com/cosmos/cosmos-db" "github.com/cosmos/cosmos-sdk/codec" codectypes "github.com/cosmos/cosmos-sdk/codec/types" sdk "github.com/cosmos/cosmos-sdk/types" "github.com/oy/openyield/x/exit/keeper" exittypes "github.com/oy/openyield/x/exit/types" ) // --- Stub expected-keeper (G-003 test exemption) ----------------------------- // stubBridgeKeeper satisfies exittypes.BridgeKeeper for the simtest. It // records GetBridgeRoute calls and returns the configured status/bridge-type. type stubBridgeKeeper struct { // routes maps bridge-id → (status, bridgeType). routes map[string]stubBridgeRoute calls int } type stubBridgeRoute struct { status string bridgeType string } func (s *stubBridgeKeeper) GetBridgeRoute(routeID string) (status string, bridgeType string, err error) { s.calls++ r, ok := s.routes[routeID] if !ok { return "", "", nil // not found: status "" → handler fails the exit } return r.status, r.bridgeType, nil } // --- Simtest context helper -------------------------------------------------- // newSimtestContext constructs an in-memory sdk.Context with a KVStore mounted // at the exit store key. D-054: in-memory, no real IBC light clients. func newSimtestContext(t *testing.T) (sdk.Context, *stubBridgeKeeper, keeper.Keeper) { t.Helper() db := dbm.NewMemDB() cdc := newTestCodec() storeKey := storetypes.NewKVStoreKey(exittypes.StoreKey) cms := store.NewCommitMultiStore(db, log.NewNopLogger(), nil) cms.MountStoreWithDB(storeKey, storetypes.StoreTypeDB, nil) if err := cms.LoadLatestVersion(); err != nil { t.Fatalf("load latest version: %v", err) } ctx := sdk.NewContext(cms, cmtproto.Header{}, false, log.NewNopLogger()) bk := &stubBridgeKeeper{routes: map[string]stubBridgeRoute{}} k := keeper.NewKeeper(cdc, storeKey, bk) return ctx, bk, k } // newTestCodec constructs a minimal codec for the simtest. func newTestCodec() codec.Codec { registry := codectypes.NewInterfaceRegistry() return codec.NewProtoCodec(registry) } // hasEvent reports whether ctx emitted an event of the given type. func hasEvent(ctx sdk.Context, eventType string) bool { for _, ev := range ctx.EventManager().Events() { if ev.Type == eventType { return true } } return false } // eventAttr returns the value of an attribute on the last event of the given // type, or "" if not found. func eventAttr(ctx sdk.Context, eventType, attrKey string) string { for _, ev := range ctx.EventManager().Events() { if ev.Type == eventType { for _, a := range ev.Attributes { if string(a.Key) == attrKey { return string(a.Value) } } } } return "" } // --- ExitStatus lifecycle: Proposed → InProgress → Settled ------------------- // TestExitStatusLifecycleProposedToSettled asserts the full success lifecycle: // SubmitExitRoute (Proposed) → ExecuteDEXSwap (InProgress → Settled). The // DEXSwap record is produced. The Fee Covenant clamp event is emitted. func TestExitStatusLifecycleProposedToSettled(t *testing.T) { ctx, _, k := newSimtestContext(t) srv := keeper.NewMsgServerImpl(k) // SubmitExitRoute → Proposed. if _, err := srv.SubmitExitRoute(ctx, &exittypes.MsgSubmitExitRoute{ RouteID: "route-1", HolderReachID: "holder-1", SourceAsset: "ubread", DestAsset: "uatom", Amount: 500, Signer: "holder-1", }); err != nil { t.Fatalf("SubmitExitRoute: %v", err) } r, ok := k.GetExitRoute(ctx, "route-1") if !ok { t.Fatal("route not found after submit") } if r.Status != exittypes.ExitProposed { t.Errorf("status = %q, want Proposed", r.Status) } if !hasEvent(ctx, "exit.submit_route") { t.Error("submit_route event not emitted") } // ExecuteDEXSwap → InProgress → Settled (same-chain exit, no bridge-route-id). if _, err := srv.ExecuteDEXSwap(ctx, &exittypes.MsgExecuteDEXSwap{ RouteID: "route-1", Venue: "uniswap-v3:5", Signer: "holder-1", }); err != nil { t.Fatalf("ExecuteDEXSwap: %v", err) } r, _ = k.GetExitRoute(ctx, "route-1") if r.Status != exittypes.ExitSettled { t.Errorf("status = %q, want Settled", r.Status) } // DEXSwap record produced. swap, ok := k.GetDEXSwap(ctx, "route-1-swap") if !ok { t.Fatal("DEXSwap record not produced") } if swap.Status != exittypes.ExitSettled { t.Errorf("swap status = %q, want Settled", swap.Status) } // Fee Covenant clamp event emitted (5 bps → within [1,10], no clamp). if !hasEvent(ctx, "exit.fee_covenant_clamp") { t.Error("fee_covenant_clamp event not emitted") } if !hasEvent(ctx, "exit.settled") { t.Error("settled event not emitted") } } // TestFeeCovenantClampHighFee asserts a fee above the ceiling (10 bps) is // clamped to the ceiling (10 bps) — the Fee Covenant auto-decline-only rule. func TestFeeCovenantClampHighFee(t *testing.T) { ctx, _, k := newSimtestContext(t) srv := keeper.NewMsgServerImpl(k) srv.SubmitExitRoute(ctx, &exittypes.MsgSubmitExitRoute{ RouteID: "route-clamp-hi", HolderReachID: "h", SourceAsset: "ubread", DestAsset: "uatom", Amount: 100, Signer: "h", }) srv.ExecuteDEXSwap(ctx, &exittypes.MsgExecuteDEXSwap{ RouteID: "route-clamp-hi", Venue: "venue:99", Signer: "h", // 99 bps → clamped to 10 }) clamped := eventAttr(ctx, "exit.fee_covenant_clamp", "fee_bps_clamped") if clamped != "10" { t.Errorf("fee should be clamped to 10 (ceiling); got %q", clamped) } requested := eventAttr(ctx, "exit.fee_covenant_clamp", "fee_bps_requested") if requested != "99" { t.Errorf("fee requested = %q, want 99", requested) } } // TestFeeCovenantClampLowFee asserts a fee below the floor (1 bps) is clamped // up to the floor (1 bps) — the Fee Covenant never-below-floor rule. func TestFeeCovenantClampLowFee(t *testing.T) { ctx, _, k := newSimtestContext(t) srv := keeper.NewMsgServerImpl(k) srv.SubmitExitRoute(ctx, &exittypes.MsgSubmitExitRoute{ RouteID: "route-clamp-lo", HolderReachID: "h", SourceAsset: "ubread", DestAsset: "uatom", Amount: 100, Signer: "h", }) srv.ExecuteDEXSwap(ctx, &exittypes.MsgExecuteDEXSwap{ RouteID: "route-clamp-lo", Venue: "venue:0", Signer: "h", // 0 bps → clamped to 1 }) clamped := eventAttr(ctx, "exit.fee_covenant_clamp", "fee_bps_clamped") if clamped != "1" { t.Errorf("fee should be clamped to 1 (floor); got %q", clamped) } } // TestFeeCovenantClampInBand asserts a fee within [1, 10] bps is unchanged. func TestFeeCovenantClampInBand(t *testing.T) { ctx, _, k := newSimtestContext(t) srv := keeper.NewMsgServerImpl(k) srv.SubmitExitRoute(ctx, &exittypes.MsgSubmitExitRoute{ RouteID: "route-band", HolderReachID: "h", SourceAsset: "ubread", DestAsset: "uatom", Amount: 100, Signer: "h", }) srv.ExecuteDEXSwap(ctx, &exittypes.MsgExecuteDEXSwap{ RouteID: "route-band", Venue: "venue:5", Signer: "h", // 5 bps → in-band, unchanged }) clamped := eventAttr(ctx, "exit.fee_covenant_clamp", "fee_bps_clamped") if clamped != "5" { t.Errorf("fee in-band should be unchanged at 5; got %q", clamped) } } // --- ExitStatus lifecycle: Failed → Refunded --------------------------------- // TestExitStatusLifecycleFailedToRefunded asserts the failure/refund path: // SubmitExitRoute (Proposed) → cross-chain ExecuteDEXSwap with a non-Active // bridge route → Failed → RefundExit → Refunded. func TestExitStatusLifecycleFailedToRefunded(t *testing.T) { ctx, bk, k := newSimtestContext(t) srv := keeper.NewMsgServerImpl(k) // Submit a cross-chain exit route (with a bridge-route-id). srv.SubmitExitRoute(ctx, &exittypes.MsgSubmitExitRoute{ RouteID: "route-fail", HolderReachID: "h", SourceAsset: "ubread", DestAsset: "uatom", Amount: 200, Signer: "h", }) // Set the bridge-route-id on the route (simtest sets it directly; the real // handler sets it at submit time from the route params). r, _ := k.GetExitRoute(ctx, "route-fail") r.BridgeRouteID = "bridge-fail-1" k.SetExitRoute(ctx, r) // Stub bridge returns a non-Active status (Closed) → exit fails. bk.routes["bridge-fail-1"] = stubBridgeRoute{status: "Closed", bridgeType: "evm-ibc"} srv.ExecuteDEXSwap(ctx, &exittypes.MsgExecuteDEXSwap{ RouteID: "route-fail", Venue: "venue:3", Signer: "h", }) r, _ = k.GetExitRoute(ctx, "route-fail") if r.Status != exittypes.ExitFailed { t.Errorf("status = %q, want Failed", r.Status) } if !hasEvent(ctx, "exit.failed") { t.Error("failed event not emitted") } // RefundExit → Refunded. if _, err := srv.RefundExit(ctx, &exittypes.MsgRefundExit{ RouteID: "route-fail", Signer: "h", }); err != nil { t.Fatalf("RefundExit: %v", err) } r, _ = k.GetExitRoute(ctx, "route-fail") if r.Status != exittypes.ExitRefunded { t.Errorf("status = %q, want Refunded", r.Status) } if !hasEvent(ctx, "exit.refunded") { t.Error("refunded event not emitted") } } // TestCrossChainExitActiveBridge asserts a cross-chain exit with an Active // bridge route succeeds (Settled), invoking the BridgeKeeper shim. func TestCrossChainExitActiveBridge(t *testing.T) { ctx, bk, k := newSimtestContext(t) srv := keeper.NewMsgServerImpl(k) srv.SubmitExitRoute(ctx, &exittypes.MsgSubmitExitRoute{ RouteID: "route-xchain", HolderReachID: "h", SourceAsset: "ubread", DestAsset: "uatom", Amount: 300, Signer: "h", }) r, _ := k.GetExitRoute(ctx, "route-xchain") r.BridgeRouteID = "bridge-active-1" k.SetExitRoute(ctx, r) bk.routes["bridge-active-1"] = stubBridgeRoute{status: "Active", bridgeType: "evm-ibc"} srv.ExecuteDEXSwap(ctx, &exittypes.MsgExecuteDEXSwap{ RouteID: "route-xchain", Venue: "venue:5", Signer: "h", }) r, _ = k.GetExitRoute(ctx, "route-xchain") if r.Status != exittypes.ExitSettled { t.Errorf("cross-chain exit with Active bridge should Settle; got %q", r.Status) } if bk.calls == 0 { t.Error("BridgeKeeper.GetBridgeRoute was not called (G-003 shim not invoked)") } } // --- Replay rejection -------------------------------------------------------- // TestReplayRejectedOnSettledRoute asserts a duplicate ExecuteDEXSwap on a // Settled route returns an error (the route is terminal — replay rejection). func TestReplayRejectedOnSettledRoute(t *testing.T) { ctx, _, k := newSimtestContext(t) srv := keeper.NewMsgServerImpl(k) srv.SubmitExitRoute(ctx, &exittypes.MsgSubmitExitRoute{ RouteID: "route-replay", HolderReachID: "h", SourceAsset: "ubread", DestAsset: "uatom", Amount: 100, Signer: "h", }) srv.ExecuteDEXSwap(ctx, &exittypes.MsgExecuteDEXSwap{ RouteID: "route-replay", Venue: "venue:5", Signer: "h", }) // Second ExecuteDEXSwap on Settled route → error (replay rejection). _, err := srv.ExecuteDEXSwap(ctx, &exittypes.MsgExecuteDEXSwap{ RouteID: "route-replay", Venue: "venue:5", Signer: "h", }) if err == nil { t.Error("duplicate ExecuteDEXSwap on Settled route should return error (replay rejection)") } } // TestRefundExitRejectsNonFailed asserts RefundExit rejects a route that is // not Failed. func TestRefundExitRejectsNonFailed(t *testing.T) { ctx, _, k := newSimtestContext(t) srv := keeper.NewMsgServerImpl(k) srv.SubmitExitRoute(ctx, &exittypes.MsgSubmitExitRoute{ RouteID: "route-refund-bad", HolderReachID: "h", SourceAsset: "ubread", DestAsset: "uatom", Amount: 100, Signer: "h", }) _, err := srv.RefundExit(ctx, &exittypes.MsgRefundExit{ RouteID: "route-refund-bad", Signer: "h", }) if err == nil { t.Error("RefundExit should reject a Proposed route (must be Failed)") } } // --- SubmitExitRoute validation ---------------------------------------------- func TestSubmitExitRouteRejectsDuplicate(t *testing.T) { ctx, _, k := newSimtestContext(t) srv := keeper.NewMsgServerImpl(k) srv.SubmitExitRoute(ctx, &exittypes.MsgSubmitExitRoute{ RouteID: "dup", HolderReachID: "h", SourceAsset: "a", DestAsset: "b", Amount: 1, Signer: "h", }) _, err := srv.SubmitExitRoute(ctx, &exittypes.MsgSubmitExitRoute{ RouteID: "dup", HolderReachID: "h", SourceAsset: "a", DestAsset: "b", Amount: 1, Signer: "h", }) if err == nil { t.Error("SubmitExitRoute should reject a duplicate route-id") } } // --- ValidateBasic (Msg types) ----------------------------------------------- func TestMsgSubmitExitRouteValidateBasic(t *testing.T) { cases := []struct { name string msg exittypes.MsgSubmitExitRoute ok bool }{ {"valid", exittypes.MsgSubmitExitRoute{"r1", "h", "a", "b", 100, "s"}, true}, {"empty holder", exittypes.MsgSubmitExitRoute{"r1", "", "a", "b", 100, "s"}, false}, {"empty source", exittypes.MsgSubmitExitRoute{"r1", "h", "", "b", 100, "s"}, false}, {"empty dest", exittypes.MsgSubmitExitRoute{"r1", "h", "a", "", 100, "s"}, false}, {"zero amount", exittypes.MsgSubmitExitRoute{"r1", "h", "a", "b", 0, "s"}, false}, {"neg amount", exittypes.MsgSubmitExitRoute{"r1", "h", "a", "b", -1, "s"}, false}, {"empty signer", exittypes.MsgSubmitExitRoute{"r1", "h", "a", "b", 100, ""}, false}, } for _, c := range cases { err := c.msg.ValidateBasic() if c.ok && err != nil { t.Errorf("%s: expected ok, got %v", c.name, err) } if !c.ok && err == nil { t.Errorf("%s: expected error, got nil", c.name) } } } func TestMsgExecuteDEXSwapValidateBasic(t *testing.T) { if err := (&exittypes.MsgExecuteDEXSwap{RouteID: "r1", Signer: "s"}).ValidateBasic(); err != nil { t.Errorf("valid: %v", err) } if err := (&exittypes.MsgExecuteDEXSwap{RouteID: "", Signer: "s"}).ValidateBasic(); err == nil { t.Error("empty route-id should fail") } if err := (&exittypes.MsgExecuteDEXSwap{RouteID: "r1", Signer: ""}).ValidateBasic(); err == nil { t.Error("empty signer should fail") } } func TestMsgRefundExitValidateBasic(t *testing.T) { if err := (&exittypes.MsgRefundExit{RouteID: "r1", Signer: "s"}).ValidateBasic(); err != nil { t.Errorf("valid: %v", err) } if err := (&exittypes.MsgRefundExit{RouteID: "", Signer: "s"}).ValidateBasic(); err == nil { t.Error("empty route-id should fail") } } func TestExitMsgGetSigners(t *testing.T) { m := &exittypes.MsgSubmitExitRoute{Signer: "holder-reach"} addrs := m.GetSigners() if len(addrs) != 1 || string(addrs[0]) != "holder-reach" { t.Errorf("GetSigners = %v, want [holder-reach]", addrs) } } // --- Keeper store helpers ---------------------------------------------------- func TestSetGetExitRoute(t *testing.T) { ctx, _, k := newSimtestContext(t) r := exittypes.ExitRoute{RouteID: "r9", Status: exittypes.ExitProposed} k.SetExitRoute(ctx, r) got, ok := k.GetExitRoute(ctx, "r9") if !ok { t.Fatal("GetExitRoute: not found") } if got.Status != exittypes.ExitProposed { t.Errorf("status = %q", got.Status) } if _, ok := k.GetExitRoute(ctx, "missing"); ok { t.Error("GetExitRoute should return false for missing route") } } func TestSetGetDEXSwap(t *testing.T) { ctx, _, k := newSimtestContext(t) s := exittypes.DEXSwap{SwapID: "s9", Venue: "oy-dex", Status: exittypes.ExitSettled} k.SetDEXSwap(ctx, s) got, ok := k.GetDEXSwap(ctx, "s9") if !ok { t.Fatal("GetDEXSwap: not found") } if got.Venue != "oy-dex" { t.Errorf("venue = %q", got.Venue) } } func TestAllExitRoutesAndSwaps(t *testing.T) { ctx, _, k := newSimtestContext(t) k.SetExitRoute(ctx, exittypes.ExitRoute{RouteID: "r1", Status: exittypes.ExitProposed}) k.SetExitRoute(ctx, exittypes.ExitRoute{RouteID: "r2", Status: exittypes.ExitSettled}) k.SetDEXSwap(ctx, exittypes.DEXSwap{SwapID: "s1", Venue: "v"}) if len(k.AllExitRoutes(ctx)) != 2 { t.Errorf("expected 2 routes") } if len(k.AllDEXSwaps(ctx)) != 1 { t.Errorf("expected 1 swap") } } // --- Cross-chain exit: nil shim handling ------------------------------------- // TestCrossChainExitNilBridgeShimFails asserts a cross-chain exit with a nil // BridgeKeeper shim fails the route (not a panic). func TestCrossChainExitNilBridgeShimFails(t *testing.T) { ctx, _, k := newSimtestContext(t) srv := keeper.NewMsgServerImpl(k) // Clear the bridge shim to simulate unwired. k.SetBridgeKeeper(nil) srv.SubmitExitRoute(ctx, &exittypes.MsgSubmitExitRoute{ RouteID: "route-noshim", HolderReachID: "h", SourceAsset: "ubread", DestAsset: "uatom", Amount: 100, Signer: "h", }) r, _ := k.GetExitRoute(ctx, "route-noshim") r.BridgeRouteID = "bridge-x" k.SetExitRoute(ctx, r) _, err := srv.ExecuteDEXSwap(ctx, &exittypes.MsgExecuteDEXSwap{ RouteID: "route-noshim", Venue: "venue:5", Signer: "h", }) if err != nil { t.Errorf("ExecuteDEXSwap with nil shim should not return error (route fails to Failed); got %v", err) } r, _ = k.GetExitRoute(ctx, "route-noshim") if r.Status != exittypes.ExitFailed { t.Errorf("cross-chain exit with nil shim should fail; got %q", r.Status) } } // --- JSON marshal/unmarshal for the InflightPacket (bridge) sanity ----------- // TestInflightPacketJSON asserts the InflightPacket JSON round-trips (the // keeper uses json.Marshal/Unmarshal). func TestInflightPacketJSON(t *testing.T) { p := struct { SourcePort string Amount int64 }{"transfer", 100} bz, _ := json.Marshal(p) var got struct { SourcePort string Amount int64 } if err := json.Unmarshal(bz, &got); err != nil { t.Fatalf("unmarshal: %v", err) } if got.SourcePort != "transfer" || got.Amount != 100 { t.Errorf("round-trip mismatch: %+v", got) } }