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 {
/**