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);