diff --git a/src/hooks/useKeyTwap.ts b/src/hooks/useKeyTwap.ts new file mode 100644 index 00000000..6dd8f504 --- /dev/null +++ b/src/hooks/useKeyTwap.ts @@ -0,0 +1,13 @@ +import { useQuery } from '@tanstack/react-query'; +import { queryKeys } from '@/lib/queryKeys'; +import { courseService } from '@/services/course.service'; + +export function useKeyTwap(keyId: string) { + return useQuery({ + queryKey: queryKeys.creators.twap(keyId), + queryFn: () => courseService.getKeyTwap(keyId), + enabled: !!keyId, + staleTime: 60_000, + retry: false, + }); +} diff --git a/src/lib/queryKeys.ts b/src/lib/queryKeys.ts index fa3def57..a09d09d6 100644 --- a/src/lib/queryKeys.ts +++ b/src/lib/queryKeys.ts @@ -25,6 +25,8 @@ export const queryKeys = { ['creators', creatorId, 'holders'] as const, activity: (creatorId: string) => ['creators', creatorId, 'activity'] as const, + twap: (creatorId: string) => + ['creators', creatorId, 'twap', '24h'] as const, }, wallet: { holdings: (address: string) => ['wallet', address, 'holdings'] as const, diff --git a/src/pages/CreatorDetailPage.tsx b/src/pages/CreatorDetailPage.tsx index c0f524a2..f6cdade9 100644 --- a/src/pages/CreatorDetailPage.tsx +++ b/src/pages/CreatorDetailPage.tsx @@ -30,6 +30,9 @@ import CoCreatorSection from '@/components/creator/CoCreatorSection'; import ShareTwitterButton from '@/components/common/ShareTwitterButton'; import { usePurchaseConfetti } from '@/hooks/usePurchaseConfetti'; import { useDocumentTitle } from '@/hooks/useDocumentTitle'; +import { useKeyTwap } from '@/hooks/useKeyTwap'; +import Skeleton from '@/components/ui/skeleton'; +import { Tooltip } from '@/components/ui/tooltip'; function CreatorDetailPageContent() { usePurchaseConfetti(); @@ -66,6 +69,7 @@ function CreatorDetailPageContent() { // return a per-user one. Only shown for authenticated users. const nextBuyAllowedAt = userPosition?.nextBuyAllowedAt ?? creator?.nextBuyAllowedAt ?? null; + const { data: twap, isLoading: isTwapLoading } = useKeyTwap(id || ''); // Track stale data indicator const { shouldShowBadge, handleRefetch } = useCreatorProfileStaleIndicator( @@ -149,6 +153,9 @@ function CreatorDetailPageContent() { supply: (index + 1) * 20, priceXLM: priceStroops / 10_000_000, })); + const spotPrice = resolveCreatorKeyPriceStroops(creator); + const twapPrice = twap?.priceStroops ?? null; + const twapDelta = twapPrice != null && spotPrice != null ? twapPrice - spotPrice : null; const hasRealStakingData = creator.stakingPoolBalance != null || @@ -219,6 +226,27 @@ function CreatorDetailPageContent() { /> + {isTwapLoading ? ( +
+
+
+ ) : twapPrice != null ? ( +
+
+
+
+ TWAP (24h) + + + +
+
{formatDisplayKeyPrice(twapPrice)}
+
+ {twapDelta != null && {twapDelta < 0 ? '▼' : '▲'} {formatDisplayKeyPrice(Math.abs(twapDelta))} vs spot} +
+
+ ) : null} + {/* Staking Rewards */} diff --git a/src/services/course.service.ts b/src/services/course.service.ts index 9dfcad6f..bc04461d 100644 --- a/src/services/course.service.ts +++ b/src/services/course.service.ts @@ -129,6 +129,12 @@ export interface KeyHoldersPage { nextCursor: string | null; } +export interface KeyTwap { + /** 24-hour time-weighted average price in stroops. */ + priceStroops: number | null; + window?: string; +} + class CourseService extends BaseApiService { private readonly PROFILE_CACHE_TTL = 30000; // 30 seconds @@ -232,6 +238,19 @@ class CourseService extends BaseApiService { } } + // Get the time-weighted average price - GET /keys/:keyId/twap + async getKeyTwap(keyId: string, window = '24h'): Promise { + try { + const response = await this.api.get>( + `/keys/${keyId}/twap`, + { params: { window } } + ); + return response.data.data; + } catch (error) { + throw this.handleError(error); + } + } + // Get enrolled courses - GET /courses/enrolled async getEnrolledCourses(): Promise { try { diff --git a/src/utils/__tests__/slippageTolerance.utils.test.ts b/src/utils/__tests__/slippageTolerance.utils.test.ts index ba9bdb57..117d5dc3 100644 --- a/src/utils/__tests__/slippageTolerance.utils.test.ts +++ b/src/utils/__tests__/slippageTolerance.utils.test.ts @@ -126,91 +126,3 @@ describe('slippageTolerance.utils', () => { }); }); -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); - }); -});