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,236 @@
/**
* Contact requests screen — DChain explorer design style.
*/
import React, { useState } from 'react';
import { View, Text, FlatList, Alert, TouchableOpacity } 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 { buildAcceptContactTx, submitTx, getIdentity } from '@/lib/api';
import { saveContact } from '@/lib/storage';
import { shortAddr } from '@/lib/crypto';
import { relativeTime } from '@/lib/utils';
import { Avatar } from '@/components/ui/Avatar';
import type { ContactRequest } from '@/lib/types';
const C = {
bg: '#0b1220',
surface: '#111a2b',
surface2:'#162035',
line: '#1c2840',
text: '#e6edf9',
muted: '#98a7c2',
accent: '#7db5ff',
ok: '#41c98a',
warn: '#f0b35a',
err: '#ff7a87',
} as const;
export default function RequestsScreen() {
const keyFile = useStore(s => s.keyFile);
const requests = useStore(s => s.requests);
const setRequests = useStore(s => s.setRequests);
const upsertContact = useStore(s => s.upsertContact);
const insets = useSafeAreaInsets();
const [accepting, setAccepting] = useState<string | null>(null);
async function accept(req: ContactRequest) {
if (!keyFile) return;
setAccepting(req.txHash);
try {
// Fetch requester's identity to get their x25519 key for messaging
const identity = await getIdentity(req.from);
const x25519Pub = identity?.x25519_pub ?? '';
const tx = buildAcceptContactTx({
from: keyFile.pub_key,
to: req.from,
privKey: keyFile.priv_key,
});
await submitTx(tx);
// Save contact with x25519 key (empty if they haven't registered one)
const contact = {
address: req.from,
x25519Pub,
username: req.username,
addedAt: Date.now(),
};
upsertContact(contact);
await saveContact(contact);
setRequests(requests.filter(r => r.txHash !== req.txHash));
Alert.alert('Принято', `${req.username ? '@' + req.username : shortAddr(req.from)} добавлен в контакты.`);
} catch (e: any) {
Alert.alert('Ошибка', e.message);
} finally {
setAccepting(null);
}
}
function decline(req: ContactRequest) {
Alert.alert(
'Отклонить запрос',
`Отклонить запрос от ${req.username ? '@' + req.username : shortAddr(req.from)}?`,
[
{ text: 'Отмена', style: 'cancel' },
{
text: 'Отклонить',
style: 'destructive',
onPress: () => setRequests(requests.filter(r => r.txHash !== req.txHash)),
},
],
);
}
return (
<View style={{ flex: 1, backgroundColor: C.bg }}>
{/* Header */}
<View style={{
flexDirection: 'row', alignItems: 'center', gap: 10,
paddingHorizontal: 12,
paddingTop: insets.top + 8, paddingBottom: 12,
borderBottomWidth: 1, borderBottomColor: C.line,
}}>
<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: 18, fontWeight: '700', flex: 1 }}>
Запросы контактов
</Text>
{requests.length > 0 && (
<View style={{
backgroundColor: C.accent, borderRadius: 999,
paddingHorizontal: 10, paddingVertical: 3,
minWidth: 24, alignItems: 'center',
}}>
<Text style={{ color: C.bg, fontSize: 12, fontWeight: '700' }}>
{requests.length}
</Text>
</View>
)}
</View>
{requests.length === 0 ? (
<View style={{ flex: 1, alignItems: 'center', justifyContent: 'center', paddingHorizontal: 32 }}>
<View style={{
width: 80, height: 80, borderRadius: 40,
backgroundColor: C.surface,
alignItems: 'center', justifyContent: 'center',
marginBottom: 16,
}}>
<Ionicons name="mail-outline" size={36} color={C.muted} />
</View>
<Text style={{ color: C.text, fontSize: 17, fontWeight: '600', marginBottom: 6 }}>
Нет входящих запросов
</Text>
<Text style={{ color: C.muted, fontSize: 13, textAlign: 'center', lineHeight: 20 }}>
Когда кто-то пришлёт вам запрос в контакты, он появится здесь.
</Text>
</View>
) : (
<FlatList
data={requests}
keyExtractor={r => r.txHash}
contentContainerStyle={{ padding: 14, paddingBottom: 24, gap: 12 }}
renderItem={({ item: req }) => (
<RequestCard
req={req}
isAccepting={accepting === req.txHash}
onAccept={() => accept(req)}
onDecline={() => decline(req)}
/>
)}
/>
)}
</View>
);
}
function RequestCard({
req, isAccepting, onAccept, onDecline,
}: {
req: ContactRequest;
isAccepting: boolean;
onAccept: () => void;
onDecline: () => void;
}) {
const displayName = req.username ? `@${req.username}` : shortAddr(req.from);
return (
<View style={{ backgroundColor: C.surface, borderRadius: 12, padding: 14 }}>
{/* Sender info */}
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 12, marginBottom: 12 }}>
<Avatar name={displayName} size="md" />
<View style={{ flex: 1, minWidth: 0 }}>
<Text style={{ color: C.text, fontWeight: '600', fontSize: 15 }} numberOfLines={1}>
{displayName}
</Text>
<Text style={{ color: C.muted, fontFamily: 'monospace', fontSize: 11 }} numberOfLines={1}>
{req.from}
</Text>
</View>
<Text style={{ color: C.muted, fontSize: 12 }}>{relativeTime(req.timestamp)}</Text>
</View>
{/* Intro message */}
{!!req.intro && (
<View style={{
backgroundColor: C.surface2, borderRadius: 8,
paddingHorizontal: 12, paddingVertical: 10, marginBottom: 12,
}}>
<Text style={{ color: C.muted, fontSize: 11, marginBottom: 4 }}>Приветствие</Text>
<Text style={{ color: C.text, fontSize: 13, lineHeight: 19 }}>{req.intro}</Text>
</View>
)}
{/* Divider */}
<View style={{ height: 1, backgroundColor: C.line, marginBottom: 12 }} />
{/* Actions */}
<View style={{ flexDirection: 'row', gap: 8 }}>
<TouchableOpacity
onPress={onAccept}
disabled={isAccepting}
activeOpacity={0.7}
style={{
flex: 1, paddingVertical: 11, borderRadius: 9,
flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 6,
backgroundColor: isAccepting ? C.surface2 : C.ok,
}}
>
<Ionicons
name={isAccepting ? 'hourglass-outline' : 'checkmark-outline'}
size={16}
color={isAccepting ? C.muted : C.bg}
/>
<Text style={{ color: isAccepting ? C.muted : C.bg, fontWeight: '700', fontSize: 14 }}>
{isAccepting ? 'Принятие…' : 'Принять'}
</Text>
</TouchableOpacity>
<TouchableOpacity
onPress={onDecline}
disabled={isAccepting}
activeOpacity={0.7}
style={{
flex: 1, paddingVertical: 11, borderRadius: 9,
flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 6,
backgroundColor: 'rgba(255,122,135,0.1)',
borderWidth: 1, borderColor: 'rgba(255,122,135,0.18)',
}}
>
<Ionicons name="close-outline" size={16} color={C.err} />
<Text style={{ color: C.err, fontWeight: '700', fontSize: 14 }}>Отклонить</Text>
</TouchableOpacity>
</View>
</View>
);
}