diff --git a/.freebuff/project-id b/.freebuff/project-id new file mode 100644 index 00000000..f45c4f4c --- /dev/null +++ b/.freebuff/project-id @@ -0,0 +1 @@ +23786e79-6f9d-4ab3-90d8-f57510f217b4 diff --git a/.github/workflows/pr-target-check.yml b/.github/workflows/pr-target-check.yml index 4ef90cf5..5803a10b 100644 --- a/.github/workflows/pr-target-check.yml +++ b/.github/workflows/pr-target-check.yml @@ -1,9 +1,9 @@ name: Redirect PRs to dev on: - pull_request_target: - branches: - - main + pull_request_target: + branches: + - main jobs: close-and-redirect: diff --git a/src/components/common/ActiveStakesList.tsx b/src/components/common/ActiveStakesList.tsx new file mode 100644 index 00000000..81b77641 --- /dev/null +++ b/src/components/common/ActiveStakesList.tsx @@ -0,0 +1,240 @@ +import { useMemo } from 'react'; +import { Loader2, Unlock, Lock, Coins } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Tooltip } from '@/components/ui/tooltip'; +import { formatXlmPrice, formatNumber } from '@/utils/numberFormat.utils'; +import type { VaultStake } from '@/services/stakingVault.service'; + +// ─── Countdown helpers ──────────────────────────────────────────────────────── + +/** + * Returns a human-readable countdown string from now until `expiresAt`. + * Accurate to the second for times under one hour, otherwise hours/days. + */ +function formatCountdown(expiresAt: string): string { + const diffMs = new Date(expiresAt).getTime() - Date.now(); + if (diffMs <= 0) return 'Expired'; + + const totalSeconds = Math.floor(diffMs / 1_000); + const days = Math.floor(totalSeconds / 86_400); + const hours = Math.floor((totalSeconds % 86_400) / 3_600); + const minutes = Math.floor((totalSeconds % 3_600) / 60); + const seconds = totalSeconds % 60; + + if (days > 0) return `${days}d ${hours}h`; + if (hours > 0) return `${hours}h ${minutes}m`; + return `${minutes}m ${seconds}s`; +} + +/** Returns true when the lock window has passed. */ +function isExpired(expiresAt: string): boolean { + return new Date(expiresAt).getTime() <= Date.now(); +} + +// ─── Props ──────────────────────────────────────────────────────────────────── + +export interface ActiveStakesListProps { + stakes: VaultStake[]; + /** Whether an unstake mutation is pending for a given stakeId. */ + pendingUnstakeId: string | null; + /** Called when the user clicks Unstake on an unlocked position. */ + onUnstake: (stakeId: string) => void; +} + +// ─── Sub-component: single stake row ───────────────────────────────────────── + +interface StakeRowProps { + stake: VaultStake; + isPendingUnstake: boolean; + onUnstake: (stakeId: string) => void; +} + +function StakeRow({ stake, isPendingUnstake, onUnstake }: StakeRowProps) { + const expired = useMemo(() => isExpired(stake.lockExpiresAt), [stake.lockExpiresAt]); + const countdown = useMemo( + () => (expired ? 'Unlocked' : formatCountdown(stake.lockExpiresAt)), + // eslint-disable-next-line react-hooks/exhaustive-deps + [expired, stake.lockExpiresAt] + ); + const lockDate = new Date(stake.lockExpiresAt).toLocaleDateString(undefined, { + year: 'numeric', + month: 'short', + day: 'numeric', + }); + + return ( +
  • +
    + {/* Left: staked amount + lock status */} +
    +
    + {expired ? ( +
    +
    +

    + {formatNumber(stake.stakedAmount)} key + {stake.stakedAmount === 1 ? '' : 's'} staked +

    +

    + {expired ? ( + + Ready to unstake + + ) : ( + <> + Unlocks{' '} + + {lockDate} + + + )} +

    +
    +
    + + {/* Right: stats + unstake button */} +
    + {/* Lock expiry countdown */} +
    +

    + {expired ? 'Status' : 'Time left'} +

    +

    + {countdown} +

    +
    + + {/* Accrued rewards */} +
    +
    + + {/* Unstake button */} + {expired ? ( + + ) : ( + + + + )} +
    +
    + + {/* Reward pool balance chip */} + {stake.rewardPoolBalance > 0 && ( +
    + Reward pool for this key: + + {formatXlmPrice(stake.rewardPoolBalance)} + +
    + )} +
  • + ); +} + +// ─── Main list component ────────────────────────────────────────────────────── + +export default function ActiveStakesList({ + stakes, + pendingUnstakeId, + onUnstake, +}: ActiveStakesListProps) { + if (stakes.length === 0) { + return ( +
    +
    +
    +

    + No active stakes yet. Use the form above to lock your keys and start + earning rewards. +

    +
    + ); + } + + return ( + + ); +} diff --git a/src/components/common/StakeForm.tsx b/src/components/common/StakeForm.tsx new file mode 100644 index 00000000..b5acdc3b --- /dev/null +++ b/src/components/common/StakeForm.tsx @@ -0,0 +1,271 @@ +import { useMemo, useState } from 'react'; +import { Loader2, Lock } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import InlineValidationMessage from '@/components/common/InlineValidationMessage'; +import { cn } from '@/lib/utils'; +import { formatXlmPrice, formatPercent } from '@/utils/numberFormat.utils'; +import type { StakingVaultLockPeriod } from '@/services/stakingVault.service'; + +// ─── Lock period options ─────────────────────────────────────────────────────── + +interface LockOption { + days: StakingVaultLockPeriod; + label: string; + /** Rough APY multiplier relative to base (purely illustrative until the API + * returns a live estimate). */ + apyMultiplier: number; +} + +const LOCK_OPTIONS: LockOption[] = [ + { days: 7, label: '7 days', apyMultiplier: 0.5 }, + { days: 30, label: '30 days', apyMultiplier: 1 }, + { days: 90, label: '90 days', apyMultiplier: 1.8 }, + { days: 180, label: '180 days', apyMultiplier: 3 }, +]; + +/** Rough estimated reward in XLM based on pool balance, lock length, and amount. + * Replaced with a live API estimate once the endpoint is wired up. */ +function computeEstimatedReward( + amount: number, + lockDays: StakingVaultLockPeriod, + rewardPoolBalance: number +): number { + // Simple heuristic: share of pool proportional to (amount * days). + // This is only shown as a UI preview — backend is authoritative. + const option = LOCK_OPTIONS.find(o => o.days === lockDays)!; + const estimatedApy = 0.12 * option.apyMultiplier; // base 12% APY + return parseFloat( + ((amount * rewardPoolBalance * estimatedApy * lockDays) / 365).toFixed(4) + ); +} + +// ─── Props ──────────────────────────────────────────────────────────────────── + +export interface StakeFormProps { + /** Maximum keys the wallet can stake (their current holdings for this key). */ + availableBalance: number; + /** Current reward pool balance for this key (XLM). Used for reward preview. */ + rewardPoolBalance: number; + /** Called when the user submits a valid stake. */ + onStake: (amount: number, lockPeriodDays: StakingVaultLockPeriod) => void; + /** Whether a stake submission is in flight. */ + isSubmitting?: boolean; + /** Whether the wallet is connected. When false the button is disabled. */ + isConnected?: boolean; +} + +// ─── Component ──────────────────────────────────────────────────────────────── + +export default function StakeForm({ + availableBalance, + rewardPoolBalance, + onStake, + isSubmitting = false, + isConnected = true, +}: StakeFormProps) { + const [amountText, setAmountText] = useState(''); + const [touched, setTouched] = useState(false); + const [selectedPeriod, setSelectedPeriod] = + useState(30); + + // Parse as integer — keys are whole units + const parsedAmount = useMemo(() => { + const n = parseInt(amountText.trim(), 10); + return Number.isFinite(n) ? n : null; + }, [amountText]); + + const validationError = useMemo((): string | null => { + if (!amountText.trim()) return 'Please enter an amount.'; + if (parsedAmount === null || parsedAmount <= 0) + return 'Amount must be a positive whole number.'; + if (parsedAmount > availableBalance) + return `You only hold ${availableBalance} key${availableBalance === 1 ? '' : 's'} available to stake.`; + return null; + }, [amountText, parsedAmount, availableBalance]); + + const isAmountValid = validationError === null; + const showValidationError = touched && !isAmountValid; + + const estimatedReward = useMemo(() => { + if (!isAmountValid || parsedAmount === null) return null; + return computeEstimatedReward(parsedAmount, selectedPeriod, rewardPoolBalance); + }, [isAmountValid, parsedAmount, selectedPeriod, rewardPoolBalance]); + + const selectedOption = LOCK_OPTIONS.find(o => o.days === selectedPeriod)!; + const illustrativeApy = 0.12 * selectedOption.apyMultiplier * 100; // as % + + const canSubmit = isAmountValid && isConnected && !isSubmitting; + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + setTouched(true); + if (!isAmountValid || parsedAmount === null) return; + onStake(parsedAmount, selectedPeriod); + }; + + return ( +
    + {/* Amount input */} +
    + +
    + { + setAmountText(e.target.value); + if (!touched && e.target.value.trim() !== '') + setTouched(true); + }} + onBlur={() => setTouched(true)} + className={cn( + 'h-12 min-w-0 flex-1 rounded-xl border bg-white/[0.03] px-4 font-mono text-sm text-white placeholder:text-white/25 outline-none transition-colors focus:border-amber-500/40 focus:ring-2 focus:ring-amber-500/20', + showValidationError ? 'border-red-500/50' : 'border-white/10' + )} + aria-invalid={showValidationError} + aria-describedby={ + showValidationError ? 'stake-amount-error' : undefined + } + /> + +
    + {showValidationError && ( +
    + +
    + )} +

    + Available balance:{' '} + + {availableBalance} key{availableBalance === 1 ? '' : 's'} + +

    +
    + + {/* Lock period selector */} +
    +

    Lock period

    +
    + {LOCK_OPTIONS.map(option => { + const isSelected = selectedPeriod === option.days; + return ( + + ); + })} +
    +
    + + {/* Estimated reward preview */} + {estimatedReward !== null && ( +
    +
    +
    +

    + Estimated reward +

    +

    + {formatXlmPrice(estimatedReward)} +

    +
    +
    +

    + Est. APY +

    +

    + {formatPercent(illustrativeApy, { maximumFractionDigits: 0 })} +

    +
    +
    +

    + Preview only — actual rewards depend on pool inflow and other + stakers. +

    +
    + )} + + {/* Submit */} + + + {!isConnected && ( +

    + Connect your wallet to stake. +

    + )} +
    + ); +} diff --git a/src/components/common/StakingVaultPanel.tsx b/src/components/common/StakingVaultPanel.tsx new file mode 100644 index 00000000..03bdd4e3 --- /dev/null +++ b/src/components/common/StakingVaultPanel.tsx @@ -0,0 +1,238 @@ +import { useState } from 'react'; +import { Vault } from 'lucide-react'; +import Skeleton from '@/components/ui/skeleton'; +import { Button } from '@/components/ui/button'; +import StakeForm from '@/components/common/StakeForm'; +import ActiveStakesList from '@/components/common/ActiveStakesList'; +import { + useVaultStakes, + useStakeVaultMutation, + useUnstakeVaultMutation, +} from '@/hooks/useStakingVault'; +import type { StakingVaultLockPeriod } from '@/services/stakingVault.service'; +import { formatXlmPrice } from '@/utils/numberFormat.utils'; + +// ─── Constants ──────────────────────────────────────────────────────────────── + +const CARD_CLASS = + 'rounded-[2rem] border border-white/10 bg-white/[0.02] p-6 shadow-2xl backdrop-blur-md md:p-8'; + +// ─── Props ──────────────────────────────────────────────────────────────────── + +export interface StakingVaultPanelProps { + /** Creator key id for this vault. */ + keyId: string; + /** Connected wallet address. Null when the user is not authenticated. */ + userAddress: string | null | undefined; + /** How many keys the wallet currently holds for this key (available to stake). */ + availableBalance: number; + /** Current reward pool balance for this key (XLM). Used for reward preview. */ + rewardPoolBalance: number; + /** Whether the parent page data is still loading (shows skeleton). */ + isLoading?: boolean; +} + +// ─── Component ──────────────────────────────────────────────────────────────── + +/** + * Staking Vault panel for the key detail page (#1017). + * + * Allows key holders to: + * - Choose an amount and lock period and stake keys via wallet tx + * - View active stakes with lock-expiry countdown, accrued rewards, and pool balance + * - Unstake once the lock period has expired + */ +export default function StakingVaultPanel({ + keyId, + userAddress, + availableBalance, + rewardPoolBalance, + isLoading = false, +}: StakingVaultPanelProps) { + const [pendingUnstakeId, setPendingUnstakeId] = useState(null); + + const { + data: vaultData, + isLoading: isStakesLoading, + isError: isStakesError, + refetch, + } = useVaultStakes(userAddress ?? '', keyId); + + const stakeMutation = useStakeVaultMutation(userAddress ?? ''); + const unstakeMutation = useUnstakeVaultMutation(userAddress ?? ''); + + const stakes = vaultData?.stakes ?? []; + + // ── Handlers ──────────────────────────────────────────────────────────── + + const handleStake = (amount: number, lockPeriodDays: StakingVaultLockPeriod) => { + if (!userAddress) return; + stakeMutation.mutate({ + keyId, + wallet: userAddress, + amount, + lockPeriodDays, + }); + }; + + const handleUnstake = (stakeId: string) => { + if (!userAddress) return; + setPendingUnstakeId(stakeId); + unstakeMutation.mutate( + { keyId, wallet: userAddress, stakeId }, + { + onSettled: () => setPendingUnstakeId(null), + } + ); + }; + + // ── Loading skeleton ───────────────────────────────────────────────────── + + if (isLoading) { + return ( +
    + +
    + +
    + {Array.from({ length: 4 }).map((_, i) => ( + + ))} +
    + + +
    +
    + ); + } + + // ── Unauthenticated placeholder ────────────────────────────────────────── + + if (!userAddress) { + return ( +
    + +
    +
    +
    +

    + Connect your wallet to stake keys and earn rewards. +

    +
    +
    + ); + } + + // ── Main render ────────────────────────────────────────────────────────── + + return ( +
    + + + {/* Stake form */} +
    + +
    + + {/* Divider */} +
    + ); +} + +// ─── Panel header sub-component ─────────────────────────────────────────────── + +function PanelHeader({ rewardPoolBalance }: { rewardPoolBalance: number }) { + return ( +
    +
    +
    + {rewardPoolBalance > 0 && ( +
    + + 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); +}