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

333 lines
12 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.

/**
* Add New Contact — DChain explorer design style.
* Sends CONTACT_REQUEST on-chain with correct amount/fee fields.
*/
import React, { useState } from 'react';
import {
View, Text, ScrollView, Alert, TouchableOpacity, TextInput,
} from 'react-native';
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 { getIdentity, buildContactRequestTx, submitTx } from '@/lib/api';
import { shortAddr } from '@/lib/crypto';
import { formatAmount } from '@/lib/utils';
import { Avatar } from '@/components/ui/Avatar';
const C = {
bg: '#0b1220',
surface: '#111a2b',
surface2:'#162035',
line: '#1c2840',
text: '#e6edf9',
muted: '#98a7c2',
accent: '#7db5ff',
ok: '#41c98a',
warn: '#f0b35a',
err: '#ff7a87',
} as const;
const MIN_CONTACT_FEE = 5000;
const FEE_OPTIONS = [
{ label: '5 000 µT', value: 5_000, note: 'минимум' },
{ label: '10 000 µT', value: 10_000, note: 'стандарт' },
{ label: '50 000 µT', value: 50_000, note: 'приоритет' },
];
interface Resolved {
address: string;
nickname?: string;
x25519?: string;
}
export default function NewContactScreen() {
const keyFile = useStore(s => s.keyFile);
const settings = useStore(s => s.settings);
const balance = useStore(s => s.balance);
const insets = useSafeAreaInsets();
const [query, setQuery] = useState('');
const [intro, setIntro] = useState('');
const [fee, setFee] = useState(MIN_CONTACT_FEE);
const [resolved, setResolved] = useState<Resolved | null>(null);
const [searching, setSearching] = useState(false);
const [sending, setSending] = useState(false);
const [error, setError] = useState<string | null>(null);
async function search() {
const q = query.trim();
if (!q) return;
setSearching(true);
setResolved(null);
setError(null);
try {
let address = q;
// @username lookup via registry contract
if (q.startsWith('@') || (!q.match(/^[0-9a-f]{64}$/i) && !q.startsWith('DC'))) {
const name = q.replace('@', '');
const { resolveUsername } = await import('@/lib/api');
const addr = await resolveUsername(settings.contractId, name);
if (!addr) {
setError(`@${name} не зарегистрирован в сети.`);
return;
}
address = addr;
}
// Fetch identity to get nickname and x25519 key
const identity = await getIdentity(address);
setResolved({
address: identity?.pub_key ?? address,
nickname: identity?.nickname || undefined,
x25519: identity?.x25519_pub || undefined,
});
} catch (e: any) {
setError(e.message);
} finally {
setSearching(false);
}
}
async function sendRequest() {
if (!resolved || !keyFile) return;
if (balance < fee + 1000) {
Alert.alert('Недостаточно средств', `Нужно ${formatAmount(fee + 1000)} (fee + комиссия сети).`);
return;
}
setSending(true);
setError(null);
try {
const tx = buildContactRequestTx({
from: keyFile.pub_key,
to: resolved.address,
contactFee: fee,
intro: intro.trim() || undefined,
privKey: keyFile.priv_key,
});
await submitTx(tx);
Alert.alert(
'Запрос отправлен',
`Контакту ${resolved.nickname ? '@' + resolved.nickname : shortAddr(resolved.address)} отправлен запрос.`,
[{ text: 'OK', onPress: () => router.back() }],
);
} catch (e: any) {
setError(e.message);
} finally {
setSending(false);
}
}
const displayName = resolved
? (resolved.nickname ? `@${resolved.nickname}` : shortAddr(resolved.address))
: null;
return (
<ScrollView
style={{ flex: 1, backgroundColor: C.bg }}
contentContainerStyle={{
paddingHorizontal: 16,
paddingTop: insets.top + 8,
paddingBottom: Math.max(insets.bottom, 32),
}}
keyboardShouldPersistTaps="handled"
>
{/* Header */}
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 6, marginBottom: 24, marginLeft: -8 }}>
<TouchableOpacity
onPress={() => router.back()}
activeOpacity={0.6}
style={{ padding: 8 }}
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
>
<Ionicons name="chevron-back" size={24} color={C.accent} />
</TouchableOpacity>
<Text style={{ color: C.text, fontSize: 20, fontWeight: '700' }}>Добавить контакт</Text>
</View>
{/* Search */}
<Text style={{ color: C.muted, fontSize: 11, letterSpacing: 1, textTransform: 'uppercase', marginBottom: 8 }}>
Адрес или @username
</Text>
<View style={{ flexDirection: 'row', gap: 8, marginBottom: 16 }}>
<View style={{
flex: 1, backgroundColor: C.surface, borderRadius: 10,
paddingHorizontal: 12, paddingVertical: 2,
}}>
<TextInput
value={query}
onChangeText={t => { setQuery(t); setError(null); }}
onSubmitEditing={search}
placeholder="@username или 64-символьный hex"
placeholderTextColor={C.muted}
autoCapitalize="none"
autoCorrect={false}
style={{ color: C.text, fontSize: 14, height: 44 }}
/>
</View>
<TouchableOpacity
onPress={search}
disabled={searching || !query.trim()}
style={{
paddingHorizontal: 16, borderRadius: 10,
backgroundColor: searching || !query.trim() ? C.surface2 : C.accent,
alignItems: 'center', justifyContent: 'center',
}}
>
<Text style={{ color: searching || !query.trim() ? C.muted : '#0b1220', fontWeight: '700', fontSize: 14 }}>
{searching ? '…' : 'Найти'}
</Text>
</TouchableOpacity>
</View>
{/* Error */}
{error && (
<View style={{
backgroundColor: 'rgba(255,122,135,0.08)', borderRadius: 10,
paddingHorizontal: 12, paddingVertical: 10, marginBottom: 16,
}}>
<Text style={{ color: C.err, fontSize: 13 }}> {error}</Text>
</View>
)}
{/* Resolved contact card */}
{resolved && (
<View style={{
backgroundColor: C.surface, borderRadius: 12,
padding: 14, marginBottom: 20,
flexDirection: 'row', alignItems: 'center', gap: 12,
}}>
<Avatar name={displayName ?? resolved.address} size="md" />
<View style={{ flex: 1, minWidth: 0 }}>
{resolved.nickname && (
<Text style={{ color: C.text, fontWeight: '600', fontSize: 15 }}>
@{resolved.nickname}
</Text>
)}
<Text style={{ color: C.muted, fontFamily: 'monospace', fontSize: 11 }} numberOfLines={1}>
{resolved.address}
</Text>
{resolved.x25519 && (
<Text style={{ color: C.ok, fontSize: 11, marginTop: 2 }}>
Зашифрованные сообщения поддерживаются
</Text>
)}
</View>
<View style={{
paddingHorizontal: 8, paddingVertical: 3,
backgroundColor: 'rgba(65,201,138,0.12)', borderRadius: 999,
}}>
<Text style={{ color: C.ok, fontSize: 11, fontWeight: '600' }}>Найден</Text>
</View>
</View>
)}
{/* Intro + fee (shown after search succeeds) */}
{resolved && (
<>
{/* Intro */}
<Text style={{ color: C.muted, fontSize: 11, letterSpacing: 1, textTransform: 'uppercase', marginBottom: 8 }}>
Сообщение (необязательно)
</Text>
<View style={{
backgroundColor: C.surface, borderRadius: 10,
paddingHorizontal: 12, paddingVertical: 8, marginBottom: 20,
}}>
<TextInput
value={intro}
onChangeText={setIntro}
placeholder="Привет, хочу добавить тебя в контакты…"
placeholderTextColor={C.muted}
multiline
numberOfLines={3}
maxLength={280}
style={{
color: C.text, fontSize: 14, lineHeight: 20,
minHeight: 72, textAlignVertical: 'top',
}}
/>
<Text style={{ color: C.muted, fontSize: 11, textAlign: 'right', marginTop: 4 }}>
{intro.length}/280
</Text>
</View>
{/* Fee selector */}
<Text style={{ color: C.muted, fontSize: 11, letterSpacing: 1, textTransform: 'uppercase', marginBottom: 8 }}>
Антиспам-сбор (отправляется контакту)
</Text>
<View style={{ flexDirection: 'row', gap: 8, marginBottom: 8 }}>
{FEE_OPTIONS.map(opt => (
<TouchableOpacity
key={opt.value}
onPress={() => setFee(opt.value)}
style={{
flex: 1, borderRadius: 10, paddingVertical: 10, alignItems: 'center',
backgroundColor: fee === opt.value
? 'rgba(125,181,255,0.15)' : C.surface,
borderWidth: fee === opt.value ? 1 : 0,
borderColor: fee === opt.value ? C.accent : 'transparent',
}}
>
<Text style={{
color: fee === opt.value ? C.accent : C.text,
fontWeight: '600', fontSize: 13,
}}>
{opt.label}
</Text>
<Text style={{ color: C.muted, fontSize: 11, marginTop: 2 }}>{opt.note}</Text>
</TouchableOpacity>
))}
</View>
{/* Balance info */}
<View style={{
flexDirection: 'row', alignItems: 'center', gap: 8,
backgroundColor: C.surface, borderRadius: 8,
paddingHorizontal: 12, paddingVertical: 9, marginBottom: 20,
}}>
<Text style={{ color: C.muted, fontSize: 13 }}>Ваш баланс:</Text>
<Text style={{ color: balance >= fee + 1000 ? C.text : C.err, fontSize: 13, fontWeight: '600' }}>
{formatAmount(balance)}
</Text>
<Text style={{ color: C.muted, fontSize: 13, marginLeft: 'auto' as any }}>
Итого: {formatAmount(fee + 1000)}
</Text>
</View>
{/* Send button */}
<TouchableOpacity
onPress={sendRequest}
disabled={sending || balance < fee + 1000}
style={{
paddingVertical: 15, borderRadius: 10, alignItems: 'center',
backgroundColor: (sending || balance < fee + 1000) ? C.surface2 : C.accent,
}}
>
<Text style={{
color: (sending || balance < fee + 1000) ? C.muted : '#0b1220',
fontWeight: '700', fontSize: 15,
}}>
{sending ? 'Отправка…' : 'Отправить запрос'}
</Text>
</TouchableOpacity>
</>
)}
{/* Hint when no search yet */}
{!resolved && !error && (
<View style={{
backgroundColor: C.surface, borderRadius: 12,
padding: 16, marginTop: 8, alignItems: 'center',
}}>
<Text style={{ color: C.muted, fontSize: 14, textAlign: 'center', lineHeight: 21 }}>
Введите @username или вставьте 64-символьный hex-адрес пользователя DChain.
</Text>
</View>
)}
</ScrollView>
);
}