import { getCsrfToken } from '@shared/api/csrf' import { tokenStore, refreshAccessToken } from '@shared/api/tokenStore' import { toBaseUnits } from '@shared/lib/utils/baseUnits' // Стейкинг живёт на том же custodial-хосте, что и кошелёк (см. features/wallet/api/walletApi.ts). const WALLET_API_URL = 'https://app.cryptowallet.elcsa.ru' export type StakeChain = 'ETH' | 'SOL' /** Десятичность смолл-юнитов: ETH = wei (10^18), SOL = lamports (10^9). */ export const STAKE_DECIMALS: Record = { ETH: 18, SOL: 9 } // ── Низкоуровневые запросы (CSRF + Bearer + один silent refresh на 401) ── async function walletGet(path: string, allowRetry = true): Promise { const csrf = await getCsrfToken() const bearer = tokenStore.get() const res = await fetch(`${WALLET_API_URL}${path}`, { credentials: 'include', headers: { 'X-CSRF-Token': csrf, ...(bearer ? { Authorization: `Bearer ${bearer}` } : {}), }, }) if (res.status === 401 && allowRetry) { try { await refreshAccessToken() return walletGet(path, false) } catch { tokenStore.clear() throw new Error('Unauthorized') } } const data = await res.json() if (!res.ok) throw data return data as T } async function walletPost( path: string, body: unknown, allowRetry = true, extraHeaders: Record = {} ): Promise { const csrf = await getCsrfToken() const bearer = tokenStore.get() const res = await fetch(`${WALLET_API_URL}${path}`, { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf, ...(bearer ? { Authorization: `Bearer ${bearer}` } : {}), ...extraHeaders, }, body: JSON.stringify(body), }) if (res.status === 401 && allowRetry) { try { await refreshAccessToken() return walletPost(path, body, false, extraHeaders) } catch { tokenStore.clear() throw new Error('Unauthorized') } } const data = await res.json() if (!res.ok) throw data return data as T } /** Idempotency-Key для мутирующих операций (анти-дабл-спенд). */ function idempotency(): Record { return { 'Idempotency-Key': crypto.randomUUID() } } // ── 1.1 Quote ── interface EthQuoteRaw { amountWeiHuman: string appFeeWeiHuman: string stakeAmountWeiHuman: string expectedStEthWeiHuman: string aprPercent: number | null } interface SolQuoteRaw { amountLamportsHuman: string appFeeLamportsHuman: string stakeLamportsHuman: string validator: string estApyPercent: number | null } /** Нормализованное превью стейка для UI (human-числа). */ export interface StakeQuote { chain: StakeChain amountHuman: string feeHuman: string afterFeeHuman: string /** Ожидаемый stETH (ETH) либо делегируемая сумма (SOL). */ resultHuman: string aprPercent: number | null validator?: string } export async function getStakeQuote(chain: StakeChain, amountHuman: string): Promise { const amount = toBaseUnits(amountHuman, STAKE_DECIMALS[chain]) if (chain === 'ETH') { const res = await walletPost<{ success: boolean; data: EthQuoteRaw }>('/api/wallets/ETH/stake/quote', { amount }) const d = res.data return { chain, amountHuman: d.amountWeiHuman, feeHuman: d.appFeeWeiHuman, afterFeeHuman: d.stakeAmountWeiHuman, resultHuman: d.expectedStEthWeiHuman, aprPercent: d.aprPercent, } } const res = await walletPost<{ success: boolean; data: SolQuoteRaw }>('/api/wallets/SOL/stake/quote', { amount }) const d = res.data return { chain, amountHuman: d.amountLamportsHuman, feeHuman: d.appFeeLamportsHuman, afterFeeHuman: d.stakeLamportsHuman, resultHuman: d.stakeLamportsHuman, aprPercent: d.estApyPercent, validator: d.validator, } } // ── 1.2 Stake ── export async function stake(chain: StakeChain, amountHuman: string): Promise { const amount = toBaseUnits(amountHuman, STAKE_DECIMALS[chain]) await walletPost(`/api/wallets/${chain}/stake`, { amount }, true, idempotency()) } // ── 1.3 Positions ── export interface EthWithdrawalRequest { requestId: string amountStEthWeiHuman: string isFinalized: boolean isClaimed: boolean } export interface EthStakePositions { chain: 'ETH' protocol: string stEthBalanceWeiHuman: string aprPercent: number | null withdrawalRequests: EthWithdrawalRequest[] /** Клиентский флаг: позиция показана оптимистично и ещё не подтверждена сервером. */ optimistic?: boolean } export type SolState = 'active' | 'activating' | 'deactivating' | 'inactive' export interface SolPosition { stakeAccount: string lamportsHuman: string state: SolState validator: string /** Клиентский флаг: позиция показана оптимистично и ещё не подтверждена сервером. */ optimistic?: boolean } export interface SolStakePositions { chain: 'SOL' protocol: string validator: string positions: SolPosition[] } export async function getEthPositions(): Promise { const res = await walletGet<{ success: boolean; data: EthStakePositions }>('/api/wallets/ETH/stake/positions') return res.data } export async function getSolPositions(): Promise { const res = await walletGet<{ success: boolean; data: SolStakePositions }>('/api/wallets/SOL/stake/positions') return res.data } // ── 1.4 Unstake ── export interface EthUnstakeResult { mode: 'swap' | 'queue' note?: string } export async function unstakeEth(amountHuman: string, mode: 'swap' | 'queue'): Promise { const amount = toBaseUnits(amountHuman, STAKE_DECIMALS.ETH) const res = await walletPost<{ success: boolean; data: EthUnstakeResult }>( '/api/wallets/ETH/unstake', { amount, mode }, true, idempotency() ) return res.data } export interface SolUnstakeResult { action: 'deactivate' | 'withdraw' note?: string } export async function unstakeSol(stakeAccount: string): Promise { const res = await walletPost<{ success: boolean; data: SolUnstakeResult }>( '/api/wallets/SOL/unstake', { stakeAccount }, true, idempotency() ) return res.data } // ── 1.5 Claim (ETH, mode=queue) ── export async function claimUnstake(requestId: string): Promise { await walletPost('/api/wallets/ETH/unstake/claim', { requestId }, true, idempotency()) } // ── Предварительная проверка стоимости/баланса (op-cost) ── // Read-only: считает комиссию 0.7% (в токене + нативе + USD) и проверяет, хватает ли // средств на сумму И нативки на газ. Для нативного стейкинга token = символ сети. export interface OpCostLeg { symbol?: string have?: string | number need?: string | number needReserve?: string | number shortfall?: string | number sufficient?: boolean | null } export interface OpCost { appCommission?: { inToken?: number; inNative?: number; usd?: number | null } balance?: { token?: OpCostLeg; gas?: OpCostLeg } /** Хватает ли средств на сумму + газ. */ sufficient: boolean } export async function getOpCost(chain: StakeChain, amountHuman: string): Promise { const res = await walletPost<{ success: boolean; data: OpCost }>(`/api/wallets/${chain}/op-cost`, { token: chain, amountHuman, }) return res.data } // ── Ошибки ── /** Достаёт человекочитаемый текст из конверта ошибки {success:false,error,code} или Error. */ export function getStakingErrorMessage(e: unknown): string { if (e && typeof e === 'object' && 'error' in e) { const msg = (e as { error?: unknown }).error if (typeof msg === 'string') return msg } if (e instanceof Error) { return e.message === 'Unauthorized' ? 'Сессия истекла — войдите снова.' : e.message } return 'Произошла ошибка. Попробуйте ещё раз.' }