From 9963968974e14566c132d7356831d3abc702fbef Mon Sep 17 00:00:00 2001 From: DanbabaJr <304314335+DanbabaJr@users.noreply.github.com> Date: Mon, 7 Sep 2026 15:48:06 +0000 Subject: [PATCH 1/4] Add quorum threshold configuration to creator dashboard governance tab (#828) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce a Governance tab on the creator dashboard with a Quorum Settings section: a 1-50% slider pre-set to the current quorumBps from the key detail API, with the selected value shown as both a percentage and basis points, submitted via the set_quorum_bps contract call. The mutation invalidates the creator detail query and drops the course cache entry so the slider reflects the updated quorum without reload. Generated with Codebuff πŸ€– Co-Authored-By: Codebuff --- src/components/common/QuorumSettingsPanel.tsx | 129 ++++++++++++++++++ src/hooks/useCreatorContractActions.ts | 28 +++- src/index.css | 42 ++++++ src/pages/CreatorDashboardPage.tsx | 29 ++++ src/services/course.service.ts | 5 + 5 files changed, 231 insertions(+), 2 deletions(-) create mode 100644 src/components/common/QuorumSettingsPanel.tsx diff --git a/src/components/common/QuorumSettingsPanel.tsx b/src/components/common/QuorumSettingsPanel.tsx new file mode 100644 index 00000000..6d90aab1 --- /dev/null +++ b/src/components/common/QuorumSettingsPanel.tsx @@ -0,0 +1,129 @@ +import React, { useEffect, useState } from 'react'; +import { Button } from '@/components/ui/button'; + +const MIN_QUORUM_PCT = 1; +const MAX_QUORUM_PCT = 50; +/** Slider position used before the key detail API supplies a stored value. */ +const DEFAULT_QUORUM_PCT = 10; + +export interface QuorumSettingsPanelProps { + /** Current proposal quorum in basis points (100–5000 = 1%–50%). */ + quorumBps?: number; + /** Called with the new quorum threshold in basis points. */ + onSubmit: (quorumBps: number) => void; + isSubmitting?: boolean; +} + +const clampPct = (pct: number): number => + Math.min(MAX_QUORUM_PCT, Math.max(MIN_QUORUM_PCT, pct)); + +const bpsToPct = (bps: number | undefined): number => + bps != null ? clampPct(bps / 100) : DEFAULT_QUORUM_PCT; + +/** + * Quorum Settings panel for the creator dashboard governance tab (#828). + * + * Shows the current proposal quorum (derived from `quorumBps` fetched from + * the key detail API), lets the creator pick a new minimum participation + * percentage on a 1%–50% slider, and submits via the `set_quorum_bps` + * contract function. The selected value is shown as both a percentage and + * in basis points below the slider. + */ +const QuorumSettingsPanel: React.FC = ({ + quorumBps, + onSubmit, + isSubmitting = false, +}) => { + const [quorumPct, setQuorumPct] = useState(() => bpsToPct(quorumBps)); + + // Keep the slider aligned with the stored value after a successful save + // refetches it (acceptance: updated quorum reflected without reload). + useEffect(() => { + setQuorumPct(bpsToPct(quorumBps)); + }, [quorumBps]); + + const quorumBpsValue = quorumPct * 100; + const isUnchanged = + quorumBps != null && quorumBps === quorumBpsValue; + const fillPercent = + ((quorumPct - MIN_QUORUM_PCT) / (MAX_QUORUM_PCT - MIN_QUORUM_PCT)) * + 100; + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + if (isSubmitting) return; + onSubmit(quorumBpsValue); + }; + + return ( +
+
+ + setQuorumPct(Number(e.target.value))} + disabled={isSubmitting} + aria-describedby="quorum-hint" + aria-valuetext={`${quorumPct} percent, ${quorumBpsValue} basis points`} + style={{ + background: `linear-gradient(to right, rgba(251, 191, 36, 0.85) 0%, rgba(251, 191, 36, 0.85) ${fillPercent}%, rgba(255, 255, 255, 0.12) ${fillPercent}%, rgba(255, 255, 255, 0.12) 100%)`, + }} + /> +
+
+

+ {quorumPct}% +

+

+ {quorumBpsValue.toLocaleString()} bps +

+
+

+ Min 1% β€” Max 50% +

+
+

+ Minimum percentage of holders that must vote for a proposal to + pass +

+
+ + +
+ ); +}; + +export default QuorumSettingsPanel; \ No newline at end of file diff --git a/src/hooks/useCreatorContractActions.ts b/src/hooks/useCreatorContractActions.ts index db5906fa..7108547c 100644 --- a/src/hooks/useCreatorContractActions.ts +++ b/src/hooks/useCreatorContractActions.ts @@ -1,12 +1,14 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'; import { queryKeys } from '@/lib/queryKeys'; +import { cacheManager } from '@/utils/cache.utils'; import showToast from '@/utils/toast.util'; import { getSignatureErrorMessage } from '@/utils/errorHandling.utils'; import type { CreatorMetadataChange } from '@/utils/creatorMetadata.utils'; /** - * Creator-facing contract calls issued from the dashboard settings tab - * (`update_metadata` β€” #818, `configure_auction` / `cancel_auction` β€” #816). + * Creator-facing contract calls issued from the dashboard tabs + * (`update_metadata` β€” #818, `configure_auction` / `cancel_auction` β€” #816, + * `set_launch_penalty`, `set_max_buy_quantity`, `set_quorum_bps` β€” #828). * * The on-chain wiring is not in the client yet, so each mutation simulates * signing latency and resolves. On success the creator detail query is @@ -122,3 +124,25 @@ export function useSetMaxBuyQuantityMutation(creatorId: string) { }, }); } + +export function useSetQuorumBpsMutation(creatorId: string) { + const queryClient = useQueryClient(); + + return useMutation({ + mutationKey: ['contract', 'set_quorum_bps', creatorId], + mutationFn: (quorumBps: number) => + submitContractCall('set_quorum_bps', { creatorId, quorumBps }), + onError: error => { + showToast.error(getSignatureErrorMessage(error)); + }, + onSuccess: () => { + // Drop the 30s course cache entry so the refetch below returns the + // freshly committed quorum and the slider reflects it immediately. + cacheManager.invalidate(`course_${creatorId}`); + queryClient.invalidateQueries({ + queryKey: queryKeys.creators.detail(creatorId), + }); + showToast.success('Quorum threshold updated'); + }, + }); +} diff --git a/src/index.css b/src/index.css index 1946f6f8..965803bb 100644 --- a/src/index.css +++ b/src/index.css @@ -213,6 +213,48 @@ height: 1rem; } + /* Quorum threshold slider on the creator dashboard governance tab (#828). + * The filled track is painted inline via a linear-gradient background; + * these rules only shape the track and thumb. */ + .quorum-slider { + -webkit-appearance: none; + appearance: none; + height: 0.375rem; + border-radius: 9999px; + outline: none; + } + + .quorum-slider:disabled { + opacity: 0.5; + cursor: not-allowed; + } + + .quorum-slider::-webkit-slider-thumb { + -webkit-appearance: none; + appearance: none; + width: 1.125rem; + height: 1.125rem; + border-radius: 9999px; + background: var(--chart-4); + border: 2px solid rgba(255, 255, 255, 0.9); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.35); + cursor: pointer; + } + + .quorum-slider::-moz-range-thumb { + width: 1.125rem; + height: 1.125rem; + border-radius: 9999px; + background: var(--chart-4); + border: 2px solid rgba(255, 255, 255, 0.9); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.35); + cursor: pointer; + } + + .quorum-slider:focus-visible { + box-shadow: 0 0 0 3px var(--ring); + } + @media (prefers-reduced-motion: reduce) { .skeleton-shimmer { animation: none; diff --git a/src/pages/CreatorDashboardPage.tsx b/src/pages/CreatorDashboardPage.tsx index a9b67392..19a12556 100644 --- a/src/pages/CreatorDashboardPage.tsx +++ b/src/pages/CreatorDashboardPage.tsx @@ -6,12 +6,14 @@ import CreatorMetadataForm from '@/components/common/CreatorMetadataForm'; import AuctionSetupPanel from '@/components/common/AuctionSetupPanel'; import LaunchPenaltyPanel from '@/components/common/LaunchPenaltyPanel'; import MaxBuyQuantityPanel from '@/components/common/MaxBuyQuantityPanel'; +import QuorumSettingsPanel from '@/components/common/QuorumSettingsPanel'; import { useCancelAuctionMutation, useConfigureAuctionMutation, useUpdateMetadataMutation, useSetLaunchPenaltyMutation, useSetMaxBuyQuantityMutation, + useSetQuorumBpsMutation, } from '@/hooks/useCreatorContractActions'; import { formatDisplayKeyPrice, resolveCreatorKeyPriceStroops } from '@/utils/keyPriceDisplay.utils'; import { formatNumber } from '@/utils/numberFormat.utils'; @@ -19,6 +21,7 @@ import { formatNumber } from '@/utils/numberFormat.utils'; const TABS = [ { label: 'Overview', value: 'overview' }, { label: 'Settings', value: 'settings' }, + { label: 'Governance', value: 'governance' }, ]; const CARD_CLASS = @@ -40,6 +43,7 @@ export default function CreatorDashboardPage() { const cancelAuction = useCancelAuctionMutation(id); const setLaunchPenalty = useSetLaunchPenaltyMutation(id); const setMaxBuyQuantity = useSetMaxBuyQuantityMutation(id); + const setQuorumBps = useSetQuorumBpsMutation(id); const setTab = (value: string) => { setSearchParams( @@ -199,6 +203,31 @@ export default function CreatorDashboardPage() { )} + + {activeTab === 'governance' && ( +
+
+

+ Quorum Settings +

+

+ Set the minimum percentage of holders that must participate + in a vote for a proposal to pass. +

+ setQuorumBps.mutate(quorumBps)} + /> +
+
+ )} ); diff --git a/src/services/course.service.ts b/src/services/course.service.ts index be837f8e..f8821946 100644 --- a/src/services/course.service.ts +++ b/src/services/course.service.ts @@ -53,6 +53,11 @@ export interface Course { * Applied to sells within the first 7 days after key creation. */ launchPenaltyBps?: number; + /** + * Proposal quorum threshold in basis points (100–5000 = 1%–50%). + * Minimum holder participation required for a governance proposal to pass. + */ + quorumBps?: number; /** Optional co-creator wallet configured for this creator key. */ coCreatorAddress?: string; /** Co-creator revenue share in basis points. */ From 319a0eda4100d4d09534a7fd40213858bab733ba Mon Sep 17 00:00:00 2001 From: DanbabaJr <304314335+DanbabaJr@users.noreply.github.com> Date: Mon, 7 Sep 2026 15:48:14 +0000 Subject: [PATCH 2/4] Resolve duplicated file contents left by the #898 merge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The #898 merge appended a second implementation to KeySimulationTool.tsx, SlippageToleranceSelector.tsx and slippageTolerance.utils.ts (and spliced imports into interfaces), leaving the tree unbuildable. Keep the implementation each production consumer is wired to and drop the orphaned duplicates and their test suites so tsc and the test runner pass again. Generated with Codebuff πŸ€– Co-Authored-By: Codebuff --- src/components/common/KeySimulationTool.tsx | 151 +----------------- .../common/SlippageToleranceSelector.tsx | 151 +----------------- .../SlippageToleranceSelector.test.tsx | 109 +------------ .../__tests__/slippageTolerance.utils.test.ts | 89 +---------- src/utils/slippageTolerance.utils.ts | 111 +------------ 5 files changed, 9 insertions(+), 602 deletions(-) diff --git a/src/components/common/KeySimulationTool.tsx b/src/components/common/KeySimulationTool.tsx index 626998bc..0ca7e6fd 100644 --- a/src/components/common/KeySimulationTool.tsx +++ b/src/components/common/KeySimulationTool.tsx @@ -181,158 +181,9 @@ 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)} -

)}
); }; -export default KeySimulationTool; +export default KeySimulationTool; \ No newline at end of file diff --git a/src/components/common/SlippageToleranceSelector.tsx b/src/components/common/SlippageToleranceSelector.tsx index f249bbf1..bb662e40 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,68 +56,14 @@ 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); } }; return ( -
+
Slippage tolerance = ({ {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`} -

- )} - -
); }; -export default SlippageToleranceSelector; +export default SlippageToleranceSelector; \ No newline at end of file diff --git a/src/components/common/__tests__/SlippageToleranceSelector.test.tsx b/src/components/common/__tests__/SlippageToleranceSelector.test.tsx index 2e0cd20b..8d06faae 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, - }); }); -}); +}); \ No newline at end of file diff --git a/src/utils/__tests__/slippageTolerance.utils.test.ts b/src/utils/__tests__/slippageTolerance.utils.test.ts index 6ae36f0c..238f0438 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); }); -}); +}); \ No newline at end of file diff --git a/src/utils/slippageTolerance.utils.ts b/src/utils/slippageTolerance.utils.ts index 6b29ec3e..2f22ab94 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 }; -} +} \ No newline at end of file From 23600d78d7784734f8e6aa8c39858ce98a579994 Mon Sep 17 00:00:00 2001 From: DanbabaJr <304314335+DanbabaJr@users.noreply.github.com> Date: Mon, 7 Sep 2026 18:35:29 +0000 Subject: [PATCH 3/4] Fix lint: remove stray closing braces in slippage tolerance files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit πŸ€– Generated with Codebuff Co-Authored-By: Codebuff --- src/utils/__tests__/slippageTolerance.utils.test.ts | 1 - src/utils/slippageTolerance.utils.ts | 1 - 2 files changed, 2 deletions(-) diff --git a/src/utils/__tests__/slippageTolerance.utils.test.ts b/src/utils/__tests__/slippageTolerance.utils.test.ts index c39dffd7..9c094a14 100644 --- a/src/utils/__tests__/slippageTolerance.utils.test.ts +++ b/src/utils/__tests__/slippageTolerance.utils.test.ts @@ -133,5 +133,4 @@ describe('slippageTolerance.utils', () => { }); }); }); -}); diff --git a/src/utils/slippageTolerance.utils.ts b/src/utils/slippageTolerance.utils.ts index b9175d62..5238ddb6 100644 --- a/src/utils/slippageTolerance.utils.ts +++ b/src/utils/slippageTolerance.utils.ts @@ -130,7 +130,6 @@ export function computeSlippageBounds( : null, }; } -} export type TradeSide = 'buy' | 'sell'; From b31d226346243ce0f26060381e928a02be0372c6 Mon Sep 17 00:00:00 2001 From: DanbabaJr <304314335+DanbabaJr@users.noreply.github.com> Date: Mon, 7 Sep 2026 19:57:40 +0000 Subject: [PATCH 4/4] Fix build: remove duplicate default export in KeySimulationTool MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit πŸ€– Generated with Codebuff Co-Authored-By: Codebuff --- src/components/common/KeySimulationTool.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/src/components/common/KeySimulationTool.tsx b/src/components/common/KeySimulationTool.tsx index f07a1d2b..28044f9a 100644 --- a/src/components/common/KeySimulationTool.tsx +++ b/src/components/common/KeySimulationTool.tsx @@ -187,5 +187,4 @@ const KeySimulationTool: React.FC = ({ }; export default KeySimulationTool; -export default KeySimulationTool;