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,732 @@
/**
* Settings screen — DChain explorer style, inline styles, Russian locale.
*/
import React, { useState, useEffect } from 'react';
import {
View, Text, ScrollView, TextInput, Alert, TouchableOpacity,
} from 'react-native';
import * as Clipboard from 'expo-clipboard';
import * as FileSystem from 'expo-file-system';
import * as Sharing from 'expo-sharing';
import { router } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { useStore } from '@/lib/store';
import { saveSettings, deleteKeyFile } from '@/lib/storage';
import {
setNodeUrl, getNetStats, resolveUsername, reverseResolve,
buildCallContractTx, submitTx,
USERNAME_REGISTRATION_FEE, MIN_USERNAME_LENGTH, MAX_USERNAME_LENGTH,
humanizeTxError,
} from '@/lib/api';
import { shortAddr } from '@/lib/crypto';
import { formatAmount } from '@/lib/utils';
import { Avatar } from '@/components/ui/Avatar';
// ─── 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;
// ─── Reusable sub-components ─────────────────────────────────────────────────
function SectionLabel({ children }: { children: string }) {
return (
<Text style={{
color: C.muted, fontSize: 11, letterSpacing: 1,
textTransform: 'uppercase', marginBottom: 8,
}}>
{children}
</Text>
);
}
function Card({ children, style }: { children: React.ReactNode; style?: object }) {
return (
<View style={{
backgroundColor: C.surface, borderRadius: 12,
overflow: 'hidden', marginBottom: 20, ...style,
}}>
{children}
</View>
);
}
function CardRow({
icon, label, value, first,
}: {
icon: keyof typeof Ionicons.glyphMap;
label: string;
value?: string;
first?: boolean;
}) {
return (
<View style={{
flexDirection: 'row', alignItems: 'center', gap: 12,
paddingHorizontal: 14, paddingVertical: 12,
borderTopWidth: first ? 0 : 1, borderTopColor: C.line,
}}>
<View style={{
width: 34, height: 34, borderRadius: 17,
backgroundColor: C.surface2,
alignItems: 'center', justifyContent: 'center', flexShrink: 0,
}}>
<Ionicons name={icon} size={16} color={C.muted} />
</View>
<View style={{ flex: 1, minWidth: 0 }}>
<Text style={{ color: C.muted, fontSize: 12 }}>{label}</Text>
{value !== undefined && (
<Text style={{ color: C.text, fontSize: 13, fontWeight: '500', marginTop: 1 }} numberOfLines={1}>
{value}
</Text>
)}
</View>
</View>
);
}
function FieldInput({
label, value, onChangeText, placeholder, keyboardType, autoCapitalize, autoCorrect,
}: {
label: string;
value: string;
onChangeText: (v: string) => void;
placeholder?: string;
keyboardType?: any;
autoCapitalize?: any;
autoCorrect?: boolean;
}) {
return (
<View style={{ paddingHorizontal: 14, paddingVertical: 10 }}>
<Text style={{ color: C.muted, fontSize: 11, marginBottom: 6 }}>{label}</Text>
<View style={{
backgroundColor: C.surface2, borderRadius: 8,
paddingHorizontal: 12, paddingVertical: 8,
borderWidth: 1, borderColor: C.line,
}}>
<TextInput
value={value}
onChangeText={onChangeText}
placeholder={placeholder}
placeholderTextColor={C.muted}
keyboardType={keyboardType}
autoCapitalize={autoCapitalize ?? 'none'}
autoCorrect={autoCorrect ?? false}
style={{ color: C.text, fontSize: 14, height: 36 }}
/>
</View>
</View>
);
}
// ─── Screen ───────────────────────────────────────────────────────────────────
export default function SettingsScreen() {
const keyFile = useStore(s => s.keyFile);
const settings = useStore(s => s.settings);
const setSettings = useStore(s => s.setSettings);
const username = useStore(s => s.username);
const setUsername = useStore(s => s.setUsername);
const setKeyFile = useStore(s => s.setKeyFile);
const balance = useStore(s => s.balance);
const [nodeUrl, setNodeUrlLocal] = useState(settings.nodeUrl);
const [contractId, setContractId] = useState(settings.contractId);
const [nodeStatus, setNodeStatus] = useState<'checking' | 'ok' | 'error'>('checking');
const [peerCount, setPeerCount] = useState<number | null>(null);
const [blockCount, setBlockCount] = useState<number | null>(null);
const [copied, setCopied] = useState(false);
const [showAdvanced, setShowAdvanced] = useState(false);
const insets = useSafeAreaInsets();
// Username registration state
const [nameInput, setNameInput] = useState('');
const [registering, setRegistering] = useState(false);
const [nameError, setNameError] = useState<string | null>(null);
// Sanitize: lowercase, only a-z 0-9 _ -, max 32 (contract limit)
function onNameInputChange(v: string) {
const cleaned = v.toLowerCase().replace(/[^a-z0-9_\-]/g, '').slice(0, MAX_USERNAME_LENGTH);
setNameInput(cleaned);
setNameError(null);
}
async function registerUsername() {
if (!keyFile) return;
const name = nameInput.trim();
// Mirror blockchain/native_username.go validateName so the UI gives
// immediate feedback without a round trip to the chain.
if (name.length < MIN_USERNAME_LENGTH) {
setNameError(`Минимум ${MIN_USERNAME_LENGTH} символа`);
return;
}
if (!/^[a-z]/.test(name)) {
setNameError('Имя должно начинаться с буквы a-z');
return;
}
if (!settings.contractId) {
setNameError('Не задан ID контракта реестра в настройках ноды.');
return;
}
const fee = USERNAME_REGISTRATION_FEE;
// Reserve for: registry fee (burned) + MIN_CALL_FEE (to validator)
// + small gas headroom (native contract is cheap, but gas is pre-charged).
const GAS_HEADROOM = 2_000;
const total = fee + 1000 + GAS_HEADROOM;
if (balance < total) {
setNameError(`Нужно ${formatAmount(total)} (с запасом на газ), доступно ${formatAmount(balance)}.`);
return;
}
// Check if name is already taken
try {
const existing = await resolveUsername(settings.contractId, name);
if (existing) {
setNameError(`@${name} уже зарегистрировано.`);
return;
}
} catch {
// ignore — lookup failure is OK, contract will reject on duplicate
}
Alert.alert(
'Купить @' + name + '?',
`Стоимость: ${formatAmount(fee)} + комиссия ${formatAmount(1000)}.\nИмя привязывается к вашему адресу навсегда (до release).`,
[
{ text: 'Отмена', style: 'cancel' },
{
text: 'Купить', onPress: async () => {
setRegistering(true);
setNameError(null);
try {
const tx = buildCallContractTx({
from: keyFile.pub_key,
contractId: settings.contractId,
method: 'register',
args: [name],
// Attach the registration fee in tx.amount — the contract
// requires exactly this much and burns it. Visible in the
// explorer so the user sees the real cost.
amount: USERNAME_REGISTRATION_FEE,
privKey: keyFile.priv_key,
});
await submitTx(tx);
Alert.alert(
'Отправлено',
`Транзакция покупки @${name} принята. Имя появится в профиле через несколько секунд.`,
);
setNameInput('');
// Poll every 2s for up to 20s until the address ↔ name binding is visible.
let attempts = 0;
const iv = setInterval(async () => {
attempts++;
const got = keyFile
? await reverseResolve(settings.contractId, keyFile.pub_key)
: null;
if (got) {
setUsername(got);
clearInterval(iv);
} else if (attempts >= 10) {
clearInterval(iv);
}
}, 2000);
} catch (e: any) {
setNameError(humanizeTxError(e));
} finally {
setRegistering(false);
}
},
},
],
);
}
// Flat fee — same for every name that passes validation.
const nameFee = USERNAME_REGISTRATION_FEE;
const nameIsValid = nameInput.length >= MIN_USERNAME_LENGTH && /^[a-z]/.test(nameInput);
useEffect(() => { checkNode(); }, []);
// Pick up auto-discovered contract IDs (useWellKnownContracts updates the
// store; reflect it into the local TextInput state so the UI stays consistent).
useEffect(() => {
setContractId(settings.contractId);
}, [settings.contractId]);
// When the registry contract becomes known (either via manual save or
// auto-discovery), look up the user's registered username reactively.
// Sets username unconditionally — a null result CLEARS the cached name,
// which matters when the user switches nodes / chains: a name on the
// previous chain should no longer show when connected to a chain where
// the same pubkey isn't registered.
useEffect(() => {
if (!settings.contractId || !keyFile) {
setUsername(null);
return;
}
(async () => {
const name = await reverseResolve(settings.contractId, keyFile.pub_key);
setUsername(name);
})();
}, [settings.contractId, keyFile, setUsername]);
async function checkNode() {
setNodeStatus('checking');
try {
const stats = await getNetStats();
setNodeStatus('ok');
setPeerCount(stats.peer_count);
setBlockCount(stats.total_blocks);
if (settings.contractId && keyFile) {
// Address → username: must use reverseResolve, not resolveUsername
// (resolveUsername goes username → address).
const name = await reverseResolve(settings.contractId, keyFile.pub_key);
if (name) setUsername(name);
}
} catch {
setNodeStatus('error');
}
}
async function saveNode() {
const url = nodeUrl.trim().replace(/\/$/, '');
setNodeUrl(url);
const next = { nodeUrl: url, contractId: contractId.trim() };
setSettings(next);
await saveSettings(next);
Alert.alert('Сохранено', 'Настройки ноды обновлены.');
checkNode();
}
async function exportKey() {
if (!keyFile) return;
try {
const json = JSON.stringify(keyFile, null, 2);
const path = FileSystem.cacheDirectory + 'dchain_key.json';
await FileSystem.writeAsStringAsync(path, json);
if (await Sharing.isAvailableAsync()) {
await Sharing.shareAsync(path, {
mimeType: 'application/json',
dialogTitle: 'Экспорт ключа DChain',
});
}
} catch (e: any) {
Alert.alert('Ошибка экспорта', e.message);
}
}
async function copyAddress() {
if (!keyFile) return;
await Clipboard.setStringAsync(keyFile.pub_key);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
function logout() {
Alert.alert(
'Удалить аккаунт',
'Ключ будет удалён с устройства. Убедитесь, что у вас есть резервная копия!',
[
{ text: 'Отмена', style: 'cancel' },
{
text: 'Удалить',
style: 'destructive',
onPress: async () => {
await deleteKeyFile();
setKeyFile(null);
router.replace('/');
},
},
],
);
}
const statusColor = nodeStatus === 'ok' ? C.ok : nodeStatus === 'error' ? C.err : C.warn;
const statusLabel = nodeStatus === 'ok' ? 'Подключена' : nodeStatus === 'error' ? 'Недоступна' : 'Проверка…';
return (
<ScrollView
style={{ flex: 1, backgroundColor: C.bg }}
contentContainerStyle={{
paddingHorizontal: 16,
paddingTop: insets.top + 12,
paddingBottom: Math.max(insets.bottom, 24) + 20,
}}
>
<Text style={{ color: C.text, fontSize: 22, fontWeight: '700', marginBottom: 24 }}>
Настройки
</Text>
{/* ── Профиль ── */}
<SectionLabel>Профиль</SectionLabel>
<Card>
{/* Avatar row */}
<View style={{
flexDirection: 'row', alignItems: 'center', gap: 14,
paddingHorizontal: 14, paddingVertical: 14,
}}>
<Avatar
name={username ? `@${username}` : (keyFile?.pub_key ?? '?')}
size="md"
/>
<View style={{ flex: 1, minWidth: 0 }}>
{username ? (
<Text style={{ color: C.text, fontWeight: '700', fontSize: 16 }}>@{username}</Text>
) : (
<Text style={{ color: C.muted, fontSize: 13 }}>Имя не зарегистрировано</Text>
)}
<Text style={{ color: C.muted, fontFamily: 'monospace', fontSize: 11 }} numberOfLines={1}>
{keyFile ? shortAddr(keyFile.pub_key, 10) : '—'}
</Text>
</View>
</View>
{/* Copy address */}
<View style={{ paddingHorizontal: 14, paddingBottom: 12, borderTopWidth: 1, borderTopColor: C.line, paddingTop: 10 }}>
<TouchableOpacity
onPress={copyAddress}
style={{
flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 8,
paddingVertical: 10, borderRadius: 8,
backgroundColor: copied ? 'rgba(65,201,138,0.12)' : C.surface2,
}}
>
<Ionicons
name={copied ? 'checkmark-outline' : 'copy-outline'}
size={15}
color={copied ? C.ok : C.muted}
/>
<Text style={{ color: copied ? C.ok : C.muted, fontSize: 13, fontWeight: '600' }}>
{copied ? 'Скопировано' : 'Скопировать адрес'}
</Text>
</TouchableOpacity>
</View>
</Card>
{/* ── Имя пользователя ── */}
<SectionLabel>Имя пользователя</SectionLabel>
<Card>
{username ? (
// Already registered
<View style={{
flexDirection: 'row', alignItems: 'center', gap: 12,
paddingHorizontal: 14, paddingVertical: 14,
}}>
<View style={{
width: 34, height: 34, borderRadius: 17,
backgroundColor: 'rgba(65,201,138,0.12)',
alignItems: 'center', justifyContent: 'center', flexShrink: 0,
}}>
<Ionicons name="at-outline" size={16} color={C.ok} />
</View>
<View style={{ flex: 1 }}>
<Text style={{ color: C.text, fontSize: 15, fontWeight: '700' }}>@{username}</Text>
<Text style={{ color: C.muted, fontSize: 12, marginTop: 2 }}>Привязано к вашему адресу</Text>
</View>
<View style={{
paddingHorizontal: 10, paddingVertical: 4,
borderRadius: 999, backgroundColor: 'rgba(65,201,138,0.12)',
}}>
<Text style={{ color: C.ok, fontSize: 11, fontWeight: '600' }}>Активно</Text>
</View>
</View>
) : (
// Register new
<>
<View style={{ paddingHorizontal: 14, paddingTop: 12, paddingBottom: 6 }}>
<Text style={{ color: C.text, fontSize: 14, fontWeight: '600', marginBottom: 3 }}>
Купить никнейм
</Text>
<Text style={{ color: C.muted, fontSize: 12, lineHeight: 17 }}>
Короткие имена дороже. Оплата идёт в казну контракта реестра.
</Text>
</View>
{/* Input */}
<View style={{ paddingHorizontal: 14, paddingTop: 10 }}>
<View style={{
flexDirection: 'row', alignItems: 'center',
backgroundColor: C.surface2, borderRadius: 8,
paddingHorizontal: 12,
borderWidth: 1, borderColor: nameError ? C.err : C.line,
}}>
<Text style={{ color: C.muted, fontSize: 15, marginRight: 4 }}>@</Text>
<TextInput
value={nameInput}
onChangeText={onNameInputChange}
placeholder="alice"
placeholderTextColor={C.muted}
autoCapitalize="none"
autoCorrect={false}
maxLength={64}
style={{ color: C.text, fontSize: 15, height: 40, flex: 1 }}
/>
{nameInput.length > 0 && (
<Text style={{ color: C.muted, fontSize: 11 }}>{nameInput.length}</Text>
)}
</View>
{nameError && (
<Text style={{ color: C.err, fontSize: 12, marginTop: 6 }}> {nameError}</Text>
)}
</View>
{/* Fee breakdown + rules */}
<View style={{ paddingHorizontal: 14, paddingTop: 10 }}>
<View style={{
backgroundColor: C.surface2, borderRadius: 8,
paddingHorizontal: 12, paddingVertical: 10,
}}>
{/* Primary cost line */}
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 8, marginBottom: 6 }}>
<Ionicons name="flame-outline" size={14} color={C.warn} />
<Text style={{ color: C.muted, fontSize: 12, flex: 1 }}>Плата за ник (сгорает)</Text>
<Text style={{ color: C.text, fontSize: 13, fontWeight: '600' }}>
{formatAmount(nameFee)}
</Text>
</View>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 8, marginBottom: 8 }}>
<Ionicons name="cube-outline" size={14} color={C.muted} />
<Text style={{ color: C.muted, fontSize: 12, flex: 1 }}>Комиссия сети (валидатору)</Text>
<Text style={{ color: C.muted, fontSize: 13 }}>
{formatAmount(1000)}
</Text>
</View>
{/* Total */}
<View style={{
flexDirection: 'row', alignItems: 'center', gap: 8,
paddingTop: 6,
borderTopWidth: 1, borderTopColor: C.line,
}}>
<Text style={{ color: C.text, fontSize: 12, fontWeight: '600', flex: 1 }}>Итого</Text>
<Text style={{ color: C.text, fontSize: 13, fontWeight: '700' }}>
{formatAmount(nameFee + 1000)}
</Text>
</View>
{/* Rules */}
<Text style={{ color: C.muted, fontSize: 11, lineHeight: 17, marginTop: 8 }}>
Минимум {MIN_USERNAME_LENGTH} символа, только{' '}
<Text style={{ color: C.text }}>a-z 0-9 _ -</Text>
, первый символ буква.
</Text>
</View>
</View>
{/* Register button */}
<View style={{ paddingHorizontal: 14, paddingVertical: 12 }}>
<TouchableOpacity
onPress={registerUsername}
disabled={registering || !nameIsValid || !settings.contractId}
style={{
flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 8,
paddingVertical: 12, borderRadius: 8,
backgroundColor: (registering || !nameIsValid || !settings.contractId)
? C.surface2 : C.accent,
}}
>
<Ionicons
name={registering ? 'hourglass-outline' : 'at-outline'}
size={16}
color={(registering || !nameIsValid || !settings.contractId) ? C.muted : C.bg}
/>
<Text style={{
color: (registering || !nameIsValid || !settings.contractId) ? C.muted : C.bg,
fontWeight: '700', fontSize: 14,
}}>
{registering ? 'Покупка…' : 'Купить никнейм'}
</Text>
</TouchableOpacity>
{!settings.contractId && (
<Text style={{ color: C.warn, fontSize: 11, marginTop: 6, textAlign: 'center' }}>
Укажите ID контракта реестра в настройках ноды ниже
</Text>
)}
</View>
</>
)}
</Card>
{/* ── Нода ── */}
<SectionLabel>Нода</SectionLabel>
<Card>
{/* Connection status */}
<View style={{
flexDirection: 'row', alignItems: 'center', gap: 12,
paddingHorizontal: 14, paddingVertical: 12,
}}>
<View style={{
width: 34, height: 34, borderRadius: 17,
backgroundColor: `${statusColor}15`,
alignItems: 'center', justifyContent: 'center',
}}>
<Ionicons
name={nodeStatus === 'ok' ? 'cloud-done-outline' : nodeStatus === 'error' ? 'cloud-offline-outline' : 'cloud-outline'}
size={16}
color={statusColor}
/>
</View>
<View style={{ flex: 1 }}>
<Text style={{ color: C.text, fontSize: 14, fontWeight: '600' }}>Подключение</Text>
<Text style={{ color: statusColor, fontSize: 12 }}>{statusLabel}</Text>
</View>
{nodeStatus === 'ok' && (
<View style={{ alignItems: 'flex-end', gap: 2 }}>
{peerCount !== null && (
<Text style={{ color: C.muted, fontSize: 11 }}>
<Text style={{ color: C.text, fontWeight: '600' }}>{peerCount}</Text> пиров
</Text>
)}
{blockCount !== null && (
<Text style={{ color: C.muted, fontSize: 11 }}>
<Text style={{ color: C.text, fontWeight: '600' }}>{blockCount.toLocaleString()}</Text> блоков
</Text>
)}
</View>
)}
</View>
{/* Node URL input */}
<View style={{ borderTopWidth: 1, borderTopColor: C.line }}>
<FieldInput
label="URL ноды"
value={nodeUrl}
onChangeText={setNodeUrlLocal}
keyboardType="url"
placeholder="http://localhost:8080"
/>
</View>
{/* Registry contract — auto-detected from node; manual override under advanced */}
<View style={{ borderTopWidth: 1, borderTopColor: C.line, paddingHorizontal: 14, paddingVertical: 12 }}>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 8, marginBottom: 6 }}>
<Ionicons
name={settings.contractId ? 'checkmark-circle' : 'help-circle-outline'}
size={14}
color={settings.contractId ? C.ok : C.warn}
/>
<Text style={{ color: C.muted, fontSize: 11, letterSpacing: 0.5 }}>
КОНТРАКТ РЕЕСТРА ИМЁН
</Text>
<Text style={{
marginLeft: 'auto' as any, color: settings.contractId ? C.ok : C.warn, fontSize: 11, fontWeight: '600',
}}>
{settings.contractId ? 'Авто-обнаружен' : 'Не найден'}
</Text>
</View>
<Text style={{ color: C.text, fontSize: 12, fontFamily: 'monospace' }} numberOfLines={1}>
{settings.contractId || '—'}
</Text>
<TouchableOpacity
onPress={() => setShowAdvanced(v => !v)}
style={{ marginTop: 8 }}
>
<Text style={{ color: C.accent, fontSize: 11 }}>
{showAdvanced ? '▾ Скрыть ручной ввод' : '▸ Указать вручную (не требуется)'}
</Text>
</TouchableOpacity>
{showAdvanced && (
<View style={{
marginTop: 8,
backgroundColor: C.surface2, borderRadius: 8,
paddingHorizontal: 12, paddingVertical: 8,
borderWidth: 1, borderColor: C.line,
}}>
<TextInput
value={contractId}
onChangeText={setContractId}
placeholder="hex contract ID"
placeholderTextColor={C.muted}
autoCapitalize="none"
autoCorrect={false}
style={{ color: C.text, fontSize: 13, fontFamily: 'monospace', height: 36 }}
/>
<Text style={{ color: C.muted, fontSize: 10, marginTop: 4 }}>
Оставьте пустым клиент запросит канонический контракт у ноды.
</Text>
</View>
)}
</View>
{/* Save button */}
<View style={{ paddingHorizontal: 14, paddingBottom: 12, paddingTop: 4, borderTopWidth: 1, borderTopColor: C.line }}>
<TouchableOpacity
onPress={saveNode}
style={{
flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 8,
paddingVertical: 11, borderRadius: 8, backgroundColor: C.accent,
}}
>
<Ionicons name="save-outline" size={16} color={C.bg} />
<Text style={{ color: C.bg, fontWeight: '700', fontSize: 14 }}>Сохранить и переподключиться</Text>
</TouchableOpacity>
</View>
</Card>
{/* ── Безопасность ── */}
<SectionLabel>Безопасность</SectionLabel>
<Card>
<View style={{
flexDirection: 'row', alignItems: 'center', gap: 12,
paddingHorizontal: 14, paddingVertical: 14,
}}>
<View style={{
width: 34, height: 34, borderRadius: 17,
backgroundColor: 'rgba(125,181,255,0.10)',
alignItems: 'center', justifyContent: 'center', flexShrink: 0,
}}>
<Ionicons name="shield-outline" size={16} color={C.accent} />
</View>
<View style={{ flex: 1 }}>
<Text style={{ color: C.text, fontSize: 14, fontWeight: '600' }}>Экспорт ключа</Text>
<Text style={{ color: C.muted, fontSize: 12, marginTop: 1 }}>Сохранить приватный ключ как key.json</Text>
</View>
<TouchableOpacity
onPress={exportKey}
style={{
paddingHorizontal: 14, paddingVertical: 8, borderRadius: 8,
backgroundColor: C.surface2, borderWidth: 1, borderColor: C.line,
}}
>
<Text style={{ color: C.text, fontSize: 13, fontWeight: '600' }}>Экспорт</Text>
</TouchableOpacity>
</View>
</Card>
{/* ── Опасная зона ── */}
<SectionLabel>Опасная зона</SectionLabel>
<Card style={{ backgroundColor: 'rgba(255,122,135,0.04)', borderWidth: 1, borderColor: 'rgba(255,122,135,0.20)' }}>
<View style={{ paddingHorizontal: 14, paddingVertical: 14 }}>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 10, marginBottom: 8 }}>
<Ionicons name="warning-outline" size={16} color={C.err} />
<Text style={{ color: C.err, fontSize: 14, fontWeight: '700' }}>Удалить аккаунт</Text>
</View>
<Text style={{ color: C.muted, fontSize: 13, lineHeight: 19, marginBottom: 12 }}>
Удаляет ключ с устройства. Онлайн-идентичность сохраняется, но доступ будет потерян без резервной копии.
</Text>
<TouchableOpacity
onPress={logout}
style={{
flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 8,
paddingVertical: 11, borderRadius: 8,
backgroundColor: 'rgba(255,122,135,0.12)',
}}
>
<Ionicons name="trash-outline" size={16} color={C.err} />
<Text style={{ color: C.err, fontWeight: '700', fontSize: 14 }}>Удалить с устройства</Text>
</TouchableOpacity>
</View>
</Card>
</ScrollView>
);
}