Files
dchain/client-app/app/(app)/wallet.tsx
vsecoder 7e7393e4f8 chore: initial commit for v0.0.1
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
2026-04-17 14:16:44 +03:00

597 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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>
);
}