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
13 changes: 13 additions & 0 deletions src/hooks/useKeyTwap.ts
Original file line number Diff line number Diff line change
@@ -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,
});
}
2 changes: 2 additions & 0 deletions src/lib/queryKeys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
28 changes: 28 additions & 0 deletions src/pages/CreatorDetailPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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 ||
Expand Down Expand Up @@ -219,6 +226,27 @@ function CreatorDetailPageContent() {
/>
</div>

{isTwapLoading ? (
<div className="rounded-2xl border border-white/10 bg-white/[0.03] px-5 py-4" data-testid="twap-price">
<div aria-label="Loading 24 hour TWAP" role="status"><Skeleton className="h-3 w-24" /><Skeleton className="mt-2 h-6 w-32" /></div>
</div>
) : twapPrice != null ? (
<div className="rounded-2xl border border-white/10 bg-white/[0.03] px-5 py-4" data-testid="twap-price">
<div className="flex items-center justify-between gap-4">
<div>
<div className="flex items-center gap-2 text-xs font-semibold uppercase tracking-wider text-white/55">
<span className={twapDelta != null ? (twapDelta < 0 ? 'text-emerald-400' : 'text-rose-400') : ''}>TWAP (24h)</span>
<Tooltip content="Time-weighted average price over the past 24 hours. Less sensitive to short-term manipulation.">
<button type="button" aria-label="What is 24 hour TWAP?" className="text-white/50">ⓘ</button>
</Tooltip>
</div>
<div className="mt-1 text-xl font-bold text-white">{formatDisplayKeyPrice(twapPrice)}</div>
</div>
{twapDelta != null && <span className={twapDelta < 0 ? 'text-sm font-semibold text-emerald-400' : 'text-sm font-semibold text-rose-400'}>{twapDelta < 0 ? '▼' : '▲'} {formatDisplayKeyPrice(Math.abs(twapDelta))} vs spot</span>}
</div>
</div>
) : null}

{/* Staking Rewards */}
<StakingRewardsSection {...stakingStats} isLoading={isLoading} />

Expand Down
19 changes: 19 additions & 0 deletions src/services/course.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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<KeyTwap> {
try {
const response = await this.api.get<APIResponse<KeyTwap>>(
`/keys/${keyId}/twap`,
{ params: { window } }
);
return response.data.data;
} catch (error) {
throw this.handleError(error);
}
}

// Get enrolled courses - GET /courses/enrolled
async getEnrolledCourses(): Promise<Course[]> {
try {
Expand Down
88 changes: 0 additions & 88 deletions src/utils/__tests__/slippageTolerance.utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
Loading