14.05.2026 rip

This commit is contained in:
2026-05-14 16:32:13 +03:00
parent 9c3fbbdc4b
commit 4913765584
9 changed files with 407 additions and 119 deletions

View File

@@ -24,6 +24,17 @@ 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 const CHAINS: Chain[] = ['ETH', 'BSC', 'BTC', 'TRX', 'SOL']
async function walletGet<T>(path: string, allowRetry: boolean = true): Promise<T> {
@@ -53,6 +64,36 @@ async function walletGet<T>(path: string, allowRetry: boolean = true): Promise<T
return data as T
}
async function walletPost<T>(path: string, body: unknown, allowRetry: boolean = true): 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}` } : {}),
},
body: JSON.stringify(body),
})
if (res.status === 401 && allowRetry) {
try {
await refreshAccessToken()
return walletPost<T>(path, body, false)
} catch {
tokenStore.clear()
throw new Error('Unauthorized')
}
}
const data = await res.json()
if (!res.ok) throw data
return data as T
}
export async function getWalletBalance(chain: Chain): Promise<WalletBalanceData> {
const res = await walletGet<{ success: boolean; data: WalletBalanceData }>(`/api/wallets/${chain}/balance`)
return res.data
@@ -64,3 +105,7 @@ export async function getPrices(symbols: string[]): Promise<Record<string, Price
)
return res.data
}
export async function sendWallet(chain: Chain, payload: SendWalletPayload): Promise<SendWalletResponse> {
return walletPost<SendWalletResponse>(`/api/wallets/${chain}/send`, payload)
}

View File

@@ -1,3 +1,3 @@
export { useAllWalletBalances, usePrices } from './model/useWalletData'
export type { Chain, FormattedAmount, WalletBalanceData, PriceEntry } from './api/walletApi'
export { useAllWalletBalances, usePrices, useSendWallet } from './model/useWalletData'
export type { Chain, FormattedAmount, WalletBalanceData, PriceEntry, SendWalletPayload, SendWalletResponse } from './api/walletApi'
export { CHAINS } from './api/walletApi'

View File

@@ -1,5 +1,5 @@
import { useQuery, useQueries } from '@tanstack/react-query'
import { getWalletBalance, getPrices, CHAINS } from '../api/walletApi'
import { useQuery, useQueries, useMutation } from '@tanstack/react-query'
import { getWalletBalance, getPrices, sendWallet, CHAINS, type Chain, type SendWalletPayload } from '../api/walletApi'
export function useAllWalletBalances() {
return useQueries({
@@ -18,3 +18,10 @@ export function usePrices(symbols: string[]) {
staleTime: 5 * 60 * 1000,
})
}
export function useSendWallet() {
return useMutation({
mutationFn: ({ chain, ...payload }: { chain: Chain } & SendWalletPayload) =>
sendWallet(chain, payload),
})
}

View File

@@ -1,2 +1,4 @@
export { SendModal } from './ui/SendModal'
export type { SendModalToken } from './ui/SendModal'
export type { Chain, FeeTier, SendRequest, SendResponse, SendEthRequest, SendBscRequest, SendBtcRequest, SendTrxRequest, SendSolRequest } from './model/sendTypes'
export { CHAIN_CONFIG, CHAIN_TOKENS, TICKER_TO_CHAIN } from './model/sendTypes'

View File

@@ -0,0 +1,150 @@
// ─── Chain identifiers ───────────────────────────────────────────────────────
export type Chain = 'ETH' | 'BSC' | 'BTC' | 'TRX' | 'SOL'
export type FeeTier = 'slow' | 'normal' | 'fast'
// ─── Per-chain token lists (excluding native) ────────────────────────────────
export type EthToken = 'USDT' | 'USDC' | 'DAI' | 'WBTC' | 'LINK' | 'UNI'
export type BscToken = 'USDT' | 'USDC' | 'BUSD' | 'WBNB' | 'DOGE'
export type TrxToken = 'USDT' | 'USDC'
// SOL has 14 SPL tokens in the registry
export type SolToken =
| 'USDC' | 'USDT' | 'RAY' | 'BONK' | 'JTO' | 'PYTH'
| 'WIF' | 'JUP' | 'ORCA' | 'MNGO' | 'MSOL' | 'STSOL'
| 'SAMO' | 'ATLAS'
export const CHAIN_TOKENS = {
ETH: ['USDT', 'USDC', 'DAI', 'WBTC', 'LINK', 'UNI'] as const satisfies readonly EthToken[],
BSC: ['USDT', 'USDC', 'BUSD', 'WBNB', 'DOGE'] as const satisfies readonly BscToken[],
BTC: [] as const,
TRX: ['USDT', 'USDC'] as const satisfies readonly TrxToken[],
SOL: ['USDC', 'USDT', 'RAY', 'BONK', 'JTO', 'PYTH', 'WIF', 'JUP', 'ORCA', 'MNGO', 'MSOL', 'STSOL', 'SAMO', 'ATLAS'] as const satisfies readonly SolToken[],
} satisfies Record<Chain, readonly string[]>
// ─── Per-chain UI / behaviour config ────────────────────────────────────────
export interface ChainConfig {
label: string
nativeSymbol: string
/** Whether the chain supports token-level sends (ERC20 / BEP20 / TRC20 / SPL) */
hasToken: boolean
/** Whether the API accepts a feeTier parameter */
hasFeeTier: boolean
/** Placeholder for the recipient address input */
addressPlaceholder: string
/** Smallest amount unit name (for tooltip / docs) */
unit: string
/** Accent color used for the chain dot in dropdowns */
color: string
}
export const CHAIN_CONFIG: Record<Chain, ChainConfig> = {
ETH: {
label: 'Ethereum',
nativeSymbol: 'ETH',
hasToken: true,
hasFeeTier: true,
addressPlaceholder: '0x…',
unit: 'wei',
color: '#627EEA',
},
BSC: {
label: 'BNB Chain',
nativeSymbol: 'BNB',
hasToken: true,
hasFeeTier: false,
addressPlaceholder: '0x…',
unit: 'wei',
color: '#F3BA2F',
},
BTC: {
label: 'Bitcoin',
nativeSymbol: 'BTC',
hasToken: false,
hasFeeTier: true,
addressPlaceholder: 'bc1q…',
unit: 'satoshi',
color: '#F7931A',
},
TRX: {
label: 'Tron',
nativeSymbol: 'TRX',
hasToken: true,
hasFeeTier: false,
addressPlaceholder: 'T…',
unit: 'sun',
color: '#FF060A',
},
SOL: {
label: 'Solana',
nativeSymbol: 'SOL',
hasToken: true,
hasFeeTier: false,
addressPlaceholder: 'Fg3R…',
unit: 'lamport',
color: '#9945FF',
},
}
// ─── Wallet ticker → chain mapping (for TokenTable integration) ──────────────
// ARB is an ERC-20 on Ethereum, not a separate chain.
export const TICKER_TO_CHAIN: Record<string, Chain> = {
BTC: 'BTC',
ETH: 'ETH',
SOL: 'SOL',
TRX: 'TRX',
BNB: 'BSC',
ARB: 'ETH',
}
// ─── API request / response types ───────────────────────────────────────────
export interface SendEthRequest {
to: string
amount: string // raw wei / token base units (string to avoid JS precision loss)
token?: EthToken
feeTier?: FeeTier
}
export interface SendBscRequest {
to: string
amount: string // raw wei / 18-dec base units (DOGE = 8 dec)
token?: BscToken
// ⚠️ USDT on BSC = 18 dec, not 6 like on ETH/TRX. DOGE = 8 dec.
}
export interface SendBtcRequest {
to: string
amount: string // satoshi (8 dec). Min dust = 294 sat. Floor fee = 2 sat/vB.
feeTier?: FeeTier // slow = ~144 blocks, normal = ~6, fast = ~1
}
export interface SendTrxRequest {
to: string
amount: string // sun (6 dec for TRX; TRC-20 USDT also 6 dec)
token?: TrxToken
// ⚠️ TRC-20 transfer burns ~1530 TRX Energy when no frozen Energy available.
}
export interface SendSolRequest {
to: string // base58 public key
amount: string // lamports (9 dec for SOL)
token?: SolToken
}
// Discriminated union — pick the right one based on `chain`
export type SendRequest =
| ({ chain: 'ETH' } & SendEthRequest)
| ({ chain: 'BSC' } & SendBscRequest)
| ({ chain: 'BTC' } & SendBtcRequest)
| ({ chain: 'TRX' } & SendTrxRequest)
| ({ chain: 'SOL' } & SendSolRequest)
export interface SendResponse {
data: {
txid: string
chain: Chain
}
}

View File

@@ -328,6 +328,19 @@
text-decoration: underline;
}
/* ─── Error message ─────────────────────────── */
.errorMsg {
background: rgba(255, 68, 102, 0.1);
border: 1px solid rgba(255, 68, 102, 0.3);
border-radius: 10px;
padding: 10px 14px;
font-size: 13px;
color: var(--error);
margin-bottom: 12px;
line-height: 1.4;
}
/* ─── Submit button ─────────────────────────── */
.submitBtn {

View File

@@ -1,4 +1,6 @@
import { useState, useEffect } from 'react'
import { useSendWallet } from '@features/wallet'
import { CHAIN_CONFIG, CHAIN_TOKENS, type Chain, type FeeTier } from '../model/sendTypes'
import styles from './SendModal.module.css'
export interface SendModalToken {
@@ -12,61 +14,100 @@ export interface SendModalToken {
interface Props {
open: boolean
onClose: () => void
tokens: SendModalToken[]
initialTicker?: string
network: Chain
/** Wallet token rows — used only for balance lookup in the "Макс" hint */
tokens?: SendModalToken[]
/** Pre-select a token in the network token list (ticker or empty string = native) */
initialToken?: string
}
type Speed = 'slow' | 'normal' | 'fast'
const SPEEDS: { value: Speed; label: string }[] = [
{ value: 'slow', label: 'Медленно' },
const SPEEDS: { value: FeeTier; label: string }[] = [
{ value: 'slow', label: 'Медленно' },
{ value: 'normal', label: 'Нормально' },
{ value: 'fast', label: 'Быстро' },
{ value: 'fast', label: 'Быстро' },
]
export function SendModal({ open, onClose, tokens, initialTicker }: Props) {
const [ticker, setTicker] = useState(initialTicker ?? tokens[0]?.ticker ?? '')
const [speed, setSpeed] = useState<Speed>('normal')
const [address, setAddress] = useState('')
const [amount, setAmount] = useState('')
const [openDropdown, setOpenDropdown] = useState<'token' | 'speed' | null>(null)
function extractErrorMessage(err: unknown): string {
if (err instanceof Error) return err.message
if (err && typeof err === 'object') {
const e = err as Record<string, unknown>
if (typeof e.message === 'string') return e.message
if (typeof e.error === 'string') return e.error
if (Array.isArray(e.detail) && (e.detail[0] as Record<string, unknown>)?.msg)
return String((e.detail[0] as Record<string, unknown>).msg)
}
return 'Что-то пошло не так. Попробуйте ещё раз.'
}
const token = tokens.find((t) => t.ticker === ticker) ?? tokens[0]
const speedLabel = SPEEDS.find((s) => s.value === speed)?.label ?? 'Нормально'
export function SendModal({ open, onClose, network, tokens = [], initialToken = '' }: Props) {
const cfg = CHAIN_CONFIG[network]
const networkTokens = CHAIN_TOKENS[network] as readonly string[]
const [selectedToken, setSelectedToken] = useState(initialToken)
const [speed, setSpeed] = useState<FeeTier>('normal')
const [address, setAddress] = useState('')
const [amount, setAmount] = useState('')
const [openDropdown, setOpenDropdown] = useState<'token' | 'speed' | null>(null)
const mutation = useSendWallet()
const speedLabel = SPEEDS.find((s) => s.value === speed)?.label ?? 'Нормально'
const tokenLabel = selectedToken === '' ? cfg.nativeSymbol : selectedToken
const walletMatch = tokens.find(
(t) => t.ticker === (selectedToken === '' ? cfg.nativeSymbol : selectedToken),
)
useEffect(() => { setSelectedToken(initialToken) }, [initialToken])
useEffect(() => { setSelectedToken('') }, [network])
useEffect(() => {
if (initialTicker) setTicker(initialTicker)
}, [initialTicker])
if (mutation.isSuccess) onClose()
}, [mutation.isSuccess, onClose])
useEffect(() => {
if (!open) {
setAddress('')
setAmount('')
setOpenDropdown(null)
mutation.reset()
return
}
const handler = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose()
}
const handler = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose() }
document.addEventListener('keydown', handler)
return () => document.removeEventListener('keydown', handler)
}, [open, onClose])
}, [open, onClose]) // eslint-disable-line react-hooks/exhaustive-deps
if (!open) return null
const showToken = cfg.hasToken
const showFeeTier = cfg.hasFeeTier
const showSelects = showToken || showFeeTier
const selectsCols = showToken && showFeeTier ? 2 : 1
function handleOverlayClick() {
if (openDropdown) {
setOpenDropdown(null)
} else {
onClose()
}
if (openDropdown) { setOpenDropdown(null) } else { onClose() }
}
function handleSubmit() {
mutation.mutate({
chain: network,
to: address,
amount,
...(selectedToken ? { token: selectedToken } : {}),
...(cfg.hasFeeTier ? { feeTier: speed } : {}),
})
}
return (
<div className={styles.overlay} onClick={handleOverlayClick}>
<div className={styles.dialog} onClick={(e) => e.stopPropagation()}>
{/* Header */}
<div className={styles.header}>
<span className={styles.title}>Отправить</span>
<div className={styles.headerLeft}>
<span className={styles.chainDot} style={{ background: cfg.color }} />
<span className={styles.title}>Отправить · {cfg.label}</span>
</div>
<button className={styles.close} onClick={onClose} type="button" aria-label="Закрыть">
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
<path d="M1 1l12 12M13 1L1 13" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
@@ -74,93 +115,106 @@ export function SendModal({ open, onClose, tokens, initialTicker }: Props) {
</button>
</div>
<div className={styles.selectsRow}>
<div className={styles.selectGroup}>
<label className={styles.selectLabel}>Токен</label>
<button
type="button"
className={`${styles.selectTrigger} ${openDropdown === 'token' ? styles.selectTriggerOpen : ''}`}
onClick={() => setOpenDropdown((d) => (d === 'token' ? null : 'token'))}
>
{token && (
<span className={styles.tokenDot} style={{ background: token.color }}>
{token.logo ? <img src={token.logo} alt={token.ticker} /> : token.ticker[0]}
</span>
)}
<span className={styles.selectValue}>{token?.ticker ?? '—'}</span>
<svg
className={`${styles.chevron} ${openDropdown === 'token' ? styles.chevronOpen : ''}`}
width="12"
height="12"
viewBox="0 0 12 12"
fill="none"
>
<path d="M2 4l4 4 4-4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</button>
{openDropdown === 'token' && (
<div className={styles.dropdown}>
{tokens.map((t) => (
<button
key={t.ticker}
type="button"
className={`${styles.dropdownItem} ${t.ticker === ticker ? styles.dropdownItemActive : ''}`}
onClick={() => { setTicker(t.ticker); setOpenDropdown(null) }}
{/* Token + Speed selects */}
{showSelects && (
<div
className={styles.selectsRow}
style={{ gridTemplateColumns: `repeat(${selectsCols}, 1fr)` }}
>
{showToken && (
<div className={styles.selectGroup}>
<label className={styles.selectLabel}>Токен</label>
<button
type="button"
className={`${styles.selectTrigger} ${openDropdown === 'token' ? styles.selectTriggerOpen : ''}`}
onClick={() => setOpenDropdown((d) => (d === 'token' ? null : 'token'))}
>
<span className={styles.tokenDot} style={{ background: cfg.color }}>
{tokenLabel[0]}
</span>
<span className={styles.selectValue}>{tokenLabel}</span>
<svg
className={`${styles.chevron} ${openDropdown === 'token' ? styles.chevronOpen : ''}`}
width="12" height="12" viewBox="0 0 12 12" fill="none"
>
<span className={styles.tokenDot} style={{ background: t.color }}>
{t.logo ? <img src={t.logo} alt={t.ticker} /> : t.ticker[0]}
</span>
<span className={styles.dropdownTicker}>{t.ticker}</span>
<span className={styles.dropdownName}>{t.name}</span>
</button>
))}
<path d="M2 4l4 4 4-4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</button>
{openDropdown === 'token' && (
<div className={styles.dropdown}>
<button
type="button"
className={`${styles.dropdownItem} ${selectedToken === '' ? styles.dropdownItemActive : ''}`}
onClick={() => { setSelectedToken(''); setOpenDropdown(null) }}
>
<span className={styles.tokenDot} style={{ background: cfg.color }}>
{cfg.nativeSymbol[0]}
</span>
<span className={styles.dropdownTicker}>{cfg.nativeSymbol}</span>
<span className={styles.dropdownName}>Нативный</span>
</button>
{networkTokens.map((tok) => (
<button
key={tok}
type="button"
className={`${styles.dropdownItem} ${selectedToken === tok ? styles.dropdownItemActive : ''}`}
onClick={() => { setSelectedToken(tok); setOpenDropdown(null) }}
>
<span className={styles.tokenDot} style={{ background: 'rgba(255,255,255,0.12)' }}>
{tok[0]}
</span>
<span className={styles.dropdownTicker}>{tok}</span>
</button>
))}
</div>
)}
</div>
)}
{showFeeTier && (
<div className={styles.selectGroup}>
<label className={styles.selectLabel}>Скорость</label>
<button
type="button"
className={`${styles.selectTrigger} ${openDropdown === 'speed' ? styles.selectTriggerOpen : ''}`}
onClick={() => setOpenDropdown((d) => (d === 'speed' ? null : 'speed'))}
>
<span className={`${styles.speedDot} ${styles[`speedDot_${speed}`]}`} />
<span className={styles.selectValue}>{speedLabel}</span>
<svg
className={`${styles.chevron} ${openDropdown === 'speed' ? styles.chevronOpen : ''}`}
width="12" height="12" viewBox="0 0 12 12" fill="none"
>
<path d="M2 4l4 4 4-4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</button>
{openDropdown === 'speed' && (
<div className={styles.dropdown}>
{SPEEDS.map((s) => (
<button
key={s.value}
type="button"
className={`${styles.dropdownItem} ${s.value === speed ? styles.dropdownItemActive : ''}`}
onClick={() => { setSpeed(s.value); setOpenDropdown(null) }}
>
<span className={`${styles.speedDot} ${styles[`speedDot_${s.value}`]}`} />
<span>{s.label}</span>
</button>
))}
</div>
)}
</div>
)}
</div>
)}
<div className={styles.selectGroup}>
<label className={styles.selectLabel}>Скорость</label>
<button
type="button"
className={`${styles.selectTrigger} ${openDropdown === 'speed' ? styles.selectTriggerOpen : ''}`}
onClick={() => setOpenDropdown((d) => (d === 'speed' ? null : 'speed'))}
>
<span className={`${styles.speedDot} ${styles[`speedDot_${speed}`]}`} />
<span className={styles.selectValue}>{speedLabel}</span>
<svg
className={`${styles.chevron} ${openDropdown === 'speed' ? styles.chevronOpen : ''}`}
width="12"
height="12"
viewBox="0 0 12 12"
fill="none"
>
<path d="M2 4l4 4 4-4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</button>
{openDropdown === 'speed' && (
<div className={styles.dropdown}>
{SPEEDS.map((s) => (
<button
key={s.value}
type="button"
className={`${styles.dropdownItem} ${s.value === speed ? styles.dropdownItemActive : ''}`}
onClick={() => { setSpeed(s.value); setOpenDropdown(null) }}
>
<span className={`${styles.speedDot} ${styles[`speedDot_${s.value}`]}`} />
<span>{s.label}</span>
</button>
))}
</div>
)}
</div>
</div>
{/* Address */}
<div className={styles.field}>
<label className={styles.fieldLabel}>Адрес кошелька</label>
<input
className={styles.input}
type="text"
placeholder="0x…"
placeholder={cfg.addressPlaceholder}
value={address}
onChange={(e) => setAddress(e.target.value)}
autoComplete="off"
@@ -168,6 +222,7 @@ export function SendModal({ open, onClose, tokens, initialTicker }: Props) {
/>
</div>
{/* Amount */}
<div className={styles.field}>
<label className={styles.fieldLabel}>Количество</label>
<div className={styles.amountWrap}>
@@ -180,24 +235,36 @@ export function SendModal({ open, onClose, tokens, initialTicker }: Props) {
min="0"
step="any"
/>
{token && <span className={styles.amountTicker}>{token.ticker}</span>}
<span className={styles.amountTicker}>{tokenLabel}</span>
</div>
{token && (
{walletMatch && (
<div className={styles.maxHint}>
Макс:{' '}
<button
type="button"
className={styles.maxBtn}
onClick={() => setAmount(token.bal)}
onClick={() => setAmount(walletMatch.bal)}
>
{token.bal} {token.ticker}
{walletMatch.bal} {tokenLabel}
</button>
</div>
)}
</div>
<button className={styles.submitBtn} type="button">
Отправить
{/* Error */}
{mutation.isError && (
<div className={styles.errorMsg}>
{extractErrorMessage(mutation.error)}
</div>
)}
<button
className={styles.submitBtn}
type="button"
disabled={mutation.isPending}
onClick={handleSubmit}
>
{mutation.isPending ? 'Отправка…' : 'Отправить'}
</button>
</div>
</div>

View File

@@ -1,15 +1,19 @@
import { useState } from 'react'
import { useTokenRows } from '../model/useTokenRows'
import { SendModal } from '@widgets/send-modal'
import { SendModal, TICKER_TO_CHAIN, type Chain } from '@widgets/send-modal'
import styles from './TokenTable.module.css'
export function TokenTable() {
const { rows, isLoading } = useTokenRows()
const [favs, setFavs] = useState<boolean[]>(() => rows.map((t) => t.fav))
const [sendModal, setSendModal] = useState<{ open: boolean; ticker: string }>({ open: false, ticker: '' })
const [sendModal, setSendModal] = useState<{ open: boolean; network: Chain }>({
open: false,
network: 'ETH',
})
function openSend(ticker: string) {
setSendModal({ open: true, ticker })
const network = TICKER_TO_CHAIN[ticker] ?? 'ETH'
setSendModal({ open: true, network })
}
function closeSend() {
@@ -143,8 +147,8 @@ export function TokenTable() {
<SendModal
open={sendModal.open}
onClose={closeSend}
network={sendModal.network}
tokens={rows}
initialTicker={sendModal.ticker}
/>
</>
)

File diff suppressed because one or more lines are too long