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);
- });
-});