From dd344ab22d01b3afe6721b3a2c94ba40b047501a Mon Sep 17 00:00:00 2001 From: CNduka001 Date: Sat, 26 Sep 2026 08:43:03 +0100 Subject: [PATCH] feat: add user profile page with held keys, staking positions, and trade history (#921) Build the user profile page showing all creator keys held, active staking positions, and a paginated trading history for the connected wallet. - ProfilePage now serves both the owner dashboard (/profile) and a shareable public portfolio (/profile/:wallet). Wallet-specific actions (buy/sell, claim, share) render only when the viewer owns the profile. - PortfolioSummaryHeader sums held keys and staked positions at current bond-curve prices, with per-segment breakdown and unrealised PnL. - HeldKeysGrid values every held key from its bonding-curve price and shows per-position unrealised PnL, sorted by total value. - StakingPositionsList renders staked quantity, current value, a live lock countdown, and claimable rewards with a Claim action for the owner. - TradeHistoryTable gains cursor-based infinite scroll via an IntersectionObserver sentinel, keeping the Load More button as a manual fallback. - New wallet.service / stakingPositions.service back the holdings and staking-positions queries; useWalletHoldings now fetches real holdings instead of resolving empty. - Profile claim invalidates the staking-positions cache on success. Tests stub @/services/wallet.service in the LandingPage buy/sell flow integrations: those suites drive the real useWalletHoldings query, which now performs a network request with no backend behind it in tests. --- src/components/common/HeldKeysGrid.tsx | 204 ++++++++++ .../common/PortfolioSummaryHeader.tsx | 210 ++++++++++ .../common/StakingPositionsList.tsx | 263 +++++++++++++ src/components/common/TradeHistoryTable.tsx | 27 ++ .../common/__tests__/HeldKeysGrid.test.tsx | 157 ++++++++ .../__tests__/PortfolioSummaryHeader.test.tsx | 132 +++++++ .../__tests__/StakingPositionsList.test.tsx | 161 ++++++++ .../TradeHistoryTable.infiniteScroll.test.tsx | 128 +++++++ src/hooks/__tests__/useWalletHoldings.test.ts | 63 +++ src/hooks/useCreatorPrices.ts | 19 + src/hooks/useStakingPositions.ts | 18 + src/hooks/useWallet.ts | 6 +- src/lib/queryKeys.ts | 3 + src/pages/ProfilePage.tsx | 362 ++++++++++++++---- src/pages/__tests__/DocumentTitle.test.tsx | 21 + ...gPage.buyFlowEndToEnd.integration.test.tsx | 8 + .../LandingPage.sellFlow.integration.test.tsx | 7 + .../ProfilePage.integration.test.tsx | 349 +++++++++++++++++ src/routes.tsx | 4 + .../stakingPositions.service.test.ts | 109 ++++++ src/services/__tests__/wallet.service.test.ts | 95 +++++ src/services/stakingPositions.service.ts | 81 ++++ src/services/wallet.service.ts | 33 ++ .../__tests__/portfolioValue.pnl.test.ts | 46 +++ .../__tests__/stakingPositions.utils.test.ts | 147 +++++++ src/utils/portfolioValue.utils.ts | 28 ++ src/utils/stakingPositions.utils.ts | 86 +++++ 27 files changed, 2700 insertions(+), 67 deletions(-) create mode 100644 src/components/common/HeldKeysGrid.tsx create mode 100644 src/components/common/PortfolioSummaryHeader.tsx create mode 100644 src/components/common/StakingPositionsList.tsx create mode 100644 src/components/common/__tests__/HeldKeysGrid.test.tsx create mode 100644 src/components/common/__tests__/PortfolioSummaryHeader.test.tsx create mode 100644 src/components/common/__tests__/StakingPositionsList.test.tsx create mode 100644 src/components/common/__tests__/TradeHistoryTable.infiniteScroll.test.tsx create mode 100644 src/hooks/__tests__/useWalletHoldings.test.ts create mode 100644 src/hooks/useCreatorPrices.ts create mode 100644 src/hooks/useStakingPositions.ts create mode 100644 src/pages/__tests__/ProfilePage.integration.test.tsx create mode 100644 src/services/__tests__/stakingPositions.service.test.ts create mode 100644 src/services/__tests__/wallet.service.test.ts create mode 100644 src/services/stakingPositions.service.ts create mode 100644 src/services/wallet.service.ts create mode 100644 src/utils/__tests__/stakingPositions.utils.test.ts create mode 100644 src/utils/stakingPositions.utils.ts diff --git a/src/components/common/HeldKeysGrid.tsx b/src/components/common/HeldKeysGrid.tsx new file mode 100644 index 00000000..f802d4e0 --- /dev/null +++ b/src/components/common/HeldKeysGrid.tsx @@ -0,0 +1,204 @@ +import React from 'react'; +import { Link } from 'react-router'; +import { KeyRound, Lock } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import HoldingsEmptyState from '@/components/common/HoldingsEmptyState'; +import { CreatorHoldingsListSkeleton } from '@/components/common/CreatorSkeleton'; +import { + formatDisplayKeyPrice, + resolveCreatorKeyPriceStroops, +} from '@/utils/keyPriceDisplay.utils'; +import { + calculatePositionTotalValue, + calculatePositionUnrealisedPnL, + formatPnLDisplay, + type HeldKeyPosition, +} from '@/utils/portfolioValue.utils'; +import { formatNumber } from '@/utils/numberFormat.utils'; +import { isKeyDeprecated } from '@/utils/keyDeprecation.utils'; +import type { Course } from '@/services/course.service'; +import { cn } from '@/lib/utils'; + +export interface HeldKeysGridProps { + /** Held positions, ready for valuation (prices already merged in). */ + positions: HeldKeyPosition[]; + /** Creator records used to resolve each key's display title and deprecation. */ + creators?: Course[]; + /** Whether the viewer owns this profile — hides trade actions when false. */ + isOwnProfile?: boolean; + isLoading?: boolean; + /** Fallback browse destination for the empty state (defaults to /creators). */ + emptyBrowseHref?: string; + className?: string; +} + +/** + * Grid of held creator keys for the user profile page (#921). + * + * Each card shows the key name (linked to the creator page), the quantity + * held, the current bond-curve price per key, and the position's current + * value. When the profile owner is viewing, Buy / Sell (or Redeem for + * deprecated keys) actions are shown; public profile views render the same + * valuation cards without any wallet-specific actions. + */ +const HeldKeysGrid: React.FC = ({ + positions, + creators = [], + isOwnProfile = true, + isLoading = false, + emptyBrowseHref = '/creators', + className, +}) => { + if (isLoading) { + return ( +
+ +
+ ); + } + + if (positions.length === 0) { + return ( +
+ +
+ ); + } + + return ( +
+ {positions.map(position => { + const creator = creators.find( + candidate => candidate.id === position.creatorId + ); + const priceStroops = resolveCreatorKeyPriceStroops(position); + const totalValueStroops = calculatePositionTotalValue(position); + const unrealisedPnL = calculatePositionUnrealisedPnL(position); + const title = creator?.title ?? 'Creator key'; + const quantity = position.quantity ?? 0; + const isDeprecated = isKeyDeprecated(creator); + const creatorHref = `/creator/${position.creatorId}`; + + return ( +
+
+ + {title} + + {position.pending && ( + + Processing… + + )} +
+ + {isDeprecated && ( +
+
+ )} + +
+
+ Quantity + + {formatNumber(quantity)} keys + +
+
+ Price / key + + {formatDisplayKeyPrice(priceStroops)} + +
+
+ Current value + + {formatDisplayKeyPrice(totalValueStroops)} + +
+
+ Unrealised P&L + 0 + ? 'text-emerald-300' + : (unrealisedPnL ?? 0) < 0 + ? 'text-rose-300' + : 'text-white/80' + )} + > + {formatPnLDisplay(unrealisedPnL ?? 0)} + +
+
+ + {isOwnProfile && ( +
+ {isDeprecated ? ( + + ) : ( + <> + + + + )} + {(position.unclaimedDividend ?? 0) > 0 && ( + + + )} +
+ )} +
+ ); + })} +
+ ); +}; + +export default HeldKeysGrid; diff --git a/src/components/common/PortfolioSummaryHeader.tsx b/src/components/common/PortfolioSummaryHeader.tsx new file mode 100644 index 00000000..6684dd95 --- /dev/null +++ b/src/components/common/PortfolioSummaryHeader.tsx @@ -0,0 +1,210 @@ +import React from 'react'; +import { Share2, Check } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { formatDisplayKeyPrice } from '@/utils/keyPriceDisplay.utils'; +import { + formatPnLDisplay, + formatPnLPercentage, + type PnLSummary, +} from '@/utils/portfolioValue.utils'; +import { cn } from '@/lib/utils'; + +export interface PortfolioSummaryHeaderProps { + /** Held-keys portfolio value in stroops, or null while prices load/unavailable. */ + heldValueStroops: number | null; + /** Staked-positions value in stroops, or null while prices load/unavailable. */ + stakedValueStroops: number | null; + /** Number of held key positions (with quantity > 0). */ + heldPositionCount: number; + /** Number of active staking positions. */ + stakedPositionCount: number; + /** PnL summary for the held-keys side. */ + pnl: PnLSummary; + /** Whether key prices are still being fetched (#921). */ + isPriceLoading?: boolean; + /** Whether the viewer owns this profile — gates the share button. */ + isOwnProfile?: boolean; + /** Copies the public `/profile/:wallet` link to the clipboard. */ + onShareProfile?: () => void; + /** Whether the share button currently shows "Copied!" feedback. */ + isShareCopied?: boolean; + className?: string; +} + +/** + * Portfolio summary header for the user profile page (#921). + * + * Renders the portfolio total as the sum of held keys and staking positions, + * a per-segment value breakdown, and the unrealised PnL band. When the owner + * is viewing, a share button copies the public profile URL for sharing. + */ +const PortfolioSummaryHeader: React.FC = ({ + heldValueStroops, + stakedValueStroops, + heldPositionCount, + stakedPositionCount, + pnl, + isPriceLoading = false, + isOwnProfile = true, + onShareProfile, + isShareCopied = false, + className, +}) => { + const totalStroops = + heldValueStroops != null && stakedValueStroops != null + ? heldValueStroops + stakedValueStroops + : null; + + const totalDisplay = isPriceLoading + ? 'Loading prices…' + : totalStroops != null + ? formatDisplayKeyPrice(totalStroops) + : 'Unavailable'; + + const showPnL = + pnl.status === 'ready' && pnl.totalInvested > 0 && !isPriceLoading; + const isPnlPositive = pnl.unrealisedPnL >= 0; + + return ( +
+
+
+

+ Portfolio overview +

+

+ Total portfolio value +

+

+ Sums the current value of every creator key you hold and every + key you have staked using the latest bond-curve prices. +

+
+ +
+
+ Portfolio total +
+
+ {isPriceLoading && ( +
+
+
+ +
+
+
+ + Held keys + + + {heldPositionCount} + +
+ + {formatDisplayKeyPrice(heldValueStroops)} + +
+ +
+
+ + Staked + + + {stakedPositionCount} + +
+ + {formatDisplayKeyPrice(stakedValueStroops)} + +
+
+ + {showPnL && ( +
+
+
+ Unrealised PnL + + {formatPnLDisplay(pnl.unrealisedPnL)} + +
+
+ Change + + {formatPnLPercentage(pnl.pnlPercentage)} + +
+
+
+ )} + + {isOwnProfile && onShareProfile && ( +
+ +
+ )} +
+ ); +}; + +export default PortfolioSummaryHeader; diff --git a/src/components/common/StakingPositionsList.tsx b/src/components/common/StakingPositionsList.tsx new file mode 100644 index 00000000..db43fd85 --- /dev/null +++ b/src/components/common/StakingPositionsList.tsx @@ -0,0 +1,263 @@ +import React, { useEffect, useState } from 'react'; +import { Link } from 'react-router'; +import { Lock, Clock, TrendingUp } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import EmptyState from '@/components/common/EmptyState'; +import { useClaimStakeMutation } from '@/hooks/useWallet'; +import { formatCountdownTime } from '@/utils/lockupCountdown.utils'; +import { + computeRemainingStakeLockSeconds, + computeStakedPositionValueStroops, + formatClaimableReward, + formatStakedQuantity, + isStakePositionUnlocked, +} from '@/utils/stakingPositions.utils'; +import { formatDisplayKeyPrice } from '@/utils/keyPriceDisplay.utils'; +import type { StakingPosition } from '@/services/stakingPositions.service'; +import { cn } from '@/lib/utils'; + +export interface StakingPositionsListProps { + /** Wallet the positions belong to — identifies the stake being claimed. */ + walletAddress: string; + /** Staking positions, ready for display (prices already merged in). */ + positions: StakingPosition[]; + /** Whether the viewer owns this profile — hides the Claim action when false. */ + isOwnProfile?: boolean; + isLoading?: boolean; + isError?: boolean; + className?: string; +} + +function LockStatus({ position }: { position: StakingPosition }) { + const [nowMs, setNowMs] = useState(() => Date.now()); + + useEffect(() => { + const intervalId = window.setInterval(() => setNowMs(Date.now()), 1000); + return () => window.clearInterval(intervalId); + }, []); + + const unlocked = isStakePositionUnlocked(position, nowMs); + + if (unlocked) { + return ( + + + ); + } + + return ( + + + ); +} + +function StakingRowSkeleton() { + return ( + + } + /> + + + ); +} + +function seedOwnerView(connectedAddress?: string) { + mockUseAccount.mockReturnValue({ + address: connectedAddress, + isConnected: !!connectedAddress, + }); + mockUseWalletHoldings.mockReturnValue({ + data: heldPositions, + isLoading: false, + }); + mockUseCreatorPrices.mockReturnValue({ data: creators, isLoading: false }); + mockUseStakingPositions.mockReturnValue({ + data: { positions: stakingPositions }, + isLoading: false, + isError: false, + }); + mockUseTradeHistory.mockReturnValue({ + data: { pages: [{ trades: [trade], nextCursor: null }] }, + fetchNextPage: vi.fn(), + hasNextPage: false, + isFetchingNextPage: false, + isLoading: false, + isError: false, + }); +} + +function simulateIntersection(isIntersecting: boolean) { + const obs = observers[observers.length - 1]; + act(() => { + obs.callback( + [{ isIntersecting } as IntersectionObserverEntry], + {} as IntersectionObserver + ); + }); +} + +describe('ProfilePage — user profile page (#921)', () => { + beforeEach(() => { + observers.length = 0; + + class MockIntersectionObserver { + callback: IntersectionObserverCallback; + observe = vi.fn(); + disconnect = vi.fn(); + unobserve = vi.fn(); + constructor(cb: IntersectionObserverCallback) { + this.callback = cb; + observers.push({ callback: cb }); + } + } + + vi.stubGlobal('IntersectionObserver', MockIntersectionObserver); + + Object.defineProperty(navigator, 'clipboard', { + value: { writeText: vi.fn().mockResolvedValue(undefined) }, + configurable: true, + }); + + mockUseWalletHoldings.mockReset(); + mockUseTradeHistory.mockReset(); + mockUseCreatorPrices.mockReset(); + mockUseStakingPositions.mockReset(); + mockUseAccount.mockReset(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('shows the owner portfolio total as the sum of held keys and staking positions', () => { + seedOwnerView(); + renderProfile('/profile'); + + expect( + screen.getByRole('heading', { name: 'My Portfolio' }) + ).toBeInTheDocument(); + + // Held keys: 0.25 XLM × 4 = 1 XLM. Staked: 0.25 XLM × 120 = 30 XLM. + // Portfolio total sums both: 31 XLM. + expect( + screen.getByTestId('portfolio-summary-held-value') + ).toHaveTextContent('1 XLM'); + expect( + screen.getByTestId('portfolio-summary-staked-value') + ).toHaveTextContent('30 XLM'); + expect(screen.getByTestId('portfolio-summary-total')).toHaveTextContent( + '31 XLM' + ); + }); + + it('prefers current creator prices over cached holding and staking prices', () => { + seedOwnerView(); + mockUseCreatorPrices.mockReturnValue({ + data: [{ ...creators[0], priceStroops: 5_000_000 }], + isLoading: false, + }); + + renderProfile('/profile'); + + expect( + screen.getByTestId('portfolio-summary-held-value') + ).toHaveTextContent('2 XLM'); + expect( + screen.getByTestId('portfolio-summary-staked-value') + ).toHaveTextContent('60 XLM'); + expect(screen.getByTestId('portfolio-summary-total')).toHaveTextContent( + '62 XLM' + ); + }); + + it('renders held keys with bond-curve values on the holdings panel', () => { + seedOwnerView(); + renderProfile('/profile'); + + const card = screen.getByTestId('holding-card-creator-1'); + expect(card).toHaveTextContent('Alpha Creator'); + expect( + screen.getByTestId('holding-current-value-creator-1') + ).toHaveTextContent('1 XLM'); + expect( + screen.getByRole('link', { name: 'Buy Alpha Creator' }) + ).toBeInTheDocument(); + }); + + it('copies the public profile link from the share button', async () => { + seedOwnerView(); + renderProfile('/profile'); + + const writeText = navigator.clipboard.writeText as ReturnType< + typeof vi.fn + >; + writeText.mockClear(); + + screen.getByTestId('share-profile-button').click(); + + await vi.waitFor(() => { + expect(writeText).toHaveBeenCalledTimes(1); + expect(writeText.mock.calls[0][0]).toContain( + `/profile/${DEMO_WALLET}` + ); + }); + }); + + it('loads more trade history via infinite scroll on the trade tab', async () => { + seedOwnerView(); + mockUseTradeHistory.mockReturnValue({ + data: { pages: [{ trades: [trade], nextCursor: 'cursor-2' }] }, + fetchNextPage: vi.fn(), + hasNextPage: true, + isFetchingNextPage: false, + isLoading: false, + isError: false, + }); + + renderProfile('/profile?tab=trade-history'); + const { fetchNextPage } = mockUseTradeHistory.mock.results[0].value; + + expect( + screen.getByTestId('trade-history-load-more-sentinel') + ).toBeInTheDocument(); + + simulateIntersection(true); + expect(fetchNextPage).toHaveBeenCalledTimes(1); + }); + + it('renders staking positions with lock status and claimable rewards on the staking tab', () => { + seedOwnerView(); + renderProfile('/profile?tab=staking&subtab=positions'); + + expect(screen.getByTestId('staking-position-pos-1')).toBeInTheDocument(); + expect(screen.getByTestId('staking-lock-status-pos-1')).toHaveTextContent( + 'Unlocked' + ); + expect(screen.getByTestId('staking-claimable-pos-1')).toHaveTextContent( + '0.1250 XLM' + ); + expect(screen.getByTestId('staking-claim-pos-1')).toBeInTheDocument(); + }); + + it('hides wallet-specific actions on a public profile view', () => { + seedOwnerView(CONNECTED_WALLET); + renderProfile(`/profile/${PUBLIC_WALLET}`); + + expect( + screen.getByRole('heading', { name: 'Portfolio' }) + ).toBeInTheDocument(); + + // No owner-only actions. + expect( + screen.queryByTestId('share-profile-button') + ).not.toBeInTheDocument(); + expect( + screen.queryByRole('link', { name: /Buy/ }) + ).not.toBeInTheDocument(); + expect( + screen.queryByRole('link', { name: /Sell/ }) + ).not.toBeInTheDocument(); + + // Valuation is still shown publicly. + expect( + screen.getByTestId('holding-current-value-creator-1') + ).toHaveTextContent('1 XLM'); + expect(screen.getByTestId('portfolio-summary-total')).toHaveTextContent( + '31 XLM' + ); + }); + + it('renders staking positions read-only on a public profile', () => { + seedOwnerView(CONNECTED_WALLET); + renderProfile(`/profile/${PUBLIC_WALLET}?tab=staking`); + + expect(screen.getByTestId('staking-position-pos-1')).toBeInTheDocument(); + expect( + screen.queryByTestId('staking-claim-pos-1') + ).not.toBeInTheDocument(); + expect( + screen.queryByTestId('staking-claimable-pos-1') + ).not.toBeInTheDocument(); + }); +}); diff --git a/src/routes.tsx b/src/routes.tsx index 3fcf6ce1..9a1f9379 100644 --- a/src/routes.tsx +++ b/src/routes.tsx @@ -69,6 +69,10 @@ export const routes = [ path: '/profile', element: , }, + { + path: '/profile/:wallet', + element: , + }, { path: '/following', element: , diff --git a/src/services/__tests__/stakingPositions.service.test.ts b/src/services/__tests__/stakingPositions.service.test.ts new file mode 100644 index 00000000..725ae4b3 --- /dev/null +++ b/src/services/__tests__/stakingPositions.service.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest'; +import { ApiError } from '../api.service'; + +const { mockGet } = vi.hoisted(() => ({ + mockGet: vi.fn(), +})); + +vi.mock('axios', () => ({ + default: { + create: vi.fn(() => ({ + get: mockGet, + interceptors: { + request: { use: vi.fn() }, + response: { use: vi.fn() }, + }, + })), + isAxiosError: (err: unknown): boolean => + err !== null && + typeof err === 'object' && + (err as Record).isAxiosError === true, + }, + isAxiosError: (err: unknown): boolean => + err !== null && + typeof err === 'object' && + (err as Record).isAxiosError === true, +})); + +import { cacheManager } from '@/utils/cache.utils'; + +import { + stakingPositionsService, + fetchStakingPositions, +} from '../stakingPositions.service'; + +const WALLET = 'GDEMOWALLET0000000000000000000000000000000000000000000000001'; + +function fakeApiResponse(data: T) { + return { + data: { success: true, data, message: 'ok' }, + }; +} + +function fakeApiError(status: number, message: string) { + return { + isAxiosError: true as const, + response: { status, data: { success: false, message } }, + config: {}, + message, + }; +} + +describe('stakingPositionsService (#921)', () => { + beforeEach(() => { + mockGet.mockReset(); + cacheManager.invalidateAll(); + }); + + it('fetches staking positions from GET /users/:wallet/staking-positions', async () => { + const mockResponse = { + positions: [ + { + id: 'pos-1', + keyId: 'creator-1', + keyName: 'Alpha Key', + stakedQuantity: 120, + unlockLedger: 1_900_000_000, + claimableReward: 5.5, + priceStroops: 2_500_000, + price: null, + }, + ], + }; + + mockGet.mockResolvedValueOnce(fakeApiResponse(mockResponse)); + + const result = await stakingPositionsService.getStakingPositions({ + wallet: WALLET, + }); + + expect(mockGet).toHaveBeenCalledWith( + `/users/${WALLET}/staking-positions` + ); + expect(result).toEqual(mockResponse); + }); + + it('fetches positions using the convenience wrapper fetchStakingPositions', async () => { + const mockResponse = { positions: [] }; + + mockGet.mockResolvedValueOnce(fakeApiResponse(mockResponse)); + + const result = await fetchStakingPositions(WALLET); + + expect(mockGet).toHaveBeenCalledWith( + `/users/${WALLET}/staking-positions` + ); + expect(result).toEqual(mockResponse); + }); + + it('throws ApiError on HTTP failure', async () => { + mockGet.mockRejectedValueOnce(fakeApiError(500, 'Server error')); + + const err = await stakingPositionsService + .getStakingPositions({ wallet: WALLET }) + .catch((e: unknown) => e); + + expect(err).toBeInstanceOf(ApiError); + expect((err as ApiError).status).toBe(500); + }); +}); diff --git a/src/services/__tests__/wallet.service.test.ts b/src/services/__tests__/wallet.service.test.ts new file mode 100644 index 00000000..c798a7bf --- /dev/null +++ b/src/services/__tests__/wallet.service.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest'; +import { ApiError } from '../api.service'; + +const { mockGet } = vi.hoisted(() => ({ + mockGet: vi.fn(), +})); + +vi.mock('axios', () => ({ + default: { + create: vi.fn(() => ({ + get: mockGet, + interceptors: { + request: { use: vi.fn() }, + response: { use: vi.fn() }, + }, + })), + isAxiosError: (err: unknown): boolean => + err !== null && + typeof err === 'object' && + (err as Record).isAxiosError === true, + }, + isAxiosError: (err: unknown): boolean => + err !== null && + typeof err === 'object' && + (err as Record).isAxiosError === true, +})); + +import { walletService, fetchWalletHoldings } from '../wallet.service'; + +const WALLET = 'GDEMOWALLET0000000000000000000000000000000000000000000000001'; + +const holdings = [ + { + creatorId: 'creator-1', + quantity: 4, + priceStroops: 2_500_000, + price: null, + }, +]; + +function fakeApiResponse(data: T) { + return { + data: { success: true, data, message: 'ok' }, + }; +} + +function fakeApiError(status: number, message: string) { + return { + isAxiosError: true as const, + response: { status, data: { success: false, message } }, + config: {}, + message, + }; +} + +describe('walletService (#921)', () => { + beforeEach(() => { + mockGet.mockReset(); + }); + + it('fetches holdings from GET /wallets/:address/holdings', async () => { + mockGet.mockResolvedValueOnce(fakeApiResponse(holdings)); + + const result = await walletService.getHoldings(WALLET); + + expect(mockGet).toHaveBeenCalledWith(`/wallets/${WALLET}/holdings`); + expect(result).toEqual(holdings); + }); + + it('normalizes an envelope response containing a holdings array', async () => { + mockGet.mockResolvedValueOnce(fakeApiResponse({ holdings })); + + const result = await walletService.getHoldings(WALLET); + + expect(result).toEqual(holdings); + }); + + it('fetches holdings using the convenience wrapper', async () => { + mockGet.mockResolvedValueOnce(fakeApiResponse(holdings)); + + const result = await fetchWalletHoldings(WALLET); + + expect(mockGet).toHaveBeenCalledWith(`/wallets/${WALLET}/holdings`); + expect(result).toEqual(holdings); + }); + + it('throws ApiError on HTTP failure', async () => { + mockGet.mockRejectedValueOnce(fakeApiError(500, 'Server error')); + + const err = await walletService.getHoldings(WALLET).catch(e => e); + + expect(err).toBeInstanceOf(ApiError); + expect((err as ApiError).status).toBe(500); + }); +}); diff --git a/src/services/stakingPositions.service.ts b/src/services/stakingPositions.service.ts new file mode 100644 index 00000000..dc3fda70 --- /dev/null +++ b/src/services/stakingPositions.service.ts @@ -0,0 +1,81 @@ +import { BaseApiService, type APIResponse } from './api.service'; +import { cacheManager } from '@/utils/cache.utils'; + +/** + * A single active staking position returned from + * GET /users/:wallet/staking-positions. + * + * Field names mirror the shape described in issue #921 so the profile page + * can surface lock status, claimable rewards, and current value without any + * further mapping. + */ +export interface StakingPosition { + /** Unique position identifier used as the React list key. */ + id: string; + /** Creator key id this position is staked in. */ + keyId: string; + /** Human-readable key / creator name (e.g. "Alpha Key"). */ + keyName: string; + /** Number of whole keys currently locked in the staking contract. */ + stakedQuantity: number; + /** Ledger sequence at which this position unlocks and becomes claimable. */ + unlockLedger: number; + /** Accumulated staking rewards claimable on unlock, in stroops. */ + claimableReward: number; + /** + * Current bonding-curve key price in stroops (preferred over legacy + * `price`), used to value staked positions in the portfolio total. + */ + priceStroops?: number | null; + /** Legacy whole-XLM price interpreted only when stroops are absent. */ + price?: number | null; +} + +/** Response envelope for the staking positions endpoint. */ +export interface StakingPositionsResponse { + positions: StakingPosition[]; +} + +/** Parameters accepted by {@link StakingPositionsService.getStakingPositions}. */ +export interface GetStakingPositionsParams { + wallet: string; +} + +const STAKING_POSITIONS_CACHE_PREFIX = 'staking_positions_'; +/** Cache for 15 s — fresh enough to avoid redundant fetches while browsing, + * short enough to pick up newly unlocked positions quickly. */ +const STAKING_POSITIONS_CACHE_TTL_MS = 15_000; + +class StakingPositionsService extends BaseApiService { + async getStakingPositions({ + wallet, + }: GetStakingPositionsParams): Promise { + const cacheKey = `${STAKING_POSITIONS_CACHE_PREFIX}${wallet}`; + const cached = cacheManager.get(cacheKey); + if (cached) return cached; + + try { + const response = await this.api.get< + APIResponse + >(`/users/${wallet}/staking-positions`); + + const data = response.data.data; + cacheManager.set(cacheKey, data, STAKING_POSITIONS_CACHE_TTL_MS); + return data; + } catch (error) { + throw this.handleError(error); + } + } +} + +export const stakingPositionsService = new StakingPositionsService(); + +/** + * Convenience wrapper that can be spied on in tests without mocking the + * service class instance directly. + */ +export async function fetchStakingPositions( + wallet: string +): Promise { + return stakingPositionsService.getStakingPositions({ wallet }); +} diff --git a/src/services/wallet.service.ts b/src/services/wallet.service.ts new file mode 100644 index 00000000..5e3b2c3b --- /dev/null +++ b/src/services/wallet.service.ts @@ -0,0 +1,33 @@ +import { BaseApiService, type APIResponse } from './api.service'; +import type { HeldKeyPosition } from '@/utils/portfolioValue.utils'; + +export type WalletHolding = HeldKeyPosition; + +type HoldingsPayload = + WalletHolding[] | { holdings?: WalletHolding[]; data?: WalletHolding[] }; + +class WalletService extends BaseApiService { + async getHoldings(address: string): Promise { + try { + const response = await this.api.get>( + `/wallets/${address}/holdings` + ); + + const payload = response.data.data; + if (Array.isArray(payload)) return payload; + if (Array.isArray(payload?.holdings)) return payload.holdings; + if (Array.isArray(payload?.data)) return payload.data; + return []; + } catch (error) { + throw this.handleError(error); + } + } +} + +export const walletService = new WalletService(); + +export async function fetchWalletHoldings( + address: string +): Promise { + return walletService.getHoldings(address); +} diff --git a/src/utils/__tests__/portfolioValue.pnl.test.ts b/src/utils/__tests__/portfolioValue.pnl.test.ts index 6778c7e8..0bd31bab 100644 --- a/src/utils/__tests__/portfolioValue.pnl.test.ts +++ b/src/utils/__tests__/portfolioValue.pnl.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest'; import { + calculatePositionUnrealisedPnL, calculatePnLSummary, calculatePositionPnL, formatPnLDisplay, @@ -391,6 +392,51 @@ describe('calculatePnLSummary', () => { expect(result.unrealisedPnL).toBe(0); expect(result.costBasisPositionCount).toBe(0); }); + + it('uses the supplied cost basis for unrealised PnL', () => { + const positions = [ + createPosition('creator1', 10, 1_000_000, { + costBasisStroops: 5_000_000, + }), + ]; + + const result = calculatePnLSummary(positions); + + expect(result.status).toBe('ready'); + expect(result.currentValue).toBe(10_000_000); + expect(result.totalInvested).toBe(5_000_000); + expect(result.unrealisedPnL).toBe(5_000_000); + expect(result.pnlPercentage).toBe(100); + }); +}); + +describe('calculatePositionUnrealisedPnL', () => { + it('returns the gain against the supplied cost basis', () => { + const position = createPosition('creator1', 10, 1_000_000, { + costBasisStroops: 4_000_000, + }); + + expect(calculatePositionUnrealisedPnL(position)).toBe(6_000_000); + }); + + it('returns zero when no cost basis is available', () => { + expect( + calculatePositionUnrealisedPnL( + createPosition('creator1', 10, 1_000_000) + ) + ).toBe(0); + }); + + it('returns null when the position has no price', () => { + expect( + calculatePositionUnrealisedPnL( + createPosition('creator1', 10, 0, { + priceStroops: null, + price: null, + }) + ) + ).toBeNull(); + }); }); describe('formatPnLDisplay', () => { diff --git a/src/utils/__tests__/stakingPositions.utils.test.ts b/src/utils/__tests__/stakingPositions.utils.test.ts new file mode 100644 index 00000000..3a7609a2 --- /dev/null +++ b/src/utils/__tests__/stakingPositions.utils.test.ts @@ -0,0 +1,147 @@ +import { describe, expect, it } from 'vitest'; +import { + computeRemainingStakeLockSeconds, + computeStakedPositionValueStroops, + computeStakingPortfolioValueStroops, + formatClaimableReward, + formatStakedQuantity, + isStakePositionUnlocked, +} from '@/utils/stakingPositions.utils'; +import type { StakingPosition } from '@/services/stakingPositions.service'; + +const unlocked = new Date('2026-01-01T00:00:00Z').getTime(); +const future = new Date('2099-01-01T00:00:00Z').getTime(); + +function position(overrides: Partial = {}): StakingPosition { + return { + id: 'pos-1', + keyId: 'creator-1', + keyName: 'Alpha Key', + stakedQuantity: 100, + unlockLedger: 0, + claimableReward: 0, + priceStroops: null, + price: null, + ...overrides, + }; +} + +describe('stakingPositions.utils (#921)', () => { + describe('isStakePositionUnlocked', () => { + it('returns true when the unlock ledger has passed', () => { + expect( + isStakePositionUnlocked( + { unlockLedger: unlocked / 1000 }, + unlocked + 60_000 + ) + ).toBe(true); + }); + + it('returns false while the lock is still active', () => { + expect( + isStakePositionUnlocked({ unlockLedger: future / 1000 }, unlocked) + ).toBe(false); + }); + + it('returns false when no unlock ledger is present', () => { + expect(isStakePositionUnlocked({ unlockLedger: 0 }, unlocked)).toBe( + false + ); + }); + }); + + describe('computeRemainingStakeLockSeconds', () => { + it('returns the full remaining duration in seconds', () => { + const remaining = computeRemainingStakeLockSeconds( + { unlockLedger: future / 1000 }, + unlocked + ); + expect(remaining).toBe(Math.ceil((future - unlocked) / 1000)); + }); + + it('returns 0 once the position is unlocked', () => { + expect( + computeRemainingStakeLockSeconds( + { unlockLedger: unlocked / 1000 }, + unlocked + 60_000 + ) + ).toBe(0); + }); + }); + + describe('computeStakedPositionValueStroops', () => { + it('values a position as bond-curve price × staked quantity', () => { + const pos = position({ + stakedQuantity: 4, + priceStroops: 2_500_000, + }); + expect(computeStakedPositionValueStroops(pos)).toBe(10_000_000); + }); + + it('returns null when the price cannot be resolved', () => { + expect( + computeStakedPositionValueStroops( + position({ priceStroops: null, price: null }) + ) + ).toBeNull(); + }); + + it('returns null when nothing is staked', () => { + expect( + computeStakedPositionValueStroops( + position({ stakedQuantity: 0, priceStroops: 2_500_000 }) + ) + ).toBeNull(); + }); + }); + + describe('computeStakingPortfolioValueStroops', () => { + it('sums the value across all active positions', () => { + const positions = [ + position({ + id: 'pos-1', + stakedQuantity: 2, + priceStroops: 2_500_000, + }), + position({ + id: 'pos-2', + keyId: 'creator-2', + stakedQuantity: 1, + priceStroops: 5_000_000, + }), + ]; + expect(computeStakingPortfolioValueStroops(positions)).toBe( + 10_000_000 + ); + }); + + it('returns 0 when there are no active positions', () => { + expect(computeStakingPortfolioValueStroops([])).toBe(0); + expect( + computeStakingPortfolioValueStroops([ + position({ stakedQuantity: 0 }), + ]) + ).toBe(0); + }); + + it('withholds the total when any position is missing a price', () => { + const positions = [ + position({ id: 'pos-1', priceStroops: 2_500_000 }), + position({ id: 'pos-2', priceStroops: null, price: null }), + ]; + expect(computeStakingPortfolioValueStroops(positions)).toBeNull(); + }); + }); + + describe('formatters', () => { + it('formats staked quantity with thousand separators', () => { + expect(formatStakedQuantity(2500)).toBe('2,500 keys'); + expect(formatStakedQuantity(0.5)).toBe('0.5 keys'); + }); + + it('formats claimable reward as XLM with 4 decimals', () => { + expect(formatClaimableReward(2_500_000)).toBe('0.2500 XLM'); + expect(formatClaimableReward(0)).toBe('0.0000 XLM'); + }); + }); +}); diff --git a/src/utils/portfolioValue.utils.ts b/src/utils/portfolioValue.utils.ts index 891d5629..e730d136 100644 --- a/src/utils/portfolioValue.utils.ts +++ b/src/utils/portfolioValue.utils.ts @@ -59,6 +59,7 @@ export interface HeldKeyPosition extends BondingCurvePriceFields { * value is present here. */ nextBuyAllowedAt?: number | string | null; + costBasisStroops?: number | null; } export type PortfolioValueStatus = 'ready' | 'loading' | 'unavailable'; @@ -305,6 +306,33 @@ export function calculatePositionTotalValue( return priceStroops * quantity; } +function resolveCostBasisStroops(position: HeldKeyPosition): number | null { + const costBasisStroops = position.costBasisStroops; + + if ( + costBasisStroops == null || + !Number.isFinite(costBasisStroops) || + costBasisStroops < 0 + ) { + return null; + } + + return costBasisStroops; +} + +export function calculatePositionUnrealisedPnL( + position: HeldKeyPosition +): number | null { + const currentValue = calculatePositionTotalValue(position); + + if (currentValue == null) { + return null; + } + + const costBasisStroops = resolveCostBasisStroops(position); + return currentValue - (costBasisStroops ?? currentValue); +} + /** * Sorts held key positions by total value in descending order. * Positions with equal total values maintain a stable secondary sort by creator ID. diff --git a/src/utils/stakingPositions.utils.ts b/src/utils/stakingPositions.utils.ts new file mode 100644 index 00000000..de256662 --- /dev/null +++ b/src/utils/stakingPositions.utils.ts @@ -0,0 +1,86 @@ +import { resolveCreatorKeyPriceStroops } from '@/utils/keyPriceDisplay.utils'; +import { STROOPS_PER_XLM } from '@/constants/stellar'; +import type { StakingPosition } from '@/services/stakingPositions.service'; + +const normalizeStakedQuantity = (quantity: number) => { + if (!Number.isFinite(quantity) || quantity <= 0) return 0; + return quantity; +}; + +/** + * A staking position is unlocked once its `unlockLedger` (a Unix epoch + * timestamp in seconds) has passed. + */ +export function isStakePositionUnlocked( + position: Pick, + nowMs: number = Date.now() +): boolean { + if (!position.unlockLedger) return false; + return position.unlockLedger * 1000 <= nowMs; +} + +/** Seconds remaining until the staking position unlocks (0 when unlocked). */ +export function computeRemainingStakeLockSeconds( + position: Pick, + nowMs: number = Date.now() +): number { + if (!position.unlockLedger) return 0; + return Math.max(0, Math.ceil((position.unlockLedger * 1000 - nowMs) / 1000)); +} + +/** + * Current value of a single staked position in stroops (bonding-curve key + * price × staked quantity). Returns `null` when the price can't be resolved. + */ +export function computeStakedPositionValueStroops( + position: StakingPosition +): number | null { + const priceStroops = resolveCreatorKeyPriceStroops(position); + const quantity = normalizeStakedQuantity(position.stakedQuantity); + + if (priceStroops == null || quantity === 0) return null; + + return priceStroops * quantity; +} + +/** + * Aggregates the current value across all staking positions in stroops. + * + * Mirrors `calculatePortfolioValue` from the holdings side: the total is + * withheld when any position with keys staked is missing a price so the UI + * never presents an incorrect staked total as complete. + */ +export function computeStakingPortfolioValueStroops( + positions: StakingPosition[] +): number | null { + const active = positions.filter( + position => normalizeStakedQuantity(position.stakedQuantity) > 0 + ); + + if (active.length === 0) return 0; + + if ( + active.some(position => resolveCreatorKeyPriceStroops(position) == null) + ) { + return null; + } + + return active.reduce((total, position) => { + const priceStroops = resolveCreatorKeyPriceStroops(position); + return ( + total + + (priceStroops ?? 0) * normalizeStakedQuantity(position.stakedQuantity) + ); + }, 0); +} + +/** Formats a staked quantity for display, e.g. "250.5 keys". */ +export function formatStakedQuantity(quantity: number): string { + return `${Number(quantity.toFixed(4)).toLocaleString()} keys`; +} + +/** Formats a claimable reward in XLM for display. */ +export function formatClaimableReward(reward: number): string { + const xlm = (reward ?? 0) / STROOPS_PER_XLM; + return `${xlm.toFixed(4)} XLM`; +}