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 ( +
+
+ ); +}; + +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' && ( + + )} +
Amount
= ({
)} {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) }) + ); }); });