diff --git a/docs/admin-treasury-api.md b/docs/admin-treasury-api.md index 9cafe204..80bbd239 100644 --- a/docs/admin-treasury-api.md +++ b/docs/admin-treasury-api.md @@ -4,22 +4,20 @@ The treasury panel calls the authenticated backend API; this client repository d All routes use the existing `BaseApiService` credentials and return the standard `{ success, data, message }` envelope. -| Method | Route | `data` | -| --- | --- | --- | -| `GET` | `/admin/treasury` | `{ accumulatedFeesStroops: string, updatedAt?: string }` | -| `GET` | `/admin/treasury/distributions` | `TreasuryDistribution[]` | -| `GET` | `/admin/treasury/fees` | Recent `FeeCollected` records, newest first | -| `POST` | `/admin/treasury/distributions` | Confirmed `{ epoch: number, transactionHash: string }` | +| Method | Route | `data` | +| ------ | ------------------------------- | -------------------------------------------------------- | +| `GET` | `/admin/treasury` | `{ accumulatedFeesStroops: string, updatedAt?: string }` | +| `GET` | `/admin/treasury/distributions` | `TreasuryDistribution[]` | +| `GET` | `/admin/treasury/fees` | Recent `FeeCollected` records, newest first | +| `POST` | `/admin/treasury/distributions` | Confirmed `{ epoch: number, transactionHash: string }` | Distribution request body: ```json { - "admin": "G...", - "totalAmountStroops": "10000000", - "recipients": [ - { "address": "G...", "amountStroops": "10000000" } - ] + "admin": "G...", + "totalAmountStroops": "10000000", + "recipients": [{ "address": "G...", "amountStroops": "10000000" }] } ``` diff --git a/src/components/common/TradeDialog.tsx b/src/components/common/TradeDialog.tsx index c9ce716e..991cf639 100644 --- a/src/components/common/TradeDialog.tsx +++ b/src/components/common/TradeDialog.tsx @@ -240,7 +240,8 @@ const TradeDialog: React.FC = ({ // On open: reset state, kick off allowance check + cap fetch. useEffect(() => { if (open) { - triggerElementRef.current = document.activeElement as HTMLElement | null; + triggerElementRef.current = + document.activeElement as HTMLElement | null; setAmountText('1'); setTouched(false); setAdjustmentNote(null); @@ -274,10 +275,18 @@ const TradeDialog: React.FC = ({ } if (event.ctrlKey || event.metaKey || event.altKey) return; const key = event.key; - const presets: Record = { '1': 1, '2': 2, '3': 3, '4': 5, '5': 10 }; + const presets: Record = { + '1': 1, + '2': 2, + '3': 3, + '4': 5, + '5': 10, + }; if (!event.shiftKey && presets[key] !== undefined) { event.preventDefault(); - setAmountText(clampBuyQuantity(presets[key].toString()).value.toString()); + setAmountText( + clampBuyQuantity(presets[key].toString()).value.toString() + ); setTouched(true); return; } @@ -312,13 +321,21 @@ const TradeDialog: React.FC = ({ maxBuyable !== null ? Math.min(BUY_QUANTITY_BOUNDS.MAX_QTY, maxBuyable) : BUY_QUANTITY_BOUNDS.MAX_QTY; - const res = clampBuyQuantity(amountText.trim(), BUY_QUANTITY_BOUNDS.MIN_QTY, maxClamp); + const res = clampBuyQuantity( + amountText.trim(), + BUY_QUANTITY_BOUNDS.MIN_QTY, + maxClamp + ); if (res.adjusted) { setAmountText(res.value.toString()); if (res.reason === 'below_min') { - setAdjustmentNote(`Quantity adjusted to the minimum of ${res.value}.`); + setAdjustmentNote( + `Quantity adjusted to the minimum of ${res.value}.` + ); } else if (res.reason === 'above_max') { - setAdjustmentNote(`Quantity adjusted to the maximum of ${res.value}.`); + setAdjustmentNote( + `Quantity adjusted to the maximum of ${res.value}.` + ); } else { setAdjustmentNote(`Quantity rounded to ${res.value}.`); } @@ -336,14 +353,20 @@ const TradeDialog: React.FC = ({ const validationError = useMemo((): string | null => { const normalized = amountText.trim(); if (!normalized) return 'Please enter an amount.'; - if (!Number.isFinite(parsedAmount)) return 'Amount must be a valid number.'; + if (!Number.isFinite(parsedAmount)) + return 'Amount must be a valid number.'; if (parsedAmount <= 0) return 'Amount must be greater than zero.'; - if (isPaused) return 'Trading is currently suspended because the contract is paused.'; + if (isPaused) + return 'Trading is currently suspended because the contract is paused.'; if (side === 'buy' && atCap) return 'You have reached the maximum holding cap for this creator.'; if (side === 'buy' && maxBuyable !== null && parsedAmount > maxBuyable) return `You can only buy up to ${formatNumber(maxBuyable)} more key${maxBuyable !== 1 ? 's' : ''} (holding cap).`; - if (side === 'buy' && maxBuyQuantity != null && parsedAmount > maxBuyQuantity) + if ( + side === 'buy' && + maxBuyQuantity != null && + parsedAmount > maxBuyQuantity + ) return `Maximum ${formatNumber(maxBuyQuantity)} keys per transaction for this key`; if ( (side === 'sell' || side === 'stake' || side === 'transfer') && @@ -351,14 +374,32 @@ const TradeDialog: React.FC = ({ ) return `You can't ${side} more than your holdings (${formatNumber(availableHoldings)} keys).`; return null; - }, [amountText, parsedAmount, side, availableHoldings, atCap, maxBuyable, maxBuyQuantity, isPaused]); + }, [ + amountText, + parsedAmount, + side, + availableHoldings, + atCap, + maxBuyable, + maxBuyQuantity, + isPaused, + ]); const amountValid = validationError === null; const showError = touched && validationError !== null; const priceImpactPercent = useMemo(() => { - if (!amountValid || !Number.isFinite(parsedAmount) || (side !== 'buy' && side !== 'sell')) return 0; - return calculateTradePriceImpact({ side, quantity: parsedAmount, currentSupply: currentSupply ?? 0 }); + if ( + !amountValid || + !Number.isFinite(parsedAmount) || + (side !== 'buy' && side !== 'sell') + ) + return 0; + return calculateTradePriceImpact({ + side, + quantity: parsedAmount, + currentSupply: currentSupply ?? 0, + }); }, [amountValid, parsedAmount, side, currentSupply]); const effectiveCircuitBreakerThresholdPercent = @@ -389,7 +430,8 @@ const TradeDialog: React.FC = ({ side === 'buy' && circuitBreakerStatus?.isBreached ); - const isAllowanceChecking = side === 'stake' && allowanceStatus === 'checking'; + const isAllowanceChecking = + side === 'stake' && allowanceStatus === 'checking'; const requiresAllowanceApproval = side === 'stake' && needsApproval; const confirmDisabled = !amountValid || @@ -401,13 +443,17 @@ const TradeDialog: React.FC = ({ isPaused || requiresAllowanceApproval; - const displayCreatorName = normalizeCreatorDisplayName(creatorName) || 'Unnamed creator'; + const displayCreatorName = + normalizeCreatorDisplayName(creatorName) || 'Unnamed creator'; const title = - side === 'buy' ? 'Buy keys' - : side === 'sell' ? 'Sell keys' - : side === 'stake' ? 'Stake keys' - : 'Transfer keys'; + side === 'buy' + ? 'Buy keys' + : side === 'sell' + ? 'Sell keys' + : side === 'stake' + ? 'Stake keys' + : 'Transfer keys'; const confirmLabel = side === 'buy' @@ -443,45 +489,92 @@ const TradeDialog: React.FC = ({ // Network fee estimation effect. useEffect(() => { - if (!open) { setNetworkFeeEstimate({ status: 'idle', fee: null }); return; } + if (!open) { + setNetworkFeeEstimate({ status: 'idle', fee: null }); + return; + } if (!amountValid || !networkFeeEstimateProvider) { - setNetworkFeeEstimate({ status: 'error', fee: null }); return; + setNetworkFeeEstimate({ status: 'error', fee: null }); + return; } let cancelled = false; setNetworkFeeEstimate({ status: 'loading', fee: null }); const feeSide = side === 'buy' || side === 'sell' ? side : 'buy'; - fetchTradeNetworkFeeEstimate(networkFeeEstimateProvider, { side: feeSide, amount: parsedAmount }) + fetchTradeNetworkFeeEstimate(networkFeeEstimateProvider, { + side: feeSide, + amount: parsedAmount, + }) .then(fee => { if (cancelled) return; - setNetworkFeeEstimate(fee == null ? { status: 'error', fee: null } : { status: 'success', fee }); + setNetworkFeeEstimate( + fee == null + ? { status: 'error', fee: null } + : { status: 'success', fee } + ); }) - .catch(() => { if (!cancelled) setNetworkFeeEstimate({ status: 'error', fee: null }); }); - return () => { cancelled = true; }; + .catch(() => { + if (!cancelled) + setNetworkFeeEstimate({ status: 'error', fee: null }); + }); + return () => { + cancelled = true; + }; }, [amountValid, networkFeeEstimateProvider, open, parsedAmount, side]); const estimatedProceedsStroops = useMemo(() => { - if (side !== 'sell' || !Number.isFinite(parsedAmount) || parsedAmount <= 0) return null; + if ( + side !== 'sell' || + !Number.isFinite(parsedAmount) || + parsedAmount <= 0 + ) + return null; return estimateSellProceeds(keyPriceStroops, currentSupply, parsedAmount); }, [side, keyPriceStroops, currentSupply, parsedAmount]); const launchPenalty = useMemo( - () => calculateLaunchPenalty(estimatedProceedsStroops, createdAtLedger, currentLedger, launchPenaltyBps), - [estimatedProceedsStroops, createdAtLedger, currentLedger, launchPenaltyBps] + () => + calculateLaunchPenalty( + estimatedProceedsStroops, + createdAtLedger, + currentLedger, + launchPenaltyBps + ), + [ + estimatedProceedsStroops, + createdAtLedger, + currentLedger, + launchPenaltyBps, + ] ); const estimatedTotalStroops = useMemo(() => { - if (side !== 'buy' || !Number.isFinite(parsedAmount) || parsedAmount <= 0 || keyPriceStroops == null) return null; + if ( + side !== 'buy' || + !Number.isFinite(parsedAmount) || + parsedAmount <= 0 || + keyPriceStroops == null + ) + return null; return keyPriceStroops * parsedAmount; }, [side, keyPriceStroops, parsedAmount]); const slippageReferencePriceStroops = useMemo(() => { - if (side === 'buy') return pricePreview?.totalCostStroops ?? estimatedTotalStroops ?? null; + if (side === 'buy') + return pricePreview?.totalCostStroops ?? estimatedTotalStroops ?? null; return estimatedProceedsStroops; }, [side, pricePreview, estimatedTotalStroops, estimatedProceedsStroops]); const slippageBounds = useMemo(() => { - if (slippageReferencePriceStroops == null || (side !== 'buy' && side !== 'sell')) return null; - return computeSlippageBounds(side, slippageReferencePriceStroops, slippageTolerancePercent); + if ( + slippageReferencePriceStroops == null || + (side !== 'buy' && side !== 'sell') + ) + return null; + return computeSlippageBounds( + side, + slippageReferencePriceStroops, + slippageTolerancePercent + ); }, [side, slippageReferencePriceStroops, slippageTolerancePercent]); const handleMaxClick = () => { @@ -489,7 +582,10 @@ const TradeDialog: React.FC = ({ if (side === 'sell') { setAmountText(String(Math.max(0, availableHoldings))); } else { - const maxVal = maxBuyQuantity != null ? maxBuyQuantity : BUY_QUANTITY_BOUNDS.MAX_QTY; + const maxVal = + maxBuyQuantity != null + ? maxBuyQuantity + : BUY_QUANTITY_BOUNDS.MAX_QTY; setAmountText(String(maxVal)); } }; @@ -497,52 +593,99 @@ const TradeDialog: React.FC = ({ // Price preview fetch (buy only). useEffect(() => { if (side !== 'buy' || keyPriceStroops == null) { - setPricePreview(null); setPreviewLoading(false); return; + setPricePreview(null); + setPreviewLoading(false); + return; } if (!Number.isFinite(parsedAmount) || parsedAmount <= 0) { - setPricePreview(null); setPreviewLoading(false); setPreviewError(null); return; + setPricePreview(null); + setPreviewLoading(false); + setPreviewError(null); + return; } const timeoutId = window.setTimeout(async () => { - if (previewAbortControllerRef.current) previewAbortControllerRef.current.abort(); + if (previewAbortControllerRef.current) + previewAbortControllerRef.current.abort(); setPreviewLoading(true); setPreviewError(null); try { const preview = await fetchPricePreview({ - quantity: parsedAmount, keyPriceStroops, currentSupply: currentSupply ?? 0, protocolFeeBps, creatorFeeBps, + quantity: parsedAmount, + keyPriceStroops, + currentSupply: currentSupply ?? 0, + protocolFeeBps, + creatorFeeBps, }); setPricePreview(preview); } catch (error) { if (error instanceof Error && error.name === 'AbortError') return; - setPreviewError(error instanceof Error ? error.message : 'Failed to fetch price preview'); + setPreviewError( + error instanceof Error + ? error.message + : 'Failed to fetch price preview' + ); setPricePreview(null); } finally { setPreviewLoading(false); } }, 200); return () => clearTimeout(timeoutId); - }, [side, parsedAmount, keyPriceStroops, currentSupply, protocolFeeBps, creatorFeeBps]); + }, [ + side, + parsedAmount, + keyPriceStroops, + currentSupply, + protocolFeeBps, + creatorFeeBps, + ]); // Price preview failure logging. useEffect(() => { - if (process.env.NODE_ENV === 'test' || !open || pricePreviewFailureLogged.current) return; + if ( + process.env.NODE_ENV === 'test' || + !open || + pricePreviewFailureLogged.current + ) + return; if (side === 'buy' && keyPriceStroops == null) { - console.debug('[price-preview-failure]', { creator_name: creatorName, quantity: Number.isFinite(parsedAmount) ? parsedAmount : null, side: 'buy', reason: 'key_price_missing', timestamp: new Date().toISOString() }); + console.debug('[price-preview-failure]', { + creator_name: creatorName, + quantity: Number.isFinite(parsedAmount) ? parsedAmount : null, + side: 'buy', + reason: 'key_price_missing', + timestamp: new Date().toISOString(), + }); pricePreviewFailureLogged.current = true; } }, [open, side, keyPriceStroops, creatorName, parsedAmount]); useEffect(() => { - if (process.env.NODE_ENV === 'test' || !open || pricePreviewFailureLogged.current) return; + if ( + process.env.NODE_ENV === 'test' || + !open || + pricePreviewFailureLogged.current + ) + return; if (side === 'sell' && estimatedProceedsStroops == null) { - console.debug('[price-preview-failure]', { creator_name: creatorName, quantity: Number.isFinite(parsedAmount) ? parsedAmount : null, side: 'sell', reason: 'estimate_unavailable', timestamp: new Date().toISOString() }); + console.debug('[price-preview-failure]', { + creator_name: creatorName, + quantity: Number.isFinite(parsedAmount) ? parsedAmount : null, + side: 'sell', + reason: 'estimate_unavailable', + timestamp: new Date().toISOString(), + }); pricePreviewFailureLogged.current = true; } }, [open, side, estimatedProceedsStroops, creatorName, parsedAmount]); const handleConfirm = async () => { if (confirmDisabled) return; - if (requireConfirmation) { setConfirmationOpen(true); return; } + if (requireConfirmation) { + setConfirmationOpen(true); + return; + } await onConfirm(parsedAmount, pricePreview, slippageBounds); - if (side === 'buy' && creatorId) incrementHolding(creatorId, parsedAmount); + if (side === 'buy' && creatorId) + incrementHolding(creatorId, parsedAmount); }; const isMobile = useIsMobile(); @@ -553,19 +696,21 @@ const TradeDialog: React.FC = ({ // the confirmation screen quotes the rate the contract will actually // charge. The breakdown is derived from the live rates and the current // trade notional, so it refreshes whenever the amount changes. - const [dynamicFeeRates, setDynamicFeeRates] = useState( - { + const [dynamicFeeRates, setDynamicFeeRates] = + useState({ protocolFeeBps, creatorRoyaltyBps: creatorFeeBps, - } - ); + }); const [isFeeRateLoading, setFeeRateLoading] = useState(false); const [feeRateError, setFeeRateError] = useState(null); const feeRateAbortRef = useRef(null); useEffect(() => { if (!open) { - setDynamicFeeRates({ protocolFeeBps, creatorRoyaltyBps: creatorFeeBps }); + setDynamicFeeRates({ + protocolFeeBps, + creatorRoyaltyBps: creatorFeeBps, + }); setFeeRateLoading(false); setFeeRateError(null); return; @@ -581,7 +726,9 @@ const TradeDialog: React.FC = ({ setFeeRateError(null); courseService - .getDynamicFeeRate(creatorName, { signal: controller.signal }) + .getDynamicFeeRate(creatorId ?? creatorName, { + signal: controller.signal, + }) .then(rates => { if (controller.signal.aborted) return; // Merge over the dialog's configured rates so a partial @@ -594,14 +741,17 @@ const TradeDialog: React.FC = ({ ); setFeeRateLoading(false); }) - .catch(error => { + .catch((error: unknown) => { if ( controller.signal.aborted || (error instanceof Error && error.name === 'CanceledError') ) { return; } - if (error instanceof DOMException && error.name === 'AbortError') { + if ( + error instanceof DOMException && + error.name === 'AbortError' + ) { return; } // Keep the last known good rates; just surface the error. @@ -615,13 +765,13 @@ const TradeDialog: React.FC = ({ }, 200); return () => clearTimeout(timeoutId); - }, [open, creatorName, protocolFeeBps, creatorFeeBps]); + }, [open, creatorId, creatorName, protocolFeeBps, creatorFeeBps]); const handleFeeRateRetry = useCallback(() => { setFeeRateError(null); setFeeRateLoading(true); courseService - .getDynamicFeeRate(creatorName) + .getDynamicFeeRate(creatorId ?? creatorName) .then(rates => { setDynamicFeeRates( mergeFeeRates( @@ -631,7 +781,7 @@ const TradeDialog: React.FC = ({ ); setFeeRateLoading(false); }) - .catch(error => { + .catch((error: unknown) => { setFeeRateError( error instanceof Error ? error.message @@ -639,7 +789,7 @@ const TradeDialog: React.FC = ({ ); setFeeRateLoading(false); }); - }, [creatorName, protocolFeeBps, creatorFeeBps]); + }, [creatorId, creatorName, protocolFeeBps, creatorFeeBps]); const dynamicFeeBreakdown = useMemo(() => { if (side === 'buy') { @@ -688,11 +838,17 @@ const TradeDialog: React.FC = ({ data-testid="trade-dialog-paused-alert" className="flex items-start gap-2.5 rounded-xl border border-red-500/30 bg-red-500/10 p-3 text-xs text-red-200" > -