diff --git a/apps/web/__tests__/usePoolTicks.test.ts b/apps/web/__tests__/usePoolTicks.test.ts index 5c1f142..297d2e9 100644 --- a/apps/web/__tests__/usePoolTicks.test.ts +++ b/apps/web/__tests__/usePoolTicks.test.ts @@ -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 = [ { @@ -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([]); + }); + }); }); \ No newline at end of file diff --git a/apps/web/components/AddLiquidity/PositionPreview.test.tsx b/apps/web/components/AddLiquidity/PositionPreview.test.tsx new file mode 100644 index 0000000..c9ee079 --- /dev/null +++ b/apps/web/components/AddLiquidity/PositionPreview.test.tsx @@ -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 { + 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(); + expect(screen.getByText('12.4%')).toBeInTheDocument(); + }); + + it('shows N/A instead of a misleading percentage when APR data is missing', () => { + render(); + 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(); + 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')); + }); +}); diff --git a/apps/web/components/AddLiquidity/PositionPreview.tsx b/apps/web/components/AddLiquidity/PositionPreview.tsx index ffa0528..b90d625 100644 --- a/apps/web/components/AddLiquidity/PositionPreview.tsx +++ b/apps/web/components/AddLiquidity/PositionPreview.tsx @@ -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; @@ -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 ( + + + + + + + ); +} + export function PositionPreview({ token0Symbol, token1Symbol, @@ -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 (
@@ -113,9 +141,18 @@ export function PositionPreview({ /> + Est. APR + + + } + value={aprAvailable ? `${estimatedApr}%` : 'N/A'} + valueClassName={ + aprAvailable + ? 'text-emerald-600 dark:text-emerald-400 font-bold' + : 'text-zinc-400 dark:text-zinc-500' + } /> void; /** The pool's current active tick */ currentTick: number; /** Currently selected lower bound tick */ @@ -47,6 +55,8 @@ const CHART_W = 100; // percentage units export function RangeSelector({ ticks, + ticksError, + onRetryTicks, currentTick, lowerTick, upperTick, @@ -146,6 +156,21 @@ export function RangeSelector({
+ {ticksError && ( +
+ Showing estimated liquidity — live data failed to load. + {onRetryTicks && ( + + )} +
+ )} + {/* Depth chart */}
({ + useSwaps: (...args: unknown[]) => mockUseSwaps(...args), +})); + +vi.mock('@/hooks/useLpActivity', () => ({ + useLpActivity: (...args: unknown[]) => mockUseLpActivity(...args), +})); + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +function makeSwap(id: string): SwapSnapshot { + return { + id, + poolId: 'pool-1', + token0Symbol: 'XLM', + token1Symbol: 'USDC', + amount0: '100', + amount1: '10', + priceAtSwap: '0.1', + txHash: `hash-${id}`, + walletAddress: 'GABC', + timestamp: 1700000000, + }; +} + +/** Total swap count used across tests: two full pages of 20. */ +const TOTAL_SWAPS = 40; + +function defaultSwapsImpl(_wallet: string | null, page: number) { + const count = Math.max(0, Math.min(PAGE_SIZE, TOTAL_SWAPS - (page - 1) * PAGE_SIZE)); + return { + data: { items: Array.from({ length: count }, (_, i) => makeSwap(`${page}-${i}`)), total: TOTAL_SWAPS }, + isLoading: false, + error: null, + }; +} + +beforeEach(() => { + mockUseSwaps.mockReset(); + mockUseLpActivity.mockReset(); + mockUseSwaps.mockImplementation(defaultSwapsImpl); + mockUseLpActivity.mockReturnValue({ + data: { items: [], total: 0 }, + isLoading: false, + error: null, + }); +}); + +describe('TransactionHistory pagination', () => { + it('hides pagination controls when there is only one page', () => { + mockUseSwaps.mockReturnValue({ + data: { items: [makeSwap('1')], total: 1 }, + isLoading: false, + error: null, + }); + + render(); + + expect(screen.queryByText(/^Page \d+ of \d+$/)).not.toBeInTheDocument(); + }); + + it('shows Prev/Next controls and disables Previous on the first page', () => { + render(); + + expect(screen.getByText('Page 1 of 2')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Previous' })).toBeDisabled(); + expect(screen.getByRole('button', { name: 'Next' })).not.toBeDisabled(); + }); + + it('advances to the next page and requests it from the API', () => { + render(); + + fireEvent.click(screen.getByRole('button', { name: 'Next' })); + + expect(screen.getByText('Page 2 of 2')).toBeInTheDocument(); + expect(mockUseSwaps).toHaveBeenLastCalledWith('GABC', 2, PAGE_SIZE); + expect(screen.getByRole('button', { name: 'Next' })).toBeDisabled(); + expect(screen.getByRole('button', { name: 'Previous' })).not.toBeDisabled(); + }); + + it('shows a distinct empty-page notice (not the zero-history message) when the current page has no items', () => { + mockUseSwaps.mockImplementation((_wallet: string | null, page: number) => ({ + data: { items: page === 1 ? [makeSwap('1')] : [], total: TOTAL_SWAPS }, + isLoading: false, + error: null, + })); + + render(); + fireEvent.click(screen.getByRole('button', { name: 'Next' })); + + expect(screen.getByText('No results on this page')).toBeInTheDocument(); + expect(screen.queryByText('No swap history yet')).not.toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: 'Back to page 1' })); + expect(mockUseSwaps).toHaveBeenLastCalledWith('GABC', 1, PAGE_SIZE); + }); + + it('shows the zero-history message (not the empty-page notice) when there is no history at all', () => { + mockUseSwaps.mockReturnValue({ + data: { items: [], total: 0 }, + isLoading: false, + error: null, + }); + + render(); + + expect(screen.getByText('No swap history yet')).toBeInTheDocument(); + expect(screen.queryByText('No results on this page')).not.toBeInTheDocument(); + }); +}); diff --git a/apps/web/components/TransactionHistory.tsx b/apps/web/components/TransactionHistory.tsx index c07e84a..e5889ac 100644 --- a/apps/web/components/TransactionHistory.tsx +++ b/apps/web/components/TransactionHistory.tsx @@ -1,13 +1,16 @@ 'use client'; import Link from 'next/link'; -import { useState } from 'react'; +import { useEffect, useState } from 'react'; import { useSwaps, SwapSnapshot } from '@/hooks/useSwaps'; import { useLpActivity, LpActivity, LpActivityType } from '@/hooks/useLpActivity'; import { useNetworkContext } from '@/context/NetworkContext'; type Tab = 'swaps' | 'lp'; +/** Page size sent to the API and used to derive the total page count. */ +const PAGE_SIZE = 20; + /** * Props accepted by the transaction history table. * @@ -34,19 +37,28 @@ export function TransactionHistory({ walletAddress }: TransactionHistoryProps) { data: swapsData, isLoading: swapsLoading, error: swapsError, - } = useSwaps(walletAddress, page); + } = useSwaps(walletAddress, page, PAGE_SIZE); const { data: lpData, isLoading: lpLoading, error: lpError, - } = useLpActivity(walletAddress, null, page); + } = useLpActivity(walletAddress, null, page, PAGE_SIZE); const filteredSwaps = filterByDate(swapsData?.items || [], startDate, endDate); const filteredLpActivity = filterByDate(lpData?.items || [], startDate, endDate); - const totalPages = Math.ceil( - (activeTab === 'swaps' ? (swapsData?.total ?? 0) : (lpData?.total ?? 0)) / 20 - ); + const activeTotal = activeTab === 'swaps' ? (swapsData?.total ?? 0) : (lpData?.total ?? 0); + const totalPages = Math.ceil(activeTotal / PAGE_SIZE); + const activeLoading = activeTab === 'swaps' ? swapsLoading : lpLoading; + + // If the underlying data set shrinks (e.g. items removed, or a stale page + // number left over from a previous tab/filter), snap back to the last page + // that actually has data instead of showing a page that will always be empty. + useEffect(() => { + if (!activeLoading && totalPages > 0 && page > totalPages) { + setPage(totalPages); + } + }, [activeLoading, totalPages, page]); function filterByDate( items: T[], @@ -149,6 +161,8 @@ export function TransactionHistory({ walletAddress }: TransactionHistoryProps) { formatDate={formatDate} truncateHash={truncateHash} cols={7} + page={page} + onBackToFirstPage={() => setPage(1)} /> ) : ( setPage(1)} /> )} @@ -196,6 +212,24 @@ interface SwapTableProps { formatDate: (timestamp: number) => string; truncateHash: (hash: string) => string; cols?: number; + page: number; + onBackToFirstPage: () => void; +} + +function EmptyPageNotice({ onBackToFirstPage }: { onBackToFirstPage: () => void }) { + return ( +
+

No results on this page

+

+ +

+
+ ); } function SkeletonRows({ cols }: { cols: number }) { @@ -222,12 +256,18 @@ function SwapTable({ formatDate, truncateHash, cols = 7, + page, + onBackToFirstPage, }: SwapTableProps) { if (error) { return
Failed to load swaps
; } if (!loading && swaps.length === 0) { + if (page > 1) { + return ; + } + return (

No swap history yet

@@ -332,6 +372,8 @@ interface LpTableProps { getExplorerUrl: (hash: string) => string; formatDate: (timestamp: number) => string; truncateHash: (hash: string) => string; + page: number; + onBackToFirstPage: () => void; } function LpTable({ @@ -341,6 +383,8 @@ function LpTable({ getExplorerUrl, formatDate, truncateHash, + page, + onBackToFirstPage, }: LpTableProps) { if (error) { return ( @@ -354,6 +398,10 @@ function LpTable({ } if (!loading && activities.length === 0) { + if (page > 1) { + return ; + } + return (

No LP activity yet

diff --git a/apps/web/hooks/useAddLiquidity.ts b/apps/web/hooks/useAddLiquidity.ts index 674aa0f..9d6e74e 100644 --- a/apps/web/hooks/useAddLiquidity.ts +++ b/apps/web/hooks/useAddLiquidity.ts @@ -308,12 +308,30 @@ export function useAddLiquidity() { const depositValue = parseFloat(state.amount0 || '0') * cp + parseFloat(state.amount1 || '0'); const shareOfPool = state.pool.tvl > 0 ? ((depositValue / state.pool.tvl) * 100).toFixed(4) : '0.0000'; - const rangeRatio = Math.min(1, (up - lp) / cp); - const boostedApr = - rangeRatio > 0 - ? (state.pool.feeApr / Math.max(0.01, rangeRatio)).toFixed(1) - : state.pool.feeApr.toFixed(1); - return { shareOfPool, estimatedApr: boostedApr, inRange }; + + // Fee APR assumptions live in docs/FEE_APR_CALCULATION.md: the boosted + // APR extrapolates from the pool's trailing-24h fee/TVL ratio. Without a + // volume24h reading there's no fee data to extrapolate from (distinct + // from a *confirmed* zero-volume pool, which the backend already prices + // at a real 0% per that doc), so we surface "N/A" rather than a + // misleading number. + const hasVolumeData = + typeof state.pool.volume24h === 'number' && !Number.isNaN(state.pool.volume24h); + const hasFeeAprData = + typeof state.pool.feeApr === 'number' && !Number.isNaN(state.pool.feeApr); + + let estimatedApr: string; + if (!hasVolumeData || !hasFeeAprData) { + estimatedApr = 'N/A'; + } else { + const rangeRatio = Math.min(1, (up - lp) / cp); + estimatedApr = + rangeRatio > 0 + ? (state.pool.feeApr / Math.max(0.01, rangeRatio)).toFixed(1) + : state.pool.feeApr.toFixed(1); + } + + return { shareOfPool, estimatedApr, inRange }; }, [state]); return { diff --git a/apps/web/hooks/usePoolTicks.ts b/apps/web/hooks/usePoolTicks.ts index 7ea571d..dbb7723 100644 --- a/apps/web/hooks/usePoolTicks.ts +++ b/apps/web/hooks/usePoolTicks.ts @@ -1,6 +1,6 @@ 'use client'; -import { useEffect, useState } from 'react'; +import { useCallback, useEffect, useState } from 'react'; import { API_BASE } from '@/lib/constants'; export interface TickData { @@ -25,14 +25,27 @@ export interface PoolDetail { volume24h: number; } +/** + * Loads initialized ticks for a pool from the API. + * + * Partial data policy: if the tick fetch fails (network error, non-2xx + * response, or bad JSON), `ticks` falls back to synthetic placeholder + * liquidity so range-selector charts always have bars to render instead of + * going blank. `error` is set in this case so callers can flag the chart as + * showing estimated (not real) liquidity and offer a way to retry via the + * returned `retry()` function. A successful retry clears `error` and + * replaces the synthetic ticks with real data. + */ export function usePoolTicks(poolId: string | null) { const [ticks, setTicks] = useState([]); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); + const [retryCount, setRetryCount] = useState(0); useEffect(() => { if (!poolId) { setTicks([]); + setError(null); return; } let cancelled = false; @@ -41,16 +54,16 @@ export function usePoolTicks(poolId: string | null) { fetch(`${API_BASE}/pools/${poolId}/ticks`) .then((r) => { - if (!r.ok) throw new Error('Failed to load tick data'); + if (!r.ok) throw new Error(`Failed to load tick data: HTTP ${r.status}`); return r.json() as Promise; }) .then((data) => { if (!cancelled) setTicks(data); }) - .catch(() => { + .catch((err: unknown) => { if (!cancelled) { setTicks(generateSyntheticTicks()); - setError(null); + setError(err instanceof Error ? err.message : 'Failed to load tick data'); } }) .finally(() => { @@ -60,9 +73,11 @@ export function usePoolTicks(poolId: string | null) { return () => { cancelled = true; }; - }, [poolId]); + }, [poolId, retryCount]); + + const retry = useCallback(() => setRetryCount((c) => c + 1), []); - return { ticks, loading, error }; + return { ticks, loading, error, retry }; } export function usePools() { diff --git a/apps/web/lib/constants.ts b/apps/web/lib/constants.ts index e8b43c9..c306708 100644 --- a/apps/web/lib/constants.ts +++ b/apps/web/lib/constants.ts @@ -9,6 +9,9 @@ export const WALLET_STORAGE_KEY = 'swyft_wallet_address'; /** localStorage key for the user's runtime testnet/mainnet selection. */ export const NETWORK_STORAGE_KEY = 'swyft_selected_network'; export const API_BASE = `${process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:3001'}/v1`; +/** Source-of-truth doc for how estimated fee APR is calculated and its assumptions. */ +export const FEE_APR_DOC_URL = + 'https://github.com/Vatix-Protocol/Swyft/blob/main/docs/FEE_APR_CALCULATION.md'; /** Per-network API base URLs. The environment variable overrides the default * for the build-time network; the other network uses its own default. */ diff --git a/packages/sdk/src/__tests__/swap.spec.ts b/packages/sdk/src/__tests__/swap.spec.ts index f682b94..042bd59 100644 --- a/packages/sdk/src/__tests__/swap.spec.ts +++ b/packages/sdk/src/__tests__/swap.spec.ts @@ -1,14 +1,16 @@ +import { TransactionBuilder, Networks, scValToNative, Transaction } from '@stellar/stellar-sdk'; import { buildSwapTx, toStellarAddress, toRawAmount, toXdrBase64, SwapValidationError, + DEFAULT_SWAP_DEADLINE_SECONDS, } from '../swap'; -const POOL = toStellarAddress('CPOOL000000000000000000000000000000000000000000000000000A'); -const TOKEN_IN = toStellarAddress('CTOKENIN0000000000000000000000000000000000000000000000000'); -const TOKEN_OUT = toStellarAddress('CTOKENOUT000000000000000000000000000000000000000000000000'); +const POOL = toStellarAddress('CAAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQC526'); +const TOKEN_IN = toStellarAddress('CABQGAYDAMBQGAYDAMBQGAYDAMBQGAYDAMBQGAYDAMBQGAYDAMBQGCK3'); +const TOKEN_OUT = toStellarAddress('CACQKBIFAUCQKBIFAUCQKBIFAUCQKBIFAUCQKBIFAUCQKBIFAUCQLC2U'); const OWNER = toStellarAddress('GCEZWKCA5VLDNRLN3RPRJMRZOX3Z6G5CHCGSNFHEYVXM3XOJMDS674JZ'); const AMOUNT_IN = toRawAmount('1000000'); const MIN_OUT = toRawAmount('990000'); @@ -93,7 +95,7 @@ describe('buildSwapTx', () => { it('produces different XDR hash for different poolId', () => { const tx1 = buildSwapTx(validParams); - const tx2 = buildSwapTx({ ...validParams, poolId: toStellarAddress('CPOOL999999999999999999999999999999999999999999999999999') }); + const tx2 = buildSwapTx({ ...validParams, poolId: toStellarAddress('CABAEAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAFNSZ') }); const hash1 = Buffer.from(tx1.xdr, 'base64').toString('hex'); const hash2 = Buffer.from(tx2.xdr, 'base64').toString('hex'); expect(hash1).not.toBe(hash2); @@ -101,7 +103,7 @@ describe('buildSwapTx', () => { it('produces different XDR hash for different tokenInId', () => { const tx1 = buildSwapTx(validParams); - const tx2 = buildSwapTx({ ...validParams, tokenInId: toStellarAddress('CTOKENIN999999999999999999999999999999999999999999999999') }); + const tx2 = buildSwapTx({ ...validParams, tokenInId: toStellarAddress('CACAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQCAINCW') }); const hash1 = Buffer.from(tx1.xdr, 'base64').toString('hex'); const hash2 = Buffer.from(tx2.xdr, 'base64').toString('hex'); expect(hash1).not.toBe(hash2); @@ -109,7 +111,7 @@ describe('buildSwapTx', () => { it('produces different XDR hash for different tokenOutId', () => { const tx1 = buildSwapTx(validParams); - const tx2 = buildSwapTx({ ...validParams, tokenOutId: toStellarAddress('CTOKENOUT9999999999999999999999999999999999999999999999') }); + const tx2 = buildSwapTx({ ...validParams, tokenOutId: toStellarAddress('CADAMBQGAYDAMBQGAYDAMBQGAYDAMBQGAYDAMBQGAYDAMBQGAYDAMSST') }); const hash1 = Buffer.from(tx1.xdr, 'base64').toString('hex'); const hash2 = Buffer.from(tx2.xdr, 'base64').toString('hex'); expect(hash1).not.toBe(hash2); @@ -125,7 +127,7 @@ describe('buildSwapTx', () => { it('produces different XDR hash for different ownerAddress', () => { const tx1 = buildSwapTx(validParams); - const tx2 = buildSwapTx({ ...validParams, ownerAddress: toStellarAddress('GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA2') }); + const tx2 = buildSwapTx({ ...validParams, ownerAddress: toStellarAddress('GADQOBYHA4DQOBYHA4DQOBYHA4DQOBYHA4DQOBYHA4DQOBYHA4DQOZPI') }); const hash1 = Buffer.from(tx1.xdr, 'base64').toString('hex'); const hash2 = Buffer.from(tx2.xdr, 'base64').toString('hex'); expect(hash1).not.toBe(hash2); @@ -211,6 +213,68 @@ describe('buildSwapTx', () => { }); }); +describe('deadline', () => { + const validParams = { + poolId: POOL, + tokenInId: TOKEN_IN, + tokenOutId: TOKEN_OUT, + amountIn: AMOUNT_IN, + minimumReceived: MIN_OUT, + ownerAddress: OWNER, + }; + + function decode(xdr: string): Transaction { + return TransactionBuilder.fromXDR(xdr, Networks.TESTNET) as Transaction; + } + + function deadlineArg(xdr: string): bigint { + const tx = decode(xdr); + const op = tx.operations[0] as unknown as { func: { invokeContract(): { args(): unknown[] } } }; + const args = op.func.invokeContract().args(); + return BigInt(scValToNative(args[args.length - 1] as never)); + } + + it('defaults the deadline to now + DEFAULT_SWAP_DEADLINE_SECONDS', () => { + const before = Math.floor(Date.now() / 1000); + const tx = buildSwapTx(validParams); + const parsed = decode(tx.xdr); + const maxTime = Number(parsed.timeBounds?.maxTime); + expect(maxTime).toBeGreaterThanOrEqual(before + DEFAULT_SWAP_DEADLINE_SECONDS); + expect(maxTime).toBeLessThanOrEqual(before + DEFAULT_SWAP_DEADLINE_SECONDS + 5); + }); + + it('uses an explicit deadline as the transaction maxTime precondition', () => { + const deadline = Math.floor(Date.now() / 1000) + 120; + const tx = buildSwapTx({ ...validParams, deadline }); + const parsed = decode(tx.xdr); + expect(Number(parsed.timeBounds?.maxTime)).toBe(deadline); + }); + + it('includes the deadline as the final swap contract call argument', () => { + const deadline = Math.floor(Date.now() / 1000) + 120; + const tx = buildSwapTx({ ...validParams, deadline }); + expect(deadlineArg(tx.xdr)).toBe(BigInt(deadline)); + }); + + it('throws SwapValidationError for an already-expired deadline', () => { + const pastDeadline = Math.floor(Date.now() / 1000) - 10; + expect(() => buildSwapTx({ ...validParams, deadline: pastDeadline })).toThrow( + SwapValidationError + ); + }); + + it('throws SwapValidationError for a non-integer deadline', () => { + expect(() => buildSwapTx({ ...validParams, deadline: 1.5 })).toThrow(SwapValidationError); + }); + + it('produces different XDR for different deadlines', () => { + const now = Math.floor(Date.now() / 1000); + const tx1 = buildSwapTx({ ...validParams, deadline: now + 60 }); + const tx2 = buildSwapTx({ ...validParams, deadline: now + 120 }); + expect(tx1.xdr).not.toBe(tx2.xdr); + }); +}); + describe('cast helpers', () => { it('toStellarAddress returns the same string value', () => { const addr = 'GCEZWKCA5VLDNRLN3RPRJMRZOX3Z6G5CHCGSNFHEYVXM3XOJMDS674JZ'; diff --git a/packages/sdk/src/swap.ts b/packages/sdk/src/swap.ts index bbff199..02330af 100644 --- a/packages/sdk/src/swap.ts +++ b/packages/sdk/src/swap.ts @@ -49,6 +49,13 @@ export interface PoolId { readonly token1: StellarAddress; } +/** + * Default swap deadline window, in seconds, applied when {@link SwapTxParams.deadline} + * is not provided. Chosen to give a wallet enough time to prompt/sign while still + * bounding how stale a swap can execute. + */ +export const DEFAULT_SWAP_DEADLINE_SECONDS = 600; + /** * Parameters for building an exact-input single-hop swap transaction. * @@ -71,6 +78,18 @@ export interface SwapTxParams { readonly ownerAddress: StellarAddress; /** Slippage tolerance in basis points (e.g., 50 = 0.5%). Defaults to 50. */ readonly slippageBps?: number; + /** + * Unix timestamp (seconds) after which the swap must no longer execute. + * Defaults to `now + {@link DEFAULT_SWAP_DEADLINE_SECONDS}`. + * + * The deadline is enforced two ways: + * - It is passed as an explicit `deadline` argument to the pool contract's + * `swap` invocation, so the contract can reject stale calls itself. + * - It is also set as the transaction's `maxTime` precondition, so Stellar + * Core rejects submission of an expired envelope outright (`txTOO_LATE`) + * even before the contract call is evaluated. + */ + readonly deadline?: number; } /** @@ -118,9 +137,14 @@ export class SwapValidationError extends Error { * contract. The transaction is built with a placeholder source account and must be * properly signed before submission. * + * The swap carries a deadline (see {@link SwapTxParams.deadline}) to prevent stale + * execution: it is forwarded as a contract call argument and also encoded as the + * transaction's `maxTime` precondition, so an expired swap is rejected at the + * Stellar protocol level (`txTOO_LATE`) in addition to any contract-side check. + * * @param params - Swap parameters including pool ID, token IDs, amounts, and owner. * @returns An unsigned swap transaction envelope in base-64 XDR format. - * @throws {SwapValidationError} If parameters are invalid (invalid addresses or amounts). + * @throws {SwapValidationError} If parameters are invalid (invalid addresses, amounts, or an already-expired deadline). */ export function buildSwapTx(params: SwapTxParams): SwapUnsignedTx { if (!isValidStellarAddress(params.poolId)) { @@ -162,6 +186,15 @@ export function buildSwapTx(params: SwapTxParams): SwapUnsignedTx { } } + const nowSeconds = Math.floor(Date.now() / 1000); + const deadline = params.deadline ?? nowSeconds + DEFAULT_SWAP_DEADLINE_SECONDS; + + if (!Number.isInteger(deadline) || deadline <= nowSeconds) { + throw new SwapValidationError( + `Invalid deadline: must be a future unix timestamp (seconds). Got: ${params.deadline}` + ); + } + try { const contract = new Contract(params.poolId); @@ -177,8 +210,16 @@ export function buildSwapTx(params: SwapTxParams): SwapUnsignedTx { const tokenOutScVal = nativeToScVal(params.tokenOutId, { type: 'address', }); - - const swapOp = contract.call('swap', tokenInScVal, tokenOutScVal, amountInScVal, minOutScVal); + const deadlineScVal = nativeToScVal(deadline, { type: 'u64' }); + + const swapOp = contract.call( + 'swap', + tokenInScVal, + tokenOutScVal, + amountInScVal, + minOutScVal, + deadlineScVal + ); const sourceKeypair = Keypair.random(); const sourceAccount = new Account(sourceKeypair.publicKey(), "0"); @@ -186,10 +227,11 @@ export function buildSwapTx(params: SwapTxParams): SwapUnsignedTx { const txBuilder = new TransactionBuilder(sourceAccount, { fee: "100000", networkPassphrase: config.networkPassphrase, + timebounds: { minTime: 0, maxTime: deadline }, }); txBuilder.addOperation(swapOp); - const tx = txBuilder.setTimeout(30).build(); + const tx = txBuilder.build(); const xdrString = tx.toEnvelope().toXDR('base64'); return { xdr: xdrString as XdrBase64, type: 'swap' };