276 lines
8.3 KiB
TypeScript
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 'Произошла ошибка. Попробуйте ещё раз.'
|
|
}
|