Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions src/components/common/LaunchPenaltyWarning.tsx
Original file line number Diff line number Diff line change
@@ -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<LaunchPenaltyWarningProps> = ({
visible,
penaltyBps,
}) => {
if (!visible) return null;

return (
<div
role="alert"
data-testid="launch-penalty-warning"
className="flex items-start gap-2 rounded-lg border border-yellow-500/40 bg-yellow-500/10 p-3 text-sm text-yellow-200"
>
<AlertTriangle
className="h-4 w-4 flex-shrink-0 text-yellow-400 mt-0.5"
aria-hidden="true"
/>
<p>
Early sell penalty applies —{' '}
<span className="font-semibold" data-testid="launch-penalty-rate">
{bpsToPercent(penaltyBps)}
</span>{' '}
will be deducted from your proceeds.
</p>
</div>
);
};

export default LaunchPenaltyWarning;
73 changes: 73 additions & 0 deletions src/components/common/SellFeeBreakdown.tsx
Original file line number Diff line number Diff line change
@@ -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<SellFeeBreakdownProps> = ({
grossProceedsStroops,
launchPenalty,
}) => {
return (
<div className="text-xs text-white/45 mt-2" data-testid="sell-fee-breakdown">
{grossProceedsStroops != null ? (
<>
<div className="flex justify-between items-center">
<span>Estimated proceeds (approximate)</span>
<span className="font-semibold text-amber-300/90 tabular-nums">
{formatDisplayKeyPrice(grossProceedsStroops)}
</span>
</div>

{launchPenalty.applies && (
<>
<div
className="flex justify-between items-center mt-1"
data-testid="sell-fee-breakdown-penalty"
>
<span className="text-yellow-300/80">
Launch penalty ({bpsToPercent(launchPenalty.penaltyBps)})
</span>
<span className="font-mono text-yellow-300/90 tabular-nums">
-{formatDisplayKeyPrice(launchPenalty.penaltyStroops)}
</span>
</div>
<div
className="flex justify-between items-center mt-1 pt-1 border-t border-white/10"
data-testid="sell-fee-breakdown-net"
>
<span className="font-semibold text-white/70">
Net proceeds
</span>
<span className="font-semibold text-amber-300/90 tabular-nums">
{formatDisplayKeyPrice(launchPenalty.netProceedsStroops)}
</span>
</div>
</>
)}
</>
) : (
<>Estimated proceeds unavailable</>
)}
</div>
);
};

export default SellFeeBreakdown;
46 changes: 34 additions & 12 deletions src/components/common/TradeDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -58,6 +67,9 @@ const TradeDialog: React.FC<TradeDialogProps> = ({
currentSupply,
protocolFeeBps = FEE_BOUNDS.DEFAULT_FEE_BPS,
creatorFeeBps = FEE_BOUNDS.DEFAULT_FEE_BPS,
createdAtLedger,
currentLedger,
launchPenaltyBps,
onOpenChange,
onConfirm,
isSubmitting = false,
Expand Down Expand Up @@ -141,6 +153,17 @@ const TradeDialog: React.FC<TradeDialogProps> = ({
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' ||
Expand Down Expand Up @@ -290,6 +313,13 @@ const TradeDialog: React.FC<TradeDialogProps> = ({
</p>
)}

{side === 'sell' && (
<LaunchPenaltyWarning
visible={launchPenalty.applies}
penaltyBps={launchPenalty.penaltyBps}
/>
)}

<div className="space-y-2">
<div className="text-sm text-white/70">Amount</div>
<input
Expand Down Expand Up @@ -373,18 +403,10 @@ const TradeDialog: React.FC<TradeDialogProps> = ({
</div>
)}
{side === 'sell' && (
<div className="text-xs text-white/45 mt-2">
{estimatedProceedsStroops != null ? (
<>
Estimated proceeds (approximate):{' '}
<span className="font-semibold text-amber-300/90 tabular-nums">
{formatDisplayKeyPrice(estimatedProceedsStroops)}
</span>
</>
) : (
<>Estimated proceeds unavailable</>
)}
</div>
<SellFeeBreakdown
grossProceedsStroops={estimatedProceedsStroops}
launchPenalty={launchPenalty}
/>
)}
</div>

Expand Down
40 changes: 40 additions & 0 deletions src/components/common/__tests__/LaunchPenaltyWarning.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
<LaunchPenaltyWarning visible={false} penaltyBps={500} />
);

expect(container.firstChild).toBeNull();
});

it('renders the warning with the penalty percentage when visible', () => {
render(<LaunchPenaltyWarning visible={true} penaltyBps={500} />);

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(<LaunchPenaltyWarning visible={true} penaltyBps={333} />);

expect(screen.getByTestId('launch-penalty-rate')).toHaveTextContent(
'3.33%'
);
});

it('has an alert role so assistive tech announces it', () => {
render(<LaunchPenaltyWarning visible={true} penaltyBps={500} />);

expect(screen.getByTestId('launch-penalty-warning')).toHaveAttribute(
'role',
'alert'
);
});
});
88 changes: 88 additions & 0 deletions src/components/common/__tests__/SellFeeBreakdown.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
<SellFeeBreakdown
grossProceedsStroops={1_000_000}
launchPenalty={noPenalty}
/>
);

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(
<SellFeeBreakdown grossProceedsStroops={null} launchPenalty={noPenalty} />
);

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(
<SellFeeBreakdown
grossProceedsStroops={1_000_000}
launchPenalty={penalty}
/>
);

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(
<SellFeeBreakdown
grossProceedsStroops={1_000_000}
launchPenalty={penalty}
/>
);

expect(screen.getByTestId('sell-fee-breakdown-penalty')).toHaveTextContent(
'Launch penalty (20%)'
);
expect(screen.getByTestId('sell-fee-breakdown-net')).toHaveTextContent(
'0.08 XLM'
);
});
});
Loading
Loading