513 lines
13 KiB
TypeScript
513 lines
13 KiB
TypeScript
import { getCsrfToken } from '@shared/api/csrf'
|
|
import { tokenStore, refreshAccessToken } from '@shared/api/tokenStore'
|
|
import {
|
|
getOrganizationWallets,
|
|
getOrganizationBalances,
|
|
type OrgAmountRaw,
|
|
type OrgWalletBalance,
|
|
} from '@features/b2b'
|
|
|
|
const WALLET_API_URL = 'https://app.cryptowallet.elcsa.ru'
|
|
|
|
export type Chain = 'ETH' | 'BSC' | 'BTC' | 'TRX' | 'SOL'
|
|
|
|
export interface FormattedAmount {
|
|
raw: string
|
|
formatted: string
|
|
decimals: number
|
|
usdPrice: number
|
|
usdValue: number
|
|
}
|
|
|
|
export interface WalletBalanceData {
|
|
chain: Chain
|
|
address: string
|
|
native: FormattedAmount
|
|
tokens: Record<string, FormattedAmount>
|
|
}
|
|
|
|
export interface PriceEntry {
|
|
usd: number
|
|
}
|
|
|
|
export interface SendWalletPayload {
|
|
to: string
|
|
amount: string
|
|
token?: string
|
|
feeTier?: 'slow' | 'normal' | 'fast'
|
|
}
|
|
|
|
export interface SendWalletResponse {
|
|
data: { txid: string; chain: Chain }
|
|
}
|
|
|
|
export interface WalletAddress {
|
|
chain: Chain
|
|
address: string
|
|
derivationPath: string
|
|
}
|
|
|
|
export interface PortfolioChain {
|
|
chain: Chain
|
|
address: string
|
|
native: FormattedAmount
|
|
tokens: Record<string, FormattedAmount>
|
|
totalUsd: number
|
|
stale: boolean
|
|
lastUpdated: number
|
|
}
|
|
|
|
export interface PortfolioData {
|
|
totalUsd: number
|
|
hasErrors: boolean
|
|
perChain: Record<Chain, PortfolioChain>
|
|
}
|
|
|
|
export const CHAINS: Chain[] = ['ETH', 'BSC', 'BTC', 'TRX', 'SOL']
|
|
|
|
async function walletGet<T>(path: string, allowRetry: boolean = 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: boolean = 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
|
|
}
|
|
|
|
export async function getWalletAddresses(): Promise<WalletAddress[]> {
|
|
const res = await walletGet<{ success: boolean; data: WalletAddress[] }>('/api/wallets')
|
|
return res.data
|
|
}
|
|
|
|
export async function getWalletBalance(chain: Chain): Promise<WalletBalanceData> {
|
|
const res = await walletGet<{ success: boolean; data: WalletBalanceData }>(`/api/wallets/${chain}/balance`)
|
|
return res.data
|
|
}
|
|
|
|
export async function getPrices(symbols: string[]): Promise<Record<string, PriceEntry>> {
|
|
const res = await walletGet<{ success: boolean; data: Record<string, PriceEntry> }>(
|
|
`/api/prices?symbols=${symbols.join(',')}`
|
|
)
|
|
return res.data
|
|
}
|
|
|
|
export async function sendWallet(chain: Chain, payload: SendWalletPayload): Promise<SendWalletResponse> {
|
|
return walletPost<SendWalletResponse>(`/api/wallets/${chain}/send`, payload)
|
|
}
|
|
|
|
export async function getPortfolio(): Promise<PortfolioData> {
|
|
const res = await walletGet<{ success: boolean; data: PortfolioData }>('/api/wallets/portfolio')
|
|
return res.data
|
|
}
|
|
|
|
// --- Legal-entity (b2b organizations) adapters ---
|
|
// The b2b balances endpoint returns the same information as the cryptowallet
|
|
// portfolio, but with snake_case keys, string-encoded numbers and an items[]
|
|
// array instead of a perChain map. These adapters normalise it into the
|
|
// existing PortfolioData / WalletAddress shapes so the wallet UI is reused as-is.
|
|
|
|
const NATIVE_SYMBOL_TO_CHAIN: Record<string, Chain> = {
|
|
ETH: 'ETH',
|
|
BNB: 'BSC',
|
|
BTC: 'BTC',
|
|
TRX: 'TRX',
|
|
SOL: 'SOL',
|
|
}
|
|
|
|
/** Normalise a b2b chain/native_symbol into our Chain enum, or null if unknown. */
|
|
function normalizeChain(chain: string, nativeSymbol?: string): Chain | null {
|
|
const upper = chain?.toUpperCase()
|
|
if ((CHAINS as string[]).includes(upper)) return upper as Chain
|
|
const bySymbol = nativeSymbol ? NATIVE_SYMBOL_TO_CHAIN[nativeSymbol.toUpperCase()] : undefined
|
|
return bySymbol ?? null
|
|
}
|
|
|
|
function parseNum(value: string | number | null | undefined): number {
|
|
const n = typeof value === 'number' ? value : parseFloat(value ?? '')
|
|
return Number.isFinite(n) ? n : 0
|
|
}
|
|
|
|
function toFormattedAmount(a: OrgAmountRaw): FormattedAmount {
|
|
return {
|
|
raw: a.raw,
|
|
formatted: a.formatted,
|
|
decimals: a.decimals,
|
|
usdPrice: parseNum(a.usd_price),
|
|
usdValue: parseNum(a.usd_value),
|
|
}
|
|
}
|
|
|
|
function toPortfolioChain(item: OrgWalletBalance, chain: Chain): PortfolioChain {
|
|
const tokens: Record<string, FormattedAmount> = {}
|
|
for (const [symbol, amount] of Object.entries(item.tokens ?? {})) {
|
|
tokens[symbol] = toFormattedAmount(amount)
|
|
}
|
|
return {
|
|
chain,
|
|
address: item.address,
|
|
native: toFormattedAmount(item.native),
|
|
tokens,
|
|
totalUsd: parseNum(item.total_usd),
|
|
stale: false,
|
|
lastUpdated: 0,
|
|
}
|
|
}
|
|
|
|
export async function getOrgPortfolio(): Promise<PortfolioData> {
|
|
const res = await getOrganizationBalances()
|
|
const perChain = {} as Record<Chain, PortfolioChain>
|
|
for (const item of res.items ?? []) {
|
|
const chain = normalizeChain(item.chain, item.native_symbol)
|
|
if (!chain) continue
|
|
perChain[chain] = toPortfolioChain(item, chain)
|
|
}
|
|
return {
|
|
totalUsd: parseNum(res.total_usd),
|
|
hasErrors: !!res.has_errors,
|
|
perChain,
|
|
}
|
|
}
|
|
|
|
const EMPTY_AMOUNT: FormattedAmount = { raw: '0', formatted: '0', decimals: 0, usdPrice: 0, usdValue: 0 }
|
|
|
|
export async function getOrgWalletBalance(chain: Chain): Promise<WalletBalanceData> {
|
|
const portfolio = await getOrgPortfolio()
|
|
const c = portfolio.perChain[chain]
|
|
if (!c) return { chain, address: '', native: EMPTY_AMOUNT, tokens: {} }
|
|
return { chain, address: c.address, native: c.native, tokens: c.tokens }
|
|
}
|
|
|
|
export async function getOrgWalletAddresses(): Promise<WalletAddress[]> {
|
|
const wallets = await getOrganizationWallets()
|
|
const result: WalletAddress[] = []
|
|
for (const w of wallets ?? []) {
|
|
const chain = normalizeChain(w.chain)
|
|
if (!chain) continue
|
|
result.push({ chain, address: w.address, derivationPath: w.derivation_path })
|
|
}
|
|
return result
|
|
}
|
|
|
|
export interface TokenInfo {
|
|
chain: string
|
|
symbol: string
|
|
name: string
|
|
contract: string | null
|
|
}
|
|
|
|
export interface RelayQuotePayload {
|
|
user: string
|
|
recipient: string
|
|
originChainId: number
|
|
destinationChainId: number
|
|
originCurrency: string
|
|
destinationCurrency: string
|
|
amount: string
|
|
tradeType: 'EXACT_INPUT'
|
|
}
|
|
|
|
export interface RelayQuoteResponse {
|
|
details: {
|
|
currencyOut: {
|
|
amountFormatted: string
|
|
amountUsd: string
|
|
}
|
|
}
|
|
fees: {
|
|
gas: {
|
|
amountUsd: string
|
|
}
|
|
}
|
|
}
|
|
|
|
export async function getTokensList(): Promise<TokenInfo[]> {
|
|
const res = await walletGet<{ success: boolean; data: TokenInfo[] }>('/api/tokens')
|
|
return res.data
|
|
}
|
|
|
|
export interface JumperToken {
|
|
address: string
|
|
chainId: number
|
|
symbol: string
|
|
decimals: number
|
|
name: string
|
|
coinKey?: string
|
|
logoURI?: string
|
|
priceUSD: string
|
|
}
|
|
|
|
export type JumperTokensMap = Record<string, JumperToken[]>
|
|
|
|
export async function getJumperTokens(): Promise<JumperTokensMap> {
|
|
const res = await walletGet<{ tokens?: JumperTokensMap; data?: { tokens: JumperTokensMap } }>(
|
|
'/api/jumper/tokens?chains=1,56,1151111081099710,728126428,20000000000001'
|
|
)
|
|
return res.data?.tokens ?? res.tokens ?? {}
|
|
}
|
|
|
|
export interface JumperQuotePayload {
|
|
fromChain: string
|
|
toChain: string
|
|
fromToken: string
|
|
toToken: string
|
|
fromAmount: string
|
|
fromAddress: string
|
|
toAddress: string
|
|
slippage: number
|
|
}
|
|
|
|
export interface JumperQuoteToken {
|
|
address: string
|
|
chainId: number
|
|
symbol: string
|
|
decimals: number
|
|
name: string
|
|
logoURI?: string
|
|
priceUSD: string
|
|
}
|
|
|
|
export interface JumperFeeCost {
|
|
name: string
|
|
description?: string
|
|
token: JumperQuoteToken
|
|
amount: string
|
|
amountUSD: string
|
|
percentage?: string
|
|
included?: boolean
|
|
}
|
|
|
|
export interface JumperQuote {
|
|
type: string
|
|
id: string
|
|
tool: string
|
|
toolDetails: { key: string; name: string; logoURI?: string }
|
|
action: {
|
|
fromToken: JumperQuoteToken
|
|
fromAmount: string
|
|
toToken: JumperQuoteToken
|
|
fromChainId: number
|
|
toChainId: number
|
|
slippage: number
|
|
fromAddress: string
|
|
toAddress: string
|
|
}
|
|
estimate: {
|
|
tool: string
|
|
approvalAddress?: string
|
|
toAmountMin: string
|
|
toAmount: string
|
|
fromAmount: string
|
|
feeCosts?: JumperFeeCost[]
|
|
}
|
|
}
|
|
|
|
export async function getJumperQuote(payload: JumperQuotePayload): Promise<JumperQuote> {
|
|
const qs = new URLSearchParams({
|
|
fromChain: payload.fromChain,
|
|
toChain: payload.toChain,
|
|
fromToken: payload.fromToken,
|
|
toToken: payload.toToken,
|
|
fromAmount: payload.fromAmount,
|
|
fromAddress: payload.fromAddress,
|
|
toAddress: payload.toAddress,
|
|
slippage: String(payload.slippage),
|
|
}).toString()
|
|
const res = await walletGet<JumperQuote & { body?: JumperQuote; data?: { body?: JumperQuote } }>(
|
|
`/api/jumper/quote-best?${qs}`
|
|
)
|
|
return (res.data?.body ?? res.body ?? res) as JumperQuote
|
|
}
|
|
|
|
export interface BridgeExecutePayload {
|
|
provider: string
|
|
fromChain: number
|
|
toChain: number
|
|
fromToken: string
|
|
toToken: string
|
|
fromAmount: string
|
|
fromAddress: string
|
|
toAddress: string
|
|
acceptedMinOut?: string
|
|
}
|
|
|
|
export interface BridgeExecuteResult {
|
|
provider: string
|
|
fromChain: number
|
|
toChain: number
|
|
toolName: string
|
|
feeTxid?: string
|
|
feeAmount?: string
|
|
bridgeTxid: string
|
|
fromAmount: string
|
|
toAmountMin: string
|
|
fromAmountUSD?: string
|
|
toAmountUSD?: string
|
|
trackerUrl?: string
|
|
}
|
|
|
|
export async function executeBridge(payload: BridgeExecutePayload): Promise<BridgeExecuteResult> {
|
|
const res = await walletPost<{ data?: { success: boolean; data: BridgeExecuteResult } }>(
|
|
'/api/bridge/execute',
|
|
payload,
|
|
true,
|
|
{ 'Idempotency-Key': crypto.randomUUID() }
|
|
)
|
|
return (res.data?.data ?? res) as BridgeExecuteResult
|
|
}
|
|
|
|
export async function getRelayQuote(payload: RelayQuotePayload): Promise<RelayQuoteResponse> {
|
|
return walletPost<RelayQuoteResponse>('/api/relay/quote', payload)
|
|
}
|
|
|
|
export interface RelaySwapStep {
|
|
id: string
|
|
action: string
|
|
description: string
|
|
kind: string
|
|
items: Array<{
|
|
status: string
|
|
data: {
|
|
from: string
|
|
to: string
|
|
data: string
|
|
value: string
|
|
chainId: number
|
|
gas: string
|
|
maxFeePerGas: string
|
|
maxPriorityFeePerGas: string
|
|
}
|
|
check: {
|
|
endpoint: string
|
|
method: string
|
|
}
|
|
}>
|
|
requestId: string
|
|
}
|
|
|
|
export interface RelaySwapResponse {
|
|
steps: RelaySwapStep[]
|
|
fees: RelayQuoteResponse['fees']
|
|
details: {
|
|
operation: string
|
|
sender: string
|
|
recipient: string
|
|
currencyIn: { amount: string; amountFormatted: string; amountUsd: string; currency: { symbol: string } }
|
|
currencyOut: { amount: string; amountFormatted: string; amountUsd: string; currency: { symbol: string } }
|
|
totalImpact: { usd: string; percent: string }
|
|
rate: string
|
|
timeEstimate: number
|
|
}
|
|
}
|
|
|
|
export async function executeRelaySwap(payload: RelayQuotePayload): Promise<RelaySwapResponse> {
|
|
return walletPost<RelaySwapResponse>('/api/relay/execute/swap', payload)
|
|
}
|
|
|
|
export async function signRawEvmTx(
|
|
chain: 'ETH' | 'BSC',
|
|
txData: RelaySwapStep['items'][0]['data']
|
|
): Promise<unknown> {
|
|
const key = `relay-${chain.toLowerCase()}-${Date.now()}`
|
|
return walletPost(`/api/wallets/${chain}/sign-raw-evm-tx`, txData, true, { 'Idempotency-Key': key })
|
|
}
|
|
|
|
export async function signSolTx(txData: unknown): Promise<unknown> {
|
|
return walletPost('/api/wallets/SOL/sign-and-broadcast-tx', txData)
|
|
}
|
|
|
|
export interface TrxSwapQuotePayload {
|
|
from: string
|
|
to: string
|
|
amountHuman: string
|
|
}
|
|
|
|
export interface TrxSwapQuoteData {
|
|
quoteId: string
|
|
expiresIn: number
|
|
expectedOutFormatted: string
|
|
minOutFormatted: string
|
|
fees: {
|
|
network: { amountFormatted: string; asset: string; amountUsd: number }
|
|
}
|
|
}
|
|
|
|
export async function getTrxSwapQuote(payload: TrxSwapQuotePayload): Promise<TrxSwapQuoteData> {
|
|
const res = await walletPost<{ success: boolean; data: TrxSwapQuoteData }>(
|
|
'/api/wallets/TRX/swap/quote',
|
|
payload
|
|
)
|
|
return res.data
|
|
}
|
|
|
|
export async function executeTrxSwap(quoteId: string): Promise<unknown> {
|
|
return walletPost(
|
|
'/api/wallets/TRX/swap',
|
|
{ quoteId },
|
|
true,
|
|
{ 'Idempotency-Key': `trx-${Date.now()}` }
|
|
)
|
|
}
|
|
|
|
export async function createWallet(): Promise<void> {
|
|
await walletPost<unknown>('/api/wallets/create', {})
|
|
}
|
|
|
|
export async function revealMnemonic(): Promise<string> {
|
|
const res = await walletPost<{ success: boolean; data: { mnemonic: string } }>('/api/wallets/mnemonic/reveal', { confirm: 'I_UNDERSTAND_SEED_IS_SECRET' })
|
|
return res.data.mnemonic
|
|
}
|