DChain single-node blockchain + React Native messenger client. Core: - PBFT consensus with multi-sig validator admission + equivocation slashing - BadgerDB + schema migration scaffold (CurrentSchemaVersion=0) - libp2p gossipsub (tx/v1, blocks/v1, relay/v1, version/v1) - Native Go contracts (username_registry) alongside WASM (wazero) - WebSocket gateway with topic-based fanout + Ed25519-nonce auth - Relay mailbox with NaCl envelope encryption (X25519 + Ed25519) - Prometheus /metrics, per-IP rate limit, body-size cap Deployment: - Single-node compose (deploy/single/) with Caddy TLS + optional Prometheus - 3-node dev compose (docker-compose.yml) with mocked internet topology - 3-validator prod compose (deploy/prod/) for federation - Auto-update from Gitea via /api/update-check + systemd timer - Build-time version injection (ldflags → node --version) - UI / Swagger toggle flags (DCHAIN_DISABLE_UI, DCHAIN_DISABLE_SWAGGER) Client (client-app/): - Expo / React Native / NativeWind - E2E NaCl encryption, typing indicator, contact requests - Auto-discovery of canonical contracts, chain_id aware, WS reconnect on node switch Documentation: - README.md, CHANGELOG.md, CONTEXT.md - deploy/single/README.md with 6 operator scenarios - deploy/UPDATE_STRATEGY.md with 4-layer forward-compat design - docs/contracts/*.md per contract
597 lines
24 KiB
TypeScript
597 lines
24 KiB
TypeScript
/**
|
||
* Wallet screen — DChain explorer style.
|
||
* Balance block inspired by Tinkoff/Gravity UI reference.
|
||
* Icons: Ionicons from @expo/vector-icons.
|
||
*/
|
||
|
||
import React, { useState, useCallback, useEffect } from 'react';
|
||
import {
|
||
View, Text, ScrollView, Modal, TouchableOpacity,
|
||
Alert, RefreshControl,
|
||
} from 'react-native';
|
||
import * as Clipboard from 'expo-clipboard';
|
||
import { Ionicons } from '@expo/vector-icons';
|
||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||
import { useStore } from '@/lib/store';
|
||
import { useBalance } from '@/hooks/useBalance';
|
||
import { buildTransferTx, submitTx, getTxHistory, getBalance } from '@/lib/api';
|
||
import { shortAddr } from '@/lib/crypto';
|
||
import { formatAmount, relativeTime } from '@/lib/utils';
|
||
import { Input } from '@/components/ui/Input';
|
||
import type { TxRecord } from '@/lib/types';
|
||
|
||
// ─── Design tokens ────────────────────────────────────────────────────────────
|
||
const C = {
|
||
bg: '#0b1220',
|
||
surface: '#111a2b',
|
||
surface2:'#162035',
|
||
surface3:'#1a2640',
|
||
line: '#1c2840',
|
||
text: '#e6edf9',
|
||
muted: '#98a7c2',
|
||
accent: '#7db5ff',
|
||
ok: '#41c98a',
|
||
warn: '#f0b35a',
|
||
err: '#ff7a87',
|
||
} as const;
|
||
|
||
// ─── TX metadata ──────────────────────────────────────────────────────────────
|
||
|
||
const TX_META: Record<string, { label: string; icon: keyof typeof Ionicons.glyphMap; color: string }> = {
|
||
TRANSFER: { label: 'Перевод', icon: 'paper-plane-outline', color: C.accent },
|
||
CONTACT_REQUEST: { label: 'Запрос контакта', icon: 'person-add-outline', color: C.ok },
|
||
ACCEPT_CONTACT: { label: 'Принят контакт', icon: 'person-outline', color: C.ok },
|
||
BLOCK_CONTACT: { label: 'Блокировка', icon: 'ban-outline', color: C.err },
|
||
DEPLOY_CONTRACT: { label: 'Деплой контракта',icon: 'document-text-outline', color: C.warn },
|
||
CALL_CONTRACT: { label: 'Вызов контракта', icon: 'flash-outline', color: C.warn },
|
||
STAKE: { label: 'Стейкинг', icon: 'lock-closed-outline', color: C.accent},
|
||
UNSTAKE: { label: 'Вывод стейка', icon: 'lock-open-outline', color: C.muted },
|
||
REGISTER_KEY: { label: 'Регистрация', icon: 'key-outline', color: C.muted },
|
||
BLOCK_REWARD: { label: 'Награда', icon: 'diamond-outline', color: C.ok },
|
||
};
|
||
|
||
function txMeta(type: string) {
|
||
return TX_META[type] ?? { label: type.replace(/_/g, ' '), icon: 'ellipse-outline' as any, color: C.muted };
|
||
}
|
||
|
||
// ─── Component ────────────────────────────────────────────────────────────────
|
||
|
||
export default function WalletScreen() {
|
||
const keyFile = useStore(s => s.keyFile);
|
||
const balance = useStore(s => s.balance);
|
||
const setBalance = useStore(s => s.setBalance);
|
||
const insets = useSafeAreaInsets();
|
||
|
||
useBalance();
|
||
|
||
const [txHistory, setTxHistory] = useState<TxRecord[]>([]);
|
||
const [refreshing, setRefreshing] = useState(false);
|
||
const [showSend, setShowSend] = useState(false);
|
||
const [selectedTx, setSelectedTx] = useState<TxRecord | null>(null);
|
||
const [toAddress, setToAddress] = useState('');
|
||
const [amount, setAmount] = useState('');
|
||
const [fee, setFee] = useState('1000');
|
||
const [sending, setSending] = useState(false);
|
||
const [copied, setCopied] = useState(false);
|
||
|
||
const load = useCallback(async () => {
|
||
if (!keyFile) return;
|
||
setRefreshing(true);
|
||
try {
|
||
const [hist, bal] = await Promise.all([
|
||
getTxHistory(keyFile.pub_key),
|
||
getBalance(keyFile.pub_key),
|
||
]);
|
||
setTxHistory(hist);
|
||
setBalance(bal);
|
||
} catch {}
|
||
setRefreshing(false);
|
||
}, [keyFile]);
|
||
|
||
useEffect(() => { load(); }, [load]);
|
||
|
||
const copyAddress = async () => {
|
||
if (!keyFile) return;
|
||
await Clipboard.setStringAsync(keyFile.pub_key);
|
||
setCopied(true);
|
||
setTimeout(() => setCopied(false), 2000);
|
||
};
|
||
|
||
const send = async () => {
|
||
if (!keyFile) return;
|
||
const amt = parseInt(amount);
|
||
const f = parseInt(fee);
|
||
if (!toAddress.trim() || isNaN(amt) || amt <= 0) {
|
||
Alert.alert('Неверные данные', 'Введите корректный адрес и сумму.');
|
||
return;
|
||
}
|
||
if (amt + f > balance) {
|
||
Alert.alert('Недостаточно средств', `Нужно ${formatAmount(amt + f)}, доступно ${formatAmount(balance)}.`);
|
||
return;
|
||
}
|
||
setSending(true);
|
||
try {
|
||
const tx = buildTransferTx({ from: keyFile.pub_key, to: toAddress.trim(), amount: amt, fee: f, privKey: keyFile.priv_key });
|
||
await submitTx(tx);
|
||
setShowSend(false);
|
||
setToAddress('');
|
||
setAmount('');
|
||
Alert.alert('Отправлено', 'Транзакция принята нодой.');
|
||
setTimeout(load, 1500);
|
||
} catch (e: any) {
|
||
Alert.alert('Ошибка', e.message);
|
||
} finally {
|
||
setSending(false);
|
||
}
|
||
};
|
||
|
||
return (
|
||
<View style={{ flex: 1, backgroundColor: C.bg }}>
|
||
<ScrollView
|
||
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={load} tintColor={C.accent} />}
|
||
contentContainerStyle={{ paddingBottom: 32 }}
|
||
>
|
||
{/* ── Balance hero ── */}
|
||
<BalanceHero
|
||
balance={balance}
|
||
address={keyFile?.pub_key ?? ''}
|
||
copied={copied}
|
||
topInset={insets.top}
|
||
onSend={() => setShowSend(true)}
|
||
onReceive={copyAddress}
|
||
onRefresh={load}
|
||
onCopy={copyAddress}
|
||
/>
|
||
|
||
{/* ── Transaction list ── */}
|
||
<View style={{ paddingHorizontal: 16, paddingTop: 8 }}>
|
||
<Text style={{ color: C.muted, fontSize: 11, letterSpacing: 1, textTransform: 'uppercase', marginBottom: 10 }}>
|
||
История транзакций
|
||
</Text>
|
||
|
||
{txHistory.length === 0 ? (
|
||
<View style={{ backgroundColor: C.surface, borderRadius: 12, paddingVertical: 36, alignItems: 'center' }}>
|
||
<Ionicons name="receipt-outline" size={32} color={C.muted} style={{ marginBottom: 10 }} />
|
||
<Text style={{ color: C.muted, fontSize: 14 }}>Нет транзакций</Text>
|
||
<Text style={{ color: C.muted, fontSize: 12, marginTop: 4 }}>Потяните вниз, чтобы обновить</Text>
|
||
</View>
|
||
) : (
|
||
<View style={{ backgroundColor: C.surface, borderRadius: 12, overflow: 'hidden' }}>
|
||
{/* Table header */}
|
||
<View style={{
|
||
flexDirection: 'row',
|
||
alignItems: 'center',
|
||
paddingHorizontal: 14,
|
||
paddingVertical: 8,
|
||
borderBottomWidth: 1,
|
||
borderBottomColor: C.line,
|
||
}}>
|
||
<Text style={{ color: C.muted, fontSize: 10, fontWeight: '600', letterSpacing: 0.5, width: 140 }}>
|
||
ТИП
|
||
</Text>
|
||
<Text style={{ color: C.muted, fontSize: 10, fontWeight: '600', letterSpacing: 0.5, flex: 1 }}>
|
||
АДРЕС
|
||
</Text>
|
||
<Text style={{ color: C.muted, fontSize: 10, fontWeight: '600', letterSpacing: 0.5, width: 84, textAlign: 'right' }}>
|
||
СУММА
|
||
</Text>
|
||
</View>
|
||
|
||
{txHistory.map((tx, i) => (
|
||
<TxRow
|
||
key={tx.hash}
|
||
tx={tx}
|
||
myPubKey={keyFile?.pub_key ?? ''}
|
||
isLast={i === txHistory.length - 1}
|
||
onPress={() => setSelectedTx(tx)}
|
||
/>
|
||
))}
|
||
</View>
|
||
)}
|
||
</View>
|
||
</ScrollView>
|
||
|
||
{/* Send modal */}
|
||
<Modal visible={showSend} animationType="slide" presentationStyle="pageSheet" onRequestClose={() => setShowSend(false)}>
|
||
<SendSheet
|
||
balance={balance}
|
||
toAddress={toAddress} setToAddress={setToAddress}
|
||
amount={amount} setAmount={setAmount}
|
||
fee={fee} setFee={setFee}
|
||
sending={sending}
|
||
onSend={send}
|
||
onClose={() => setShowSend(false)}
|
||
/>
|
||
</Modal>
|
||
|
||
{/* TX Detail modal */}
|
||
<Modal visible={!!selectedTx} animationType="slide" presentationStyle="pageSheet" onRequestClose={() => setSelectedTx(null)}>
|
||
{selectedTx && (
|
||
<TxDetailSheet tx={selectedTx} myPubKey={keyFile?.pub_key ?? ''} onClose={() => setSelectedTx(null)} />
|
||
)}
|
||
</Modal>
|
||
</View>
|
||
);
|
||
}
|
||
|
||
// ─── Balance Hero ─────────────────────────────────────────────────────────────
|
||
|
||
function BalanceHero({
|
||
balance, address, copied, topInset, onSend, onReceive, onRefresh, onCopy,
|
||
}: {
|
||
balance: number; address: string; copied: boolean;
|
||
topInset: number;
|
||
onSend: () => void; onReceive: () => void;
|
||
onRefresh: () => void; onCopy: () => void;
|
||
}) {
|
||
return (
|
||
<View style={{
|
||
backgroundColor: C.surface,
|
||
paddingTop: topInset + 16, paddingBottom: 28,
|
||
paddingHorizontal: 20,
|
||
borderBottomLeftRadius: 20,
|
||
borderBottomRightRadius: 20,
|
||
alignItems: 'center',
|
||
marginBottom: 20,
|
||
}}>
|
||
{/* Label */}
|
||
<Text style={{ color: C.muted, fontSize: 13, marginBottom: 8, letterSpacing: 0.3 }}>
|
||
Баланс
|
||
</Text>
|
||
|
||
{/* Main balance */}
|
||
<Text style={{ color: C.text, fontSize: 42, fontWeight: '700', letterSpacing: -1, lineHeight: 50 }}>
|
||
{formatAmount(balance)}
|
||
</Text>
|
||
|
||
{/* µT sub-label */}
|
||
<Text style={{ color: C.muted, fontSize: 13, marginTop: 4, marginBottom: 20 }}>
|
||
{(balance ?? 0).toLocaleString()} µT
|
||
</Text>
|
||
|
||
{/* Address chip */}
|
||
<TouchableOpacity
|
||
onPress={onCopy}
|
||
style={{
|
||
flexDirection: 'row', alignItems: 'center', gap: 6,
|
||
backgroundColor: copied ? 'rgba(65,201,138,0.12)' : C.surface2,
|
||
borderRadius: 999, paddingHorizontal: 12, paddingVertical: 6,
|
||
marginBottom: 24,
|
||
}}
|
||
>
|
||
<Ionicons
|
||
name={copied ? 'checkmark-outline' : 'copy-outline'}
|
||
size={13}
|
||
color={copied ? C.ok : C.muted}
|
||
/>
|
||
<Text style={{ color: copied ? C.ok : C.muted, fontSize: 12, fontFamily: 'monospace' }}>
|
||
{copied ? 'Скопировано' : (address ? shortAddr(address, 8) : '—')}
|
||
</Text>
|
||
</TouchableOpacity>
|
||
|
||
{/* Action buttons */}
|
||
<View style={{ flexDirection: 'row', gap: 20 }}>
|
||
<ActionButton icon="paper-plane-outline" label="Отправить" color={C.accent} onPress={onSend} />
|
||
<ActionButton icon="arrow-down-circle-outline" label="Получить" color={C.accent} onPress={onReceive} />
|
||
<ActionButton icon="refresh-outline" label="Обновить" color={C.muted} onPress={onRefresh} />
|
||
</View>
|
||
</View>
|
||
);
|
||
}
|
||
|
||
function ActionButton({
|
||
icon, label, color, onPress,
|
||
}: {
|
||
icon: keyof typeof Ionicons.glyphMap; label: string; color: string; onPress: () => void;
|
||
}) {
|
||
return (
|
||
<TouchableOpacity onPress={onPress} style={{ alignItems: 'center', gap: 8 }}>
|
||
<View style={{
|
||
width: 52, height: 52, borderRadius: 26,
|
||
backgroundColor: C.surface3,
|
||
alignItems: 'center', justifyContent: 'center',
|
||
}}>
|
||
<Ionicons name={icon} size={22} color={color} />
|
||
</View>
|
||
<Text style={{ color: C.muted, fontSize: 12 }}>{label}</Text>
|
||
</TouchableOpacity>
|
||
);
|
||
}
|
||
|
||
// ─── Transaction Row ──────────────────────────────────────────────────────────
|
||
// Column widths must match the header above:
|
||
// col1 (type+icon): 140 col2 (address): flex:1 col3 (amount): 84
|
||
|
||
// tx types where "from" is always the owner but it's income, not a send
|
||
const RECEIVED_TYPES = new Set(['BLOCK_REWARD', 'STAKE_REWARD']);
|
||
|
||
function TxRow({
|
||
tx, myPubKey, isLast, onPress,
|
||
}: {
|
||
tx: TxRecord; myPubKey: string; isLast: boolean; onPress: () => void;
|
||
}) {
|
||
const meta = txMeta(tx.type);
|
||
const isSynthetic = !tx.from; // block reward / mint
|
||
const isSent = !isSynthetic && !RECEIVED_TYPES.has(tx.type) && tx.from === myPubKey;
|
||
const amt = tx.amount ?? 0;
|
||
const amtText = amt === 0 ? '' : `${isSent ? '−' : '+'}${formatAmount(amt)}`;
|
||
const amtColor = isSent ? C.err : C.ok;
|
||
|
||
// Counterpart label: for synthetic (empty from) rewards → "Сеть",
|
||
// otherwise show the short address of the other side.
|
||
const counterpart = isSynthetic
|
||
? 'Сеть'
|
||
: isSent
|
||
? (tx.to ? shortAddr(tx.to, 6) : '—')
|
||
: shortAddr(tx.from, 6);
|
||
|
||
return (
|
||
<TouchableOpacity
|
||
onPress={onPress}
|
||
activeOpacity={0.6}
|
||
style={{
|
||
flexDirection: 'row',
|
||
alignItems: 'center',
|
||
paddingHorizontal: 14,
|
||
paddingVertical: 11,
|
||
borderBottomWidth: isLast ? 0 : 1,
|
||
borderBottomColor: C.line,
|
||
}}
|
||
>
|
||
{/* Col 1 — icon + type label, fixed 140px */}
|
||
<View style={{ width: 140, flexDirection: 'row', alignItems: 'center' }}>
|
||
<View style={{
|
||
width: 28, height: 28, borderRadius: 14,
|
||
backgroundColor: `${meta.color}1a`,
|
||
alignItems: 'center', justifyContent: 'center',
|
||
marginRight: 8, flexShrink: 0,
|
||
}}>
|
||
<Ionicons name={meta.icon} size={14} color={meta.color} />
|
||
</View>
|
||
<Text style={{ color: C.text, fontSize: 13, fontWeight: '500', flexShrink: 1 }} numberOfLines={1}>
|
||
{meta.label}
|
||
</Text>
|
||
</View>
|
||
|
||
{/* Col 2 — address, flex */}
|
||
<View style={{ flex: 1 }}>
|
||
<Text style={{ color: C.muted, fontSize: 12 }} numberOfLines={1}>
|
||
{isSent ? `→ ${counterpart}` : `← ${counterpart}`}
|
||
</Text>
|
||
</View>
|
||
|
||
{/* Col 3 — amount + time, fixed 84px, right-aligned */}
|
||
<View style={{ width: 84, alignItems: 'flex-end' }}>
|
||
{!!amtText && (
|
||
<Text style={{ color: amtColor, fontSize: 12, fontWeight: '600' }} numberOfLines={1}>
|
||
{amtText}
|
||
</Text>
|
||
)}
|
||
<Text style={{ color: C.muted, fontSize: 11, marginTop: amtText ? 1 : 0 }} numberOfLines={1}>
|
||
{relativeTime(tx.timestamp)}
|
||
</Text>
|
||
</View>
|
||
</TouchableOpacity>
|
||
);
|
||
}
|
||
|
||
// ─── Send Sheet ───────────────────────────────────────────────────────────────
|
||
|
||
function SendSheet({
|
||
balance, toAddress, setToAddress, amount, setAmount, fee, setFee, sending, onSend, onClose,
|
||
}: {
|
||
balance: number;
|
||
toAddress: string; setToAddress: (v: string) => void;
|
||
amount: string; setAmount: (v: string) => void;
|
||
fee: string; setFee: (v: string) => void;
|
||
sending: boolean; onSend: () => void; onClose: () => void;
|
||
}) {
|
||
return (
|
||
<View style={{ flex: 1, backgroundColor: C.bg, paddingHorizontal: 16, paddingTop: 20 }}>
|
||
<View style={{ flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginBottom: 20 }}>
|
||
<Text style={{ color: C.text, fontSize: 20, fontWeight: '700' }}>Отправить</Text>
|
||
<TouchableOpacity onPress={onClose} style={{ padding: 8, backgroundColor: C.surface2, borderRadius: 8 }}>
|
||
<Ionicons name="close-outline" size={18} color={C.muted} />
|
||
</TouchableOpacity>
|
||
</View>
|
||
|
||
<View style={{
|
||
flexDirection: 'row', alignItems: 'center', gap: 8,
|
||
backgroundColor: C.surface, borderRadius: 8,
|
||
paddingHorizontal: 12, paddingVertical: 10, marginBottom: 20,
|
||
}}>
|
||
<Ionicons name="wallet-outline" size={15} color={C.muted} />
|
||
<Text style={{ color: C.muted, fontSize: 13 }}>Доступно:</Text>
|
||
<Text style={{ color: C.text, fontSize: 13, fontWeight: '600' }}>{formatAmount(balance)}</Text>
|
||
</View>
|
||
|
||
<Input label="Адрес получателя" placeholder="64-символьный hex" value={toAddress}
|
||
onChangeText={setToAddress} autoCapitalize="none" autoCorrect={false} className="mb-4" />
|
||
<Input label="Сумма (µT)" placeholder="например 100000" value={amount}
|
||
onChangeText={setAmount} keyboardType="numeric" className="mb-4" />
|
||
<Input label="Комиссия (µT)" value={fee}
|
||
onChangeText={setFee} keyboardType="numeric" className="mb-8" />
|
||
|
||
<TouchableOpacity
|
||
onPress={onSend}
|
||
disabled={sending}
|
||
style={{
|
||
paddingVertical: 15, borderRadius: 10, alignItems: 'center', flexDirection: 'row',
|
||
justifyContent: 'center', gap: 8,
|
||
backgroundColor: sending ? C.surface2 : C.accent,
|
||
}}
|
||
>
|
||
{!sending && <Ionicons name="paper-plane-outline" size={18} color="#0b1220" />}
|
||
<Text style={{ color: sending ? C.muted : '#0b1220', fontWeight: '700', fontSize: 15 }}>
|
||
{sending ? 'Отправка…' : 'Подтвердить'}
|
||
</Text>
|
||
</TouchableOpacity>
|
||
</View>
|
||
);
|
||
}
|
||
|
||
// ─── TX Detail Sheet ──────────────────────────────────────────────────────────
|
||
|
||
function TxDetailSheet({
|
||
tx, myPubKey, onClose,
|
||
}: {
|
||
tx: TxRecord; myPubKey: string; onClose: () => void;
|
||
}) {
|
||
const [copiedHash, setCopiedHash] = useState(false);
|
||
const meta = txMeta(tx.type);
|
||
const isSent = tx.from === myPubKey;
|
||
const amtValue = tx.amount ?? 0;
|
||
|
||
const copyHash = async () => {
|
||
await Clipboard.setStringAsync(tx.hash);
|
||
setCopiedHash(true);
|
||
setTimeout(() => setCopiedHash(false), 2000);
|
||
};
|
||
|
||
const amtColor = amtValue === 0 ? C.muted : isSent ? C.err : C.ok;
|
||
const amtSign = amtValue === 0 ? '' : isSent ? '−' : '+';
|
||
|
||
return (
|
||
<View style={{ flex: 1, backgroundColor: C.bg }}>
|
||
{/* Handle */}
|
||
<View style={{ alignItems: 'center', paddingTop: 12, marginBottom: 4 }}>
|
||
<View style={{ width: 36, height: 4, borderRadius: 2, backgroundColor: C.line }} />
|
||
</View>
|
||
|
||
<ScrollView contentContainerStyle={{ paddingHorizontal: 16, paddingTop: 8, paddingBottom: 32 }}>
|
||
{/* Header */}
|
||
<View style={{ flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginBottom: 20 }}>
|
||
<Text style={{ color: C.text, fontSize: 17, fontWeight: '700' }}>Транзакция</Text>
|
||
<TouchableOpacity onPress={onClose} style={{ padding: 8, backgroundColor: C.surface2, borderRadius: 8 }}>
|
||
<Ionicons name="close-outline" size={18} color={C.muted} />
|
||
</TouchableOpacity>
|
||
</View>
|
||
|
||
{/* Hero */}
|
||
<View style={{ backgroundColor: C.surface, borderRadius: 12, padding: 20, alignItems: 'center', marginBottom: 16 }}>
|
||
<View style={{
|
||
width: 56, height: 56, borderRadius: 28,
|
||
backgroundColor: `${meta.color}18`,
|
||
alignItems: 'center', justifyContent: 'center', marginBottom: 12,
|
||
}}>
|
||
<Ionicons name={meta.icon} size={26} color={meta.color} />
|
||
</View>
|
||
<Text style={{ color: C.text, fontSize: 16, fontWeight: '600', marginBottom: amtValue > 0 ? 8 : 0 }}>
|
||
{meta.label}
|
||
</Text>
|
||
{amtValue > 0 && (
|
||
<Text style={{ color: amtColor, fontSize: 30, fontWeight: '700', letterSpacing: -0.5 }}>
|
||
{amtSign}{formatAmount(amtValue)}
|
||
</Text>
|
||
)}
|
||
<View style={{
|
||
marginTop: 12, paddingHorizontal: 12, paddingVertical: 4,
|
||
borderRadius: 999,
|
||
backgroundColor: tx.status === 'confirmed' ? 'rgba(65,201,138,0.12)' : 'rgba(240,179,90,0.12)',
|
||
}}>
|
||
<Text style={{ color: tx.status === 'confirmed' ? C.ok : C.warn, fontSize: 12, fontWeight: '600' }}>
|
||
{tx.status === 'confirmed' ? '✓ Подтверждена' : '⏳ В обработке'}
|
||
</Text>
|
||
</View>
|
||
</View>
|
||
|
||
{/* Details table */}
|
||
<View style={{ backgroundColor: C.surface, borderRadius: 12, overflow: 'hidden', marginBottom: 16 }}>
|
||
<DetailRow icon="code-outline" label="Тип" value={tx.type} mono first />
|
||
{tx.amount !== undefined && (
|
||
<DetailRow icon="cash-outline" label="Сумма" value={`${tx.amount.toLocaleString()} µT`} />
|
||
)}
|
||
<DetailRow icon="pricetag-outline" label="Комиссия" value={`${(tx.fee ?? 0).toLocaleString()} µT`} />
|
||
{tx.timestamp > 0 && (
|
||
<DetailRow icon="time-outline" label="Время" value={new Date(tx.timestamp * 1000).toLocaleString()} />
|
||
)}
|
||
</View>
|
||
|
||
{/* Addresses */}
|
||
<View style={{ backgroundColor: C.surface, borderRadius: 12, overflow: 'hidden', marginBottom: 16 }}>
|
||
{/* "From" for synthetic txs (empty tx.from) reads "Сеть" rather than an empty row. */}
|
||
<DetailRow
|
||
icon="person-outline"
|
||
label="От"
|
||
value={tx.from || 'Сеть (синтетическая tx)'}
|
||
mono={!!tx.from}
|
||
truncate={!!tx.from}
|
||
first
|
||
/>
|
||
{tx.to && (
|
||
<DetailRow icon="arrow-forward-outline" label="Кому" value={tx.to} mono truncate />
|
||
)}
|
||
</View>
|
||
|
||
{/* TX Hash */}
|
||
<View style={{ backgroundColor: C.surface, borderRadius: 12, overflow: 'hidden', marginBottom: 20 }}>
|
||
<View style={{ paddingHorizontal: 14, paddingVertical: 12 }}>
|
||
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 6, marginBottom: 6 }}>
|
||
<Ionicons name="receipt-outline" size={13} color={C.muted} />
|
||
<Text style={{ color: C.muted, fontSize: 11, letterSpacing: 0.5, textTransform: 'uppercase' }}>
|
||
TX ID / Hash
|
||
</Text>
|
||
</View>
|
||
<Text style={{ color: C.text, fontFamily: 'monospace', fontSize: 11, lineHeight: 17 }}>
|
||
{tx.hash}
|
||
</Text>
|
||
</View>
|
||
</View>
|
||
|
||
<TouchableOpacity
|
||
onPress={copyHash}
|
||
style={{
|
||
paddingVertical: 13, borderRadius: 10, alignItems: 'center',
|
||
flexDirection: 'row', justifyContent: 'center', gap: 8,
|
||
backgroundColor: copiedHash ? 'rgba(65,201,138,0.12)' : C.surface2,
|
||
}}
|
||
>
|
||
<Ionicons
|
||
name={copiedHash ? 'checkmark-outline' : 'copy-outline'}
|
||
size={16}
|
||
color={copiedHash ? C.ok : C.text}
|
||
/>
|
||
<Text style={{ color: copiedHash ? C.ok : C.text, fontWeight: '600', fontSize: 14 }}>
|
||
{copiedHash ? 'Скопировано' : 'Копировать хеш'}
|
||
</Text>
|
||
</TouchableOpacity>
|
||
</ScrollView>
|
||
</View>
|
||
);
|
||
}
|
||
|
||
// ─── Detail Row ───────────────────────────────────────────────────────────────
|
||
|
||
function DetailRow({
|
||
icon, label, value, mono, truncate, first,
|
||
}: {
|
||
icon: keyof typeof Ionicons.glyphMap;
|
||
label: string; value: string;
|
||
mono?: boolean; truncate?: boolean; first?: boolean;
|
||
}) {
|
||
return (
|
||
<View style={{
|
||
flexDirection: 'row', alignItems: 'center',
|
||
paddingHorizontal: 14, paddingVertical: 11,
|
||
borderTopWidth: first ? 0 : 1, borderTopColor: C.line,
|
||
gap: 10,
|
||
}}>
|
||
<Ionicons name={icon} size={14} color={C.muted} style={{ width: 16 }} />
|
||
<Text style={{ color: C.muted, fontSize: 13, width: 72 }}>{label}</Text>
|
||
<Text
|
||
style={{
|
||
color: C.text,
|
||
flex: 1,
|
||
textAlign: 'right',
|
||
fontFamily: mono ? 'monospace' : undefined,
|
||
fontSize: mono ? 11 : 13,
|
||
}}
|
||
numberOfLines={truncate ? 1 : undefined}
|
||
ellipsizeMode="middle"
|
||
>
|
||
{value}
|
||
</Text>
|
||
</View>
|
||
);
|
||
}
|