+
+ Pool
+
+
+ {formatXlmPrice(rewardPoolBalance)}
+
+
+ )}
+
+ );
+}
diff --git a/src/components/common/__tests__/ActiveStakesList.test.tsx b/src/components/common/__tests__/ActiveStakesList.test.tsx
new file mode 100644
index 00000000..934ba75a
--- /dev/null
+++ b/src/components/common/__tests__/ActiveStakesList.test.tsx
@@ -0,0 +1,114 @@
+import { describe, expect, it, vi } from 'vitest';
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import ActiveStakesList from '@/components/common/ActiveStakesList';
+import type { VaultStake } from '@/services/stakingVault.service';
+
+// ─── Fixtures ─────────────────────────────────────────────────────────────────
+
+const LOCKED_STAKE: VaultStake = {
+ id: 'stake-locked',
+ keyId: 'key-1',
+ stakedAmount: 3,
+ // One year in the future — always locked during tests
+ lockExpiresAt: new Date(Date.now() + 365 * 24 * 60 * 60 * 1_000).toISOString(),
+ accruedRewards: 12.5,
+ rewardPoolBalance: 500,
+};
+
+const EXPIRED_STAKE: VaultStake = {
+ id: 'stake-expired',
+ keyId: 'key-1',
+ stakedAmount: 1,
+ // One year in the past — always expired during tests
+ lockExpiresAt: new Date(Date.now() - 365 * 24 * 60 * 60 * 1_000).toISOString(),
+ accruedRewards: 4.2,
+ rewardPoolBalance: 500,
+};
+
+function makeProps(overrides = {}) {
+ return {
+ stakes: [LOCKED_STAKE, EXPIRED_STAKE],
+ pendingUnstakeId: null as string | null,
+ onUnstake: vi.fn(),
+ ...overrides,
+ };
+}
+
+// ─── Tests ────────────────────────────────────────────────────────────────────
+
+describe('ActiveStakesList (#1017)', () => {
+ it('shows an empty-state message when there are no stakes', () => {
+ render();
+
+ expect(screen.getByTestId('active-stakes-empty')).toBeInTheDocument();
+ expect(screen.queryByTestId('active-stakes-list')).not.toBeInTheDocument();
+ });
+
+ it('renders a row for each stake', () => {
+ render();
+
+ expect(screen.getByTestId('active-stakes-list')).toBeInTheDocument();
+ expect(screen.getByTestId('vault-stake-row-stake-locked')).toBeInTheDocument();
+ expect(screen.getByTestId('vault-stake-row-stake-expired')).toBeInTheDocument();
+ });
+
+ it('shows accrued rewards for each stake (acceptance: accrued rewards)', () => {
+ render();
+
+ // Both reward values should be visible
+ expect(screen.getByTestId('vault-stake-rewards-stake-locked')).toBeInTheDocument();
+ expect(screen.getByTestId('vault-stake-rewards-stake-expired')).toBeInTheDocument();
+ });
+
+ it('disables the Unstake button with a tooltip for a locked stake (acceptance: unstake disabled during lock)', () => {
+ render();
+
+ const unstakeBtn = screen.getByTestId('unstake-button-stake-locked');
+ expect(unstakeBtn).toBeDisabled();
+ expect(unstakeBtn).toHaveAttribute('aria-disabled', 'true');
+ });
+
+ it('enables the Unstake button once the lock has expired (acceptance: unstake enabled after lock)', () => {
+ render();
+
+ expect(screen.getByTestId('unstake-button-stake-expired')).toBeEnabled();
+ });
+
+ it('calls onUnstake with the correct stakeId when Unstake is clicked', async () => {
+ const user = userEvent.setup();
+ const props = makeProps({ stakes: [EXPIRED_STAKE] });
+ render();
+
+ await user.click(screen.getByTestId('unstake-button-stake-expired'));
+
+ expect(props.onUnstake).toHaveBeenCalledWith('stake-expired');
+ });
+
+ it('shows a loading spinner on the pending unstake row', () => {
+ render(
+
+ );
+
+ expect(screen.getByTestId('unstake-button-stake-expired')).toBeDisabled();
+ expect(screen.getByText(/unstaking…/i)).toBeInTheDocument();
+ });
+
+ it('shows the countdown for a locked stake (acceptance: lock expiry countdown)', () => {
+ render();
+
+ const countdown = screen.getByTestId('vault-stake-countdown-stake-locked');
+ expect(countdown).toBeInTheDocument();
+ // Should show days remaining (e.g. "365d 0h" — not "Unlocked")
+ expect(countdown).not.toHaveTextContent(/unlocked/i);
+ });
+
+ it('shows the reward pool balance for each stake', () => {
+ render();
+
+ expect(screen.getByTestId('vault-stake-pool-stake-locked')).toBeInTheDocument();
+ expect(screen.getByTestId('vault-stake-pool-stake-expired')).toBeInTheDocument();
+ });
+});
diff --git a/src/components/common/__tests__/StakeForm.test.tsx b/src/components/common/__tests__/StakeForm.test.tsx
new file mode 100644
index 00000000..b9740f5b
--- /dev/null
+++ b/src/components/common/__tests__/StakeForm.test.tsx
@@ -0,0 +1,106 @@
+import { describe, expect, it, vi } from 'vitest';
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import StakeForm from '@/components/common/StakeForm';
+
+function makeProps(overrides = {}) {
+ return {
+ availableBalance: 5,
+ rewardPoolBalance: 1000,
+ onStake: vi.fn(),
+ isSubmitting: false,
+ isConnected: true,
+ ...overrides,
+ };
+}
+
+describe('StakeForm (#1017)', () => {
+ it('renders the amount input, Max button, lock period selector and submit button', () => {
+ render();
+
+ expect(screen.getByTestId('stake-amount-input')).toBeInTheDocument();
+ expect(
+ screen.getByRole('button', { name: /max/i })
+ ).toBeInTheDocument();
+ expect(screen.getByTestId('lock-period-selector')).toBeInTheDocument();
+ expect(screen.getByTestId('stake-submit-button')).toBeInTheDocument();
+ });
+
+ it('renders all four lock period options', () => {
+ render();
+
+ expect(screen.getByTestId('lock-period-7')).toBeInTheDocument();
+ expect(screen.getByTestId('lock-period-30')).toBeInTheDocument();
+ expect(screen.getByTestId('lock-period-90')).toBeInTheDocument();
+ expect(screen.getByTestId('lock-period-180')).toBeInTheDocument();
+ });
+
+ it('shows a validation error and blocks submit when amount is empty (acceptance: validates amount)', async () => {
+ const user = userEvent.setup();
+ const props = makeProps();
+ render();
+
+ await user.click(screen.getByTestId('stake-submit-button'));
+
+ expect(screen.getByTestId('stake-amount-error')).toBeInTheDocument();
+ expect(props.onStake).not.toHaveBeenCalled();
+ });
+
+ it('shows a validation error when amount exceeds available balance', async () => {
+ const user = userEvent.setup();
+ const props = makeProps({ availableBalance: 3 });
+ render();
+
+ await user.type(screen.getByTestId('stake-amount-input'), '10');
+ await user.click(screen.getByTestId('stake-submit-button'));
+
+ expect(screen.getByTestId('stake-amount-error')).toHaveTextContent(
+ /only hold 3 key/i
+ );
+ expect(props.onStake).not.toHaveBeenCalled();
+ });
+
+ it('calls onStake with the entered amount and selected period on valid submit', async () => {
+ const user = userEvent.setup();
+ const props = makeProps({ availableBalance: 5 });
+ render();
+
+ await user.type(screen.getByTestId('stake-amount-input'), '2');
+ // 30-day period is default — no need to click it
+ await user.click(screen.getByTestId('stake-submit-button'));
+
+ expect(props.onStake).toHaveBeenCalledWith(2, 30);
+ });
+
+ it('fills max balance when Max button is clicked', async () => {
+ const user = userEvent.setup();
+ render();
+
+ await user.click(screen.getByRole('button', { name: /max/i }));
+
+ expect(screen.getByTestId('stake-amount-input')).toHaveValue('7');
+ });
+
+ it('shows the reward preview after a valid amount is entered (acceptance: reward preview)', async () => {
+ const user = userEvent.setup();
+ render();
+
+ await user.type(screen.getByTestId('stake-amount-input'), '2');
+
+ expect(screen.getByTestId('reward-preview')).toBeInTheDocument();
+ expect(screen.getByTestId('reward-preview-value')).toBeInTheDocument();
+ });
+
+ it('disables the submit button while isSubmitting is true', () => {
+ render();
+
+ expect(screen.getByTestId('stake-submit-button')).toBeDisabled();
+ });
+
+ it('disables the submit button when wallet is not connected', () => {
+ render();
+
+ expect(screen.getByTestId('stake-submit-button')).toBeDisabled();
+ expect(screen.getByText(/connect your wallet/i)).toBeInTheDocument();
+ });
+});
diff --git a/src/hooks/useStakingVault.ts b/src/hooks/useStakingVault.ts
new file mode 100644
index 00000000..98f19ebd
--- /dev/null
+++ b/src/hooks/useStakingVault.ts
@@ -0,0 +1,90 @@
+import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
+import { queryKeys } from '@/lib/queryKeys';
+import {
+ fetchVaultStakes,
+ submitStakeKeys,
+ submitUnstakeKeys,
+ type StakeVaultVariables,
+ type UnstakeVaultVariables,
+} from '@/services/stakingVault.service';
+import showToast from '@/utils/toast.util';
+import { getSignatureErrorMessage } from '@/utils/errorHandling.utils';
+
+// ─── Queries ──────────────────────────────────────────────────────────────────
+
+/**
+ * Active vault stakes for a wallet on a specific creator key (#1017).
+ *
+ * Polls GET /keys/:keyId/vault-stakes/:wallet so the staking vault panel
+ * can render lock status, countdown, accrued rewards, and the unstake CTA
+ * for every position the wallet currently holds in the vault.
+ */
+export function useVaultStakes(wallet: string, keyId: string) {
+ return useQuery({
+ queryKey: queryKeys.stakingVault.stakes(wallet, keyId),
+ queryFn: () => fetchVaultStakes(keyId, wallet),
+ enabled: Boolean(wallet) && Boolean(keyId),
+ staleTime: 30_000,
+ retry: false,
+ });
+}
+
+// ─── Mutations ────────────────────────────────────────────────────────────────
+
+/**
+ * Stakes a chosen quantity of creator keys for a selected lock period.
+ * On success invalidates the vault stakes and wallet holdings caches.
+ */
+export function useStakeVaultMutation(wallet: string) {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationKey: ['staking-vault', 'stake', wallet],
+ mutationFn: (variables: StakeVaultVariables) =>
+ submitStakeKeys(variables),
+ onSuccess: (_data, variables) => {
+ void queryClient.invalidateQueries({
+ queryKey: queryKeys.stakingVault.stakes(wallet, variables.keyId),
+ });
+ void queryClient.invalidateQueries({
+ queryKey: queryKeys.wallet.holdings(wallet),
+ });
+ void queryClient.invalidateQueries({
+ queryKey: queryKeys.wallet.stakingPositions(wallet),
+ });
+ showToast.success('Keys staked successfully');
+ },
+ onError: (error: unknown) => {
+ showToast.error(getSignatureErrorMessage(error));
+ },
+ });
+}
+
+/**
+ * Unstakes a vault position after its lock period has expired.
+ * On success invalidates vault stakes and wallet holdings caches.
+ */
+export function useUnstakeVaultMutation(wallet: string) {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationKey: ['staking-vault', 'unstake', wallet],
+ mutationFn: (variables: UnstakeVaultVariables) =>
+ submitUnstakeKeys(variables),
+ onSuccess: (_data, variables) => {
+ void queryClient.invalidateQueries({
+ queryKey: queryKeys.stakingVault.stakes(wallet, variables.keyId),
+ });
+ void queryClient.invalidateQueries({
+ queryKey: queryKeys.wallet.holdings(wallet),
+ });
+ void queryClient.invalidateQueries({
+ queryKey: queryKeys.wallet.stakingPositions(wallet),
+ });
+ showToast.success('Stake withdrawn successfully');
+ },
+ onError: (error: unknown) => {
+ showToast.error(getSignatureErrorMessage(error));
+ },
+ });
+}
diff --git a/src/lib/queryKeys.ts b/src/lib/queryKeys.ts
index 34fdbc66..d7b8d133 100644
--- a/src/lib/queryKeys.ts
+++ b/src/lib/queryKeys.ts
@@ -182,4 +182,9 @@ export const queryKeys = {
withdrawals: (creatorId: string) =>
['creatorRevenue', creatorId, 'withdrawals'] as const,
},
+ stakingVault: {
+ /** Active vault stakes for a (wallet, key) pair. */
+ stakes: (wallet: string, keyId: string) =>
+ ['staking-vault', 'stakes', wallet, keyId] as const,
+ },
} as const;
diff --git a/src/pages/CreatorDetailPage.tsx b/src/pages/CreatorDetailPage.tsx
index 2d6aa9d6..fc767eb1 100644
--- a/src/pages/CreatorDetailPage.tsx
+++ b/src/pages/CreatorDetailPage.tsx
@@ -18,6 +18,7 @@ import BuyCooldownCountdown from '@/components/common/BuyCooldownCountdown';
import KeyHolderList from '@/components/common/KeyHolderList';
import HolderConcentrationChart from '@/components/common/HolderConcentrationChart';
import StakingRewardsSection from '@/components/common/StakingRewardsSection';
+import StakingVaultPanel from '@/components/common/StakingVaultPanel';
import DeprecationNotice from '@/components/common/DeprecationNotice';
import DeprecationBanner from '@/components/common/DeprecationBanner';
import SubscriptionAccessGate from '@/components/common/SubscriptionAccessGate';
@@ -97,8 +98,9 @@ function CreatorDetailPageContent() {
const [recentSettlement, setRecentSettlement] =
useState(null);
const [shareModalOpen, setShareModalOpen] = useState(false);
- const [lastPurchasedAmount, setLastPurchasedAmount] =
- useState(null);
+ const [lastPurchasedAmount, setLastPurchasedAmount] = useState<
+ number | null
+ >(null);
const [deprecationDismissed, setDeprecationDismissed] = useState(false);
const [buyDialogOpen, setBuyDialogOpen] = useState(false);
const [tradeSubmitting, setTradeSubmitting] = useState(false);
@@ -198,24 +200,24 @@ function CreatorDetailPageContent() {
const isWhitelistGateActive = Boolean(
creator?.isWhitelistEnabled ??
- creator?.whitelistEnabled ??
- creator?.earlyAccessEnabled ??
- false
+ creator?.whitelistEnabled ??
+ creator?.earlyAccessEnabled ??
+ false
);
const whitelistEntries = creator?.whitelist ?? [];
const isUserWhitelisted = Boolean(
userAddress &&
- (whitelistEntries.some(
- entry =>
- entry.walletAddress?.toUpperCase() === userAddress.toUpperCase()
+ (whitelistEntries.some(
+ entry =>
+ entry.walletAddress?.toUpperCase() === userAddress.toUpperCase()
+ ) ||
+ (creator?.earlyAccessWhitelist ?? []).some(
+ address => address.toLowerCase() === userAddress.toLowerCase()
) ||
- (creator?.earlyAccessWhitelist ?? []).some(
- address => address.toLowerCase() === userAddress.toLowerCase()
- ) ||
- (creator?.instructorId &&
- creator.instructorId.toUpperCase() === userAddress.toUpperCase()))
+ (creator?.instructorId &&
+ creator.instructorId.toUpperCase() === userAddress.toUpperCase()))
);
const publicLaunchTimestamp = creator?.publicLaunchDate
@@ -771,6 +773,15 @@ function CreatorDetailPageContent() {
+ {/* Staking Vault — stake keys, track locks, claim rewards (#1017) */}
+
+
{auctionPhase !== 'active' && (
<>
>
)}
-
- 🎉 Welcome to the exclusive content section! Here you can access
- premium videos, articles, and perks from{' '}
+ 🎉 Welcome to the exclusive content section! Here you
+ can access premium videos, articles, and perks from{' '}
{creator.title || creator.name || 'this creator'}.
@@ -902,12 +912,14 @@ function CreatorDetailPageContent() {
creatorId={creator.id}
creatorTitle={creator.title || creator.name || 'Creator Key'}
holdingsCount={holdingsCount}
- buybackPriceStroops={resolveCreatorKeyPriceStroops(creator) ?? 0}
+ buybackPriceStroops={
+ resolveCreatorKeyPriceStroops(creator) ?? 0
+ }
userAddress={userAddress}
onSettled={receipt => {
setRecentSettlement(receipt);
}}
- />
+ />
)}
{creator && (
diff --git a/src/services/stakingVault.service.ts b/src/services/stakingVault.service.ts
new file mode 100644
index 00000000..0b65d5d0
--- /dev/null
+++ b/src/services/stakingVault.service.ts
@@ -0,0 +1,158 @@
+import { BaseApiService, type APIResponse } from './api.service';
+import { cacheManager } from '@/utils/cache.utils';
+
+// ─── Types ────────────────────────────────────────────────────────────────────
+
+/** Available lock durations for the staking vault. */
+export type StakingVaultLockPeriod = 7 | 30 | 90 | 180;
+
+/** A single active vault stake for one (wallet, key) pair. */
+export interface VaultStake {
+ /** Unique stake identifier used as a React list key. */
+ id: string;
+ /** Creator key id this stake belongs to. */
+ keyId: string;
+ /** Number of whole keys locked in the vault. */
+ stakedAmount: number;
+ /** ISO-8601 timestamp at which the lock expires and unstaking is allowed. */
+ lockExpiresAt: string;
+ /** Accrued rewards in XLM (whole units) at the time of the last API poll. */
+ accruedRewards: number;
+ /** Reward pool balance for this key at the time of the last API poll (XLM). */
+ rewardPoolBalance: number;
+}
+
+/** Response envelope from GET /keys/:keyId/vault-stakes/:wallet */
+export interface VaultStakesResponse {
+ stakes: VaultStake[];
+}
+
+/** Estimated reward preview returned by POST /keys/:keyId/vault-stakes/estimate */
+export interface StakeRewardEstimate {
+ estimatedReward: number;
+ lockPeriodDays: StakingVaultLockPeriod;
+ annualisedApy: number | null;
+}
+
+/** Params for fetching vault stakes. */
+export interface GetVaultStakesParams {
+ keyId: string;
+ wallet: string;
+}
+
+/** Variables passed to the stake mutation. */
+export interface StakeVaultVariables {
+ keyId: string;
+ wallet: string;
+ amount: number;
+ lockPeriodDays: StakingVaultLockPeriod;
+}
+
+/** Variables passed to the unstake mutation. */
+export interface UnstakeVaultVariables {
+ keyId: string;
+ wallet: string;
+ stakeId: string;
+}
+
+// ─── Cache constants ───────────────────────────────────────────────────────────
+
+const VAULT_STAKES_CACHE_PREFIX = 'vault_stakes_';
+const VAULT_STAKES_CACHE_TTL_MS = 15_000;
+
+// ─── Service ──────────────────────────────────────────────────────────────────
+
+class StakingVaultService extends BaseApiService {
+ /**
+ * Fetches the active vault stakes for a wallet on a specific key.
+ * GET /keys/:keyId/vault-stakes/:wallet
+ */
+ async getVaultStakes({
+ keyId,
+ wallet,
+ }: GetVaultStakesParams): Promise {
+ const cacheKey = `${VAULT_STAKES_CACHE_PREFIX}${keyId}_${wallet}`;
+ const cached = cacheManager.get(cacheKey);
+ if (cached) return cached;
+
+ try {
+ const response = await this.api.get>(
+ `/keys/${keyId}/vault-stakes/${wallet}`
+ );
+ const data = response.data.data;
+ cacheManager.set(cacheKey, data, VAULT_STAKES_CACHE_TTL_MS);
+ return data;
+ } catch (error) {
+ throw this.handleError(error);
+ }
+ }
+
+ /**
+ * Submits a stake transaction to the backend signing pipeline.
+ * POST /keys/:keyId/vault-stakes
+ */
+ async stakeKeys(variables: StakeVaultVariables): Promise<{ success: true }> {
+ try {
+ await this.api.post>(
+ `/keys/${variables.keyId}/vault-stakes`,
+ {
+ wallet: variables.wallet,
+ amount: variables.amount,
+ lockPeriodDays: variables.lockPeriodDays,
+ }
+ );
+ // Bust cache so the next query fetches fresh stakes
+ cacheManager.invalidate(
+ `${VAULT_STAKES_CACHE_PREFIX}${variables.keyId}_${variables.wallet}`
+ );
+ return { success: true };
+ } catch (error) {
+ throw this.handleError(error);
+ }
+ }
+
+ /**
+ * Submits an unstake transaction after lock expiry.
+ * POST /keys/:keyId/vault-stakes/:stakeId/unstake
+ */
+ async unstakeKeys(
+ variables: UnstakeVaultVariables
+ ): Promise<{ success: true }> {
+ try {
+ await this.api.post>(
+ `/keys/${variables.keyId}/vault-stakes/${variables.stakeId}/unstake`,
+ { wallet: variables.wallet }
+ );
+ cacheManager.invalidate(
+ `${VAULT_STAKES_CACHE_PREFIX}${variables.keyId}_${variables.wallet}`
+ );
+ return { success: true };
+ } catch (error) {
+ throw this.handleError(error);
+ }
+ }
+}
+
+export const stakingVaultService = new StakingVaultService();
+
+// Convenience wrappers that can be spied on in tests without mocking the
+// service class instance directly.
+
+export async function fetchVaultStakes(
+ keyId: string,
+ wallet: string
+): Promise {
+ return stakingVaultService.getVaultStakes({ keyId, wallet });
+}
+
+export async function submitStakeKeys(
+ variables: StakeVaultVariables
+): Promise<{ success: true }> {
+ return stakingVaultService.stakeKeys(variables);
+}
+
+export async function submitUnstakeKeys(
+ variables: UnstakeVaultVariables
+): Promise<{ success: true }> {
+ return stakingVaultService.unstakeKeys(variables);
+}