diff --git a/src/components/common/BatchBuyModal.tsx b/src/components/common/BatchBuyModal.tsx index 2ab356f1..3efc9037 100644 --- a/src/components/common/BatchBuyModal.tsx +++ b/src/components/common/BatchBuyModal.tsx @@ -151,8 +151,9 @@ export default function BatchBuyModal({ // Cleanup timers on unmount useEffect(() => { + const timers = debounceTimers.current; return () => { - debounceTimers.current.forEach(t => clearTimeout(t)); + timers.forEach(t => clearTimeout(t)); }; }, []); diff --git a/src/components/common/KeySimulationTool.tsx b/src/components/common/KeySimulationTool.tsx index 626998bc..72958912 100644 --- a/src/components/common/KeySimulationTool.tsx +++ b/src/components/common/KeySimulationTool.tsx @@ -181,155 +181,6 @@ const KeySimulationTool: React.FC = ({ -import React, { useEffect, useRef, useState } from 'react'; -import { courseService } from '@/services/course.service'; -import { - calculatePriceImpact, - formatPriceImpact, -} from '@/utils/priceImpact.utils'; - -export interface KeySimulationToolProps { - /** Key identifier used for GET /keys/:keyId/simulate?quantity=N */ - keyId: string; - /** Current spot price in the same unit as simulated_price (e.g. XLM or stroops) */ - spotPrice: number; - /** Optional initial quantity */ - initialQuantity?: number; -} - -interface SimulateResult { - simulated_price?: number; - simulatedPrice?: number; - spot_price?: number; - spotPrice?: number; -} - -/** - * Key price simulation tool (#887). - * - * Lets the user enter a custom quantity, debounces the input by 300ms, - * fetches GET /keys/:keyId/simulate?quantity=N, computes price impact as - * (simulated_price - spot_price) / spot_price * 100 and displays it. - * - * Loading shows a skeleton, fetch errors show 'Unable to simulate price' - * and hide the impact value. - */ -const KeySimulationTool: React.FC = ({ - keyId, - spotPrice, - initialQuantity = 1, -}) => { - const [quantityInput, setQuantityInput] = useState( - String(initialQuantity) - ); - const [simulatedPrice, setSimulatedPrice] = useState(null); - const [resolvedSpotPrice, setResolvedSpotPrice] = useState(spotPrice); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - const debounceRef = useRef | null>(null); - - // Keep spot price in sync when prop changes - useEffect(() => { - setResolvedSpotPrice(spotPrice); - }, [spotPrice]); - - useEffect(() => { - const quantity = Number(quantityInput); - // Empty or invalid quantity: clear simulation - if (quantityInput.trim() === '' || isNaN(quantity) || quantity <= 0) { - setSimulatedPrice(null); - setError(null); - setLoading(false); - return; - } - - if (debounceRef.current) clearTimeout(debounceRef.current); - - setLoading(true); - setError(null); - - debounceRef.current = setTimeout(async () => { - try { - const result: SimulateResult = - await courseService.simulateBuy(keyId, quantity); - // Support both snake_case and camelCase shapes - const sim = - result.simulated_price ?? result.simulatedPrice ?? null; - const spot = - result.spot_price ?? result.spotPrice ?? spotPrice; - if (sim != null) { - setSimulatedPrice(sim); - if (spot != null) setResolvedSpotPrice(spot); - setError(null); - } else { - setSimulatedPrice(null); - } - } catch { - setError('Unable to simulate price'); - setSimulatedPrice(null); - } finally { - setLoading(false); - } - }, 300); - - return () => { - if (debounceRef.current) clearTimeout(debounceRef.current); - }; - }, [quantityInput, keyId, spotPrice]); - - const impact = - simulatedPrice != null - ? calculatePriceImpact(simulatedPrice, resolvedSpotPrice) - : null; - - return ( -
-
- - setQuantityInput(e.target.value)} - className="w-full rounded-md border border-white/10 bg-white/[0.04] px-3 py-2 text-sm text-white placeholder:text-white/30 outline-none" - placeholder="Enter quantity" - /> -
- - {loading && ( -
- )} - - {!loading && error && ( -

- {error} -

- )} - - {!loading && !error && impact != null && ( -

- {formatPriceImpact(impact)} -

)}
); diff --git a/src/components/common/SlippageToleranceSelector.tsx b/src/components/common/SlippageToleranceSelector.tsx index f249bbf1..0f02e119 100644 --- a/src/components/common/SlippageToleranceSelector.tsx +++ b/src/components/common/SlippageToleranceSelector.tsx @@ -4,20 +4,13 @@ import { SLIPPAGE_TOLERANCE_PRESETS, SLIPPAGE_TOLERANCE_BOUNDS, validateSlippageTolerancePercent, -} from '@/utils/slippageTolerance.utils'; - -export interface SlippageToleranceSelectorProps { - /** Currently selected tolerance, as a percentage (e.g. 1 = 1%). */ - value: number; - onChange: (percent: number) => void; - disabled?: boolean; computeSlippagePriceBounds, validateSlippageTolerance, - SLIPPAGE_TOLERANCE_PRESETS, type TradeSide, } from '@/utils/slippageTolerance.utils'; -export interface SlippageToleranceSelectorProps { +/** New (#877) prop interface — self-contained with preview price and side. */ +export interface SlippageToleranceSelectorNewProps { /** The quoted/preview price the tolerance is applied against. */ previewPrice: number; /** Whether this trade is a buy (computes max_price) or sell (min_price). */ @@ -37,17 +30,50 @@ export interface SlippageToleranceSelectorProps { className?: string; } +/** Legacy (#872) prop interface — controlled value managed by the parent. */ +export interface SlippageToleranceSelectorLegacyProps { + /** Currently selected tolerance, as a percentage (e.g. 1 = 1%). */ + value: number; + onChange: (percent: number) => void; + disabled?: boolean; + className?: string; +} + +export type SlippageToleranceSelectorProps = + | SlippageToleranceSelectorNewProps + | SlippageToleranceSelectorLegacyProps; + +function isNewProps( + props: SlippageToleranceSelectorProps +): props is SlippageToleranceSelectorNewProps { + return 'previewPrice' in props && 'side' in props; +} + /** - * Preset + custom slippage tolerance picker used by the buy/sell trade - * dialogs (#872). Presets are 0.5% / 1% / 5%; a custom input accepts any - * value in [0, 50]. Selecting a preset clears any custom-input error state. + * Slippage tolerance selector — issue #872 / #877 trade flow. + * + * Supports two prop interfaces: + * - **New (#877)**: self-contained with `previewPrice`/`side`, shows + * XLM-denominated bounds and a confirm button. + * - **Legacy (#872)**: controlled via `value`/`onChange`/`disabled`, + * used by TradeDialog. */ -const SlippageToleranceSelector: React.FC = ({ - value, - onChange, - disabled = false, - className, -}) => { +const SlippageToleranceSelector: React.FC = ( + props +) => { + if (isNewProps(props)) { + return ; + } + return ; +}; + +// --------------------------------------------------------------------------- +// Legacy (#872) — controlled value/onChange, preset buttons + custom input +// --------------------------------------------------------------------------- + +const SlippageToleranceSelectorLegacy: React.FC< + SlippageToleranceSelectorLegacyProps +> = ({ value, onChange, disabled = false, className }) => { const isPresetSelected = ( SLIPPAGE_TOLERANCE_PRESETS as readonly number[] ).includes(value); @@ -79,63 +105,6 @@ const SlippageToleranceSelector: React.FC = ({ const parsed = Number(normalized); if (validateSlippageTolerancePercent(parsed) === null) { onChange(parsed); - * Slippage tolerance selector — issue #877 / #784 trade flow. - * - * Lets the user pick a preset tolerance (0.5% / 1% / 5%) or enter a custom - * percentage, and displays the resulting max_price (buy) / min_price (sell) - * bound. A custom tolerance above 50% is rejected with a validation error - * and disables the confirm action. - */ -const SlippageToleranceSelector: React.FC = ({ - previewPrice, - side, - onToleranceChange, - onValidityChange, - onConfirm, - className, -}) => { - const [selectedPreset, setSelectedPreset] = useState( - SLIPPAGE_TOLERANCE_PRESETS[0] - ); - const [customValue, setCustomValue] = useState(''); - const [isCustom, setIsCustom] = useState(false); - - const activeToleranceText = isCustom - ? customValue - : String(selectedPreset ?? ''); - const parsedTolerance = activeToleranceText.trim() - ? Number(activeToleranceText) - : NaN; - - const validation = useMemo( - () => validateSlippageTolerance(parsedTolerance), - [parsedTolerance] - ); - - const bounds = useMemo(() => { - if (!validation.valid) return { maxPrice: null, minPrice: null }; - return computeSlippagePriceBounds(previewPrice, parsedTolerance, side); - }, [validation.valid, previewPrice, parsedTolerance, side]); - - const canConfirm = validation.valid; - - const selectPreset = (preset: number) => { - setIsCustom(false); - setSelectedPreset(preset); - onToleranceChange?.(preset); - onValidityChange?.(true); - }; - - const handleCustomChange = (rawValue: string) => { - setIsCustom(true); - setSelectedPreset(null); - setCustomValue(rawValue); - - const parsed = rawValue.trim() ? Number(rawValue) : NaN; - const result = validateSlippageTolerance(parsed); - onValidityChange?.(result.valid); - if (result.valid) { - onToleranceChange?.(parsed); } }; @@ -209,6 +178,63 @@ const SlippageToleranceSelector: React.FC = ({ {SLIPPAGE_TOLERANCE_BOUNDS.MAX_PERCENT}%. The trade will revert if the price moves beyond your tolerance before it executes.

+
+ ); +}; + +// --------------------------------------------------------------------------- +// New (#877) — self-contained with preview price, shows XLM bounds + confirm +// --------------------------------------------------------------------------- + +const SlippageToleranceSelectorNew: React.FC< + SlippageToleranceSelectorNewProps +> = ({ previewPrice, side, onToleranceChange, onValidityChange, onConfirm, className }) => { + const [selectedPreset, setSelectedPreset] = useState( + SLIPPAGE_TOLERANCE_PRESETS[0] + ); + const [customValue, setCustomValue] = useState(''); + const [isCustom, setIsCustom] = useState(false); + + const activeToleranceText = isCustom + ? customValue + : String(selectedPreset ?? ''); + const parsedTolerance = activeToleranceText.trim() + ? Number(activeToleranceText) + : NaN; + + const validation = useMemo( + () => validateSlippageTolerance(parsedTolerance), + [parsedTolerance] + ); + + const bounds = useMemo(() => { + if (!validation.valid) return { maxPrice: null, minPrice: null }; + return computeSlippagePriceBounds(previewPrice, parsedTolerance, side); + }, [validation.valid, previewPrice, parsedTolerance, side]); + + const canConfirm = validation.valid; + + const selectPreset = (preset: number) => { + setIsCustom(false); + setSelectedPreset(preset); + onToleranceChange?.(preset); + onValidityChange?.(true); + }; + + const handleCustomChange = (rawValue: string) => { + setIsCustom(true); + setSelectedPreset(null); + setCustomValue(rawValue); + + const parsed = rawValue.trim() ? Number(rawValue) : NaN; + const result = validateSlippageTolerance(parsed); + onValidityChange?.(result.valid); + if (result.valid) { + onToleranceChange?.(parsed); + } + }; + + return (
Slippage tolerance
diff --git a/src/components/common/__tests__/SlippageToleranceSelector.test.tsx b/src/components/common/__tests__/SlippageToleranceSelector.test.tsx index 2e0cd20b..37dba8e6 100644 --- a/src/components/common/__tests__/SlippageToleranceSelector.test.tsx +++ b/src/components/common/__tests__/SlippageToleranceSelector.test.tsx @@ -1,93 +1,4 @@ import { describe, expect, it, vi } from 'vitest'; -import { render, screen, fireEvent } from '@testing-library/react'; -import SlippageToleranceSelector from '@/components/common/SlippageToleranceSelector'; - -describe('SlippageToleranceSelector', () => { - function renderSelector( - overrides: Partial< - React.ComponentProps - > = {} - ) { - const onChange = vi.fn(); - const utils = render( - - ); - return { onChange, ...utils }; - } - - it('renders the 0.5% / 1% / 5% presets', () => { - renderSelector(); - expect(screen.getByTestId('slippage-preset-0.5')).toBeInTheDocument(); - expect(screen.getByTestId('slippage-preset-1')).toBeInTheDocument(); - expect(screen.getByTestId('slippage-preset-5')).toBeInTheDocument(); - }); - - it('marks the currently selected preset as pressed', () => { - renderSelector({ value: 5 }); - expect(screen.getByTestId('slippage-preset-5')).toHaveAttribute( - 'aria-pressed', - 'true' - ); - expect(screen.getByTestId('slippage-preset-1')).toHaveAttribute( - 'aria-pressed', - 'false' - ); - }); - - it('calls onChange with the preset value when clicked', () => { - const { onChange } = renderSelector({ value: 1 }); - fireEvent.click(screen.getByTestId('slippage-preset-5')); - expect(onChange).toHaveBeenCalledWith(5); - }); - - it('displays the currently selected value', () => { - renderSelector({ value: 0.5 }); - expect(screen.getByTestId('slippage-tolerance-current-value')).toHaveTextContent( - '0.5%' - ); - }); - - it('calls onChange with a valid custom value', () => { - const { onChange } = renderSelector(); - const input = screen.getByTestId('slippage-custom-input'); - fireEvent.change(input, { target: { value: '2.5' } }); - expect(onChange).toHaveBeenCalledWith(2.5); - }); - - it('shows a validation error for a negative custom value and does not call onChange', () => { - const { onChange } = renderSelector(); - const input = screen.getByTestId('slippage-custom-input'); - fireEvent.change(input, { target: { value: '-1' } }); - expect(screen.getByTestId('slippage-custom-error')).toHaveTextContent( - /cannot be negative/i - ); - expect(onChange).not.toHaveBeenCalled(); - }); - - it('shows a validation error for a custom value above 50 and does not call onChange', () => { - const { onChange } = renderSelector(); - const input = screen.getByTestId('slippage-custom-input'); - fireEvent.change(input, { target: { value: '75' } }); - expect(screen.getByTestId('slippage-custom-error')).toHaveTextContent( - /cannot exceed 50%/i - ); - expect(onChange).not.toHaveBeenCalled(); - }); - - it('disables presets and custom input when disabled', () => { - renderSelector({ disabled: true }); - expect(screen.getByTestId('slippage-preset-1')).toBeDisabled(); - expect(screen.getByTestId('slippage-custom-input')).toBeDisabled(); - }); - - it('clears custom input state when a preset is clicked after typing a custom value', () => { - renderSelector(); - const input = screen.getByTestId( - 'slippage-custom-input' - ) as HTMLInputElement; - fireEvent.change(input, { target: { value: '3' } }); - fireEvent.click(screen.getByTestId('slippage-preset-0.5')); - expect(input).toHaveValue(''); import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import React from 'react'; diff --git a/src/pages/ProfilePage.tsx b/src/pages/ProfilePage.tsx index c69b6d64..7b613908 100644 --- a/src/pages/ProfilePage.tsx +++ b/src/pages/ProfilePage.tsx @@ -1,9 +1,12 @@ -import { useState } from 'react'; -import { BarChart2, Clock } from 'lucide-react'; +import { useCallback, useState } from 'react'; +import { BarChart2, Clock, Download, Loader2 } from 'lucide-react'; +import { Button } from '@/components/ui/button'; import ReferralLinkPanel from '@/components/common/ReferralLinkPanel'; import TradeHistoryTable from '@/components/common/TradeHistoryTable'; import { ProfileTabPillGroup } from '@/components/common/ProfileTabPill'; import { useProfileStore } from '@/hooks/useProfileStore'; +import { fetchAllTrades } from '@/services/tradeHistory.service'; +import type { Trade } from '@/services/tradeHistory.service'; import { useDocumentTitle } from '@/hooks/useDocumentTitle'; const TABS = [ @@ -23,9 +26,65 @@ const keys = [ { id: 'gamma', label: 'Gamma Key' }, ]; +function escapeCsvField(value: string | number): string { + const str = String(value); + if (str.includes(',') || str.includes('"') || str.includes('\n')) { + return `"${str.replace(/"/g, '""')}"`; + } + return str; +} + +function tradesToCsv(trades: Trade[]): string { + const header = 'Date,Key Name,Type,Quantity,Price per Key,Total,Fee'; + const rows = trades.map(trade => { + const date = new Date(trade.timestamp).toISOString(); + const total = (trade.quantity * trade.pricePerKey).toFixed(4); + return [ + escapeCsvField(date), + escapeCsvField(trade.keyName), + escapeCsvField(trade.tradeType), + trade.quantity, + trade.pricePerKey.toFixed(4), + total, + trade.fee.toFixed(4), + ].join(','); + }); + return [header, ...rows].join('\n'); +} + +function downloadCsv(csvContent: string, filename: string): void { + const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' }); + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = filename; + link.style.display = 'none'; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(url); +} + export default function ProfilePage() { const profile = useProfileStore(state => state.profile); const [activeTab, setActiveTab] = useState('holdings'); + const [isExporting, setIsExporting] = useState(false); + + const handleExportCsv = useCallback(async () => { + setIsExporting(true); + try { + const trades = await fetchAllTrades(DEMO_WALLET); + const csv = tradesToCsv(trades); + const truncated = DEMO_WALLET.slice(0, 6); + const date = new Date().toISOString().split('T')[0]; + const filename = `trades-${truncated}-${date}.csv`; + downloadCsv(csv, filename); + } catch (error) { + console.error('[csv-export]', error); + } finally { + setIsExporting(false); + } + }, []); useDocumentTitle('My Portfolio — AccessLayer'); return ( @@ -67,13 +126,40 @@ export default function ProfilePage() { data-testid="portfolio-trade-history-panel" >
-
-

- Trade History -

-

- A full audit trail of your past buys and sells -

+
+
+

+ Trade History +

+

+ A full audit trail of your past buys and sells +

+
+
diff --git a/src/services/tradeHistory.service.ts b/src/services/tradeHistory.service.ts index 3be3da9b..5881ce4a 100644 --- a/src/services/tradeHistory.service.ts +++ b/src/services/tradeHistory.service.ts @@ -22,6 +22,8 @@ export interface Trade { pricePerKey: number; /** Unix timestamp (milliseconds) of when the trade settled. */ timestamp: number; + /** Platform fee in XLM charged for this trade. */ + fee: number; /** * On-chain transaction hash for this trade, or `null` when no hash is * available. Powers the copy-hash and block-explorer actions on each @@ -97,3 +99,24 @@ export async function fetchTradeHistoryPage( ): Promise { return tradeHistoryService.getTradeHistory({ wallet, cursor }); } + +/** + * Fetches all trade history pages for a wallet by paginating through + * cursor-based results until there are no more pages. + */ +export async function fetchAllTrades(wallet: string): Promise { + const allTrades: Trade[] = []; + let cursor: string | null | undefined = undefined; + + do { + const page = await tradeHistoryService.getTradeHistory({ + wallet, + cursor, + limit: 100, + }); + allTrades.push(...page.trades); + cursor = page.nextCursor; + } while (cursor); + + return allTrades; +} diff --git a/src/utils/__tests__/slippageTolerance.utils.test.ts b/src/utils/__tests__/slippageTolerance.utils.test.ts index 6ae36f0c..063cbf38 100644 --- a/src/utils/__tests__/slippageTolerance.utils.test.ts +++ b/src/utils/__tests__/slippageTolerance.utils.test.ts @@ -1,128 +1,3 @@ -/** - * Unit tests for slippage tolerance utilities (#872). - */ - -import { describe, expect, it } from 'vitest'; -import { - SLIPPAGE_TOLERANCE_PRESETS, - SLIPPAGE_TOLERANCE_BOUNDS, - DEFAULT_SLIPPAGE_TOLERANCE_PERCENT, - validateSlippageTolerancePercent, - computeMaxPriceStroops, - computeMinPriceStroops, - computeSlippageBounds, -} from '../slippageTolerance.utils'; - -describe('slippageTolerance.utils', () => { - describe('presets and defaults', () => { - it('exposes the 0.5% / 1% / 5% presets', () => { - expect(SLIPPAGE_TOLERANCE_PRESETS).toEqual([0.5, 1, 5]); - }); - - it('defaults to 1%', () => { - expect(DEFAULT_SLIPPAGE_TOLERANCE_PERCENT).toBe(1); - }); - - it('bounds tolerance between 0% and 50%', () => { - expect(SLIPPAGE_TOLERANCE_BOUNDS.MIN_PERCENT).toBe(0); - expect(SLIPPAGE_TOLERANCE_BOUNDS.MAX_PERCENT).toBe(50); - }); - }); - - describe('validateSlippageTolerancePercent', () => { - it('accepts values within [0, 50]', () => { - expect(validateSlippageTolerancePercent(0)).toBeNull(); - expect(validateSlippageTolerancePercent(1.5)).toBeNull(); - expect(validateSlippageTolerancePercent(50)).toBeNull(); - }); - - it('rejects negative values', () => { - expect(validateSlippageTolerancePercent(-1)).toMatch(/cannot be negative/i); - }); - - it('rejects values above 50', () => { - expect(validateSlippageTolerancePercent(50.1)).toMatch(/cannot exceed 50%/i); - }); - - it('rejects null/undefined/NaN', () => { - expect(validateSlippageTolerancePercent(null)).toMatch(/valid/i); - expect(validateSlippageTolerancePercent(undefined)).toMatch(/valid/i); - expect(validateSlippageTolerancePercent(NaN)).toMatch(/valid/i); - }); - }); - - describe('computeMaxPriceStroops', () => { - it('computes preview_price * (1 + tolerance) for a buy', () => { - // 1_000_000 * (1 + 0.01) = 1_010_000 - expect(computeMaxPriceStroops(1_000_000, 1)).toBe(1_010_000); - }); - - it('handles 0% tolerance (max == preview price)', () => { - expect(computeMaxPriceStroops(1_000_000, 0)).toBe(1_000_000); - }); - - it('handles the 5% preset', () => { - expect(computeMaxPriceStroops(2_000_000, 5)).toBe(2_100_000); - }); - - it('returns null when preview price is null/undefined', () => { - expect(computeMaxPriceStroops(null, 1)).toBeNull(); - expect(computeMaxPriceStroops(undefined, 1)).toBeNull(); - }); - - it('returns null when preview price is negative or non-finite', () => { - expect(computeMaxPriceStroops(-100, 1)).toBeNull(); - expect(computeMaxPriceStroops(NaN, 1)).toBeNull(); - }); - - it('rounds to the nearest stroop', () => { - expect(computeMaxPriceStroops(3, 1)).toBe(Math.round(3 * 1.01)); - }); - }); - - describe('computeMinPriceStroops', () => { - it('computes preview_price * (1 - tolerance) for a sell', () => { - // 1_000_000 * (1 - 0.01) = 990_000 - expect(computeMinPriceStroops(1_000_000, 1)).toBe(990_000); - }); - - it('handles 0% tolerance (min == preview price)', () => { - expect(computeMinPriceStroops(1_000_000, 0)).toBe(1_000_000); - }); - - it('floors at 0 when tolerance is 100%+ (never returns a negative price)', () => { - expect(computeMinPriceStroops(1_000_000, 100)).toBe(0); - }); - - it('returns null when preview price is null/undefined', () => { - expect(computeMinPriceStroops(null, 1)).toBeNull(); - expect(computeMinPriceStroops(undefined, 1)).toBeNull(); - }); - - it('returns null when preview price is negative or non-finite', () => { - expect(computeMinPriceStroops(-100, 1)).toBeNull(); - expect(computeMinPriceStroops(NaN, 1)).toBeNull(); - }); - }); - - describe('computeSlippageBounds', () => { - it('only populates maxPriceStroops for a buy', () => { - const bounds = computeSlippageBounds('buy', 1_000_000, 1); - expect(bounds.maxPriceStroops).toBe(1_010_000); - expect(bounds.minPriceStroops).toBeNull(); - expect(bounds.toleranceZPercent).toBe(1); - }); - - it('only populates minPriceStroops for a sell', () => { - const bounds = computeSlippageBounds('sell', 1_000_000, 1); - expect(bounds.minPriceStroops).toBe(990_000); - expect(bounds.maxPriceStroops).toBeNull(); - }); - - it('propagates null bounds when the reference price is unavailable', () => { - expect(computeSlippageBounds('buy', null, 1).maxPriceStroops).toBeNull(); - expect(computeSlippageBounds('sell', undefined, 1).minPriceStroops).toBeNull(); - }); import { describe, expect, it } from 'vitest'; import { computeSlippagePriceBounds, diff --git a/src/utils/slippageTolerance.utils.ts b/src/utils/slippageTolerance.utils.ts index 6b29ec3e..0e6f35ad 100644 --- a/src/utils/slippageTolerance.utils.ts +++ b/src/utils/slippageTolerance.utils.ts @@ -1,5 +1,5 @@ /** - * Slippage tolerance utilities for buy/sell trades (#872). + * Slippage tolerance utilities for buy/sell trades (#872, #877). * * Computes the on-chain `max_price` (buy) / `min_price` (sell) bounds from a * preview price and a selected tolerance percentage, so the contract call @@ -18,6 +18,18 @@ export const SLIPPAGE_TOLERANCE_BOUNDS = { MAX_PERCENT: 50, } as const; +/** Tolerances above this percentage are rejected as invalid. */ +export const MAX_SLIPPAGE_TOLERANCE_PERCENT = 50; + +/** Tolerances below this percentage are rejected as invalid. */ +export const MIN_SLIPPAGE_TOLERANCE_PERCENT = 0; + +export type TradeSide = 'buy' | 'sell'; + +// --------------------------------------------------------------------------- +// Legacy stroops-based helpers (#872) — used by TradeDialog +// --------------------------------------------------------------------------- + /** * Validates a custom slippage tolerance input (percentage, e.g. 1.5 = 1.5%). * Returns an error message when invalid, or `null` when the value is usable. @@ -114,24 +126,11 @@ export function computeSlippageBounds( ? computeMinPriceStroops(previewPriceStroops, toleranceZPercent) : null, }; - * Slippage tolerance selector logic — issue #877. - * - * A trade preview's `max_price` (for buys) or `min_price` (for sells) is - * the preview price adjusted by the user's selected slippage tolerance: - * buys accept paying up to `tolerance%` more than the preview price, sells - * accept receiving up to `tolerance%` less. - */ - -/** Preset tolerance options shown in the slippage selector, in percent. */ -export const SLIPPAGE_TOLERANCE_PRESETS = [0.5, 1, 5] as const; - -/** Tolerances above this percentage are rejected as invalid. */ -export const MAX_SLIPPAGE_TOLERANCE_PERCENT = 50; - -/** Tolerances below this percentage are rejected as invalid. */ -export const MIN_SLIPPAGE_TOLERANCE_PERCENT = 0; +} -export type TradeSide = 'buy' | 'sell'; +// --------------------------------------------------------------------------- +// XLM-based helpers (#877) — used by the standalone selector & tests +// --------------------------------------------------------------------------- export interface SlippagePriceBounds { /**