Skip to content
Merged
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
39 changes: 32 additions & 7 deletions src/components/common/BuySellKeyFlow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,16 @@ import {
} from '@/utils/keyPriceDisplay.utils';
import SlippageToleranceSelector from '@/components/common/SlippageToleranceSelector';
import PriceImpactWarning from '@/components/common/PriceImpactWarning';
import PriceImpactOverrideCheckbox from '@/components/common/PriceImpactOverrideCheckbox';
import TradeConfirmationModal from '@/components/common/TradeConfirmationModal';
import LaunchPenaltyWarning from '@/components/common/LaunchPenaltyWarning';
import {
DEFAULT_SLIPPAGE_TOLERANCE_PERCENT,
computeSlippageBounds,
type SlippageBounds,
} from '@/utils/slippageTolerance.utils';
import {
calculateTradePriceImpact,
PRICE_IMPACT_THRESHOLD_PERCENT,
isHighPriceImpact,
} from '@/utils/priceImpact.utils';
import { calculateLaunchPenalty } from '@/utils/launchPenalty.utils';
import {
Expand All @@ -30,6 +30,7 @@ import type { KeyConfig } from '@/services/course.service';
import SpreadIndicator from '@/components/common/SpreadIndicator';
import { cn } from '@/lib/utils';
import { ArrowRight } from 'lucide-react';
import { useSlippageTolerancePreference } from '@/hooks/useSlippageTolerancePreference';

export interface BuySellTradeParams {
creatorId?: string;
Expand Down Expand Up @@ -100,7 +101,10 @@ export const BuySellKeyFlow: React.FC<BuySellKeyFlowProps> = ({
const [amountText, setAmountText] = useState('1');
const [touched, setTouched] = useState(false);
const [slippageTolerancePercent, setSlippageTolerancePercent] =
useState<number>(DEFAULT_SLIPPAGE_TOLERANCE_PERCENT);
useSlippageTolerancePreference();
const [acknowledgedImpactKey, setAcknowledgedImpactKey] = useState<
string | null
>(null);
const [confirmationOpen, setConfirmationOpen] = useState(false);
const [internalSubmitting, setInternalSubmitting] = useState(false);

Expand Down Expand Up @@ -215,6 +219,12 @@ export const BuySellKeyFlow: React.FC<BuySellKeyFlowProps> = ({
currentSupply: currentSupply ?? 0,
});
}, [isValid, parsedAmount, side, currentSupply]);
const impactWarningActive =
isValid &&
isHighPriceImpact(priceImpactPercent, slippageTolerancePercent);
const impactAcknowledgementKey = `${side}:${parsedAmount}:${currentSupply ?? 0}:${slippageTolerancePercent}`;
const impactAcknowledged =
acknowledgedImpactKey === impactAcknowledgementKey;

// Early sell launch penalty
const launchPenalty = useMemo(() => {
Expand All @@ -234,12 +244,13 @@ export const BuySellKeyFlow: React.FC<BuySellKeyFlowProps> = ({
// Open confirmation modal
const handleReviewOrder = () => {
setTouched(true);
if (!isValid) return;
if (!isValid || (impactWarningActive && !impactAcknowledged)) return;
setConfirmationOpen(true);
};

// Final submission
const handleConfirmSubmission = async () => {
if (impactWarningActive && !impactAcknowledged) return;
setInternalSubmitting(true);
const tradeParams: BuySellTradeParams = {
creatorId,
Expand Down Expand Up @@ -461,17 +472,31 @@ export const BuySellKeyFlow: React.FC<BuySellKeyFlowProps> = ({
/>
</div>

{/* Price Impact Warning when impact exceeds 5% */}
{/* Price impact warning when impact exceeds the selected tolerance */}
<PriceImpactWarning
impactPercent={priceImpactPercent}
threshold={PRICE_IMPACT_THRESHOLD_PERCENT}
threshold={slippageTolerancePercent}
/>
{impactWarningActive && (
<PriceImpactOverrideCheckbox
checked={impactAcknowledged}
onChange={checked =>
setAcknowledgedImpactKey(
checked ? impactAcknowledgementKey : null
)
}
/>
)}

{/* Review / Proceed to Confirmation Button */}
<Button
type="button"
onClick={handleReviewOrder}
disabled={!isValid || isSubmitting}
disabled={
!isValid ||
isSubmitting ||
(impactWarningActive && !impactAcknowledged)
}
data-testid="trade-review-button"
className={cn(
'w-full rounded-xl py-3 font-bold text-sm shadow-md transition-all',
Expand Down
27 changes: 27 additions & 0 deletions src/components/common/PriceImpactOverrideCheckbox.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import React from 'react';

export interface PriceImpactOverrideCheckboxProps {
checked: boolean;
onChange: (checked: boolean) => void;
}

/** Requires explicit acknowledgement before a high-impact trade can proceed. */
const PriceImpactOverrideCheckbox: React.FC<
PriceImpactOverrideCheckboxProps
> = ({ checked, onChange }) => (
<label className="flex cursor-pointer items-start gap-2 text-xs text-white/80">
<input
type="checkbox"
checked={checked}
onChange={event => onChange(event.target.checked)}
className="mt-0.5 accent-amber-400"
data-testid="price-impact-override-checkbox"
/>
<span>
I understand this trade&apos;s estimated price impact exceeds my
slippage tolerance and want to proceed.
</span>
</label>
);

export default PriceImpactOverrideCheckbox;
8 changes: 4 additions & 4 deletions src/components/common/PriceImpactWarning.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ export const PriceImpactWarning: React.FC<PriceImpactWarningProps> = ({
/>
<div className="space-y-0.5">
<p className="font-semibold">
High Price Impact Warning:{' '}
Estimated Price Impact:{' '}
<span
className="font-mono font-bold"
data-testid="price-impact-value"
Expand All @@ -62,9 +62,9 @@ export const PriceImpactWarning: React.FC<PriceImpactWarningProps> = ({
</span>
</p>
<p className="text-white/70">
This trade has a price impact exceeding {threshold}%. Your order
will execute at a significantly different price from the current
spot price.
This trade has a price impact exceeding {threshold}%. Your
estimated execution price differs from the current spot price by
the amount shown above.
</p>
</div>
</div>
Expand Down
6 changes: 5 additions & 1 deletion src/components/common/TradeConfirmationModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -215,10 +215,14 @@ export const TradeConfirmationModal: React.FC<TradeConfirmationModalProps> = ({

{/* Price Impact Warning if exceeds 5% */}
{priceImpactPercent != null &&
isHighPriceImpact(priceImpactPercent) && (
isHighPriceImpact(
priceImpactPercent,
slippageTolerancePercent
) && (
<div data-testid="confirmation-modal-impact-warning">
<PriceImpactWarning
impactPercent={priceImpactPercent}
threshold={slippageTolerancePercent}
/>
</div>
)}
Expand Down
36 changes: 29 additions & 7 deletions src/components/common/TradeDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,18 +42,19 @@ import {
type FeeBreakdown,
} from '@/utils/pricePreview.utils';
import PriceImpactWarning from '@/components/common/PriceImpactWarning';
import PriceImpactOverrideCheckbox from '@/components/common/PriceImpactOverrideCheckbox';
import TradeConfirmationModal from '@/components/common/TradeConfirmationModal';
import {
calculateTradePriceImpact,
PRICE_IMPACT_THRESHOLD_PERCENT,
isHighPriceImpact,
} from '@/utils/priceImpact.utils';
import {
DEFAULT_SLIPPAGE_TOLERANCE_PERCENT,
computeSlippageBounds,
type SlippageBounds,
} from '@/utils/slippageTolerance.utils';
import type { KeyConfig } from '@/services/course.service';
import SpreadIndicator from '@/components/common/SpreadIndicator';
import { useSlippageTolerancePreference } from '@/hooks/useSlippageTolerancePreference';

export type TradeSide = 'buy' | 'sell';

Expand Down Expand Up @@ -121,9 +122,11 @@ const TradeDialog: React.FC<TradeDialogProps> = ({
const [pricePreview, setPricePreview] = useState<FeeBreakdown | null>(null);
const [previewLoading, setPreviewLoading] = useState(false);
const [previewError, setPreviewError] = useState<string | null>(null);
const [slippageTolerancePercent, setSlippageTolerancePercent] = useState(
DEFAULT_SLIPPAGE_TOLERANCE_PERCENT
);
const [slippageTolerancePercent, setSlippageTolerancePercent] =
useSlippageTolerancePreference();
const [acknowledgedImpactKey, setAcknowledgedImpactKey] = useState<
string | null
>(null);
const [confirmationOpen, setConfirmationOpen] = useState(false);
const amountInputRef = useRef<HTMLInputElement | null>(null);
const pricePreviewFailureLogged = useRef(false);
Expand All @@ -146,7 +149,7 @@ const TradeDialog: React.FC<TradeDialogProps> = ({
setPricePreview(null);
setPreviewLoading(false);
setPreviewError(null);
setSlippageTolerancePercent(DEFAULT_SLIPPAGE_TOLERANCE_PERCENT);
setAcknowledgedImpactKey(null);
pricePreviewFailureLogged.current = false;
}
}, [open]);
Expand Down Expand Up @@ -339,6 +342,12 @@ const TradeDialog: React.FC<TradeDialogProps> = ({
currentSupply: currentSupply ?? 0,
});
}, [amountValid, parsedAmount, side, currentSupply]);
const impactWarningActive =
amountValid &&
isHighPriceImpact(priceImpactPercent, slippageTolerancePercent);
const impactAcknowledgementKey = `${side}:${parsedAmount}:${currentSupply ?? 0}:${slippageTolerancePercent}`;
const impactAcknowledged =
acknowledgedImpactKey === impactAcknowledgementKey;

const handleMaxClick = () => {
setTouched(true);
Expand Down Expand Up @@ -615,9 +624,19 @@ const TradeDialog: React.FC<TradeDialogProps> = ({
)}
<PriceImpactWarning
impactPercent={priceImpactPercent}
threshold={PRICE_IMPACT_THRESHOLD_PERCENT}
threshold={slippageTolerancePercent}
className="mt-2"
/>
{impactWarningActive && (
<PriceImpactOverrideCheckbox
checked={impactAcknowledged}
onChange={checked =>
setAcknowledgedImpactKey(
checked ? impactAcknowledgementKey : null
)
}
/>
)}
</div>
)}
</div>
Expand All @@ -639,6 +658,7 @@ const TradeDialog: React.FC<TradeDialogProps> = ({
<Button
type="button"
onClick={() => {
if (impactWarningActive && !impactAcknowledged) return;
if (requireConfirmation) {
setConfirmationOpen(true);
} else {
Expand All @@ -648,6 +668,7 @@ const TradeDialog: React.FC<TradeDialogProps> = ({
disabled={
!amountValid ||
isSubmitting ||
(impactWarningActive && !impactAcknowledged) ||
(side === 'buy' && (previewLoading || previewError != null))
}
aria-busy={isSubmitting || undefined}
Expand Down Expand Up @@ -678,6 +699,7 @@ const TradeDialog: React.FC<TradeDialogProps> = ({
minPriceStroops={slippageBounds?.minPriceStroops ?? null}
priceImpactPercent={priceImpactPercent}
onConfirm={async () => {
if (impactWarningActive && !impactAcknowledged) return;
await onConfirm(parsedAmount, pricePreview, slippageBounds);
setConfirmationOpen(false);
}}
Expand Down
16 changes: 16 additions & 0 deletions src/components/common/__tests__/BuySellKeyFlow.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ const mockShowToast = vi.mocked(showToast);
describe('BuySellKeyFlow (#919)', () => {
beforeEach(() => {
vi.clearAllMocks();
window.localStorage.clear();
});

it('renders buy/sell input panel with amount field and max button', () => {
Expand Down Expand Up @@ -104,6 +105,10 @@ describe('BuySellKeyFlow (#919)', () => {

expect(screen.getByTestId('price-impact-warning')).toBeInTheDocument();
expect(screen.getByTestId('price-impact-value')).toBeInTheDocument();
expect(
screen.getByTestId('price-impact-override-checkbox')
).toBeInTheDocument();
expect(screen.getByTestId('trade-review-button')).toBeDisabled();
});

it('does not show price impact warning when impact is within 5%', () => {
Expand Down Expand Up @@ -141,6 +146,9 @@ describe('BuySellKeyFlow (#919)', () => {

// Select 2% slippage tolerance
fireEvent.click(screen.getByTestId('slippage-preset-2'));
if (screen.queryByTestId('price-impact-override-checkbox')) {
fireEvent.click(screen.getByTestId('price-impact-override-checkbox'));
}

// Click review order button
fireEvent.click(screen.getByTestId('trade-review-button'));
Expand Down Expand Up @@ -178,6 +186,9 @@ describe('BuySellKeyFlow (#919)', () => {
fireEvent.change(amountInput, { target: { value: '3' } });

fireEvent.click(screen.getByTestId('slippage-preset-1'));
if (screen.queryByTestId('price-impact-override-checkbox')) {
fireEvent.click(screen.getByTestId('price-impact-override-checkbox'));
}
fireEvent.click(screen.getByTestId('trade-review-button'));

expect(
Expand All @@ -203,6 +214,7 @@ describe('BuySellKeyFlow (#919)', () => {
creatorName="Alex Rivers"
availableHoldings={10}
keyPriceStroops={1_000_000}
currentSupply={100}
protocolFeeBps={0}
creatorFeeBps={0}
onSubmitTrade={onSubmitTrade}
Expand All @@ -215,6 +227,9 @@ describe('BuySellKeyFlow (#919)', () => {

// Select 0.5% slippage
fireEvent.click(screen.getByTestId('slippage-preset-0.5'));
if (screen.queryByTestId('price-impact-override-checkbox')) {
fireEvent.click(screen.getByTestId('price-impact-override-checkbox'));
}

// Open confirmation modal
fireEvent.click(screen.getByTestId('trade-review-button'));
Expand Down Expand Up @@ -263,6 +278,7 @@ describe('BuySellKeyFlow (#919)', () => {
creatorName="Alex Rivers"
availableHoldings={10}
keyPriceStroops={1_000_000}
currentSupply={100}
initialSide="sell"
onSubmitTrade={onSubmitTrade}
onError={onError}
Expand Down
Loading
Loading