Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 14 additions & 31 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,46 +4,28 @@
"type": "module",
"private": true,
"scripts": {
"dev": "vite dev --port 3000 --mode testnet",
"dev:mainnet": "vite dev --port 3000 --mode mainnet",
"build": "vite build --mode testnet",
"build:mainnet": "vite build --mode mainnet",
"dev": "vite dev --port 3000",
"test": "bun test --preload ./setup-tests.ts",
"build": "vite build",
"preview": "vite preview",
"test": "vitest run",
"test:watch": "vitest",
"test:cov": "vitest run --coverage",
"test:coverage": "vitest run --coverage",
"lint": "eslint",
"format": "prettier --write \"**/*.{ts,tsx}\"",
"typecheck": "tsc --noEmit",
"contracts:check:cli": "stellar --version 2>/dev/null | grep -q 'stellar 22\\.' && echo 'OK' || echo 'ERROR: stellar CLI 22.x required (see docs/contracts.md)'",
"contracts:gen:exchange-router": "grep -v '^#' .env.testnet | xargs stellar contract bindings typescript --contract-id \"$VITE_CONTRACT_EXCHANGE_ROUTER\" --output-dir src/lib/contracts/generated/exchange-router --overwrite",
"contracts:gen:synthetics-reader": "grep -v '^#' .env.testnet | xargs stellar contract bindings typescript --contract-id \"$VITE_CONTRACT_SYNTHETICS_READER\" --output-dir src/lib/contracts/generated/synthetics-reader --overwrite",
"contracts:gen:data-store": "grep -v '^#' .env.testnet | xargs stellar contract bindings typescript --contract-id \"$VITE_CONTRACT_DATA_STORE\" --output-dir src/lib/contracts/generated/data-store --overwrite",
"contracts:gen:order-vault": "grep -v '^#' .env.testnet | xargs stellar contract bindings typescript --contract-id \"$VITE_CONTRACT_ORDER_VAULT\" --output-dir src/lib/contracts/generated/order-vault --overwrite",
"contracts:gen:staking-router": "grep -v '^#' .env.testnet | xargs stellar contract bindings typescript --contract-id \"$VITE_CONTRACT_STAKING_ROUTER\" --output-dir src/lib/contracts/generated/staking-router --overwrite",
"contracts:gen:glv-router": "grep -v '^#' .env.testnet | xargs stellar contract bindings typescript --contract-id \"$VITE_CONTRACT_GLV_ROUTER\" --output-dir src/lib/contracts/generated/glv-router --overwrite",
"contracts:gen:vesting-router": "grep -v '^#' .env.testnet | xargs stellar contract bindings typescript --contract-id \"$VITE_CONTRACT_VESTING_ROUTER\" --output-dir src/lib/contracts/generated/vesting-router --overwrite",
"contracts:gen:referral-storage": "grep -v '^#' .env.testnet | xargs stellar contract bindings typescript --contract-id \"$VITE_CONTRACT_REFERRAL_STORAGE\" --output-dir src/lib/contracts/generated/referral-storage --overwrite",
"contracts:gen:all": "bun run contracts:gen:exchange-router && bun run contracts:gen:synthetics-reader && bun run contracts:gen:data-store && bun run contracts:gen:order-vault && bun run contracts:gen:staking-router && bun run contracts:gen:glv-router && bun run contracts:gen:vesting-router && bun run contracts:gen:referral-storage"
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@creit.tech/stellar-wallets-kit": "^2.2.0",
"@creit.tech/stellar-wallets-kit": "^2.4.0",
"@hugeicons/core-free-icons": "^4.1.2",
"@hugeicons/react": "^1.1.6",
"@pythnetwork/hermes-client": "^3.1.0",
"@stellar/freighter-api": "^4.0.0",
"@stellar/stellar-sdk": "^15.1.0",
"@tailwindcss/vite": "^4.1.18",
"@tanstack/react-query": "^5.100.9",
"@tanstack/react-router": "^1.132.0",
"@tanstack/react-start": "^1.132.0",
"@tanstack/router-plugin": "^1.132.0",
"@workspace/contracts": "workspace:*",
"@workspace/ui": "workspace:*",
"lightweight-charts": "^5.2.0",
"nitro": "latest",
"qrcode.react": "^4.2.0",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"sonner": "^2.0.7",
Expand All @@ -52,25 +34,26 @@
"zustand": "^5.0.4"
},
"devDependencies": {
"@happy-dom/global-registrator": "^20.10.6",
"@pythnetwork/hermes-client": "^3.1.0",
"@repo/vitest-config": "workspace:*",
"@tanstack/eslint-config": "^0.3.0",
"@tanstack/react-query-devtools": "^5.100.9",
"@tanstack/react-query-devtools": "^5.101.1",
"@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^14.0.0",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/node": "^25.1.0",
"@types/react": "^19.2.10",
"@types/react-dom": "^19.2.3",
"@typescript-eslint/eslint-plugin": "^8.60.0",
"@vitejs/plugin-react": "^5.1.1",
"@vitest/coverage-v8": "^4.1.8",
"@vitest/ui": "3",
"eslint": "^9.39.2",
"eslint-plugin-react-hooks": "^7.1.1",
"jsdom": "^22.1.0",
"happy-dom": "^20.10.6",
"msw": "^2.14.6",
"qrcode.react": "^4.2.0",
"typescript": "^5.9.3",
"typescript-eslint": "^8.60.0",
"vite": "^7.3.2",
"vitest": "^4.1.8"
"vitest": "3"
}
}
34 changes: 34 additions & 0 deletions apps/web/setup-tests.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import "@testing-library/jest-dom/vitest"
import { GlobalRegistrator } from '@happy-dom/global-registrator';

GlobalRegistrator.register();

// happy-dom defaults to about:blank which makes window.location.origin === "null"
// This breaks new URL("/", origin) in ConnectButton's WalletModal
window.location.href = "http://localhost:3000/";

// Required env vars (vitest config normally injects these via define)
process.env.VITE_NETWORK = 'testnet';
process.env.VITE_RPC_URL = 'https://soroban-testnet.stellar.org';
process.env.VITE_HORIZON_URL = 'https://horizon-testnet.stellar.org';
process.env.VITE_CONTRACT_EXCHANGE_ROUTER = 'CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABSC4';
process.env.VITE_CONTRACT_SYNTHETICS_READER = 'CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABSE2';
process.env.VITE_CONTRACT_DATA_STORE = 'CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABSDS';
process.env.VITE_CONTRACT_ORDER_VAULT = 'CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABSOV';
process.env.VITE_CONTRACT_STAKING_ROUTER = 'CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABSSR';
process.env.VITE_CONTRACT_GLV_ROUTER = 'CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABSGR';
process.env.VITE_CONTRACT_VESTING_ROUTER = 'CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABSVR';
process.env.VITE_CONTRACT_REFERRAL_STORAGE = 'CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABSRS';
process.env.VITE_CONTRACT_MARKET_FACTORY = 'CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABSMF';
process.env.VITE_CONTRACT_DEPOSIT_HANDLER = 'CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABSDH';
process.env.VITE_CONTRACT_WITHDRAWAL_HANDLER = 'CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABSWH';
process.env.VITE_CONTRACT_ORACLE = 'CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABSO';
process.env.VITE_CONTRACT_ORDER_HANDLER = 'CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABSOH';
process.env.VITE_FAUCET = 'CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABSF';
process.env.VITE_TOKEN_TUSDC = 'CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABTUSDC';
process.env.VITE_TOKEN_TWBTC = 'CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABTWBTC';
process.env.VITE_TOKEN_TETH = 'CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABTETH';
process.env.VITE_TOKEN_TXLM = 'CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABTXLM';
process.env.VITE_MARKET_TOKEN_TWBTC_TUSDC = 'CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABMT1';
process.env.VITE_MARKET_TOKEN_TETH_TUSDC = 'CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABMT2';
process.env.VITE_MARKET_TOKEN_TXLM_TUSDC = 'CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABMT3';
39 changes: 39 additions & 0 deletions apps/web/src/features/pools/components/gm-pools-table.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { afterEach, describe, expect, it } from 'vitest';

import { cleanup, render, screen } from '@testing-library/react';
import { GmPoolsTable } from './gm-pools-table';
import type { Pool } from './gm-pools-table';

// Ensure cleanup after each test
afterEach(() => {
cleanup();
});

describe('GmPoolsTable', () => {
it('renders loading state', () => {
render(<GmPoolsTable isLoading />);
expect(screen.getByText('Loading pools...')).toBeTruthy();
});

it('renders empty state', () => {
render(<GmPoolsTable pools={[]} />);
expect(screen.getByText('No pools found.')).toBeTruthy();
});

it('renders fixture row and visible columns', () => {
const fixture: Array<Pool> = [
{ id: '1', name: 'USDC/XLM', tvl: '$1,000,000', apr: '12%' },
];
render(<GmPoolsTable pools={fixture} />);

// Assert visible columns
expect(screen.getByRole('columnheader', { name: 'Pool' })).toBeTruthy();
expect(screen.getByRole('columnheader', { name: 'TVL' })).toBeTruthy();
expect(screen.getByRole('columnheader', { name: 'APR' })).toBeTruthy();

// Assert row text
expect(screen.getByRole('cell', { name: 'USDC/XLM' })).toBeTruthy();
expect(screen.getByRole('cell', { name: '$1,000,000' })).toBeTruthy();
expect(screen.getByRole('cell', { name: '12%' })).toBeTruthy();
});
});
208 changes: 42 additions & 166 deletions apps/web/src/features/pools/components/gm-pools-table.tsx
Original file line number Diff line number Diff line change
@@ -1,178 +1,54 @@
import { useCallback, useMemo, useState } from "react"
import { GmPoolRow } from "./gm-pool-row"
import type { PoolRowMetrics } from "./gm-pool-row"
import type { PoolMarketConfig } from "../data/markets"

type GmPoolsTableProps = {
markets: Array<PoolMarketConfig>
}

type SortKey = "tvlUsd" | "openInterestUsd" | "apy"
type SortDirection = "asc" | "desc"
import type { PoolMarketConfig } from '../data/markets';

type SortState = {
key: SortKey
direction: SortDirection
export interface Pool {
id: string;
name: string;
tvl: string;
apr: string;
}

const SORT_LABELS: Record<SortKey, string> = {
tvlUsd: "TVL",
openInterestUsd: "Open Interest",
apy: "APY",
export interface GmPoolsTableProps {
isLoading?: boolean;
pools?: Array<Pool>;
markets?: Array<PoolMarketConfig>;
}

function SortHeader({
label,
sortKey,
sort,
onSort,
}: {
label: string
sortKey: SortKey
sort: SortState
onSort: (key: SortKey) => void
}) {
const active = sort.key === sortKey
const indicator = active ? (sort.direction === "asc" ? "↑" : "↓") : "↕"

return (
<button
type="button"
className={`ml-auto inline-flex h-7 items-center gap-1 rounded px-2 text-right font-medium transition-colors hover:bg-muted hover:text-foreground ${
active ? "text-foreground" : "text-muted-foreground"
}`}
aria-label={`Sort by ${label}`}
aria-sort={active ? (sort.direction === "asc" ? "ascending" : "descending") : "none"}
onClick={() => onSort(sortKey)}
>
<span>{label}</span>
<span className="w-3 text-center text-[10px]">{indicator}</span>
</button>
)
}

export function GmPoolsTable({ markets }: GmPoolsTableProps) {
const [sort, setSort] = useState<SortState>({ key: "tvlUsd", direction: "desc" })
const [metricsByMarket, setMetricsByMarket] = useState<Partial<Record<string, PoolRowMetrics>>>({})

const handleSort = useCallback((key: SortKey) => {
setSort((current) =>
current.key === key
? { key, direction: current.direction === "asc" ? "desc" : "asc" }
: { key, direction: "desc" },
)
}, [])

const handleMetricsChange = useCallback(
(marketToken: string, metrics: PoolRowMetrics) => {
setMetricsByMarket((current) => {
const previous = current[marketToken]
if (
previous?.tvlUsd === metrics.tvlUsd &&
previous.openInterestUsd === metrics.openInterestUsd &&
previous.apy === metrics.apy
) {
return current
}

return { ...current, [marketToken]: metrics }
})
},
[],
)

const sortedMarkets = useMemo(() => {
return [...markets].sort((a, b) => {
const aMetrics = metricsByMarket[a.marketToken]
const bMetrics = metricsByMarket[b.marketToken]
const aValue = aMetrics?.[sort.key] ?? Number.NEGATIVE_INFINITY
const bValue = bMetrics?.[sort.key] ?? Number.NEGATIVE_INFINITY
const result = aValue - bValue

if (result === 0) return a.label.localeCompare(b.label)

return sort.direction === "asc" ? result : -result
})
}, [markets, metricsByMarket, sort.direction, sort.key])
export function GmPoolsTable({ isLoading, pools, markets }: GmPoolsTableProps) {
const data = pools || (markets ? markets.map(m => ({
id: m.marketToken,
name: m.displayName,
tvl: '...',
apr: '...'
})) : []);
if (isLoading) {
return <div className="p-8 text-center text-muted-foreground animate-pulse">Loading pools...</div>;
}

if (markets.length === 0) {
return (
<div className="px-5 py-10 text-center text-sm text-muted-foreground">
No markets configured.
</div>
)
if (data.length === 0) {
return <div className="p-8 text-center text-muted-foreground">No pools found.</div>;
}

return (
<>
<div className="hidden md:block">
<table className="w-full min-w-190 table-fixed text-left">
<colgroup>
<col className="w-[19%]" />
<col className="w-[12%]" />
<col className="w-[20%]" />
<col className="w-[13%]" />
<col className="w-[11%]" />
<col className="w-[10%]" />
<col className="w-[11%]" />
<col className="w-[14%]" />
</colgroup>
<thead className="sticky top-0 z-10 bg-card/95 backdrop-blur">
<tr className="border-b border-border text-[11px] uppercase text-muted-foreground">
<th className="px-5 py-3 font-medium">Pool</th>
<th className="px-2 py-3 text-right font-medium">
<SortHeader label="TVL" sortKey="tvlUsd" sort={sort} onSort={handleSort} />
</th>
<th className="px-5 py-3 font-medium">Composition</th>
<th className="px-2 py-3 text-right font-medium">
<SortHeader
label="Open Interest"
sortKey="openInterestUsd"
sort={sort}
onSort={handleSort}
/>
</th>
<th className="px-5 py-3 text-right font-medium">Funding / hr</th>
<th className="px-2 py-3 text-right font-medium">
<SortHeader label="APY" sortKey="apy" sort={sort} onSort={handleSort} />
</th>
<th className="px-5 py-3 text-right font-medium">Your GM</th>
<th className="px-5 py-3 text-right font-medium">Actions</th>
<div className="w-full overflow-auto border rounded-md">
<table className="w-full text-sm text-left">
<thead className="text-xs uppercase bg-muted text-muted-foreground">
<tr>
<th className="px-6 py-3 font-medium">Pool</th>
<th className="px-6 py-3 font-medium">TVL</th>
<th className="px-6 py-3 font-medium">APR</th>
</tr>
</thead>
<tbody>
{data.map((pool) => (
<tr key={pool.id} className="border-b last:border-0 hover:bg-muted/50 transition-colors">
<td className="px-6 py-4">{pool.name}</td>
<td className="px-6 py-4">{pool.tvl}</td>
<td className="px-6 py-4">{pool.apr}</td>
</tr>
</thead>
<tbody>
{sortedMarkets.map((market) => (
<GmPoolRow
key={market.marketToken}
market={market}
variant="desktop"
onMetricsChange={handleMetricsChange}
/>
))}
</tbody>
</table>
</div>

<div className="grid gap-3 p-3 md:hidden">
<div className="flex items-center justify-between gap-3 px-1 text-[11px] uppercase text-muted-foreground">
<span>Sorted by {SORT_LABELS[sort.key]}</span>
<button
type="button"
className="rounded border border-border px-2 py-1 text-foreground"
onClick={() => handleSort(sort.key)}
>
{sort.direction === "asc" ? "Ascending" : "Descending"}
</button>
</div>
{sortedMarkets.map((market) => (
<GmPoolRow
key={market.marketToken}
market={market}
variant="mobile"
onMetricsChange={handleMetricsChange}
/>
))}
</div>
</>
)
))}
</tbody>
</table>
</div>
);
}
Loading
Loading