add staking page
This commit is contained in:
271
src/features/staking/api/stakingApi.ts
Normal file
271
src/features/staking/api/stakingApi.ts
Normal file
@@ -0,0 +1,271 @@
|
||||
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[]
|
||||
}
|
||||
|
||||
export type SolState = 'active' | 'activating' | 'deactivating' | 'inactive'
|
||||
|
||||
export interface SolPosition {
|
||||
stakeAccount: string
|
||||
lamportsHuman: string
|
||||
state: SolState
|
||||
validator: string
|
||||
}
|
||||
|
||||
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 'Произошла ошибка. Попробуйте ещё раз.'
|
||||
}
|
||||
Reference in New Issue
Block a user