diff --git a/e2e/compose-test-helpers.ts b/e2e/compose-test-helpers.ts index 6ed4d70e..14c3f668 100644 --- a/e2e/compose-test-helpers.ts +++ b/e2e/compose-test-helpers.ts @@ -17,6 +17,18 @@ import { TEST_ADDRESSES } from './test-data'; export async function enableValidationBypass(page: Page): Promise { const context = page.context(); + const mockPoolPosition = { + asset_a: 'XCP', + asset_b: 'PEPECASH', + lp_asset: 'A95428956661682177', + reserve_a: 500000000000, + reserve_b: 250000000000, + reserve_a_normalized: '5000', + reserve_b_normalized: '2500', + quantity: 100000000, + quantity_normalized: '1', + }; + // Mock compose response - provides data for review page display const mockComposeResponse = { result: { @@ -57,6 +69,78 @@ export async function enableValidationBypass(page: Page): Promise { const method = route.request().method(); console.log(`[E2E Debug] ${method} ${url}`); + if (url.match(/\/v2\/?(\?.*)?$/)) { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + result: { + server_ready: true, + network: 'mainnet', + version: '11.1.0', + backend_height: 952500, + counterparty_height: 952500, + }, + }), + }); + return; + } + + if (url.includes('/estimatexcpfees')) { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ result: 10000 }), + }); + return; + } + + if (url.includes('/v2/addresses/') && url.includes('/pools')) { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + result: [mockPoolPosition], + result_count: 1, + }), + }); + return; + } + + if (url.includes('/v2/pools/') && url.includes('/quote/deposit')) { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + result: { + asset_a: 'XCP', + asset_b: 'PEPECASH', + pool_exists: true, + first_deposit: false, + quantity_a_required: 100000000, + quantity_b_required: 50000000, + quantity_minted_estimate: 2000000, + }, + }), + }); + return; + } + + if (url.includes('/v2/pools/') && url.includes('/quote/withdraw')) { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + result: { + pool_exists: true, + quantity_a_estimate: 50000000, + quantity_b_estimate: 25000000, + }, + }), + }); + return; + } + // Handle compose endpoints - return mock transaction data if (url.includes('/compose/')) { // Parse compose type and params from URL @@ -116,6 +200,36 @@ export async function enableValidationBypass(page: Page): Promise { console.log(`[E2E Mock] Order: ${giveQuantity} ${giveAsset} for ${getQuantity} ${getAsset}`); } + if (composeType === 'pooldeposit') { + responseParams = { + source: mockComposeResponse.result.params.source, + asset_a: urlParams.get('asset_a') || 'XCP', + asset_b: urlParams.get('asset_b') || 'PEPECASH', + quantity_a: parseInt(urlParams.get('quantity_a') || '100000000', 10), + quantity_b: parseInt(urlParams.get('quantity_b') || '50000000', 10), + min_lp_quantity: parseInt(urlParams.get('min_lp_quantity') || '1900000', 10), + lp_asset: urlParams.get('lp_asset') || mockPoolPosition.lp_asset, + quantity_a_normalized: '1', + quantity_b_normalized: '0.5', + min_lp_quantity_normalized: '0.019', + }; + } + + if (composeType === 'poolwithdraw') { + responseParams = { + source: mockComposeResponse.result.params.source, + asset_a: urlParams.get('asset_a') || mockPoolPosition.asset_a, + asset_b: urlParams.get('asset_b') || mockPoolPosition.asset_b, + lp_asset: urlParams.get('lp_asset') || mockPoolPosition.lp_asset, + quantity: parseInt(urlParams.get('quantity') || '10000000', 10), + min_quantity_a: parseInt(urlParams.get('min_quantity_a') || '47500000', 10), + min_quantity_b: parseInt(urlParams.get('min_quantity_b') || '23750000', 10), + quantity_normalized: '0.1', + min_quantity_a_normalized: '0.475', + min_quantity_b_normalized: '0.2375', + }; + } + // Build dynamic response with params from request const dynamicResponse = { ...mockComposeResponse, @@ -144,6 +258,7 @@ export async function enableValidationBypass(page: Page): Promise { XCP: { divisible: true, supply: 2648755823622677, locked: true }, BTC: { divisible: true, supply: 0, locked: true }, PEPECASH: { divisible: true, supply: 1000000000000000, locked: true }, + A95428956661682177: { divisible: true, supply: 10000000000, locked: true }, // TESTUNLOCKED is an unlocked asset for testing issue-supply and lock-supply TESTUNLOCKED: { divisible: true, supply: 100000000000, locked: false }, }; diff --git a/e2e/pages/assets/[asset]/balance.spec.ts b/e2e/pages/assets/[asset]/balance.spec.ts index f97ad0f0..f74aac45 100644 --- a/e2e/pages/assets/[asset]/balance.spec.ts +++ b/e2e/pages/assets/[asset]/balance.spec.ts @@ -11,6 +11,7 @@ */ import { walletTest, expect } from '@e2e/fixtures'; +import { enableValidationBypass } from '../../../compose-test-helpers'; walletTest.describe('View Balance Page (/assets/:asset/balance)', () => { // Helper to navigate to balance page and wait for content to load @@ -151,6 +152,17 @@ walletTest.describe('View Balance Page (/assets/:asset/balance)', () => { await expect(page).toHaveURL(/compose\/dispenser\/XCP/, { timeout: 5000 }); }); + walletTest('LP asset shows Manage Pool action and navigates to pool page', async ({ page }) => { + await enableValidationBypass(page); + await navigateToBalance(page, 'A95428956661682177'); + + const managePoolAction = page.locator('button:has-text("Manage Pool"), div[role="button"]:has-text("Manage Pool")').first(); + await expect(managePoolAction).toBeVisible({ timeout: 10000 }); + await managePoolAction.click(); + + await expect(page).toHaveURL(/\/pools\/A95428956661682177/, { timeout: 5000 }); + }); + walletTest('Attach action navigates to compose/utxo/attach for XCP', async ({ page }) => { await navigateToBalance(page, 'XCP'); diff --git a/e2e/pages/compose/pool/deposit.spec.ts b/e2e/pages/compose/pool/deposit.spec.ts new file mode 100644 index 00000000..d249408a --- /dev/null +++ b/e2e/pages/compose/pool/deposit.spec.ts @@ -0,0 +1,67 @@ +/** + * Compose Pool Deposit Page Tests (/compose/pool/deposit) + */ + +import { walletTest, expect } from '@e2e/fixtures'; +import { compose } from '@e2e/selectors'; +import { + clickBack, + enableDryRun, + enableValidationBypass, + waitForReview, +} from '../../../compose-test-helpers'; + +walletTest.describe('Pool Deposit Flow - Full Compose Flow', () => { + walletTest.beforeEach(async ({ page }) => { + await enableValidationBypass(page); + await enableDryRun(page); + }); + + walletTest('prefilled pair can quote and reach review', async ({ page }) => { + await page.goto(page.url().replace(/\/index.*/, '/compose/pool/deposit/XCP/PEPECASH')); + await page.waitForLoadState('networkidle'); + + await expect(compose.pool.assetAAmountInput(page)).toBeVisible({ timeout: 10000 }); + await compose.pool.assetAAmountInput(page).fill('1'); + await compose.pool.assetBAmountInput(page).fill('0.5'); + + await expect(page.getByText(/Quoted partner amount/i)).toBeVisible({ timeout: 10000 }); + await expect(page.getByText(/Minimum LP tokens/i)).toBeVisible({ timeout: 10000 }); + + const submitBtn = compose.pool.depositButton(page); + await expect(submitBtn).toBeEnabled({ timeout: 10000 }); + await submitBtn.click(); + + await waitForReview(page); + await expect(page.getByText('PEPECASH / XCP')).toBeVisible(); + await expect(page.getByText(/Minimum LP/i)).toBeVisible(); + }); + + walletTest('form data is preserved after returning from review', async ({ page }) => { + await page.goto(page.url().replace(/\/index.*/, '/compose/pool/deposit/XCP/PEPECASH')); + await page.waitForLoadState('networkidle'); + + await compose.pool.assetAAmountInput(page).fill('1'); + await compose.pool.assetBAmountInput(page).fill('0.5'); + + await expect(compose.pool.depositButton(page)).toBeEnabled({ timeout: 10000 }); + await compose.pool.depositButton(page).click(); + await waitForReview(page); + + await clickBack(page); + + await expect(compose.pool.assetAAmountInput(page)).toHaveValue('1'); + await expect(compose.pool.assetBAmountInput(page)).toHaveValue('0.5'); + }); + + walletTest('Use quote fills the partner asset amount', async ({ page }) => { + await page.goto(page.url().replace(/\/index.*/, '/compose/pool/deposit/XCP/PEPECASH')); + await page.waitForLoadState('networkidle'); + + await compose.pool.assetAAmountInput(page).fill('1'); + await expect(compose.pool.useQuoteButton(page)).toBeVisible({ timeout: 10000 }); + await compose.pool.useQuoteButton(page).click(); + + await expect(compose.pool.assetBAmountInput(page)).toHaveValue('0.5'); + }); +}); diff --git a/e2e/pages/compose/pool/withdraw.spec.ts b/e2e/pages/compose/pool/withdraw.spec.ts new file mode 100644 index 00000000..fe001f93 --- /dev/null +++ b/e2e/pages/compose/pool/withdraw.spec.ts @@ -0,0 +1,53 @@ +/** + * Compose Pool Withdraw Page Tests (/compose/pool/withdraw/:lpAsset) + */ + +import { walletTest, expect } from '@e2e/fixtures'; +import { compose } from '@e2e/selectors'; +import { + clickBack, + enableDryRun, + enableValidationBypass, + waitForReview, +} from '../../../compose-test-helpers'; + +walletTest.describe('Pool Withdraw Flow - Full Compose Flow', () => { + walletTest.beforeEach(async ({ page }) => { + await enableValidationBypass(page); + await enableDryRun(page); + }); + + walletTest('LP position can quote and reach review', async ({ page }) => { + await page.goto(page.url().replace(/\/index.*/, '/compose/pool/withdraw/A95428956661682177')); + await page.waitForLoadState('networkidle'); + + await expect(page.getByText('PEPECASH / XCP')).toBeVisible({ timeout: 10000 }); + await expect(compose.pool.lpWithdrawInput(page)).toBeVisible({ timeout: 10000 }); + await compose.pool.lpWithdrawInput(page).fill('0.1'); + + await expect(page.getByText(/Estimated receive/i)).toBeVisible({ timeout: 10000 }); + await expect(page.getByText(/Minimum received/i)).toBeVisible({ timeout: 10000 }); + + const submitBtn = compose.pool.withdrawButton(page); + await expect(submitBtn).toBeEnabled({ timeout: 10000 }); + await submitBtn.click(); + + await waitForReview(page); + await expect(page.getByText('PEPECASH / XCP')).toBeVisible(); + await expect(page.getByText(/Minimum Receive/i)).toBeVisible(); + }); + + walletTest('form data is preserved after returning from review', async ({ page }) => { + await page.goto(page.url().replace(/\/index.*/, '/compose/pool/withdraw/A95428956661682177')); + await page.waitForLoadState('networkidle'); + + await compose.pool.lpWithdrawInput(page).fill('0.1'); + await expect(compose.pool.withdrawButton(page)).toBeEnabled({ timeout: 10000 }); + await compose.pool.withdrawButton(page).click(); + await waitForReview(page); + + await clickBack(page); + + await expect(compose.pool.lpWithdrawInput(page)).toHaveValue('0.1'); + }); +}); diff --git a/e2e/pages/pools/[lpAsset].spec.ts b/e2e/pages/pools/[lpAsset].spec.ts new file mode 100644 index 00000000..f24c6dab --- /dev/null +++ b/e2e/pages/pools/[lpAsset].spec.ts @@ -0,0 +1,39 @@ +/** + * Pool Position Page Tests (/pools/:lpAsset) + */ + +import { walletTest, expect } from '@e2e/fixtures'; +import { enableValidationBypass } from '../../compose-test-helpers'; + +walletTest.describe('Pool Position Page (/pools/:lpAsset)', () => { + walletTest.beforeEach(async ({ page }) => { + await enableValidationBypass(page); + }); + + walletTest('shows pool details and action buttons', async ({ page }) => { + await page.goto(page.url().replace(/\/index.*/, '/pools/A95428956661682177')); + await page.waitForLoadState('networkidle'); + + await expect(page.getByRole('heading', { name: 'PEPECASH / XCP' })).toBeVisible({ timeout: 10000 }); + await expect(page.getByText('Reserve PEPECASH')).toBeVisible(); + await expect(page.getByText('Reserve XCP')).toBeVisible(); + await expect(page.getByRole('button', { name: 'Deposit' })).toBeVisible(); + await expect(page.getByRole('button', { name: 'Withdraw' })).toBeVisible(); + }); + + walletTest('Deposit opens the prefilled pool deposit flow', async ({ page }) => { + await page.goto(page.url().replace(/\/index.*/, '/pools/A95428956661682177')); + await page.waitForLoadState('networkidle'); + + await page.getByRole('button', { name: 'Deposit' }).click(); + await expect(page).toHaveURL(/compose\/pool\/deposit\/XCP\/PEPECASH/, { timeout: 5000 }); + }); + + walletTest('Withdraw opens the pool withdraw flow', async ({ page }) => { + await page.goto(page.url().replace(/\/index.*/, '/pools/A95428956661682177')); + await page.waitForLoadState('networkidle'); + + await page.getByRole('button', { name: 'Withdraw' }).click(); + await expect(page).toHaveURL(/compose\/pool\/withdraw\/A95428956661682177/, { timeout: 5000 }); + }); +}); diff --git a/e2e/pages/pools/index.spec.ts b/e2e/pages/pools/index.spec.ts new file mode 100644 index 00000000..9543ac57 --- /dev/null +++ b/e2e/pages/pools/index.spec.ts @@ -0,0 +1,44 @@ +/** + * Manage Pools Page Tests (/pools) + * + * Tests the pool entry point and position list using mocked Counterparty pool data. + */ + +import { walletTest, expect, navigateTo } from '@e2e/fixtures'; +import { actions } from '@e2e/selectors'; +import { enableValidationBypass } from '../../compose-test-helpers'; + +walletTest.describe('Manage Pools Page (/pools)', () => { + walletTest.beforeEach(async ({ page }) => { + await enableValidationBypass(page); + }); + + walletTest('can navigate to Manage Pools from actions', async ({ page }) => { + await navigateTo(page, 'actions'); + + await expect(actions.managePoolsOption(page)).toBeVisible({ timeout: 5000 }); + await actions.managePoolsOption(page).click(); + + await expect(page).toHaveURL(/\/pools/, { timeout: 5000 }); + await expect(page.getByRole('button', { name: /Enter Pool/i }).first()).toBeVisible(); + }); + + walletTest('lists LP positions and opens the position page', async ({ page }) => { + await page.goto(page.url().replace(/\/index.*/, '/pools')); + await page.waitForLoadState('networkidle'); + + await expect(page.getByText('PEPECASH / XCP')).toBeVisible({ timeout: 10000 }); + await expect(page.getByText('A95428956661682177')).toBeVisible(); + + await page.getByRole('button').filter({ hasText: 'PEPECASH / XCP' }).first().click(); + await expect(page).toHaveURL(/\/pools\/A95428956661682177/, { timeout: 5000 }); + }); + + walletTest('Enter Pool opens the pool deposit flow', async ({ page }) => { + await page.goto(page.url().replace(/\/index.*/, '/pools')); + await page.waitForLoadState('networkidle'); + + await page.getByRole('button', { name: /Enter Pool/i }).first().click(); + await expect(page).toHaveURL(/compose\/pool\/deposit/, { timeout: 5000 }); + }); +}); diff --git a/e2e/selectors.ts b/e2e/selectors.ts index 498d3d92..e11b51fa 100644 --- a/e2e/selectors.ts +++ b/e2e/selectors.ts @@ -168,6 +168,7 @@ export const actions = { cancelOrderOption: (page: Page) => page.getByText('Cancel Order'), closeDispenserOption: (page: Page) => page.getByText('Close Dispenser', { exact: true }), recoverBitcoinOption: (page: Page) => page.getByText('Recover Bitcoin'), + managePoolsOption: (page: Page) => page.getByText('Manage Pools'), toolsSection: (page: Page) => page.getByText('Tools'), assetsSection: (page: Page) => page.locator('text=Assets').first(), addressSection: (page: Page) => page.locator('text=Address').first(), @@ -357,6 +358,17 @@ export const compose = { quantityInput: (page: Page) => page.locator('input[name*="quantity"], input[type="number"]').first(), }, + // AMM pool operations (/compose/pool/*) + pool: { + assetAAmountInput: (page: Page) => page.locator('input[name="quantity_a_display"]'), + assetBAmountInput: (page: Page) => page.locator('input[name="quantity_b_display"]'), + lpWithdrawInput: (page: Page) => page.locator('input[name="quantity_display"]'), + depositButton: (page: Page) => page.getByRole('button', { name: /Review Deposit/i }), + withdrawButton: (page: Page) => page.getByRole('button', { name: /Review Withdrawal/i }), + useQuoteButton: (page: Page) => page.getByRole('button', { name: /Use quote/i }), + slippageInput: (page: Page) => page.locator('input[name="slippage"]'), + }, + // UTXO operations (/compose/utxo/*) utxo: { attachQuantityInput: (page: Page) => page.locator('input[name*="quantity"], input[name*="amount"]').first(), diff --git a/src/components/composer/composer.tsx b/src/components/composer/composer.tsx index 136c58af..d10dfdbd 100644 --- a/src/components/composer/composer.tsx +++ b/src/components/composer/composer.tsx @@ -16,7 +16,7 @@ export type ComposeType = | 'cancel' | 'dispenser-close-by-hash' | 'broadcast' | 'attach' | 'detach' | 'move-utxo' | 'move' | 'destroy' | 'issue-supply' | 'lock-supply' | 'reset-supply' | 'transfer' | 'update-description' - | 'lock-description' | 'issuance'; + | 'lock-description' | 'issuance' | 'pooldeposit' | 'poolwithdraw'; /** * Props for the Composer component. @@ -238,4 +238,4 @@ export function Composer({ /> ); -} \ No newline at end of file +} diff --git a/src/components/ui/headers/pool-header.tsx b/src/components/ui/headers/pool-header.tsx new file mode 100644 index 00000000..5f4893e6 --- /dev/null +++ b/src/components/ui/headers/pool-header.tsx @@ -0,0 +1,32 @@ +import { type ReactElement } from "react"; +import { AssetIcon } from "@/components/domain/asset/asset-icon"; +import { formatAmount } from "@/utils/format"; +import { getCanonicalPoolPair } from "@/utils/blockchain/counterparty/pool"; +import type { PoolPosition } from "@/utils/blockchain/counterparty/api"; + +interface PoolHeaderProps { + pool: PoolPosition; + className?: string; +} + +export function PoolHeader({ pool, className = "" }: PoolHeaderProps): ReactElement { + const pair = getCanonicalPoolPair(pool.asset_a, pool.asset_b); + const balance = pool.quantity_normalized + ? formatAmount({ + value: Number(pool.quantity_normalized), + minimumFractionDigits: 8, + maximumFractionDigits: 8, + useGrouping: true, + }) + : "0"; + + return ( +
+ +
+

{pair}

+

Balance: {balance}

+
+
+ ); +} diff --git a/src/entrypoints/popup/app.tsx b/src/entrypoints/popup/app.tsx index 62e67e1b..455bc8a5 100644 --- a/src/entrypoints/popup/app.tsx +++ b/src/entrypoints/popup/app.tsx @@ -65,6 +65,8 @@ import AssetsPage from '@/pages/assets'; import AssetPage from '@/pages/assets/[asset]'; import AssetBalancePage from '@/pages/assets/[asset]/balance'; import UtxoPage from '@/pages/assets/utxos/[txHash]'; +import ManagePoolsPage from '@/pages/pools'; +import PoolPositionPage from '@/pages/pools/[lpAsset]'; import TransactionPage from '@/pages/transactions/[txHash]'; // Market - Swaps @@ -100,6 +102,8 @@ import ComposeBroadcastAddressOptionsPage from '@/pages/compose/broadcast/addres import ComposeUtxoAttachPage from '@/pages/compose/utxo/attach'; import ComposeUtxoDetachPage from '@/pages/compose/utxo/detach'; import ComposeUtxoMovePage from '@/pages/compose/utxo/move'; +import ComposePoolDepositPage from '@/pages/compose/pool/deposit'; +import ComposePoolWithdrawPage from '@/pages/compose/pool/withdraw'; import NotFoundPage from '@/pages/not-found'; @@ -205,6 +209,8 @@ export default function App() { } /> } /> } /> + } /> + } /> } /> @@ -231,6 +237,9 @@ export default function App() { } /> } /> } /> + } /> + } /> + } /> } /> } /> } /> diff --git a/src/hooks/__tests__/useLpAssetPool.test.ts b/src/hooks/__tests__/useLpAssetPool.test.ts new file mode 100644 index 00000000..00149733 --- /dev/null +++ b/src/hooks/__tests__/useLpAssetPool.test.ts @@ -0,0 +1,115 @@ +import { renderHook, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { useLpAssetPool } from '../useLpAssetPool'; +import { fetchAddressPoolByLpAsset } from '@/utils/blockchain/counterparty/api'; + +const mocks = vi.hoisted(() => ({ + activeAddress: { address: 'bc1qtest123' }, +})); + +vi.mock('@/contexts/wallet-context', () => ({ + useWallet: () => ({ + activeAddress: mocks.activeAddress, + }), +})); + +vi.mock('@/utils/blockchain/counterparty/api', () => ({ + fetchAddressPoolByLpAsset: vi.fn(), +})); + +const mockedFetchAddressPoolByLpAsset = vi.mocked(fetchAddressPoolByLpAsset); + +const poolA = { + asset_a: 'XCP', + asset_b: 'POOLTEST', + reserve_a: 100000000, + reserve_b: 200000000, + lp_asset: 'A11111111111111111', + quantity: 100000000, + quantity_normalized: '1', +}; + +const poolB = { + asset_a: 'XCP', + asset_b: 'OTHER', + reserve_a: 300000000, + reserve_b: 400000000, + lp_asset: 'A22222222222222222', + quantity: 200000000, + quantity_normalized: '2', +}; + +function deferred() { + let resolve!: (value: T) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +describe('useLpAssetPool', () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.activeAddress = { address: 'bc1qtest123' }; + }); + + it('fetches a pool position for the active address and LP asset', async () => { + mockedFetchAddressPoolByLpAsset.mockResolvedValue(poolA); + + const { result } = renderHook(() => useLpAssetPool('A11111111111111111')); + + expect(result.current.isLoading).toBe(true); + expect(result.current.data).toBeNull(); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + expect(result.current.data).toEqual(poolA); + expect(mockedFetchAddressPoolByLpAsset).toHaveBeenCalledWith( + 'bc1qtest123', + 'A11111111111111111', + { limit: 100 }, + ); + }); + + it('clears stale pool data while a different LP asset is loading', async () => { + mockedFetchAddressPoolByLpAsset.mockResolvedValueOnce(poolA); + + const { result, rerender } = renderHook( + ({ asset }) => useLpAssetPool(asset), + { initialProps: { asset: 'A11111111111111111' } }, + ); + + await waitFor(() => expect(result.current.data).toEqual(poolA)); + + const nextLookup = deferred(); + mockedFetchAddressPoolByLpAsset.mockReturnValueOnce(nextLookup.promise); + + rerender({ asset: 'A22222222222222222' }); + + await waitFor(() => { + expect(result.current.isLoading).toBe(true); + expect(result.current.data).toBeNull(); + }); + + nextLookup.resolve(poolB); + + await waitFor(() => expect(result.current.data).toEqual(poolB)); + }); + + it('does not fetch for BTC or a missing asset', () => { + const { result, rerender } = renderHook( + ({ asset }) => useLpAssetPool(asset), + { initialProps: { asset: 'BTC' as string | undefined } }, + ); + + expect(result.current).toEqual({ data: null, isLoading: false, error: null }); + expect(mockedFetchAddressPoolByLpAsset).not.toHaveBeenCalled(); + + rerender({ asset: undefined }); + + expect(result.current).toEqual({ data: null, isLoading: false, error: null }); + expect(mockedFetchAddressPoolByLpAsset).not.toHaveBeenCalled(); + }); +}); diff --git a/src/hooks/useInView.ts b/src/hooks/useInView.ts index e2fd591c..7b21d91b 100644 --- a/src/hooks/useInView.ts +++ b/src/hooks/useInView.ts @@ -17,12 +17,6 @@ export function useInView(options?: IntersectionObserverInit) { // Create and start observing immediately const observer = new IntersectionObserver( ([entry]) => { - console.log('[useInView] Intersection:', { - isIntersecting: entry.isIntersecting, - intersectionRatio: entry.intersectionRatio, - boundingClientRect: entry.boundingClientRect, - rootBounds: entry.rootBounds - }); setInView(entry.isIntersecting); }, { @@ -46,4 +40,4 @@ export function useInView(options?: IntersectionObserverInit) { }, []); return { ref, inView }; -} \ No newline at end of file +} diff --git a/src/hooks/useLpAssetPool.ts b/src/hooks/useLpAssetPool.ts new file mode 100644 index 00000000..0b10663e --- /dev/null +++ b/src/hooks/useLpAssetPool.ts @@ -0,0 +1,45 @@ +import { useEffect, useState } from "react"; +import { useWallet } from "@/contexts/wallet-context"; +import { fetchAddressPoolByLpAsset, type PoolPosition } from "@/utils/blockchain/counterparty/api"; + +interface LpAssetPoolState { + data: PoolPosition | null; + isLoading: boolean; + error: Error | null; +} + +export function useLpAssetPool(asset: string | undefined): LpAssetPoolState { + const { activeAddress } = useWallet(); + const [state, setState] = useState({ data: null, isLoading: false, error: null }); + + useEffect(() => { + if (!asset || !activeAddress?.address || asset === "BTC") { + setState({ data: null, isLoading: false, error: null }); + return; + } + + let cancelled = false; + setState({ data: null, isLoading: true, error: null }); + + fetchAddressPoolByLpAsset(activeAddress.address, asset, { limit: 100 }) + .then((response) => { + if (cancelled) return; + setState({ data: response, isLoading: false, error: null }); + }) + .catch((err) => { + if (!cancelled) { + setState({ + data: null, + isLoading: false, + error: err instanceof Error ? err : new Error("Failed to check pool position"), + }); + } + }); + + return () => { + cancelled = true; + }; + }, [activeAddress?.address, asset]); + + return state; +} diff --git a/src/hooks/usePoolQuotes.ts b/src/hooks/usePoolQuotes.ts new file mode 100644 index 00000000..19ebf726 --- /dev/null +++ b/src/hooks/usePoolQuotes.ts @@ -0,0 +1,122 @@ +import { useEffect, useState } from "react"; +import { + fetchPoolDepositQuote, + fetchPoolWithdrawQuote, + type PoolDepositQuote, + type PoolWithdrawQuote, +} from "@/utils/blockchain/counterparty/api"; +import { roundDown, toSatoshis } from "@/utils/numeric"; + +interface PoolQuoteState { + data: T | null; + isLoading: boolean; + error: string | null; +} + +export function usePoolDepositQuote({ + assetA, + assetB, + quantityA, + isAssetADivisible, + enabled, +}: { + assetA: string; + assetB: string; + quantityA: string; + isAssetADivisible: boolean; + enabled: boolean; +}): PoolQuoteState { + const [state, setState] = useState>({ + data: null, + isLoading: false, + error: null, + }); + + useEffect(() => { + if (!enabled) { + setState({ data: null, isLoading: false, error: null }); + return; + } + + let cancelled = false; + setState({ data: null, isLoading: true, error: null }); + + const timer = setTimeout(() => { + fetchPoolDepositQuote( + assetA, + assetB, + isAssetADivisible ? toSatoshis(quantityA) : roundDown(quantityA).toString() + ) + .then((data) => { + if (!cancelled) setState({ data, isLoading: false, error: null }); + }) + .catch((err) => { + if (!cancelled) { + setState({ + data: null, + isLoading: false, + error: err instanceof Error ? err.message : "Unable to load pool quote.", + }); + } + }); + }, 300); + + return () => { + cancelled = true; + clearTimeout(timer); + }; + }, [assetA, assetB, enabled, isAssetADivisible, quantityA]); + + return state; +} + +export function usePoolWithdrawQuote({ + assetA, + assetB, + quantity, + enabled, +}: { + assetA: string; + assetB: string; + quantity: string; + enabled: boolean; +}): PoolQuoteState { + const [state, setState] = useState>({ + data: null, + isLoading: false, + error: null, + }); + + useEffect(() => { + if (!enabled) { + setState({ data: null, isLoading: false, error: null }); + return; + } + + let cancelled = false; + setState({ data: null, isLoading: true, error: null }); + + const timer = setTimeout(() => { + fetchPoolWithdrawQuote(assetA, assetB, toSatoshis(quantity)) + .then((data) => { + if (!cancelled) setState({ data, isLoading: false, error: null }); + }) + .catch((err) => { + if (!cancelled) { + setState({ + data: null, + isLoading: false, + error: err instanceof Error ? err.message : "Unable to load withdrawal quote.", + }); + } + }); + }, 300); + + return () => { + cancelled = true; + clearTimeout(timer); + }; + }, [assetA, assetB, enabled, quantity]); + + return state; +} diff --git a/src/pages/actions/index.tsx b/src/pages/actions/index.tsx index d099dec4..7423e6ba 100644 --- a/src/pages/actions/index.tsx +++ b/src/pages/actions/index.tsx @@ -115,6 +115,12 @@ const getActionSections = ( description: "Close a dispenser using its transaction hash", onClick: () => navigate("/compose/dispenser/close-by-hash"), }, + { + id: "manage-pools", + title: "Manage Pools", + description: "View liquidity positions and enter pools", + onClick: () => navigate("/pools"), + }, ], }, ]; diff --git a/src/pages/assets/[asset]/balance.tsx b/src/pages/assets/[asset]/balance.tsx index abe24e73..0201fd33 100644 --- a/src/pages/assets/[asset]/balance.tsx +++ b/src/pages/assets/[asset]/balance.tsx @@ -5,6 +5,7 @@ import { BalanceHeader } from "@/components/ui/headers/balance-header"; import { ActionList } from "@/components/ui/lists/action-list"; import { useHeader } from "@/contexts/header-context"; import { useAssetDetails } from "@/hooks/useAssetDetails"; +import { useLpAssetPool } from "@/hooks/useLpAssetPool"; import type { TokenBalance } from "@/utils/blockchain/counterparty/api"; import type { ReactElement } from "react"; @@ -37,6 +38,7 @@ export default function AssetBalancePage(): ReactElement { const navigate = useNavigate(); const { setHeaderProps, getCachedBalance } = useHeader(); const { data: assetDetails, isLoading, error } = useAssetDetails(asset || ""); + const { data: lpPool } = useLpAssetPool(asset); // Get cached data for instant display const cachedBalance = useMemo(() => getCachedBalance(asset || ""), [getCachedBalance, asset]); @@ -168,6 +170,22 @@ export default function AssetBalancePage(): ReactElement { className: "!border !border-red-500", }, ]; + if (lpPool) { + return [ + { + title: "Liquidity Pool", + items: [ + { + id: "manage-pool", + title: "Manage Pool", + description: `${lpPool.asset_a} / ${lpPool.asset_b}`, + onClick: () => navigate(`/pools/${encodeURIComponent(lpPool.lp_asset)}`), + }, + ], + }, + { items }, + ]; + } return [{ items }]; }; diff --git a/src/pages/compose/pool/deposit/form.tsx b/src/pages/compose/pool/deposit/form.tsx new file mode 100644 index 00000000..1473c93a --- /dev/null +++ b/src/pages/compose/pool/deposit/form.tsx @@ -0,0 +1,300 @@ +import { useMemo, useState, type ReactElement } from "react"; +import { useFormStatus } from "react-dom"; +import { Field, Description } from "@headlessui/react"; +import { ComposerForm } from "@/components/composer/composer-form"; +import { ErrorAlert } from "@/components/ui/error-alert"; +import { BalanceHeader } from "@/components/ui/headers/balance-header"; +import { AmountWithMaxInput } from "@/components/ui/inputs/amount-with-max-input"; +import { AssetNameInput } from "@/components/ui/inputs/asset-name-input"; +import { AssetSelectInput } from "@/components/ui/inputs/asset-select-input"; +import { useComposer } from "@/contexts/composer-context"; +import { useAssetDetails } from "@/hooks/useAssetDetails"; +import { usePoolDepositQuote } from "@/hooks/usePoolQuotes"; +import { + applyPoolSlippage, + calculateInitialLpEstimate, + calculateLimitingLpEstimate, +} from "@/utils/blockchain/counterparty/pool"; +import { + fromSatoshis, + isEqualTo, + isGreaterThan, + isLessThan, + isLessThanOrEqualTo, + isValidPositiveNumber, + roundDown, + toSatoshis, +} from "@/utils/numeric"; +import type { PoolDepositOptions } from "@/utils/blockchain/counterparty/compose"; +import type { TokenBalance } from "@/utils/blockchain/counterparty/api"; +import { DEFAULT_POOL_SLIPPAGE, SlippageInput } from "../slippage-input"; + +interface PoolDepositFormProps { + formAction: (formData: FormData) => void; + initialFormData: PoolDepositOptions | null; + initialAssetA?: string; + initialAssetB?: string; +} + +export function PoolDepositForm({ + formAction, + initialFormData, + initialAssetA, + initialAssetB, +}: PoolDepositFormProps): ReactElement { + const { activeAddress, showHelpText, feeRate } = useComposer(); + const { pending } = useFormStatus(); + const [assetA, setAssetA] = useState(initialFormData?.asset_a || initialAssetA || "XCP"); + const [assetB, setAssetB] = useState(initialFormData?.asset_b || initialAssetB || ""); + const [quantityA, setQuantityA] = useState(initialFormData?.quantity_a?.toString() || ""); + const [quantityB, setQuantityB] = useState(initialFormData?.quantity_b?.toString() || ""); + const [lpAsset, setLpAsset] = useState(initialFormData?.lp_asset || ""); + const [isLpAssetValid, setIsLpAssetValid] = useState(false); + const [slippage, setSlippage] = useState((initialFormData as PoolDepositOptions & { slippage?: string })?.slippage || DEFAULT_POOL_SLIPPAGE); + const [localError, setLocalError] = useState(null); + + const { data: assetADetails } = useAssetDetails(assetA); + const { data: assetBDetails } = useAssetDetails(assetB); + + const assetADetailsReady = assetADetails?.assetInfo?.asset === assetA; + const assetBDetailsReady = assetB ? assetBDetails?.assetInfo?.asset === assetB : false; + const isAssetADivisible = assetADetailsReady ? assetADetails.isDivisible : true; + const isAssetBDivisible = assetBDetailsReady && assetBDetails ? assetBDetails.isDivisible : true; + const canQuote = assetA && assetB && assetA !== assetB && assetADetailsReady && isGreaterThan(quantityA || 0, 0); + const needsQuote = canQuote && isGreaterThan(quantityB || 0, 0); + const { data: quote, isLoading: isLoadingQuote, error: quoteError } = usePoolDepositQuote({ + assetA, + assetB, + quantityA, + isAssetADivisible, + enabled: Boolean(canQuote), + }); + + const isFirstDeposit = quote?.first_deposit === true; + const partnerQuantityRaw = quote?.asset_a === assetA + ? quote?.quantity_b_required + : quote?.quantity_a_required; + const partnerQuantity = partnerQuantityRaw !== undefined && partnerQuantityRaw !== null + ? isAssetBDivisible + ? fromSatoshis(partnerQuantityRaw, { removeTrailingZeros: true }) + : partnerQuantityRaw.toString() + : null; + const quantityARaw = quantityA + ? isAssetADivisible ? toSatoshis(quantityA) : roundDown(quantityA).toString() + : "0"; + const quantityBRaw = quantityB + ? isAssetBDivisible ? toSatoshis(quantityB) : roundDown(quantityB).toString() + : "0"; + const partnerQuantityMatches = partnerQuantityRaw === undefined || partnerQuantityRaw === null + || isEqualTo(quantityBRaw, partnerQuantityRaw); + const partnerQuantityIsLow = partnerQuantityRaw !== undefined && partnerQuantityRaw !== null + && isLessThan(quantityBRaw, partnerQuantityRaw); + const partnerQuantityIsHigh = partnerQuantityRaw !== undefined && partnerQuantityRaw !== null + && isGreaterThan(quantityBRaw, partnerQuantityRaw); + const isZeroSupplyRestart = !isFirstDeposit && quote?.quantity_minted_estimate === 0; + const initialLpEstimate = calculateInitialLpEstimate(quantityARaw, quantityBRaw); + const limitingLpEstimate = calculateLimitingLpEstimate(quote?.quantity_minted_estimate, partnerQuantityRaw, quantityBRaw); + const lpEstimateForMinimum = isFirstDeposit || isZeroSupplyRestart ? initialLpEstimate : limitingLpEstimate; + const minLpQuantity = applyPoolSlippage(lpEstimateForMinimum, slippage); + const hasLpMinimum = isGreaterThan(minLpQuantity, 0); + const isSlippageValid = isValidPositiveNumber(slippage, { allowZero: true, maxDecimals: 2 }) + && isLessThanOrEqualTo(slippage, 50); + const assetABalanceHeader: TokenBalance | null = assetADetailsReady && assetADetails + ? { + asset: assetA, + quantity_normalized: assetADetails.availableBalance, + asset_info: assetADetails.assetInfo ? { + asset_longname: assetADetails.assetInfo.asset_longname, + description: assetADetails.assetInfo.description || "", + issuer: assetADetails.assetInfo.issuer || "", + divisible: assetADetails.assetInfo.divisible, + locked: assetADetails.assetInfo.locked, + supply: assetADetails.assetInfo.supply, + } : undefined, + } + : null; + + const submitDisabled = useMemo(() => { + if (!assetA || !assetB || assetA === assetB) return true; + if (!assetADetailsReady || !assetBDetailsReady) return true; + if (!isGreaterThan(quantityA || 0, 0)) return true; + if (!isGreaterThan(quantityB || 0, 0)) return true; + if (needsQuote && (isLoadingQuote || !quote)) return true; + if (isFirstDeposit && lpAsset && !isLpAssetValid) return true; + if (!isSlippageValid) return true; + return false; + }, [assetA, assetB, assetADetailsReady, assetBDetailsReady, quantityA, quantityB, needsQuote, isLoadingQuote, quote, isFirstDeposit, lpAsset, isLpAssetValid, isSlippageValid]); + + const handleFormAction = (formData: FormData) => { + if (assetA === assetB) { + setLocalError("Pool assets must be different."); + return; + } + + formData.set("asset_a", assetA); + formData.set("asset_b", assetB); + formData.set("quantity_a", quantityA); + formData.set("quantity_b", quantityB); + formData.set("min_lp_quantity", minLpQuantity); + if (lpAsset.trim()) { + formData.set("lp_asset", lpAsset.trim()); + } else { + formData.delete("lp_asset"); + } + formAction(formData); + }; + + return ( + : null} + submitText="Review Deposit" + submitDisabled={pending || submitDisabled} + > + {localError && setLocalError(null)} />} + + + + + + + + setQuantityB(partnerQuantity.toString())} + > + Use quote + + ) : null + } + /> + + {isLoadingQuote && ( +

Loading pool quote...

+ )} + + {quoteError && ( + + )} + + {quote?.message && ( +
+ {quote.message} +
+ )} + + {partnerQuantity && !isFirstDeposit && ( +
+ Quoted partner amount: {partnerQuantity.toString()} {assetB} +
+ )} + + {partnerQuantity && !isFirstDeposit && !partnerQuantityMatches && ( +
+ {partnerQuantityIsHigh + ? "Only the pool-ratio amount will be deposited; extra is left unused." + : partnerQuantityIsLow + ? "This deposits less than the quoted ratio allows." + : "Pool deposits use the current pool ratio."} +
+ )} + + {isZeroSupplyRestart && ( +
+ LP supply is zero. This deposit restarts the pool and may claim existing reserves. +
+ )} + + {(isFirstDeposit || isZeroSupplyRestart || (quote?.quantity_minted_estimate !== undefined && quote.quantity_minted_estimate !== null)) && ( + <> + + + {hasLpMinimum && ( +
+ Minimum LP tokens:{" "} + + {fromSatoshis(minLpQuantity, { removeTrailingZeros: true })} + {" "} + after {slippage || "0"}% slippage. +
+ )} + + )} + + {isFirstDeposit && ( + + + {showHelpText && ( + + The LP asset represents your share of the pool. + + )} + + )} + + + + + + + + {lpAsset && } +
+ ); +} diff --git a/src/pages/compose/pool/deposit/index.tsx b/src/pages/compose/pool/deposit/index.tsx new file mode 100644 index 00000000..fa38baea --- /dev/null +++ b/src/pages/compose/pool/deposit/index.tsx @@ -0,0 +1,27 @@ +import { useParams } from "react-router-dom"; +import { Composer } from "@/components/composer/composer"; +import { composePoolDeposit, type PoolDepositOptions } from "@/utils/blockchain/counterparty/compose"; +import { PoolDepositForm } from "./form"; +import { ReviewPoolDeposit } from "./review"; + +export default function ComposePoolDepositPage() { + const { assetA, assetB } = useParams<{ assetA?: string; assetB?: string }>(); + + return ( +
+ + composeType="pooldeposit" + composeApiMethod={composePoolDeposit} + initialTitle="Pool Deposit" + FormComponent={(props) => ( + + )} + ReviewComponent={ReviewPoolDeposit} + /> +
+ ); +} diff --git a/src/pages/compose/pool/deposit/review.tsx b/src/pages/compose/pool/deposit/review.tsx new file mode 100644 index 00000000..1060414b --- /dev/null +++ b/src/pages/compose/pool/deposit/review.tsx @@ -0,0 +1,95 @@ +import { useEffect, useState } from "react"; +import { ReviewScreen } from "@/components/screens/review-screen"; +import { useMarketPrices } from "@/hooks/useMarketPrices"; +import { useSettings } from "@/contexts/settings-context"; +import { getPoolDepositEstimateXcpFee } from "@/utils/blockchain/counterparty/compose"; +import { getCanonicalPoolPair } from "@/utils/blockchain/counterparty/pool"; +import { formatAmount } from "@/utils/format"; +import { fromSatoshis } from "@/utils/numeric"; + +interface ReviewPoolDepositProps { + apiResponse: any; + onSign: () => void; + onBack: () => void; + error: string | null; + isSigning: boolean; +} + +export function ReviewPoolDeposit({ + apiResponse, + onSign, + onBack, + error, + isSigning, +}: ReviewPoolDepositProps) { + const { result } = apiResponse; + const params = result.params; + const { settings } = useSettings(); + const { xcp: xcpPrice } = useMarketPrices(settings.fiat); + const [xcpFeeEstimate, setXcpFeeEstimate] = useState(null); + const [feeLoading, setFeeLoading] = useState(true); + + useEffect(() => { + const fetchFeeEstimate = async () => { + try { + const sourceAddress = params.source; + if (sourceAddress) { + const fee = await getPoolDepositEstimateXcpFee(sourceAddress); + setXcpFeeEstimate(fee); + } + } catch (err) { + console.error("Failed to fetch pool deposit XCP fee estimate:", err); + } finally { + setFeeLoading(false); + } + }; + + fetchFeeEstimate(); + }, [params.source]); + + const xcpFeeInXcp = xcpFeeEstimate !== null ? fromSatoshis(xcpFeeEstimate, true) : null; + const xcpFeeInFiat = xcpFeeInXcp !== null && xcpPrice ? xcpFeeInXcp * xcpPrice : null; + const minimumLpDisplay = params.min_lp_quantity_normalized + ?? fromSatoshis(params.min_lp_quantity ?? 0, { removeTrailingZeros: true }); + + const customFields = [ + { + label: "Pool", + value: getCanonicalPoolPair(params.asset_a, params.asset_b), + }, + { + label: "Deposit", + value: `${params.quantity_a_normalized ?? params.quantity_a} ${params.asset_a}\n${params.quantity_b_normalized ?? params.quantity_b} ${params.asset_b}`, + }, + ...(params.min_lp_quantity && params.min_lp_quantity !== "0" + ? [{ label: "Minimum LP", value: minimumLpDisplay }] + : []), + ...(params.lp_asset ? [{ label: "LP Asset", value: params.lp_asset }] : []), + { + label: "XCP Fee", + value: feeLoading + ? "Loading..." + : xcpFeeEstimate !== null + ? `${formatAmount({ + value: xcpFeeInXcp!, + minimumFractionDigits: 8, + maximumFractionDigits: 8, + })} XCP` + : "Unable to estimate", + rightElement: !feeLoading && xcpFeeInFiat !== null + ? ${formatAmount({ value: xcpFeeInFiat, minimumFractionDigits: 2, maximumFractionDigits: 2 })} + : undefined, + }, + ]; + + return ( + + ); +} diff --git a/src/pages/compose/pool/slippage-input.tsx b/src/pages/compose/pool/slippage-input.tsx new file mode 100644 index 00000000..738c2616 --- /dev/null +++ b/src/pages/compose/pool/slippage-input.tsx @@ -0,0 +1,181 @@ +import { useEffect, useState } from "react"; +import { + Field, + Label, + Description, + Input, + Listbox, + ListboxButton, + ListboxOption, + ListboxOptions, +} from "@headlessui/react"; +import { Button } from "@/components/ui/button"; +import { + isFiniteNumber, + isGreaterThanOrEqualTo, + isLessThan, +} from "@/utils/numeric"; +import type { ReactElement } from "react"; + +export const DEFAULT_POOL_SLIPPAGE = "2.5"; + +const PRESET_OPTIONS = [ + { id: "tight", name: "Tight", value: "0.5" }, + { id: "standard", name: "Standard", value: DEFAULT_POOL_SLIPPAGE }, + { id: "loose", name: "Loose", value: "5" }, +] as const; + +type PresetId = typeof PRESET_OPTIONS[number]["id"]; +type OptionId = PresetId | "custom"; + +const LOW_SLIPPAGE_THRESHOLD = "0.05"; +const HIGH_SLIPPAGE_THRESHOLD = "20"; + +interface SlippageInputProps { + value: string; + onChange: (value: string) => void; + showHelpText?: boolean; +} + +export function SlippageInput({ + value, + onChange, + showHelpText = false, +}: SlippageInputProps): ReactElement { + const [selectedOption, setSelectedOption] = useState("standard"); + const [customInput, setCustomInput] = useState(value); + + useEffect(() => { + const matchingPreset = PRESET_OPTIONS.find((option) => option.value === value); + if (matchingPreset) { + setSelectedOption(matchingPreset.id); + setCustomInput(value); + } else { + setSelectedOption("custom"); + setCustomInput(value); + } + }, [value]); + + const options = [ + ...PRESET_OPTIONS, + { id: "custom" as const, name: "Custom", value: customInput || DEFAULT_POOL_SLIPPAGE }, + ]; + + const showSlippageWarning = value.trim() !== "" && isFiniteNumber(value); + const isLowSlippage = showSlippageWarning + && isGreaterThanOrEqualTo(value, 0) + && isLessThan(value, LOW_SLIPPAGE_THRESHOLD); + const isHighSlippage = showSlippageWarning + && isGreaterThanOrEqualTo(value, HIGH_SLIPPAGE_THRESHOLD); + + const handleOptionSelect = (option: typeof options[number] | null) => { + if (!option) return; + + setSelectedOption(option.id); + if (option.id !== "custom") { + setCustomInput(option.value); + onChange(option.value); + } + }; + + const handleCustomInputChange = (event: React.ChangeEvent) => { + const nextValue = event.target.value.trim(); + const parts = nextValue.split("."); + if (parts.length > 2) return; + if (nextValue !== "" && !isFiniteNumber(nextValue)) return; + + setCustomInput(nextValue); + onChange(nextValue); + }; + + const handleCustomInputBlur = () => { + if (!customInput || isLessThan(customInput, 0)) { + setCustomInput(DEFAULT_POOL_SLIPPAGE); + onChange(DEFAULT_POOL_SLIPPAGE); + } + }; + + const handleEscClick = () => { + const standard = PRESET_OPTIONS.find((option) => option.id === "standard") ?? PRESET_OPTIONS[0]; + setSelectedOption(standard.id); + setCustomInput(standard.value); + onChange(standard.value); + }; + + return ( + + +
+ {selectedOption === "custom" ? ( +
+ + +
+ ) : ( +
+ option.id === selectedOption) || options[1]} + onChange={handleOptionSelect} + > + + {({ value }) => ( +
+ {value?.name} + {value?.id !== "custom" && {value?.value}%} +
+ )} +
+ + {options.map((option) => ( + + `p-2.5 cursor-pointer select-none ${focus ? "bg-blue-500 text-white" : "text-gray-900"}` + } + > + {({ selected, focus }) => ( +
+ {option.name} + {option.id !== "custom" && ( + {option.value}% + )} +
+ )} +
+ ))} +
+
+
+ )} +
+ {showHelpText && ( + + Sets how far the pool quote may move before the transaction fails. + + )} + {isLowSlippage && ( +
+ Low slippage may fail if the pool changes before confirmation. +
+ )} + {isHighSlippage && ( +
+ High slippage allows a worse result before failing. +
+ )} +
+ ); +} diff --git a/src/pages/compose/pool/withdraw/form.tsx b/src/pages/compose/pool/withdraw/form.tsx new file mode 100644 index 00000000..6bfba401 --- /dev/null +++ b/src/pages/compose/pool/withdraw/form.tsx @@ -0,0 +1,175 @@ +import { useMemo, useState, type ReactElement } from "react"; +import { useFormStatus } from "react-dom"; +import { ComposerForm } from "@/components/composer/composer-form"; +import { ErrorAlert } from "@/components/ui/error-alert"; +import { PoolHeader } from "@/components/ui/headers/pool-header"; +import { AmountWithMaxInput } from "@/components/ui/inputs/amount-with-max-input"; +import { Spinner } from "@/components/ui/spinner"; +import { useComposer } from "@/contexts/composer-context"; +import { useAssetDetails } from "@/hooks/useAssetDetails"; +import { useLpAssetPool } from "@/hooks/useLpAssetPool"; +import { usePoolWithdrawQuote } from "@/hooks/usePoolQuotes"; +import { applyPoolSlippage } from "@/utils/blockchain/counterparty/pool"; +import { fromSatoshis, isGreaterThan, isLessThanOrEqualTo, isValidPositiveNumber } from "@/utils/numeric"; +import type { PoolWithdrawOptions } from "@/utils/blockchain/counterparty/compose"; +import { DEFAULT_POOL_SLIPPAGE, SlippageInput } from "../slippage-input"; + +interface PoolWithdrawFormProps { + formAction: (formData: FormData) => void; + initialFormData: PoolWithdrawOptions | null; + lpAsset: string; +} + +export function PoolWithdrawForm({ + formAction, + initialFormData, + lpAsset, +}: PoolWithdrawFormProps): ReactElement { + const { activeAddress, showHelpText, feeRate } = useComposer(); + const { pending } = useFormStatus(); + const { data: pool, isLoading, error: poolError } = useLpAssetPool(lpAsset); + const { data: assetADetails } = useAssetDetails(pool?.asset_a || ""); + const { data: assetBDetails } = useAssetDetails(pool?.asset_b || ""); + const [quantity, setQuantity] = useState(initialFormData?.quantity?.toString() || ""); + const [slippage, setSlippage] = useState((initialFormData as PoolWithdrawOptions & { slippage?: string })?.slippage || DEFAULT_POOL_SLIPPAGE); + const [localError, setLocalError] = useState(null); + + const canQuote = !!pool && isGreaterThan(quantity || 0, 0); + const isAssetADivisible = assetADetails?.isDivisible ?? true; + const isAssetBDivisible = assetBDetails?.isDivisible ?? true; + const { data: quote, isLoading: isLoadingQuote, error: quoteError } = usePoolWithdrawQuote({ + assetA: pool?.asset_a || "", + assetB: pool?.asset_b || "", + quantity, + enabled: canQuote, + }); + + const formatReceived = (value: number | string | undefined, divisible: boolean): string => { + if (value === undefined) return "0"; + return divisible ? fromSatoshis(value.toString(), { removeTrailingZeros: true }) : value.toString(); + }; + + const minQuantityA = applyPoolSlippage(quote?.quantity_a_estimate, slippage); + const minQuantityB = applyPoolSlippage(quote?.quantity_b_estimate, slippage); + const hasMinimums = isGreaterThan(minQuantityA, 0) || isGreaterThan(minQuantityB, 0); + const isSlippageValid = isValidPositiveNumber(slippage, { allowZero: true, maxDecimals: 2 }) + && isLessThanOrEqualTo(slippage, 50); + + const submitDisabled = useMemo(() => { + if (!pool) return true; + if (!isGreaterThan(quantity || 0, 0)) return true; + if (isGreaterThan(quantity, pool.quantity_normalized ?? pool.quantity)) return true; + if (canQuote && (isLoadingQuote || !quote?.pool_exists)) return true; + if (!isSlippageValid) return true; + return false; + }, [pool, quantity, canQuote, isLoadingQuote, quote?.pool_exists, isSlippageValid]); + + const handleFormAction = (formData: FormData) => { + if (!pool) return; + formData.set("lp_asset", pool.lp_asset); + formData.set("asset_a", pool.asset_a); + formData.set("asset_b", pool.asset_b); + formData.set("quantity", quantity); + formData.set("min_quantity_a", minQuantityA); + formData.set("min_quantity_b", minQuantityB); + formAction(formData); + }; + + if (isLoading) { + return ; + } + + if (!pool) { + if (poolError) { + return ( +
+ +
+ ); + } + return
Pool position not found
; + } + + return ( + } + submitText="Review Withdrawal" + submitDisabled={pending || submitDisabled} + > + {localError && setLocalError(null)} />} + + + + {isLoadingQuote && ( +

Loading withdrawal quote...

+ )} + + {quoteError && ( + + )} + + {quote?.pool_exists && ( +
+ Estimated receive: +
+ {formatReceived(quote.quantity_a_estimate, isAssetADivisible)} {pool.asset_a} +
+
+ {formatReceived(quote.quantity_b_estimate, isAssetBDivisible)} {pool.asset_b} +
+
+ )} + + {quote?.pool_exists && ( + <> + + + {hasMinimums && ( +
+ Minimum received after {slippage || "0"}% slippage: +
+ {formatReceived(minQuantityA, isAssetADivisible)} {pool.asset_a} +
+
+ {formatReceived(minQuantityB, isAssetBDivisible)} {pool.asset_b} +
+
+ )} + + )} + + {quote?.message && ( +
+ {quote.message} +
+ )} + + + + + + + + +
+ ); +} diff --git a/src/pages/compose/pool/withdraw/index.tsx b/src/pages/compose/pool/withdraw/index.tsx new file mode 100644 index 00000000..26867baa --- /dev/null +++ b/src/pages/compose/pool/withdraw/index.tsx @@ -0,0 +1,22 @@ +import { useParams } from "react-router-dom"; +import { Composer } from "@/components/composer/composer"; +import { composePoolWithdraw, type PoolWithdrawOptions } from "@/utils/blockchain/counterparty/compose"; +import { PoolWithdrawForm } from "./form"; +import { ReviewPoolWithdraw } from "./review"; + +export default function ComposePoolWithdrawPage() { + const { lpAsset } = useParams<{ lpAsset: string }>(); + const asset = lpAsset ? decodeURIComponent(lpAsset) : ""; + + return ( +
+ + composeType="poolwithdraw" + composeApiMethod={composePoolWithdraw} + initialTitle="Pool Withdraw" + FormComponent={(props) => } + ReviewComponent={ReviewPoolWithdraw} + /> +
+ ); +} diff --git a/src/pages/compose/pool/withdraw/review.tsx b/src/pages/compose/pool/withdraw/review.tsx new file mode 100644 index 00000000..efcc1f6f --- /dev/null +++ b/src/pages/compose/pool/withdraw/review.tsx @@ -0,0 +1,113 @@ +import { useEffect, useState } from "react"; +import { ReviewScreen } from "@/components/screens/review-screen"; +import { useMarketPrices } from "@/hooks/useMarketPrices"; +import { useSettings } from "@/contexts/settings-context"; +import { useAssetInfo } from "@/hooks/useAssetInfo"; +import { getPoolWithdrawEstimateXcpFee } from "@/utils/blockchain/counterparty/compose"; +import { getCanonicalPoolPair } from "@/utils/blockchain/counterparty/pool"; +import { formatAmount } from "@/utils/format"; +import { fromSatoshis } from "@/utils/numeric"; + +interface ReviewPoolWithdrawProps { + apiResponse: any; + onSign: () => void; + onBack: () => void; + error: string | null; + isSigning: boolean; +} + +export function ReviewPoolWithdraw({ + apiResponse, + onSign, + onBack, + error, + isSigning, +}: ReviewPoolWithdrawProps) { + const { result } = apiResponse; + const params = result.params; + const { settings } = useSettings(); + const { xcp: xcpPrice } = useMarketPrices(settings.fiat); + const { data: assetAInfo, isLoading: isLoadingAssetA } = useAssetInfo(params.asset_a || ""); + const { data: assetBInfo, isLoading: isLoadingAssetB } = useAssetInfo(params.asset_b || ""); + const [xcpFeeEstimate, setXcpFeeEstimate] = useState(null); + const [feeLoading, setFeeLoading] = useState(true); + + useEffect(() => { + const fetchFeeEstimate = async () => { + try { + const sourceAddress = params.source; + if (sourceAddress) { + const fee = await getPoolWithdrawEstimateXcpFee(sourceAddress); + setXcpFeeEstimate(fee); + } + } catch (err) { + console.error("Failed to fetch pool withdraw XCP fee estimate:", err); + } finally { + setFeeLoading(false); + } + }; + + fetchFeeEstimate(); + }, [params.source]); + + const xcpFeeInXcp = xcpFeeEstimate !== null ? fromSatoshis(xcpFeeEstimate, true) : null; + const xcpFeeInFiat = xcpFeeInXcp !== null && xcpPrice ? xcpFeeInXcp * xcpPrice : null; + const formatMinimum = ( + normalized: string | undefined, + raw: string | number | undefined, + divisible: boolean | undefined, + isLoadingAsset: boolean + ) => { + if (normalized !== undefined) return normalized; + if (raw === undefined) return "0"; + if (divisible === undefined && isLoadingAsset) return "Loading..."; + return divisible ? fromSatoshis(raw, { removeTrailingZeros: true }) : raw.toString(); + }; + const minQuantityADisplay = formatMinimum(params.min_quantity_a_normalized, params.min_quantity_a, assetAInfo?.divisible, isLoadingAssetA); + const minQuantityBDisplay = formatMinimum(params.min_quantity_b_normalized, params.min_quantity_b, assetBInfo?.divisible, isLoadingAssetB); + const quantityDisplay = params.quantity_normalized + ?? fromSatoshis(params.quantity ?? 0, { removeTrailingZeros: true }); + + const customFields = [ + { + label: "Pool", + value: params.asset_a && params.asset_b ? getCanonicalPoolPair(params.asset_a, params.asset_b) : params.lp_asset, + }, + { + label: "Withdraw", + value: `${quantityDisplay} ${params.lp_asset ?? "LP"}`, + }, + ...(params.min_quantity_a || params.min_quantity_b + ? [{ + label: "Minimum Receive", + value: `${minQuantityADisplay} ${params.asset_a ?? ""}\n${minQuantityBDisplay} ${params.asset_b ?? ""}`, + }] + : []), + { + label: "XCP Fee", + value: feeLoading + ? "Loading..." + : xcpFeeEstimate !== null + ? `${formatAmount({ + value: xcpFeeInXcp!, + minimumFractionDigits: 8, + maximumFractionDigits: 8, + })} XCP` + : "Unable to estimate", + rightElement: !feeLoading && xcpFeeInFiat !== null + ? ${formatAmount({ value: xcpFeeInFiat, minimumFractionDigits: 2, maximumFractionDigits: 2 })} + : undefined, + }, + ]; + + return ( + + ); +} diff --git a/src/pages/pools/[lpAsset].tsx b/src/pages/pools/[lpAsset].tsx new file mode 100644 index 00000000..4740c2ab --- /dev/null +++ b/src/pages/pools/[lpAsset].tsx @@ -0,0 +1,147 @@ +import { useEffect } from "react"; +import { useNavigate, useParams } from "react-router-dom"; +import { Button } from "@/components/ui/button"; +import { Spinner } from "@/components/ui/spinner"; +import { ErrorAlert } from "@/components/ui/error-alert"; +import { PoolHeader } from "@/components/ui/headers/pool-header"; +import { ActionList } from "@/components/ui/lists/action-list"; +import { useHeader } from "@/contexts/header-context"; +import { useAssetInfo } from "@/hooks/useAssetInfo"; +import { useLpAssetPool } from "@/hooks/useLpAssetPool"; +import { getCanonicalPoolAssets, getCanonicalPoolPair } from "@/utils/blockchain/counterparty/pool"; +import { divide, formatDecimal, isGreaterThan, multiply, toBigNumber } from "@/utils/numeric"; + +import type { ReactElement } from "react"; + +export default function PoolPositionPage(): ReactElement { + const { lpAsset } = useParams<{ lpAsset: string }>(); + const navigate = useNavigate(); + const { setHeaderProps } = useHeader(); + const asset = lpAsset ? decodeURIComponent(lpAsset) : undefined; + const { data: pool, isLoading, error } = useLpAssetPool(asset); + const { data: lpAssetInfo } = useAssetInfo(pool?.lp_asset || ""); + + useEffect(() => { + setHeaderProps({ + title: "Pool", + onBack: () => navigate(-1), + }); + return () => setHeaderProps(null); + }, [navigate, setHeaderProps]); + + if (isLoading) { + return ; + } + + if (!pool) { + if (error) { + return ( +
+ +
+ ); + } + return
Pool position not found
; + } + + const pair = getCanonicalPoolPair(pool.asset_a, pool.asset_b); + const [firstReserveAsset, secondReserveAsset] = getCanonicalPoolAssets(pool.asset_a, pool.asset_b); + const reserveByAsset = { + [pool.asset_a]: pool.reserve_a_normalized ?? pool.reserve_a, + [pool.asset_b]: pool.reserve_b_normalized ?? pool.reserve_b, + }; + const lpBalanceValue = toBigNumber(pool.quantity_normalized ?? pool.quantity); + const lpSupply = toBigNumber(lpAssetInfo?.supply_normalized); + const poolShare = isGreaterThan(lpSupply, 0) ? divide(lpBalanceValue, lpSupply) : null; + const poolSharePercent = poolShare ? formatDecimal(multiply(poolShare, 100), 4) : null; + const underlyingA = poolShare + ? formatDecimal(multiply(poolShare, pool.reserve_a_normalized ?? pool.reserve_a)) + : null; + const underlyingB = poolShare + ? formatDecimal(multiply(poolShare, pool.reserve_b_normalized ?? pool.reserve_b)) + : null; + + return ( +
+
+
+ +
+ +
+
+
LP Asset
+
{pool.lp_asset}
+
+
+
+
Reserve {firstReserveAsset}
+
+ {reserveByAsset[firstReserveAsset]} +
+
+
+
Reserve {secondReserveAsset}
+
+ {reserveByAsset[secondReserveAsset]} +
+
+
+ {poolSharePercent && underlyingA && underlyingB && ( +
+
+
Pool share
+
{poolSharePercent}%
+
+
+
Underlying
+
+ {underlyingA} {pool.asset_a} +
+
+ {underlyingB} {pool.asset_b} +
+
+
+ )} +
+ +
+ + +
+
+ + navigate(`/compose/order/${encodeURIComponent(pool.asset_a)}`), + }, + { + id: "trade-b", + title: `Trade ${pool.asset_b}`, + description: `Open DEX order flow for ${pool.asset_b}`, + onClick: () => navigate(`/compose/order/${encodeURIComponent(pool.asset_b)}`), + }, + ], + }, + ]} + /> +
+ ); +} diff --git a/src/pages/pools/index.tsx b/src/pages/pools/index.tsx new file mode 100644 index 00000000..d4f698e1 --- /dev/null +++ b/src/pages/pools/index.tsx @@ -0,0 +1,183 @@ +import { useCallback, useEffect, useState, type ReactElement } from "react"; +import { useNavigate } from "react-router-dom"; +import { Button } from "@/components/ui/button"; +import { Spinner } from "@/components/ui/spinner"; +import { ErrorAlert } from "@/components/ui/error-alert"; +import { ActionList, type ActionSection } from "@/components/ui/lists/action-list"; +import { useHeader } from "@/contexts/header-context"; +import { useWallet } from "@/contexts/wallet-context"; +import { useInView } from "@/hooks/useInView"; +import { fetchAddressPools, type PoolPosition } from "@/utils/blockchain/counterparty/api"; +import { getCanonicalPoolPair } from "@/utils/blockchain/counterparty/pool"; + +const PAGE_SIZE = 20; + +export default function ManagePoolsPage(): ReactElement { + const navigate = useNavigate(); + const { setHeaderProps } = useHeader(); + const { activeAddress } = useWallet(); + const [positions, setPositions] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [isFetchingMore, setIsFetchingMore] = useState(false); + const [offset, setOffset] = useState(0); + const [hasMore, setHasMore] = useState(true); + const [initialLoaded, setInitialLoaded] = useState(false); + const [error, setError] = useState(null); + const { ref: loadMoreRef, inView } = useInView({ rootMargin: "300px", threshold: 0 }); + + useEffect(() => { + setHeaderProps({ + title: "Pools", + onBack: () => navigate("/actions"), + }); + return () => setHeaderProps(null); + }, [navigate, setHeaderProps]); + + useEffect(() => { + if (!activeAddress?.address) { + setPositions([]); + setOffset(0); + setHasMore(true); + setInitialLoaded(false); + return; + } + + let cancelled = false; + setPositions([]); + setOffset(0); + setHasMore(true); + setInitialLoaded(false); + setIsLoading(true); + setError(null); + + fetchAddressPools(activeAddress.address, { limit: PAGE_SIZE, offset: 0 }) + .then((response) => { + if (cancelled) return; + setPositions(response.result); + setOffset(PAGE_SIZE); + setHasMore(response.result.length === PAGE_SIZE && response.result.length < response.result_count); + setInitialLoaded(true); + }) + .catch((err) => { + if (cancelled) return; + setError(err instanceof Error ? err.message : "Failed to load pool positions"); + setInitialLoaded(true); + }) + .finally(() => { + if (!cancelled) setIsLoading(false); + }); + + return () => { + cancelled = true; + }; + }, [activeAddress?.address]); + + const appendPositions = useCallback((newPositions: PoolPosition[]) => { + setPositions((current) => { + const existing = new Set(current.map((position) => position.lp_asset)); + const unique = newPositions.filter((position) => !existing.has(position.lp_asset)); + return [...current, ...unique]; + }); + }, []); + + useEffect(() => { + if (!activeAddress?.address || !inView || !hasMore || isFetchingMore || isLoading || !initialLoaded) { + return; + } + + let cancelled = false; + + const loadMore = async () => { + setIsFetchingMore(true); + setError(null); + + try { + const response = await fetchAddressPools(activeAddress.address, { limit: PAGE_SIZE, offset }); + if (cancelled) return; + + appendPositions(response.result); + setOffset((current) => current + PAGE_SIZE); + setHasMore(response.result.length === PAGE_SIZE && offset + response.result.length < response.result_count); + } catch (err) { + if (!cancelled) { + setError(err instanceof Error ? err.message : "Failed to load more pool positions"); + setHasMore(false); + } + } finally { + if (!cancelled) setIsFetchingMore(false); + } + }; + + loadMore(); + + return () => { + cancelled = true; + }; + }, [activeAddress?.address, appendPositions, hasMore, inView, initialLoaded, isFetchingMore, isLoading, offset]); + + const sections: ActionSection[] = [ + { + title: "Pools", + items: [ + { + id: "enter-pool", + title: "Enter Pool", + description: "Deposit assets into a liquidity pool", + onClick: () => navigate("/compose/pool/deposit"), + }, + ], + }, + ]; + + if (positions.length > 0) { + sections.push({ + title: "Your Positions", + items: positions.map((position) => ({ + id: position.lp_asset, + title: getCanonicalPoolPair(position.asset_a, position.asset_b), + description: `${position.quantity_normalized ?? position.quantity} ${position.lp_asset}`, + onClick: () => navigate(`/pools/${encodeURIComponent(position.lp_asset)}`), + })), + }); + } + + return ( +
+

Manage Pools

+
+ {error && setError(null)} />} + {isLoading ? ( + + ) : ( + <> + + {positions.length === 0 && ( +
+

No LP positions were found for this address.

+ +
+ )} + {positions.length > 0 && ( +
+ {hasMore ? ( + isFetchingMore ? ( + + ) : ( +
Scroll to load more...
+ ) + ) : null} +
+ )} + + )} +
+
+ ); +} diff --git a/src/pages/requests/psbt/approve.tsx b/src/pages/requests/psbt/approve.tsx index 162baa60..3453cf86 100644 --- a/src/pages/requests/psbt/approve.tsx +++ b/src/pages/requests/psbt/approve.tsx @@ -12,6 +12,11 @@ import { useSignPsbtRequest } from '@/hooks/useSignPsbtRequest'; import { getWalletService } from '@/services/walletService'; import type { DecodedPsbtInfo } from '@/hooks/useSignPsbtRequest'; +function normalizeLpQuantity(quantity: unknown): string { + if (quantity == null) return '?'; + return fromSatoshis(String(quantity), { removeTrailingZeros: true }); +} + /** * Build a human-readable label and description from PSBT decoded info. * Uses the API counterpartyMessage if available, otherwise falls back @@ -69,7 +74,7 @@ function getTxActionInfo(decodedInfo: DecodedPsbtInfo): { label: string; descrip case 'pooldeposit': return { label: 'Pool Deposit', description: `${data.quantityA} ${data.assetA} + ${data.quantityB} ${data.assetB}` }; case 'poolwithdraw': - return { label: 'Pool Withdraw', description: `Burn ${data.quantity} LP tokens from ${data.assetA}/${data.assetB}` }; + return { label: 'Pool Withdraw', description: `Burn ${normalizeLpQuantity(data.quantity)} LP tokens from ${data.assetA}/${data.assetB}` }; default: return { label, description: unpack.messageType }; } diff --git a/src/pages/requests/transaction/approve.tsx b/src/pages/requests/transaction/approve.tsx index 1d98295c..aa9dfa9e 100644 --- a/src/pages/requests/transaction/approve.tsx +++ b/src/pages/requests/transaction/approve.tsx @@ -44,6 +44,11 @@ function normalizeQuantity(quantity: unknown, asset: string, messageData?: Recor return val.toLocaleString(); } +function normalizeLpQuantity(quantity: unknown): string { + if (quantity == null) return '?'; + return fromSatoshis(String(quantity), { removeTrailingZeros: true }); +} + /** * Build a human-readable label and description from transaction data. * Uses the API counterpartyMessage if available, otherwise falls back @@ -102,7 +107,7 @@ function getTxActionInfo(decodedInfo: DecodedTransactionInfo): { label: string; case 'pooldeposit': return { label: 'Pool Deposit', description: `${normalizeQuantity(data.quantityA, data.assetA as string)} ${data.assetA} + ${normalizeQuantity(data.quantityB, data.assetB as string)} ${data.assetB}` }; case 'poolwithdraw': - return { label: 'Pool Withdraw', description: `Burn ${String(data.quantity)} LP tokens from ${data.assetA}/${data.assetB}` }; + return { label: 'Pool Withdraw', description: `Burn ${normalizeLpQuantity(data.quantity)} LP tokens from ${data.assetA}/${data.assetB}` }; default: return { label, description: unpack.messageType }; } diff --git a/src/utils/__tests__/numeric.test.ts b/src/utils/__tests__/numeric.test.ts index f95bc3bc..126e8139 100644 --- a/src/utils/__tests__/numeric.test.ts +++ b/src/utils/__tests__/numeric.test.ts @@ -11,6 +11,12 @@ import { divideSatoshis, isLessThanSatoshis, isLessThanOrEqualToSatoshis, + isFiniteNumber, + isEqualTo, + isLessThan, + isGreaterThan, + isGreaterThanOrEqualTo, + isLessThanOrEqualTo, normalizeAssetSupply, calculateMaxDividendPerUnit, } from '../numeric'; @@ -250,6 +256,18 @@ describe('numeric utilities', () => { }); }); + describe('generic comparisons', () => { + it('compares mixed numeric inputs safely', () => { + expect(isFiniteNumber('1.25')).toBe(true); + expect(isFiniteNumber('Infinity')).toBe(false); + expect(isEqualTo('100', 100)).toBe(true); + expect(isLessThan('99.99999999', 100)).toBe(true); + expect(isLessThanOrEqualTo('100', 100)).toBe(true); + expect(isGreaterThan('100.00000001', 100)).toBe(true); + expect(isGreaterThanOrEqualTo('100', 100)).toBe(true); + }); + }); + describe('subtractSatoshis', () => { it('should subtract satoshi values', () => { expect(subtractSatoshis('1000', '300')).toBe('700'); @@ -550,4 +568,4 @@ describe('numeric utilities', () => { expect(totalPayout.toString()).toBe(balance); }); }); -}); \ No newline at end of file +}); diff --git a/src/utils/blockchain/counterparty/__tests__/api.test.ts b/src/utils/blockchain/counterparty/__tests__/api.test.ts index 7977714d..9b3caa4f 100644 --- a/src/utils/blockchain/counterparty/__tests__/api.test.ts +++ b/src/utils/blockchain/counterparty/__tests__/api.test.ts @@ -19,6 +19,8 @@ import { fetchPoolQuote, fetchPoolDepositQuote, fetchPoolWithdrawQuote, + fetchAddressPools, + fetchAddressPoolByLpAsset, fetchServerInfo, clearApiCache, AssetInfo, @@ -1206,6 +1208,29 @@ describe('counterparty/api.ts', () => { expect.objectContaining({ params: { quantity: '1000' } }) ); }); + + it('should fetch address LP pool positions', async () => { + await fetchAddressPools(mockAddress, { limit: 50, offset: 10 }); + + expect(mockedApiClient.get).toHaveBeenCalledWith( + `${mockApiBase}/v2/addresses/${mockAddress}/pools`, + expect.objectContaining({ + params: expect.objectContaining({ verbose: true, limit: 50, offset: 10 }), + }) + ); + }); + + it('should find an address LP pool position by LP asset', async () => { + const result = await fetchAddressPoolByLpAsset(mockAddress, 'A77777777777777777'); + + expect(result).toEqual(mockPool); + expect(mockedApiClient.get).toHaveBeenCalledWith( + `${mockApiBase}/v2/addresses/${mockAddress}/pools`, + expect.objectContaining({ + params: expect.objectContaining({ verbose: true, limit: 100, offset: 0 }), + }) + ); + }); }); describe('fetchServerInfo', () => { diff --git a/src/utils/blockchain/counterparty/__tests__/compose.specialized.test.ts b/src/utils/blockchain/counterparty/__tests__/compose.specialized.test.ts index 3d106403..a289ba01 100644 --- a/src/utils/blockchain/counterparty/__tests__/compose.specialized.test.ts +++ b/src/utils/blockchain/counterparty/__tests__/compose.specialized.test.ts @@ -450,7 +450,11 @@ describe('Compose Specialized Operations', () => { }); it('should compose pool withdraw with LP asset and slippage parameters', async () => { - await composePoolWithdraw({ + mockedApiClient.get.mockResolvedValueOnce(createMockApiResponse({ + result: createMockComposeResult(), + })); + + const result = await composePoolWithdraw({ sourceAddress: mockAddress, sat_per_vbyte: mockSatPerVbyte, quantity: '1000000', @@ -467,6 +471,7 @@ describe('Compose Specialized Operations', () => { expect(url).toContain('lp_asset=A77777777777777777'); expect(url).not.toContain('asset_a='); expect(url).not.toContain('asset_b='); + expect(result.result.params.lp_asset).toBe('A77777777777777777'); }); }); diff --git a/src/utils/blockchain/counterparty/__tests__/normalize.test.ts b/src/utils/blockchain/counterparty/__tests__/normalize.test.ts index c1e30502..b72cacb6 100644 --- a/src/utils/blockchain/counterparty/__tests__/normalize.test.ts +++ b/src/utils/blockchain/counterparty/__tests__/normalize.test.ts @@ -91,6 +91,25 @@ describe('normalize.ts', () => { expect(getComposeType(formData)).toBe('movetoutxo'); }); + it('should detect pool deposit type from pool deposit fields', () => { + const formData = { + asset_a: 'XCP', + asset_b: 'POOLTEST', + quantity_a: '1', + quantity_b: '2', + }; + expect(getComposeType(formData)).toBe('pooldeposit'); + }); + + it('should detect pool withdraw type from LP asset withdraw fields', () => { + const formData = { + lp_asset: 'A77777777777777777', + quantity: '1', + min_quantity_a: '0', + }; + expect(getComposeType(formData)).toBe('poolwithdraw'); + }); + it('should use explicit type mapping from __type field', () => { const formData = { __type: 'SendOptions' }; expect(getComposeType(formData)).toBe('send'); @@ -205,6 +224,65 @@ describe('normalize.ts', () => { expect(result.normalizedData.get_quantity).toBe('100000'); expect(result.assetInfoCache.size).toBe(2); }); + + it('should normalize pool deposit asset quantities', async () => { + const formData = new FormData(); + formData.set('asset_a', 'XCP'); + formData.set('asset_b', 'POOLTEST'); + formData.set('quantity_a', '1.5'); + formData.set('quantity_b', '2'); + formData.set('min_lp_quantity', '0'); + + const mockPoolAsset: AssetInfo = { + asset: 'POOLTEST', + asset_longname: null, + divisible: true, + locked: false, + supply_normalized: '1000000.00000000' + }; + + mockFetchAssetDetails + .mockResolvedValueOnce(mockDivisibleAsset) + .mockResolvedValueOnce(mockPoolAsset); + + mockToSatoshis + .mockReturnValueOnce('150000000') + .mockReturnValueOnce('200000000'); + + const result = await normalizeFormData(formData, 'pooldeposit'); + + expect(mockFetchAssetDetails).toHaveBeenCalledWith('XCP'); + expect(mockFetchAssetDetails).toHaveBeenCalledWith('POOLTEST'); + expect(result.normalizedData.quantity_a).toBe('150000000'); + expect(result.normalizedData.quantity_b).toBe('200000000'); + expect(result.normalizedData.min_lp_quantity).toBe('0'); + }); + + it('should normalize pool withdraw LP quantity', async () => { + const formData = new FormData(); + formData.set('lp_asset', 'A77777777777777777'); + formData.set('quantity', '3.25'); + formData.set('min_quantity_a', '0'); + formData.set('min_quantity_b', '0'); + + const mockLpAsset: AssetInfo = { + asset: 'A77777777777777777', + asset_longname: null, + divisible: true, + locked: false, + supply_normalized: '1000000.00000000' + }; + + mockFetchAssetDetails.mockResolvedValue(mockLpAsset); + mockToSatoshis.mockReturnValue('325000000'); + + const result = await normalizeFormData(formData, 'poolwithdraw'); + + expect(mockFetchAssetDetails).toHaveBeenCalledWith('A77777777777777777'); + expect(result.normalizedData.quantity).toBe('325000000'); + expect(result.normalizedData.min_quantity_a).toBe('0'); + expect(result.normalizedData.min_quantity_b).toBe('0'); + }); }); describe('Indivisible asset normalization', () => { @@ -905,4 +983,4 @@ describe('normalize.ts', () => { }); }); }); -}); \ No newline at end of file +}); diff --git a/src/utils/blockchain/counterparty/__tests__/pool.test.ts b/src/utils/blockchain/counterparty/__tests__/pool.test.ts new file mode 100644 index 00000000..99206ea6 --- /dev/null +++ b/src/utils/blockchain/counterparty/__tests__/pool.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from "vitest"; +import { + applyPoolSlippage, + calculateInitialLpEstimate, + calculateLimitingLpEstimate, + getCanonicalPoolAssets, + getCanonicalPoolPair, +} from "../pool"; + +describe("counterparty pool utilities", () => { + it("formats pool pairs in canonical Counterparty asset order", () => { + expect(getCanonicalPoolAssets("XCP", "PEPECASH")).toEqual(["PEPECASH", "XCP"]); + expect(getCanonicalPoolPair("XCP", "PEPECASH")).toBe("PEPECASH / XCP"); + expect(getCanonicalPoolPair("A111111111111111111", "XCP")).toBe("A111111111111111111 / XCP"); + }); + + it("applies pool slippage to raw integer quantities", () => { + expect(applyPoolSlippage("100000000", "2.5")).toBe("97500000"); + expect(applyPoolSlippage("100", "0.5")).toBe("99"); + expect(applyPoolSlippage(undefined, "1")).toBe("0"); + }); + + it("estimates initial LP supply from the geometric mean", () => { + expect(calculateInitialLpEstimate("100", "400")).toBe("200"); + expect(calculateInitialLpEstimate("0", "400")).toBe("0"); + }); + + it("uses the limiting side when a deposit is below the quoted ratio", () => { + expect(calculateLimitingLpEstimate("1000", "500", "250")).toBe("500"); + expect(calculateLimitingLpEstimate("1000", "500", "500")).toBe("1000"); + expect(calculateLimitingLpEstimate("1000", null, "250")).toBe("1000"); + }); +}); diff --git a/src/utils/blockchain/counterparty/api.ts b/src/utils/blockchain/counterparty/api.ts index f230a5d4..df67c263 100644 --- a/src/utils/blockchain/counterparty/api.ts +++ b/src/utils/blockchain/counterparty/api.ts @@ -260,6 +260,11 @@ export interface Pool { [key: string]: unknown; } +export interface PoolPosition extends Pool { + quantity: number; + quantity_normalized?: string; +} + export interface PoolQuote { estimated_output?: number; pool_output?: number; @@ -860,6 +865,40 @@ export async function fetchPoolWithdrawQuote( return data.result; } +export async function fetchAddressPools( + address: string, + options: PaginationOptions = {} +): Promise> { + return cpApiGet>( + `/v2/addresses/${encodePath(address)}/pools`, + { + verbose: options.verbose ?? true, + limit: options.limit ?? 100, + offset: options.offset ?? 0, + } + ); +} + +export async function fetchAddressPoolByLpAsset( + address: string, + lpAsset: string, + options: PaginationOptions = {} +): Promise { + const limit = options.limit ?? 100; + let offset = options.offset ?? 0; + + while (true) { + const page = await fetchAddressPools(address, { ...options, limit, offset }); + const pool = page.result.find((position) => position.lp_asset === lpAsset); + if (pool) return pool; + + offset += limit; + if (page.result.length < limit || offset >= page.result_count) { + return null; + } + } +} + // ============================================================================= // API - Dispensers // ============================================================================= diff --git a/src/utils/blockchain/counterparty/compose.ts b/src/utils/blockchain/counterparty/compose.ts index 40866648..6c67556e 100644 --- a/src/utils/blockchain/counterparty/compose.ts +++ b/src/utils/blockchain/counterparty/compose.ts @@ -79,6 +79,7 @@ export interface ComposeParams { asset_info: ComposeAssetInfo; quantity_normalized: string; more_outputs?: string; + lp_asset?: string; } export interface ComposeResult { @@ -1080,7 +1081,11 @@ export async function composePoolWithdraw(options: PoolWithdrawOptions): Promise ...(lp_asset && { lp_asset }), ...(max_fee !== undefined && { max_fee: max_fee.toString() }), }; - return composeTransaction('poolwithdraw', paramsObj, sourceAddress, sat_per_vbyte, encoding); + const response = await composeTransaction('poolwithdraw', paramsObj, sourceAddress, sat_per_vbyte, encoding); + if (lp_asset && response.result?.params) { + response.result.params.lp_asset = lp_asset; + } + return response; } export async function getPoolWithdrawEstimateXcpFee(sourceAddress: string): Promise { diff --git a/src/utils/blockchain/counterparty/normalize.ts b/src/utils/blockchain/counterparty/normalize.ts index a315e6f2..ee848430 100644 --- a/src/utils/blockchain/counterparty/normalize.ts +++ b/src/utils/blockchain/counterparty/normalize.ts @@ -132,6 +132,17 @@ const NORMALIZATION_CONFIG: Record): string | undefine 'CancelOptions': 'cancel', 'MPMAOptions': 'mpma', 'MPMAData': 'mpma', + 'PoolDepositOptions': 'pooldeposit', + 'PoolWithdrawOptions': 'poolwithdraw', }; // Try to detect based on presence of specific fields @@ -175,6 +188,8 @@ export function getComposeType(formData: Record): string | undefine if ('quantity' in formData && 'asset_name' in formData) return 'issuance'; if ('destination' in formData && 'asset' in formData) return 'attach'; if ('utxo_address' in formData) return 'movetoutxo'; + if ('asset_a' in formData && 'asset_b' in formData && 'quantity_a' in formData && 'quantity_b' in formData) return 'pooldeposit'; + if ('lp_asset' in formData && 'quantity' in formData && ('min_quantity_a' in formData || 'min_quantity_b' in formData)) return 'poolwithdraw'; // Fallback: check if type is explicitly provided const typeName = formData.__type || formData.type; @@ -324,4 +339,4 @@ export async function normalizeFormData( } return { normalizedData, assetInfoCache }; -} \ No newline at end of file +} diff --git a/src/utils/blockchain/counterparty/pool.ts b/src/utils/blockchain/counterparty/pool.ts new file mode 100644 index 00000000..82708076 --- /dev/null +++ b/src/utils/blockchain/counterparty/pool.ts @@ -0,0 +1,49 @@ +import { BigNumber, toBigNumber } from "@/utils/numeric"; + +export function getCanonicalPoolAssets(assetA: string, assetB: string): [string, string] { + return assetA <= assetB ? [assetA, assetB] : [assetB, assetA]; +} + +export function getCanonicalPoolPair(assetA: string, assetB: string): string { + return getCanonicalPoolAssets(assetA, assetB).join(" / "); +} + +export function applyPoolSlippage(value: number | string | null | undefined, slippagePercent: string): string { + if (value === null || value === undefined) return "0"; + + const bps = toBigNumber(slippagePercent || "0").times(100); + const multiplier = BigNumber.maximum(0, toBigNumber(10000).minus(bps)); + + return toBigNumber(value) + .times(multiplier) + .div(10000) + .integerValue(BigNumber.ROUND_DOWN) + .toString(); +} + +export function calculateInitialLpEstimate(quantityA: string, quantityB: string): string { + const product = toBigNumber(quantityA).times(quantityB); + if (!product.isGreaterThan(0)) return "0"; + return product.sqrt().integerValue(BigNumber.ROUND_DOWN).toString(); +} + +export function calculateLimitingLpEstimate( + mintedEstimate: number | string | null | undefined, + partnerRequired: number | string | null | undefined, + partnerProvided: string +): string { + if (mintedEstimate === null || mintedEstimate === undefined) return "0"; + if (partnerRequired === null || partnerRequired === undefined) return mintedEstimate.toString(); + + const required = toBigNumber(partnerRequired); + const provided = toBigNumber(partnerProvided); + if (!required.isGreaterThan(0) || provided.isGreaterThanOrEqualTo(required)) { + return mintedEstimate.toString(); + } + + return toBigNumber(mintedEstimate) + .times(provided) + .div(required) + .integerValue(BigNumber.ROUND_DOWN) + .toString(); +} diff --git a/src/utils/fathom.ts b/src/utils/fathom.ts index 4db507f3..2dddab8b 100644 --- a/src/utils/fathom.ts +++ b/src/utils/fathom.ts @@ -81,6 +81,7 @@ const SENSITIVE_PATH_PATTERNS: Array<{ prefix: string; sanitized: string }> = [ // ═══════════════════════════════════════════════════════════════════════════ { prefix: '/transactions/', sanitized: '/transactions' }, { prefix: '/assets/utxos/', sanitized: '/assets/utxos' }, + { prefix: '/pools/', sanitized: '/pools' }, // ═══════════════════════════════════════════════════════════════════════════ // ASSET VIEWING (asset names - must come after /assets/utxos/) @@ -120,6 +121,9 @@ const SENSITIVE_PATH_PATTERNS: Array<{ prefix: string; sanitized: string }> = [ { prefix: '/compose/order/btcpay', sanitized: '/compose/order/btcpay' }, // static { prefix: '/compose/order/cancel/', sanitized: '/compose/order/cancel' }, { prefix: '/compose/order/', sanitized: '/compose/order' }, + { prefix: '/compose/pool/deposit/', sanitized: '/compose/pool/deposit' }, + { prefix: '/compose/pool/deposit', sanitized: '/compose/pool/deposit' }, + { prefix: '/compose/pool/withdraw/', sanitized: '/compose/pool/withdraw' }, // ═══════════════════════════════════════════════════════════════════════════ // COMPOSE - UTXO OPERATIONS (asset names, tx hashes) @@ -348,4 +352,4 @@ export const noopAnalytics = { }; // Default export based on context -export default isExtensionContext() ? analytics : noopAnalytics; \ No newline at end of file +export default isExtensionContext() ? analytics : noopAnalytics; diff --git a/src/utils/numeric.ts b/src/utils/numeric.ts index e1b5f6a9..979299cb 100644 --- a/src/utils/numeric.ts +++ b/src/utils/numeric.ts @@ -141,6 +141,14 @@ export const toSatoshis = (value: BigNumber | string | number): string => { return toBigNumber(value).times(1e8).integerValue(BigNumber.ROUND_DOWN).toString(); }; +/** + * Converts a display quantity to the integer quantity expected by Counterparty APIs. + * Divisible assets use satoshi-style 1e8 precision; indivisible assets are whole units. + * + * @param value - The display quantity + * @param isDivisible - Whether the asset is divisible + * @returns Integer quantity as a string + */ /** * Converts satoshis to BTC (divides by 1e8) * @@ -213,6 +221,86 @@ export const isLessThanOrEqualToSatoshis = (value: string | number, threshold: s return toBigNumber(value).isLessThanOrEqualTo(toBigNumber(threshold)); }; +/** + * Checks if a numeric value is finite. + * + * @param value - The value to check + * @returns Boolean indicating if the value is finite + */ +export const isFiniteNumber = (value: string | number | BigNumber | null | undefined): boolean => { + return toBigNumber(value).isFinite(); +}; + +/** + * Checks if a numeric value is equal to another. + * + * @param value - The value to compare + * @param threshold - The threshold to compare against + * @returns Boolean indicating if value is equal to threshold + */ +export const isEqualTo = ( + value: string | number | BigNumber | null | undefined, + threshold: string | number | BigNumber | null | undefined +): boolean => { + return toBigNumber(value).isEqualTo(toBigNumber(threshold)); +}; + +/** + * Checks if a numeric value is less than another. + * + * @param value - The value to compare + * @param threshold - The threshold to compare against + * @returns Boolean indicating if value is less than threshold + */ +export const isLessThan = ( + value: string | number | BigNumber | null | undefined, + threshold: string | number | BigNumber | null | undefined +): boolean => { + return toBigNumber(value).isLessThan(toBigNumber(threshold)); +}; + +/** + * Checks if a numeric value is greater than another. + * + * @param value - The value to compare + * @param threshold - The threshold to compare against + * @returns Boolean indicating if value is greater than threshold + */ +export const isGreaterThan = ( + value: string | number | BigNumber | null | undefined, + threshold: string | number | BigNumber | null | undefined +): boolean => { + return toBigNumber(value).isGreaterThan(toBigNumber(threshold)); +}; + +/** + * Checks if a numeric value is greater than or equal to another. + * + * @param value - The value to compare + * @param threshold - The threshold to compare against + * @returns Boolean indicating if value is greater than or equal to threshold + */ +export const isGreaterThanOrEqualTo = ( + value: string | number | BigNumber | null | undefined, + threshold: string | number | BigNumber | null | undefined +): boolean => { + return toBigNumber(value).isGreaterThanOrEqualTo(toBigNumber(threshold)); +}; + +/** + * Checks if a numeric value is less than or equal to another. + * + * @param value - The value to compare + * @param threshold - The threshold to compare against + * @returns Boolean indicating if value is less than or equal to threshold + */ +export const isLessThanOrEqualTo = ( + value: string | number | BigNumber | null | undefined, + threshold: string | number | BigNumber | null | undefined +): boolean => { + return toBigNumber(value).isLessThanOrEqualTo(toBigNumber(threshold)); +}; + /** * Multiplies a value by another value *