diff --git a/apps/web/__tests__/RemoveLiquidityFlow.test.tsx b/apps/web/__tests__/RemoveLiquidityFlow.test.tsx new file mode 100644 index 0000000..82af20e --- /dev/null +++ b/apps/web/__tests__/RemoveLiquidityFlow.test.tsx @@ -0,0 +1,242 @@ +/** + * Integration test for remove liquidity flow + * Tests the complete user flow from position loading to liquidity removal + */ + +import React from 'react'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { vi, describe, it, expect, beforeEach } from 'vitest'; +import type { PositionSnapshot } from '@swyft/ui'; + +// ─── Module mocks ──────────────────────────────────────────────────────────── + +const mockPush = vi.fn(); + +vi.mock('next/navigation', () => ({ + useRouter: () => ({ push: mockPush }), +})); + +vi.mock('next/link', () => ({ + default: ({ + href, + children, + className, + }: { + href: string; + children: React.ReactNode; + className?: string; + }) => ( + + {children} + + ), +})); + +const mockEstimateRemoveAmountsAsync = vi.fn(); +const mockUseRemoveLiquidity = vi.fn(); + +vi.mock('@swyft/sdk', () => ({ + estimateRemoveAmountsAsync: (...args: unknown[]) => mockEstimateRemoveAmountsAsync(...args), +})); + +vi.mock('@/hooks/useRemoveLiquidity', () => ({ + useRemoveLiquidity: (...args: unknown[]) => mockUseRemoveLiquidity(...args), +})); + +vi.mock('@/hooks/usePositions', () => ({ + usePosition: () => ({ + position: mockPosition, + loading: false, + error: null, + }), +})); + +// ─── Test data ───────────────────────────────────────────────────────────────── + +const mockPosition: PositionSnapshot = { + id: 'pos-1', + ownerWallet: 'GTEST123', + poolId: 'pool-xlm-usdc', + token0: 'XLM', + token1: 'USDC', + lowerTick: -1000, + upperTick: 1000, + liquidity: '1000000', + currentValueUsd: 500, + uncollectedFeesToken0: '1.5', + uncollectedFeesToken1: '0.5', + createdAt: 1_700_000_000, + closedAt: null, + status: 'active', + poolCurrentPrice: 0.1085, +}; + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +async function importPage() { + const mod = await import('../app/positions/[id]/remove/page'); + return mod.default; +} + +// ─── Tests ─────────────────────────────────────────────────────────────────── + +describe('RemoveLiquidityFlow', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockEstimateRemoveAmountsAsync.mockResolvedValue({ + amount0: '10.5', + amount1: '5.2', + }); + mockUseRemoveLiquidity.mockReturnValue({ + status: 'idle', + txError: null, + txHash: null, + removeLiquidity: vi.fn(), + collectFees: vi.fn(), + reset: vi.fn(), + }); + Object.defineProperty(window, 'localStorage', { + value: { getItem: vi.fn(() => 'mock-token'), setItem: vi.fn(), removeItem: vi.fn() }, + writable: true, + }); + }); + + it('loads the remove liquidity page with position data', async () => { + const RemoveLiquidityPage = await importPage(); + render(); + + await waitFor(() => { + expect(screen.getByText('Remove liquidity')).toBeInTheDocument(); + }); + }); + + it('displays position details including price range and current price', async () => { + const RemoveLiquidityPage = await importPage(); + render(); + + await waitFor(() => { + expect(screen.getByText('Price range')).toBeInTheDocument(); + expect(screen.getByText('Current price')).toBeInTheDocument(); + expect(screen.getByText('Position value')).toBeInTheDocument(); + }); + }); + + it('shows uncollected fees with collect button', async () => { + const RemoveLiquidityPage = await importPage(); + render(); + + await waitFor(() => { + expect(screen.getByText('Uncollected fees')).toBeInTheDocument(); + expect(screen.getByText('Collect fees only')).toBeInTheDocument(); + }); + }); + + it('allows selecting preset percentages (25, 50, 75, 100)', async () => { + const RemoveLiquidityPage = await importPage(); + render(); + + await waitFor(() => { + expect(screen.getByText('25%')).toBeInTheDocument(); + expect(screen.getByText('50%')).toBeInTheDocument(); + expect(screen.getByText('75%')).toBeInTheDocument(); + expect(screen.getByText('100%')).toBeInTheDocument(); + }); + }); + + it('calls removeLiquidity when remove button is clicked', async () => { + const mockRemoveLiquidity = vi.fn(); + mockUseRemoveLiquidity.mockReturnValue({ + status: 'idle', + txError: null, + txHash: null, + removeLiquidity: mockRemoveLiquidity, + collectFees: vi.fn(), + reset: vi.fn(), + }); + + const RemoveLiquidityPage = await importPage(); + render(); + + await waitFor(() => { + expect(screen.getByText('Remove 100% liquidity')).toBeInTheDocument(); + }); + + const removeButton = screen.getByText('Remove 100% liquidity'); + fireEvent.click(removeButton); + + expect(mockRemoveLiquidity).toHaveBeenCalledWith(100); + }); + + it('shows success state after successful removal', async () => { + mockUseRemoveLiquidity.mockReturnValue({ + status: 'success', + txError: null, + txHash: 'abc123', + removeLiquidity: vi.fn(), + collectFees: vi.fn(), + reset: vi.fn(), + }); + + const RemoveLiquidityPage = await importPage(); + render(); + + await waitFor(() => { + expect(screen.getByText(/Position closed successfully/)).toBeInTheDocument(); + }); + }); + + it('shows error state on transaction failure', async () => { + mockUseRemoveLiquidity.mockReturnValue({ + status: 'error', + txError: 'rejected', + txHash: null, + removeLiquidity: vi.fn(), + collectFees: vi.fn(), + reset: vi.fn(), + }); + + const RemoveLiquidityPage = await importPage(); + render(); + + await waitFor(() => { + expect(screen.getByText('Transaction rejected in wallet.')).toBeInTheDocument(); + }); + }); + + it('disables remove button while signing or submitting', async () => { + mockUseRemoveLiquidity.mockReturnValue({ + status: 'signing', + txError: null, + txHash: null, + removeLiquidity: vi.fn(), + collectFees: vi.fn(), + reset: vi.fn(), + }); + + const RemoveLiquidityPage = await importPage(); + render(); + + await waitFor(() => { + const removeButton = screen.getByText('Waiting for signature…'); + expect(removeButton).toBeDisabled(); + }); + }); + + it('navigates to portfolio after 100% removal success', async () => { + mockUseRemoveLiquidity.mockReturnValue({ + status: 'success', + txError: null, + txHash: 'abc123', + removeLiquidity: vi.fn(), + collectFees: vi.fn(), + reset: vi.fn(), + }); + + const RemoveLiquidityPage = await importPage(); + render(); + + await waitFor(() => { + expect(screen.getByText(/Redirecting to portfolio/)).toBeInTheDocument(); + }, { timeout: 3000 }); + }); +}); diff --git a/packages/sdk/src/__tests__/swap.spec.ts b/packages/sdk/src/__tests__/swap.spec.ts index 9476051..f682b94 100644 --- a/packages/sdk/src/__tests__/swap.spec.ts +++ b/packages/sdk/src/__tests__/swap.spec.ts @@ -83,6 +83,62 @@ describe('buildSwapTx', () => { expect(tx1.xdr).not.toBe(tx2.xdr); }); + it('produces consistent XDR hash for identical parameters', () => { + const tx1 = buildSwapTx(validParams); + const tx2 = buildSwapTx(validParams); + const hash1 = Buffer.from(tx1.xdr, 'base64').toString('hex'); + const hash2 = Buffer.from(tx2.xdr, 'base64').toString('hex'); + expect(hash1).toBe(hash2); + }); + + it('produces different XDR hash for different poolId', () => { + const tx1 = buildSwapTx(validParams); + const tx2 = buildSwapTx({ ...validParams, poolId: toStellarAddress('CPOOL999999999999999999999999999999999999999999999999999') }); + const hash1 = Buffer.from(tx1.xdr, 'base64').toString('hex'); + const hash2 = Buffer.from(tx2.xdr, 'base64').toString('hex'); + expect(hash1).not.toBe(hash2); + }); + + it('produces different XDR hash for different tokenInId', () => { + const tx1 = buildSwapTx(validParams); + const tx2 = buildSwapTx({ ...validParams, tokenInId: toStellarAddress('CTOKENIN999999999999999999999999999999999999999999999999') }); + const hash1 = Buffer.from(tx1.xdr, 'base64').toString('hex'); + const hash2 = Buffer.from(tx2.xdr, 'base64').toString('hex'); + expect(hash1).not.toBe(hash2); + }); + + it('produces different XDR hash for different tokenOutId', () => { + const tx1 = buildSwapTx(validParams); + const tx2 = buildSwapTx({ ...validParams, tokenOutId: toStellarAddress('CTOKENOUT9999999999999999999999999999999999999999999999') }); + const hash1 = Buffer.from(tx1.xdr, 'base64').toString('hex'); + const hash2 = Buffer.from(tx2.xdr, 'base64').toString('hex'); + expect(hash1).not.toBe(hash2); + }); + + it('produces different XDR hash for different minimumReceived', () => { + const tx1 = buildSwapTx(validParams); + const tx2 = buildSwapTx({ ...validParams, minimumReceived: toRawAmount('980000') }); + const hash1 = Buffer.from(tx1.xdr, 'base64').toString('hex'); + const hash2 = Buffer.from(tx2.xdr, 'base64').toString('hex'); + expect(hash1).not.toBe(hash2); + }); + + it('produces different XDR hash for different ownerAddress', () => { + const tx1 = buildSwapTx(validParams); + const tx2 = buildSwapTx({ ...validParams, ownerAddress: toStellarAddress('GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA2') }); + const hash1 = Buffer.from(tx1.xdr, 'base64').toString('hex'); + const hash2 = Buffer.from(tx2.xdr, 'base64').toString('hex'); + expect(hash1).not.toBe(hash2); + }); + + it('produces different XDR hash for different slippageBps', () => { + const tx1 = buildSwapTx({ ...validParams, slippageBps: 50 }); + const tx2 = buildSwapTx({ ...validParams, slippageBps: 100 }); + const hash1 = Buffer.from(tx1.xdr, 'base64').toString('hex'); + const hash2 = Buffer.from(tx2.xdr, 'base64').toString('hex'); + expect(hash1).not.toBe(hash2); + }); + it('throws when poolId is empty', () => { expect(() => buildSwapTx({ @@ -121,6 +177,38 @@ describe('buildSwapTx', () => { }) ).toThrow(); }); + + it('accepts valid slippageBps parameter', () => { + const tx = buildSwapTx({ ...validParams, slippageBps: 50 }); + expect(tx.type).toBe('swap'); + }); + + it('throws SwapValidationError for negative slippageBps', () => { + expect(() => buildSwapTx({ ...validParams, slippageBps: -10 })).toThrow( + SwapValidationError + ); + }); + + it('throws SwapValidationError for slippageBps > 10000', () => { + expect(() => buildSwapTx({ ...validParams, slippageBps: 10001 })).toThrow( + SwapValidationError + ); + }); + + it('accepts slippageBps of 0', () => { + const tx = buildSwapTx({ ...validParams, slippageBps: 0 }); + expect(tx.type).toBe('swap'); + }); + + it('accepts slippageBps of 10000 (100%)', () => { + const tx = buildSwapTx({ ...validParams, slippageBps: 10000 }); + expect(tx.type).toBe('swap'); + }); + + it('works without slippageBps parameter (defaults to undefined)', () => { + const tx = buildSwapTx(validParams); + expect(tx.type).toBe('swap'); + }); }); describe('cast helpers', () => { diff --git a/packages/sdk/src/swap.ts b/packages/sdk/src/swap.ts index a662e2b..cfc2498 100644 --- a/packages/sdk/src/swap.ts +++ b/packages/sdk/src/swap.ts @@ -68,6 +68,8 @@ export interface SwapTxParams { readonly minimumReceived: RawAmount; /** Stellar account address of the transaction submitter / recipient. */ readonly ownerAddress: StellarAddress; + /** Slippage tolerance in basis points (e.g., 50 = 0.5%). Defaults to 50. */ + readonly slippageBps?: number; } /** @@ -150,6 +152,14 @@ export function buildSwapTx(params: SwapTxParams): SwapUnsignedTx { `Invalid minimumReceived: must be a positive number. Got: ${params.minimumReceived}` ); } + if (params.slippageBps !== undefined) { + const slippage = params.slippageBps; + if (typeof slippage !== 'number' || slippage < 0 || slippage > 10000) { + throw new SwapValidationError( + `Invalid slippageBps: must be between 0 and 10000. Got: ${slippage}` + ); + } + } try { const contract = new Contract(params.poolId);