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
242 changes: 242 additions & 0 deletions apps/web/__tests__/RemoveLiquidityFlow.test.tsx
Original file line number Diff line number Diff line change
@@ -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;
}) => (
<a href={href} className={className}>
{children}
</a>
),
}));

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(<RemoveLiquidityPage params={Promise.resolve({ id: 'pos-1' })} />);

await waitFor(() => {
expect(screen.getByText('Remove liquidity')).toBeInTheDocument();
});
});

it('displays position details including price range and current price', async () => {
const RemoveLiquidityPage = await importPage();
render(<RemoveLiquidityPage params={Promise.resolve({ id: 'pos-1' })} />);

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(<RemoveLiquidityPage params={Promise.resolve({ id: 'pos-1' })} />);

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(<RemoveLiquidityPage params={Promise.resolve({ id: 'pos-1' })} />);

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(<RemoveLiquidityPage params={Promise.resolve({ id: 'pos-1' })} />);

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(<RemoveLiquidityPage params={Promise.resolve({ id: 'pos-1' })} />);

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(<RemoveLiquidityPage params={Promise.resolve({ id: 'pos-1' })} />);

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(<RemoveLiquidityPage params={Promise.resolve({ id: 'pos-1' })} />);

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(<RemoveLiquidityPage params={Promise.resolve({ id: 'pos-1' })} />);

await waitFor(() => {
expect(screen.getByText(/Redirecting to portfolio/)).toBeInTheDocument();
}, { timeout: 3000 });
});
});
88 changes: 88 additions & 0 deletions packages/sdk/src/__tests__/swap.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -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', () => {
Expand Down
10 changes: 10 additions & 0 deletions packages/sdk/src/swap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

/**
Expand Down Expand Up @@ -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);
Expand Down
Loading