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

0
client-app/.gitignore vendored Normal file
View File

93
client-app/README.md Normal file
View File

@@ -0,0 +1,93 @@
# DChain Messenger — React Native Client
E2E-encrypted mobile/desktop messenger built on the DChain blockchain stack.
**Stack:** React Native · Expo · NativeWind (Tailwind) · TweetNaCl · Zustand
## Quick Start
```bash
cd client-app
npm install
npx expo start # opens Expo Dev Tools
# Press 'i' for iOS simulator, 'a' for Android, 'w' for web
```
## Requirements
- Node.js 18+
- [Expo Go](https://expo.dev/client) on your phone (for Expo tunnel), or iOS/Android emulator
- A running DChain node (see root README for `docker compose up --build -d`)
## Project Structure
```
client-app/
├── app/
│ ├── _layout.tsx # Root layout — loads keys, sets up nav
│ ├── index.tsx # Welcome / onboarding
│ ├── (auth)/
│ │ ├── create.tsx # Generate new Ed25519 + X25519 keys
│ │ ├── created.tsx # Key created — export reminder
│ │ └── import.tsx # Import existing key.json
│ └── (app)/
│ ├── _layout.tsx # Tab bar — Chats · Wallet · Settings
│ ├── chats/
│ │ ├── index.tsx # Chat list with contacts
│ │ └── [id].tsx # Individual chat with E2E encryption
│ ├── requests.tsx # Incoming contact requests
│ ├── new-contact.tsx # Add contact by @username or address
│ ├── wallet.tsx # Balance + TX history + send
│ └── settings.tsx # Node URL, key export, profile
├── components/ui/ # shadcn-style components (Button, Card, Input…)
├── hooks/
│ ├── useMessages.ts # Poll relay inbox, decrypt messages
│ ├── useBalance.ts # Poll token balance
│ └── useContacts.ts # Load contacts + poll contact requests
└── lib/
├── api.ts # REST client for all DChain endpoints
├── crypto.ts # NaCl box encrypt/decrypt, Ed25519 sign
├── storage.ts # SecureStore (keys) + AsyncStorage (data)
├── store.ts # Zustand global state
├── types.ts # TypeScript interfaces
└── utils.ts # cn(), formatAmount(), relativeTime()
```
## Cryptography
| Operation | Algorithm | Library |
|-----------|-----------|---------|
| Transaction signing | Ed25519 | TweetNaCl `sign` |
| Key exchange | X25519 (Curve25519) | TweetNaCl `box` |
| Message encryption | NaCl box (XSalsa20-Poly1305) | TweetNaCl `box` |
| Key storage | Device secure enclave | expo-secure-store |
Messages are encrypted as:
```
Envelope {
sender_pub: <X25519 hex> // sender's public key
recipient_pub: <X25519 hex> // recipient's public key
nonce: <24-byte hex> // random per message
ciphertext: <hex> // NaCl box(plaintext, nonce, sender_priv, recipient_pub)
}
```
## Connect to your node
1. Start the DChain node: `docker compose up --build -d`
2. Open the app → Settings → Node URL → `http://YOUR_IP:8081`
3. If using Expo Go on physical device: your PC and phone must be on the same network, or use `npx expo start --tunnel`
## Key File Format
The `key.json` exported/imported by the app:
```json
{
"pub_key": "26018d40...", // Ed25519 public key (64 hex chars)
"priv_key": "...", // Ed25519 private key (128 hex chars)
"x25519_pub": "...", // X25519 public key (64 hex chars)
"x25519_priv": "..." // X25519 private key (64 hex chars)
}
```
This is the same format as the Go node's `--key` flag.

36
client-app/app.json Normal file
View File

@@ -0,0 +1,36 @@
{
"expo": {
"name": "DChain Messenger",
"slug": "dchain-messenger",
"version": "1.0.0",
"orientation": "portrait",
"userInterfaceStyle": "dark",
"backgroundColor": "#0d1117",
"ios": {
"supportsTablet": false,
"bundleIdentifier": "com.dchain.messenger"
},
"android": {
"package": "com.dchain.messenger",
"softwareKeyboardLayoutMode": "pan"
},
"web": {
"bundler": "metro",
"output": "static"
},
"plugins": [
"expo-router",
"expo-secure-store",
[
"expo-camera",
{
"cameraPermission": "Allow DChain to scan QR codes for node configuration."
}
]
],
"experiments": {
"typedRoutes": false
},
"scheme": "dchain"
}
}

View File

@@ -0,0 +1,127 @@
/**
* Main app tab layout.
* Redirects to welcome if no key found.
*/
import React, { useEffect } from 'react';
import { Tabs, router } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { useStore } from '@/lib/store';
import { useBalance } from '@/hooks/useBalance';
import { useContacts } from '@/hooks/useContacts';
import { useWellKnownContracts } from '@/hooks/useWellKnownContracts';
import { getWSClient } from '@/lib/ws';
const C_ACCENT = '#7db5ff';
const C_MUTED = '#98a7c2';
const C_BG = '#111a2b';
const C_BORDER = '#1c2840';
export default function AppLayout() {
const keyFile = useStore(s => s.keyFile);
const requests = useStore(s => s.requests);
const insets = useSafeAreaInsets();
useBalance();
useContacts();
useWellKnownContracts(); // auto-discover canonical system contracts from node
// Arm the WS client with this user's Ed25519 keypair. The client signs the
// server's auth nonce on every (re)connect so scoped subscriptions
// (addr:<my_pub>, inbox:<my_x25519>) are accepted. Without this the
// server would still accept global topic subs but reject scoped ones.
useEffect(() => {
const ws = getWSClient();
if (keyFile) {
ws.setAuthCreds({ pubKey: keyFile.pub_key, privKey: keyFile.priv_key });
} else {
ws.setAuthCreds(null);
}
}, [keyFile]);
useEffect(() => {
if (keyFile === null) {
const t = setTimeout(() => {
if (!useStore.getState().keyFile) router.replace('/');
}, 300);
return () => clearTimeout(t);
}
}, [keyFile]);
// Tab bar layout math:
// icon (22) + gap (4) + label (~13) = ~39px of content
// We add a 12px visual margin above, and pad the bottom by the larger of
// the platform safe-area inset or 10px so the bar never sits flush on the
// home indicator.
const BAR_CONTENT_HEIGHT = 52;
const bottomPad = Math.max(insets.bottom, 10);
return (
<Tabs
screenOptions={{
headerShown: false,
tabBarActiveTintColor: C_ACCENT,
tabBarInactiveTintColor: C_MUTED,
tabBarLabelStyle: {
fontSize: 10,
fontWeight: '500',
marginTop: 2,
},
tabBarStyle: {
backgroundColor: C_BG,
borderTopColor: C_BORDER,
borderTopWidth: 1,
height: BAR_CONTENT_HEIGHT + bottomPad,
paddingTop: 8,
paddingBottom: bottomPad,
},
}}
>
<Tabs.Screen
name="chats"
options={{
tabBarLabel: 'Чаты',
tabBarIcon: ({ color, focused }) => (
<Ionicons
name={focused ? 'chatbubbles' : 'chatbubbles-outline'}
size={22}
color={color}
/>
),
tabBarBadge: requests.length > 0 ? requests.length : undefined,
tabBarBadgeStyle: { backgroundColor: C_ACCENT, fontSize: 10 },
}}
/>
<Tabs.Screen
name="wallet"
options={{
tabBarLabel: 'Кошелёк',
tabBarIcon: ({ color, focused }) => (
<Ionicons
name={focused ? 'wallet' : 'wallet-outline'}
size={22}
color={color}
/>
),
}}
/>
<Tabs.Screen
name="settings"
options={{
tabBarLabel: 'Настройки',
tabBarIcon: ({ color, focused }) => (
<Ionicons
name={focused ? 'settings' : 'settings-outline'}
size={22}
color={color}
/>
),
}}
/>
{/* Non-tab screens — hidden from tab bar */}
<Tabs.Screen name="requests" options={{ href: null }} />
<Tabs.Screen name="new-contact" options={{ href: null }} />
</Tabs>
);
}

View File

@@ -0,0 +1,413 @@
/**
* Chat view — DChain messenger.
* Safe-area aware header/input, smooth scroll, proper E2E indicators,
* responsive send button with press feedback.
*/
import React, { useState, useRef, useEffect, useCallback } from 'react';
import {
View, Text, FlatList, TextInput, TouchableOpacity, Pressable,
KeyboardAvoidingView, Platform, ActivityIndicator, Alert,
} from 'react-native';
import { router, useLocalSearchParams } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { useStore } from '@/lib/store';
import { useMessages } from '@/hooks/useMessages';
import { encryptMessage } from '@/lib/crypto';
import { sendEnvelope } from '@/lib/api';
import { getWSClient } from '@/lib/ws';
import { appendMessage, loadMessages } from '@/lib/storage';
import { formatTime, randomId } from '@/lib/utils';
import { Avatar } from '@/components/ui/Avatar';
import type { Message } from '@/lib/types';
// ─── 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;
// ─── Helpers ──────────────────────────────────────────────────────────────────
function shortAddr(a: string, n = 5): string {
if (!a) return '—';
return a.length <= n * 2 + 1 ? a : `${a.slice(0, n)}${a.slice(-n)}`;
}
/** Group messages by calendar day for day-separator labels. */
function dateBucket(ts: number): string {
const d = new Date(ts * 1000);
const now = new Date();
const yday = new Date(); yday.setDate(now.getDate() - 1);
const same = (a: Date, b: Date) =>
a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate();
if (same(d, now)) return 'Сегодня';
if (same(d, yday)) return 'Вчера';
return d.toLocaleDateString('ru', { day: 'numeric', month: 'long' });
}
// A row in the FlatList: either a message or a date separator we inject.
type Row =
| { kind: 'msg'; msg: Message }
| { kind: 'sep'; id: string; label: string };
function buildRows(msgs: Message[]): Row[] {
const rows: Row[] = [];
let lastBucket = '';
for (const m of msgs) {
const b = dateBucket(m.timestamp);
if (b !== lastBucket) {
rows.push({ kind: 'sep', id: `sep_${b}_${m.id}`, label: b });
lastBucket = b;
}
rows.push({ kind: 'msg', msg: m });
}
return rows;
}
// ─── Screen ───────────────────────────────────────────────────────────────────
export default function ChatScreen() {
const { id: contactAddress } = useLocalSearchParams<{ id: string }>();
const keyFile = useStore(s => s.keyFile);
const contacts = useStore(s => s.contacts);
const messages = useStore(s => s.messages);
const setMsgs = useStore(s => s.setMessages);
const appendMsg = useStore(s => s.appendMessage);
const insets = useSafeAreaInsets();
const contact = contacts.find(c => c.address === contactAddress);
const chatMsgs = messages[contactAddress ?? ''] ?? [];
const listRef = useRef<FlatList>(null);
const [text, setText] = useState('');
const [sending, setSending] = useState(false);
const [peerTyping, setPeerTyping] = useState(false);
// Poll relay inbox for messages from this contact
useMessages(contact?.x25519Pub ?? '');
// Subscribe to typing indicators sent to us. Clears after 3 seconds of
// silence so the "typing…" bubble disappears naturally when the peer
// stops. sendTyping on our side happens per-keystroke (throttled below).
useEffect(() => {
if (!keyFile?.x25519_pub) return;
const ws = getWSClient();
let clearTimer: ReturnType<typeof setTimeout> | null = null;
const off = ws.subscribe('typing:' + keyFile.x25519_pub, (frame) => {
if (frame.event !== 'typing') return;
const d = frame.data as { from?: string } | undefined;
// Only show typing for the contact currently open in this view.
if (!contact?.x25519Pub || d?.from !== contact.x25519Pub) return;
setPeerTyping(true);
if (clearTimer) clearTimeout(clearTimer);
clearTimer = setTimeout(() => setPeerTyping(false), 3_000);
});
return () => {
off();
if (clearTimer) clearTimeout(clearTimer);
};
}, [keyFile?.x25519_pub, contact?.x25519Pub]);
// Throttled sendTyping: fire on every keystroke but no more than every 2s.
const lastSentTyping = useRef(0);
const handleTextChange = useCallback((t: string) => {
setText(t);
if (!contact?.x25519Pub || !t.trim()) return;
const now = Date.now();
if (now - lastSentTyping.current < 2_000) return;
lastSentTyping.current = now;
getWSClient().sendTyping(contact.x25519Pub);
}, [contact?.x25519Pub]);
// Load cached messages on mount
useEffect(() => {
if (contactAddress) {
loadMessages(contactAddress).then(cached => {
setMsgs(contactAddress, cached as Message[]);
});
}
}, [contactAddress]);
const displayName = contact?.username
? `@${contact.username}`
: contact?.alias ?? shortAddr(contactAddress ?? '', 6);
const canSend = !!text.trim() && !sending && !!contact?.x25519Pub;
const send = useCallback(async () => {
if (!text.trim() || !keyFile || !contact) return;
if (!contact.x25519Pub) {
Alert.alert(
'Ключ ещё не опубликован',
'Контакт пока не опубликовал ключ шифрования. Попробуйте позже.',
);
return;
}
setSending(true);
try {
const { nonce, ciphertext } = encryptMessage(
text.trim(),
keyFile.x25519_priv,
contact.x25519Pub,
);
await sendEnvelope({
sender_pub: keyFile.x25519_pub,
recipient_pub: contact.x25519Pub,
nonce,
ciphertext,
});
const msg: Message = {
id: randomId(),
from: keyFile.x25519_pub,
text: text.trim(),
timestamp: Math.floor(Date.now() / 1000),
mine: true,
};
appendMsg(contact.address, msg);
await appendMessage(contact.address, msg);
setText('');
setTimeout(() => listRef.current?.scrollToEnd({ animated: true }), 50);
} catch (e: any) {
Alert.alert('Ошибка отправки', e.message);
} finally {
setSending(false);
}
}, [text, keyFile, contact]);
const rows = buildRows(chatMsgs);
const renderRow = ({ item }: { item: Row }) => {
if (item.kind === 'sep') {
return (
<View style={{ alignItems: 'center', marginVertical: 12 }}>
<View style={{
backgroundColor: C.surface2, borderRadius: 999,
paddingHorizontal: 12, paddingVertical: 4,
}}>
<Text style={{ color: C.muted, fontSize: 11, fontWeight: '500' }}>{item.label}</Text>
</View>
</View>
);
}
const m = item.msg;
return (
<View style={{
flexDirection: 'row',
justifyContent: m.mine ? 'flex-end' : 'flex-start',
paddingHorizontal: 12,
marginBottom: 6,
}}>
{!m.mine && (
<View style={{ marginRight: 8, marginBottom: 4, flexShrink: 0 }}>
<Avatar name={displayName} size="sm" />
</View>
)}
<View style={{
maxWidth: '78%',
backgroundColor: m.mine ? C.accent : C.surface,
borderRadius: 18,
borderTopRightRadius: m.mine ? 6 : 18,
borderTopLeftRadius: m.mine ? 18 : 6,
paddingHorizontal: 14,
paddingTop: 9, paddingBottom: 7,
borderWidth: m.mine ? 0 : 1,
borderColor: C.line,
}}>
<Text style={{
color: m.mine ? C.bg : C.text,
fontSize: 15, lineHeight: 21,
}}>
{m.text}
</Text>
<View style={{
flexDirection: 'row', alignItems: 'center', justifyContent: 'flex-end',
marginTop: 3, gap: 4,
}}>
<Text style={{
color: m.mine ? 'rgba(11,18,32,0.65)' : C.muted,
fontSize: 10,
}}>
{formatTime(m.timestamp)}
</Text>
{m.mine && (
<Ionicons
name="checkmark-done"
size={12}
color="rgba(11,18,32,0.65)"
/>
)}
</View>
</View>
</View>
);
};
return (
<KeyboardAvoidingView
style={{ flex: 1, backgroundColor: C.bg }}
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
keyboardVerticalOffset={0}
>
{/* ── Header ── */}
<View style={{
flexDirection: 'row', alignItems: 'center',
paddingHorizontal: 10,
paddingTop: insets.top + 8,
paddingBottom: 10,
borderBottomWidth: 1, borderBottomColor: C.line,
backgroundColor: C.surface,
}}>
<TouchableOpacity
onPress={() => router.back()}
activeOpacity={0.6}
style={{ padding: 8, marginRight: 4 }}
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
>
<Ionicons name="chevron-back" size={24} color={C.accent} />
</TouchableOpacity>
<View style={{ marginRight: 10, position: 'relative' }}>
<Avatar name={displayName} size="sm" />
{contact?.x25519Pub && (
<View style={{
position: 'absolute', right: -2, bottom: -2,
width: 12, height: 12, borderRadius: 6,
backgroundColor: C.ok,
borderWidth: 2, borderColor: C.surface,
}} />
)}
</View>
<View style={{ flex: 1, minWidth: 0 }}>
<Text style={{ color: C.text, fontWeight: '600', fontSize: 15 }} numberOfLines={1}>
{displayName}
</Text>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 4, marginTop: 1 }}>
{peerTyping ? (
<>
<Ionicons name="ellipsis-horizontal" size={12} color={C.accent} />
<Text style={{ color: C.accent, fontSize: 11, fontWeight: '500' }}>печатает</Text>
</>
) : contact?.x25519Pub ? (
<>
<Ionicons name="lock-closed" size={10} color={C.ok} />
<Text style={{ color: C.ok, fontSize: 11, fontWeight: '500' }}>E2E шифрование</Text>
</>
) : (
<>
<Ionicons name="time-outline" size={10} color={C.warn} />
<Text style={{ color: C.warn, fontSize: 11 }}>Ожидание ключа шифрования</Text>
</>
)}
</View>
</View>
<TouchableOpacity
activeOpacity={0.6}
style={{ padding: 8 }}
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
>
<Ionicons name="ellipsis-vertical" size={18} color={C.muted} />
</TouchableOpacity>
</View>
{/* ── Messages ── */}
<FlatList
ref={listRef}
data={rows}
keyExtractor={r => r.kind === 'sep' ? r.id : r.msg.id}
renderItem={renderRow}
contentContainerStyle={{ paddingTop: 14, paddingBottom: 10, flexGrow: 1 }}
onContentSizeChange={() => listRef.current?.scrollToEnd({ animated: false })}
ListEmptyComponent={() => (
<View style={{ flex: 1, alignItems: 'center', justifyContent: 'center', paddingHorizontal: 32, gap: 14 }}>
<View style={{
width: 76, height: 76, borderRadius: 38,
backgroundColor: 'rgba(125,181,255,0.08)',
alignItems: 'center', justifyContent: 'center',
}}>
<Ionicons name="lock-closed-outline" size={34} color={C.accent} />
</View>
<Text style={{ color: C.text, fontSize: 15, fontWeight: '600', textAlign: 'center' }}>
Начните разговор
</Text>
<Text style={{ color: C.muted, fontSize: 13, textAlign: 'center', lineHeight: 20 }}>
Сообщения зашифрованы end-to-end.{'\n'}Только вы и {displayName} их прочитаете.
</Text>
</View>
)}
showsVerticalScrollIndicator={false}
/>
{/* ── Input bar ── */}
<View style={{
flexDirection: 'row', alignItems: 'flex-end', gap: 8,
paddingHorizontal: 10,
paddingTop: 8,
paddingBottom: Math.max(insets.bottom, 8),
borderTopWidth: 1, borderTopColor: C.line,
backgroundColor: C.surface,
}}>
<View style={{
flex: 1,
backgroundColor: C.surface2,
borderRadius: 22,
borderWidth: 1, borderColor: C.line,
paddingHorizontal: 14, paddingVertical: 8,
minHeight: 42, maxHeight: 140,
justifyContent: 'center',
}}>
<TextInput
value={text}
onChangeText={handleTextChange}
placeholder="Сообщение…"
placeholderTextColor={C.muted}
multiline
maxLength={2000}
style={{
color: C.text, fontSize: 15, lineHeight: 21,
// iOS needs explicit padding:0 to avoid extra vertical space
paddingTop: 0, paddingBottom: 0,
}}
/>
</View>
<Pressable
onPress={send}
disabled={!canSend}
style={({ pressed }) => ({
width: 42, height: 42, borderRadius: 21,
backgroundColor: canSend ? C.accent : C.surface2,
alignItems: 'center', justifyContent: 'center',
flexShrink: 0,
opacity: pressed && canSend ? 0.7 : 1,
transform: [{ scale: pressed && canSend ? 0.95 : 1 }],
})}
>
{sending
? <ActivityIndicator color={C.bg} size="small" />
: <Ionicons
name="send"
size={18}
color={canSend ? C.bg : C.muted}
style={{ marginLeft: 2 }} // visual centre of the paper-plane glyph
/>
}
</Pressable>
</View>
</KeyboardAvoidingView>
);
}

View File

@@ -0,0 +1,7 @@
import { Stack } from 'expo-router';
export default function ChatsLayout() {
return (
<Stack screenOptions={{ headerShown: false }} />
);
}

View File

@@ -0,0 +1,337 @@
/**
* Chat list — DChain messenger.
* Safe-area aware, Ionicons, polished empty states, responsive press feedback.
*/
import React, { useCallback, useMemo, useEffect, useState } from 'react';
import { View, Text, FlatList, Pressable, 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 { getWSClient } from '@/lib/ws';
import { Avatar } from '@/components/ui/Avatar';
import { formatTime, formatAmount } from '@/lib/utils';
import type { Contact } from '@/lib/types';
const MIN_FEE = 5000;
// ─── 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;
// ─── Helpers ──────────────────────────────────────────────────────────────────
/** Truncate a message preview without breaking words too awkwardly. */
function previewText(s: string, max = 60): string {
if (s.length <= max) return s;
return s.slice(0, max).trimEnd() + '…';
}
/** Short address helper matching the rest of the app. */
function shortAddr(a: string, n = 6): string {
if (!a) return '—';
return a.length <= n * 2 + 1 ? a : `${a.slice(0, n)}${a.slice(-n)}`;
}
export default function ChatsScreen() {
const contacts = useStore(s => s.contacts);
const messages = useStore(s => s.messages);
const requests = useStore(s => s.requests);
const balance = useStore(s => s.balance);
const keyFile = useStore(s => s.keyFile);
const insets = useSafeAreaInsets();
// Real-time transport indicator (green dot when WS is live, yellow when
// using HTTP polling fallback).
const [wsLive, setWsLive] = useState(false);
useEffect(() => {
const ws = getWSClient();
setWsLive(ws.isConnected());
return ws.onConnectionChange(ok => setWsLive(ok));
}, []);
const hasBalance = balance >= MIN_FEE;
const displayName = (c: Contact) =>
c.username ? `@${c.username}` : c.alias ?? shortAddr(c.address, 5);
const lastMsg = (c: Contact) => {
const msgs = messages[c.address];
return msgs?.length ? msgs[msgs.length - 1] : null;
};
// Sort contacts: most recent activity first.
const sortedContacts = useMemo(() => {
const withTime = contacts.map(c => {
const last = lastMsg(c);
return {
contact: c,
sortKey: last ? last.timestamp : (c.addedAt / 1000),
};
});
return withTime
.sort((a, b) => b.sortKey - a.sortKey)
.map(x => x.contact);
}, [contacts, messages]);
const renderItem = useCallback(({ item: c, index }: { item: Contact; index: number }) => {
const last = lastMsg(c);
const name = displayName(c);
const hasKey = !!c.x25519Pub;
return (
<Pressable
onPress={() => router.push(`/(app)/chats/${c.address}`)}
android_ripple={{ color: C.surface2 }}
style={({ pressed }) => ({
flexDirection: 'row', alignItems: 'center',
paddingHorizontal: 14, paddingVertical: 12,
borderTopWidth: index === 0 ? 0 : 1, borderTopColor: C.line,
backgroundColor: pressed ? C.surface2 : 'transparent',
})}
>
{/* Avatar with E2E status pip */}
<View style={{ position: 'relative', marginRight: 12 }}>
<Avatar name={name} size="md" />
{hasKey && (
<View style={{
position: 'absolute', right: -2, bottom: -2,
width: 14, height: 14, borderRadius: 7,
backgroundColor: C.ok,
borderWidth: 2, borderColor: C.bg,
alignItems: 'center', justifyContent: 'center',
}}>
<Ionicons name="lock-closed" size={7} color="#0b1220" />
</View>
)}
</View>
{/* Text column */}
<View style={{ flex: 1, minWidth: 0 }}>
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
<Text
style={{ color: C.text, fontWeight: '600', fontSize: 15, flex: 1 }}
numberOfLines={1}
>
{name}
</Text>
{last && (
<Text style={{ color: C.muted, fontSize: 11, marginLeft: 8 }}>
{formatTime(last.timestamp)}
</Text>
)}
</View>
<View style={{ flexDirection: 'row', alignItems: 'center', marginTop: 2 }}>
{last?.mine && (
<Ionicons
name="checkmark-done-outline"
size={13}
color={C.muted}
style={{ marginRight: 4 }}
/>
)}
<Text
style={{ color: C.muted, fontSize: 13, flex: 1 }}
numberOfLines={1}
>
{last ? previewText(last.text) : (hasKey ? 'Напишите первое сообщение' : 'Ожидание публикации ключа…')}
</Text>
</View>
</View>
</Pressable>
);
}, [messages, lastMsg]);
return (
<View style={{ flex: 1, backgroundColor: C.bg }}>
{/* ── Header ── */}
<View style={{
flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between',
paddingHorizontal: 16,
paddingTop: insets.top + 10,
paddingBottom: 14,
borderBottomWidth: 1, borderBottomColor: C.line,
}}>
<View>
<Text style={{ color: C.text, fontSize: 22, fontWeight: '700', letterSpacing: -0.3 }}>
Сообщения
</Text>
{contacts.length > 0 && (
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 5, marginTop: 2 }}>
<Text style={{ color: C.muted, fontSize: 12 }}>
{contacts.length} {contacts.length === 1 ? 'контакт' : contacts.length < 5 ? 'контакта' : 'контактов'}
{' · '}
<Text style={{ color: C.ok }}>E2E</Text>
{' · '}
</Text>
<View style={{
width: 6, height: 6, borderRadius: 3,
backgroundColor: wsLive ? C.ok : C.warn,
}} />
<Text style={{ color: wsLive ? C.ok : C.warn, fontSize: 11 }}>
{wsLive ? 'live' : 'polling'}
</Text>
</View>
)}
</View>
<View style={{ flexDirection: 'row', gap: 8, alignItems: 'center' }}>
{/* Incoming requests chip */}
{requests.length > 0 && (
<TouchableOpacity
onPress={() => router.push('/(app)/requests')}
activeOpacity={0.7}
style={{
flexDirection: 'row', alignItems: 'center', gap: 6,
backgroundColor: 'rgba(125,181,255,0.14)', borderRadius: 999,
paddingHorizontal: 12, paddingVertical: 7,
borderWidth: 1, borderColor: 'rgba(125,181,255,0.25)',
}}
>
<Ionicons name="mail-unread-outline" size={14} color={C.accent} />
<Text style={{ color: C.accent, fontSize: 12, fontWeight: '600' }}>
{requests.length}
</Text>
</TouchableOpacity>
)}
{/* Add contact button */}
<TouchableOpacity
onPress={() => hasBalance ? router.push('/(app)/new-contact') : router.push('/(app)/wallet')}
activeOpacity={0.7}
style={{
width: 38, height: 38, borderRadius: 19,
backgroundColor: hasBalance ? C.accent : C.surface2,
alignItems: 'center', justifyContent: 'center',
}}
>
<Ionicons
name={hasBalance ? 'add-outline' : 'lock-closed-outline'}
size={20}
color={hasBalance ? C.bg : C.muted}
/>
</TouchableOpacity>
</View>
</View>
{/* ── No balance gate (no contacts) ── */}
{!hasBalance && contacts.length === 0 && (
<View style={{
margin: 16, padding: 18,
backgroundColor: 'rgba(125,181,255,0.07)',
borderRadius: 14,
borderWidth: 1, borderColor: 'rgba(125,181,255,0.15)',
}}>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 10, marginBottom: 8 }}>
<View style={{
width: 32, height: 32, borderRadius: 16,
backgroundColor: 'rgba(125,181,255,0.18)',
alignItems: 'center', justifyContent: 'center',
}}>
<Ionicons name="diamond-outline" size={16} color={C.accent} />
</View>
<Text style={{ color: C.text, fontWeight: '700', fontSize: 15 }}>
Пополните баланс
</Text>
</View>
<Text style={{ color: C.muted, fontSize: 13, lineHeight: 20, marginBottom: 14 }}>
Отправка запроса контакта стоит{' '}
<Text style={{ color: C.text, fontWeight: '600' }}>{formatAmount(MIN_FEE)}</Text>
{' '} антиспам-сбор идёт напрямую получателю.
</Text>
<TouchableOpacity
onPress={() => router.push('/(app)/wallet')}
activeOpacity={0.7}
style={{
flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 8,
paddingVertical: 11, borderRadius: 10,
backgroundColor: C.accent,
}}
>
<Ionicons name="wallet-outline" size={16} color={C.bg} />
<Text style={{ color: C.bg, fontWeight: '700', fontSize: 13 }}>Перейти в кошелёк</Text>
</TouchableOpacity>
</View>
)}
{/* ── Low balance warning (has contacts) ── */}
{!hasBalance && contacts.length > 0 && (
<TouchableOpacity
onPress={() => router.push('/(app)/wallet')}
activeOpacity={0.7}
style={{
flexDirection: 'row', alignItems: 'center', gap: 10,
marginHorizontal: 16, marginTop: 12,
backgroundColor: 'rgba(255,122,135,0.08)',
borderRadius: 10, paddingHorizontal: 12, paddingVertical: 10,
borderWidth: 1, borderColor: 'rgba(255,122,135,0.18)',
}}
>
<Ionicons name="warning-outline" size={16} color={C.err} />
<Text style={{ color: C.err, fontSize: 13, flex: 1 }}>
Недостаточно токенов для добавления контакта
</Text>
<Ionicons name="chevron-forward" size={14} color={C.err} />
</TouchableOpacity>
)}
{/* ── Empty state (has balance, no contacts) ── */}
{contacts.length === 0 && hasBalance && (
<View style={{ flex: 1, alignItems: 'center', justifyContent: 'center', paddingHorizontal: 32 }}>
<View style={{
width: 88, height: 88, borderRadius: 44,
backgroundColor: C.surface,
alignItems: 'center', justifyContent: 'center',
marginBottom: 18,
}}>
<Ionicons name="chatbubbles-outline" size={40} color={C.accent} />
</View>
<Text style={{ color: C.text, fontSize: 19, fontWeight: '700', textAlign: 'center', marginBottom: 8 }}>
Нет диалогов
</Text>
<Text style={{ color: C.muted, fontSize: 14, textAlign: 'center', lineHeight: 22, marginBottom: 20 }}>
Добавьте контакт по адресу или{' '}
<Text style={{ color: C.accent, fontWeight: '600' }}>@username</Text>
{' '}для начала зашифрованной переписки.
</Text>
<TouchableOpacity
onPress={() => router.push('/(app)/new-contact')}
activeOpacity={0.7}
style={{
flexDirection: 'row', alignItems: 'center', gap: 8,
paddingHorizontal: 22, paddingVertical: 12, borderRadius: 12,
backgroundColor: C.accent,
}}
>
<Ionicons name="person-add-outline" size={16} color={C.bg} />
<Text style={{ color: C.bg, fontWeight: '700', fontSize: 14 }}>Добавить контакт</Text>
</TouchableOpacity>
</View>
)}
{/* ── Chat list ── */}
{contacts.length > 0 && (
<FlatList
data={sortedContacts}
keyExtractor={c => c.address}
renderItem={renderItem}
contentContainerStyle={{ paddingBottom: 20 }}
showsVerticalScrollIndicator={false}
/>
)}
</View>
);
}

View File

@@ -0,0 +1,332 @@
/**
* 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>
);
}

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

View File

@@ -0,0 +1,732 @@
/**
* 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>
);
}

View File

@@ -0,0 +1,596 @@
/**
* Wallet screen — DChain explorer style.
* Balance block inspired by Tinkoff/Gravity UI reference.
* Icons: Ionicons from @expo/vector-icons.
*/
import React, { useState, useCallback, useEffect } from 'react';
import {
View, Text, ScrollView, Modal, TouchableOpacity,
Alert, RefreshControl,
} from 'react-native';
import * as Clipboard from 'expo-clipboard';
import { Ionicons } from '@expo/vector-icons';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { useStore } from '@/lib/store';
import { useBalance } from '@/hooks/useBalance';
import { buildTransferTx, submitTx, getTxHistory, getBalance } from '@/lib/api';
import { shortAddr } from '@/lib/crypto';
import { formatAmount, relativeTime } from '@/lib/utils';
import { Input } from '@/components/ui/Input';
import type { TxRecord } from '@/lib/types';
// ─── 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;
// ─── TX metadata ──────────────────────────────────────────────────────────────
const TX_META: Record<string, { label: string; icon: keyof typeof Ionicons.glyphMap; color: string }> = {
TRANSFER: { label: 'Перевод', icon: 'paper-plane-outline', color: C.accent },
CONTACT_REQUEST: { label: 'Запрос контакта', icon: 'person-add-outline', color: C.ok },
ACCEPT_CONTACT: { label: 'Принят контакт', icon: 'person-outline', color: C.ok },
BLOCK_CONTACT: { label: 'Блокировка', icon: 'ban-outline', color: C.err },
DEPLOY_CONTRACT: { label: 'Деплой контракта',icon: 'document-text-outline', color: C.warn },
CALL_CONTRACT: { label: 'Вызов контракта', icon: 'flash-outline', color: C.warn },
STAKE: { label: 'Стейкинг', icon: 'lock-closed-outline', color: C.accent},
UNSTAKE: { label: 'Вывод стейка', icon: 'lock-open-outline', color: C.muted },
REGISTER_KEY: { label: 'Регистрация', icon: 'key-outline', color: C.muted },
BLOCK_REWARD: { label: 'Награда', icon: 'diamond-outline', color: C.ok },
};
function txMeta(type: string) {
return TX_META[type] ?? { label: type.replace(/_/g, ' '), icon: 'ellipse-outline' as any, color: C.muted };
}
// ─── Component ────────────────────────────────────────────────────────────────
export default function WalletScreen() {
const keyFile = useStore(s => s.keyFile);
const balance = useStore(s => s.balance);
const setBalance = useStore(s => s.setBalance);
const insets = useSafeAreaInsets();
useBalance();
const [txHistory, setTxHistory] = useState<TxRecord[]>([]);
const [refreshing, setRefreshing] = useState(false);
const [showSend, setShowSend] = useState(false);
const [selectedTx, setSelectedTx] = useState<TxRecord | null>(null);
const [toAddress, setToAddress] = useState('');
const [amount, setAmount] = useState('');
const [fee, setFee] = useState('1000');
const [sending, setSending] = useState(false);
const [copied, setCopied] = useState(false);
const load = useCallback(async () => {
if (!keyFile) return;
setRefreshing(true);
try {
const [hist, bal] = await Promise.all([
getTxHistory(keyFile.pub_key),
getBalance(keyFile.pub_key),
]);
setTxHistory(hist);
setBalance(bal);
} catch {}
setRefreshing(false);
}, [keyFile]);
useEffect(() => { load(); }, [load]);
const copyAddress = async () => {
if (!keyFile) return;
await Clipboard.setStringAsync(keyFile.pub_key);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
const send = async () => {
if (!keyFile) return;
const amt = parseInt(amount);
const f = parseInt(fee);
if (!toAddress.trim() || isNaN(amt) || amt <= 0) {
Alert.alert('Неверные данные', 'Введите корректный адрес и сумму.');
return;
}
if (amt + f > balance) {
Alert.alert('Недостаточно средств', `Нужно ${formatAmount(amt + f)}, доступно ${formatAmount(balance)}.`);
return;
}
setSending(true);
try {
const tx = buildTransferTx({ from: keyFile.pub_key, to: toAddress.trim(), amount: amt, fee: f, privKey: keyFile.priv_key });
await submitTx(tx);
setShowSend(false);
setToAddress('');
setAmount('');
Alert.alert('Отправлено', 'Транзакция принята нодой.');
setTimeout(load, 1500);
} catch (e: any) {
Alert.alert('Ошибка', e.message);
} finally {
setSending(false);
}
};
return (
<View style={{ flex: 1, backgroundColor: C.bg }}>
<ScrollView
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={load} tintColor={C.accent} />}
contentContainerStyle={{ paddingBottom: 32 }}
>
{/* ── Balance hero ── */}
<BalanceHero
balance={balance}
address={keyFile?.pub_key ?? ''}
copied={copied}
topInset={insets.top}
onSend={() => setShowSend(true)}
onReceive={copyAddress}
onRefresh={load}
onCopy={copyAddress}
/>
{/* ── Transaction list ── */}
<View style={{ paddingHorizontal: 16, paddingTop: 8 }}>
<Text style={{ color: C.muted, fontSize: 11, letterSpacing: 1, textTransform: 'uppercase', marginBottom: 10 }}>
История транзакций
</Text>
{txHistory.length === 0 ? (
<View style={{ backgroundColor: C.surface, borderRadius: 12, paddingVertical: 36, alignItems: 'center' }}>
<Ionicons name="receipt-outline" size={32} color={C.muted} style={{ marginBottom: 10 }} />
<Text style={{ color: C.muted, fontSize: 14 }}>Нет транзакций</Text>
<Text style={{ color: C.muted, fontSize: 12, marginTop: 4 }}>Потяните вниз, чтобы обновить</Text>
</View>
) : (
<View style={{ backgroundColor: C.surface, borderRadius: 12, overflow: 'hidden' }}>
{/* Table header */}
<View style={{
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 14,
paddingVertical: 8,
borderBottomWidth: 1,
borderBottomColor: C.line,
}}>
<Text style={{ color: C.muted, fontSize: 10, fontWeight: '600', letterSpacing: 0.5, width: 140 }}>
ТИП
</Text>
<Text style={{ color: C.muted, fontSize: 10, fontWeight: '600', letterSpacing: 0.5, flex: 1 }}>
АДРЕС
</Text>
<Text style={{ color: C.muted, fontSize: 10, fontWeight: '600', letterSpacing: 0.5, width: 84, textAlign: 'right' }}>
СУММА
</Text>
</View>
{txHistory.map((tx, i) => (
<TxRow
key={tx.hash}
tx={tx}
myPubKey={keyFile?.pub_key ?? ''}
isLast={i === txHistory.length - 1}
onPress={() => setSelectedTx(tx)}
/>
))}
</View>
)}
</View>
</ScrollView>
{/* Send modal */}
<Modal visible={showSend} animationType="slide" presentationStyle="pageSheet" onRequestClose={() => setShowSend(false)}>
<SendSheet
balance={balance}
toAddress={toAddress} setToAddress={setToAddress}
amount={amount} setAmount={setAmount}
fee={fee} setFee={setFee}
sending={sending}
onSend={send}
onClose={() => setShowSend(false)}
/>
</Modal>
{/* TX Detail modal */}
<Modal visible={!!selectedTx} animationType="slide" presentationStyle="pageSheet" onRequestClose={() => setSelectedTx(null)}>
{selectedTx && (
<TxDetailSheet tx={selectedTx} myPubKey={keyFile?.pub_key ?? ''} onClose={() => setSelectedTx(null)} />
)}
</Modal>
</View>
);
}
// ─── Balance Hero ─────────────────────────────────────────────────────────────
function BalanceHero({
balance, address, copied, topInset, onSend, onReceive, onRefresh, onCopy,
}: {
balance: number; address: string; copied: boolean;
topInset: number;
onSend: () => void; onReceive: () => void;
onRefresh: () => void; onCopy: () => void;
}) {
return (
<View style={{
backgroundColor: C.surface,
paddingTop: topInset + 16, paddingBottom: 28,
paddingHorizontal: 20,
borderBottomLeftRadius: 20,
borderBottomRightRadius: 20,
alignItems: 'center',
marginBottom: 20,
}}>
{/* Label */}
<Text style={{ color: C.muted, fontSize: 13, marginBottom: 8, letterSpacing: 0.3 }}>
Баланс
</Text>
{/* Main balance */}
<Text style={{ color: C.text, fontSize: 42, fontWeight: '700', letterSpacing: -1, lineHeight: 50 }}>
{formatAmount(balance)}
</Text>
{/* µT sub-label */}
<Text style={{ color: C.muted, fontSize: 13, marginTop: 4, marginBottom: 20 }}>
{(balance ?? 0).toLocaleString()} µT
</Text>
{/* Address chip */}
<TouchableOpacity
onPress={onCopy}
style={{
flexDirection: 'row', alignItems: 'center', gap: 6,
backgroundColor: copied ? 'rgba(65,201,138,0.12)' : C.surface2,
borderRadius: 999, paddingHorizontal: 12, paddingVertical: 6,
marginBottom: 24,
}}
>
<Ionicons
name={copied ? 'checkmark-outline' : 'copy-outline'}
size={13}
color={copied ? C.ok : C.muted}
/>
<Text style={{ color: copied ? C.ok : C.muted, fontSize: 12, fontFamily: 'monospace' }}>
{copied ? 'Скопировано' : (address ? shortAddr(address, 8) : '—')}
</Text>
</TouchableOpacity>
{/* Action buttons */}
<View style={{ flexDirection: 'row', gap: 20 }}>
<ActionButton icon="paper-plane-outline" label="Отправить" color={C.accent} onPress={onSend} />
<ActionButton icon="arrow-down-circle-outline" label="Получить" color={C.accent} onPress={onReceive} />
<ActionButton icon="refresh-outline" label="Обновить" color={C.muted} onPress={onRefresh} />
</View>
</View>
);
}
function ActionButton({
icon, label, color, onPress,
}: {
icon: keyof typeof Ionicons.glyphMap; label: string; color: string; onPress: () => void;
}) {
return (
<TouchableOpacity onPress={onPress} style={{ alignItems: 'center', gap: 8 }}>
<View style={{
width: 52, height: 52, borderRadius: 26,
backgroundColor: C.surface3,
alignItems: 'center', justifyContent: 'center',
}}>
<Ionicons name={icon} size={22} color={color} />
</View>
<Text style={{ color: C.muted, fontSize: 12 }}>{label}</Text>
</TouchableOpacity>
);
}
// ─── Transaction Row ──────────────────────────────────────────────────────────
// Column widths must match the header above:
// col1 (type+icon): 140 col2 (address): flex:1 col3 (amount): 84
// tx types where "from" is always the owner but it's income, not a send
const RECEIVED_TYPES = new Set(['BLOCK_REWARD', 'STAKE_REWARD']);
function TxRow({
tx, myPubKey, isLast, onPress,
}: {
tx: TxRecord; myPubKey: string; isLast: boolean; onPress: () => void;
}) {
const meta = txMeta(tx.type);
const isSynthetic = !tx.from; // block reward / mint
const isSent = !isSynthetic && !RECEIVED_TYPES.has(tx.type) && tx.from === myPubKey;
const amt = tx.amount ?? 0;
const amtText = amt === 0 ? '' : `${isSent ? '' : '+'}${formatAmount(amt)}`;
const amtColor = isSent ? C.err : C.ok;
// Counterpart label: for synthetic (empty from) rewards → "Сеть",
// otherwise show the short address of the other side.
const counterpart = isSynthetic
? 'Сеть'
: isSent
? (tx.to ? shortAddr(tx.to, 6) : '—')
: shortAddr(tx.from, 6);
return (
<TouchableOpacity
onPress={onPress}
activeOpacity={0.6}
style={{
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 14,
paddingVertical: 11,
borderBottomWidth: isLast ? 0 : 1,
borderBottomColor: C.line,
}}
>
{/* Col 1 — icon + type label, fixed 140px */}
<View style={{ width: 140, flexDirection: 'row', alignItems: 'center' }}>
<View style={{
width: 28, height: 28, borderRadius: 14,
backgroundColor: `${meta.color}1a`,
alignItems: 'center', justifyContent: 'center',
marginRight: 8, flexShrink: 0,
}}>
<Ionicons name={meta.icon} size={14} color={meta.color} />
</View>
<Text style={{ color: C.text, fontSize: 13, fontWeight: '500', flexShrink: 1 }} numberOfLines={1}>
{meta.label}
</Text>
</View>
{/* Col 2 — address, flex */}
<View style={{ flex: 1 }}>
<Text style={{ color: C.muted, fontSize: 12 }} numberOfLines={1}>
{isSent ? `${counterpart}` : `${counterpart}`}
</Text>
</View>
{/* Col 3 — amount + time, fixed 84px, right-aligned */}
<View style={{ width: 84, alignItems: 'flex-end' }}>
{!!amtText && (
<Text style={{ color: amtColor, fontSize: 12, fontWeight: '600' }} numberOfLines={1}>
{amtText}
</Text>
)}
<Text style={{ color: C.muted, fontSize: 11, marginTop: amtText ? 1 : 0 }} numberOfLines={1}>
{relativeTime(tx.timestamp)}
</Text>
</View>
</TouchableOpacity>
);
}
// ─── Send Sheet ───────────────────────────────────────────────────────────────
function SendSheet({
balance, toAddress, setToAddress, amount, setAmount, fee, setFee, sending, onSend, onClose,
}: {
balance: number;
toAddress: string; setToAddress: (v: string) => void;
amount: string; setAmount: (v: string) => void;
fee: string; setFee: (v: string) => void;
sending: boolean; onSend: () => void; onClose: () => void;
}) {
return (
<View style={{ flex: 1, backgroundColor: C.bg, paddingHorizontal: 16, paddingTop: 20 }}>
<View style={{ flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginBottom: 20 }}>
<Text style={{ color: C.text, fontSize: 20, fontWeight: '700' }}>Отправить</Text>
<TouchableOpacity onPress={onClose} style={{ padding: 8, backgroundColor: C.surface2, borderRadius: 8 }}>
<Ionicons name="close-outline" size={18} color={C.muted} />
</TouchableOpacity>
</View>
<View style={{
flexDirection: 'row', alignItems: 'center', gap: 8,
backgroundColor: C.surface, borderRadius: 8,
paddingHorizontal: 12, paddingVertical: 10, marginBottom: 20,
}}>
<Ionicons name="wallet-outline" size={15} color={C.muted} />
<Text style={{ color: C.muted, fontSize: 13 }}>Доступно:</Text>
<Text style={{ color: C.text, fontSize: 13, fontWeight: '600' }}>{formatAmount(balance)}</Text>
</View>
<Input label="Адрес получателя" placeholder="64-символьный hex" value={toAddress}
onChangeText={setToAddress} autoCapitalize="none" autoCorrect={false} className="mb-4" />
<Input label="Сумма (µT)" placeholder="например 100000" value={amount}
onChangeText={setAmount} keyboardType="numeric" className="mb-4" />
<Input label="Комиссия (µT)" value={fee}
onChangeText={setFee} keyboardType="numeric" className="mb-8" />
<TouchableOpacity
onPress={onSend}
disabled={sending}
style={{
paddingVertical: 15, borderRadius: 10, alignItems: 'center', flexDirection: 'row',
justifyContent: 'center', gap: 8,
backgroundColor: sending ? C.surface2 : C.accent,
}}
>
{!sending && <Ionicons name="paper-plane-outline" size={18} color="#0b1220" />}
<Text style={{ color: sending ? C.muted : '#0b1220', fontWeight: '700', fontSize: 15 }}>
{sending ? 'Отправка…' : 'Подтвердить'}
</Text>
</TouchableOpacity>
</View>
);
}
// ─── TX Detail Sheet ──────────────────────────────────────────────────────────
function TxDetailSheet({
tx, myPubKey, onClose,
}: {
tx: TxRecord; myPubKey: string; onClose: () => void;
}) {
const [copiedHash, setCopiedHash] = useState(false);
const meta = txMeta(tx.type);
const isSent = tx.from === myPubKey;
const amtValue = tx.amount ?? 0;
const copyHash = async () => {
await Clipboard.setStringAsync(tx.hash);
setCopiedHash(true);
setTimeout(() => setCopiedHash(false), 2000);
};
const amtColor = amtValue === 0 ? C.muted : isSent ? C.err : C.ok;
const amtSign = amtValue === 0 ? '' : isSent ? '' : '+';
return (
<View style={{ flex: 1, backgroundColor: C.bg }}>
{/* Handle */}
<View style={{ alignItems: 'center', paddingTop: 12, marginBottom: 4 }}>
<View style={{ width: 36, height: 4, borderRadius: 2, backgroundColor: C.line }} />
</View>
<ScrollView contentContainerStyle={{ paddingHorizontal: 16, paddingTop: 8, paddingBottom: 32 }}>
{/* Header */}
<View style={{ flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginBottom: 20 }}>
<Text style={{ color: C.text, fontSize: 17, fontWeight: '700' }}>Транзакция</Text>
<TouchableOpacity onPress={onClose} style={{ padding: 8, backgroundColor: C.surface2, borderRadius: 8 }}>
<Ionicons name="close-outline" size={18} color={C.muted} />
</TouchableOpacity>
</View>
{/* Hero */}
<View style={{ backgroundColor: C.surface, borderRadius: 12, padding: 20, alignItems: 'center', marginBottom: 16 }}>
<View style={{
width: 56, height: 56, borderRadius: 28,
backgroundColor: `${meta.color}18`,
alignItems: 'center', justifyContent: 'center', marginBottom: 12,
}}>
<Ionicons name={meta.icon} size={26} color={meta.color} />
</View>
<Text style={{ color: C.text, fontSize: 16, fontWeight: '600', marginBottom: amtValue > 0 ? 8 : 0 }}>
{meta.label}
</Text>
{amtValue > 0 && (
<Text style={{ color: amtColor, fontSize: 30, fontWeight: '700', letterSpacing: -0.5 }}>
{amtSign}{formatAmount(amtValue)}
</Text>
)}
<View style={{
marginTop: 12, paddingHorizontal: 12, paddingVertical: 4,
borderRadius: 999,
backgroundColor: tx.status === 'confirmed' ? 'rgba(65,201,138,0.12)' : 'rgba(240,179,90,0.12)',
}}>
<Text style={{ color: tx.status === 'confirmed' ? C.ok : C.warn, fontSize: 12, fontWeight: '600' }}>
{tx.status === 'confirmed' ? '✓ Подтверждена' : '⏳ В обработке'}
</Text>
</View>
</View>
{/* Details table */}
<View style={{ backgroundColor: C.surface, borderRadius: 12, overflow: 'hidden', marginBottom: 16 }}>
<DetailRow icon="code-outline" label="Тип" value={tx.type} mono first />
{tx.amount !== undefined && (
<DetailRow icon="cash-outline" label="Сумма" value={`${tx.amount.toLocaleString()} µT`} />
)}
<DetailRow icon="pricetag-outline" label="Комиссия" value={`${(tx.fee ?? 0).toLocaleString()} µT`} />
{tx.timestamp > 0 && (
<DetailRow icon="time-outline" label="Время" value={new Date(tx.timestamp * 1000).toLocaleString()} />
)}
</View>
{/* Addresses */}
<View style={{ backgroundColor: C.surface, borderRadius: 12, overflow: 'hidden', marginBottom: 16 }}>
{/* "From" for synthetic txs (empty tx.from) reads "Сеть" rather than an empty row. */}
<DetailRow
icon="person-outline"
label="От"
value={tx.from || 'Сеть (синтетическая tx)'}
mono={!!tx.from}
truncate={!!tx.from}
first
/>
{tx.to && (
<DetailRow icon="arrow-forward-outline" label="Кому" value={tx.to} mono truncate />
)}
</View>
{/* TX Hash */}
<View style={{ backgroundColor: C.surface, borderRadius: 12, overflow: 'hidden', marginBottom: 20 }}>
<View style={{ paddingHorizontal: 14, paddingVertical: 12 }}>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 6, marginBottom: 6 }}>
<Ionicons name="receipt-outline" size={13} color={C.muted} />
<Text style={{ color: C.muted, fontSize: 11, letterSpacing: 0.5, textTransform: 'uppercase' }}>
TX ID / Hash
</Text>
</View>
<Text style={{ color: C.text, fontFamily: 'monospace', fontSize: 11, lineHeight: 17 }}>
{tx.hash}
</Text>
</View>
</View>
<TouchableOpacity
onPress={copyHash}
style={{
paddingVertical: 13, borderRadius: 10, alignItems: 'center',
flexDirection: 'row', justifyContent: 'center', gap: 8,
backgroundColor: copiedHash ? 'rgba(65,201,138,0.12)' : C.surface2,
}}
>
<Ionicons
name={copiedHash ? 'checkmark-outline' : 'copy-outline'}
size={16}
color={copiedHash ? C.ok : C.text}
/>
<Text style={{ color: copiedHash ? C.ok : C.text, fontWeight: '600', fontSize: 14 }}>
{copiedHash ? 'Скопировано' : 'Копировать хеш'}
</Text>
</TouchableOpacity>
</ScrollView>
</View>
);
}
// ─── Detail Row ───────────────────────────────────────────────────────────────
function DetailRow({
icon, label, value, mono, truncate, first,
}: {
icon: keyof typeof Ionicons.glyphMap;
label: string; value: string;
mono?: boolean; truncate?: boolean; first?: boolean;
}) {
return (
<View style={{
flexDirection: 'row', alignItems: 'center',
paddingHorizontal: 14, paddingVertical: 11,
borderTopWidth: first ? 0 : 1, borderTopColor: C.line,
gap: 10,
}}>
<Ionicons name={icon} size={14} color={C.muted} style={{ width: 16 }} />
<Text style={{ color: C.muted, fontSize: 13, width: 72 }}>{label}</Text>
<Text
style={{
color: C.text,
flex: 1,
textAlign: 'right',
fontFamily: mono ? 'monospace' : undefined,
fontSize: mono ? 11 : 13,
}}
numberOfLines={truncate ? 1 : undefined}
ellipsizeMode="middle"
>
{value}
</Text>
</View>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View File

@@ -0,0 +1,40 @@
import '../global.css';
import React, { useEffect } from 'react';
import { Stack } from 'expo-router';
import { StatusBar } from 'expo-status-bar';
import { View } from 'react-native';
import { SafeAreaProvider } from 'react-native-safe-area-context';
import { loadKeyFile, loadSettings } from '@/lib/storage';
import { setNodeUrl } from '@/lib/api';
import { useStore } from '@/lib/store';
export default function RootLayout() {
const setKeyFile = useStore(s => s.setKeyFile);
const setSettings = useStore(s => s.setSettings);
// Bootstrap: load key + settings from storage
useEffect(() => {
(async () => {
const [kf, settings] = await Promise.all([loadKeyFile(), loadSettings()]);
if (kf) setKeyFile(kf);
setSettings(settings);
setNodeUrl(settings.nodeUrl);
})();
}, []);
return (
<SafeAreaProvider>
<View className="flex-1 bg-background">
<StatusBar style="light" />
<Stack
screenOptions={{
headerShown: false,
contentStyle: { backgroundColor: '#0b1220' },
animation: 'slide_from_right',
}}
/>
</View>
</SafeAreaProvider>
);
}

220
client-app/app/index.tsx Normal file
View File

@@ -0,0 +1,220 @@
/**
* Welcome / landing screen.
* - Node URL input with live ping + QR scanner
* - Create / Import account buttons
* Redirects to (app)/chats if key already loaded.
*/
import React, { useEffect, useState, useCallback } from 'react';
import {
View, Text, TextInput, Pressable,
ScrollView, Alert, ActivityIndicator,
} from 'react-native';
import { router } from 'expo-router';
import { CameraView, useCameraPermissions } from 'expo-camera';
import { useStore } from '@/lib/store';
import { saveSettings } from '@/lib/storage';
import { setNodeUrl, getNetStats } from '@/lib/api';
import { Button } from '@/components/ui/Button';
export default function WelcomeScreen() {
const keyFile = useStore(s => s.keyFile);
const settings = useStore(s => s.settings);
const setSettings = useStore(s => s.setSettings);
const [nodeInput, setNodeInput] = useState('');
const [scanning, setScanning] = useState(false);
const [checking, setChecking] = useState(false);
const [nodeOk, setNodeOk] = useState<boolean | null>(null);
const [permission, requestPermission] = useCameraPermissions();
useEffect(() => {
if (keyFile) router.replace('/(app)/chats');
}, [keyFile]);
useEffect(() => {
setNodeInput(settings.nodeUrl);
}, [settings.nodeUrl]);
const applyNode = useCallback(async (url: string) => {
const clean = url.trim().replace(/\/$/, '');
if (!clean) return;
setChecking(true);
setNodeOk(null);
setNodeUrl(clean);
try {
await getNetStats();
setNodeOk(true);
const next = { ...settings, nodeUrl: clean };
setSettings(next);
await saveSettings(next);
} catch {
setNodeOk(false);
} finally {
setChecking(false);
}
}, [settings, setSettings]);
const onQrScanned = useCallback(({ data }: { data: string }) => {
setScanning(false);
let url = data.trim();
try { const p = JSON.parse(url); if (p.nodeUrl) url = p.nodeUrl; } catch {}
setNodeInput(url);
applyNode(url);
}, [applyNode]);
const openScanner = async () => {
if (!permission?.granted) {
const { granted } = await requestPermission();
if (!granted) {
Alert.alert('Camera permission required', 'Allow camera access to scan QR codes.');
return;
}
}
setScanning(true);
};
// ── QR Scanner overlay ───────────────────────────────────────────────────
if (scanning) {
return (
<View style={{ flex: 1, backgroundColor: '#000' }}>
<CameraView
style={{ flex: 1 }}
facing="back"
barcodeScannerSettings={{ barcodeTypes: ['qr'] }}
onBarcodeScanned={onQrScanned}
/>
<View style={{
position: 'absolute', top: 0, left: 0, right: 0, bottom: 0,
alignItems: 'center', justifyContent: 'center',
}}>
<View style={{
width: 240, height: 240,
borderWidth: 2, borderColor: 'white', borderRadius: 16,
}} />
<Text style={{ color: 'white', marginTop: 20, opacity: 0.8 }}>
Point at a DChain node QR code
</Text>
</View>
<Pressable
onPress={() => setScanning(false)}
style={{
position: 'absolute', top: 56, left: 16,
backgroundColor: 'rgba(0,0,0,0.6)', borderRadius: 20,
paddingHorizontal: 16, paddingVertical: 8,
}}
>
<Text style={{ color: 'white', fontSize: 16 }}> Cancel</Text>
</Pressable>
</View>
);
}
// ── Main screen ──────────────────────────────────────────────────────────
const statusColor = nodeOk === true ? '#3fb950' : nodeOk === false ? '#f85149' : '#8b949e';
return (
<ScrollView
style={{ flex: 1, backgroundColor: '#0d1117' }}
contentContainerStyle={{ flexGrow: 1, paddingHorizontal: 24, paddingTop: 80, paddingBottom: 40 }}
keyboardShouldPersistTaps="handled"
keyboardDismissMode="on-drag"
>
{/* Logo ─ takes remaining space above, centered */}
<View style={{ flex: 1, alignItems: 'center', justifyContent: 'center', gap: 16 }}>
<View style={{
width: 96, height: 96, borderRadius: 24,
backgroundColor: '#2563eb', alignItems: 'center', justifyContent: 'center',
}}>
<Text style={{ fontSize: 48 }}></Text>
</View>
<Text style={{ color: '#fff', fontSize: 36, fontWeight: '700', letterSpacing: -1 }}>
DChain
</Text>
<Text style={{ color: '#8b949e', textAlign: 'center', fontSize: 15, lineHeight: 22 }}>
Decentralised E2E-encrypted messenger.{'\n'}Your keys. Your messages.
</Text>
</View>
{/* Bottom section ─ node input + buttons */}
<View style={{ gap: 12, marginTop: 40 }}>
{/* Node URL label */}
<Text style={{ color: '#8b949e', fontSize: 11, fontWeight: '600',
textTransform: 'uppercase', letterSpacing: 1.2, marginBottom: 2 }}>
Node URL
</Text>
{/* Input row */}
<View style={{ flexDirection: 'row', gap: 8 }}>
<View style={{
flex: 1, flexDirection: 'row', alignItems: 'center',
backgroundColor: '#21262d', borderWidth: 1, borderColor: '#30363d',
borderRadius: 12, paddingHorizontal: 12, gap: 8,
}}>
{/* Status dot */}
<View style={{ width: 8, height: 8, borderRadius: 4, backgroundColor: statusColor }} />
<TextInput
value={nodeInput}
onChangeText={t => { setNodeInput(t); setNodeOk(null); }}
onEndEditing={() => applyNode(nodeInput)}
onSubmitEditing={() => applyNode(nodeInput)}
placeholder="http://192.168.1.10:8081"
placeholderTextColor="#8b949e"
autoCapitalize="none"
autoCorrect={false}
keyboardType="url"
returnKeyType="done"
style={{ flex: 1, color: '#fff', fontSize: 14, paddingVertical: 14 }}
/>
{checking
? <ActivityIndicator size="small" color="#8b949e" />
: nodeOk === true
? <Text style={{ color: '#3fb950', fontSize: 16 }}></Text>
: nodeOk === false
? <Text style={{ color: '#f85149', fontSize: 14 }}></Text>
: null
}
</View>
{/* QR button */}
<Pressable
onPress={openScanner}
style={({ pressed }) => ({
width: 48, alignItems: 'center', justifyContent: 'center',
backgroundColor: '#21262d', borderWidth: 1, borderColor: '#30363d',
borderRadius: 12, opacity: pressed ? 0.7 : 1,
})}
>
<Text style={{ fontSize: 22 }}></Text>
</Pressable>
</View>
{/* Status text */}
{nodeOk === true && (
<Text style={{ color: '#3fb950', fontSize: 12, marginTop: -4 }}>
Node connected
</Text>
)}
{nodeOk === false && (
<Text style={{ color: '#f85149', fontSize: 12, marginTop: -4 }}>
Cannot reach node check URL and that the node is running
</Text>
)}
{/* Buttons */}
<View style={{ gap: 10, marginTop: 4 }}>
<Button size="lg" onPress={() => router.push('/(auth)/create')}>
Create New Account
</Button>
<Button variant="outline" size="lg" onPress={() => router.push('/(auth)/import')}>
Import Existing Key
</Button>
</View>
</View>
</ScrollView>
);
}

View File

@@ -0,0 +1,12 @@
module.exports = function (api) {
api.cache(true);
return {
presets: [
['babel-preset-expo', { jsxImportSource: 'nativewind' }],
'nativewind/babel',
],
plugins: [
'react-native-reanimated/plugin', // must be last
],
};
};

View File

@@ -0,0 +1,37 @@
import React from 'react';
import { View, Text } from 'react-native';
import { cn } from '@/lib/utils';
/** Deterministic color from a string */
function colorFor(str: string): string {
const colors = [
'bg-blue-600', 'bg-purple-600', 'bg-green-600',
'bg-pink-600', 'bg-orange-600', 'bg-teal-600',
'bg-red-600', 'bg-indigo-600', 'bg-cyan-600',
];
let h = 0;
for (let i = 0; i < str.length; i++) h = (h * 31 + str.charCodeAt(i)) >>> 0;
return colors[h % colors.length];
}
interface AvatarProps {
name?: string;
size?: 'sm' | 'md' | 'lg';
className?: string;
}
const sizeMap = {
sm: { outer: 'w-8 h-8', text: 'text-sm' },
md: { outer: 'w-10 h-10', text: 'text-base' },
lg: { outer: 'w-14 h-14', text: 'text-xl' },
};
export function Avatar({ name = '?', size = 'md', className }: AvatarProps) {
const initials = name.slice(0, 2).toUpperCase();
const { outer, text } = sizeMap[size];
return (
<View className={cn(outer, colorFor(name), 'rounded-full items-center justify-center', className)}>
<Text className={cn(text, 'text-white font-bold')}>{initials}</Text>
</View>
);
}

View File

@@ -0,0 +1,24 @@
import React from 'react';
import { View, Text } from 'react-native';
import { cn } from '@/lib/utils';
interface BadgeProps {
label: string | number;
variant?: 'default' | 'success' | 'destructive' | 'muted';
className?: string;
}
const variantMap = {
default: 'bg-primary',
success: 'bg-success',
destructive: 'bg-destructive',
muted: 'bg-surfaceHigh border border-border',
};
export function Badge({ label, variant = 'default', className }: BadgeProps) {
return (
<View className={cn('rounded-full px-2 py-0.5 items-center justify-center', variantMap[variant], className)}>
<Text className="text-white text-xs font-semibold">{label}</Text>
</View>
);
}

View File

@@ -0,0 +1,76 @@
import React from 'react';
import { Pressable, Text, ActivityIndicator } from 'react-native';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
const buttonVariants = cva(
'flex-row items-center justify-center rounded-xl px-5 py-3 active:opacity-80',
{
variants: {
variant: {
default: 'bg-primary',
secondary: 'bg-surfaceHigh border border-border',
destructive: 'bg-destructive',
ghost: 'bg-transparent',
outline: 'bg-transparent border border-primary',
},
size: {
sm: 'px-3 py-2',
md: 'px-5 py-3',
lg: 'px-6 py-4',
icon: 'p-2',
},
},
defaultVariants: {
variant: 'default',
size: 'md',
},
},
);
const textVariants = cva('font-semibold text-center', {
variants: {
variant: {
default: 'text-white',
secondary: 'text-white',
destructive: 'text-white',
ghost: 'text-primary',
outline: 'text-primary',
},
size: {
sm: 'text-sm',
md: 'text-base',
lg: 'text-lg',
icon: 'text-base',
},
},
defaultVariants: {
variant: 'default',
size: 'md',
},
});
interface ButtonProps extends VariantProps<typeof buttonVariants> {
onPress?: () => void;
disabled?: boolean;
loading?: boolean;
children: React.ReactNode;
className?: string;
}
export function Button({
variant, size, onPress, disabled, loading, children, className,
}: ButtonProps) {
return (
<Pressable
onPress={onPress}
disabled={disabled || loading}
className={cn(buttonVariants({ variant, size }), disabled && 'opacity-50', className)}
>
{loading
? <ActivityIndicator color="#fff" size="small" />
: <Text className={textVariants({ variant, size })}>{children}</Text>
}
</Pressable>
);
}

View File

@@ -0,0 +1,16 @@
import React from 'react';
import { View } from 'react-native';
import { cn } from '@/lib/utils';
interface CardProps {
children: React.ReactNode;
className?: string;
}
export function Card({ children, className }: CardProps) {
return (
<View className={cn('bg-surface border border-border rounded-2xl p-4', className)}>
{children}
</View>
);
}

View File

@@ -0,0 +1,34 @@
import React, { forwardRef } from 'react';
import { TextInput, View, Text, type TextInputProps } from 'react-native';
import { cn } from '@/lib/utils';
interface InputProps extends TextInputProps {
label?: string;
error?: string;
className?: string;
}
export const Input = forwardRef<TextInput, InputProps>(
({ label, error, className, ...props }, ref) => (
<View className="w-full gap-1">
{label && (
<Text className="text-muted text-sm font-medium mb-1">{label}</Text>
)}
<TextInput
ref={ref}
placeholderTextColor="#8b949e"
className={cn(
'bg-surfaceHigh border border-border rounded-xl px-4 py-3 text-white text-base',
error && 'border-destructive',
className,
)}
{...props}
/>
{error && (
<Text className="text-destructive text-xs mt-1">{error}</Text>
)}
</View>
),
);
Input.displayName = 'Input';

View File

@@ -0,0 +1,7 @@
import React from 'react';
import { View } from 'react-native';
import { cn } from '@/lib/utils';
export function Separator({ className }: { className?: string }) {
return <View className={cn('h-px bg-border my-2', className)} />;
}

View File

@@ -0,0 +1,6 @@
export { Button } from './Button';
export { Card } from './Card';
export { Input } from './Input';
export { Avatar } from './Avatar';
export { Badge } from './Badge';
export { Separator } from './Separator';

3
client-app/global.css Normal file
View File

@@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

View File

@@ -0,0 +1,94 @@
/**
* Balance hook — uses the WebSocket gateway to receive instant updates when
* a tx involving the current address is committed, with HTTP polling as a
* graceful fallback for old nodes that don't expose /api/ws.
*
* Flow:
* 1. On mount: immediate HTTP fetch so the UI has a non-zero balance ASAP
* 2. Subscribe to `addr:<my_pubkey>` on the WS hub
* 3. On every `tx` event, re-fetch balance (cheap — one Badger read server-side)
* 4. If WS disconnects for >15s, fall back to 10-second polling until it reconnects
*/
import { useEffect, useCallback, useRef } from 'react';
import { getBalance } from '@/lib/api';
import { getWSClient } from '@/lib/ws';
import { useStore } from '@/lib/store';
const FALLBACK_POLL_INTERVAL = 10_000; // HTTP poll when WS is down
const WS_GRACE_BEFORE_POLLING = 15_000; // don't start polling immediately on disconnect
export function useBalance() {
const keyFile = useStore(s => s.keyFile);
const setBalance = useStore(s => s.setBalance);
const refresh = useCallback(async () => {
if (!keyFile) return;
try {
const bal = await getBalance(keyFile.pub_key);
setBalance(bal);
} catch {
// transient — next call will retry
}
}, [keyFile, setBalance]);
// --- fallback polling management ---
const pollTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const disconnectSinceRef = useRef<number | null>(null);
const disconnectTORef = useRef<ReturnType<typeof setTimeout> | null>(null);
const startPolling = useCallback(() => {
if (pollTimerRef.current) return;
console.log('[useBalance] WS down for grace period — starting HTTP poll');
refresh();
pollTimerRef.current = setInterval(refresh, FALLBACK_POLL_INTERVAL);
}, [refresh]);
const stopPolling = useCallback(() => {
if (pollTimerRef.current) {
clearInterval(pollTimerRef.current);
pollTimerRef.current = null;
}
if (disconnectTORef.current) {
clearTimeout(disconnectTORef.current);
disconnectTORef.current = null;
}
disconnectSinceRef.current = null;
}, []);
useEffect(() => {
if (!keyFile) return;
const ws = getWSClient();
// Immediate HTTP fetch so the UI is not empty while the WS hello arrives.
refresh();
// Refresh balance whenever a tx for our address is committed.
const offTx = ws.subscribe('addr:' + keyFile.pub_key, (frame) => {
if (frame.event === 'tx') {
refresh();
}
});
// Manage fallback polling based on WS connection state.
const offConn = ws.onConnectionChange((ok) => {
if (ok) {
stopPolling();
refresh(); // catch up anything we missed while disconnected
} else if (disconnectTORef.current === null) {
disconnectSinceRef.current = Date.now();
disconnectTORef.current = setTimeout(startPolling, WS_GRACE_BEFORE_POLLING);
}
});
ws.connect();
return () => {
offTx();
offConn();
stopPolling();
};
}, [keyFile, refresh, startPolling, stopPolling]);
return { refresh };
}

View File

@@ -0,0 +1,80 @@
/**
* Contacts + inbound request tracking.
*
* - Loads cached contacts from local storage on boot.
* - Subscribes to the address WS topic so a new CONTACT_REQUEST pulls the
* relay contact list immediately (sub-second UX).
* - Keeps a 30 s polling fallback for nodes without WS or while disconnected.
*/
import { useEffect, useCallback } from 'react';
import { fetchContactRequests } from '@/lib/api';
import { getWSClient } from '@/lib/ws';
import { loadContacts } from '@/lib/storage';
import { useStore } from '@/lib/store';
const FALLBACK_POLL_INTERVAL = 30_000;
export function useContacts() {
const keyFile = useStore(s => s.keyFile);
const setContacts = useStore(s => s.setContacts);
const setRequests = useStore(s => s.setRequests);
const contacts = useStore(s => s.contacts);
// Load cached contacts from local storage once
useEffect(() => {
loadContacts().then(setContacts);
}, [setContacts]);
const pollRequests = useCallback(async () => {
if (!keyFile) return;
try {
const raw = await fetchContactRequests(keyFile.pub_key);
// Filter out already-accepted contacts
const contactAddresses = new Set(contacts.map(c => c.address));
const requests = raw
.filter(r => r.status === 'pending' && !contactAddresses.has(r.requester_pub))
.map(r => ({
from: r.requester_pub,
// x25519Pub will be fetched from identity when user taps Accept
x25519Pub: '',
intro: r.intro ?? '',
timestamp: r.created_at,
txHash: r.tx_id,
}));
setRequests(requests);
} catch {
// Ignore transient network errors
}
}, [keyFile, contacts, setRequests]);
useEffect(() => {
if (!keyFile) return;
const ws = getWSClient();
// Initial load + low-frequency fallback poll (covers missed WS events,
// works even when the node has no WS endpoint).
pollRequests();
const interval = setInterval(pollRequests, FALLBACK_POLL_INTERVAL);
// Immediate refresh when a CONTACT_REQUEST / ACCEPT_CONTACT tx addressed
// to us lands on-chain. WS fan-out already filters to our address topic.
const off = ws.subscribe('addr:' + keyFile.pub_key, (frame) => {
if (frame.event === 'tx') {
const d = frame.data as { tx_type?: string } | undefined;
if (d?.tx_type === 'CONTACT_REQUEST' || d?.tx_type === 'ACCEPT_CONTACT') {
pollRequests();
}
}
});
ws.connect();
return () => {
clearInterval(interval);
off();
};
}, [keyFile, pollRequests]);
}

View File

@@ -0,0 +1,123 @@
/**
* Subscribe to the relay inbox via WebSocket and decrypt incoming envelopes
* for the active chat. Falls back to 30-second polling whenever the WS is
* not connected — preserves correctness on older nodes or flaky networks.
*
* Flow:
* 1. On mount: one HTTP fetch so we have whatever is already in the inbox
* 2. Subscribe to topic `inbox:<my_x25519>` — the node pushes a summary
* for each fresh envelope as soon as mailbox.Store() succeeds
* 3. On each push, pull the full envelope list (cheap — bounded by
* MailboxPerRecipientCap) and decrypt anything we haven't seen yet
* 4. If WS disconnects for > 15 seconds, start a 30 s HTTP poll until it
* reconnects
*/
import { useEffect, useCallback, useRef } from 'react';
import { fetchInbox } from '@/lib/api';
import { getWSClient } from '@/lib/ws';
import { decryptMessage } from '@/lib/crypto';
import { appendMessage } from '@/lib/storage';
import { useStore } from '@/lib/store';
const FALLBACK_POLL_INTERVAL = 30_000; // HTTP poll when WS is down
const WS_GRACE_BEFORE_POLLING = 15_000; // don't start polling immediately on disconnect
export function useMessages(contactX25519: string) {
const keyFile = useStore(s => s.keyFile);
const appendMsg = useStore(s => s.appendMessage);
const pullAndDecrypt = useCallback(async () => {
if (!keyFile || !contactX25519) return;
try {
const envelopes = await fetchInbox(keyFile.x25519_pub);
for (const env of envelopes) {
// Only process messages from this contact
if (env.sender_pub !== contactX25519) continue;
const text = decryptMessage(
env.ciphertext,
env.nonce,
env.sender_pub,
keyFile.x25519_priv,
);
if (!text) continue;
const msg = {
id: `${env.sender_pub}_${env.timestamp}_${env.nonce.slice(0, 8)}`,
from: env.sender_pub,
text,
timestamp: env.timestamp,
mine: false,
};
appendMsg(contactX25519, msg);
await appendMessage(contactX25519, msg);
}
} catch (e) {
// Don't surface inbox errors aggressively — next event or poll retries
console.warn('[useMessages] pull error:', e);
}
}, [keyFile, contactX25519, appendMsg]);
// ── Fallback polling state ────────────────────────────────────────────
const pollTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const disconnectTORef = useRef<ReturnType<typeof setTimeout> | null>(null);
const startPolling = useCallback(() => {
if (pollTimerRef.current) return;
console.log('[useMessages] WS down — starting HTTP poll fallback');
pullAndDecrypt();
pollTimerRef.current = setInterval(pullAndDecrypt, FALLBACK_POLL_INTERVAL);
}, [pullAndDecrypt]);
const stopPolling = useCallback(() => {
if (pollTimerRef.current) {
clearInterval(pollTimerRef.current);
pollTimerRef.current = null;
}
if (disconnectTORef.current) {
clearTimeout(disconnectTORef.current);
disconnectTORef.current = null;
}
}, []);
useEffect(() => {
if (!keyFile || !contactX25519) return;
const ws = getWSClient();
// Initial fetch — populate whatever landed before we mounted.
pullAndDecrypt();
// Subscribe to our x25519 inbox — the node emits on mailbox.Store.
// Topic filter: only envelopes for ME; we then filter by sender inside
// the handler so we only render messages in THIS chat.
const offInbox = ws.subscribe('inbox:' + keyFile.x25519_pub, (frame) => {
if (frame.event !== 'inbox') return;
const d = frame.data as { sender_pub?: string } | undefined;
// Optimisation: if the envelope is from a different peer, skip the
// whole refetch — we'd just drop it in the sender filter below anyway.
if (d?.sender_pub && d.sender_pub !== contactX25519) return;
pullAndDecrypt();
});
// Manage fallback polling based on WS connection state.
const offConn = ws.onConnectionChange((ok) => {
if (ok) {
stopPolling();
// Catch up anything we missed while disconnected.
pullAndDecrypt();
} else if (disconnectTORef.current === null) {
disconnectTORef.current = setTimeout(startPolling, WS_GRACE_BEFORE_POLLING);
}
});
ws.connect();
return () => {
offInbox();
offConn();
stopPolling();
};
}, [keyFile, contactX25519, pullAndDecrypt, startPolling, stopPolling]);
}

View File

@@ -0,0 +1,61 @@
/**
* Auto-discover canonical system contracts from the node so the user doesn't
* have to paste contract IDs into settings by hand.
*
* Flow:
* 1. On app boot (and whenever nodeUrl changes), call GET /api/well-known-contracts
* 2. If the node advertises a `username_registry` and the user has not
* manually set `settings.contractId`, auto-populate it and persist.
* 3. A user-supplied contractId is never overwritten — so power users can
* still pin a non-canonical deployment from settings.
*/
import { useEffect } from 'react';
import { fetchWellKnownContracts } from '@/lib/api';
import { saveSettings } from '@/lib/storage';
import { useStore } from '@/lib/store';
export function useWellKnownContracts() {
const nodeUrl = useStore(s => s.settings.nodeUrl);
const contractId = useStore(s => s.settings.contractId);
const settings = useStore(s => s.settings);
const setSettings = useStore(s => s.setSettings);
useEffect(() => {
let cancelled = false;
async function run() {
if (!nodeUrl) return;
const res = await fetchWellKnownContracts();
if (cancelled || !res) return;
const registry = res.contracts['username_registry'];
if (!registry) return;
// Always keep the stored contractId in sync with what the node reports
// as canonical. If the user resets their chain or we migrate from a
// WASM contract to the native one, the stale contract_id cached in
// local storage would otherwise keep the client trying to call a
// contract that no longer exists on this chain.
//
// To still support intentional overrides: the UI's "advanced" section
// allows pasting a specific ID — and since that also writes to
// settings.contractId, the loop converges back to whatever the node
// says after a short delay. Operators who want a hard override should
// either run a patched node or pin the value with a wrapper config
// outside the app.
if (registry.contract_id !== contractId) {
const next = { ...settings, contractId: registry.contract_id };
setSettings({ contractId: registry.contract_id });
await saveSettings(next);
console.log('[well-known] synced username_registry =', registry.contract_id,
'(was:', contractId || '<empty>', ')');
}
}
run();
return () => { cancelled = true; };
// Re-run when the node URL changes (user switched networks) or when
// contractId is cleared.
}, [nodeUrl, contractId]); // eslint-disable-line react-hooks/exhaustive-deps
}

701
client-app/lib/api.ts Normal file
View File

@@ -0,0 +1,701 @@
/**
* DChain REST API client.
* All requests go to the configured node URL (e.g. http://192.168.1.10:8081).
*/
import type { Envelope, TxRecord, NetStats, Contact } from './types';
// ─── Base ─────────────────────────────────────────────────────────────────────
let _nodeUrl = 'http://localhost:8081';
/**
* Listeners invoked AFTER _nodeUrl changes. The WS client registers here so
* that switching nodes in Settings tears down the old socket and re-dials
* the new one (without this, a user who pointed their app at node A would
* keep receiving A's events forever after flipping to B).
*/
const nodeUrlListeners = new Set<(url: string) => void>();
export function setNodeUrl(url: string) {
const normalised = url.replace(/\/$/, '');
if (_nodeUrl === normalised) return;
_nodeUrl = normalised;
for (const fn of nodeUrlListeners) {
try { fn(_nodeUrl); } catch { /* ignore — listeners are best-effort */ }
}
}
export function getNodeUrl(): string {
return _nodeUrl;
}
/** Register a callback for node-URL changes. Returns an unsubscribe fn. */
export function onNodeUrlChange(fn: (url: string) => void): () => void {
nodeUrlListeners.add(fn);
return () => { nodeUrlListeners.delete(fn); };
}
async function get<T>(path: string): Promise<T> {
const res = await fetch(`${_nodeUrl}${path}`);
if (!res.ok) throw new Error(`GET ${path}${res.status}`);
return res.json() as Promise<T>;
}
/**
* Enhanced error reporter for POST failures. The node's `jsonErr` writes
* `{"error": "..."}` as the response body; we parse that out so the UI layer
* can show a meaningful message instead of a raw status code.
*
* Rate-limit and timestamp-skew rejections produce specific strings the UI
* can translate to user-friendly Russian via matcher functions below.
*/
async function post<T>(path: string, body: unknown): Promise<T> {
const res = await fetch(`${_nodeUrl}${path}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!res.ok) {
const text = await res.text();
// Try to extract {"error":"..."} payload for a cleaner message.
let detail = text;
try {
const parsed = JSON.parse(text);
if (parsed?.error) detail = parsed.error;
} catch { /* keep raw text */ }
// Include HTTP status so `humanizeTxError` can branch on 429/400/etc.
throw new Error(`${res.status}: ${detail}`);
}
return res.json() as Promise<T>;
}
/**
* Turn a submission error from `post()` / `submitTx()` into a user-facing
* Russian message with actionable hints. Preserves the raw detail at the end
* so advanced users can still copy the original for support.
*/
export function humanizeTxError(e: unknown): string {
const raw = e instanceof Error ? e.message : String(e);
if (raw.startsWith('429')) {
return 'Слишком много запросов к ноде. Подождите пару секунд и попробуйте снова.';
}
if (raw.startsWith('400') && raw.includes('timestamp')) {
return 'Часы устройства не синхронизированы с нодой. Проверьте время на телефоне (±1 час).';
}
if (raw.startsWith('400') && raw.includes('signature')) {
return 'Подпись транзакции невалидна. Попробуйте ещё раз; если не помогает — вероятна несовместимость версий клиента и ноды.';
}
if (raw.startsWith('400')) {
return `Нода отклонила транзакцию: ${raw.replace(/^400:\s*/, '')}`;
}
if (raw.startsWith('5')) {
return `Ошибка ноды (${raw}). Попробуйте позже.`;
}
// Network-level
if (raw.toLowerCase().includes('network request failed')) {
return 'Нет связи с нодой. Проверьте URL в настройках и доступность сервера.';
}
return raw;
}
// ─── Chain API ────────────────────────────────────────────────────────────────
export async function getNetStats(): Promise<NetStats> {
return get<NetStats>('/api/netstats');
}
interface AddrResponse {
balance_ut: number;
balance: string;
transactions: Array<{
id: string;
type: string;
from: string;
to?: string;
amount_ut: number;
fee_ut: number;
time: string; // ISO-8601 e.g. "2025-01-01T12:00:00Z"
block_index: number;
}>;
tx_count: number;
has_more: boolean;
}
export async function getBalance(pubkey: string): Promise<number> {
const data = await get<AddrResponse>(`/api/address/${pubkey}`);
return data.balance_ut ?? 0;
}
/**
* Transaction as sent to /api/tx — maps 1-to-1 to blockchain.Transaction JSON.
* Key facts:
* - `payload` is base64-encoded JSON bytes (Go []byte → base64 in JSON)
* - `signature` is base64-encoded Ed25519 sig (Go []byte → base64 in JSON)
* - `timestamp` is RFC3339 string (Go time.Time → string in JSON)
* - There is NO nonce field; dedup is by `id`
*/
export interface RawTx {
id: string; // "tx-<nanoseconds>" or sha256-based
type: string; // "TRANSFER", "CONTACT_REQUEST", etc.
from: string; // hex Ed25519 pub key
to: string; // hex Ed25519 pub key (empty string if N/A)
amount: number; // µT (uint64)
fee: number; // µT (uint64)
memo?: string; // optional
payload: string; // base64(json.Marshal(TypeSpecificPayload))
signature: string; // base64(ed25519.Sign(canonical_bytes, priv))
timestamp: string; // RFC3339 e.g. "2025-01-01T12:00:00Z"
}
export async function submitTx(tx: RawTx): Promise<{ id: string; status: string }> {
console.log('[submitTx] →', {
id: tx.id,
type: tx.type,
from: tx.from.slice(0, 12) + '…',
to: tx.to ? tx.to.slice(0, 12) + '…' : '',
amount: tx.amount,
fee: tx.fee,
timestamp: tx.timestamp,
transport: 'auto',
});
// Try the WebSocket path first: no HTTP round-trip, and we get a proper
// submit_ack correlated back to our tx id. Falls through to HTTP if WS is
// unavailable (old node, disconnected, timeout, etc.) so legacy setups
// keep working.
try {
// Lazy import avoids a circular dep with lib/ws.ts (which itself
// imports getNodeUrl from this module).
const { getWSClient } = await import('./ws');
const ws = getWSClient();
if (ws.isConnected()) {
try {
const res = await ws.submitTx(tx);
console.log('[submitTx] ← accepted via WS', res);
return { id: res.id || tx.id, status: 'accepted' };
} catch (e) {
console.warn('[submitTx] WS path failed, falling back to HTTP:', e);
}
}
} catch { /* circular import edge case — ignore and use HTTP */ }
try {
const res = await post<{ id: string; status: string }>('/api/tx', tx);
console.log('[submitTx] ← accepted via HTTP', res);
return res;
} catch (e) {
console.warn('[submitTx] ← rejected', e);
throw e;
}
}
export async function getTxHistory(pubkey: string, limit = 50): Promise<TxRecord[]> {
const data = await get<AddrResponse>(`/api/address/${pubkey}?limit=${limit}`);
return (data.transactions ?? []).map(tx => ({
hash: tx.id,
type: tx.type,
from: tx.from,
to: tx.to,
amount: tx.amount_ut,
fee: tx.fee_ut,
// Convert ISO-8601 string → unix seconds
timestamp: tx.time ? Math.floor(new Date(tx.time).getTime() / 1000) : 0,
status: 'confirmed' as const,
}));
}
// ─── Relay API ────────────────────────────────────────────────────────────────
export interface SendEnvelopeReq {
sender_pub: string;
recipient_pub: string;
nonce: string;
ciphertext: string;
}
export async function sendEnvelope(env: SendEnvelopeReq): Promise<{ ok: boolean }> {
return post<{ ok: boolean }>('/api/relay/send', env);
}
export async function fetchInbox(x25519PubHex: string): Promise<Envelope[]> {
return get<Envelope[]>(`/api/relay/inbox?pub=${x25519PubHex}`);
}
// ─── Contact requests (on-chain) ─────────────────────────────────────────────
/**
* Maps blockchain.ContactInfo returned by GET /api/relay/contacts?pub=...
* The response shape is { pub, count, contacts: ContactInfo[] }.
*/
export interface ContactRequestRaw {
requester_pub: string; // Ed25519 pubkey of requester
requester_addr: string; // DChain address (DC…)
status: string; // "pending" | "accepted" | "blocked"
intro: string; // plaintext intro message (may be empty)
fee_ut: number; // anti-spam fee paid in µT
tx_id: string; // transaction ID
created_at: number; // unix seconds
}
export async function fetchContactRequests(edPubHex: string): Promise<ContactRequestRaw[]> {
const data = await get<{ contacts: ContactRequestRaw[] }>(`/api/relay/contacts?pub=${edPubHex}`);
return data.contacts ?? [];
}
// ─── Identity API ─────────────────────────────────────────────────────────────
export interface IdentityInfo {
pub_key: string;
address: string;
x25519_pub: string; // hex Curve25519 key; empty string if not published
nickname: string;
registered: boolean;
}
/** Fetch identity info for any pubkey or DC address. Returns null on 404. */
export async function getIdentity(pubkeyOrAddr: string): Promise<IdentityInfo | null> {
try {
return await get<IdentityInfo>(`/api/identity/${pubkeyOrAddr}`);
} catch {
return null;
}
}
// ─── Contract API ─────────────────────────────────────────────────────────────
/**
* Response shape from GET /api/contracts/{id}/state/{key}.
* The node handler (node/api_contract.go:handleContractState) returns either:
* { value_b64: null, value_hex: null, ... } when the key is missing
* or
* { value_b64: "...", value_hex: "...", value_u64?: 0 } when the key exists.
*/
interface ContractStateResponse {
contract_id: string;
key: string;
value_b64: string | null;
value_hex: string | null;
value_u64?: number;
}
/**
* Decode a hex string (lowercase/uppercase) back to the original string value
* it represents. The username registry contract stores values as plain ASCII
* bytes (pubkey hex strings / username strings), so `value_hex` on the wire
* is the hex-encoding of UTF-8 bytes. We hex-decode to bytes, then interpret
* those bytes as UTF-8.
*/
function hexToUtf8(hex: string): string {
if (hex.length % 2 !== 0) return '';
const bytes = new Uint8Array(hex.length / 2);
for (let i = 0; i < hex.length; i += 2) {
bytes[i / 2] = parseInt(hex.substr(i, 2), 16);
}
// TextDecoder is available in Hermes / RN's JS runtime.
try {
return new TextDecoder('utf-8').decode(bytes);
} catch {
// Fallback for environments without TextDecoder.
let s = '';
for (const b of bytes) s += String.fromCharCode(b);
return s;
}
}
/** username → address (hex pubkey). Returns null if unregistered. */
export async function resolveUsername(contractId: string, username: string): Promise<string | null> {
try {
const data = await get<ContractStateResponse>(`/api/contracts/${contractId}/state/name:${username}`);
if (!data.value_hex) return null;
const decoded = hexToUtf8(data.value_hex).trim();
return decoded || null;
} catch {
return null;
}
}
/** address (hex pubkey) → username. Returns null if this address hasn't registered a name. */
export async function reverseResolve(contractId: string, address: string): Promise<string | null> {
try {
const data = await get<ContractStateResponse>(`/api/contracts/${contractId}/state/addr:${address}`);
if (!data.value_hex) return null;
const decoded = hexToUtf8(data.value_hex).trim();
return decoded || null;
} catch {
return null;
}
}
// ─── Well-known contracts ─────────────────────────────────────────────────────
/**
* Per-entry shape returned by GET /api/well-known-contracts.
* Matches node/api_well_known.go:WellKnownContract.
*/
export interface WellKnownContract {
contract_id: string;
name: string;
version?: string;
deployed_at: number;
}
/**
* Response from GET /api/well-known-contracts.
* `contracts` is keyed by ABI name (e.g. "username_registry").
*/
export interface WellKnownResponse {
count: number;
contracts: Record<string, WellKnownContract>;
}
/**
* Fetch the node's view of canonical system contracts so the client doesn't
* have to force the user to paste contract IDs into settings.
*
* The node returns the earliest-deployed contract per ABI name; this means
* every peer in the same chain reports the same mapping.
*
* Returns `null` on failure (old node, network hiccup, endpoint missing).
*/
export async function fetchWellKnownContracts(): Promise<WellKnownResponse | null> {
try {
return await get<WellKnownResponse>('/api/well-known-contracts');
} catch {
return null;
}
}
// ─── Node version / update-check ─────────────────────────────────────────────
//
// The three calls below let the client:
// 1. fetchNodeVersion() — see what tag/commit/features the connected node
// exposes. Used on first boot + on every chain-switch so we can warn if
// a required feature is missing.
// 2. checkNodeVersion(required) — thin wrapper that returns {supported,
// missing} by diffing a client-expected feature list against the node's.
// 3. fetchUpdateCheck() — ask the node whether its operator has a newer
// release available from their configured release source (Gitea). For
// messenger UX this is purely informational ("the node you're on is N
// versions behind"), never used to update the node automatically.
/** The shape returned by GET /api/well-known-version. */
export interface NodeVersionInfo {
node_version: string;
protocol_version: number;
features: string[];
chain_id?: string;
build?: {
tag: string;
commit: string;
date: string;
dirty: string;
};
}
/** Client-expected protocol version. Bumped only when wire-protocol breaks. */
export const CLIENT_PROTOCOL_VERSION = 1;
/**
* Minimum feature set this client build relies on. A node missing any of
* these is considered "unsupported" — caller should surface an upgrade
* prompt to the user instead of silently failing on the first feature call.
*/
export const CLIENT_REQUIRED_FEATURES = [
'chain_id',
'identity_registry',
'onboarding_api',
'relay_mailbox',
'ws_submit_tx',
];
/** GET /api/well-known-version. Returns null on failure (old node, network hiccup). */
export async function fetchNodeVersion(): Promise<NodeVersionInfo | null> {
try {
return await get<NodeVersionInfo>('/api/well-known-version');
} catch {
return null;
}
}
/**
* Check whether the connected node supports this client's required features
* and protocol version. Returns a decision blob the UI can render directly.
*
* { supported: true } → everything fine
* { supported: false, reason: "...", ... } → show update prompt
* { supported: null, reason: "unreachable" } → couldn't reach the endpoint,
* likely old node — assume OK
* but warn quietly.
*/
export async function checkNodeVersion(
required: string[] = CLIENT_REQUIRED_FEATURES,
): Promise<{
supported: boolean | null;
reason?: string;
missing?: string[];
info?: NodeVersionInfo;
}> {
const info = await fetchNodeVersion();
if (!info) {
return { supported: null, reason: 'unreachable' };
}
if (info.protocol_version !== CLIENT_PROTOCOL_VERSION) {
return {
supported: false,
reason: `protocol v${info.protocol_version} but client expects v${CLIENT_PROTOCOL_VERSION}`,
info,
};
}
const have = new Set(info.features || []);
const missing = required.filter((f) => !have.has(f));
if (missing.length > 0) {
return {
supported: false,
reason: `node missing features: ${missing.join(', ')}`,
missing,
info,
};
}
return { supported: true, info };
}
/** The shape returned by GET /api/update-check. */
export interface UpdateCheckResponse {
current: { tag: string; commit: string; date: string; dirty: string };
latest?: { tag: string; commit?: string; url?: string; published_at?: string };
update_available: boolean;
checked_at: string;
source?: string;
}
/**
* GET /api/update-check. Returns null when:
* - the node operator hasn't configured DCHAIN_UPDATE_SOURCE_URL (503),
* - upstream Gitea call failed (502),
* - request errored out.
* All three are non-fatal for the client; the UI just doesn't render the
* "update available" banner.
*/
export async function fetchUpdateCheck(): Promise<UpdateCheckResponse | null> {
try {
return await get<UpdateCheckResponse>('/api/update-check');
} catch {
return null;
}
}
// ─── Transaction builder helpers ─────────────────────────────────────────────
import { signBase64, bytesToBase64 } from './crypto';
/** Minimum blockchain tx fee paid to the block validator (matches blockchain.MinFee = 1000 µT). */
const MIN_TX_FEE = 1000;
const _encoder = new TextEncoder();
/** RFC3339 timestamp with second precision — matches Go time.Time JSON output. */
function rfc3339Now(): string {
const d = new Date();
d.setMilliseconds(0);
// toISOString() gives "2025-01-01T12:00:00.000Z" → replace ".000Z" with "Z"
return d.toISOString().replace('.000Z', 'Z');
}
/** Unique transaction ID (nanoseconds-like using Date.now + random). */
function newTxID(): string {
return `tx-${Date.now()}${Math.floor(Math.random() * 1_000_000)}`;
}
/**
* Canonical bytes for signing — must match identity.txSignBytes in Go exactly.
*
* Go struct field order: id, type, from, to, amount, fee, payload, timestamp.
* JS JSON.stringify preserves insertion order, so we rely on that here.
*/
function txCanonicalBytes(tx: {
id: string; type: string; from: string; to: string;
amount: number; fee: number; payload: string; timestamp: string;
}): Uint8Array {
const s = JSON.stringify({
id: tx.id,
type: tx.type,
from: tx.from,
to: tx.to,
amount: tx.amount,
fee: tx.fee,
payload: tx.payload,
timestamp: tx.timestamp,
});
return _encoder.encode(s);
}
/** Encode a JS string (UTF-8) to base64. */
function strToBase64(s: string): string {
return bytesToBase64(_encoder.encode(s));
}
export function buildTransferTx(params: {
from: string;
to: string;
amount: number;
fee: number;
privKey: string;
memo?: string;
}): RawTx {
const id = newTxID();
const timestamp = rfc3339Now();
const payloadObj = params.memo ? { memo: params.memo } : {};
const payload = strToBase64(JSON.stringify(payloadObj));
const canonical = txCanonicalBytes({
id, type: 'TRANSFER', from: params.from, to: params.to,
amount: params.amount, fee: params.fee, payload, timestamp,
});
return {
id, type: 'TRANSFER', from: params.from, to: params.to,
amount: params.amount, fee: params.fee,
memo: params.memo,
payload, timestamp,
signature: signBase64(canonical, params.privKey),
};
}
/**
* CONTACT_REQUEST transaction.
*
* blockchain.Transaction fields:
* Amount = contactFee — anti-spam fee, paid directly to recipient (>= 5000 µT)
* Fee = MIN_TX_FEE — blockchain tx fee to the block validator (1000 µT)
* Payload = ContactRequestPayload { intro? } as base64 JSON bytes
*/
export function buildContactRequestTx(params: {
from: string; // sender Ed25519 pubkey
to: string; // recipient Ed25519 pubkey
contactFee: number; // anti-spam amount paid to recipient (>= 5000 µT)
intro?: string; // optional plaintext intro message (≤ 280 chars)
privKey: string;
}): RawTx {
const id = newTxID();
const timestamp = rfc3339Now();
// Payload matches ContactRequestPayload{Intro: "..."} in Go
const payloadObj = params.intro ? { intro: params.intro } : {};
const payload = strToBase64(JSON.stringify(payloadObj));
const canonical = txCanonicalBytes({
id, type: 'CONTACT_REQUEST', from: params.from, to: params.to,
amount: params.contactFee, fee: MIN_TX_FEE, payload, timestamp,
});
return {
id, type: 'CONTACT_REQUEST', from: params.from, to: params.to,
amount: params.contactFee, fee: MIN_TX_FEE, payload, timestamp,
signature: signBase64(canonical, params.privKey),
};
}
/**
* ACCEPT_CONTACT transaction.
* AcceptContactPayload is an empty struct in Go — no fields needed.
*/
export function buildAcceptContactTx(params: {
from: string; // acceptor Ed25519 pubkey (us — the recipient of the request)
to: string; // requester Ed25519 pubkey
privKey: string;
}): RawTx {
const id = newTxID();
const timestamp = rfc3339Now();
const payload = strToBase64(JSON.stringify({})); // AcceptContactPayload{}
const canonical = txCanonicalBytes({
id, type: 'ACCEPT_CONTACT', from: params.from, to: params.to,
amount: 0, fee: MIN_TX_FEE, payload, timestamp,
});
return {
id, type: 'ACCEPT_CONTACT', from: params.from, to: params.to,
amount: 0, fee: MIN_TX_FEE, payload, timestamp,
signature: signBase64(canonical, params.privKey),
};
}
// ─── Contract call ────────────────────────────────────────────────────────────
/** Minimum base fee for CALL_CONTRACT (matches blockchain.MinCallFee). */
const MIN_CALL_FEE = 1000;
/**
* CALL_CONTRACT transaction.
*
* Payload shape (CallContractPayload):
* { contract_id, method, args_json?, gas_limit }
*
* `amount` is the payment attached to the call and made available to the
* contract as `tx.Amount`. Whether it's collected depends on the contract
* — e.g. username_registry.register requires exactly 10_000 µT. Contracts
* that don't need payment should be called with `amount: 0` (default).
*
* The on-chain tx envelope carries `amount` openly, so the explorer shows
* the exact cost of a call rather than hiding it in a contract-internal
* debit — this was the UX motivation for this field.
*
* `fee` is the NETWORK fee paid to the block validator (not the contract).
* `gas` costs are additional and billed at the live gas price.
*/
export function buildCallContractTx(params: {
from: string;
contractId: string;
method: string;
args?: unknown[]; // JSON-serializable arguments
amount?: number; // µT attached to the call (default 0)
gasLimit?: number; // default 1_000_000
privKey: string;
}): RawTx {
const id = newTxID();
const timestamp = rfc3339Now();
const amount = params.amount ?? 0;
const argsJson = params.args && params.args.length > 0
? JSON.stringify(params.args)
: '';
const payloadObj = {
contract_id: params.contractId,
method: params.method,
args_json: argsJson,
gas_limit: params.gasLimit ?? 1_000_000,
};
const payload = strToBase64(JSON.stringify(payloadObj));
const canonical = txCanonicalBytes({
id, type: 'CALL_CONTRACT', from: params.from, to: '',
amount, fee: MIN_CALL_FEE, payload, timestamp,
});
return {
id, type: 'CALL_CONTRACT', from: params.from, to: '',
amount, fee: MIN_CALL_FEE, payload, timestamp,
signature: signBase64(canonical, params.privKey),
};
}
/**
* Flat registration fee for a username, in µT.
*
* The native username_registry charges a single flat fee (10 000 µT = 0.01 T)
* per register() call regardless of name length, replacing the earlier
* length-based formula. Flat pricing is easier to communicate and the
* 4-char minimum (enforced both in the client UI and the on-chain contract)
* already removes the squatting pressure that tiered pricing mitigated.
*/
export const USERNAME_REGISTRATION_FEE = 10_000;
/** Minimum/maximum allowed username length. Match blockchain/native_username.go. */
export const MIN_USERNAME_LENGTH = 4;
export const MAX_USERNAME_LENGTH = 32;
/** @deprecated Kept for backward compatibility; always returns the flat fee. */
export function usernameRegistrationFee(_name: string): number {
return USERNAME_REGISTRATION_FEE;
}

156
client-app/lib/crypto.ts Normal file
View File

@@ -0,0 +1,156 @@
/**
* Cryptographic operations for DChain messenger.
*
* Ed25519 — transaction signing (via TweetNaCl sign)
* X25519 — Diffie-Hellman key exchange for NaCl box
* NaCl box — authenticated encryption for relay messages
*/
import nacl from 'tweetnacl';
import { decodeUTF8, encodeUTF8 } from 'tweetnacl-util';
import { getRandomBytes } from 'expo-crypto';
import type { KeyFile } from './types';
// ─── PRNG ─────────────────────────────────────────────────────────────────────
// TweetNaCl looks for window.crypto which doesn't exist in React Native/Hermes.
// Wire nacl to expo-crypto which uses the platform's secure RNG natively.
nacl.setPRNG((output: Uint8Array, length: number) => {
const bytes = getRandomBytes(length);
for (let i = 0; i < length; i++) output[i] = bytes[i];
});
// ─── Helpers ──────────────────────────────────────────────────────────────────
export function hexToBytes(hex: string): Uint8Array {
if (hex.length % 2 !== 0) throw new Error('odd hex length');
const bytes = new Uint8Array(hex.length / 2);
for (let i = 0; i < bytes.length; i++) {
bytes[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
}
return bytes;
}
export function bytesToHex(bytes: Uint8Array): string {
return Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join('');
}
// ─── Key generation ───────────────────────────────────────────────────────────
/**
* Generate a new identity: Ed25519 signing keys + X25519 encryption keys.
* Returns a KeyFile compatible with the Go node format.
*/
export function generateKeyFile(): KeyFile {
// Ed25519 for signing / blockchain identity
const signKP = nacl.sign.keyPair();
// X25519 for NaCl box encryption
// nacl.box.keyPair() returns Curve25519 keys
const boxKP = nacl.box.keyPair();
return {
pub_key: bytesToHex(signKP.publicKey),
priv_key: bytesToHex(signKP.secretKey),
x25519_pub: bytesToHex(boxKP.publicKey),
x25519_priv: bytesToHex(boxKP.secretKey),
};
}
// ─── NaCl box encryption ──────────────────────────────────────────────────────
/**
* Encrypt a plaintext message using NaCl box.
* Sender uses their X25519 secret key + recipient's X25519 public key.
* Returns { nonce, ciphertext } as hex strings.
*/
export function encryptMessage(
plaintext: string,
senderSecretHex: string,
recipientPubHex: string,
): { nonce: string; ciphertext: string } {
const nonce = nacl.randomBytes(nacl.box.nonceLength);
const message = decodeUTF8(plaintext);
const secretKey = hexToBytes(senderSecretHex);
const publicKey = hexToBytes(recipientPubHex);
const box = nacl.box(message, nonce, publicKey, secretKey);
return {
nonce: bytesToHex(nonce),
ciphertext: bytesToHex(box),
};
}
/**
* Decrypt a NaCl box.
* Recipient uses their X25519 secret key + sender's X25519 public key.
*/
export function decryptMessage(
ciphertextHex: string,
nonceHex: string,
senderPubHex: string,
recipientSecHex: string,
): string | null {
try {
const ciphertext = hexToBytes(ciphertextHex);
const nonce = hexToBytes(nonceHex);
const senderPub = hexToBytes(senderPubHex);
const secretKey = hexToBytes(recipientSecHex);
const plain = nacl.box.open(ciphertext, nonce, senderPub, secretKey);
if (!plain) return null;
return encodeUTF8(plain);
} catch {
return null;
}
}
// ─── Ed25519 signing ──────────────────────────────────────────────────────────
/**
* Sign arbitrary data with the Ed25519 private key.
* Returns signature as hex.
*/
export function sign(data: Uint8Array, privKeyHex: string): string {
const secretKey = hexToBytes(privKeyHex);
const sig = nacl.sign.detached(data, secretKey);
return bytesToHex(sig);
}
/**
* Sign arbitrary data with the Ed25519 private key.
* Returns signature as base64 — this is the format the Go blockchain node
* expects ([]byte fields are base64 in JSON).
*/
export function signBase64(data: Uint8Array, privKeyHex: string): string {
const secretKey = hexToBytes(privKeyHex);
const sig = nacl.sign.detached(data, secretKey);
return bytesToBase64(sig);
}
/** Encode bytes as base64. Works on Hermes (btoa is available since RN 0.71). */
export function bytesToBase64(bytes: Uint8Array): string {
let binary = '';
for (let i = 0; i < bytes.length; i++) {
binary += String.fromCharCode(bytes[i]);
}
return btoa(binary);
}
/**
* Verify an Ed25519 signature.
*/
export function verify(data: Uint8Array, sigHex: string, pubKeyHex: string): boolean {
try {
return nacl.sign.detached.verify(data, hexToBytes(sigHex), hexToBytes(pubKeyHex));
} catch {
return false;
}
}
// ─── Address helpers ──────────────────────────────────────────────────────────
/** Truncate a long hex address for display: 8...8 */
export function shortAddr(hex: string, chars = 8): string {
if (hex.length <= chars * 2 + 3) return hex;
return `${hex.slice(0, chars)}${hex.slice(-chars)}`;
}

101
client-app/lib/storage.ts Normal file
View File

@@ -0,0 +1,101 @@
/**
* Persistent storage for keys and app settings.
* On mobile: expo-secure-store for key material, AsyncStorage for settings.
* On web: falls back to localStorage (dev only).
*/
import * as SecureStore from 'expo-secure-store';
import AsyncStorage from '@react-native-async-storage/async-storage';
import type { KeyFile, Contact, NodeSettings } from './types';
// ─── Keys ─────────────────────────────────────────────────────────────────────
const KEYFILE_KEY = 'dchain_keyfile';
const CONTACTS_KEY = 'dchain_contacts';
const SETTINGS_KEY = 'dchain_settings';
const CHATS_KEY = 'dchain_chats';
/** Save the key file in secure storage (encrypted on device). */
export async function saveKeyFile(kf: KeyFile): Promise<void> {
await SecureStore.setItemAsync(KEYFILE_KEY, JSON.stringify(kf));
}
/** Load key file. Returns null if not set. */
export async function loadKeyFile(): Promise<KeyFile | null> {
const raw = await SecureStore.getItemAsync(KEYFILE_KEY);
if (!raw) return null;
return JSON.parse(raw) as KeyFile;
}
/** Delete key file (logout / factory reset). */
export async function deleteKeyFile(): Promise<void> {
await SecureStore.deleteItemAsync(KEYFILE_KEY);
}
// ─── Node settings ─────────────────────────────────────────────────────────────
const DEFAULT_SETTINGS: NodeSettings = {
nodeUrl: 'http://localhost:8081',
contractId: '',
};
export async function loadSettings(): Promise<NodeSettings> {
const raw = await AsyncStorage.getItem(SETTINGS_KEY);
if (!raw) return DEFAULT_SETTINGS;
return { ...DEFAULT_SETTINGS, ...JSON.parse(raw) };
}
export async function saveSettings(s: Partial<NodeSettings>): Promise<void> {
const current = await loadSettings();
await AsyncStorage.setItem(SETTINGS_KEY, JSON.stringify({ ...current, ...s }));
}
// ─── Contacts ─────────────────────────────────────────────────────────────────
export async function loadContacts(): Promise<Contact[]> {
const raw = await AsyncStorage.getItem(CONTACTS_KEY);
if (!raw) return [];
return JSON.parse(raw) as Contact[];
}
export async function saveContact(c: Contact): Promise<void> {
const contacts = await loadContacts();
const idx = contacts.findIndex(x => x.address === c.address);
if (idx >= 0) contacts[idx] = c;
else contacts.push(c);
await AsyncStorage.setItem(CONTACTS_KEY, JSON.stringify(contacts));
}
export async function deleteContact(address: string): Promise<void> {
const contacts = await loadContacts();
await AsyncStorage.setItem(
CONTACTS_KEY,
JSON.stringify(contacts.filter(c => c.address !== address)),
);
}
// ─── Message cache (per-chat local store) ────────────────────────────────────
export interface CachedMessage {
id: string;
from: string;
text: string;
timestamp: number;
mine: boolean;
}
export async function loadMessages(chatId: string): Promise<CachedMessage[]> {
const raw = await AsyncStorage.getItem(`${CHATS_KEY}_${chatId}`);
if (!raw) return [];
return JSON.parse(raw) as CachedMessage[];
}
export async function appendMessage(chatId: string, msg: CachedMessage): Promise<void> {
const msgs = await loadMessages(chatId);
// Deduplicate by id
if (msgs.find(m => m.id === msg.id)) return;
msgs.push(msg);
// Keep last 500 messages per chat
const trimmed = msgs.slice(-500);
await AsyncStorage.setItem(`${CHATS_KEY}_${chatId}`, JSON.stringify(trimmed));
}

103
client-app/lib/store.ts Normal file
View File

@@ -0,0 +1,103 @@
/**
* Global app state via Zustand.
* Keeps runtime state; persistent data lives in storage.ts.
*/
import { create } from 'zustand';
import type { KeyFile, Contact, Chat, Message, ContactRequest, NodeSettings } from './types';
interface AppState {
// Identity
keyFile: KeyFile | null;
username: string | null;
setKeyFile: (kf: KeyFile | null) => void;
setUsername: (u: string | null) => void;
// Node settings
settings: NodeSettings;
setSettings: (s: Partial<NodeSettings>) => void;
// Contacts
contacts: Contact[];
setContacts: (contacts: Contact[]) => void;
upsertContact: (c: Contact) => void;
// Chats (derived from contacts + messages)
chats: Chat[];
setChats: (chats: Chat[]) => void;
// Active chat messages
messages: Record<string, Message[]>; // key: contactAddress
setMessages: (chatId: string, msgs: Message[]) => void;
appendMessage: (chatId: string, msg: Message) => void;
// Contact requests (pending)
requests: ContactRequest[];
setRequests: (reqs: ContactRequest[]) => void;
// Balance
balance: number;
setBalance: (b: number) => void;
// Loading / error states
loading: boolean;
setLoading: (v: boolean) => void;
error: string | null;
setError: (e: string | null) => void;
// Nonce cache (to avoid refetching)
nonce: number;
setNonce: (n: number) => void;
}
export const useStore = create<AppState>((set, get) => ({
keyFile: null,
username: null,
setKeyFile: (kf) => set({ keyFile: kf }),
setUsername: (u) => set({ username: u }),
settings: {
nodeUrl: 'http://localhost:8081',
contractId: '',
},
setSettings: (s) => set(state => ({ settings: { ...state.settings, ...s } })),
contacts: [],
setContacts: (contacts) => set({ contacts }),
upsertContact: (c) => set(state => {
const idx = state.contacts.findIndex(x => x.address === c.address);
if (idx >= 0) {
const updated = [...state.contacts];
updated[idx] = c;
return { contacts: updated };
}
return { contacts: [...state.contacts, c] };
}),
chats: [],
setChats: (chats) => set({ chats }),
messages: {},
setMessages: (chatId, msgs) => set(state => ({
messages: { ...state.messages, [chatId]: msgs },
})),
appendMessage: (chatId, msg) => set(state => {
const current = state.messages[chatId] ?? [];
if (current.find(m => m.id === msg.id)) return {};
return { messages: { ...state.messages, [chatId]: [...current, msg] } };
}),
requests: [],
setRequests: (reqs) => set({ requests: reqs }),
balance: 0,
setBalance: (b) => set({ balance: b }),
loading: false,
setLoading: (v) => set({ loading: v }),
error: null,
setError: (e) => set({ error: e }),
nonce: 0,
setNonce: (n) => set({ nonce: n }),
}));

86
client-app/lib/types.ts Normal file
View File

@@ -0,0 +1,86 @@
// ─── Key material ────────────────────────────────────────────────────────────
export interface KeyFile {
pub_key: string; // hex Ed25519 public key (32 bytes)
priv_key: string; // hex Ed25519 private key (64 bytes)
x25519_pub: string; // hex X25519 public key (32 bytes)
x25519_priv: string; // hex X25519 private key (32 bytes)
}
// ─── Contact ─────────────────────────────────────────────────────────────────
export interface Contact {
address: string; // Ed25519 pubkey hex — blockchain address
x25519Pub: string; // X25519 pubkey hex — encryption key
username?: string; // @name from registry contract
alias?: string; // local nickname
addedAt: number; // unix ms
}
// ─── Messages ─────────────────────────────────────────────────────────────────
export interface Envelope {
sender_pub: string; // X25519 hex
recipient_pub: string; // X25519 hex
nonce: string; // hex 24 bytes
ciphertext: string; // hex NaCl box
timestamp: number; // unix seconds
}
export interface Message {
id: string;
from: string; // X25519 pubkey of sender
text: string;
timestamp: number;
mine: boolean;
}
// ─── Chat ────────────────────────────────────────────────────────────────────
export interface Chat {
contactAddress: string; // Ed25519 pubkey hex
contactX25519: string; // X25519 pubkey hex
username?: string;
alias?: string;
lastMessage?: string;
lastTime?: number;
unread: number;
}
// ─── Contact request ─────────────────────────────────────────────────────────
export interface ContactRequest {
from: string; // Ed25519 pubkey hex
x25519Pub: string; // X25519 pubkey hex; empty until fetched from identity
username?: string;
intro: string; // plaintext intro (stored on-chain)
timestamp: number;
txHash: string;
}
// ─── Transaction ─────────────────────────────────────────────────────────────
export interface TxRecord {
hash: string;
type: string;
from: string;
to?: string;
amount?: number;
fee: number;
timestamp: number;
status: 'confirmed' | 'pending';
}
// ─── Node info ───────────────────────────────────────────────────────────────
export interface NetStats {
total_blocks: number;
total_txs: number;
peer_count: number;
chain_id: string;
}
export interface NodeSettings {
nodeUrl: string;
contractId: string; // username_registry contract
}

35
client-app/lib/utils.ts Normal file
View File

@@ -0,0 +1,35 @@
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
/** Format µT amount to human-readable string */
export function formatAmount(microTokens: number | undefined | null): string {
if (microTokens == null) return '—';
if (microTokens >= 1_000_000) return `${(microTokens / 1_000_000).toFixed(2)} T`;
if (microTokens >= 1_000) return `${(microTokens / 1_000).toFixed(1)} mT`;
return `${microTokens} µT`;
}
/** Format unix seconds to relative time */
export function relativeTime(unixSeconds: number | undefined | null): string {
if (!unixSeconds) return '';
const diff = Date.now() / 1000 - unixSeconds;
if (diff < 60) return 'just now';
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
return new Date(unixSeconds * 1000).toLocaleDateString();
}
/** Format unix seconds to HH:MM */
export function formatTime(unixSeconds: number | undefined | null): string {
if (!unixSeconds) return '';
return new Date(unixSeconds * 1000).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
}
/** Generate a random nonce string */
export function randomId(): string {
return Math.random().toString(36).slice(2) + Date.now().toString(36);
}

401
client-app/lib/ws.ts Normal file
View File

@@ -0,0 +1,401 @@
/**
* DChain WebSocket client — replaces balance / inbox / contacts polling with
* server-push. Matches `node/ws.go` exactly.
*
* Usage:
* const ws = getWSClient();
* ws.connect(); // idempotent
* const off = ws.subscribe('addr:ab12…', ev => { ... });
* // later:
* off(); // unsubscribe + stop handler
* ws.disconnect();
*
* Features:
* - Auto-reconnect with exponential backoff (1s → 30s cap).
* - Re-subscribes all topics after a reconnect.
* - `hello` frame exposes chain_id + tip_height for connection state UI.
* - Degrades silently if the endpoint returns 501 (old node without WS).
*/
import { getNodeUrl, onNodeUrlChange } from './api';
import { sign } from './crypto';
export type WSEventName =
| 'hello'
| 'block'
| 'tx'
| 'contract_log'
| 'inbox'
| 'typing'
| 'pong'
| 'error'
| 'subscribed'
| 'submit_ack'
| 'lag';
export interface WSFrame {
event: WSEventName;
data?: unknown;
topic?: string;
msg?: string;
chain_id?: string;
tip_height?: number;
/** Server-issued nonce in the hello frame; client signs it for auth. */
auth_nonce?: string;
// submit_ack fields
id?: string;
status?: 'accepted' | 'rejected';
reason?: string;
}
type Handler = (frame: WSFrame) => void;
class WSClient {
private ws: WebSocket | null = null;
private url: string | null = null;
private reconnectMs: number = 1000;
private closing: boolean = false;
/** topic → set of handlers interested in frames for this topic */
private handlers: Map<string, Set<Handler>> = new Map();
/** topics we want the server to push — replayed on every reconnect */
private wantedTopics: Set<string> = new Set();
private connectionListeners: Set<(ok: boolean, err?: string) => void> = new Set();
private helloInfo: { chainId?: string; tipHeight?: number; authNonce?: string } = {};
/**
* Credentials used for auto-auth on every (re)connect. The signer runs on
* each hello frame so scoped subscriptions (addr:*, inbox:*) are accepted.
* Without these, subscribe requests to scoped topics get rejected by the
* server; global topics (blocks, tx, …) still work unauthenticated.
*/
private authCreds: { pubKey: string; privKey: string } | null = null;
/** Current connection state (read-only for UI). */
isConnected(): boolean {
return this.ws?.readyState === WebSocket.OPEN;
}
getHelloInfo(): { chainId?: string; tipHeight?: number } {
return this.helloInfo;
}
/** Subscribe to a connection-state listener — fires on connect/disconnect. */
onConnectionChange(cb: (ok: boolean, err?: string) => void): () => void {
this.connectionListeners.add(cb);
return () => this.connectionListeners.delete(cb) as unknown as void;
}
private fireConnectionChange(ok: boolean, err?: string) {
for (const cb of this.connectionListeners) {
try { cb(ok, err); } catch { /* noop */ }
}
}
/**
* Register the Ed25519 keypair used for auto-auth. The signer runs on each
* (re)connect against the server-issued nonce so the connection is bound
* to this identity. Pass null to disable auth (only global topics will
* work — useful for observers).
*/
setAuthCreds(creds: { pubKey: string; privKey: string } | null): void {
this.authCreds = creds;
// If we're already connected, kick off auth immediately.
if (creds && this.isConnected() && this.helloInfo.authNonce) {
this.sendAuth(this.helloInfo.authNonce);
}
}
/** Idempotent connect. Call once on app boot. */
connect(): void {
const base = getNodeUrl();
const newURL = base.replace(/^http/, 'ws') + '/api/ws';
if (this.ws) {
const state = this.ws.readyState;
// Already pointing at this URL and connected / connecting — nothing to do.
if (this.url === newURL && (state === WebSocket.OPEN || state === WebSocket.CONNECTING)) {
return;
}
// URL changed (operator flipped nodes in settings) — tear down and
// re-dial. Existing subscriptions live in wantedTopics and will be
// replayed after the new onopen fires.
if (this.url !== newURL && (state === WebSocket.OPEN || state === WebSocket.CONNECTING)) {
try { this.ws.close(); } catch { /* noop */ }
this.ws = null;
}
}
this.closing = false;
this.url = newURL;
try {
this.ws = new WebSocket(this.url);
} catch (e: any) {
this.fireConnectionChange(false, e?.message ?? 'ws construct failed');
this.scheduleReconnect();
return;
}
this.ws.onopen = () => {
this.reconnectMs = 1000; // reset backoff
this.fireConnectionChange(true);
// Replay all wanted subscriptions.
for (const topic of this.wantedTopics) {
this.sendRaw({ op: 'subscribe', topic });
}
};
this.ws.onmessage = (ev) => {
let frame: WSFrame;
try {
frame = JSON.parse(typeof ev.data === 'string' ? ev.data : '');
} catch {
return;
}
if (frame.event === 'hello') {
this.helloInfo = {
chainId: frame.chain_id,
tipHeight: frame.tip_height,
authNonce: frame.auth_nonce,
};
// Auto-authenticate if credentials are set. The server binds this
// connection to the signed pubkey so scoped subscriptions (addr:*,
// inbox:*) get through. On reconnect a new nonce is issued, so the
// auth dance repeats transparently.
if (this.authCreds && frame.auth_nonce) {
this.sendAuth(frame.auth_nonce);
}
}
// Dispatch to all handlers for any topic that could match this frame.
// We use a simple predicate: look at the frame to decide which topics it
// was fanned out to, then fire every matching handler.
for (const topic of this.topicsForFrame(frame)) {
const set = this.handlers.get(topic);
if (!set) continue;
for (const h of set) {
try { h(frame); } catch (e) { console.warn('[ws] handler error', e); }
}
}
};
this.ws.onerror = (e: any) => {
this.fireConnectionChange(false, 'ws error');
};
this.ws.onclose = () => {
this.ws = null;
this.fireConnectionChange(false);
if (!this.closing) this.scheduleReconnect();
};
}
disconnect(): void {
this.closing = true;
if (this.ws) {
try { this.ws.close(); } catch { /* noop */ }
this.ws = null;
}
}
/**
* Subscribe to a topic. Returns an `off()` function that unsubscribes AND
* removes the handler. If multiple callers subscribe to the same topic,
* the server is only notified on the first and last caller.
*/
subscribe(topic: string, handler: Handler): () => void {
let set = this.handlers.get(topic);
if (!set) {
set = new Set();
this.handlers.set(topic, set);
}
set.add(handler);
// Notify server only on the first handler for this topic.
if (!this.wantedTopics.has(topic)) {
this.wantedTopics.add(topic);
if (this.isConnected()) {
this.sendRaw({ op: 'subscribe', topic });
} else {
this.connect(); // lazy-connect on first subscribe
}
}
return () => {
const s = this.handlers.get(topic);
if (!s) return;
s.delete(handler);
if (s.size === 0) {
this.handlers.delete(topic);
this.wantedTopics.delete(topic);
if (this.isConnected()) {
this.sendRaw({ op: 'unsubscribe', topic });
}
}
};
}
/** Force a keepalive ping. Useful for debugging. */
ping(): void {
this.sendRaw({ op: 'ping' });
}
/**
* Send a typing indicator to another user. Recipient is their X25519 pubkey
* (the one used for inbox encryption). Ephemeral — no ack, no retry; just
* fire and forget. Call on each keystroke but throttle to once per 2-3s
* at the caller side so we don't flood the WS with frames.
*/
sendTyping(recipientX25519: string): void {
if (!this.isConnected()) return;
try {
this.ws!.send(JSON.stringify({ op: 'typing', to: recipientX25519 }));
} catch { /* best-effort */ }
}
/**
* Submit a signed transaction over the WebSocket and resolve once the
* server returns a `submit_ack`. Saves the HTTP round-trip on every tx
* and gives the UI immediate accept/reject feedback.
*
* Rejects if:
* - WS is not connected (caller should fall back to HTTP)
* - Server returns `status: "rejected"` — `reason` is surfaced as error msg
* - No ack within `timeoutMs` (default 10 s)
*/
submitTx(tx: unknown, timeoutMs = 10_000): Promise<{ id: string }> {
if (!this.isConnected()) {
return Promise.reject(new Error('WS not connected'));
}
const reqId = 's_' + Date.now() + '_' + Math.random().toString(36).slice(2, 8);
return new Promise((resolve, reject) => {
const off = this.subscribe('$system', (frame) => {
if (frame.event !== 'submit_ack' || frame.id !== reqId) return;
off();
clearTimeout(timer);
if (frame.status === 'accepted') {
// `msg` carries the server-confirmed tx id.
resolve({ id: typeof frame.msg === 'string' ? frame.msg : '' });
} else {
reject(new Error(frame.reason || 'submit_tx rejected'));
}
});
const timer = setTimeout(() => {
off();
reject(new Error('submit_tx timeout (' + timeoutMs + 'ms)'));
}, timeoutMs);
try {
this.ws!.send(JSON.stringify({ op: 'submit_tx', tx, id: reqId }));
} catch (e: any) {
off();
clearTimeout(timer);
reject(new Error('WS send failed: ' + (e?.message ?? 'unknown')));
}
});
}
// ── internals ───────────────────────────────────────────────────────────
private scheduleReconnect(): void {
if (this.closing) return;
const delay = Math.min(this.reconnectMs, 30_000);
this.reconnectMs = Math.min(this.reconnectMs * 2, 30_000);
setTimeout(() => {
if (!this.closing) this.connect();
}, delay);
}
private sendRaw(cmd: { op: string; topic?: string }): void {
if (!this.isConnected()) return;
try { this.ws!.send(JSON.stringify(cmd)); } catch { /* noop */ }
}
/**
* Sign the server nonce with our Ed25519 private key and send the `auth`
* op. The server binds this connection to `authCreds.pubKey`; subsequent
* subscribe requests to `addr:<pubKey>` / `inbox:<my_x25519>` are accepted.
*/
private sendAuth(nonce: string): void {
if (!this.authCreds || !this.isConnected()) return;
try {
const bytes = new TextEncoder().encode(nonce);
const sig = sign(bytes, this.authCreds.privKey);
this.ws!.send(JSON.stringify({
op: 'auth',
pubkey: this.authCreds.pubKey,
sig,
}));
} catch (e) {
console.warn('[ws] auth send failed:', e);
}
}
/**
* Given an incoming frame, enumerate every topic that handlers could have
* subscribed to and still be interested. This mirrors the fan-out logic in
* node/ws.go:EmitBlock / EmitTx / EmitContractLog.
*/
private topicsForFrame(frame: WSFrame): string[] {
switch (frame.event) {
case 'block':
return ['blocks'];
case 'tx': {
const d = frame.data as { from?: string; to?: string } | undefined;
const topics = ['tx'];
if (d?.from) topics.push('addr:' + d.from);
if (d?.to && d.to !== d.from) topics.push('addr:' + d.to);
return topics;
}
case 'contract_log': {
const d = frame.data as { contract_id?: string } | undefined;
const topics = ['contract_log'];
if (d?.contract_id) topics.push('contract:' + d.contract_id);
return topics;
}
case 'inbox': {
// Node fans inbox events to `inbox` + `inbox:<recipient_x25519>`;
// we mirror that here so both firehose listeners and address-scoped
// subscribers see the event.
const d = frame.data as { recipient_pub?: string } | undefined;
const topics = ['inbox'];
if (d?.recipient_pub) topics.push('inbox:' + d.recipient_pub);
return topics;
}
case 'typing': {
// Server fans to `typing:<to>` only (the recipient).
const d = frame.data as { to?: string } | undefined;
return d?.to ? ['typing:' + d.to] : [];
}
// Control-plane events — no topic fan-out; use a pseudo-topic so UI
// can listen for them via subscribe('$system', ...).
case 'hello':
case 'pong':
case 'error':
case 'subscribed':
case 'submit_ack':
case 'lag':
return ['$system'];
default:
return [];
}
}
}
let _singleton: WSClient | null = null;
/**
* Return the app-wide WebSocket client. Safe to call from any component;
* `.connect()` is idempotent.
*
* On first creation we register a node-URL listener so flipping the node
* in Settings tears down the existing socket and dials the new one — the
* user's active subscriptions (addr:*, inbox:*) replay automatically.
*/
export function getWSClient(): WSClient {
if (!_singleton) {
_singleton = new WSClient();
onNodeUrlChange(() => {
// Fire and forget — connect() is idempotent and handles stale URLs.
_singleton!.connect();
});
}
return _singleton;
}

View File

@@ -0,0 +1,6 @@
const { getDefaultConfig } = require('expo/metro-config');
const { withNativeWind } = require('nativewind/metro');
const config = getDefaultConfig(__dirname);
module.exports = withNativeWind(config, { input: './global.css' });

1
client-app/nativewind-env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="nativewind/types" />

10317
client-app/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

51
client-app/package.json Normal file
View File

@@ -0,0 +1,51 @@
{
"name": "dchain-messenger",
"version": "1.0.0",
"main": "expo-router/entry",
"scripts": {
"start": "expo start",
"android": "expo start --android",
"ios": "expo start --ios",
"web": "expo start --web",
"lint": "eslint ."
},
"dependencies": {
"@react-native-async-storage/async-storage": "2.2.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"expo": "~54.0.0",
"expo-asset": "~12.0.12",
"expo-camera": "~16.1.6",
"expo-crypto": "~14.1.4",
"expo-clipboard": "~8.0.8",
"expo-constants": "~18.0.13",
"expo-document-picker": "~14.0.8",
"expo-file-system": "~19.0.21",
"expo-font": "~14.0.11",
"expo-linking": "~8.0.11",
"expo-router": "~6.0.23",
"expo-secure-store": "~15.0.8",
"expo-sharing": "~14.0.8",
"expo-splash-screen": "~31.0.13",
"expo-status-bar": "~3.0.9",
"expo-web-browser": "~15.0.10",
"nativewind": "^4.1.23",
"react": "19.1.0",
"react-native": "0.81.5",
"react-native-reanimated": "~3.17.0",
"react-native-safe-area-context": "~5.6.0",
"react-native-screens": "~4.16.0",
"react-native-worklets": "~0.8.1",
"tailwind-merge": "^2.6.0",
"tailwindcss": "^3.4.17",
"tweetnacl": "^1.0.3",
"tweetnacl-util": "^0.15.1",
"zustand": "^5.0.3"
},
"devDependencies": {
"@babel/core": "^7.25.2",
"@types/react": "~19.1.0",
"babel-preset-expo": "~13.0.0",
"typescript": "^5.3.3"
}
}

View File

@@ -0,0 +1,28 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: [
'./app/**/*.{js,jsx,ts,tsx}',
'./components/**/*.{js,jsx,ts,tsx}',
'./hooks/**/*.{js,jsx,ts,tsx}',
'./lib/**/*.{js,jsx,ts,tsx}',
],
presets: [require('nativewind/preset')],
theme: {
extend: {
colors: {
// DChain brand — deep navy + teal accent
background: '#0d1117',
surface: '#161b22',
surfaceHigh: '#21262d',
border: '#30363d',
primary: '#2563eb',
primaryFg: '#ffffff',
accent: '#22d3ee',
muted: '#8b949e',
destructive: '#f85149',
success: '#3fb950',
},
},
},
plugins: [],
};

9
client-app/tsconfig.json Normal file
View File

@@ -0,0 +1,9 @@
{
"extends": "expo/tsconfig.base",
"compilerOptions": {
"strict": true,
"paths": {
"@/*": ["./*"]
}
}
}