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
302 lines
10 KiB
TypeScript
302 lines
10 KiB
TypeScript
/**
|
|
* 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>
|
|
);
|
|
}
|