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
122 changes: 120 additions & 2 deletions apps/web/__tests__/usePoolTicks.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { renderHook, waitFor } from '@testing-library/react';
import { usePools } from '@/hooks/usePoolTicks';
import { renderHook, waitFor, act } from '@testing-library/react';
import { usePools, usePoolTicks } from '@/hooks/usePoolTicks';

const mockPools = [
{
Expand Down Expand Up @@ -151,4 +151,122 @@ describe('usePools', () => {
expect(result.current.pools).toHaveLength(0);
});
});
});

describe('usePoolTicks', () => {
const mockTicks = [
{ tick: -60, liquidityNet: '1000', liquidityGross: '1000' },
{ tick: 0, liquidityNet: '2000', liquidityGross: '2000' },
{ tick: 60, liquidityNet: '1000', liquidityGross: '1000' },
];

beforeEach(() => {
vi.stubGlobal('fetch', vi.fn());
});

afterEach(() => {
vi.unstubAllGlobals();
});

it('returns empty ticks and no error when poolId is null', () => {
const { result } = renderHook(() => usePoolTicks(null));

expect(result.current.ticks).toEqual([]);
expect(result.current.error).toBeNull();
expect(result.current.loading).toBe(false);
});

it('loads real tick data on success with no error', async () => {
global.fetch = vi.fn().mockResolvedValueOnce({
ok: true,
json: async () => mockTicks,
});

const { result } = renderHook(() => usePoolTicks('pool-1'));

expect(result.current.loading).toBe(true);

await waitFor(() => {
expect(result.current.loading).toBe(false);
});

expect(result.current.ticks).toEqual(mockTicks);
expect(result.current.error).toBeNull();
});

it('falls back to synthetic ticks and surfaces an error on HTTP failure', async () => {
global.fetch = vi.fn().mockResolvedValueOnce({
ok: false,
status: 503,
});

const { result } = renderHook(() => usePoolTicks('pool-1'));

await waitFor(() => {
expect(result.current.loading).toBe(false);
});

expect(result.current.error).toContain('503');
expect(result.current.ticks.length).toBeGreaterThan(0);
// Synthetic fallback ticks are not the real data returned by the API.
expect(result.current.ticks).not.toEqual(mockTicks);
});

it('falls back to synthetic ticks and surfaces an error on network failure mid-fetch', async () => {
global.fetch = vi.fn().mockRejectedValueOnce(new Error('RPC connection reset'));

const { result } = renderHook(() => usePoolTicks('pool-1'));

await waitFor(() => {
expect(result.current.loading).toBe(false);
});

expect(result.current.error).toBe('RPC connection reset');
expect(result.current.ticks.length).toBeGreaterThan(0);
});

it('retry() re-fetches and clears the error once the retry succeeds', async () => {
const fetchMock = vi
.fn()
.mockResolvedValueOnce({ ok: false, status: 500 })
.mockResolvedValueOnce({ ok: true, json: async () => mockTicks });
global.fetch = fetchMock;

const { result } = renderHook(() => usePoolTicks('pool-1'));

await waitFor(() => {
expect(result.current.error).not.toBeNull();
});

act(() => {
result.current.retry();
});

await waitFor(() => {
expect(result.current.error).toBeNull();
});

expect(result.current.ticks).toEqual(mockTicks);
expect(fetchMock).toHaveBeenCalledTimes(2);
});

it('does not update state after unmount', async () => {
let resolveFetch: () => void;
global.fetch = vi.fn().mockImplementation(
() =>
new Promise((resolve) => {
resolveFetch = () => resolve({ ok: true, json: async () => mockTicks });
})
);

const { result, unmount } = renderHook(() => usePoolTicks('pool-1'));
expect(result.current.loading).toBe(true);

unmount();
resolveFetch!();

await waitFor(() => {
expect(result.current.ticks).toEqual([]);
});
});
});
48 changes: 48 additions & 0 deletions apps/web/components/AddLiquidity/PositionPreview.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import { PositionPreview, type PositionPreviewProps } from './PositionPreview';
import { FEE_APR_DOC_URL } from '@/lib/constants';

function baseProps(overrides: Partial<PositionPreviewProps> = {}): PositionPreviewProps {
return {
token0Symbol: 'XLM',
token1Symbol: 'USDC',
amount0: '100',
amount1: '10',
lowerPrice: '0.09',
upperPrice: '0.11',
shareOfPool: '0.5',
estimatedApr: '12.4',
inRange: true,
currentPrice: 0.1,
txStatus: 'idle',
txError: null,
txHash: null,
positionNftId: null,
onSubmit: vi.fn(),
onReset: vi.fn(),
isWalletConnected: true,
...overrides,
};
}

describe('PositionPreview - estimated fees APR', () => {
it('shows the APR percentage when data is available', () => {
render(<PositionPreview {...baseProps({ estimatedApr: '12.4' })} />);
expect(screen.getByText('12.4%')).toBeInTheDocument();
});

it('shows N/A instead of a misleading percentage when APR data is missing', () => {
render(<PositionPreview {...baseProps({ estimatedApr: 'N/A' })} />);
expect(screen.getByText('N/A')).toBeInTheDocument();
expect(screen.queryByText('N/A%')).not.toBeInTheDocument();
});

it('links the APR assumptions to the fee APR calculation doc', () => {
render(<PositionPreview {...baseProps()} />);
const link = screen.getByLabelText('Fee APR calculation assumptions');
expect(link).toHaveAttribute('href', FEE_APR_DOC_URL);
expect(link).toHaveAttribute('target', '_blank');
expect(link).toHaveAttribute('rel', expect.stringContaining('noopener'));
});
});
45 changes: 41 additions & 4 deletions apps/web/components/AddLiquidity/PositionPreview.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
'use client';

import Link from 'next/link';
import type { ReactNode } from 'react';
import type { TxStatus } from '@/hooks/useAddLiquidity';
import { FEE_APR_DOC_URL } from '@/lib/constants';

export interface PositionPreviewProps {
token0Symbol: string;
Expand Down Expand Up @@ -38,11 +40,36 @@ export interface PositionPreviewProps {
}

interface RowProps {
label: string;
label: ReactNode;
value: string;
valueClassName?: string;
}

/** Info link pointing to the fee APR calculation doc, shown next to the "Est. APR" label. */
function AprInfoLink() {
return (
<a
href={FEE_APR_DOC_URL}
target="_blank"
rel="noopener noreferrer"
title="Estimated APR is annualized trailing-24h fees / TVL, scaled by how concentrated your selected range is. See the fee APR calculation assumptions."
aria-label="Fee APR calculation assumptions"
className="text-zinc-400 hover:text-zinc-600 dark:text-zinc-500 dark:hover:text-zinc-300"
>
<svg
className="h-3 w-3"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<circle cx="12" cy="12" r="9" />
<path strokeLinecap="round" d="M12 16v-4.5M12 8h.01" />
</svg>
</a>
);
}

export function PositionPreview({
token0Symbol,
token1Symbol,
Expand All @@ -64,6 +91,7 @@ export function PositionPreview({
}: PositionPreviewProps) {
const isBusy = txStatus === 'signing' || txStatus === 'submitting';
const hasAmounts = parseFloat(amount0 || '0') > 0 || parseFloat(amount1 || '0') > 0;
const aprAvailable = estimatedApr !== 'N/A' && estimatedApr !== '—';

return (
<div className="flex flex-col gap-3">
Expand Down Expand Up @@ -113,9 +141,18 @@ export function PositionPreview({
/>
<Row label="Share of pool" value={`${shareOfPool}%`} />
<Row
label="Est. APR"
value={`${estimatedApr}%`}
valueClassName="text-emerald-600 dark:text-emerald-400 font-bold"
label={
<span className="inline-flex items-center gap-1">
Est. APR
<AprInfoLink />
</span>
}
value={aprAvailable ? `${estimatedApr}%` : 'N/A'}
valueClassName={
aprAvailable
? 'text-emerald-600 dark:text-emerald-400 font-bold'
: 'text-zinc-400 dark:text-zinc-500'
}
/>
<Row
label="Status"
Expand Down
25 changes: 25 additions & 0 deletions apps/web/components/AddLiquidity/RangeSelector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,14 @@ import { tickToPrice, priceToTick, nearestUsableTick } from '@/hooks/useAddLiqui
export interface RangeSelectorProps {
/** Tick data used to render the liquidity depth chart */
ticks: TickData[];
/**
* Set when the tick fetch failed and `ticks` is synthetic placeholder
* data rather than real liquidity. Shows a warning banner with a retry
* action when present.
*/
ticksError?: string | null;
/** Re-fetches tick data after a failed load. */
onRetryTicks?: () => void;
/** The pool's current active tick */
currentTick: number;
/** Currently selected lower bound tick */
Expand Down Expand Up @@ -47,6 +55,8 @@ const CHART_W = 100; // percentage units

export function RangeSelector({
ticks,
ticksError,
onRetryTicks,
currentTick,
lowerTick,
upperTick,
Expand Down Expand Up @@ -146,6 +156,21 @@ export function RangeSelector({
</button>
</div>

{ticksError && (
<div className="flex items-center justify-between gap-2 rounded-lg bg-amber-50 px-3 py-2 text-xs text-amber-700 dark:bg-amber-900/30 dark:text-amber-400">
<span>Showing estimated liquidity — live data failed to load.</span>
{onRetryTicks && (
<button
type="button"
onClick={onRetryTicks}
className="shrink-0 font-semibold underline hover:no-underline"
>
Retry
</button>
)}
</div>
)}

{/* Depth chart */}
<div className="relative rounded-xl border border-zinc-100 bg-zinc-50 dark:border-zinc-800 dark:bg-zinc-900/50 overflow-hidden">
<svg
Expand Down
4 changes: 3 additions & 1 deletion apps/web/components/AddLiquidity/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ export function AddLiquidity() {
reset,
} = useAddLiquidity();

const { ticks } = usePoolTicks(pool?.id ?? null);
const { ticks, error: ticksError, retry: retryTicks } = usePoolTicks(pool?.id ?? null);
const { address, signTransaction } = useWalletContext();

const token0Symbol = pool?.token0Symbol ?? pool?.token0 ?? 'Token A';
Expand Down Expand Up @@ -70,6 +70,8 @@ export function AddLiquidity() {
{/* Step 2: Range */}
<RangeSelector
ticks={ticks}
ticksError={ticksError}
onRetryTicks={retryTicks}
currentTick={pool.currentTick}
lowerTick={lowerTick}
upperTick={upperTick}
Expand Down
Loading
Loading