From a09c1ff089907271aeb28f1d55e915df6707cff4 Mon Sep 17 00:00:00 2001 From: Risktaker001 <268169772+Risktaker001@users.noreply.github.com> Date: Mon, 31 Aug 2026 11:30:37 +0000 Subject: [PATCH 1/3] Update 2 files\n\nCo-authored-by: Freebuff Agent --- src/pages/ProfilePage.tsx | 107 ++++++++++++++++++++++++--- src/services/tradeHistory.service.ts | 23 ++++++ 2 files changed, 120 insertions(+), 10 deletions(-) diff --git a/src/pages/ProfilePage.tsx b/src/pages/ProfilePage.tsx index f54e39aa..e4ea36f9 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'; const TABS = [ { label: 'Holdings', value: 'holdings', icon: }, @@ -11,7 +14,8 @@ const TABS = [ ]; // Mock wallet address – in a real app this would come from the wallet provider. -const DEMO_WALLET = 'GDEMOWALLET0000000000000000000000000000000000000000000000001'; +const DEMO_WALLET = + 'GDEMOWALLET0000000000000000000000000000000000000000000000001'; // For demo purposes, generate some mock keys. In a real app these would // come from the backend (the user's keys / most traded key etc.). @@ -21,9 +25,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); + } + }, []); return (
@@ -64,13 +124,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 fe0761e7..657aa33d 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; } /** Cursor-paginated response envelope for the trade history endpoint. */ @@ -91,3 +93,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; +} From bacc0f0695fe6687c8fc33e95d7670123deee21d Mon Sep 17 00:00:00 2001 From: Risktaker001 <268169772+Risktaker001@users.noreply.github.com> Date: Tue, 1 Sep 2026 20:51:18 +0000 Subject: [PATCH 2/3] fix: remove duplicated code in 5 files causing eslint parse errors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four source files had two versions of the same code concatenated together (likely from a bad merge), producing parse errors in eslint: KeySimulationTool.tsx, SlippageToleranceSelector.tsx, slippageTolerance.utils.ts, and their test files. Kept the newer (#875/#877) versions that match the test expectations. Also fixed react-hooks/exhaustive-deps warning in BatchBuyModal by copying debounceTimers.current to a local variable in the cleanup effect. πŸ€– Generated with Codebuff Co-Authored-By: Codebuff --- src/components/common/BatchBuyModal.tsx | 3 +- src/components/common/KeySimulationTool.tsx | 149 ------------------ .../common/SlippageToleranceSelector.tsx | 120 -------------- .../SlippageToleranceSelector.test.tsx | 89 ----------- .../__tests__/slippageTolerance.utils.test.ts | 125 --------------- src/utils/slippageTolerance.utils.ts | 115 -------------- 6 files changed, 2 insertions(+), 599 deletions(-) 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..6e56b9e9 100644 --- a/src/components/common/SlippageToleranceSelector.tsx +++ b/src/components/common/SlippageToleranceSelector.tsx @@ -2,18 +2,8 @@ import { useMemo, useState } from 'react'; import { cn } from '@/lib/utils'; 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'; @@ -38,47 +28,6 @@ export interface SlippageToleranceSelectorProps { } /** - * 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. - */ -const SlippageToleranceSelector: React.FC = ({ - value, - onChange, - disabled = false, - className, -}) => { - const isPresetSelected = ( - SLIPPAGE_TOLERANCE_PRESETS as readonly number[] - ).includes(value); - const [customText, setCustomText] = useState( - isPresetSelected ? '' : String(value) - ); - const [customActive, setCustomActive] = useState(!isPresetSelected); - - const customError = useMemo(() => { - if (!customActive) return null; - const normalized = customText.trim(); - if (!normalized) return null; - return validateSlippageTolerancePercent(Number(normalized)); - }, [customActive, customText]); - - const handlePresetClick = (preset: number) => { - setCustomActive(false); - setCustomText(''); - onChange(preset); - }; - - const handleCustomChange = (text: string) => { - setCustomActive(true); - setCustomText(text); - - const normalized = text.trim(); - if (!normalized) return; - - 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 @@ -140,75 +89,6 @@ const SlippageToleranceSelector: React.FC = ({ }; return ( -
-
- Slippage tolerance - - {value}% - -
-
- {SLIPPAGE_TOLERANCE_PRESETS.map(preset => { - const selected = !customActive && value === preset; - return ( - - ); - })} -
- handleCustomChange(event.target.value)} - onFocus={() => setCustomActive(true)} - aria-label="Custom slippage tolerance percentage" - aria-invalid={customError != null || undefined} - data-testid="slippage-custom-input" - className={cn( - 'w-20 rounded-lg border bg-white/[0.04] px-2 py-1.5 text-xs text-white outline-none transition-colors', - customActive - ? 'border-amber-500/60 ring-2 ring-amber-500/15' - : 'border-white/10', - customError ? 'border-red-500/60' : '' - )} - /> - % -
-
- {customError && ( -

- {customError} -

- )} -

- Between {SLIPPAGE_TOLERANCE_BOUNDS.MIN_PERCENT}% and{' '} - {SLIPPAGE_TOLERANCE_BOUNDS.MAX_PERCENT}%. The trade will revert if the - price moves beyond your tolerance before it executes. -

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/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..45a4f389 100644 --- a/src/utils/slippageTolerance.utils.ts +++ b/src/utils/slippageTolerance.utils.ts @@ -1,119 +1,4 @@ /** - * Slippage tolerance utilities for buy/sell trades (#872). - * - * Computes the on-chain `max_price` (buy) / `min_price` (sell) bounds from a - * preview price and a selected tolerance percentage, so the contract call - * rejects the trade if the executed price moves against the user by more - * than the tolerance allows. - */ - -/** Preset slippage tolerance percentages surfaced in the selector UI. */ -export const SLIPPAGE_TOLERANCE_PRESETS = [0.5, 1, 5] as const; - -/** Default tolerance applied when the user has not made a selection. */ -export const DEFAULT_SLIPPAGE_TOLERANCE_PERCENT = 1; - -export const SLIPPAGE_TOLERANCE_BOUNDS = { - MIN_PERCENT: 0, - MAX_PERCENT: 50, -} as const; - -/** - * 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. - */ -export function validateSlippageTolerancePercent( - value: number | null | undefined -): string | null { - if (value == null || !Number.isFinite(value)) { - return 'Enter a valid slippage tolerance.'; - } - if (value < SLIPPAGE_TOLERANCE_BOUNDS.MIN_PERCENT) { - return 'Slippage tolerance cannot be negative.'; - } - if (value > SLIPPAGE_TOLERANCE_BOUNDS.MAX_PERCENT) { - return `Slippage tolerance cannot exceed ${SLIPPAGE_TOLERANCE_BOUNDS.MAX_PERCENT}%.`; - } - return null; -} - -/** - * Computes the maximum acceptable price (in stroops) for a buy transaction - * given a preview price and a tolerance percentage. - * - * `max_price = preview_price * (1 + tolerance)` - */ -export function computeMaxPriceStroops( - previewPriceStroops: number | null | undefined, - toleranceZPercent: number -): number | null { - if ( - previewPriceStroops == null || - !Number.isFinite(previewPriceStroops) || - previewPriceStroops < 0 || - !Number.isFinite(toleranceZPercent) - ) { - return null; - } - - const toleranceFraction = toleranceZPercent / 100; - return Math.round(previewPriceStroops * (1 + toleranceFraction)); -} - -/** - * Computes the minimum acceptable price (in stroops) for a sell transaction - * given a preview price and a tolerance percentage. - * - * `min_price = preview_price * (1 - tolerance)`, floored at 0. - */ -export function computeMinPriceStroops( - previewPriceStroops: number | null | undefined, - toleranceZPercent: number -): number | null { - if ( - previewPriceStroops == null || - !Number.isFinite(previewPriceStroops) || - previewPriceStroops < 0 || - !Number.isFinite(toleranceZPercent) - ) { - return null; - } - - const toleranceFraction = toleranceZPercent / 100; - const minPrice = previewPriceStroops * (1 - toleranceFraction); - return Math.max(0, Math.round(minPrice)); -} - -export interface SlippageBounds { - /** Selected tolerance, as a percentage (e.g. 1 = 1%). */ - toleranceZPercent: number; - /** `max_price` in stroops to pass to the buy contract call. */ - maxPriceStroops: number | null; - /** `min_price` in stroops to pass to the sell contract call. */ - minPriceStroops: number | null; -} - -/** - * Computes both bounds for a given side; only the bound relevant to the - * trade side is populated (the other is `null`), matching how buy/sell - * contract calls only ever need one of `max_price`/`min_price`. - */ -export function computeSlippageBounds( - side: 'buy' | 'sell', - previewPriceStroops: number | null | undefined, - toleranceZPercent: number -): SlippageBounds { - return { - toleranceZPercent, - maxPriceStroops: - side === 'buy' - ? computeMaxPriceStroops(previewPriceStroops, toleranceZPercent) - : null, - minPriceStroops: - side === 'sell' - ? computeMinPriceStroops(previewPriceStroops, toleranceZPercent) - : null, - }; * Slippage tolerance selector logic β€” issue #877. * * A trade preview's `max_price` (for buys) or `min_price` (for sells) is From 2de7d5a5c5a4dc7a6b6c820a07f08969be7cb26d Mon Sep 17 00:00:00 2001 From: Risktaker001 <268169772+Risktaker001@users.noreply.github.com> Date: Tue, 1 Sep 2026 21:01:35 +0000 Subject: [PATCH 3/3] fix: restore old slippageTolerance exports and support legacy selector props MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TradeDialog and LandingPage import DEFAULT_SLIPPAGE_TOLERANCE_PERCENT, SlippageBounds, and computeSlippageBounds from the old (#872) API, and render SlippageToleranceSelector with value/onChange/disabled props. Added those exports back alongside the new (#877) ones and made the selector component accept both prop interfaces via a discriminated union. πŸ€– Generated with Codebuff Co-Authored-By: Codebuff --- .../common/SlippageToleranceSelector.tsx | 174 ++++++++++++++++-- src/utils/slippageTolerance.utils.ts | 126 ++++++++++++- 2 files changed, 280 insertions(+), 20 deletions(-) diff --git a/src/components/common/SlippageToleranceSelector.tsx b/src/components/common/SlippageToleranceSelector.tsx index 6e56b9e9..0f02e119 100644 --- a/src/components/common/SlippageToleranceSelector.tsx +++ b/src/components/common/SlippageToleranceSelector.tsx @@ -2,12 +2,15 @@ import { useMemo, useState } from 'react'; import { cn } from '@/lib/utils'; import { SLIPPAGE_TOLERANCE_PRESETS, + SLIPPAGE_TOLERANCE_BOUNDS, + validateSlippageTolerancePercent, computeSlippagePriceBounds, validateSlippageTolerance, 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). */ @@ -27,22 +30,165 @@ 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; +} + /** - * Slippage tolerance selector β€” issue #877 / #784 trade flow. + * Slippage tolerance selector β€” issue #872 / #877 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. + * 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 = ({ - previewPrice, - side, - onToleranceChange, - onValidityChange, - onConfirm, - 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); + const [customText, setCustomText] = useState( + isPresetSelected ? '' : String(value) + ); + const [customActive, setCustomActive] = useState(!isPresetSelected); + + const customError = useMemo(() => { + if (!customActive) return null; + const normalized = customText.trim(); + if (!normalized) return null; + return validateSlippageTolerancePercent(Number(normalized)); + }, [customActive, customText]); + + const handlePresetClick = (preset: number) => { + setCustomActive(false); + setCustomText(''); + onChange(preset); + }; + + const handleCustomChange = (text: string) => { + setCustomActive(true); + setCustomText(text); + + const normalized = text.trim(); + if (!normalized) return; + + const parsed = Number(normalized); + if (validateSlippageTolerancePercent(parsed) === null) { + onChange(parsed); + } + }; + + return ( +
+
+ Slippage tolerance + + {value}% + +
+
+ {SLIPPAGE_TOLERANCE_PRESETS.map(preset => { + const selected = !customActive && value === preset; + return ( + + ); + })} +
+ handleCustomChange(event.target.value)} + onFocus={() => setCustomActive(true)} + aria-label="Custom slippage tolerance percentage" + aria-invalid={customError != null || undefined} + data-testid="slippage-custom-input" + className={cn( + 'w-20 rounded-lg border bg-white/[0.04] px-2 py-1.5 text-xs text-white outline-none transition-colors', + customActive + ? 'border-amber-500/60 ring-2 ring-amber-500/15' + : 'border-white/10', + customError ? 'border-red-500/60' : '' + )} + /> + % +
+
+ {customError && ( +

+ {customError} +

+ )} +

+ Between {SLIPPAGE_TOLERANCE_BOUNDS.MIN_PERCENT}% and{' '} + {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] ); diff --git a/src/utils/slippageTolerance.utils.ts b/src/utils/slippageTolerance.utils.ts index 45a4f389..0e6f35ad 100644 --- a/src/utils/slippageTolerance.utils.ts +++ b/src/utils/slippageTolerance.utils.ts @@ -1,15 +1,23 @@ /** - * Slippage tolerance selector logic β€” issue #877. + * Slippage tolerance utilities for buy/sell trades (#872, #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. + * Computes the on-chain `max_price` (buy) / `min_price` (sell) bounds from a + * preview price and a selected tolerance percentage, so the contract call + * rejects the trade if the executed price moves against the user by more + * than the tolerance allows. */ -/** Preset tolerance options shown in the slippage selector, in percent. */ +/** Preset slippage tolerance percentages surfaced in the selector UI. */ export const SLIPPAGE_TOLERANCE_PRESETS = [0.5, 1, 5] as const; +/** Default tolerance applied when the user has not made a selection. */ +export const DEFAULT_SLIPPAGE_TOLERANCE_PERCENT = 1; + +export const SLIPPAGE_TOLERANCE_BOUNDS = { + MIN_PERCENT: 0, + MAX_PERCENT: 50, +} as const; + /** Tolerances above this percentage are rejected as invalid. */ export const MAX_SLIPPAGE_TOLERANCE_PERCENT = 50; @@ -18,6 +26,112 @@ 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. + */ +export function validateSlippageTolerancePercent( + value: number | null | undefined +): string | null { + if (value == null || !Number.isFinite(value)) { + return 'Enter a valid slippage tolerance.'; + } + if (value < SLIPPAGE_TOLERANCE_BOUNDS.MIN_PERCENT) { + return 'Slippage tolerance cannot be negative.'; + } + if (value > SLIPPAGE_TOLERANCE_BOUNDS.MAX_PERCENT) { + return `Slippage tolerance cannot exceed ${SLIPPAGE_TOLERANCE_BOUNDS.MAX_PERCENT}%.`; + } + return null; +} + +/** + * Computes the maximum acceptable price (in stroops) for a buy transaction + * given a preview price and a tolerance percentage. + * + * `max_price = preview_price * (1 + tolerance)` + */ +export function computeMaxPriceStroops( + previewPriceStroops: number | null | undefined, + toleranceZPercent: number +): number | null { + if ( + previewPriceStroops == null || + !Number.isFinite(previewPriceStroops) || + previewPriceStroops < 0 || + !Number.isFinite(toleranceZPercent) + ) { + return null; + } + + const toleranceFraction = toleranceZPercent / 100; + return Math.round(previewPriceStroops * (1 + toleranceFraction)); +} + +/** + * Computes the minimum acceptable price (in stroops) for a sell transaction + * given a preview price and a tolerance percentage. + * + * `min_price = preview_price * (1 - tolerance)`, floored at 0. + */ +export function computeMinPriceStroops( + previewPriceStroops: number | null | undefined, + toleranceZPercent: number +): number | null { + if ( + previewPriceStroops == null || + !Number.isFinite(previewPriceStroops) || + previewPriceStroops < 0 || + !Number.isFinite(toleranceZPercent) + ) { + return null; + } + + const toleranceFraction = toleranceZPercent / 100; + const minPrice = previewPriceStroops * (1 - toleranceFraction); + return Math.max(0, Math.round(minPrice)); +} + +export interface SlippageBounds { + /** Selected tolerance, as a percentage (e.g. 1 = 1%). */ + toleranceZPercent: number; + /** `max_price` in stroops to pass to the buy contract call. */ + maxPriceStroops: number | null; + /** `min_price` in stroops to pass to the sell contract call. */ + minPriceStroops: number | null; +} + +/** + * Computes both bounds for a given side; only the bound relevant to the + * trade side is populated (the other is `null`), matching how buy/sell + * contract calls only ever need one of `max_price`/`min_price`. + */ +export function computeSlippageBounds( + side: 'buy' | 'sell', + previewPriceStroops: number | null | undefined, + toleranceZPercent: number +): SlippageBounds { + return { + toleranceZPercent, + maxPriceStroops: + side === 'buy' + ? computeMaxPriceStroops(previewPriceStroops, toleranceZPercent) + : null, + minPriceStroops: + side === 'sell' + ? computeMinPriceStroops(previewPriceStroops, toleranceZPercent) + : null, + }; +} + +// --------------------------------------------------------------------------- +// XLM-based helpers (#877) β€” used by the standalone selector & tests +// --------------------------------------------------------------------------- + export interface SlippagePriceBounds { /** * Highest price the trade will accept paying, for a buy. `null` for