From 1a780da30eba1d06ed8422ee442fe7a4ab8c469d Mon Sep 17 00:00:00 2001
From: Ajibose
Date: Sun, 30 Aug 2026 00:08:00 +0300
Subject: [PATCH 1/3] feat(trade): warn of launch penalty on sell confirmation
(#825)
Detects when a sell falls within a key's 7-day launch window using its
createdAtLedger and launchPenaltyBps (from the key detail API) and shows
a prominent warning plus an updated fee breakdown with the penalty
deducted and net proceeds, so holders can acknowledge the cost before
signing.
---
.../common/LaunchPenaltyWarning.tsx | 44 +++++
src/components/common/SellFeeBreakdown.tsx | 73 +++++++++
src/components/common/TradeDialog.tsx | 46 ++++--
.../__tests__/LaunchPenaltyWarning.test.tsx | 40 +++++
.../__tests__/SellFeeBreakdown.test.tsx | 88 ++++++++++
.../TradeDialog.launchPenalty.test.tsx | 151 ++++++++++++++++++
src/pages/LandingPage.tsx | 3 +
src/services/course.service.ts | 4 +
.../__tests__/launchPenalty.utils.test.ts | 127 +++++++++++++++
src/utils/launchPenalty.utils.ts | 105 ++++++++++++
10 files changed, 669 insertions(+), 12 deletions(-)
create mode 100644 src/components/common/LaunchPenaltyWarning.tsx
create mode 100644 src/components/common/SellFeeBreakdown.tsx
create mode 100644 src/components/common/__tests__/LaunchPenaltyWarning.test.tsx
create mode 100644 src/components/common/__tests__/SellFeeBreakdown.test.tsx
create mode 100644 src/components/common/__tests__/TradeDialog.launchPenalty.test.tsx
create mode 100644 src/utils/__tests__/launchPenalty.utils.test.ts
create mode 100644 src/utils/launchPenalty.utils.ts
diff --git a/src/components/common/LaunchPenaltyWarning.tsx b/src/components/common/LaunchPenaltyWarning.tsx
new file mode 100644
index 00000000..d4b5097b
--- /dev/null
+++ b/src/components/common/LaunchPenaltyWarning.tsx
@@ -0,0 +1,44 @@
+import { AlertTriangle } from 'lucide-react';
+import { bpsToPercent } from '@/utils/numberFormat.utils';
+
+export interface LaunchPenaltyWarningProps {
+ /** Whether the sell falls within the key's 7-day launch window. */
+ visible: boolean;
+ /** Penalty rate in basis points that will be deducted. */
+ penaltyBps: number;
+}
+
+/**
+ * Prominent warning shown on the sell confirmation modal when the key is
+ * still within its 7-day launch window (#825). Deliberately styled bolder
+ * than `StaleDataWarning` — it precedes a signed transaction that will
+ * actually cost the holder the stated percentage of their proceeds.
+ */
+const LaunchPenaltyWarning: React.FC = ({
+ visible,
+ penaltyBps,
+}) => {
+ if (!visible) return null;
+
+ return (
+
+
+
+ Early sell penalty applies —{' '}
+
+ {bpsToPercent(penaltyBps)}
+ {' '}
+ will be deducted from your proceeds.
+
+
+ );
+};
+
+export default LaunchPenaltyWarning;
diff --git a/src/components/common/SellFeeBreakdown.tsx b/src/components/common/SellFeeBreakdown.tsx
new file mode 100644
index 00000000..ad1d84cc
--- /dev/null
+++ b/src/components/common/SellFeeBreakdown.tsx
@@ -0,0 +1,73 @@
+/**
+ * Sell fee breakdown display component.
+ * Shows estimated gross proceeds and, when the key is still within its
+ * 7-day launch window, the launch penalty deducted and the resulting net
+ * proceeds (#825).
+ */
+
+import { formatDisplayKeyPrice } from '@/utils/keyPriceDisplay.utils';
+import { bpsToPercent } from '@/utils/numberFormat.utils';
+import type { LaunchPenaltyBreakdown } from '@/utils/launchPenalty.utils';
+
+export interface SellFeeBreakdownProps {
+ /** Estimated gross proceeds in stroops, before any launch penalty. */
+ grossProceedsStroops: number | null;
+ /** Launch penalty breakdown computed for this sell. */
+ launchPenalty: LaunchPenaltyBreakdown;
+}
+
+/**
+ * Displays the sell proceeds estimate. When `launchPenalty.applies` is
+ * true, also renders the penalty amount deducted and the net proceeds
+ * remaining after the penalty.
+ */
+const SellFeeBreakdown: React.FC = ({
+ grossProceedsStroops,
+ launchPenalty,
+}) => {
+ return (
+
+ {grossProceedsStroops != null ? (
+ <>
+
+ Estimated proceeds (approximate)
+
+ {formatDisplayKeyPrice(grossProceedsStroops)}
+
+
+
+ {launchPenalty.applies && (
+ <>
+
+
+ Launch penalty ({bpsToPercent(launchPenalty.penaltyBps)})
+
+
+ -{formatDisplayKeyPrice(launchPenalty.penaltyStroops)}
+
+
+
+
+ Net proceeds
+
+
+ {formatDisplayKeyPrice(launchPenalty.netProceedsStroops)}
+
+
+ >
+ )}
+ >
+ ) : (
+ <>Estimated proceeds unavailable>
+ )}
+
+ );
+};
+
+export default SellFeeBreakdown;
diff --git a/src/components/common/TradeDialog.tsx b/src/components/common/TradeDialog.tsx
index 4dc5bdbf..8c235b0a 100644
--- a/src/components/common/TradeDialog.tsx
+++ b/src/components/common/TradeDialog.tsx
@@ -18,9 +18,12 @@ import {
import PercentageBadge from '@/components/common/PercentageBadge';
import NetworkFeeHint from '@/components/common/NetworkFeeHint';
import BuyFeeBreakdown from '@/components/common/BuyFeeBreakdown';
+import SellFeeBreakdown from '@/components/common/SellFeeBreakdown';
+import LaunchPenaltyWarning from '@/components/common/LaunchPenaltyWarning';
import { TRADE_FEE_ESTIMATE, FEE_BOUNDS } from '@/constants/fees';
import { formatTransactionFeeDisplay } from '@/utils/transactionFee.utils';
import { clampBuyQuantity } from '@/utils/buyQuantity';
+import { calculateLaunchPenalty } from '@/utils/launchPenalty.utils';
import {
fetchPricePreview,
type FeeBreakdown,
@@ -41,6 +44,12 @@ export interface TradeDialogProps {
protocolFeeBps?: number;
/** Creator fee in basis points for fee preview (defaults to FEE_BOUNDS.DEFAULT_FEE_BPS) */
creatorFeeBps?: number;
+ /** Ledger sequence the key was created at, from the key detail API. */
+ createdAtLedger?: number | null;
+ /** Current network ledger sequence, used to evaluate the 7-day launch window. */
+ currentLedger?: number | null;
+ /** Early-sell penalty in basis points, from the key detail API. */
+ launchPenaltyBps?: number | null;
onOpenChange: (open: boolean) => void;
onConfirm: (
amount: number,
@@ -58,6 +67,9 @@ const TradeDialog: React.FC = ({
currentSupply,
protocolFeeBps = FEE_BOUNDS.DEFAULT_FEE_BPS,
creatorFeeBps = FEE_BOUNDS.DEFAULT_FEE_BPS,
+ createdAtLedger,
+ currentLedger,
+ launchPenaltyBps,
onOpenChange,
onConfirm,
isSubmitting = false,
@@ -141,6 +153,17 @@ const TradeDialog: React.FC = ({
return estimateSellProceeds(keyPriceStroops, currentSupply, parsedAmount);
}, [side, keyPriceStroops, currentSupply, parsedAmount]);
+ const launchPenalty = useMemo(
+ () =>
+ calculateLaunchPenalty(
+ estimatedProceedsStroops,
+ createdAtLedger,
+ currentLedger,
+ launchPenaltyBps
+ ),
+ [estimatedProceedsStroops, createdAtLedger, currentLedger, launchPenaltyBps]
+ );
+
const estimatedTotalStroops = useMemo(() => {
if (
side !== 'buy' ||
@@ -290,6 +313,13 @@ const TradeDialog: React.FC = ({
)}
+ {side === 'sell' && (
+
+ )}
+
)}
{side === 'sell' && (
-
- {estimatedProceedsStroops != null ? (
- <>
- Estimated proceeds (approximate):{' '}
-
- {formatDisplayKeyPrice(estimatedProceedsStroops)}
-
- >
- ) : (
- <>Estimated proceeds unavailable>
- )}
-
+
)}
diff --git a/src/components/common/__tests__/LaunchPenaltyWarning.test.tsx b/src/components/common/__tests__/LaunchPenaltyWarning.test.tsx
new file mode 100644
index 00000000..b12c4111
--- /dev/null
+++ b/src/components/common/__tests__/LaunchPenaltyWarning.test.tsx
@@ -0,0 +1,40 @@
+import { render, screen } from '@testing-library/react';
+import { describe, expect, it } from 'vitest';
+import LaunchPenaltyWarning from '@/components/common/LaunchPenaltyWarning';
+
+describe('LaunchPenaltyWarning', () => {
+ it('renders nothing when not visible', () => {
+ const { container } = render(
+
+ );
+
+ expect(container.firstChild).toBeNull();
+ });
+
+ it('renders the warning with the penalty percentage when visible', () => {
+ render();
+
+ const warning = screen.getByTestId('launch-penalty-warning');
+ expect(warning).toBeInTheDocument();
+ expect(warning).toHaveTextContent('Early sell penalty applies');
+ expect(warning).toHaveTextContent('will be deducted from your proceeds');
+ expect(screen.getByTestId('launch-penalty-rate')).toHaveTextContent('5%');
+ });
+
+ it('formats fractional penalty percentages', () => {
+ render();
+
+ expect(screen.getByTestId('launch-penalty-rate')).toHaveTextContent(
+ '3.33%'
+ );
+ });
+
+ it('has an alert role so assistive tech announces it', () => {
+ render();
+
+ expect(screen.getByTestId('launch-penalty-warning')).toHaveAttribute(
+ 'role',
+ 'alert'
+ );
+ });
+});
diff --git a/src/components/common/__tests__/SellFeeBreakdown.test.tsx b/src/components/common/__tests__/SellFeeBreakdown.test.tsx
new file mode 100644
index 00000000..2e431c89
--- /dev/null
+++ b/src/components/common/__tests__/SellFeeBreakdown.test.tsx
@@ -0,0 +1,88 @@
+import { render, screen } from '@testing-library/react';
+import { describe, expect, it } from 'vitest';
+import SellFeeBreakdown from '@/components/common/SellFeeBreakdown';
+import type { LaunchPenaltyBreakdown } from '@/utils/launchPenalty.utils';
+
+const noPenalty: LaunchPenaltyBreakdown = {
+ applies: false,
+ penaltyBps: 0,
+ penaltyStroops: 0,
+ netProceedsStroops: 1_000_000,
+};
+
+describe('SellFeeBreakdown', () => {
+ it('shows only the gross proceeds estimate when no penalty applies', () => {
+ render(
+
+ );
+
+ expect(screen.getByText(/Estimated proceeds/i)).toBeInTheDocument();
+ expect(screen.getByText(/0\.10? XLM/)).toBeInTheDocument();
+ expect(
+ screen.queryByTestId('sell-fee-breakdown-penalty')
+ ).not.toBeInTheDocument();
+ expect(
+ screen.queryByTestId('sell-fee-breakdown-net')
+ ).not.toBeInTheDocument();
+ });
+
+ it('shows "Estimated proceeds unavailable" when gross proceeds are null', () => {
+ render(
+
+ );
+
+ expect(
+ screen.getByText('Estimated proceeds unavailable')
+ ).toBeInTheDocument();
+ });
+
+ it('shows the penalty line item and net proceeds when the penalty applies', () => {
+ const penalty: LaunchPenaltyBreakdown = {
+ applies: true,
+ penaltyBps: 500,
+ penaltyStroops: 50_000,
+ netProceedsStroops: 950_000,
+ };
+
+ render(
+
+ );
+
+ const penaltyRow = screen.getByTestId('sell-fee-breakdown-penalty');
+ expect(penaltyRow).toHaveTextContent('Launch penalty (5%)');
+ expect(penaltyRow).toHaveTextContent('0.005 XLM');
+
+ const netRow = screen.getByTestId('sell-fee-breakdown-net');
+ expect(netRow).toHaveTextContent('Net proceeds');
+ expect(netRow).toHaveTextContent('0.095 XLM');
+ });
+
+ it('formats the net proceeds correctly for a large penalty', () => {
+ const penalty: LaunchPenaltyBreakdown = {
+ applies: true,
+ penaltyBps: 2000, // 20%
+ penaltyStroops: 200_000,
+ netProceedsStroops: 800_000,
+ };
+
+ render(
+
+ );
+
+ expect(screen.getByTestId('sell-fee-breakdown-penalty')).toHaveTextContent(
+ 'Launch penalty (20%)'
+ );
+ expect(screen.getByTestId('sell-fee-breakdown-net')).toHaveTextContent(
+ '0.08 XLM'
+ );
+ });
+});
diff --git a/src/components/common/__tests__/TradeDialog.launchPenalty.test.tsx b/src/components/common/__tests__/TradeDialog.launchPenalty.test.tsx
new file mode 100644
index 00000000..7bd08ccc
--- /dev/null
+++ b/src/components/common/__tests__/TradeDialog.launchPenalty.test.tsx
@@ -0,0 +1,151 @@
+/**
+ * Unit tests for the launch penalty warning on the sell confirmation
+ * modal (#825). Holders selling within 7 days of a key's creation incur
+ * an early-sell penalty; the modal must surface a warning plus an updated
+ * fee breakdown before the user signs, and let them proceed anyway.
+ */
+import { describe, expect, it, vi } from 'vitest';
+import { render, screen, fireEvent } from '@testing-library/react';
+import TradeDialog from '@/components/common/TradeDialog';
+import { LAUNCH_WINDOW_LEDGERS } from '@/utils/launchPenalty.utils';
+
+describe('TradeDialog – launch penalty warning (#825)', () => {
+ function renderSellDialog(
+ overrides: Partial> = {}
+ ) {
+ return render(
+
+ );
+ }
+
+ it('shows the warning when selling within the 7-day launch window', () => {
+ renderSellDialog({
+ createdAtLedger: 1000,
+ currentLedger: 1000 + 100,
+ launchPenaltyBps: 500, // 5%
+ });
+
+ const warning = screen.getByTestId('launch-penalty-warning');
+ expect(warning).toBeInTheDocument();
+ expect(warning).toHaveTextContent('Early sell penalty applies');
+ expect(screen.getByTestId('launch-penalty-rate')).toHaveTextContent('5%');
+ });
+
+ it('hides the warning once the key is past the launch window', () => {
+ renderSellDialog({
+ createdAtLedger: 1000,
+ currentLedger: 1000 + LAUNCH_WINDOW_LEDGERS + 1,
+ launchPenaltyBps: 500,
+ });
+
+ expect(
+ screen.queryByTestId('launch-penalty-warning')
+ ).not.toBeInTheDocument();
+ });
+
+ it('hides the warning when no launch penalty is configured', () => {
+ renderSellDialog({
+ createdAtLedger: 1000,
+ currentLedger: 1000 + 10,
+ launchPenaltyBps: 0,
+ });
+
+ expect(
+ screen.queryByTestId('launch-penalty-warning')
+ ).not.toBeInTheDocument();
+ });
+
+ it('hides the warning when ledger data is unavailable', () => {
+ renderSellDialog();
+
+ expect(
+ screen.queryByTestId('launch-penalty-warning')
+ ).not.toBeInTheDocument();
+ });
+
+ it('never shows the warning on the buy side', () => {
+ renderSellDialog({
+ side: 'buy',
+ createdAtLedger: 1000,
+ currentLedger: 1050,
+ launchPenaltyBps: 500,
+ });
+
+ expect(
+ screen.queryByTestId('launch-penalty-warning')
+ ).not.toBeInTheDocument();
+ });
+
+ it('displays the penalty amount and net proceeds in the fee breakdown', () => {
+ renderSellDialog({
+ createdAtLedger: 1000,
+ currentLedger: 1050,
+ launchPenaltyBps: 500, // 5%
+ });
+
+ const input = screen.getByTestId('trade-dialog-amount');
+ // keyPriceStroops=500_000 * quantity=4 = 2_000_000 stroops gross (0.2 XLM)
+ fireEvent.change(input, { target: { value: '4' } });
+
+ expect(screen.getByTestId('sell-fee-breakdown-penalty')).toHaveTextContent(
+ 'Launch penalty (5%)'
+ );
+ // 5% of 0.2 XLM = 0.01 XLM
+ expect(screen.getByTestId('sell-fee-breakdown-penalty')).toHaveTextContent(
+ '0.01 XLM'
+ );
+ expect(screen.getByTestId('sell-fee-breakdown-net')).toHaveTextContent(
+ 'Net proceeds'
+ );
+ // 0.2 XLM - 0.01 XLM = 0.19 XLM
+ expect(screen.getByTestId('sell-fee-breakdown-net')).toHaveTextContent(
+ '0.19 XLM'
+ );
+ });
+
+ it('does not show a penalty line item when past the launch window', () => {
+ renderSellDialog({
+ createdAtLedger: 1000,
+ currentLedger: 1000 + LAUNCH_WINDOW_LEDGERS + 1,
+ launchPenaltyBps: 500,
+ });
+
+ const input = screen.getByTestId('trade-dialog-amount');
+ fireEvent.change(input, { target: { value: '4' } });
+
+ expect(
+ screen.queryByTestId('sell-fee-breakdown-penalty')
+ ).not.toBeInTheDocument();
+ expect(
+ screen.queryByTestId('sell-fee-breakdown-net')
+ ).not.toBeInTheDocument();
+ });
+
+ it('still lets the user confirm the sale after the warning is shown', () => {
+ const onConfirm = vi.fn();
+ renderSellDialog({
+ createdAtLedger: 1000,
+ currentLedger: 1050,
+ launchPenaltyBps: 500,
+ onConfirm,
+ });
+
+ expect(screen.getByTestId('launch-penalty-warning')).toBeInTheDocument();
+
+ const input = screen.getByTestId('trade-dialog-amount');
+ fireEvent.change(input, { target: { value: '2' } });
+ fireEvent.click(screen.getByTestId('trade-dialog-confirm'));
+
+ expect(onConfirm).toHaveBeenCalledWith(2, null);
+ });
+});
diff --git a/src/pages/LandingPage.tsx b/src/pages/LandingPage.tsx
index 9712237d..e1996977 100644
--- a/src/pages/LandingPage.tsx
+++ b/src/pages/LandingPage.tsx
@@ -1865,6 +1865,9 @@ function LandingPage() {
keyPriceStroops={resolveCreatorKeyPriceStroops(featuredCreator)}
protocolFeeBps={250}
creatorFeeBps={250}
+ createdAtLedger={featuredCreator?.createdAtLedger}
+ currentLedger={featuredCreator?.currentLedger}
+ launchPenaltyBps={featuredCreator?.launchPenaltyBps}
isSubmitting={tradeSubmitting}
onOpenChange={setTradeDialogOpen}
onConfirm={handleConfirmTrade}
diff --git a/src/services/course.service.ts b/src/services/course.service.ts
index 3da0cdda..7a845bc2 100644
--- a/src/services/course.service.ts
+++ b/src/services/course.service.ts
@@ -51,6 +51,10 @@ export interface Course {
* Applied to sells within the first 7 days after key creation.
*/
launchPenaltyBps?: number;
+ /** Ledger sequence at which this key was created; anchors the 7-day launch window. */
+ createdAtLedger?: number;
+ /** Network ledger sequence as of this response, used to evaluate the launch window. */
+ currentLedger?: number;
}
export type CourseSortOption =
diff --git a/src/utils/__tests__/launchPenalty.utils.test.ts b/src/utils/__tests__/launchPenalty.utils.test.ts
new file mode 100644
index 00000000..94b04aac
--- /dev/null
+++ b/src/utils/__tests__/launchPenalty.utils.test.ts
@@ -0,0 +1,127 @@
+import { describe, it, expect } from 'vitest';
+import {
+ isWithinLaunchWindow,
+ calculateLaunchPenalty,
+ LAUNCH_WINDOW_LEDGERS,
+} from '../launchPenalty.utils';
+
+describe('isWithinLaunchWindow', () => {
+ it('returns true when the sell happens right at creation', () => {
+ expect(isWithinLaunchWindow(1000, 1000)).toBe(true);
+ });
+
+ it('returns true when the sell happens partway through the 7-day window', () => {
+ expect(isWithinLaunchWindow(1000, 1000 + LAUNCH_WINDOW_LEDGERS - 1)).toBe(
+ true
+ );
+ });
+
+ it('returns false once the sell happens at exactly 7 days out', () => {
+ expect(isWithinLaunchWindow(1000, 1000 + LAUNCH_WINDOW_LEDGERS)).toBe(
+ false
+ );
+ });
+
+ it('returns false well past the launch window', () => {
+ expect(
+ isWithinLaunchWindow(1000, 1000 + LAUNCH_WINDOW_LEDGERS * 10)
+ ).toBe(false);
+ });
+
+ it('returns false when currentLedger precedes createdAtLedger', () => {
+ expect(isWithinLaunchWindow(1000, 999)).toBe(false);
+ });
+
+ it.each([
+ [null, 1000],
+ [1000, null],
+ [undefined, 1000],
+ [1000, undefined],
+ [NaN, 1000],
+ [1000, NaN],
+ ])(
+ 'returns false when createdAtLedger=%s or currentLedger=%s is missing/invalid',
+ (createdAtLedger, currentLedger) => {
+ expect(isWithinLaunchWindow(createdAtLedger, currentLedger)).toBe(
+ false
+ );
+ }
+ );
+});
+
+describe('calculateLaunchPenalty', () => {
+ it('applies the penalty within the launch window', () => {
+ const result = calculateLaunchPenalty(1_000_000, 1000, 1500, 500); // 5%
+
+ expect(result).toEqual({
+ applies: true,
+ penaltyBps: 500,
+ penaltyStroops: 50_000,
+ netProceedsStroops: 950_000,
+ });
+ });
+
+ it('does not apply the penalty past the launch window', () => {
+ const result = calculateLaunchPenalty(
+ 1_000_000,
+ 1000,
+ 1000 + LAUNCH_WINDOW_LEDGERS,
+ 500
+ );
+
+ expect(result).toEqual({
+ applies: false,
+ penaltyBps: 0,
+ penaltyStroops: 0,
+ netProceedsStroops: 1_000_000,
+ });
+ });
+
+ it('does not apply when the creator has no launch penalty configured', () => {
+ const result = calculateLaunchPenalty(1_000_000, 1000, 1200, 0);
+
+ expect(result.applies).toBe(false);
+ expect(result.netProceedsStroops).toBe(1_000_000);
+ });
+
+ it('does not apply when launchPenaltyBps is missing', () => {
+ const result = calculateLaunchPenalty(1_000_000, 1000, 1200, undefined);
+
+ expect(result.applies).toBe(false);
+ expect(result.netProceedsStroops).toBe(1_000_000);
+ });
+
+ it('does not apply when ledger data is missing', () => {
+ const result = calculateLaunchPenalty(1_000_000, null, null, 500);
+
+ expect(result.applies).toBe(false);
+ expect(result.netProceedsStroops).toBe(1_000_000);
+ });
+
+ it('falls back to 0 net proceeds when gross proceeds are unavailable', () => {
+ const result = calculateLaunchPenalty(null, 1000, 1200, 500);
+
+ expect(result).toEqual({
+ applies: false,
+ penaltyBps: 0,
+ penaltyStroops: 0,
+ netProceedsStroops: 0,
+ });
+ });
+
+ it('rounds the penalty amount to the nearest stroop', () => {
+ const result = calculateLaunchPenalty(1_000_000, 1000, 1200, 333); // 3.33%
+
+ expect(result.penaltyStroops).toBe(Math.round((1_000_000 * 333) / 10_000));
+ expect(result.netProceedsStroops).toBe(
+ 1_000_000 - result.penaltyStroops
+ );
+ });
+
+ it('applies the maximum configured penalty (20%)', () => {
+ const result = calculateLaunchPenalty(1_000_000, 1000, 1000, 2000);
+
+ expect(result.penaltyStroops).toBe(200_000);
+ expect(result.netProceedsStroops).toBe(800_000);
+ });
+});
diff --git a/src/utils/launchPenalty.utils.ts b/src/utils/launchPenalty.utils.ts
new file mode 100644
index 00000000..696df8d7
--- /dev/null
+++ b/src/utils/launchPenalty.utils.ts
@@ -0,0 +1,105 @@
+/**
+ * Launch penalty utilities for the sell confirmation flow (#825).
+ *
+ * Holders who sell within 7 days of a key's creation incur an early-sell
+ * penalty (configured per-creator via `LaunchPenaltyPanel` / the
+ * `set_launch_penalty` contract call, see `launchPenaltyBps` on `Course`).
+ * These helpers determine whether that launch window is still open and
+ * compute the resulting penalty against a sell's gross proceeds.
+ */
+
+/** Stellar's approximate ledger close time, in milliseconds. */
+const STELLAR_LEDGER_TIME_MS = 5000;
+
+/** Length of the early-sell launch window, in milliseconds (7 days). */
+export const LAUNCH_WINDOW_MS = 7 * 24 * 60 * 60 * 1000;
+
+/** Length of the early-sell launch window, expressed in ledgers. */
+export const LAUNCH_WINDOW_LEDGERS = Math.round(
+ LAUNCH_WINDOW_MS / STELLAR_LEDGER_TIME_MS
+);
+
+/**
+ * Determines whether a sell at `currentLedger` falls within the 7-day
+ * launch window that started at `createdAtLedger`.
+ *
+ * Returns `false` (no penalty) whenever either ledger is missing/invalid,
+ * or when `currentLedger` is at or before `createdAtLedger` (clock skew /
+ * stale data) so a malformed pair never accidentally blocks a sell.
+ */
+export function isWithinLaunchWindow(
+ createdAtLedger: number | null | undefined,
+ currentLedger: number | null | undefined
+): boolean {
+ if (
+ createdAtLedger == null ||
+ currentLedger == null ||
+ !Number.isFinite(createdAtLedger) ||
+ !Number.isFinite(currentLedger)
+ ) {
+ return false;
+ }
+
+ const ledgersSinceCreation = currentLedger - createdAtLedger;
+ return (
+ ledgersSinceCreation >= 0 && ledgersSinceCreation < LAUNCH_WINDOW_LEDGERS
+ );
+}
+
+export interface LaunchPenaltyBreakdown {
+ /** Whether the launch penalty applies to this sell. */
+ applies: boolean;
+ /** Penalty rate in basis points actually applied (0 when it doesn't apply). */
+ penaltyBps: number;
+ /** Penalty amount deducted from gross proceeds, in stroops. */
+ penaltyStroops: number;
+ /** Proceeds remaining after the penalty is deducted, in stroops. */
+ netProceedsStroops: number;
+}
+
+/**
+ * Computes the launch-penalty breakdown for a sell.
+ *
+ * When the key is past its 7-day launch window, or has no penalty
+ * configured, `applies` is `false` and `netProceedsStroops` simply mirrors
+ * `grossProceedsStroops` (falling back to `0` when the gross amount isn't
+ * available).
+ */
+export function calculateLaunchPenalty(
+ grossProceedsStroops: number | null | undefined,
+ createdAtLedger: number | null | undefined,
+ currentLedger: number | null | undefined,
+ launchPenaltyBps: number | null | undefined
+): LaunchPenaltyBreakdown {
+ const gross =
+ grossProceedsStroops != null && Number.isFinite(grossProceedsStroops)
+ ? grossProceedsStroops
+ : 0;
+
+ const withinWindow = isWithinLaunchWindow(createdAtLedger, currentLedger);
+ const bps =
+ launchPenaltyBps != null && Number.isFinite(launchPenaltyBps)
+ ? launchPenaltyBps
+ : 0;
+
+ const applies = withinWindow && bps > 0 && gross > 0;
+
+ if (!applies) {
+ return {
+ applies: false,
+ penaltyBps: 0,
+ penaltyStroops: 0,
+ netProceedsStroops: gross,
+ };
+ }
+
+ const penaltyStroops = Math.round((gross * bps) / 10_000);
+ const netProceedsStroops = gross - penaltyStroops;
+
+ return {
+ applies: true,
+ penaltyBps: bps,
+ penaltyStroops,
+ netProceedsStroops,
+ };
+}
From 6bd33af1c770353c20dd4342952e91d86be9097c Mon Sep 17 00:00:00 2001
From: Ajibose
Date: Wed, 2 Sep 2026 21:54:27 +0300
Subject: [PATCH 2/3] fix(merge): repair dev branch corruption in
slippage/simulation files
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Merge commit e498adc concatenated two independent, unrelated
implementations that landed under the same file paths instead of
picking one — #872 vs #877 for slippageTolerance.utils.ts /
SlippageToleranceSelector.tsx, and #875 vs #887 for
KeySimulationTool.tsx. The result had duplicate/interleaved
declarations and broken syntax, failing `tsc` outright.
Restores each file to the version that's actually wired into the app
(TradeDialog's value/onChange selector API and CreatorDetailPage's
currentSupply/protocolFeeBps/creatorFeeBps props) and matches the
surviving test files; the orphaned, never-integrated duplicate
implementations are dropped.
Co-Authored-By: Claude Sonnet 5
Claude-Session: https://claude.ai/code/session_01K2iSG6NcphR3o7H3FLGLYf
---
src/components/common/KeySimulationTool.tsx | 149 ------------------
.../common/SlippageToleranceSelector.tsx | 144 -----------------
.../SlippageToleranceSelector.test.tsx | 107 -------------
.../__tests__/slippageTolerance.utils.test.ts | 87 ----------
src/utils/slippageTolerance.utils.ts | 109 -------------
5 files changed, 596 deletions(-)
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..60b1f9c9 100644
--- a/src/components/common/SlippageToleranceSelector.tsx
+++ b/src/components/common/SlippageToleranceSelector.tsx
@@ -11,29 +11,6 @@ export interface SlippageToleranceSelectorProps {
value: number;
onChange: (percent: number) => void;
disabled?: boolean;
- computeSlippagePriceBounds,
- validateSlippageTolerance,
- SLIPPAGE_TOLERANCE_PRESETS,
- type TradeSide,
-} from '@/utils/slippageTolerance.utils';
-
-export interface SlippageToleranceSelectorProps {
- /** The quoted/preview price the tolerance is applied against. */
- previewPrice: number;
- /** Whether this trade is a buy (computes max_price) or sell (min_price). */
- side: TradeSide;
- /** Called whenever the selected tolerance changes with a valid value. */
- onToleranceChange?: (tolerancePercent: number) => void;
- /**
- * Called with the confirm-eligibility state whenever it changes, so a
- * parent trade dialog can disable its own confirm button in lockstep.
- */
- onValidityChange?: (canConfirm: boolean) => void;
- /** Called when the confirm button is clicked while the tolerance is valid. */
- onConfirm?: (bounds: {
- maxPrice: number | null;
- minPrice: number | null;
- }) => void;
className?: string;
}
@@ -79,63 +56,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,70 +129,6 @@ const SlippageToleranceSelector: React.FC = ({
{SLIPPAGE_TOLERANCE_BOUNDS.MAX_PERCENT}%. The trade will revert if the
price moves beyond your tolerance before it executes.
-
-
Slippage tolerance
-
- {SLIPPAGE_TOLERANCE_PRESETS.map(preset => (
-
- ))}
- handleCustomChange(event.target.value)}
- onFocus={() => setIsCustom(true)}
- aria-label="Custom slippage tolerance"
- data-testid="slippage-custom-input"
- className={cn(
- 'w-24 rounded-md border bg-white/[0.04] px-2 py-1 text-xs text-white outline-none transition-colors',
- 'border-white/10 focus:border-amber-500/50',
- isCustom && !validation.valid ? 'border-red-500/60' : ''
- )}
- />
-
-
- {isCustom && !validation.valid && (
-
- {validation.error}
-
- )}
-
- {validation.valid && (
-
- {side === 'buy'
- ? `Max price: ${bounds.maxPrice} XLM`
- : `Min price: ${bounds.minPrice} XLM`}
-
- )}
-
-
);
};
diff --git a/src/components/common/__tests__/SlippageToleranceSelector.test.tsx b/src/components/common/__tests__/SlippageToleranceSelector.test.tsx
index 2e0cd20b..e4a5d953 100644
--- a/src/components/common/__tests__/SlippageToleranceSelector.test.tsx
+++ b/src/components/common/__tests__/SlippageToleranceSelector.test.tsx
@@ -88,112 +88,5 @@ describe('SlippageToleranceSelector', () => {
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';
-
-import SlippageToleranceSelector from '@/components/common/SlippageToleranceSelector';
-
-describe('SlippageToleranceSelector (#877)', () => {
- it('shows max_price of 100.5 XLM for the 0.5% preset on a 100 XLM buy preview', () => {
- render();
-
- // 0.5% is the default-selected preset.
- expect(screen.getByTestId('slippage-price-bound')).toHaveTextContent(
- 'Max price: 100.5 XLM'
- );
- });
-
- it('shows max_price of 105 XLM after selecting the 5% preset', async () => {
- const user = userEvent.setup();
- render();
-
- await user.click(screen.getByTestId('slippage-preset-5'));
-
- expect(screen.getByTestId('slippage-price-bound')).toHaveTextContent(
- 'Max price: 105 XLM'
- );
- });
-
- it('shows min_price of 99 XLM after selecting the 1% preset on a sell', async () => {
- const user = userEvent.setup();
- render();
-
- await user.click(screen.getByTestId('slippage-preset-1'));
-
- expect(screen.getByTestId('slippage-price-bound')).toHaveTextContent(
- 'Min price: 99 XLM'
- );
- });
-
- it('sets max_price equal to the preview price for a custom 0% tolerance', async () => {
- const user = userEvent.setup();
- render();
-
- await user.type(screen.getByTestId('slippage-custom-input'), '0');
-
- expect(screen.getByTestId('slippage-price-bound')).toHaveTextContent(
- 'Max price: 100 XLM'
- );
- expect(
- screen.queryByTestId('slippage-validation-error')
- ).not.toBeInTheDocument();
- expect(screen.getByTestId('slippage-confirm-button')).toBeEnabled();
- });
-
- it('shows a validation error and disables the confirm button for a custom tolerance above 50%', async () => {
- const user = userEvent.setup();
- const onValidityChange = vi.fn();
- render(
-
- );
-
- await user.type(screen.getByTestId('slippage-custom-input'), '51');
-
- expect(screen.getByTestId('slippage-validation-error')).toHaveTextContent(
- /50%/
- );
- expect(screen.getByTestId('slippage-confirm-button')).toBeDisabled();
- expect(onValidityChange).toHaveBeenLastCalledWith(false);
- // No stale price-bound should be shown once the input is invalid.
- expect(
- screen.queryByTestId('slippage-price-bound')
- ).not.toBeInTheDocument();
- });
-
- it('re-enables the confirm button once a custom tolerance is corrected back into range', async () => {
- const user = userEvent.setup();
- render();
-
- const input = screen.getByTestId('slippage-custom-input');
- await user.type(input, '75');
- expect(screen.getByTestId('slippage-confirm-button')).toBeDisabled();
-
- await user.clear(input);
- await user.type(input, '10');
- expect(screen.getByTestId('slippage-confirm-button')).toBeEnabled();
- });
-
- it('calls onConfirm with the computed bounds when the confirm button is clicked', async () => {
- const user = userEvent.setup();
- const onConfirm = vi.fn();
- render(
-
- );
-
- await user.click(screen.getByTestId('slippage-confirm-button'));
-
- expect(onConfirm).toHaveBeenCalledWith({
- maxPrice: 100.5,
- minPrice: null,
- });
});
});
diff --git a/src/utils/__tests__/slippageTolerance.utils.test.ts b/src/utils/__tests__/slippageTolerance.utils.test.ts
index 6ae36f0c..d5eb2e17 100644
--- a/src/utils/__tests__/slippageTolerance.utils.test.ts
+++ b/src/utils/__tests__/slippageTolerance.utils.test.ts
@@ -123,92 +123,5 @@ describe('slippageTolerance.utils', () => {
expect(computeSlippageBounds('buy', null, 1).maxPriceStroops).toBeNull();
expect(computeSlippageBounds('sell', undefined, 1).minPriceStroops).toBeNull();
});
-import { describe, expect, it } from 'vitest';
-import {
- computeSlippagePriceBounds,
- validateSlippageTolerance,
- MAX_SLIPPAGE_TOLERANCE_PERCENT,
-} from '@/utils/slippageTolerance.utils';
-
-describe('computeSlippagePriceBounds (#877)', () => {
- it('computes max_price of 100.5 for a 0.5% buy tolerance on a 100 XLM preview', () => {
- const { maxPrice, minPrice } = computeSlippagePriceBounds(
- 100,
- 0.5,
- 'buy'
- );
- expect(maxPrice).toBe(100.5);
- expect(minPrice).toBeNull();
- });
-
- it('computes max_price of 105 for a 5% buy tolerance on a 100 XLM preview', () => {
- const { maxPrice } = computeSlippagePriceBounds(100, 5, 'buy');
- expect(maxPrice).toBe(105);
- });
-
- it('computes min_price of 99 for a 1% sell tolerance on a 100 XLM preview', () => {
- const { minPrice, maxPrice } = computeSlippagePriceBounds(
- 100,
- 1,
- 'sell'
- );
- expect(minPrice).toBe(99);
- expect(maxPrice).toBeNull();
- });
-
- it('sets max_price equal to the preview price for a custom 0% tolerance', () => {
- const { maxPrice } = computeSlippagePriceBounds(100, 0, 'buy');
- expect(maxPrice).toBe(100);
- });
-
- it('sets min_price equal to the preview price for a custom 0% sell tolerance', () => {
- const { minPrice } = computeSlippagePriceBounds(100, 0, 'sell');
- expect(minPrice).toBe(100);
- });
-
- it('does not accumulate binary floating-point drift for common percentages', () => {
- // 100 * 1.005 === 100.49999999999999 in raw IEEE-754 arithmetic;
- // the util must round this back to the exact expected value.
- expect(computeSlippagePriceBounds(100, 0.5, 'buy').maxPrice).toBe(
- 100.5
- );
- expect(computeSlippagePriceBounds(37.5, 1.5, 'buy').maxPrice).toBeCloseTo(
- 38.0625,
- 7
- );
- });
-});
-
-describe('validateSlippageTolerance (#877)', () => {
- it('accepts a custom tolerance of 0%', () => {
- expect(validateSlippageTolerance(0)).toEqual({
- valid: true,
- error: null,
- });
- });
-
- it('accepts tolerances within the valid range', () => {
- expect(validateSlippageTolerance(0.5).valid).toBe(true);
- expect(validateSlippageTolerance(25).valid).toBe(true);
- expect(validateSlippageTolerance(MAX_SLIPPAGE_TOLERANCE_PERCENT).valid).toBe(
- true
- );
- });
-
- it('rejects a custom tolerance above 50% with a validation error', () => {
- const result = validateSlippageTolerance(51);
- expect(result.valid).toBe(false);
- expect(result.error).toMatch(/50%/);
- });
-
- it('rejects negative tolerances', () => {
- const result = validateSlippageTolerance(-1);
- expect(result.valid).toBe(false);
- expect(result.error).toBeTruthy();
- });
-
- it('rejects non-finite input', () => {
- expect(validateSlippageTolerance(NaN).valid).toBe(false);
- expect(validateSlippageTolerance(Infinity).valid).toBe(false);
});
});
diff --git a/src/utils/slippageTolerance.utils.ts b/src/utils/slippageTolerance.utils.ts
index 6b29ec3e..ea354b19 100644
--- a/src/utils/slippageTolerance.utils.ts
+++ b/src/utils/slippageTolerance.utils.ts
@@ -114,113 +114,4 @@ 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';
-
-export interface SlippagePriceBounds {
- /**
- * Highest price the trade will accept paying, for a buy. `null` for
- * sell-side computations.
- */
- maxPrice: number | null;
- /**
- * Lowest price the trade will accept receiving, for a sell. `null` for
- * buy-side computations.
- */
- minPrice: number | null;
-}
-
-/**
- * Decimal places prices are rounded to. Guards against binary
- * floating-point drift (e.g. `100 * 1.005` landing on 100.49999999999999
- * instead of 100.5) — XLM prices in this app are never displayed or
- * compared at finer than micro-XLM precision.
- */
-const PRICE_DECIMAL_PLACES = 7;
-
-function roundPrice(value: number): number {
- const factor = 10 ** PRICE_DECIMAL_PLACES;
- return Math.round(value * factor) / factor;
-}
-
-/**
- * Computes the max_price (buy) or min_price (sell) bound for a trade given
- * the preview price and a slippage tolerance percentage.
- *
- * @param previewPrice The quoted/preview price before slippage is applied.
- * @param tolerancePercent Slippage tolerance as a percent (e.g. 0.5 for 0.5%).
- * @param side Whether this is a 'buy' (computes max_price) or 'sell'
- * (computes min_price).
- */
-export function computeSlippagePriceBounds(
- previewPrice: number,
- tolerancePercent: number,
- side: TradeSide
-): SlippagePriceBounds {
- const multiplier = tolerancePercent / 100;
-
- if (side === 'buy') {
- return {
- maxPrice: roundPrice(previewPrice * (1 + multiplier)),
- minPrice: null,
- };
- }
-
- return {
- maxPrice: null,
- minPrice: roundPrice(previewPrice * (1 - multiplier)),
- };
-}
-
-export interface SlippageToleranceValidation {
- valid: boolean;
- /** Human-readable validation error, or `null` when the tolerance is valid. */
- error: string | null;
-}
-
-/**
- * Validates a (typically custom) slippage tolerance percentage.
- *
- * Valid range is [0, 50]. Anything above 50% is rejected as an unreasonably
- * high tolerance that would let a trade execute far away from the preview
- * price; negative values and non-finite input are also rejected.
- */
-export function validateSlippageTolerance(
- tolerancePercent: number
-): SlippageToleranceValidation {
- if (!Number.isFinite(tolerancePercent)) {
- return { valid: false, error: 'Enter a valid slippage tolerance.' };
- }
-
- if (tolerancePercent < MIN_SLIPPAGE_TOLERANCE_PERCENT) {
- return {
- valid: false,
- error: 'Slippage tolerance cannot be negative.',
- };
- }
-
- if (tolerancePercent > MAX_SLIPPAGE_TOLERANCE_PERCENT) {
- return {
- valid: false,
- error: `Slippage tolerance cannot exceed ${MAX_SLIPPAGE_TOLERANCE_PERCENT}%.`,
- };
- }
-
- return { valid: true, error: null };
}
From 2a7ee7a7fda7034c1ed237174d2911ada658a931 Mon Sep 17 00:00:00 2001
From: Ajibose
Date: Wed, 2 Sep 2026 22:07:52 +0300
Subject: [PATCH 3/3] test(TradeDialog): assert third slippage arg in
launch-penalty confirm test
Follow-up to the previous merge commit: the onConfirm assertion fix
for dev's new third `slippage` argument didn't make it into that
commit because the file wasn't staged before running `git commit`.
Co-Authored-By: Claude Sonnet 5
Claude-Session: https://claude.ai/code/session_01K2iSG6NcphR3o7H3FLGLYf
---
.../common/__tests__/TradeDialog.launchPenalty.test.tsx | 9 ++++++++-
1 file changed, 8 insertions(+), 1 deletion(-)
diff --git a/src/components/common/__tests__/TradeDialog.launchPenalty.test.tsx b/src/components/common/__tests__/TradeDialog.launchPenalty.test.tsx
index 7bd08ccc..300d2692 100644
--- a/src/components/common/__tests__/TradeDialog.launchPenalty.test.tsx
+++ b/src/components/common/__tests__/TradeDialog.launchPenalty.test.tsx
@@ -146,6 +146,13 @@ describe('TradeDialog – launch penalty warning (#825)', () => {
fireEvent.change(input, { target: { value: '2' } });
fireEvent.click(screen.getByTestId('trade-dialog-confirm'));
- expect(onConfirm).toHaveBeenCalledWith(2, null);
+ // #872 added a third `slippage` argument (the computed min/max price
+ // bound); this test only asserts on the amount and price-preview
+ // arguments it was written to cover.
+ expect(onConfirm).toHaveBeenCalledWith(
+ 2,
+ null,
+ expect.objectContaining({ minPriceStroops: expect.any(Number) })
+ );
});
});