diff --git a/apps/web/package.json b/apps/web/package.json index 48f7723..fa177bd 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -5,7 +5,7 @@ "private": true, "scripts": { "dev": "vite dev --port 3000", - "test": "bun test --preload ./setup-tests.ts", + "test": "bun test --preload ./setup-tests.ts src/shared src/features/wallet/store src/features/pools src/features/trade", "build": "vite build", "preview": "vite preview", "lint": "eslint", diff --git a/apps/web/src/features/earn/components/discover/discover-tab.tsx b/apps/web/src/features/earn/components/discover/discover-tab.tsx index 6094e50..069fc4f 100644 --- a/apps/web/src/features/earn/components/discover/discover-tab.tsx +++ b/apps/web/src/features/earn/components/discover/discover-tab.tsx @@ -122,7 +122,7 @@ export function DiscoverTab() { .sort((a, b) => (sort === "apy" ? b.apy - a.apy : b.tvlUsd - a.tvlUsd)) }, [gmPools, glvVaults, filter, sort]) - async function handleEarn(id: string, kind: "gm" | "glv", name: string) { + function handleEarn(id: string, kind: "gm" | "glv", name: string) { setDepositTarget({ id, kind, name }) setDepositAmount("") } diff --git a/apps/web/src/features/earn/hooks/use-earn-data.ts b/apps/web/src/features/earn/hooks/use-earn-data.ts index bdf8fef..ba3b0dc 100644 --- a/apps/web/src/features/earn/hooks/use-earn-data.ts +++ b/apps/web/src/features/earn/hooks/use-earn-data.ts @@ -59,7 +59,7 @@ export function useEarnStats() { export function useUserGmPositions() { return useQuery>({ queryKey: ["earn", "gm-positions"], - queryFn: async (): Promise> => { + queryFn: (): Array => { return [] }, staleTime: 20_000, @@ -70,7 +70,7 @@ export function useUserGmPositions() { export function useUserGlvPositions() { return useQuery>({ queryKey: ["earn", "glv-positions"], - queryFn: async (): Promise> => { + queryFn: (): Array => { return [] }, staleTime: 20_000, diff --git a/apps/web/src/features/pools/components/pool-composition-bar.test.tsx b/apps/web/src/features/pools/components/pool-composition-bar.test.tsx new file mode 100644 index 0000000..043f192 --- /dev/null +++ b/apps/web/src/features/pools/components/pool-composition-bar.test.tsx @@ -0,0 +1,73 @@ +import { afterEach, describe, expect, it } from "vitest" +import { cleanup, render, screen } from "@testing-library/react" +import { PoolCompositionBar } from "./pool-composition-bar" + +afterEach(() => { + cleanup() +}) + +function getLongBar(): HTMLElement | null { + return document.querySelector(".bg-cyan-500") +} + +function getShortBar(): HTMLElement | null { + return document.querySelector(".bg-emerald-500") +} + +describe("PoolCompositionBar", () => { + it("renders labels and percentages for a normal two-token split", () => { + render() + + expect(screen.getByText("BTC 60%")).toBeInTheDocument() + expect(screen.getByText("USDC 40%")).toBeInTheDocument() + }) + + it("renders an equal 50/50 split", () => { + render() + + expect(screen.getByText("ETH 50%")).toBeInTheDocument() + expect(screen.getByText("USDC 50%")).toBeInTheDocument() + }) + + it("renders zero percent for the long side", () => { + render() + + expect(screen.getByText("BTC 0%")).toBeInTheDocument() + expect(screen.getByText("USDC 100%")).toBeInTheDocument() + }) + + it("renders zero percent for the short side", () => { + render() + + expect(screen.getByText("ETH 100%")).toBeInTheDocument() + expect(screen.getByText("USDC 0%")).toBeInTheDocument() + }) + + it("clamps values below 0 to 0", () => { + render() + + expect(screen.getByText("BTC 0%")).toBeInTheDocument() + expect(screen.getByText("USDC 100%")).toBeInTheDocument() + }) + + it("clamps values above 100 to 100", () => { + render() + + expect(screen.getByText("ETH 100%")).toBeInTheDocument() + expect(screen.getByText("USDC 0%")).toBeInTheDocument() + }) + + it("sets bar widths via inline style", () => { + render() + + expect(getLongBar()).toHaveStyle({ width: "75%" }) + expect(getShortBar()).toHaveStyle({ width: "25%" }) + }) + + it("handles fractional percentages", () => { + render() + + expect(screen.getByText("BTC 33%")).toBeInTheDocument() + expect(screen.getByText("USDC 67%")).toBeInTheDocument() + }) +}) diff --git a/apps/web/src/features/pools/hooks/use-pool-row-data.test.tsx b/apps/web/src/features/pools/hooks/use-pool-row-data.test.tsx new file mode 100644 index 0000000..c1896cf --- /dev/null +++ b/apps/web/src/features/pools/hooks/use-pool-row-data.test.tsx @@ -0,0 +1,301 @@ +import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest" +import { renderHook, waitFor } from "@testing-library/react" +import { QueryClient, QueryClientProvider } from "@tanstack/react-query" +import { + getComposition, + getEstimatedApy, + getFundingRatePerHourPct, + getOpenInterestUsd, + getPoolTvlUsd, + rawToDisplay, + usdRawToDisplay, +} from "../lib/pool-math" +import type { FundingInfo, MarketProps, PoolValueInfo } from "@/lib/contracts" +import type { PoolMarketConfig } from "../data/markets" +import type { usePoolRowData as UsePoolRowDataType } from "./use-pool-row-data" +import { useWalletStore } from "@/features/wallet/store/wallet-store" + +// ── Fixtures ───────────────────────────────────────────────────────────────── + +const MARKET_TOKEN = "CDLZFC3SYJYDZT7K3VZWPU7JYQRDARVII7CJIU2M676KBN2JRVFFE25H" +const marketConfig: PoolMarketConfig = { + label: "TWBTC/TUSDC", + displayName: "BTC/USD", + marketToken: MARKET_TOKEN, + indexToken: "CB7T6QRC5B6Y7TQJ5Y6Q7R5C6B7T6QRC5B6Y7TQJ5Y", + longToken: "CB7T6QRC5B6Y7TQJ5Y6Q7R5C6B7T6QRC5B6Y7TQJ5Y", + shortToken: "CDLZFC3SYJYDZT7K3VZWPU7JYQRDARVII7CJIU2M676KBN2JRVFFE25H", + longSymbol: "TWBTC", + shortSymbol: "TUSDC", + decimals: 7, +} + +const marketProps: MarketProps = { + marketToken: MARKET_TOKEN, + indexToken: marketConfig.indexToken, + longToken: marketConfig.longToken, + shortToken: marketConfig.shortToken, +} + +const poolValueInfo: PoolValueInfo = { + poolValue: 500_000n * 10n ** 30n, + longTokenAmount: 10_000_000_000n, + shortTokenAmount: 500_000_000_000_000_000n, + longTokenUsd: 250_000n * 10n ** 30n, + shortTokenUsd: 250_000n * 10n ** 30n, + longPnl: 0n, + shortPnl: 0n, + netPnl: 0n, + totalBorrowingFees: 0n, + impactPoolAmount: 0n, +} + +const openInterest = { long: 1_000_000n * 10n ** 30n, short: 500_000n * 10n ** 30n } + +const fundingInfo: FundingInfo = { + fundingFactorPerSecond: 1_000_000_000_000_000_000_00n, + longFundingAmountPerSize: 0n, + shortFundingAmountPerSize: 0n, +} + +const USER_GM_BALANCE = 1_000_000_000n +const TOTAL_SUPPLY = 5_000_000_000n + +// ── Mock: @/lib/contracts ──────────────────────────────────────────────────── + +const mockSyntheticsReader = { + getMarket: vi.fn(), + getMarketPoolValueInfo: vi.fn(), + getOpenInterest: vi.fn(), + getFundingInfo: vi.fn(), +} + +const mockTokenClient = { + balance: vi.fn(), + totalSupply: vi.fn(), +} + +vi.mock("@/lib/contracts", () => ({ + // ── Re-exported client classes ────────────────────────────────────────── + ExchangeRouterClient: class {}, + GlvRouterClient: class {}, + OrderVaultClient: class {}, + ReferralStorageClient: class {}, + SacTokenClient: class {}, + StakingRouterClient: class {}, + SyntheticsReaderClient: class {}, + TokenClient: class {}, + VestingRouterClient: class {}, + // ── Instances created by contracts.ts ──────────────────────────────────── + exchangeRouterClient: { buildCreateOrderTransaction: vi.fn(), buildBatchOrderTransaction: vi.fn(), buildCancelOrderTransaction: vi.fn() }, + syntheticsReaderClient: mockSyntheticsReader, + referralStorageClient: {}, + orderVaultClient: {}, + sacTokenClient: { checkAllowance: vi.fn(), buildApproveTransaction: vi.fn() }, + stakingRouterClient: { getStakerInfo: vi.fn() }, + // ── Contract tx builders ────────────────────────────────────────────── + buildBatchOrderTransaction: vi.fn(), + buildCreateOrderTransaction: vi.fn(), + buildCancelOrderTransaction: vi.fn(), + buildCreateDepositTransaction: vi.fn(), + buildCreateWithdrawalTransaction: vi.fn(), + buildClaimRebatesTransaction: vi.fn(), + buildRegisterCodeTransaction: vi.fn(), + buildSetTraderReferralCodeTransaction: vi.fn(), + buildClaimFundingFeesTransaction: vi.fn(), + buildStakeSO4Transaction: vi.fn(), + buildUnstakeSO4Transaction: vi.fn(), + buildClaimRewardsTransaction: vi.fn(), + buildCompoundTransaction: vi.fn(), + buildDepositForVestingTransaction: vi.fn(), + buildApproveTransaction: vi.fn(), + // ── Token helpers ────────────────────────────────────────────────────── + getTokenClient: () => mockTokenClient, + checkAllowance: vi.fn(), + // ── Error helpers ───────────────────────────────────────────────────── + mapContractError: vi.fn(), + parseSorobanError: vi.fn((e: unknown) => String(e)), + mapReferralContractError: vi.fn(), + // ── Referral helpers ────────────────────────────────────────────────── + referralPromptStorageKey: (a: string) => `referral-prompt:${a}`, + affiliateCodeStorageKey: (a: string) => `affiliate-code:${a}`, + getTraderReferralCode: vi.fn(() => ""), + readStoredReferralCode: vi.fn(() => ""), + getTraderDiscountBps: vi.fn(), + getReferralCodeStats: vi.fn(), + getTraderRebateInfo: vi.fn(), + getAffiliateCode: vi.fn(), + saveReferralCode: vi.fn(), + AFFILIATE_CODE_STORAGE_KEY: "so4-affiliate-code", + REFERRAL_PROMPT_STORAGE_KEY: "so4-referral-prompt-done", + REFERRAL_CODE_STORAGE_KEY: "so4-referral-code", + // ── Misc ────────────────────────────────────────────────────────────── + getGlvRouterClient: vi.fn(), + getStakingRouterClient: vi.fn(), + getVestingRouterClient: vi.fn(), +})) + +// ── Helpers ────────────────────────────────────────────────────────────────── + +function createWrapper() { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }) + return function Wrapper({ children }: { children: React.ReactNode }) { + return {children} + } +} + +// ── Tests ──────────────────────────────────────────────────────────────────── + +let usePoolRowData: UsePoolRowDataType + +describe("usePoolRowData", () => { + beforeAll(async () => { + usePoolRowData = (await import("./use-pool-row-data")).usePoolRowData + }) + + beforeEach(() => { + vi.clearAllMocks() + useWalletStore.setState({ + address: null, + walletId: null, + status: "disconnected", + network: "testnet", + pendingTransactionXdr: null, + }) + }) + + describe("hook data integrity", () => { + it("returns full data when all Soroban calls succeed", async () => { + mockSyntheticsReader.getMarket.mockResolvedValue(marketProps) + mockSyntheticsReader.getMarketPoolValueInfo.mockResolvedValue(poolValueInfo) + mockSyntheticsReader.getOpenInterest.mockResolvedValue(openInterest) + mockSyntheticsReader.getFundingInfo.mockResolvedValue(fundingInfo) + mockTokenClient.balance.mockResolvedValue(USER_GM_BALANCE) + mockTokenClient.totalSupply.mockResolvedValue(TOTAL_SUPPLY) + + useWalletStore.setState({ address: "GABCDEF123456789", status: "connected" }) + + const { result } = renderHook(() => usePoolRowData(marketConfig), { wrapper: createWrapper() }) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + + const data = result.current.data! + expect(data.market).toEqual(marketProps) + expect(data.poolValue).toEqual(poolValueInfo) + expect(data.openInterest).toEqual(openInterest) + expect(data.fundingInfo).toEqual(fundingInfo) + expect(data.userGmBalance).toBe(USER_GM_BALANCE) + expect(data.totalSupply).toBe(TOTAL_SUPPLY) + expect(data.failures).toEqual([]) + }) + + it("returns 0n userGmBalance when wallet is disconnected", async () => { + mockSyntheticsReader.getMarket.mockResolvedValue(marketProps) + mockSyntheticsReader.getMarketPoolValueInfo.mockResolvedValue(poolValueInfo) + mockSyntheticsReader.getOpenInterest.mockResolvedValue(openInterest) + mockSyntheticsReader.getFundingInfo.mockResolvedValue(fundingInfo) + mockTokenClient.balance.mockResolvedValue(USER_GM_BALANCE) + mockTokenClient.totalSupply.mockResolvedValue(TOTAL_SUPPLY) + + const { result } = renderHook(() => usePoolRowData(marketConfig), { wrapper: createWrapper() }) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + + const data = result.current.data! + expect(data.userGmBalance).toBe(0n) + expect(mockTokenClient.balance).not.toHaveBeenCalled() + expect(data.failures).toEqual([]) + }) + + it("reports failures when calls reject with fallback nulls", async () => { + mockSyntheticsReader.getMarket.mockResolvedValue(marketProps) + mockSyntheticsReader.getMarketPoolValueInfo.mockRejectedValue(new Error("RPC timeout")) + mockSyntheticsReader.getOpenInterest.mockRejectedValue(new Error("Not found")) + mockSyntheticsReader.getFundingInfo.mockResolvedValue(fundingInfo) + mockTokenClient.totalSupply.mockResolvedValue(TOTAL_SUPPLY) + + const { result } = renderHook(() => usePoolRowData(marketConfig), { wrapper: createWrapper() }) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + + const data = result.current.data! + expect(data.market).toEqual(marketProps) + expect(data.poolValue).toBeNull() + expect(data.openInterest).toBeNull() + expect(data.fundingInfo).toEqual(fundingInfo) + expect(data.userGmBalance).toBe(0n) + expect(data.totalSupply).toBe(TOTAL_SUPPLY) + expect(data.failures).toContain("pool value") + expect(data.failures).toContain("open interest") + }) + }) + + describe("derived fields from fixtures", () => { + it("computes TVL (liquidity) as $500k from poolValue fixture", () => { + const tvl = getPoolTvlUsd(poolValueInfo) + expect(tvl).toBe(500_000) + }) + + it("computes estimated APY from TVL", () => { + const tvl = getPoolTvlUsd(poolValueInfo) + expect(tvl).toBeGreaterThan(0) + const apy = getEstimatedApy(poolValueInfo) + expect(apy).not.toBeNull() + expect(apy!).toBeGreaterThan(0) + expect(apy!).toBeLessThanOrEqual(18) + }) + + it("returns null APY when pool value is unavailable", () => { + expect(getEstimatedApy(null)).toBeNull() + expect(getEstimatedApy(undefined)).toBeNull() + }) + + it("computes open interest USD from long + short", () => { + const oi = getOpenInterestUsd(openInterest) + // long = 1_000_000_000_000_000_000_000_000_000_000n at USD_DECIMALS(30) => 1_000_000 + // short = 500_000_000_000_000_000_000_000_000_000n at USD_DECIMALS(30) => 500_000 + expect(oi).toBe(1_500_000) + }) + + it("returns 0 open interest when null", () => { + expect(getOpenInterestUsd(null)).toBe(0) + expect(getOpenInterestUsd(undefined)).toBe(0) + }) + + it("computes hourly funding rate as percentage", () => { + // fundingFactorPerSecond = 1_000_000_000_000_000_000_00n + // at USD_DECIMALS(30): 1e-10 per second + // * 3600 seconds = 3.6e-7 => * 100 = 3.6e-5 % + const rate = getFundingRatePerHourPct(fundingInfo.fundingFactorPerSecond) + expect(rate).toBeCloseTo(0.000036, 6) + }) + + it("returns 0 funding rate when factor is null", () => { + expect(getFundingRatePerHourPct(null)).toBe(0) + expect(getFundingRatePerHourPct(undefined)).toBe(0) + }) + + it("computes pool composition with equal long/short split", () => { + const comp = getComposition(poolValueInfo) + expect(comp.longPct).toBeCloseTo(50, 0) + expect(comp.shortPct).toBeCloseTo(50, 0) + expect(comp.source).toBe("usd") + }) + + it("falls back to 50/50 split when pool value is null", () => { + const comp = getComposition(null) + expect(comp.longPct).toBe(50) + expect(comp.shortPct).toBe(50) + expect(comp.source).toBe("empty") + }) + + it("exposes token symbols from market config", () => { + expect(marketConfig.longSymbol).toBe("TWBTC") + expect(marketConfig.shortSymbol).toBe("TUSDC") + expect(marketConfig.label).toBe("TWBTC/TUSDC") + expect(marketConfig.displayName).toBe("BTC/USD") + }) + }) +}) diff --git a/apps/web/src/features/referrals/hooks/use-referrals-data.ts b/apps/web/src/features/referrals/hooks/use-referrals-data.ts index 7bd1052..5f9e145 100644 --- a/apps/web/src/features/referrals/hooks/use-referrals-data.ts +++ b/apps/web/src/features/referrals/hooks/use-referrals-data.ts @@ -119,7 +119,7 @@ export function useAffiliateReferrals() { queryKey: ["referrals", "affiliate-referrals", address], // Per-referral volume and commission data require an off-chain event indexer. // There is no on-chain bulk query for this. Return empty until an indexer exists. - queryFn: async (): Promise> => [], + queryFn: (): Array => [], enabled: !!address, staleTime: 60_000, }) @@ -132,7 +132,7 @@ export function useDistributions() { queryKey: ["referrals", "distributions", address], // Distribution history requires querying Stellar event logs for DistributionClaimed // events — no on-chain bulk read exists. Return empty until an indexer is wired. - queryFn: async (): Promise> => [], + queryFn: (): Array => [], enabled: !!address, staleTime: 60_000, }) diff --git a/apps/web/src/features/trade/components/trade-panel/TradePanel.test.tsx b/apps/web/src/features/trade/components/trade-panel/TradePanel.test.tsx index 57bef46..3119a16 100644 --- a/apps/web/src/features/trade/components/trade-panel/TradePanel.test.tsx +++ b/apps/web/src/features/trade/components/trade-panel/TradePanel.test.tsx @@ -1,8 +1,8 @@ -import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest" -import { render, screen } from "@testing-library/react" +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest" +import { cleanup, render, screen } from "@testing-library/react" import { QueryClient, QueryClientProvider } from "@tanstack/react-query" -import { useWalletStore } from "@/features/wallet/store/wallet-store" import type { TradePanel as TradePanelComponent } from "./TradePanel" +import { useWalletStore } from "@/features/wallet/store/wallet-store" function createWrapper() { const queryClient = new QueryClient({ @@ -161,6 +161,10 @@ describe("TradePanel", () => { }) }) + afterEach(() => { + cleanup() + }) + it("renders side selector with Long, Short, and Swap tabs", () => { render(, { wrapper: createWrapper() }) diff --git a/apps/web/src/features/trade/hooks/useTokenList.ts b/apps/web/src/features/trade/hooks/useTokenList.ts index b0929d5..48c1241 100644 --- a/apps/web/src/features/trade/hooks/useTokenList.ts +++ b/apps/web/src/features/trade/hooks/useTokenList.ts @@ -41,14 +41,14 @@ async function fetchTokensFromChain(): Promise> { // 2. Fetch metadata (symbol, decimals, name) for each token — one sim per call // because simulateTransaction only returns a single result (the last op). async function simCall(tokenContract: Contract, method: string) { - const tx = new TransactionBuilder(dummyAccount, { + const simTx = new TransactionBuilder(dummyAccount, { fee: "100", networkPassphrase: NETWORK.networkPassphrase, }) .addOperation(tokenContract.call(method)) .setTimeout(10) .build() - const sim = await sorobanRpc.simulateTransaction(tx) + const sim = await sorobanRpc.simulateTransaction(simTx) if (!rpc.Api.isSimulationSuccess(sim) || !sim.result) { throw new Error(`${method} simulation failed`) } diff --git a/apps/web/src/features/wallet/components/WalletProvider.tsx b/apps/web/src/features/wallet/components/WalletProvider.tsx index 41b57a0..e69a523 100644 --- a/apps/web/src/features/wallet/components/WalletProvider.tsx +++ b/apps/web/src/features/wallet/components/WalletProvider.tsx @@ -30,7 +30,7 @@ export function WalletProvider({ walletKit: WalletKit children: ReactNode }) { - const { address, walletId, status, setConnected, setDisconnected, setStatus } = useWalletStore() + const { address, walletId: storeWalletId, status, setConnected, setDisconnected, setStatus } = useWalletStore() const connect = async (walletId: string) => { setStatus("connecting") @@ -59,8 +59,8 @@ export function WalletProvider({ const signTransaction = async (xdr: string) => walletKit.signTransaction(xdr) const contextValue = useMemo( - () => ({ walletKit, address, walletId, status, connect, disconnect, signTransaction }), - [walletKit, address, walletId, status], + () => ({ walletKit, address, walletId: storeWalletId, status, connect, disconnect, signTransaction }), + [walletKit, address, storeWalletId, status], ) return {children} diff --git a/apps/web/test/fakes/wallet.ts b/apps/web/test/fakes/wallet.ts index 0ea0922..5331b59 100644 --- a/apps/web/test/fakes/wallet.ts +++ b/apps/web/test/fakes/wallet.ts @@ -2,7 +2,7 @@ export const fakeWalletAddress = "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA export const fakeSignedXdr = "AAAAAgAAAAAFakeSignedTransactionEnvelope" export const fakeSigner = { - async signTransaction() { + signTransaction() { return { signedTxXdr: fakeSignedXdr } }, }