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
This commit is contained in:
vsecoder
2026-04-17 14:16:44 +03:00
commit 7e7393e4f8
196 changed files with 55947 additions and 0 deletions

View File

@@ -0,0 +1,596 @@
/**
* 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>
);
}