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:
82
client-app/app/(auth)/create.tsx
Normal file
82
client-app/app/(auth)/create.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* Create Account screen.
|
||||
* Generates a new Ed25519 + X25519 keypair and saves it securely.
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { View, Text, ScrollView, Alert } from 'react-native';
|
||||
import { router } from 'expo-router';
|
||||
import { generateKeyFile } from '@/lib/crypto';
|
||||
import { saveKeyFile } from '@/lib/storage';
|
||||
import { useStore } from '@/lib/store';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
|
||||
export default function CreateAccountScreen() {
|
||||
const setKeyFile = useStore(s => s.setKeyFile);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
async function handleCreate() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const kf = generateKeyFile();
|
||||
await saveKeyFile(kf);
|
||||
setKeyFile(kf);
|
||||
router.replace('/(auth)/created');
|
||||
} catch (e: any) {
|
||||
Alert.alert('Error', e.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
className="flex-1 bg-background"
|
||||
contentContainerClassName="px-6 pt-16 pb-10"
|
||||
>
|
||||
{/* Header */}
|
||||
<Button variant="ghost" size="sm" onPress={() => router.back()} className="self-start mb-6 -ml-2">
|
||||
← Back
|
||||
</Button>
|
||||
|
||||
<Text className="text-white text-3xl font-bold mb-2">Create Account</Text>
|
||||
<Text className="text-muted text-base mb-8 leading-6">
|
||||
A new identity will be generated on your device.
|
||||
Your private key never leaves this app.
|
||||
</Text>
|
||||
|
||||
{/* Info cards */}
|
||||
<Card className="mb-4 gap-3">
|
||||
<InfoRow icon="🔑" label="Ed25519 signing key" desc="Your blockchain address and tx signing key" />
|
||||
<InfoRow icon="🔒" label="X25519 encryption key" desc="End-to-end encryption for messages" />
|
||||
<InfoRow icon="📱" label="Stored on device" desc="Keys are encrypted in the device secure store" />
|
||||
</Card>
|
||||
|
||||
<Card className="mb-8 border-primary/30 bg-primary/10">
|
||||
<Text className="text-accent text-sm font-semibold mb-1">⚠ Important</Text>
|
||||
<Text className="text-muted text-sm leading-5">
|
||||
After creation, export and backup your key file.
|
||||
If you lose it there is no recovery — the blockchain has no password reset.
|
||||
</Text>
|
||||
</Card>
|
||||
|
||||
<Button onPress={handleCreate} loading={loading} size="lg">
|
||||
Generate Keys & Create Account
|
||||
</Button>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
function InfoRow({ icon, label, desc }: { icon: string; label: string; desc: string }) {
|
||||
return (
|
||||
<View className="flex-row items-start gap-3">
|
||||
<Text className="text-xl">{icon}</Text>
|
||||
<View className="flex-1">
|
||||
<Text className="text-white text-sm font-semibold">{label}</Text>
|
||||
<Text className="text-muted text-xs mt-0.5">{desc}</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
118
client-app/app/(auth)/created.tsx
Normal file
118
client-app/app/(auth)/created.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
/**
|
||||
* Account Created confirmation screen.
|
||||
* Shows address, pubkeys, and export options.
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { View, Text, ScrollView, Alert, Share } from 'react-native';
|
||||
import { router } from 'expo-router';
|
||||
import * as Clipboard from 'expo-clipboard';
|
||||
import * as FileSystem from 'expo-file-system';
|
||||
import * as Sharing from 'expo-sharing';
|
||||
import { useStore } from '@/lib/store';
|
||||
import { shortAddr } from '@/lib/crypto';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { Separator } from '@/components/ui/Separator';
|
||||
|
||||
export default function AccountCreatedScreen() {
|
||||
const keyFile = useStore(s => s.keyFile);
|
||||
const [copied, setCopied] = useState<string | null>(null);
|
||||
|
||||
if (!keyFile) {
|
||||
router.replace('/');
|
||||
return null;
|
||||
}
|
||||
|
||||
async function copy(value: string, label: string) {
|
||||
await Clipboard.setStringAsync(value);
|
||||
setCopied(label);
|
||||
setTimeout(() => setCopied(null), 2000);
|
||||
}
|
||||
|
||||
async function exportKey() {
|
||||
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: 'Save your DChain key file',
|
||||
});
|
||||
} else {
|
||||
Alert.alert('Export', 'Sharing not available on this device.');
|
||||
}
|
||||
} catch (e: any) {
|
||||
Alert.alert('Export failed', e.message);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
className="flex-1 bg-background"
|
||||
contentContainerClassName="px-6 pt-16 pb-10"
|
||||
>
|
||||
{/* Success header */}
|
||||
<View className="items-center mb-8">
|
||||
<View className="w-20 h-20 rounded-full bg-success/20 items-center justify-center mb-4">
|
||||
<Text className="text-4xl">✓</Text>
|
||||
</View>
|
||||
<Text className="text-white text-2xl font-bold">Account Created!</Text>
|
||||
<Text className="text-muted text-sm mt-2 text-center">
|
||||
Your keys have been generated and stored securely.
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* Address card */}
|
||||
<Card className="mb-4">
|
||||
<Text className="text-muted text-xs uppercase tracking-widest mb-3 font-semibold">
|
||||
Your Address (Ed25519)
|
||||
</Text>
|
||||
<Text className="text-white font-mono text-xs leading-5 mb-3">
|
||||
{keyFile.pub_key}
|
||||
</Text>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onPress={() => copy(keyFile.pub_key, 'address')}
|
||||
>
|
||||
{copied === 'address' ? '✓ Copied' : 'Copy Address'}
|
||||
</Button>
|
||||
</Card>
|
||||
|
||||
{/* X25519 key */}
|
||||
<Card className="mb-4">
|
||||
<Text className="text-muted text-xs uppercase tracking-widest mb-3 font-semibold">
|
||||
Encryption Key (X25519)
|
||||
</Text>
|
||||
<Text className="text-white font-mono text-xs leading-5 mb-3">
|
||||
{keyFile.x25519_pub}
|
||||
</Text>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onPress={() => copy(keyFile.x25519_pub, 'x25519')}
|
||||
>
|
||||
{copied === 'x25519' ? '✓ Copied' : 'Copy Encryption Key'}
|
||||
</Button>
|
||||
</Card>
|
||||
|
||||
{/* Export warning */}
|
||||
<Card className="mb-8 border-yellow-500/30 bg-yellow-500/10">
|
||||
<Text className="text-yellow-400 text-sm font-semibold mb-2">🔐 Backup your key file</Text>
|
||||
<Text className="text-muted text-xs leading-5 mb-3">
|
||||
Export <Text className="text-white font-mono">dchain_key.json</Text> and store it safely.
|
||||
This file contains your private keys — keep it secret.
|
||||
</Text>
|
||||
<Button variant="outline" onPress={exportKey}>
|
||||
Export key.json
|
||||
</Button>
|
||||
</Card>
|
||||
|
||||
<Button size="lg" onPress={() => router.replace('/(app)/chats')}>
|
||||
Open Messenger
|
||||
</Button>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
301
client-app/app/(auth)/import.tsx
Normal file
301
client-app/app/(auth)/import.tsx
Normal file
@@ -0,0 +1,301 @@
|
||||
/**
|
||||
* Import Existing Key screen.
|
||||
* Two methods:
|
||||
* 1. Paste JSON directly into a text field
|
||||
* 2. Pick key.json file via document picker
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
View, Text, ScrollView, TextInput,
|
||||
TouchableOpacity, Alert, Pressable,
|
||||
} from 'react-native';
|
||||
import { router } from 'expo-router';
|
||||
import * as DocumentPicker from 'expo-document-picker';
|
||||
import * as Clipboard from 'expo-clipboard';
|
||||
import { saveKeyFile } from '@/lib/storage';
|
||||
import { useStore } from '@/lib/store';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import type { KeyFile } from '@/lib/types';
|
||||
|
||||
type Tab = 'paste' | 'file';
|
||||
|
||||
const REQUIRED_FIELDS: (keyof KeyFile)[] = ['pub_key', 'priv_key', 'x25519_pub', 'x25519_priv'];
|
||||
|
||||
function validateKeyFile(raw: string): KeyFile {
|
||||
let parsed: any;
|
||||
try {
|
||||
parsed = JSON.parse(raw.trim());
|
||||
} catch {
|
||||
throw new Error('Invalid JSON — check that you copied the full key file contents.');
|
||||
}
|
||||
for (const field of REQUIRED_FIELDS) {
|
||||
if (!parsed[field] || typeof parsed[field] !== 'string') {
|
||||
throw new Error(`Missing or invalid field: "${field}"`);
|
||||
}
|
||||
if (!/^[0-9a-f]+$/i.test(parsed[field])) {
|
||||
throw new Error(`Field "${field}" must be a hex string.`);
|
||||
}
|
||||
}
|
||||
return parsed as KeyFile;
|
||||
}
|
||||
|
||||
export default function ImportKeyScreen() {
|
||||
const setKeyFile = useStore(s => s.setKeyFile);
|
||||
|
||||
const [tab, setTab] = useState<Tab>('paste');
|
||||
const [jsonText, setJsonText] = useState('');
|
||||
const [fileName, setFileName] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// ── Shared: save validated key and navigate ──────────────────────────────
|
||||
async function applyKey(kf: KeyFile) {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
await saveKeyFile(kf);
|
||||
setKeyFile(kf);
|
||||
router.replace('/(app)/chats');
|
||||
} catch (e: any) {
|
||||
setError(e.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Method 1: paste JSON ─────────────────────────────────────────────────
|
||||
async function handlePasteImport() {
|
||||
setError(null);
|
||||
const text = jsonText.trim();
|
||||
if (!text) {
|
||||
// Try reading clipboard if field is empty
|
||||
const clip = await Clipboard.getStringAsync();
|
||||
if (clip) setJsonText(clip);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const kf = validateKeyFile(text);
|
||||
await applyKey(kf);
|
||||
} catch (e: any) {
|
||||
setError(e.message);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Method 2: pick file ──────────────────────────────────────────────────
|
||||
async function pickFile() {
|
||||
setError(null);
|
||||
try {
|
||||
const result = await DocumentPicker.getDocumentAsync({
|
||||
type: ['application/json', 'text/plain', '*/*'],
|
||||
copyToCacheDirectory: true,
|
||||
});
|
||||
if (result.canceled) return;
|
||||
|
||||
const asset = result.assets[0];
|
||||
setFileName(asset.name);
|
||||
|
||||
// Use fetch() — readAsStringAsync is deprecated in newer expo-file-system
|
||||
const response = await fetch(asset.uri);
|
||||
const raw = await response.text();
|
||||
|
||||
const kf = validateKeyFile(raw);
|
||||
await applyKey(kf);
|
||||
} catch (e: any) {
|
||||
setError(e.message);
|
||||
}
|
||||
}
|
||||
|
||||
const tabStyle = (t: Tab) => ({
|
||||
flex: 1 as const,
|
||||
paddingVertical: 10,
|
||||
alignItems: 'center' as const,
|
||||
borderBottomWidth: 2,
|
||||
borderBottomColor: tab === t ? '#2563eb' : 'transparent',
|
||||
});
|
||||
|
||||
const tabTextStyle = (t: Tab) => ({
|
||||
fontSize: 14,
|
||||
fontWeight: '600' as const,
|
||||
color: tab === t ? '#fff' : '#8b949e',
|
||||
});
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
style={{ flex: 1, backgroundColor: '#0d1117' }}
|
||||
contentContainerStyle={{ paddingHorizontal: 20, paddingTop: 60, paddingBottom: 40 }}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
keyboardDismissMode="on-drag"
|
||||
>
|
||||
{/* Back */}
|
||||
<Pressable onPress={() => router.back()} style={{ marginBottom: 24, alignSelf: 'flex-start' }}>
|
||||
<Text style={{ color: '#2563eb', fontSize: 15 }}>← Back</Text>
|
||||
</Pressable>
|
||||
|
||||
<Text style={{ color: '#fff', fontSize: 28, fontWeight: '700', marginBottom: 6 }}>
|
||||
Import Key
|
||||
</Text>
|
||||
<Text style={{ color: '#8b949e', fontSize: 15, lineHeight: 22, marginBottom: 24 }}>
|
||||
Restore your account from an existing{' '}
|
||||
<Text style={{ color: '#fff', fontFamily: 'monospace' }}>key.json</Text>.
|
||||
</Text>
|
||||
|
||||
{/* Tabs */}
|
||||
<View style={{
|
||||
flexDirection: 'row',
|
||||
borderBottomWidth: 1, borderBottomColor: '#30363d',
|
||||
marginBottom: 24,
|
||||
}}>
|
||||
<TouchableOpacity style={tabStyle('paste')} onPress={() => setTab('paste')}>
|
||||
<Text style={tabTextStyle('paste')}>📋 Paste JSON</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity style={tabStyle('file')} onPress={() => setTab('file')}>
|
||||
<Text style={tabTextStyle('file')}>📁 Open File</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* ── Paste tab ── */}
|
||||
{tab === 'paste' && (
|
||||
<View style={{ gap: 12 }}>
|
||||
<Text style={{ color: '#8b949e', fontSize: 12, fontWeight: '600',
|
||||
textTransform: 'uppercase', letterSpacing: 1 }}>
|
||||
Key JSON
|
||||
</Text>
|
||||
|
||||
<View style={{
|
||||
backgroundColor: '#161b22',
|
||||
borderWidth: 1,
|
||||
borderColor: error ? '#f85149' : jsonText ? '#2563eb' : '#30363d',
|
||||
borderRadius: 12,
|
||||
padding: 12,
|
||||
}}>
|
||||
<TextInput
|
||||
value={jsonText}
|
||||
onChangeText={t => { setJsonText(t); setError(null); }}
|
||||
placeholder={'{\n "pub_key": "...",\n "priv_key": "...",\n "x25519_pub": "...",\n "x25519_priv": "..."\n}'}
|
||||
placeholderTextColor="#8b949e"
|
||||
multiline
|
||||
numberOfLines={8}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
style={{
|
||||
color: '#fff',
|
||||
fontFamily: 'monospace',
|
||||
fontSize: 12,
|
||||
lineHeight: 18,
|
||||
minHeight: 160,
|
||||
textAlignVertical: 'top',
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Paste from clipboard shortcut */}
|
||||
{!jsonText && (
|
||||
<TouchableOpacity
|
||||
onPress={async () => {
|
||||
const clip = await Clipboard.getStringAsync();
|
||||
if (clip) { setJsonText(clip); setError(null); }
|
||||
else Alert.alert('Clipboard empty', 'Copy your key JSON first.');
|
||||
}}
|
||||
style={{
|
||||
flexDirection: 'row', alignItems: 'center', gap: 8,
|
||||
padding: 12, backgroundColor: '#161b22',
|
||||
borderWidth: 1, borderColor: '#30363d', borderRadius: 12,
|
||||
}}
|
||||
>
|
||||
<Text style={{ fontSize: 18 }}>📋</Text>
|
||||
<Text style={{ color: '#8b949e', fontSize: 14 }}>Paste from clipboard</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<View style={{
|
||||
backgroundColor: 'rgba(248,81,73,0.1)',
|
||||
borderWidth: 1, borderColor: 'rgba(248,81,73,0.3)',
|
||||
borderRadius: 10, padding: 12,
|
||||
}}>
|
||||
<Text style={{ color: '#f85149', fontSize: 13, lineHeight: 18 }}>⚠ {error}</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<Button
|
||||
size="lg"
|
||||
loading={loading}
|
||||
disabled={!jsonText.trim()}
|
||||
onPress={handlePasteImport}
|
||||
>
|
||||
Import Key
|
||||
</Button>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* ── File tab ── */}
|
||||
{tab === 'file' && (
|
||||
<View style={{ gap: 12 }}>
|
||||
<TouchableOpacity
|
||||
onPress={pickFile}
|
||||
style={{
|
||||
backgroundColor: '#161b22',
|
||||
borderWidth: 2, borderColor: '#30363d',
|
||||
borderRadius: 16, borderStyle: 'dashed',
|
||||
padding: 32, alignItems: 'center', gap: 12,
|
||||
}}
|
||||
>
|
||||
<Text style={{ fontSize: 40 }}>📂</Text>
|
||||
<Text style={{ color: '#fff', fontSize: 16, fontWeight: '600' }}>
|
||||
{fileName ?? 'Choose key.json'}
|
||||
</Text>
|
||||
<Text style={{ color: '#8b949e', fontSize: 13, textAlign: 'center' }}>
|
||||
Tap to browse files
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
{fileName && (
|
||||
<View style={{
|
||||
flexDirection: 'row', alignItems: 'center', gap: 10,
|
||||
backgroundColor: 'rgba(63,185,80,0.1)',
|
||||
borderWidth: 1, borderColor: 'rgba(63,185,80,0.3)',
|
||||
borderRadius: 10, padding: 12,
|
||||
}}>
|
||||
<Text style={{ fontSize: 18 }}>📄</Text>
|
||||
<Text style={{ color: '#3fb950', fontSize: 13, flex: 1 }} numberOfLines={1}>
|
||||
{fileName}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<View style={{
|
||||
backgroundColor: 'rgba(248,81,73,0.1)',
|
||||
borderWidth: 1, borderColor: 'rgba(248,81,73,0.3)',
|
||||
borderRadius: 10, padding: 12,
|
||||
}}>
|
||||
<Text style={{ color: '#f85149', fontSize: 13, lineHeight: 18 }}>⚠ {error}</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{loading && (
|
||||
<Text style={{ color: '#8b949e', textAlign: 'center', fontSize: 14 }}>
|
||||
Validating key…
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Format hint */}
|
||||
<View style={{
|
||||
marginTop: 28, padding: 14,
|
||||
backgroundColor: '#161b22',
|
||||
borderWidth: 1, borderColor: '#30363d', borderRadius: 12,
|
||||
}}>
|
||||
<Text style={{ color: '#8b949e', fontSize: 12, fontWeight: '600',
|
||||
marginBottom: 8, textTransform: 'uppercase', letterSpacing: 1 }}>
|
||||
Expected format
|
||||
</Text>
|
||||
<Text style={{ color: '#8b949e', fontFamily: 'monospace', fontSize: 11, lineHeight: 17 }}>
|
||||
{`{\n "pub_key": "<64 hex chars>",\n "priv_key": "<128 hex chars>",\n "x25519_pub": "<64 hex chars>",\n "x25519_priv": "<64 hex chars>"\n}`}
|
||||
</Text>
|
||||
</View>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user