Files
frontend/src/features/staking/api/stakingApi.ts
2026-06-16 17:37:58 +03:00

276 lines
8.3 KiB
TypeScript

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<StakeChain, number> = { ETH: 18, SOL: 9 }
// ── Низкоуровневые запросы (CSRF + Bearer + один silent refresh на 401) ──
async function walletGet<T>(path: string, allowRetry = true): Promise<T> {
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<T>(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<T>(
path: string,
body: unknown,
allowRetry = true,
extraHeaders: Record<string, string> = {}
): Promise<T> {
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<T>(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<string, string> {
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<StakeQuote> {
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<void> {
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<EthStakePositions> {
const res = await walletGet<{ success: boolean; data: EthStakePositions }>('/api/wallets/ETH/stake/positions')
return res.data
}
export async function getSolPositions(): Promise<SolStakePositions> {
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<EthUnstakeResult> {
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<SolUnstakeResult> {
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<void> {
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<OpCost> {
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 'Произошла ошибка. Попробуйте ещё раз.'
}