Files
dchain/client-app/app/(app)/settings.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

733 lines
29 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.

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