diff --git a/apps/s03-indexer/tests/mappingHandlers.test.ts b/apps/s03-indexer/tests/mappingHandlers.test.ts
index 9a7cc01..1108457 100644
--- a/apps/s03-indexer/tests/mappingHandlers.test.ts
+++ b/apps/s03-indexer/tests/mappingHandlers.test.ts
@@ -368,6 +368,43 @@ describe("SO4 event dispatch", () => {
expect(withdrawal.cancellationReason).toBe("expired");
});
+ test("indexes withdrawal create event with all key fields", async () => {
+ await dispatchEvent(so4Event("wth_crt", lifecyclePayload("wth-2")));
+
+ const [withdrawal] = records("Withdrawal");
+ expect(records("Withdrawal")).toHaveLength(1);
+ expect(withdrawal.id).toBe("withdrawal:wth-2");
+ expect(withdrawal.status).toBe("CREATED");
+ expect(withdrawal.account).toBe(account);
+ expect(withdrawal.market).toBe(marketToken);
+ expect(withdrawal.createdLedger).toBe(100);
+ });
+
+ test("indexes withdrawal execute event", async () => {
+ await dispatchEvent(so4Event("wth_crt", lifecyclePayload("wth-3")));
+ await dispatchEvent(so4Event("wth_exe", lifecyclePayload("wth-3")));
+
+ const [withdrawal] = records("Withdrawal");
+ expect(records("Withdrawal")).toHaveLength(1);
+ expect(withdrawal.id).toBe("withdrawal:wth-3");
+ expect(withdrawal.status).toBe("EXECUTED");
+ expect(withdrawal.executedLedger).toBe(100);
+ });
+
+ test("withdrawal lifecycle events are idempotent on rerun", async () => {
+ const event1 = so4Event("wth_crt", lifecyclePayload("wth-4"));
+ const event2 = so4Event("wth_exe", lifecyclePayload("wth-4"));
+
+ await dispatchEvent(event1);
+ await dispatchEvent(event2);
+ await dispatchEvent(event1);
+ await dispatchEvent(event2);
+
+ expect(records("Withdrawal")).toHaveLength(1);
+ const [withdrawal] = records("Withdrawal");
+ expect(withdrawal.status).toBe("EXECUTED");
+ });
+
test("indexes order lifecycle updates", async () => {
await dispatchEvent(
so4Event("ord_crt", lifecyclePayload("ord-1", { order_type: "MARKET", is_long: true })),
@@ -384,6 +421,63 @@ describe("SO4 event dispatch", () => {
expect(order.acceptablePrice).toBe("2000");
});
+ test("indexes order freeze event", async () => {
+ await dispatchEvent(
+ so4Event("ord_crt", lifecyclePayload("ord-2", { order_type: "LIMIT", is_long: false })),
+ );
+ await dispatchEvent(
+ so4Event("ord_frz", lifecyclePayload("ord-2")),
+ );
+
+ const [order] = records("Order");
+ expect(records("Order")).toHaveLength(1);
+ expect(order.id).toBe("order:ord-2");
+ expect(order.status).toBe("FROZEN");
+ expect(order.isLong).toBe(false);
+ });
+
+ test("indexes order execute event", async () => {
+ await dispatchEvent(
+ so4Event("ord_crt", lifecyclePayload("ord-3", { order_type: "MARKET", is_long: true })),
+ );
+ await dispatchEvent(
+ so4Event("ord_exe", lifecyclePayload("ord-3")),
+ );
+
+ const [order] = records("Order");
+ expect(records("Order")).toHaveLength(1);
+ expect(order.id).toBe("order:ord-3");
+ expect(order.status).toBe("EXECUTED");
+ });
+
+ test("indexes order cancel event", async () => {
+ await dispatchEvent(
+ so4Event("ord_crt", lifecyclePayload("ord-4", { order_type: "LIMIT" })),
+ );
+ await dispatchEvent(
+ so4Event("ord_can", lifecyclePayload("ord-4", { reason: "user_request" })),
+ );
+
+ const [order] = records("Order");
+ expect(records("Order")).toHaveLength(1);
+ expect(order.id).toBe("order:ord-4");
+ expect(order.status).toBe("CANCELLED");
+ });
+
+ test("order lifecycle events are idempotent on rerun", async () => {
+ const event1 = so4Event("ord_crt", lifecyclePayload("ord-5", { order_type: "MARKET" }));
+ const event2 = so4Event("ord_exe", lifecyclePayload("ord-5"));
+
+ await dispatchEvent(event1);
+ await dispatchEvent(event2);
+ await dispatchEvent(event1);
+ await dispatchEvent(event2);
+
+ expect(records("Order")).toHaveLength(1);
+ const [order] = records("Order");
+ expect(order.status).toBe("EXECUTED");
+ });
+
test("indexes position changes and current position state", async () => {
await dispatchEvent(
so4Event("pos_inc", {
diff --git a/apps/web/src/features/trade/components/CircuitBreakerBanner.test.tsx b/apps/web/src/features/trade/components/CircuitBreakerBanner.test.tsx
new file mode 100644
index 0000000..025332d
--- /dev/null
+++ b/apps/web/src/features/trade/components/CircuitBreakerBanner.test.tsx
@@ -0,0 +1,171 @@
+import { describe, expect, it, vi, beforeEach } from "vitest"
+import { render, screen, fireEvent } from "@testing-library/react"
+import { CircuitBreakerBanner } from "./CircuitBreakerBanner"
+import * as circuitBreakerHook from "../hooks/useCircuitBreaker"
+
+vi.mock("../hooks/useCircuitBreaker")
+
+describe("CircuitBreakerBanner", () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ it("renders nothing when status is not active", () => {
+ vi.mocked(circuitBreakerHook.useCircuitBreaker).mockReturnValueOnce({
+ data: { active: false, message: "" },
+ isLoading: false,
+ error: null,
+ } as never)
+
+ const { container } = render()
+ expect(container.firstChild).toBeNull()
+ })
+
+ it("renders nothing when status is undefined", () => {
+ vi.mocked(circuitBreakerHook.useCircuitBreaker).mockReturnValueOnce({
+ data: undefined,
+ isLoading: false,
+ error: null,
+ } as never)
+
+ const { container } = render()
+ expect(container.firstChild).toBeNull()
+ })
+
+ it("renders banner when status is active", () => {
+ vi.mocked(circuitBreakerHook.useCircuitBreaker).mockReturnValueOnce({
+ data: {
+ active: true,
+ message: "Market frozen due to circuit breaker",
+ },
+ isLoading: false,
+ error: null,
+ } as never)
+
+ render()
+ expect(screen.getByText("Market frozen due to circuit breaker")).toBeInTheDocument()
+ })
+
+ it("has alert role for accessibility", () => {
+ vi.mocked(circuitBreakerHook.useCircuitBreaker).mockReturnValueOnce({
+ data: {
+ active: true,
+ message: "Execution frozen",
+ },
+ isLoading: false,
+ error: null,
+ } as never)
+
+ render()
+ expect(screen.getByRole("alert")).toBeInTheDocument()
+ })
+
+ it("displays amber styling for warning severity", () => {
+ vi.mocked(circuitBreakerHook.useCircuitBreaker).mockReturnValueOnce({
+ data: {
+ active: true,
+ message: "Caution: market freeze active",
+ },
+ isLoading: false,
+ error: null,
+ } as never)
+
+ const { container } = render()
+ const banner = container.querySelector("[role='alert']")
+ expect(banner?.className).toContain("bg-amber-500/10")
+ expect(banner?.className).toContain("border-amber-500/40")
+ expect(banner?.className).toContain("text-amber-200")
+ })
+
+ it("shows dismiss button with correct styling", () => {
+ vi.mocked(circuitBreakerHook.useCircuitBreaker).mockReturnValueOnce({
+ data: {
+ active: true,
+ message: "Circuit breaker engaged",
+ },
+ isLoading: false,
+ error: null,
+ } as never)
+
+ const { container } = render()
+ const dismissBtn = container.querySelector("button")
+ expect(dismissBtn).toHaveTextContent("Dismiss")
+ expect(dismissBtn?.className).toContain("text-amber-300")
+ })
+
+ it("dismisses banner when dismiss button clicked", () => {
+ vi.mocked(circuitBreakerHook.useCircuitBreaker).mockReturnValueOnce({
+ data: {
+ active: true,
+ message: "Execution frozen",
+ },
+ isLoading: false,
+ error: null,
+ } as never)
+
+ const { container, rerender } = render()
+ expect(screen.getByText("Execution frozen")).toBeInTheDocument()
+
+ const dismissBtn = screen.getByRole("button", { name: /Dismiss/ })
+ fireEvent.click(dismissBtn)
+
+ expect(container.querySelector("[role='alert']")).not.toBeInTheDocument()
+ })
+
+ it("hides banner after dismissal even if status remains active", () => {
+ const useCircuitBreakerMock = vi.mocked(circuitBreakerHook.useCircuitBreaker)
+ useCircuitBreakerMock.mockReturnValueOnce({
+ data: {
+ active: true,
+ message: "Market frozen",
+ },
+ isLoading: false,
+ error: null,
+ } as never)
+
+ const { container } = render()
+ expect(screen.getByText("Market frozen")).toBeInTheDocument()
+
+ const dismissBtn = screen.getByRole("button", { name: /Dismiss/ })
+ fireEvent.click(dismissBtn)
+
+ expect(container.querySelector("[role='alert']")).not.toBeInTheDocument()
+ })
+
+ it("uses symbol from props in hook", () => {
+ vi.mocked(circuitBreakerHook.useCircuitBreaker).mockReturnValueOnce({
+ data: { active: false, message: "" },
+ isLoading: false,
+ error: null,
+ } as never)
+
+ render()
+ expect(circuitBreakerHook.useCircuitBreaker).toHaveBeenCalledWith("TSLA")
+ })
+
+ it("handles undefined symbol", () => {
+ vi.mocked(circuitBreakerHook.useCircuitBreaker).mockReturnValueOnce({
+ data: { active: false, message: "" },
+ isLoading: false,
+ error: null,
+ } as never)
+
+ render()
+ expect(circuitBreakerHook.useCircuitBreaker).toHaveBeenCalledWith(undefined)
+ })
+
+ it("displays explanatory copy with accessible text", () => {
+ vi.mocked(circuitBreakerHook.useCircuitBreaker).mockReturnValueOnce({
+ data: {
+ active: true,
+ message: "Trading is frozen: volatility limit exceeded",
+ },
+ isLoading: false,
+ error: null,
+ } as never)
+
+ render()
+ const banner = screen.getByRole("alert")
+ expect(banner).toHaveTextContent("Trading is frozen: volatility limit exceeded")
+ })
+})
diff --git a/apps/web/src/features/trade/components/trade-panel/ApplyReferralCodePrompt.test.tsx b/apps/web/src/features/trade/components/trade-panel/ApplyReferralCodePrompt.test.tsx
new file mode 100644
index 0000000..67bb01e
--- /dev/null
+++ b/apps/web/src/features/trade/components/trade-panel/ApplyReferralCodePrompt.test.tsx
@@ -0,0 +1,218 @@
+import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"
+import { render, screen, fireEvent, waitFor } from "@testing-library/react"
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
+import { ApplyReferralCodePrompt } from "./ApplyReferralCodePrompt"
+import * as referralLib from "@/features/referrals/lib/referrals"
+import * as contractLib from "@/lib/contracts"
+
+vi.mock("@/features/referrals/lib/referrals")
+vi.mock("@/lib/contracts")
+
+describe("ApplyReferralCodePrompt", () => {
+ let queryClient: QueryClient
+
+ beforeEach(() => {
+ queryClient = new QueryClient({
+ defaultOptions: {
+ queries: { retry: false },
+ },
+ })
+ vi.clearAllMocks()
+ })
+
+ afterEach(() => {
+ queryClient.clear()
+ })
+
+ const renderComponent = (account: string | null = "GTEST") => {
+ return render(
+
+
+ ,
+ )
+ }
+
+ it("renders nothing when no account provided", () => {
+ const { container } = renderComponent(null)
+ expect(container.firstChild).toBeNull()
+ })
+
+ it("renders nothing when account is loading", () => {
+ vi.mocked(contractLib.getTraderReferralCode).mockImplementationOnce(() => new Promise(() => {}))
+ vi.mocked(referralLib.hasCompletedReferralPrompt).mockReturnValueOnce(false)
+ vi.mocked(contractLib.readStoredReferralCode).mockReturnValueOnce(null)
+
+ const { container } = renderComponent("GTEST")
+ expect(container.querySelector("input")).not.toBeInTheDocument()
+ })
+
+ it("renders prompt when account exists and no referral applied", () => {
+ vi.mocked(contractLib.getTraderReferralCode).mockResolvedValueOnce(null)
+ vi.mocked(referralLib.hasCompletedReferralPrompt).mockReturnValueOnce(false)
+ vi.mocked(contractLib.readStoredReferralCode).mockReturnValueOnce(null)
+
+ renderComponent("GTEST")
+ expect(screen.getByText(/I have a referral code/)).toBeInTheDocument()
+ expect(screen.getByPlaceholderText("e.g. MYCODE123")).toBeInTheDocument()
+ })
+
+ it("renders nothing when referral already applied", () => {
+ vi.mocked(contractLib.getTraderReferralCode).mockResolvedValueOnce("EXISTING")
+ vi.mocked(referralLib.hasCompletedReferralPrompt).mockReturnValueOnce(false)
+ vi.mocked(contractLib.readStoredReferralCode).mockReturnValueOnce(null)
+
+ const { container } = renderComponent("GTEST")
+ expect(container.querySelector("input")).not.toBeInTheDocument()
+ })
+
+ it("renders nothing when prompt already completed", () => {
+ vi.mocked(contractLib.getTraderReferralCode).mockResolvedValueOnce(null)
+ vi.mocked(referralLib.hasCompletedReferralPrompt).mockReturnValueOnce(true)
+ vi.mocked(contractLib.readStoredReferralCode).mockReturnValueOnce(null)
+
+ const { container } = renderComponent("GTEST")
+ expect(container.querySelector("input")).not.toBeInTheDocument()
+ })
+
+ it("normalizes code to uppercase on input", async () => {
+ vi.mocked(contractLib.getTraderReferralCode).mockResolvedValueOnce(null)
+ vi.mocked(referralLib.hasCompletedReferralPrompt).mockReturnValueOnce(false)
+ vi.mocked(contractLib.readStoredReferralCode).mockReturnValueOnce(null)
+
+ renderComponent("GTEST")
+ const input = screen.getByPlaceholderText("e.g. MYCODE123") as HTMLInputElement
+ fireEvent.change(input, { target: { value: "mycode" } })
+ expect(input.value).toBe("MYCODE")
+ })
+
+ it("submits normalized code and dismisses on success", async () => {
+ vi.mocked(contractLib.getTraderReferralCode).mockResolvedValueOnce(null)
+ vi.mocked(referralLib.hasCompletedReferralPrompt).mockReturnValueOnce(false)
+ vi.mocked(contractLib.readStoredReferralCode).mockReturnValueOnce(null)
+ vi.mocked(referralLib.validateReferralCode).mockReturnValueOnce(null)
+ vi.mocked(referralLib.applyReferralCode).mockResolvedValueOnce(undefined)
+ vi.mocked(referralLib.markReferralPromptComplete).mockReturnValueOnce(undefined)
+
+ renderComponent("GTEST")
+ const input = screen.getByPlaceholderText("e.g. MYCODE123") as HTMLInputElement
+ const button = screen.getByRole("button", { name: /Apply/ })
+
+ fireEvent.change(input, { target: { value: " mycode " } })
+ fireEvent.click(button)
+
+ await waitFor(() => {
+ expect(referralLib.applyReferralCode).toHaveBeenCalledWith("GTEST", "MYCODE")
+ })
+ expect(referralLib.markReferralPromptComplete).toHaveBeenCalledWith("GTEST")
+ })
+
+ it("shows validation error without submitting", async () => {
+ vi.mocked(contractLib.getTraderReferralCode).mockResolvedValueOnce(null)
+ vi.mocked(referralLib.hasCompletedReferralPrompt).mockReturnValueOnce(false)
+ vi.mocked(contractLib.readStoredReferralCode).mockReturnValueOnce(null)
+ vi.mocked(referralLib.validateReferralCode).mockReturnValueOnce("Invalid code format")
+
+ renderComponent("GTEST")
+ const input = screen.getByPlaceholderText("e.g. MYCODE123")
+ const button = screen.getByRole("button", { name: /Apply/ })
+
+ fireEvent.change(input, { target: { value: "BAD" } })
+ fireEvent.click(button)
+
+ expect(screen.getByText("Invalid code format")).toBeInTheDocument()
+ expect(referralLib.applyReferralCode).not.toHaveBeenCalled()
+ })
+
+ it("shows error message on apply failure", async () => {
+ vi.mocked(contractLib.getTraderReferralCode).mockResolvedValueOnce(null)
+ vi.mocked(referralLib.hasCompletedReferralPrompt).mockReturnValueOnce(false)
+ vi.mocked(contractLib.readStoredReferralCode).mockReturnValueOnce(null)
+ vi.mocked(referralLib.validateReferralCode).mockReturnValueOnce(null)
+ vi.mocked(referralLib.applyReferralCode).mockRejectedValueOnce(new Error("Code not found"))
+
+ renderComponent("GTEST")
+ const input = screen.getByPlaceholderText("e.g. MYCODE123")
+ const button = screen.getByRole("button", { name: /Apply/ })
+
+ fireEvent.change(input, { target: { value: "BADCODE" } })
+ fireEvent.click(button)
+
+ await waitFor(() => {
+ expect(screen.getByText("Code not found")).toBeInTheDocument()
+ })
+ })
+
+ it("disables apply button when pending", async () => {
+ vi.mocked(contractLib.getTraderReferralCode).mockResolvedValueOnce(null)
+ vi.mocked(referralLib.hasCompletedReferralPrompt).mockReturnValueOnce(false)
+ vi.mocked(contractLib.readStoredReferralCode).mockReturnValueOnce(null)
+ vi.mocked(referralLib.validateReferralCode).mockReturnValueOnce(null)
+ vi.mocked(referralLib.applyReferralCode).mockImplementationOnce(() => new Promise(() => {}))
+
+ renderComponent("GTEST")
+ const input = screen.getByPlaceholderText("e.g. MYCODE123")
+ const button = screen.getByRole("button", { name: /Apply/ })
+
+ fireEvent.change(input, { target: { value: "CODE" } })
+ fireEvent.click(button)
+
+ await waitFor(() => {
+ expect(button).toHaveTextContent("…")
+ })
+ })
+
+ it("disables apply button when code is empty", () => {
+ vi.mocked(contractLib.getTraderReferralCode).mockResolvedValueOnce(null)
+ vi.mocked(referralLib.hasCompletedReferralPrompt).mockReturnValueOnce(false)
+ vi.mocked(contractLib.readStoredReferralCode).mockReturnValueOnce(null)
+
+ renderComponent("GTEST")
+ const button = screen.getByRole("button", { name: /Apply/ })
+ expect(button).toBeDisabled()
+ })
+
+ it("clears error when user edits code", async () => {
+ vi.mocked(contractLib.getTraderReferralCode).mockResolvedValueOnce(null)
+ vi.mocked(referralLib.hasCompletedReferralPrompt).mockReturnValueOnce(false)
+ vi.mocked(contractLib.readStoredReferralCode).mockReturnValueOnce(null)
+ vi.mocked(referralLib.validateReferralCode).mockReturnValueOnce("Invalid format")
+
+ renderComponent("GTEST")
+ const input = screen.getByPlaceholderText("e.g. MYCODE123")
+ const button = screen.getByRole("button", { name: /Apply/ })
+
+ fireEvent.change(input, { target: { value: "BAD" } })
+ fireEvent.click(button)
+
+ expect(screen.getByText("Invalid format")).toBeInTheDocument()
+
+ fireEvent.change(input, { target: { value: "GOOD" } })
+ expect(screen.queryByText("Invalid format")).not.toBeInTheDocument()
+ })
+
+ it("skips prompt and dismisses", async () => {
+ vi.mocked(contractLib.getTraderReferralCode).mockResolvedValueOnce(null)
+ vi.mocked(referralLib.hasCompletedReferralPrompt).mockReturnValueOnce(false)
+ vi.mocked(contractLib.readStoredReferralCode).mockReturnValueOnce(null)
+ vi.mocked(referralLib.markReferralPromptComplete).mockReturnValueOnce(undefined)
+
+ renderComponent("GTEST")
+ const skipButton = screen.getByRole("button", { name: /Skip for now/ })
+ fireEvent.click(skipButton)
+
+ expect(referralLib.markReferralPromptComplete).toHaveBeenCalledWith("GTEST")
+ await waitFor(() => {
+ expect(screen.queryByText(/I have a referral code/)).not.toBeInTheDocument()
+ })
+ })
+
+ it("reads stored referral code on mount", () => {
+ vi.mocked(contractLib.readStoredReferralCode).mockReturnValueOnce("STORED")
+ vi.mocked(contractLib.getTraderReferralCode).mockResolvedValueOnce(null)
+ vi.mocked(referralLib.hasCompletedReferralPrompt).mockReturnValueOnce(false)
+
+ renderComponent("GTEST")
+ const input = screen.getByPlaceholderText("e.g. MYCODE123") as HTMLInputElement
+ expect(input.value).toBe("STORED")
+ })
+})