chore(release): clean up repo for v0.0.1 release
Excluded from release bundle:
- CONTEXT.md, CHANGELOG.md (agent/project working notes)
- client-app/ (React Native messenger — tracked separately)
- contracts/hello_go/ (unused standalone example)
Kept contracts/counter/ and contracts/name_registry/ as vm-test fixtures
(referenced by vm/vm_test.go; NOT production contracts).
Docs refactor:
- docs/README.md — new top-level index with cross-references
- docs/quickstart.md — rewrite around single-node as primary path
- docs/node/README.md — full rewrite, all CLI flags, schema table
- docs/api/README.md — add /api/well-known-version, /api/update-check
- docs/contracts/README.md — split native (Go) vs WASM (user-deployable)
- docs/update-system.md — new, full 5-layer update system design
- README.md — link into docs/, drop CHANGELOG/client-app references
Build-time version system (inherited from earlier commits this branch):
- node --version / client --version with ldflags-injected metadata
- /api/well-known-version with {build, protocol_version, features[]}
- Peer-version gossip on dchain/version/v1
- /api/update-check against Gitea release API
- deploy/single/update.sh with semver guard + 15-min systemd jitter
This commit is contained in:
5
.gitignore
vendored
5
.gitignore
vendored
@@ -56,3 +56,8 @@ Thumbs.db
|
|||||||
|
|
||||||
# Claude Code / agent local state
|
# Claude Code / agent local state
|
||||||
.claude/
|
.claude/
|
||||||
|
|
||||||
|
# Not part of the release bundle — tracked separately
|
||||||
|
CONTEXT.md
|
||||||
|
CHANGELOG.md
|
||||||
|
client-app/
|
||||||
|
|||||||
181
CHANGELOG.md
181
CHANGELOG.md
@@ -1,181 +0,0 @@
|
|||||||
# DChain CHANGELOG
|
|
||||||
|
|
||||||
Consolidated record of what landed. Replaces the now-deleted
|
|
||||||
`REFACTOR_PLAN.md`, `NODE_ONBOARDING.md`, and `ROADMAP.md` — every numbered
|
|
||||||
item there is either shipped (listed below) or explicitly deferred.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Production-ready stack (shipped)
|
|
||||||
|
|
||||||
### Consensus & chain
|
|
||||||
- **PBFT multi-sig validator admission**. `ADD_VALIDATOR` requires
|
|
||||||
⌈2/3⌉ cosigs from the current set + candidate must have ≥ `MinValidatorStake`
|
|
||||||
(1 T) locked via STAKE. Same gate on forced `REMOVE_VALIDATOR`; self-
|
|
||||||
removal stays unilateral.
|
|
||||||
- **Equivocation slashing**. `SLASH` tx with `reason=equivocation` carries
|
|
||||||
both conflicting PREPARE/COMMIT messages as evidence; `ValidateEquivocation`
|
|
||||||
verifies on-chain — any node can report, no trust. Offender's stake is
|
|
||||||
burned and they're evicted from the set.
|
|
||||||
- **Liveness tracking**. PBFT records per-validator last-seen seqNum;
|
|
||||||
`LivenessReport()` + `MissedBlocks()` surface stalemates. Exposed via
|
|
||||||
`dchain_max_missed_blocks` Prometheus gauge.
|
|
||||||
- **Fair mempool**. Per-sender FIFO queues drained round-robin into
|
|
||||||
proposals; one spammer can't starve others.
|
|
||||||
- **Block-reward fix**. Synthetic BLOCK_REWARD transactions use `From=""`
|
|
||||||
so self-validators don't appear to pay themselves in history.
|
|
||||||
|
|
||||||
### Storage & stability
|
|
||||||
- **Re-entrant-deadlock fix**. Dedicated `configMu` and `nativeMu` separate
|
|
||||||
from `c.mu` — applyTx can safely read config/native registry while
|
|
||||||
`AddBlock` holds the write lock.
|
|
||||||
- **BadgerDB tuning**. `WithValueLogFileSize(64 MiB)` + `WithNumVersionsToKeep(1)`
|
|
||||||
+ background `StartValueLogGC` every 5 minutes + one-shot `CompactNow()`
|
|
||||||
at startup. Reclaims gigabytes from upgraded nodes automatically.
|
|
||||||
- **`TipIndex()`** — lock-free reads so `/api/blocks` and `/api/txs/recent`
|
|
||||||
never hang even when `AddBlock` is stuck.
|
|
||||||
- **Chronological tx index** (`txchron:<block20d>:<seq04d>`). `RecentTxs`
|
|
||||||
runs in O(limit) instead of O(empty blocks) — important when tps is low.
|
|
||||||
- **WASM VM timeout + `WithCloseOnContextDone`**. Any contract call aborts
|
|
||||||
at 30 s hard cap; gas metering evasion can no longer freeze the chain.
|
|
||||||
|
|
||||||
### Native contracts
|
|
||||||
- **`native:username_registry`** (v2.1.0). Replaces WASM registry — 100×
|
|
||||||
faster, no VM failure surface. `register(name)` requires exact
|
|
||||||
`tx.Amount = 10 000 µT` (burned, visible in history). Min length 4,
|
|
||||||
lowercase `a-z 0-9 _ -`, first char letter, reserved words blacklist.
|
|
||||||
- **Native dispatcher** in `applyTx` → checks `native:*` IDs first, falls
|
|
||||||
through to WASM VM otherwise. ABI JSON + contract metadata surfaced via
|
|
||||||
`/api/contracts/:id` with `"native": true` flag.
|
|
||||||
- **Well-known auto-discovery**. `/api/well-known-contracts` returns
|
|
||||||
canonical contract IDs indexed by ABI name; native always wins. Client
|
|
||||||
auto-syncs on connect.
|
|
||||||
|
|
||||||
### WebSocket gateway (push-based UX)
|
|
||||||
- **`GET /api/ws`** — persistent bidirectional JSON-framed connection.
|
|
||||||
- **Topics**: `blocks`, `tx`, `addr:<pub>`, `inbox:<x25519>`,
|
|
||||||
`contract_log`, `contract:<id>`, `typing:<x25519>`.
|
|
||||||
- **`auth` op**. Client signs server-issued nonce with Ed25519; hub binds
|
|
||||||
connection to pubkey so scoped subscriptions (`addr:*`, `inbox:*`,
|
|
||||||
`typing:*`) are accepted only for owned identities.
|
|
||||||
- **`submit_tx` op**. Low-latency tx submission with correlated
|
|
||||||
`submit_ack` frame; removes the HTTP round-trip. Client falls back to
|
|
||||||
POST `/api/tx` automatically if WS is down.
|
|
||||||
- **Typing indicators**. Ephemeral `typing` op, authenticated, scoped to
|
|
||||||
recipient. Mobile client shows "печатает…" in chat header.
|
|
||||||
- **Per-connection quotas**. Max 10 connections / IP, 32 subs / connection.
|
|
||||||
Bounded outbox drops oldest on overflow with `{event:"lag"}` notice.
|
|
||||||
- **Fanout mirrors SSE**. `eventBus` dispatches to SSE + WS + future
|
|
||||||
consumers from one emit site.
|
|
||||||
|
|
||||||
### Relay mailbox
|
|
||||||
- **Push notifications**. `Mailbox.SetOnStore` hook → `wsHub.EmitInbox(...)`
|
|
||||||
on every fresh envelope. Client's `useMessages` subscribes instead of
|
|
||||||
polling every 3 s.
|
|
||||||
- **Relay TTL**. `REGISTER_RELAY` and HEARTBEAT (from registered relays)
|
|
||||||
refresh a `relayhb:<pub>` timestamp; `/api/relays` filters anything
|
|
||||||
older than 2 hours. Stale relays are delisted automatically.
|
|
||||||
|
|
||||||
### Node onboarding
|
|
||||||
- **`--join <url1,url2,…>`** — multi-seed bootstrap. Tries each URL in
|
|
||||||
order, persists the live list to `<db>/seeds.json` on first success so
|
|
||||||
subsequent restarts don't need the CLI flag.
|
|
||||||
- **`/api/network-info`** — one-shot payload (chain_id, genesis_hash,
|
|
||||||
validators, peers, contracts, stats) for joiners.
|
|
||||||
- **`/api/peers`** — live libp2p peer list with multiaddrs.
|
|
||||||
- **Genesis-hash verification**. A node with expected hash aborts if its
|
|
||||||
local block 0 doesn't match (protection against forged seeds). Override
|
|
||||||
with `--allow-genesis-mismatch` for migrations.
|
|
||||||
- **Gap-fill on gossip**. Blocks with `b.Index > tip+1` trigger
|
|
||||||
`SyncFromPeerFull` to the gossiping peer (rate-limited 1 per peer per
|
|
||||||
minute). Nodes recover from brief outages without restart.
|
|
||||||
|
|
||||||
### API surface & security
|
|
||||||
- **Rate limiter** (`node/api_guards.go`). Per-IP token bucket on
|
|
||||||
`/api/tx` and `/v2/chain/transactions`: 10 tx/s, burst 20.
|
|
||||||
- **Request-size cap**. `/api/tx` body ≤ 64 KiB.
|
|
||||||
- **Timestamp validation**. ±1 h window on submit, refuses clock-skewed
|
|
||||||
or replayed txs.
|
|
||||||
- **Humanised errors in client**. `humanizeTxError` translates 429 /
|
|
||||||
400+timestamp / 400+signature / network-failure into Russian user-
|
|
||||||
facing text.
|
|
||||||
|
|
||||||
### Observability & ops
|
|
||||||
- **Prometheus `/metrics`**. Zero-dep in-tree implementation (`node/metrics.go`)
|
|
||||||
with counters (blocks, txs, submit accepted/rejected), gauges
|
|
||||||
(ws connections, peer count, max missed blocks), histogram (block
|
|
||||||
commit seconds).
|
|
||||||
- **Load test**. `cmd/loadtest` — N concurrent WS clients with auth +
|
|
||||||
scoped subs + TRANSFER at rate. Validates chain advances, reject rate,
|
|
||||||
ws-drop count. Smoke at 20 clients × 15 s → 136 accepted / 0 rejected.
|
|
||||||
- **Structured logging**. `--log-format=text|json` flag. JSON mode routes
|
|
||||||
both `slog.*` and legacy `log.Printf` through one JSON handler for
|
|
||||||
Loki/ELK ingestion.
|
|
||||||
- **Observer mode**. `--observer` (env `DCHAIN_OBSERVER`) disables PBFT
|
|
||||||
producer + heartbeat + auto-relay-register; node still gossips and
|
|
||||||
serves HTTP/WS. For horizontally-scaling read-only API frontends.
|
|
||||||
|
|
||||||
### Deployment
|
|
||||||
- **`deploy/single/`** — one-node production bundle:
|
|
||||||
- Same `Dockerfile.slim` as the cluster variant.
|
|
||||||
- Compose stack: 1 node + Caddy + optional Prometheus/Grafana.
|
|
||||||
- Supports three operator-chosen access modes:
|
|
||||||
- Public (no token) — anyone can read + submit.
|
|
||||||
- Public reads, token-gated writes (`DCHAIN_API_TOKEN` set) —
|
|
||||||
reads stay open, submit tx requires `Authorization: Bearer`.
|
|
||||||
- Fully private (`DCHAIN_API_TOKEN` + `DCHAIN_API_PRIVATE`) —
|
|
||||||
every endpoint requires the token.
|
|
||||||
- Runbook covers three scenarios: genesis node, joiner, private.
|
|
||||||
- **`deploy/prod/`** — 3-validator cluster for federations/consortiums.
|
|
||||||
- **Access-token middleware** in `node/api_guards.go`:
|
|
||||||
- `withWriteTokenGuard` gates POST /api/tx and WS submit_tx.
|
|
||||||
- `withReadTokenGuard` gates reads when `--api-private` is set.
|
|
||||||
- WS upgrade applies the same check; `submit_tx` ops on a
|
|
||||||
non-authenticated connection are rejected with `submit_ack`
|
|
||||||
rejected.
|
|
||||||
- **All CLI flags accept `DCHAIN_*` env fallbacks** for Docker-driven
|
|
||||||
configuration, including the new `DCHAIN_API_TOKEN` /
|
|
||||||
`DCHAIN_API_PRIVATE`.
|
|
||||||
|
|
||||||
### Client (React Native / Expo)
|
|
||||||
- WebSocket module `lib/ws.ts` with reconnect, auto-resubscribe,
|
|
||||||
auto-auth on reconnect.
|
|
||||||
- `useBalance`, `useContacts`, `useMessages` — all push-based with HTTP
|
|
||||||
polling fallback after 15 s disconnect.
|
|
||||||
- `useWellKnownContracts` — auto-syncs `settings.contractId` with node's
|
|
||||||
canonical registry.
|
|
||||||
- Safe-area-aware layout throughout. Tab bar no longer hides under home
|
|
||||||
indicator on iPhone.
|
|
||||||
- Username purchase UI with live validation (min 4, first letter, charset).
|
|
||||||
- Transaction detail sheet with system-tx handling (BLOCK_REWARD shows
|
|
||||||
"Сеть" as counterpart, not validator's self-pay).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Deliberately deferred
|
|
||||||
|
|
||||||
- **Split `blockchain/chain.go`** into `state/`, `applytx/`, `mempool/`,
|
|
||||||
`index/`, `events/` subpackages. A ~2.5k-line single-file refactor is
|
|
||||||
high risk; to be attempted after the chain has been running in prod
|
|
||||||
long enough that regressions there would be caught fast.
|
|
||||||
- **Full `p2p/` rewrite with typed event channel.** The libp2p integration
|
|
||||||
works; event-bus was added at the node layer instead (see `node/events.go`).
|
|
||||||
- **Full mempool admission pricing** (gas-priced priority queues).
|
|
||||||
Current fair round-robin works within spam-proofing needs.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Compatibility notes
|
|
||||||
|
|
||||||
- BadgerDB tuning is compatible with databases created by previous
|
|
||||||
versions; the first run reclaims old value-log space via `CompactNow()`.
|
|
||||||
- `AddValidatorPayload` / `RemoveValidatorPayload` gained a `cosigs`
|
|
||||||
field; older payloads without it still parse (default empty), but will
|
|
||||||
fail the ⌈2/3⌉ threshold on chains with >1 validator.
|
|
||||||
- `BLOCK_REWARD` transactions changed from `From=validator` to `From=""`.
|
|
||||||
Old indexed records keep their previous `From`; new ones use the new
|
|
||||||
shape. Explorer/client handle both.
|
|
||||||
- Registration fee for usernames moved from internal `ctx.Debit` to
|
|
||||||
`tx.Amount`. The WASM username_registry is superseded by
|
|
||||||
`native:username_registry`; well-known endpoint returns the native
|
|
||||||
version as canonical.
|
|
||||||
229
CONTEXT.md
229
CONTEXT.md
@@ -1,229 +0,0 @@
|
|||||||
# Decentralized Messenger — Project Context for Claude Code
|
|
||||||
|
|
||||||
> Этот файл содержит полный контекст проекта из чата. Передай его в Claude Code командой:
|
|
||||||
> `claude --context CONTEXT.md` или просто открой в проекте и Claude Code подхватит его автоматически через CLAUDE.md
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Суть проекта
|
|
||||||
|
|
||||||
Полностью децентрализованный мессенджер с функциональностью уровня Telegram/ВКонтакте.
|
|
||||||
- Никакого центрального сервера
|
|
||||||
- Блокчейн как регулятор сети (не транспорт)
|
|
||||||
- Кастомный протокол с маскировкой трафика
|
|
||||||
- E2E шифрование верифицированное через блокчейн
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Архитектура: четыре слоя
|
|
||||||
|
|
||||||
### Слой 1 — Идентичность (L1 блокчейн)
|
|
||||||
Хранит только редкие важные события:
|
|
||||||
- Регистрация keypair (Ed25519) — раз в жизни
|
|
||||||
- Создание канала/чата — владелец и метаданные
|
|
||||||
- Изменение прав участников
|
|
||||||
- Открытие/закрытие платёжных state channels
|
|
||||||
|
|
||||||
**Тела сообщений НИКОГДА не попадают в блокчейн.**
|
|
||||||
|
|
||||||
### Слой 2 — Транспорт (relay-ноды)
|
|
||||||
- DHT маршрутизация (как BitTorrent) — нет центрального роутера
|
|
||||||
- Onion routing — каждый узел видит только следующий хоп
|
|
||||||
- Маскировка трафика — имитация HTTPS/QUIC или BitTorrent uTP
|
|
||||||
- Офлайн-буфер — зашифрованные конверты TTL 30 дней
|
|
||||||
- Proof of Relay — криптодоказательство честной доставки
|
|
||||||
|
|
||||||
### Слой 3 — Хранение
|
|
||||||
- IPFS / Arweave — медиафайлы (content-addressed)
|
|
||||||
- Relay-кэш — горячая история, последние N сообщений
|
|
||||||
- Локальная зашифрованная БД (SQLite + NaCl) на устройстве
|
|
||||||
- Блокчейн — только хэши событий
|
|
||||||
|
|
||||||
### Слой 4 — Приложение
|
|
||||||
Личные сообщения, группы, каналы, звонки (WebRTC P2P), сторис, посты, боты
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Блокчейн: детали
|
|
||||||
|
|
||||||
### Консенсус — PBFT (Tendermint-style)
|
|
||||||
- Финальность: 1–3 секунды
|
|
||||||
- Три фазы: Pre-prepare → Prepare → Commit
|
|
||||||
- Кворум: 2/3 валидаторов
|
|
||||||
- Валидаторы = операторы крупных relay-нод
|
|
||||||
|
|
||||||
### Структура блока (Go)
|
|
||||||
```go
|
|
||||||
type Block struct {
|
|
||||||
Index uint64
|
|
||||||
Timestamp time.Time
|
|
||||||
Transactions []*Transaction
|
|
||||||
PrevHash []byte
|
|
||||||
Hash []byte // SHA-256
|
|
||||||
ValidatorSig []byte // Ed25519
|
|
||||||
Validator string // pub_key валидатора
|
|
||||||
}
|
|
||||||
|
|
||||||
type Transaction struct {
|
|
||||||
ID string
|
|
||||||
Type EventType // REGISTER_KEY | CREATE_CHANNEL | ADD_MEMBER | OPEN_PAY_CHAN ...
|
|
||||||
From string // pub_key отправителя
|
|
||||||
To string // pub_key получателя (если есть)
|
|
||||||
Payload []byte // json с данными события
|
|
||||||
Signature []byte // Ed25519 подпись From
|
|
||||||
Timestamp time.Time
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Хранение блоков
|
|
||||||
- Light nodes для мобильных клиентов (только заголовки)
|
|
||||||
- Sharding для валидаторов (каждый хранит свой шард)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Формат сообщений и постов
|
|
||||||
|
|
||||||
### Личное сообщение — конверт
|
|
||||||
```go
|
|
||||||
type Envelope struct {
|
|
||||||
To []byte // pub_key получателя (для маршрутизации relay)
|
|
||||||
Nonce []byte // 24 случайных байта (anti-replay)
|
|
||||||
Ciphertext []byte // NaCl box: зашифровано pub_bob + priv_alice
|
|
||||||
SentAt int64 // unix timestamp (внутри шифра, снаружи не видно)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
Relay видит только `To`. Всё остальное — непрозрачный blob.
|
|
||||||
|
|
||||||
Поток доставки:
|
|
||||||
1. Alice шифрует конверт pub_key Боба
|
|
||||||
2. Отправляет на свою relay-ноду (P2P)
|
|
||||||
3. Relay ищет Bob по DHT и доставляет (<50мс если онлайн)
|
|
||||||
4. Если офлайн — хранит конверт TTL 30 дней
|
|
||||||
5. Bob расшифровывает своим priv_key
|
|
||||||
|
|
||||||
### Пост в канале
|
|
||||||
```go
|
|
||||||
type Post struct {
|
|
||||||
ChannelID string
|
|
||||||
SeqNum uint64 // монотонно растёт — клиент знает что пропустил
|
|
||||||
ContentHash []byte // sha256 тела = IPFS CID
|
|
||||||
AuthorSig []byte // подпись канала
|
|
||||||
Timestamp int64
|
|
||||||
// Тело поста хранится в IPFS по ContentHash, не здесь
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Поток публикации:
|
|
||||||
1. Автор создаёт Post, подписывает, загружает тело в IPFS
|
|
||||||
2. Relay-нода анонсирует через gossip-протокол: "в канале X пост #N"
|
|
||||||
3. Волна расходится по DHT к подписчикам
|
|
||||||
4. Клиент проверяет: sig автора → pub_key из блокчейна → sha256(тело) == ContentHash
|
|
||||||
5. Тело подгружается из IPFS лениво (lazy loading)
|
|
||||||
|
|
||||||
Офлайн-синхронизация через seq_num: клиент хранит последний прочитанный номер,
|
|
||||||
при подключении запрашивает пропущенные у relay.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Экономика
|
|
||||||
|
|
||||||
### Три механизма
|
|
||||||
1. **State Channels** — микроплатежи без газа на каждое действие (как Lightning Network)
|
|
||||||
2. **Proof of Relay** — нода зарабатывает токены за доказанную доставку сообщений
|
|
||||||
3. **Delegated Staking** — делегировать токены ноде оператора без своего сервера
|
|
||||||
|
|
||||||
### Источники токенов
|
|
||||||
- Стартовый грант при регистрации
|
|
||||||
- PoW при создании keypair (CPU-барьер против Sybil)
|
|
||||||
- Лёгкая нода на телефоне (relay для соседей пока на зарядке)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Безопасность
|
|
||||||
|
|
||||||
### E2E шифрование
|
|
||||||
- Signal Protocol (Double Ratchet) или Noise Protocol
|
|
||||||
- Sender Keys для групп — один симметричный ключ на группу
|
|
||||||
- Блокчейн решает проблему TOFU (верификация pub_key)
|
|
||||||
|
|
||||||
### Защита метаданных
|
|
||||||
- Onion routing — relay не знает реального отправителя
|
|
||||||
- Sealed sender — сервер видит только получателя
|
|
||||||
- Маскировка трафика — QUIC / obfs4 / domain fronting
|
|
||||||
|
|
||||||
### Sybil-защита
|
|
||||||
- PoW при регистрации
|
|
||||||
- Социальный граф — новый аккаунт без контактов имеет ограниченные права
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Технический стек
|
|
||||||
|
|
||||||
| Компонент | Библиотека | Причина |
|
|
||||||
|-----------|-----------|---------|
|
|
||||||
| Блокчейн | Cosmos SDK / Tendermint | Лучший PBFT на Go |
|
|
||||||
| P2P сеть | go-libp2p | Используется IPFS и Ethereum |
|
|
||||||
| БД блоков | BadgerDB | Go-native key-value |
|
|
||||||
| Криптография | crypto/ed25519 (stdlib) | В стандартной библиотеке |
|
|
||||||
| E2E шифрование | golang.org/x/crypto/nacl | NaCl/box |
|
|
||||||
| gRPC API | google.golang.org/grpc | Стандарт для Go |
|
|
||||||
| Relay протокол | кастомный поверх QUIC | Контроль маскировки |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Структура репозитория (планируемая)
|
|
||||||
|
|
||||||
```
|
|
||||||
/
|
|
||||||
├── blockchain/
|
|
||||||
│ ├── block.go # структура блока, хэширование, валидация
|
|
||||||
│ ├── chain.go # хранилище, state machine
|
|
||||||
│ └── types.go # Transaction, EventType и т.д.
|
|
||||||
├── consensus/
|
|
||||||
│ └── pbft.go # PBFT: Pre-prepare → Prepare → Commit
|
|
||||||
├── identity/
|
|
||||||
│ └── identity.go # keypair Ed25519, подпись, верификация
|
|
||||||
├── relay/
|
|
||||||
│ ├── node.go # relay-нода: маршрутизация конвертов
|
|
||||||
│ ├── dht.go # DHT для discovery нод
|
|
||||||
│ └── buffer.go # офлайн-буфер с TTL
|
|
||||||
├── messaging/
|
|
||||||
│ ├── envelope.go # личные сообщения (NaCl box)
|
|
||||||
│ └── channel.go # посты в каналах (IPFS + gossip)
|
|
||||||
├── crypto/
|
|
||||||
│ ├── nacl.go # обёртки над NaCl box/secretbox
|
|
||||||
│ └── shamir.go # Shamir's Secret Sharing для recovery ключей
|
|
||||||
└── cmd/
|
|
||||||
├── node/ # relay-нода (сервер)
|
|
||||||
└── client/ # CLI клиент для тестирования
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Уже написанный код (в outputs/)
|
|
||||||
|
|
||||||
- `blockchain/block.go` — Block, Transaction, GenesisBlock, ComputeHash, Validate
|
|
||||||
- `blockchain/chain.go` — Chain, AddBlock, applyTx, state (identities, channels)
|
|
||||||
- `consensus/consensus.go` — Node, HandleMessage, PBFT фазы, broadcast
|
|
||||||
- `identity/identity.go` — Generate, RegisterTx, SignMessage, VerifyMessage
|
|
||||||
- `main.go` — пример запуска, simulateBlockProduction, simulateMessageFlow
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Следующие приоритеты для разработки
|
|
||||||
|
|
||||||
1. Заменить in-memory map в chain.go на BadgerDB
|
|
||||||
2. Добавить go-libp2p для P2P между нодами
|
|
||||||
3. Реализовать DHT для discovery и маршрутизации
|
|
||||||
4. Написать relay/node.go с буфером конвертов
|
|
||||||
5. Написать messaging/envelope.go с NaCl шифрованием
|
|
||||||
6. View-change протокол в PBFT (смена лидера при падении)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Аналоги для изучения
|
|
||||||
- **Nostr** — минималистичный протокол, Lightning для relay
|
|
||||||
- **Tendermint** — лучший PBFT на Go, изучить view-change
|
|
||||||
- **go-libp2p** — P2P стек
|
|
||||||
- **Status.im** — мессенджер на Ethereum, токен SNT
|
|
||||||
- **lnd** — Lightning Network на Go (state channels)
|
|
||||||
306
README.md
306
README.md
@@ -1,89 +1,91 @@
|
|||||||
# DChain
|
# DChain
|
||||||
|
|
||||||
Блокчейн-стек для децентрализованного мессенджера:
|
Блокчейн-стек для децентрализованного мессенджера:
|
||||||
|
|
||||||
- **PBFT** консенсус с multi-sig validator governance и equivocation slashing
|
- **PBFT** консенсус с multi-sig validator governance и equivocation slashing
|
||||||
- **Native Go контракты** рядом с WASM (wazero) — нулевая задержка для
|
- **Native Go контракты** рядом с WASM (wazero) — нулевая задержка для
|
||||||
системных сервисов типа username registry
|
системных сервисов типа `username_registry`
|
||||||
- **WebSocket push API** — клиент не опрашивает, все события прилетают
|
- **WebSocket push API** — клиент не опрашивает, все события прилетают
|
||||||
на соединение
|
на соединение
|
||||||
- **E2E-шифрованный relay mailbox** на libp2p gossipsub с TTL live-detection
|
- **E2E-шифрованный relay mailbox** на libp2p gossipsub с TTL live-detection
|
||||||
|
- **Система обновлений:** build-time версия → `/api/well-known-version`,
|
||||||
|
peer-version gossip, `/api/update-check` против Gitea releases,
|
||||||
|
`update.sh` с semver guard
|
||||||
- **Prometheus `/metrics`**, Caddy auto-HTTPS, observer mode, load-test
|
- **Prometheus `/metrics`**, Caddy auto-HTTPS, observer mode, load-test
|
||||||
- React Native / Expo мессенджер (`client-app/`)
|
|
||||||
|
|
||||||
## Содержание
|
## Содержание
|
||||||
|
|
||||||
- [Быстрый старт (dev)](#быстрый-старт-dev)
|
- [Быстрый старт](#быстрый-старт)
|
||||||
- [Продакшен деплой](#продакшен-деплой)
|
- [Продакшен деплой](#продакшен-деплой)
|
||||||
- [Клиент](#клиент)
|
|
||||||
- [Архитектура](#архитектура)
|
- [Архитектура](#архитектура)
|
||||||
- [Контракты](#контракты)
|
|
||||||
- [REST / WebSocket API](#rest--websocket-api)
|
- [REST / WebSocket API](#rest--websocket-api)
|
||||||
- [CLI](#cli)
|
- [CLI](#cli)
|
||||||
- [Мониторинг](#мониторинг)
|
- [Мониторинг](#мониторинг)
|
||||||
- [Тесты](#тесты)
|
- [Тесты](#тесты)
|
||||||
- [История изменений](#история-изменений)
|
- [Документация](#документация)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Быстрый старт (dev)
|
## Быстрый старт
|
||||||
|
|
||||||
3 валидатора в docker-compose с замоканной «интернет» топологией:
|
Одна нода в Docker, HTTP API на `localhost:8080`, Explorer UI и Swagger
|
||||||
|
открыты:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose up --build -d
|
# 1. Собираем prod-образ
|
||||||
open http://localhost:8081 # Explorer главной ноды
|
docker build -t dchain-node-slim -f deploy/prod/Dockerfile.slim .
|
||||||
curl -s http://localhost:8081/api/netstats # синхронность ноды
|
|
||||||
```
|
|
||||||
|
|
||||||
После поднятия нативный `username_registry` уже доступен — отдельный
|
# 2. Ключ ноды (один раз)
|
||||||
`--profile deploy` больше не нужен. Системные контракты регистрируются
|
|
||||||
в Go-коде при запуске (см. `blockchain/native.go`).
|
|
||||||
|
|
||||||
## Продакшен деплой
|
|
||||||
|
|
||||||
Два варианта, по масштабу:
|
|
||||||
|
|
||||||
### 🔸 Single-node (`deploy/single/`)
|
|
||||||
|
|
||||||
**Рекомендуется для личного/первого узла.** Один узел + Caddy TLS + опциональный Prometheus.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd deploy/single
|
|
||||||
|
|
||||||
# 1. Ключ ноды (один раз, сохранить в безопасном месте)
|
|
||||||
docker build -t dchain-node-slim -f ../prod/Dockerfile.slim ../..
|
|
||||||
mkdir -p keys
|
mkdir -p keys
|
||||||
docker run --rm --entrypoint /usr/local/bin/client \
|
docker run --rm --entrypoint /usr/local/bin/client \
|
||||||
-v "$PWD/keys:/out" dchain-node-slim \
|
-v "$PWD/keys:/out" dchain-node-slim \
|
||||||
keygen --out /out/node.json
|
keygen --out /out/node.json
|
||||||
|
|
||||||
# 2. Конфиг
|
# 3. Запуск (genesis-валидатор)
|
||||||
cp node.env.example node.env && $EDITOR node.env
|
docker run -d --name dchain --restart unless-stopped \
|
||||||
# минимум: DCHAIN_ANNOUNCE=<public ip>, DOMAIN=<fqdn>, ACME_EMAIL=<your>
|
-p 4001:4001 -p 8080:8080 \
|
||||||
|
-v dchain_data:/data \
|
||||||
# 3. Вверх
|
-v "$PWD/keys:/keys:ro" \
|
||||||
docker compose up -d
|
-e DCHAIN_GENESIS=true \
|
||||||
|
-e DCHAIN_ANNOUNCE=/ip4/127.0.0.1/tcp/4001 \
|
||||||
|
dchain-node-slim \
|
||||||
|
--db=/data/chain --mailbox-db=/data/mailbox --key=/keys/node.json \
|
||||||
|
--relay-key=/data/relay.json --listen=/ip4/0.0.0.0/tcp/4001 --stats-addr=:8080
|
||||||
|
|
||||||
# 4. Проверка
|
# 4. Проверка
|
||||||
curl -s https://$DOMAIN/api/netstats
|
open http://localhost:8080/ # Explorer
|
||||||
curl -s https://$DOMAIN/api/well-known-version
|
open http://localhost:8080/swagger # Swagger UI
|
||||||
|
curl -s http://localhost:8080/api/well-known-version | jq .
|
||||||
```
|
```
|
||||||
|
|
||||||
|
3-node dev-кластер (для тестов PBFT кворума, slashing, federation): `docker compose up --build -d` — см. [`docs/quickstart.md`](docs/quickstart.md).
|
||||||
|
|
||||||
|
## Продакшен деплой
|
||||||
|
|
||||||
|
Два варианта, по масштабу.
|
||||||
|
|
||||||
|
### 🔸 Single-node (`deploy/single/`)
|
||||||
|
|
||||||
|
**Рекомендуется для личного/первого узла.** Один узел + Caddy TLS +
|
||||||
|
опциональный Prometheus. Полный runbook с 6 сценариями (публичная с UI,
|
||||||
|
headless, полностью приватная, genesis, joiner, auto-update) — в
|
||||||
|
[`deploy/single/README.md`](deploy/single/README.md).
|
||||||
|
|
||||||
#### Модели доступа
|
#### Модели доступа
|
||||||
|
|
||||||
| Режим | `DCHAIN_API_TOKEN` | `DCHAIN_API_PRIVATE` | Поведение |
|
| Режим | `DCHAIN_API_TOKEN` | `DCHAIN_API_PRIVATE` | Поведение |
|
||||||
|-------|:------------------:|:--------------------:|-----------|
|
|-------|:------------------:|:--------------------:|-----------|
|
||||||
| Public (default) | не задан | — | Все могут читать и писать |
|
| Public (default) | не задан | — | Все могут читать и писать |
|
||||||
| Public reads / token writes | задан | `false` | Читать — любой; submit tx — только с токеном |
|
| Token writes | задан | `false` | Читать — любой; submit tx — только с токеном |
|
||||||
| Fully private | задан | `true` | Всё требует `Authorization: Bearer <token>` |
|
| Fully private | задан | `true` | Всё требует `Authorization: Bearer <token>` |
|
||||||
|
|
||||||
#### UI / Swagger — включать или нет?
|
#### UI / Swagger — включать или нет?
|
||||||
|
|
||||||
| Нужно | `DCHAIN_DISABLE_UI` | `DCHAIN_DISABLE_SWAGGER` | Где что открыто |
|
| Нужно | `DCHAIN_DISABLE_UI` | `DCHAIN_DISABLE_SWAGGER` | Открыто |
|
||||||
|-------|:-------------------:|:------------------------:|-----------------|
|
|-------|:-------------------:|:------------------------:|---------|
|
||||||
| Публичная с эксплорером + живой docs | не задано | не задано | `/` Explorer, `/swagger` UI, `/api/*`, `/metrics` |
|
| Публичная с эксплорером + docs | не задано | не задано | `/` Explorer, `/swagger`, `/api/*`, `/metrics` |
|
||||||
| Headless API-нода с открытой OpenAPI | `true` | не задано | `/swagger` UI, `/api/*`, `/metrics` (Explorer выкл.) |
|
| Headless API-нода с OpenAPI | `true` | не задано | `/swagger`, `/api/*`, `/metrics` |
|
||||||
| Личная hardened | `true` | `true` | только `/api/*` + `/metrics` |
|
| Личная hardened | `true` | `true` | `/api/*` + `/metrics` |
|
||||||
|
|
||||||
Флаги читаются и из CLI (`--disable-ui`, `--disable-swagger`), и из env.
|
Флаги читаются и из CLI (`--disable-ui`, `--disable-swagger`), и из env.
|
||||||
`/api/*` JSON-поверхность регистрируется всегда — отключить её можно
|
`/api/*` JSON-поверхность регистрируется всегда — отключить её можно
|
||||||
@@ -92,7 +94,7 @@ curl -s https://$DOMAIN/api/well-known-version
|
|||||||
#### Auto-update
|
#### Auto-update
|
||||||
|
|
||||||
```ini
|
```ini
|
||||||
# node.env — когда проект у вас в Gitea
|
# node.env — когда проект в Gitea
|
||||||
DCHAIN_UPDATE_SOURCE_URL=https://gitea.example.com/api/v1/repos/OWNER/REPO/releases/latest
|
DCHAIN_UPDATE_SOURCE_URL=https://gitea.example.com/api/v1/repos/OWNER/REPO/releases/latest
|
||||||
UPDATE_ALLOW_MAJOR=false
|
UPDATE_ALLOW_MAJOR=false
|
||||||
```
|
```
|
||||||
@@ -104,175 +106,102 @@ sudo systemctl daemon-reload
|
|||||||
sudo systemctl enable --now dchain-update.timer
|
sudo systemctl enable --now dchain-update.timer
|
||||||
```
|
```
|
||||||
|
|
||||||
Скрипт `deploy/single/update.sh` читает `/api/update-check`, делает
|
Подробно: [`docs/update-system.md`](docs/update-system.md) + [`deploy/UPDATE_STRATEGY.md`](deploy/UPDATE_STRATEGY.md).
|
||||||
semver-guarded `git checkout tag`, ребилдит образ с injected версией,
|
|
||||||
smoke-test `node --version`, recreate, health poll.
|
|
||||||
|
|
||||||
Подробные сценарии (первая нода, joiner, приватная, headless) +
|
|
||||||
полный API reference + systemd-интеграция + troubleshooting — в
|
|
||||||
**[`deploy/single/README.md`](deploy/single/README.md)**.
|
|
||||||
|
|
||||||
### 🔹 Multi-validator (`deploy/prod/`)
|
### 🔹 Multi-validator (`deploy/prod/`)
|
||||||
|
|
||||||
3 validator'а в PBFT-кворуме, Caddy с ip_hash для WS-стикинесса и
|
3 validator'а в PBFT-кворуме, Caddy с `ip_hash` для WS-стикинесса и
|
||||||
least-conn для REST. Для федераций / консорциумов — см.
|
`least-conn` для REST. Для федераций / консорциумов — см.
|
||||||
`deploy/prod/README.md`.
|
[`deploy/prod/README.md`](deploy/prod/README.md) и
|
||||||
|
[`docs/node/multi-server.md`](docs/node/multi-server.md).
|
||||||
## Клиент
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd client-app
|
|
||||||
npm install
|
|
||||||
npx expo start # Expo Dev Tools — iOS / Android / Web
|
|
||||||
```
|
|
||||||
|
|
||||||
React Native + Expo + NativeWind. Ключевые экраны:
|
|
||||||
|
|
||||||
| Экран | Описание |
|
|
||||||
|-------|----------|
|
|
||||||
| Welcome / Create Account | Генерация Ed25519 + X25519 keypair |
|
|
||||||
| Chat List | Диалоги + real-time via WS (`inbox:<x25519>`) |
|
|
||||||
| Chat | E2E NaCl, typing-индикатор, day-separators |
|
|
||||||
| Contact Requests | Входящие запросы (push через `addr:<pub>`) |
|
|
||||||
| Add Contact | Поиск по `@username` (native registry) или hex |
|
|
||||||
| Wallet | Баланс, история (push), кошельковые action buttons |
|
|
||||||
| Settings | Нода, `@username` покупка, экспорт ключа |
|
|
||||||
|
|
||||||
Клиент подсоединяется к одной ноде через HTTP + WebSocket:
|
|
||||||
- **WS для всего real-time** (balance, inbox, contacts, tx commit).
|
|
||||||
- **HTTP fallback** через 15 s после обрыва, автоматически.
|
|
||||||
- **Auto-discovery** канонического `username_registry` через
|
|
||||||
`/api/well-known-contracts`.
|
|
||||||
|
|
||||||
## Архитектура
|
## Архитектура
|
||||||
|
|
||||||
```
|
```
|
||||||
┌────────────┐ libp2p ┌────────────┐ libp2p ┌────────────┐
|
┌────────────┐ libp2p ┌────────────┐ libp2p ┌────────────┐
|
||||||
│ node1 │◄─────pubsub──►│ node2 │◄─────pubsub──►│ node3 │
|
│ node-A │◄─────pubsub──►│ node-B │◄─────pubsub──►│ node-C │
|
||||||
│ validator │ │ validator │ │ validator │
|
│ validator │ │ validator │ │ validator │
|
||||||
│ + relay │ │ + relay │ │ + relay │
|
│ + relay │ │ + relay │ │ + relay │
|
||||||
└─────┬──────┘ └─────┬──────┘ └─────┬──────┘
|
└─────┬──────┘ └─────┬──────┘ └─────┬──────┘
|
||||||
│ │ │
|
│ │ │
|
||||||
│ HTTPS / wss (via Caddy) │ │
|
│ HTTPS / wss (via Caddy) │ │
|
||||||
▼ ▼ ▼
|
▼ ▼ ▼
|
||||||
mobile / web / CLI clients (load-balanced ip_hash for WS)
|
mobile / web / CLI clients (ip_hash для WS, least-conn для REST)
|
||||||
```
|
```
|
||||||
|
|
||||||
Слои (`blockchain/`):
|
Четыре слоя: network → chain → transport → app. Детали — в
|
||||||
- `chain.go` — блочная машина (applyTx, AddBlock, BadgerDB)
|
[`docs/architecture.md`](docs/architecture.md).
|
||||||
- `native.go` — системные Go-контракты (интерфейс + registry)
|
|
||||||
- `native_username.go` — реализация username_registry
|
|
||||||
- `equivocation.go` — проверка evidence для SLASH
|
|
||||||
- `types.go` — транзакции, payloads
|
|
||||||
|
|
||||||
Сети (`p2p/`):
|
Основные пакеты:
|
||||||
- gossipsub topics `dchain/tx/v1`, `dchain/blocks/v1`
|
|
||||||
- стрим-протокол `/dchain/sync/1.0.0` для catch-up
|
|
||||||
- mDNS + DHT для peer discovery
|
|
||||||
|
|
||||||
Консенсус (`consensus/pbft.go`):
|
| Путь | Роль |
|
||||||
- Pre-prepare → Prepare → Commit, 2/3 quorum
|
|------|------|
|
||||||
- liveness tracking, equivocation detection (`recordVote`, `TakeEvidence`)
|
| `blockchain/` | Блочная машина, applyTx, native-контракты, schema-migrations |
|
||||||
- per-sender FIFO мемпул + round-robin drain до `MaxTxsPerBlock`
|
| `consensus/` | PBFT (Pre-prepare → Prepare → Commit), мемпул, equivocation |
|
||||||
|
| `p2p/` | libp2p host, gossipsub, sync протокол, peer-version gossip |
|
||||||
Node service layer (`node/`):
|
| `node/` | HTTP + WS API, SSE, metrics, access control |
|
||||||
- HTTP API + SSE + **WebSocket hub** с auth и topic-based fanout
|
| `node/version/` | Build-time version metadata (ldflags-инжектимый) |
|
||||||
- Prometheus `/metrics` (zero-dep)
|
| `vm/` | wazero runtime для WASM-контрактов + gas model |
|
||||||
- event bus (`events.go`) — SSE + WS + future consumers с одного emit'а
|
| `relay/` | E2E mailbox с NaCl-envelopes |
|
||||||
- rate-limiter + body-size cap на tx submit
|
| `identity/` | Ed25519 + X25519 keypair, tx signing |
|
||||||
|
| `economy/` | Fee model, rewards |
|
||||||
Relay (`relay/`):
|
| `wallet/` | Optional payout wallet (отдельный ключ) |
|
||||||
- encrypted envelope routing через gossipsub
|
|
||||||
- BadgerDB mailbox для offline-получателей (TTL 7 дней)
|
|
||||||
- `SetOnStore` hook push'ит новые envelopes в WS
|
|
||||||
|
|
||||||
## Контракты
|
|
||||||
|
|
||||||
**Системные (native Go)** — зарегистрированы при запуске ноды:
|
|
||||||
|
|
||||||
| ID | Назначение | ABI |
|
|
||||||
|----|-----------|-----|
|
|
||||||
| `native:username_registry` | `@username` → адрес | `register`, `resolve`, `lookup`, `transfer`, `release` |
|
|
||||||
|
|
||||||
**Пользовательские (WASM)** — деплоятся через `DEPLOY_CONTRACT` tx.
|
|
||||||
TinyGo SDK + host functions (get_state, transfer, get_caller, log,
|
|
||||||
call_contract, …). Ручной деплой из `scripts/deploy_contracts.sh` или
|
|
||||||
CLI `client deploy-contract --wasm ...`.
|
|
||||||
|
|
||||||
Обзор ABI / примеры — `docs/contracts/` (отдельные файлы на auction,
|
|
||||||
escrow, governance, username_registry).
|
|
||||||
|
|
||||||
## REST / WebSocket API
|
## REST / WebSocket API
|
||||||
|
|
||||||
### Chain
|
Обзор (полный reference — [`docs/api/README.md`](docs/api/README.md)):
|
||||||
|
|
||||||
|
### Chain + stats
|
||||||
| Endpoint | Описание |
|
| Endpoint | Описание |
|
||||||
|----------|----------|
|
|----------|----------|
|
||||||
| `GET /api/netstats` | total_blocks, tx_count, supply, peer count |
|
| `GET /api/netstats` | height, total_txs, supply, validator_count |
|
||||||
| `GET /api/blocks?limit=N` | Последние блоки |
|
| `GET /api/network-info` | chain_id, genesis, peers, validators, contracts |
|
||||||
| `GET /api/block/{index}` | Конкретный блок |
|
| `GET /api/blocks?limit=N` / `/api/block/{index}` | Блоки |
|
||||||
| `GET /api/tx/{id}` | Транзакция по ID |
|
| `GET /api/tx/{id}` / `/api/txs/recent?limit=N` | Транзакции |
|
||||||
| `GET /api/txs/recent?limit=N` | Последние tx (O(limit)) |
|
| `GET /api/address/{pub_or_addr}` | Баланс + история |
|
||||||
| `GET /api/address/{pubkey}` | Баланс + история tx |
|
| `GET /api/validators` | Validator set |
|
||||||
| `GET /api/validators` | Активный validator set |
|
| `GET /api/peers` | Connected peers + их версии |
|
||||||
| `GET /api/network-info` | One-shot bootstrap payload (chain_id, genesis, peers, validators, contracts) |
|
|
||||||
| `GET /api/peers` | Живые libp2p peer'ы |
|
|
||||||
| `GET /api/well-known-contracts` | Канонические контракты (native + WASM) |
|
|
||||||
| `GET /api/contracts/{id}` | Метаданные контракта, `"native": true/false` |
|
|
||||||
| `GET /api/contracts/{id}/state/{key}` | Прямое чтение state |
|
|
||||||
| `GET /api/relays` | Список relay-нод (filtered TTL) |
|
|
||||||
| `POST /api/tx` | Submit signed tx (rate-limited, size-capped) |
|
| `POST /api/tx` | Submit signed tx (rate-limited, size-capped) |
|
||||||
|
|
||||||
|
### Discovery + update
|
||||||
|
| Endpoint | Описание |
|
||||||
|
|----------|----------|
|
||||||
|
| `GET /api/well-known-version` | `{node_version, build{}, protocol_version, features[], chain_id}` |
|
||||||
|
| `GET /api/well-known-contracts` | `{name → contract_id}` map |
|
||||||
|
| `GET /api/update-check` | Diff с Gitea release (см. [`docs/update-system.md`](docs/update-system.md)) |
|
||||||
|
|
||||||
### Real-time
|
### Real-time
|
||||||
|
- `GET /api/ws` — **WebSocket** (recommended). Ops: `auth`, `subscribe`,
|
||||||
|
`unsubscribe`, `submit_tx`, `typing`, `ping`. Push: `block`, `tx`,
|
||||||
|
`inbox`, `typing`, `submit_ack`.
|
||||||
|
- `GET /api/events` — SSE legacy one-way.
|
||||||
|
|
||||||
- `GET /api/events` — Server-Sent Events (classic, 1-way)
|
Scoped WS-топики (`addr:`, `inbox:`, `typing:`) требуют auth через
|
||||||
- `GET /api/ws` — **WebSocket** (bidirectional, recommended)
|
Ed25519-nonce; публичные (`blocks`, `tx`, `contract_log`) — без.
|
||||||
|
|
||||||
WS protocol — см. `node/ws.go`:
|
### Docs / UI
|
||||||
|
- `GET /swagger` — **Swagger UI** (рендерится через swagger-ui-dist).
|
||||||
```json
|
- `GET /swagger/openapi.json` — сырая OpenAPI 3.0 спека.
|
||||||
{ "op": "auth", "pubkey": "...", "sig": "..." }
|
- `GET /` — block-explorer HTML (выключается `DCHAIN_DISABLE_UI=true`).
|
||||||
{ "op": "subscribe", "topic": "addr:..." | "inbox:..." | "typing:..." | "blocks" | "tx" }
|
|
||||||
{ "op": "unsubscribe", "topic": "..." }
|
|
||||||
{ "op": "submit_tx", "tx": {...}, "id": "client-req-id" }
|
|
||||||
{ "op": "typing", "to": "<x25519>" }
|
|
||||||
{ "op": "ping" }
|
|
||||||
```
|
|
||||||
|
|
||||||
Server push:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{ "event": "hello", "chain_id": "...", "auth_nonce": "..." }
|
|
||||||
{ "event": "block", "data": {...} }
|
|
||||||
{ "event": "tx", "data": {...} }
|
|
||||||
{ "event": "inbox", "data": { id, recipient_pub, sender_pub, sent_at } }
|
|
||||||
{ "event": "typing", "data": { from, to } }
|
|
||||||
{ "event": "submit_ack", "id": "...", "status": "accepted|rejected", "reason": "..." }
|
|
||||||
```
|
|
||||||
|
|
||||||
Scoped топики (`addr:`, `inbox:`, `typing:`) требуют auth. Без auth
|
|
||||||
доступны только публичные (`blocks`, `tx`, `contract_log`).
|
|
||||||
|
|
||||||
## CLI
|
## CLI
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
client keygen --out key.json
|
client keygen --out key.json
|
||||||
|
client --version # → dchain-client vX.Y.Z (commit=... date=...)
|
||||||
client balance --key key.json --node URL
|
client balance --key key.json --node URL
|
||||||
client transfer --key key.json --to <pub> --amount <µT> --node URL
|
client transfer --key key.json --to <pub> --amount <µT> --node URL
|
||||||
client call-contract --key key.json --contract native:username_registry \
|
client call-contract --key key.json --contract native:username_registry \
|
||||||
--method register --args '["alice"]' --amount 10000 \
|
--method register --arg alice --amount 10000 \
|
||||||
--node URL
|
--node URL
|
||||||
client add-validator --key key.json --target <pub> --cosigs pub:sig,pub:sig
|
client add-validator --key key.json --target <pub> --cosigs pub:sig,pub:sig
|
||||||
client admit-sign --key validator.json --target <candidate-pub>
|
client admit-sign --key validator.json --target <candidate-pub>
|
||||||
client remove-validator --key key.json --target <pub>
|
client deploy-contract --key key.json --wasm ./my.wasm --abi ./my_abi.json --node URL
|
||||||
# ... полный список — `client` без аргументов
|
# полный список — `client` без аргументов, или в docs/cli/README.md
|
||||||
```
|
```
|
||||||
|
|
||||||
Node flags (все читают `DCHAIN_*` env fallbacks):
|
Все флаги `node` — в [`docs/node/README.md`](docs/node/README.md), либо
|
||||||
- `--db`, `--listen`, `--announce`, `--peers`, `--validators`
|
`node --help`.
|
||||||
- `--join http://seed1:8080,http://seed2:8080` (multi-seed, persisted)
|
|
||||||
- `--genesis`, `--observer`, `--register-relay`
|
|
||||||
- `--log-format text|json`, `--allow-genesis-mismatch`
|
|
||||||
|
|
||||||
## Мониторинг
|
## Мониторинг
|
||||||
|
|
||||||
@@ -289,22 +218,39 @@ dchain_max_missed_blocks # worst validator liveness gap
|
|||||||
dchain_block_commit_seconds # histogram of AddBlock time
|
dchain_block_commit_seconds # histogram of AddBlock time
|
||||||
```
|
```
|
||||||
|
|
||||||
Grafana dashboard + provisioning — `deploy/prod/grafana/` (create as
|
Grafana + Prometheus поднимаются вместе с нодой через
|
||||||
needed; Prometheus data source is auto-wired in compose profile
|
`docker compose --profile monitor up -d` (см. `deploy/single/docker-compose.yml`).
|
||||||
`monitor`).
|
|
||||||
|
|
||||||
## Тесты
|
## Тесты
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
go test ./... # blockchain + consensus + relay + identity + vm
|
# Unit + integration
|
||||||
|
go test ./... # blockchain + consensus + identity + relay + vm
|
||||||
|
go vet ./... # static checks
|
||||||
|
|
||||||
|
# End-to-end load (3-node dev cluster должен быть поднят):
|
||||||
|
docker compose up --build -d
|
||||||
go run ./cmd/loadtest \
|
go run ./cmd/loadtest \
|
||||||
--node http://localhost:8081 \
|
--node http://localhost:8081 \
|
||||||
--funder testdata/node1.json \
|
--funder testdata/node1.json \
|
||||||
--clients 50 --duration 60s # end-to-end WS + submit_tx + native contract path
|
--clients 50 --duration 60s
|
||||||
```
|
```
|
||||||
|
|
||||||
## История изменений
|
## Документация
|
||||||
|
|
||||||
Подробный список того, что сделано за последнюю итерацию (стабилизация
|
Полный справочник в `docs/` — см. [`docs/README.md`](docs/README.md) как
|
||||||
чейна, governance, WS gateway, observability, native contracts,
|
оглавление. Ключевые документы:
|
||||||
deployment) — `CHANGELOG.md`.
|
|
||||||
|
| Документ | О чём |
|
||||||
|
|----------|-------|
|
||||||
|
| [`docs/quickstart.md`](docs/quickstart.md) | Три пути: локально, single-node prod, федерация |
|
||||||
|
| [`docs/architecture.md`](docs/architecture.md) | 4 слоя, consensus, storage, gas model |
|
||||||
|
| [`docs/update-system.md`](docs/update-system.md) | Versioning + update-check + semver guard |
|
||||||
|
| [`docs/api/README.md`](docs/api/README.md) | REST + WebSocket reference |
|
||||||
|
| [`docs/cli/README.md`](docs/cli/README.md) | CLI команды и флаги |
|
||||||
|
| [`docs/node/README.md`](docs/node/README.md) | Запуск ноды (native + Docker) |
|
||||||
|
| [`docs/contracts/README.md`](docs/contracts/README.md) | Системные + WASM контракты |
|
||||||
|
| [`docs/development/README.md`](docs/development/README.md) | SDK для своих контрактов |
|
||||||
|
| [`deploy/single/README.md`](deploy/single/README.md) | Single-node operator runbook |
|
||||||
|
| [`deploy/prod/README.md`](deploy/prod/README.md) | Multi-validator federation |
|
||||||
|
| [`deploy/UPDATE_STRATEGY.md`](deploy/UPDATE_STRATEGY.md) | Forward-compat design (4 слоя) |
|
||||||
|
|||||||
0
client-app/.gitignore
vendored
0
client-app/.gitignore
vendored
@@ -1,93 +0,0 @@
|
|||||||
# 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.
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
{
|
|
||||||
"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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,127 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,413 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
import { Stack } from 'expo-router';
|
|
||||||
|
|
||||||
export default function ChatsLayout() {
|
|
||||||
return (
|
|
||||||
<Stack screenOptions={{ headerShown: false }} />
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,337 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,332 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,236 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,732 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,596 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,82 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,118 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,301 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,220 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
module.exports = function (api) {
|
|
||||||
api.cache(true);
|
|
||||||
return {
|
|
||||||
presets: [
|
|
||||||
['babel-preset-expo', { jsxImportSource: 'nativewind' }],
|
|
||||||
'nativewind/babel',
|
|
||||||
],
|
|
||||||
plugins: [
|
|
||||||
'react-native-reanimated/plugin', // must be last
|
|
||||||
],
|
|
||||||
};
|
|
||||||
};
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
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';
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
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)} />;
|
|
||||||
}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
export { Button } from './Button';
|
|
||||||
export { Card } from './Card';
|
|
||||||
export { Input } from './Input';
|
|
||||||
export { Avatar } from './Avatar';
|
|
||||||
export { Badge } from './Badge';
|
|
||||||
export { Separator } from './Separator';
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
@tailwind base;
|
|
||||||
@tailwind components;
|
|
||||||
@tailwind utilities;
|
|
||||||
@@ -1,94 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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 };
|
|
||||||
}
|
|
||||||
@@ -1,80 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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]);
|
|
||||||
}
|
|
||||||
@@ -1,123 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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]);
|
|
||||||
}
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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
|
|
||||||
}
|
|
||||||
@@ -1,701 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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;
|
|
||||||
}
|
|
||||||
@@ -1,156 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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)}`;
|
|
||||||
}
|
|
||||||
@@ -1,101 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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));
|
|
||||||
}
|
|
||||||
@@ -1,103 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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 }),
|
|
||||||
}));
|
|
||||||
@@ -1,86 +0,0 @@
|
|||||||
// ─── 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
|
|
||||||
}
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
@@ -1,401 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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;
|
|
||||||
}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
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
1
client-app/nativewind-env.d.ts
vendored
@@ -1 +0,0 @@
|
|||||||
/// <reference types="nativewind/types" />
|
|
||||||
10317
client-app/package-lock.json
generated
10317
client-app/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,51 +0,0 @@
|
|||||||
{
|
|
||||||
"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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
/** @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: [],
|
|
||||||
};
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
{
|
|
||||||
"extends": "expo/tsconfig.base",
|
|
||||||
"compilerOptions": {
|
|
||||||
"strict": true,
|
|
||||||
"paths": {
|
|
||||||
"@/*": ["./*"]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
{
|
|
||||||
"contract": "hello_go",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"description": "Example DChain contract written in Go (TinyGo SDK). Demonstrates counter, owner-gated reset, string args, and inter-contract calls.",
|
|
||||||
"build": "tinygo build -o hello_go.wasm -target wasip1 -no-debug .",
|
|
||||||
"methods": [
|
|
||||||
{
|
|
||||||
"name": "increment",
|
|
||||||
"description": "Add 1 to the counter. Logs 'counter: N'.",
|
|
||||||
"args": []
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "get",
|
|
||||||
"description": "Log the current counter value without changing state.",
|
|
||||||
"args": []
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "reset",
|
|
||||||
"description": "Reset counter to 0. First caller becomes the owner; only owner can reset.",
|
|
||||||
"args": []
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "greet",
|
|
||||||
"description": "Log a greeting. Logs 'hello, <name>! block=N'.",
|
|
||||||
"args": [
|
|
||||||
{"name": "name", "type": "string", "description": "Name to greet (defaults to 'world')"}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "ping",
|
|
||||||
"description": "Call a method on another contract (inter-contract call demo).",
|
|
||||||
"args": [
|
|
||||||
{"name": "contract_id", "type": "string", "description": "Target contract ID"},
|
|
||||||
{"name": "method", "type": "string", "description": "Method to call on target"}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1,104 +0,0 @@
|
|||||||
// Package main is an example DChain smart contract written in Go.
|
|
||||||
//
|
|
||||||
// # Build
|
|
||||||
//
|
|
||||||
// tinygo build -o hello_go.wasm -target wasip1 -no-debug .
|
|
||||||
//
|
|
||||||
// # Deploy
|
|
||||||
//
|
|
||||||
// client deploy-contract --key /keys/node1.json \
|
|
||||||
// --wasm hello_go.wasm --abi hello_go_abi.json \
|
|
||||||
// --node http://localhost:8081
|
|
||||||
//
|
|
||||||
// # Use
|
|
||||||
//
|
|
||||||
// client call-contract --key /keys/node1.json --contract $ID \
|
|
||||||
// --method increment --gas 5000 --node http://localhost:8081
|
|
||||||
//
|
|
||||||
// The contract implements a simple counter with owner-gated reset.
|
|
||||||
// It demonstrates every SDK primitive: arguments, state, caller, logging,
|
|
||||||
// and inter-contract calls.
|
|
||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"strconv"
|
|
||||||
|
|
||||||
dc "go-blockchain/contracts/sdk"
|
|
||||||
)
|
|
||||||
|
|
||||||
// increment adds 1 to the counter and logs the new value.
|
|
||||||
//
|
|
||||||
//export increment
|
|
||||||
func increment() {
|
|
||||||
v := dc.GetU64("counter")
|
|
||||||
v++
|
|
||||||
dc.PutU64("counter", v)
|
|
||||||
dc.Log("counter: " + strconv.FormatUint(v, 10))
|
|
||||||
}
|
|
||||||
|
|
||||||
// get logs the current counter value without changing state.
|
|
||||||
//
|
|
||||||
//export get
|
|
||||||
func get() {
|
|
||||||
v := dc.GetU64("counter")
|
|
||||||
dc.Log("counter: " + strconv.FormatUint(v, 10))
|
|
||||||
}
|
|
||||||
|
|
||||||
// reset sets the counter to 0. On first call the caller becomes the owner.
|
|
||||||
// Subsequent calls are restricted to the owner.
|
|
||||||
//
|
|
||||||
//export reset
|
|
||||||
func reset() {
|
|
||||||
owner := dc.GetStateStr("owner")
|
|
||||||
caller := dc.Caller()
|
|
||||||
if owner == "" {
|
|
||||||
dc.SetStateStr("owner", caller)
|
|
||||||
dc.PutU64("counter", 0)
|
|
||||||
dc.Log("initialized — owner: " + caller[:min8(caller)])
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if caller != owner {
|
|
||||||
dc.Log("unauthorized: " + caller[:min8(caller)])
|
|
||||||
return
|
|
||||||
}
|
|
||||||
dc.PutU64("counter", 0)
|
|
||||||
dc.Log("reset by owner")
|
|
||||||
}
|
|
||||||
|
|
||||||
// greet logs a personalised greeting using the first call argument.
|
|
||||||
//
|
|
||||||
//export greet
|
|
||||||
func greet() {
|
|
||||||
name := dc.ArgStr(0, 64)
|
|
||||||
if name == "" {
|
|
||||||
name = "world"
|
|
||||||
}
|
|
||||||
dc.Log("hello, " + name + "! block=" + strconv.FormatUint(dc.BlockHeight(), 10))
|
|
||||||
}
|
|
||||||
|
|
||||||
// ping calls another contract's method (demonstrates inter-contract calls).
|
|
||||||
// Args: contract_id (string), method (string)
|
|
||||||
//
|
|
||||||
//export ping
|
|
||||||
func ping() {
|
|
||||||
target := dc.ArgStr(0, 64)
|
|
||||||
method := dc.ArgStr(1, 64)
|
|
||||||
if target == "" || method == "" {
|
|
||||||
dc.Log("ping: target and method required")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if dc.CallContract(target, method, "[]") {
|
|
||||||
dc.Log("ping: " + method + " ok")
|
|
||||||
} else {
|
|
||||||
dc.Log("ping: " + method + " failed")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func min8(s string) int {
|
|
||||||
if len(s) < 8 {
|
|
||||||
return len(s)
|
|
||||||
}
|
|
||||||
return 8
|
|
||||||
}
|
|
||||||
|
|
||||||
func main() {}
|
|
||||||
34
docs/README.md
Normal file
34
docs/README.md
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
# DChain documentation
|
||||||
|
|
||||||
|
Справочник по блокчейн-ноде DChain. Читается в любом порядке, но если в первый
|
||||||
|
раз — идите по разделам сверху вниз.
|
||||||
|
|
||||||
|
## Оглавление
|
||||||
|
|
||||||
|
| Документ | О чём |
|
||||||
|
|----------|-------|
|
||||||
|
| [quickstart.md](quickstart.md) | Поднять ноду локально за 5 минут |
|
||||||
|
| [architecture.md](architecture.md) | 4 слоя стека: network / chain / transport / app |
|
||||||
|
| [node/README.md](node/README.md) | Запуск ноды (docker, native, deployment) |
|
||||||
|
| [update-system.md](update-system.md) | Версионирование, `/api/update-check`, auto-update от Gitea |
|
||||||
|
| [api/README.md](api/README.md) | REST + WebSocket API endpoints |
|
||||||
|
| [cli/README.md](cli/README.md) | CLI `client` — команды, флаги, примеры |
|
||||||
|
| [contracts/README.md](contracts/README.md) | Системные контракты (native + WASM) |
|
||||||
|
| [development/README.md](development/README.md) | SDK для написания своих контрактов (TinyGo) |
|
||||||
|
| [node/governance.md](node/governance.md) | On-chain governance, голосование параметров |
|
||||||
|
| [node/multi-server.md](node/multi-server.md) | Multi-validator federation deploy |
|
||||||
|
|
||||||
|
## Внешние ссылки из репо
|
||||||
|
|
||||||
|
- [README.md](../README.md) — обзор проекта
|
||||||
|
- [deploy/single/README.md](../deploy/single/README.md) — operator runbook для single-node
|
||||||
|
- [deploy/prod/README.md](../deploy/prod/README.md) — operator runbook для multi-validator
|
||||||
|
- [deploy/UPDATE_STRATEGY.md](../deploy/UPDATE_STRATEGY.md) — дизайн forward-compat обновлений (4 слоя)
|
||||||
|
|
||||||
|
## Соглашения
|
||||||
|
|
||||||
|
- **Единицы:** микро-токены (`µT`). 1 T = 1,000,000 µT. MinFee = 1000 µT = 0.001 T.
|
||||||
|
- **Pubkey:** 32-байтный Ed25519 (hex, 64 символа).
|
||||||
|
- **Address:** `DC` + первые 24 hex-символа SHA-256(pubkey).
|
||||||
|
- **ChainID:** `dchain-` + первые 12 hex-символов SHA-256 genesis-блока.
|
||||||
|
- **Времена:** RFC 3339 (UTC) везде, кроме internal-счётчиков в Prometheus (unix seconds).
|
||||||
@@ -1,45 +1,72 @@
|
|||||||
# REST API
|
# REST + WebSocket API
|
||||||
|
|
||||||
DChain-нода предоставляет HTTP API на порту `--stats-addr` (по умолчанию `:8080`).
|
DChain-нода обслуживает HTTP + WebSocket на `--stats-addr` (по умолчанию `:8080`).
|
||||||
|
JSON везде; ошибки в формате `{"error": "описание"}`, с 4xx для клиентских
|
||||||
|
проблем и 5xx для серверных.
|
||||||
|
|
||||||
## Базовые URL
|
Полная OpenAPI-спека живёт в бинаре (`/swagger/openapi.json`) и рендерится
|
||||||
|
Swagger UI на `/swagger`. Эти два эндпоинта можно выключить через
|
||||||
|
`DCHAIN_DISABLE_SWAGGER=true` — см. [deploy/single/README.md](../../deploy/single/README.md).
|
||||||
|
|
||||||
| Окружение | URL |
|
## Разделы
|
||||||
|---------|-----|
|
|
||||||
| Локально | `http://localhost:8081` |
|
|
||||||
| Docker node1 | `http://node1:8080` |
|
|
||||||
| Docker node2 | `http://node2:8080` |
|
|
||||||
| Docker node3 | `http://node3:8080` |
|
|
||||||
|
|
||||||
## Разделы API
|
|
||||||
|
|
||||||
| Документ | Эндпоинты |
|
| Документ | Эндпоинты |
|
||||||
|---------|----------|
|
|---------|----------|
|
||||||
| [Chain API](chain.md) | Блоки, транзакции, балансы, адреса, stats |
|
| [chain.md](chain.md) | Блоки, транзакции, балансы, адреса, netstats, validators |
|
||||||
| [Contracts API](contracts.md) | Деплой, вызов, state, логи |
|
| [contracts.md](contracts.md) | Деплой, вызов, state, логи контрактов |
|
||||||
| [Relay API](relay.md) | Отправка сообщений, inbox, контакты |
|
| [relay.md](relay.md) | Relay-mailbox: отправка/приём encrypted envelopes |
|
||||||
|
|
||||||
## Формат ошибок
|
## Discovery / metadata (always on)
|
||||||
|
|
||||||
```json
|
| Endpoint | Возврат |
|
||||||
{"error": "описание ошибки"}
|
|----------|---------|
|
||||||
```
|
| `GET /api/netstats` | height, total_txs, supply, validator_count |
|
||||||
|
| `GET /api/network-info` | chain_id, genesis_hash, peers[], validators[], contracts |
|
||||||
|
| `GET /api/well-known-version` | node_version, build{}, protocol_version, features[], chain_id |
|
||||||
|
| `GET /api/well-known-contracts` | canonical contract_id → name mapping |
|
||||||
|
| `GET /api/update-check` | comparison with upstream Gitea release (см. [../update-system.md](../update-system.md)) |
|
||||||
|
| `GET /api/peers` | connected peer IDs + их версии (из gossip `dchain/version/v1`) |
|
||||||
|
| `GET /api/validators` | active validator set |
|
||||||
|
| `GET /metrics` | Prometheus exposition |
|
||||||
|
|
||||||
HTTP-статус: 400 для клиентских ошибок, 500 для серверных.
|
## Real-time (push)
|
||||||
|
|
||||||
|
- **`GET /api/ws`** — WebSocket, рекомендованный транспорт. Поддерживает
|
||||||
|
`submit_tx`, `subscribe`, `unsubscribe`, `typing`, `ping`. Push-события:
|
||||||
|
`block`, `tx`, `inbox`, `typing`, `submit_ack`.
|
||||||
|
- `GET /api/events` — SSE, legacy одностороннее streaming (для старых клиентов).
|
||||||
|
|
||||||
|
Protocol reference — `node/ws.go` + [../architecture.md](../architecture.md).
|
||||||
|
|
||||||
## Аутентификация
|
## Аутентификация
|
||||||
|
|
||||||
REST API не требует аутентификации. Транзакции подписываются на стороне клиента (CLI-командами) и отправляются как подписанные JSON-объекты. API не имеет admin-эндпоинтов требующих токенов.
|
По умолчанию API **публичен**. Три режима access-control настраиваются через
|
||||||
|
env:
|
||||||
|
|
||||||
## Пример
|
| Режим | `DCHAIN_API_TOKEN` | `DCHAIN_API_PRIVATE` | Эффект |
|
||||||
|
|-------|:------------------:|:--------------------:|--------|
|
||||||
|
| Public | не задан | — | Все могут читать и submit'ить tx |
|
||||||
|
| Token writes | задан | `false` | Читать — любой; `POST /api/tx` и WS `submit_tx` — только с `Authorization: Bearer <token>` |
|
||||||
|
| Fully private | задан | `true` | Все эндпоинты требуют token (включая `/api/netstats`) |
|
||||||
|
|
||||||
|
WebSocket: токен передаётся query-параметром `?token=<...>`.
|
||||||
|
|
||||||
|
## Примеры
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Статистика сети
|
# Базовый health
|
||||||
curl http://localhost:8081/api/stats
|
curl -s http://localhost:8080/api/netstats | jq .
|
||||||
|
|
||||||
# Баланс адреса
|
# Версия ноды
|
||||||
curl http://localhost:8081/api/balance/03a1b2c3...
|
curl -s http://localhost:8080/api/well-known-version | jq '{tag: .build.tag, features: .features}'
|
||||||
|
|
||||||
# Последние блоки
|
# Список пиров с их версиями
|
||||||
curl http://localhost:8081/api/blocks?limit=10
|
curl -s http://localhost:8080/api/peers | jq '.peers[].version'
|
||||||
|
|
||||||
|
# С токеном (private-режим)
|
||||||
|
curl -s -H "Authorization: Bearer $TOKEN" http://localhost:8080/api/netstats
|
||||||
|
|
||||||
|
# Submit tx (public-режим)
|
||||||
|
curl -s -X POST -H "Content-Type: application/json" \
|
||||||
|
-d @tx.json http://localhost:8080/api/tx
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -1,59 +1,85 @@
|
|||||||
# Production Smart Contracts
|
# Smart contracts
|
||||||
|
|
||||||
DChain поставляется с четырьмя production-контрактами, задеплоенными из genesis-кошелька.
|
DChain поддерживает **два типа** контрактов — native Go (запечены в
|
||||||
|
бинарь ноды) и WASM (деплоятся on-chain через `DEPLOY_CONTRACT` tx).
|
||||||
|
|
||||||
## Обзор
|
## Native (Go, скомпилированы в бинарь)
|
||||||
|
|
||||||
| Контракт | Назначение | Ключевые фичи |
|
Живут в `blockchain/native*.go`. Видны как `native:<name>`, работают на
|
||||||
|---------|-----------|--------------|
|
каждой ноде идентично (тот же Go-код — нет WASM-drift'а).
|
||||||
| [username_registry](username_registry.md) | Username ↔ адрес | Тарифная сетка, treasury fees, reverse lookup |
|
|
||||||
|
| Контракт | ID | Назначение |
|
||||||
|
|----------|----|-----------|
|
||||||
|
| [username_registry](username_registry.md) | `native:username_registry` | `@username` ↔ pubkey, плата 10_000 µT, min 4 символа |
|
||||||
|
|
||||||
|
Клиент находит `native:username_registry` автоматически через
|
||||||
|
`/api/well-known-contracts` — без конфигурирования.
|
||||||
|
|
||||||
|
## WASM (user-deployable)
|
||||||
|
|
||||||
|
Деплоятся любым обладателем баланса через `client deploy-contract`.
|
||||||
|
Исполняются в wazero runtime с gas limit и set'ом host-функций.
|
||||||
|
|
||||||
|
| Контракт | Назначение | Key features |
|
||||||
|
|----------|------------|--------------|
|
||||||
| [governance](governance.md) | On-chain параметры | Propose/approve workflow, admin role |
|
| [governance](governance.md) | On-chain параметры | Propose/approve workflow, admin role |
|
||||||
| [auction](auction.md) | English auction | Token escrow, автоматический refund, settle |
|
| [auction](auction.md) | English auction | Token escrow, автоматический refund, settle |
|
||||||
| [escrow](escrow.md) | Двусторонний escrow | Dispute/resolve, admin arbitration |
|
| [escrow](escrow.md) | Двусторонний escrow | Dispute/resolve, admin arbitration |
|
||||||
|
|
||||||
## Деплой
|
Исходники + WASM-артефакты — в `contracts/<name>/`. SDK для написания
|
||||||
|
новых контрактов — [../development/README.md](../development/README.md).
|
||||||
|
|
||||||
|
## Деплой WASM
|
||||||
|
|
||||||
|
Контракты **не деплоятся автоматически** — это выбор оператора.
|
||||||
|
Скрипт-хелпер:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose --profile deploy run --rm deploy
|
./scripts/deploy_contracts.sh # деплоит auction + escrow + governance из CWD
|
||||||
```
|
```
|
||||||
|
|
||||||
Все 4 контракта деплоятся автоматически. ID сохраняются в `/tmp/contracts.env`.
|
Или вручную:
|
||||||
|
|
||||||
## Вызов контракта
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker exec node1 client call-contract \
|
client deploy-contract \
|
||||||
--key /keys/node1.json \
|
--key node.json \
|
||||||
--contract <CONTRACT_ID> \
|
--wasm contracts/auction/auction.wasm \
|
||||||
--method <METHOD> \
|
--abi contracts/auction/auction_abi.json \
|
||||||
--arg <STRING_ARG> # строковый аргумент (можно несколько)
|
--node http://localhost:8080
|
||||||
--arg64 <UINT64_ARG> # числовой аргумент uint64
|
|
||||||
--gas <GAS_LIMIT> # рекомендуется 20000 для записи, 5000 для чтения
|
|
||||||
--node http://node1:8080
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Contract Treasury
|
После деплоя `contract_id` печатается в stdout — сохраните его.
|
||||||
|
|
||||||
У каждого контракта есть **ownerless treasury address** — `hex(sha256(contractID + ":treasury"))`.
|
## Вызов контракта (любой типа)
|
||||||
Это эскроу-адрес без private key. Только сам контракт может снять с него деньги через host function `transfer`.
|
|
||||||
|
|
||||||
Используется в `auction` и `escrow` для хранения заблокированных токенов.
|
|
||||||
|
|
||||||
## Просмотр состояния
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Через REST API
|
client call-contract \
|
||||||
curl http://localhost:8081/api/contracts/<ID>/state/<key>
|
--key node.json \
|
||||||
|
--contract <contract_id_or_native:name> \
|
||||||
# Через Explorer
|
--method <method> \
|
||||||
open http://localhost:8081/contract?id=<ID>
|
--arg <string_arg> # можно повторять
|
||||||
|
--arg64 <uint64_arg> # числовые аргументы
|
||||||
|
--gas 20000 # reasonable default; read-only методы: 5000
|
||||||
|
--amount 0 # tx.Amount (для методов с платой — см. username_registry)
|
||||||
|
--node http://localhost:8080
|
||||||
```
|
```
|
||||||
|
|
||||||
## Логи контракта
|
## Contract treasury
|
||||||
|
|
||||||
|
У каждого WASM-контракта есть **ownerless treasury** по адресу
|
||||||
|
`hex(sha256(contractID + ":treasury"))`. Private-key не существует,
|
||||||
|
списывать можно только через host function `transfer` из самого
|
||||||
|
контракта. Используется `auction` (escrow bids) и `escrow` (held amount).
|
||||||
|
|
||||||
|
## Просмотр state / логов
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# REST
|
# Конкретный ключ state
|
||||||
curl "http://localhost:8081/api/contracts/<ID>/logs?limit=20"
|
curl -s http://localhost:8080/api/contracts/<id>/state/<key>
|
||||||
|
|
||||||
# Explorer → вкладка Logs
|
# Последние N логов контракта
|
||||||
|
curl -s "http://localhost:8080/api/contracts/<id>/logs?limit=20" | jq .
|
||||||
|
|
||||||
|
# Или через Explorer UI
|
||||||
|
open http://localhost:8080/contract?id=<id>
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -85,19 +85,20 @@ contracts/
|
|||||||
## Деплой контракта
|
## Деплой контракта
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Локально
|
|
||||||
client deploy-contract \
|
client deploy-contract \
|
||||||
--key key.json \
|
--key node.json \
|
||||||
--wasm mycontract.wasm \
|
--wasm mycontract.wasm \
|
||||||
--abi mycontract_abi.json \
|
--abi mycontract_abi.json \
|
||||||
--node http://localhost:8081
|
--node http://localhost:8080
|
||||||
|
```
|
||||||
|
|
||||||
# В Docker
|
В Docker (single-node):
|
||||||
docker exec node1 client deploy-contract \
|
```bash
|
||||||
--key /keys/node1.json \
|
docker exec dchain /usr/local/bin/client deploy-contract \
|
||||||
|
--key /keys/node.json \
|
||||||
--wasm /path/to/mycontract.wasm \
|
--wasm /path/to/mycontract.wasm \
|
||||||
--abi /path/to/mycontract_abi.json \
|
--abi /path/to/mycontract_abi.json \
|
||||||
--node http://node1:8080
|
--node http://localhost:8080
|
||||||
```
|
```
|
||||||
|
|
||||||
Успешный деплой логирует `contract_id: <hex16>`.
|
Успешный деплой логирует `contract_id: <hex16>`.
|
||||||
|
|||||||
@@ -1,219 +1,182 @@
|
|||||||
# Запуск ноды
|
# Запуск ноды
|
||||||
|
|
||||||
## Быстрый старт (Docker)
|
Три основных сценария — по возрастанию сложности.
|
||||||
|
|
||||||
|
| Сценарий | Где подробно |
|
||||||
|
|----------|--------------|
|
||||||
|
| **Одна нода локально (dev, genesis)** | [../quickstart.md](../quickstart.md) → Путь 1 |
|
||||||
|
| **Одна нода с TLS и доменом (production personal)** | [../../deploy/single/README.md](../../deploy/single/README.md) |
|
||||||
|
| **Multi-validator federation (3+ нод, PBFT quorum)** | [../../deploy/prod/README.md](../../deploy/prod/README.md) + [multi-server.md](multi-server.md) |
|
||||||
|
|
||||||
|
Этот документ — про native запуск (не через docker) и обзор всех флагов.
|
||||||
|
|
||||||
|
## Native build
|
||||||
|
|
||||||
|
Требования:
|
||||||
|
- Go 1.24+
|
||||||
|
- BadgerDB + libp2p встроены в бинарь (no-cgo, `CGO_ENABLED=0`)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/your/go-blockchain
|
go build -ldflags "-s -w \
|
||||||
cd go-blockchain
|
-X go-blockchain/node/version.Tag=$(git describe --tags --always --dirty) \
|
||||||
docker compose up -d
|
-X go-blockchain/node/version.Commit=$(git rev-parse HEAD) \
|
||||||
|
-X go-blockchain/node/version.Date=$(date -u +%Y-%m-%dT%H:%M:%SZ) \
|
||||||
|
-X go-blockchain/node/version.Dirty=$(git diff --quiet HEAD -- && echo false || echo true)" \
|
||||||
|
-o node ./cmd/node
|
||||||
|
|
||||||
|
./node --version
|
||||||
|
# dchain-node v0.0.1 (commit=abc1234 date=… dirty=false)
|
||||||
```
|
```
|
||||||
|
|
||||||
Запускает 3 ноды: `node1` (8081), `node2` (8082), `node3` (8083).
|
Ldflags не обязательны — без них версия будет "dev". Makefile в корне
|
||||||
|
инкапсулирует это: `make build`.
|
||||||
|
|
||||||
Деплой контрактов:
|
## Single node (genesis)
|
||||||
```bash
|
|
||||||
docker exec node1 /scripts/deploy_contracts.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
Explorer: http://localhost:8081
|
Сгенерировать ключ и поднять блок 0 с собой как единственным валидатором:
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Запуск вручную
|
|
||||||
|
|
||||||
### Требования
|
|
||||||
|
|
||||||
- Go 1.21+
|
|
||||||
- BadgerDB (встроен)
|
|
||||||
- libp2p (встроен)
|
|
||||||
|
|
||||||
### Сборка
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd go-blockchain
|
./node --version # проверка
|
||||||
go build ./cmd/node/
|
./client keygen --out node.json
|
||||||
go build ./cmd/client/
|
|
||||||
```
|
|
||||||
|
|
||||||
### Genesis нода
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Первый запуск — создать genesis
|
|
||||||
./node \
|
./node \
|
||||||
--key node1.json \
|
--key node.json \
|
||||||
|
--db ./data \
|
||||||
--genesis \
|
--genesis \
|
||||||
--validators "$(cat node1.json | jq -r .pub_key),$(cat node2.json | jq -r .pub_key),$(cat node3.json | jq -r .pub_key)" \
|
|
||||||
--stats-addr :8081 \
|
|
||||||
--listen /ip4/0.0.0.0/tcp/4001 \
|
--listen /ip4/0.0.0.0/tcp/4001 \
|
||||||
--db ./data/node1
|
--stats-addr :8080
|
||||||
```
|
```
|
||||||
|
|
||||||
### Peer нода
|
После первого успешного запуска удалите `--genesis` (no-op, но шумит в логах).
|
||||||
|
|
||||||
|
## Join existing network
|
||||||
|
|
||||||
|
Зная URL хотя бы одной живой ноды:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./node \
|
./node \
|
||||||
--key node2.json \
|
--key node.json \
|
||||||
--peers /ip4/127.0.0.1/tcp/4001/p2p/<node1-peer-id> \
|
--db ./data \
|
||||||
--stats-addr :8082 \
|
--join http://seed1.example.com:8080,http://seed2.example.com:8080 \
|
||||||
--listen /ip4/0.0.0.0/tcp/4002 \
|
--listen /ip4/0.0.0.0/tcp/4001 \
|
||||||
--db ./data/node2
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Флаги командной строки
|
|
||||||
|
|
||||||
| Флаг | По умолчанию | Описание |
|
|
||||||
|------|------------|---------|
|
|
||||||
| `--key` | `node.json` | Файл Ed25519 + X25519 идентичности |
|
|
||||||
| `--db` | `chaindata` | Директория BadgerDB |
|
|
||||||
| `--listen` | `/ip4/0.0.0.0/tcp/4001` | libp2p адрес |
|
|
||||||
| `--peers` | — | Bootstrap peer multiaddrs (через запятую) |
|
|
||||||
| `--validators` | — | Pubkeys валидаторов (через запятую, только для `--genesis`) |
|
|
||||||
| `--genesis` | false | Создать genesis блок при первом старте |
|
|
||||||
| `--stats-addr` | `:8080` | HTTP API/Explorer порт |
|
|
||||||
| `--wallet` | — | Payout кошелёк (hex pubkey) |
|
|
||||||
| `--wallet-pass` | — | Пароль к кошельку |
|
|
||||||
| `--heartbeat` | false | Отправлять heartbeat каждые 60 минут |
|
|
||||||
| `--register-relay` | false | Зарегистрироваться как relay-провайдер |
|
|
||||||
| `--relay-fee` | 0 | Fee за relay сообщение в µT |
|
|
||||||
| `--relay-key` | `relay.json` | X25519 ключ для relay шифрования |
|
|
||||||
| `--mailbox-db` | — | Директория для relay mailbox |
|
|
||||||
| `--governance-contract` | — | ID governance контракта для динамических параметров |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Docker Compose
|
|
||||||
|
|
||||||
### docker-compose.yml структура
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
services:
|
|
||||||
node1:
|
|
||||||
build: .
|
|
||||||
ports:
|
|
||||||
- "8081:8080" # HTTP API
|
|
||||||
- "4001:4001" # libp2p
|
|
||||||
volumes:
|
|
||||||
- node1_data:/chaindata
|
|
||||||
- ./keys:/keys:ro
|
|
||||||
command: >
|
|
||||||
node
|
|
||||||
--key /keys/node1.json
|
|
||||||
--genesis
|
|
||||||
--validators "$VALIDATORS"
|
|
||||||
--stats-addr :8080
|
--stats-addr :8080
|
||||||
--listen /ip4/0.0.0.0/tcp/4001
|
|
||||||
--governance-contract "$GOV_ID"
|
|
||||||
environment:
|
|
||||||
- VALIDATORS=03...,04...,05...
|
|
||||||
|
|
||||||
node2:
|
|
||||||
build: .
|
|
||||||
ports:
|
|
||||||
- "8082:8080"
|
|
||||||
command: >
|
|
||||||
node
|
|
||||||
--key /keys/node2.json
|
|
||||||
--peers /dns4/node1/tcp/4001/p2p/$NODE1_PEER_ID
|
|
||||||
--stats-addr :8080
|
|
||||||
--listen /ip4/0.0.0.0/tcp/4001
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Управление
|
Нода скачает `/api/network-info`, подхватит chain_id, genesis_hash, список
|
||||||
|
валидаторов и peer multiaddrs. По умолчанию запускается как **observer**
|
||||||
|
(применяет блоки, принимает tx, но не голосует). Чтобы стать валидатором,
|
||||||
|
существующий валидатор должен подать `ADD_VALIDATOR` tx с multi-sig.
|
||||||
|
|
||||||
```bash
|
## Все флаги
|
||||||
# Запустить
|
|
||||||
docker compose up -d
|
|
||||||
|
|
||||||
# Логи
|
Каждый флаг имеет env fallback (`DCHAIN_<FLAG_NAME>` в UPPERCASE с
|
||||||
docker compose logs -f node1
|
заменой `-` на `_`).
|
||||||
|
|
||||||
# Остановить
|
### Идентичность
|
||||||
docker compose down
|
| Флаг | env | По умолчанию | Описание |
|
||||||
|
|------|-----|--------------|----------|
|
||||||
|
| `--key` | `DCHAIN_KEY` | `node.json` | Ed25519 + X25519 identity file |
|
||||||
|
| `--db` | `DCHAIN_DB` | `chaindata` | BadgerDB directory |
|
||||||
|
|
||||||
# Сбросить данные
|
### Сеть
|
||||||
docker compose down -v
|
| Флаг | env | По умолчанию | Описание |
|
||||||
```
|
|------|-----|--------------|----------|
|
||||||
|
| `--listen` | `DCHAIN_LISTEN` | `/ip4/0.0.0.0/tcp/4001` | libp2p listen multiaddr |
|
||||||
|
| `--announce` | `DCHAIN_ANNOUNCE` | auto | Что анонсировать пирам (публичный IP!) |
|
||||||
|
| `--peers` | `DCHAIN_PEERS` | — | Bootstrap peers (comma-sep multiaddrs) |
|
||||||
|
| `--join` | `DCHAIN_JOIN` | — | Seed HTTP URLs для onboarding |
|
||||||
|
|
||||||
---
|
### Консенсус
|
||||||
|
| Флаг | env | По умолчанию | Описание |
|
||||||
|
|------|-----|--------------|----------|
|
||||||
|
| `--genesis` | `DCHAIN_GENESIS` | false | Создать block 0 при первом старте |
|
||||||
|
| `--validators` | `DCHAIN_VALIDATORS` | — | Initial validator set (для `--genesis`) |
|
||||||
|
| `--observer` | `DCHAIN_OBSERVER` | false | Observer mode (не голосовать) |
|
||||||
|
| `--allow-genesis-mismatch` | — | false | Пропустить safety check (опасно) |
|
||||||
|
|
||||||
|
### Relay
|
||||||
|
| Флаг | env | По умолчанию | Описание |
|
||||||
|
|------|-----|--------------|----------|
|
||||||
|
| `--register-relay` | `DCHAIN_REGISTER_RELAY` | false | Подать REGISTER_RELAY tx при старте |
|
||||||
|
| `--relay-fee` | — | 1000 | Fee за relay envelope, µT |
|
||||||
|
| `--relay-key` | — | `relay.json` | X25519 key для E2E шифрования envelope |
|
||||||
|
| `--mailbox-db` | — | — | Badger dir для offline-получателей |
|
||||||
|
|
||||||
|
### Governance / misc
|
||||||
|
| Флаг | env | По умолчанию | Описание |
|
||||||
|
|------|-----|--------------|----------|
|
||||||
|
| `--governance-contract` | `DCHAIN_GOVERNANCE_CONTRACT` | — | ID governance контракта |
|
||||||
|
| `--heartbeat` | — | false | Слать heartbeat tx каждые 60 мин |
|
||||||
|
| `--log-format` | `DCHAIN_LOG_FORMAT` | `text` | `text` или `json` |
|
||||||
|
|
||||||
|
### HTTP / WebSocket / UI
|
||||||
|
| Флаг | env | По умолчанию | Описание |
|
||||||
|
|------|-----|--------------|----------|
|
||||||
|
| `--stats-addr` | — | `:8080` | HTTP + WS listen address |
|
||||||
|
| `--disable-ui` | `DCHAIN_DISABLE_UI` | false | Выключить блок-эксплорер HTML-страницы |
|
||||||
|
| `--disable-swagger` | `DCHAIN_DISABLE_SWAGGER` | false | Выключить `/swagger` + `/swagger/openapi.json` |
|
||||||
|
| `--api-token` | `DCHAIN_API_TOKEN` | — | Bearer token для submit (пусто = public) |
|
||||||
|
| `--api-private` | `DCHAIN_API_PRIVATE` | false | Требовать token и для чтения |
|
||||||
|
|
||||||
|
### Update system
|
||||||
|
| Флаг | env | По умолчанию | Описание |
|
||||||
|
|------|-----|--------------|----------|
|
||||||
|
| `--update-source-url` | `DCHAIN_UPDATE_SOURCE_URL` | — | Gitea `/api/v1/.../releases/latest` URL |
|
||||||
|
| `--update-source-token` | `DCHAIN_UPDATE_SOURCE_TOKEN` | — | PAT для приватных repo |
|
||||||
|
|
||||||
## Файл ключа
|
## Файл ключа
|
||||||
|
|
||||||
Каждая нода использует один файл с обоими ключами:
|
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"pub_key": "03abcd...", // Ed25519 pubkey (hex, 66 символов)
|
"pub_key": "26018d40...", // Ed25519 public (64 hex chars)
|
||||||
"priv_key": "...", // Ed25519 privkey (hex)
|
"priv_key": "16aba1d2...", // Ed25519 private (128 hex chars, priv||pub)
|
||||||
"x25519_pub": "...", // X25519 pubkey для E2E relay (hex, 64 символа)
|
"x25519_pub": "baada10a...", // X25519 public для relay E2E
|
||||||
"x25519_priv": "..." // X25519 privkey
|
"x25519_priv":"a814c191..." // X25519 private
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Генерация:
|
`client keygen --out node.json` — создаёт валидный файл.
|
||||||
```bash
|
|
||||||
client keygen --out node1.json
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
## Схема ключей BadgerDB
|
||||||
|
|
||||||
## Мониторинг
|
|
||||||
|
|
||||||
### HTTP healthcheck
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl http://localhost:8081/api/netstats
|
|
||||||
```
|
|
||||||
|
|
||||||
### Logs
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Docker
|
|
||||||
docker compose logs -f node1 | grep -E "block|error|warn"
|
|
||||||
|
|
||||||
# Прямой запуск
|
|
||||||
./node ... 2>&1 | tee node.log
|
|
||||||
```
|
|
||||||
|
|
||||||
### Метрики производительности
|
|
||||||
|
|
||||||
| Показатель | Норма |
|
|
||||||
|-----------|------|
|
|
||||||
| Время блока | ~3 с |
|
|
||||||
| Блоков в минуту | ~20 |
|
|
||||||
| PBFT фазы | prepare → commit → finalize |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Структура данных BadgerDB
|
|
||||||
|
|
||||||
```
|
```
|
||||||
balance:<pubkey> → uint64 (µT)
|
height → uint64 (tip)
|
||||||
identity:<pubkey> → JSON RegisterKeyPayload
|
|
||||||
stake:<pubkey> → uint64 (µT)
|
|
||||||
block:<index> → JSON Block
|
block:<index> → JSON Block
|
||||||
tx:<txid> → JSON TxRecord
|
tx:<txid> → JSON TxRecord
|
||||||
txidx:<pubkey>:<block>:<seq> → txid (индекс по адресу)
|
txchron:<block20d>:<seq04d> → tx_id (recent-tx index)
|
||||||
contract:<id> → JSON ContractRecord
|
balance:<pubkey> → uint64 (µT)
|
||||||
cstate:<id>:<key> → []byte (state контракта)
|
stake:<pubkey> → uint64 (µT)
|
||||||
clog:<id>:<seq> → JSON ContractLogEntry
|
id:<pubkey> → JSON RegisterKeyPayload
|
||||||
relay:<pubkey> → JSON RegisteredRelayInfo
|
chan:<channelID> → JSON CreateChannelPayload
|
||||||
|
chan-member:<ch>:<pub> → ""
|
||||||
|
contract:<contractID> → JSON ContractRecord
|
||||||
|
cstate:<contractID>:<key> → bytes
|
||||||
|
clog:<ct>:<block>:<seq> → JSON ContractLogEntry
|
||||||
|
relay:<pubkey> → JSON RegisterRelayPayload
|
||||||
|
validator:<pubkey> → "" (presence = active)
|
||||||
|
schema:ver → uint32 (migration version)
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
## Метрики + healthcheck
|
||||||
|
|
||||||
## Сброс и восстановление
|
- **Healthcheck:** `curl http://localhost:8080/api/netstats` возвращает
|
||||||
|
200 с JSON, если нода живая.
|
||||||
|
- **Prometheus:** `GET /metrics` — см. [../api/README.md](../api/README.md).
|
||||||
|
- **Время блока:** норма ~5 сек (константа в `consensus/pbft.go`).
|
||||||
|
|
||||||
|
## Сброс данных
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Полный сброс
|
# Native
|
||||||
docker compose down -v
|
rm -rf ./data ./mailbox
|
||||||
docker compose up -d
|
|
||||||
|
|
||||||
# Только данные одной ноды
|
# Docker
|
||||||
docker compose stop node1
|
docker compose down -v
|
||||||
docker volume rm go-blockchain_node1_data
|
|
||||||
docker compose up -d node1
|
# Single-node container
|
||||||
|
docker stop dchain && docker rm dchain
|
||||||
|
docker volume rm dchain_data
|
||||||
```
|
```
|
||||||
|
|
||||||
После сброса нужно заново задеплоить контракты и залинковать governance.
|
После сброса нода начнёт с пустого стейта; c `--genesis` — создаст новый
|
||||||
|
chain_id, с `--join` — синкается с сетью.
|
||||||
|
|||||||
@@ -1,177 +1,85 @@
|
|||||||
# Быстрый старт
|
# Quickstart
|
||||||
|
|
||||||
|
Три пути в зависимости от того, что вам нужно.
|
||||||
|
|
||||||
|
## Путь 1: локальная single-node за 5 минут
|
||||||
|
|
||||||
|
Самый быстрый способ потрогать блокчейн — одна нода в Docker, без TLS, без
|
||||||
|
федерации, с HTTP-API на `localhost:8080`.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://git.vsecoder.vodka/vsecoder/dchain.git
|
||||||
|
cd dchain
|
||||||
|
|
||||||
|
# 1. Собираем образ
|
||||||
|
docker build -t dchain-node-slim -f deploy/prod/Dockerfile.slim .
|
||||||
|
|
||||||
|
# 2. Генерим ключ ноды (один раз)
|
||||||
|
mkdir -p keys
|
||||||
|
docker run --rm --entrypoint /usr/local/bin/client \
|
||||||
|
-v "$PWD/keys:/out" dchain-node-slim \
|
||||||
|
keygen --out /out/node.json
|
||||||
|
|
||||||
|
# 3. Запускаем как genesis (блок 0 = эта нода — единственный валидатор)
|
||||||
|
docker run -d --name dchain --restart unless-stopped \
|
||||||
|
-p 4001:4001 -p 8080:8080 \
|
||||||
|
-v dchain_data:/data \
|
||||||
|
-v "$PWD/keys:/keys:ro" \
|
||||||
|
-e DCHAIN_GENESIS=true \
|
||||||
|
-e DCHAIN_ANNOUNCE=/ip4/127.0.0.1/tcp/4001 \
|
||||||
|
dchain-node-slim \
|
||||||
|
--db=/data/chain --mailbox-db=/data/mailbox --key=/keys/node.json \
|
||||||
|
--relay-key=/data/relay.json --listen=/ip4/0.0.0.0/tcp/4001 --stats-addr=:8080
|
||||||
|
|
||||||
|
# 4. Проверяем
|
||||||
|
curl -s http://localhost:8080/api/netstats
|
||||||
|
curl -s http://localhost:8080/api/well-known-version
|
||||||
|
open http://localhost:8080/ # Explorer UI
|
||||||
|
open http://localhost:8080/swagger # Swagger UI
|
||||||
|
```
|
||||||
|
|
||||||
|
Блоки коммитятся каждые ~5 секунд. Что дальше:
|
||||||
|
|
||||||
|
- Первая отправка tx — [cli/README.md](cli/README.md)
|
||||||
|
- API endpoint reference — [api/README.md](api/README.md)
|
||||||
|
- Выключить UI / Swagger — см. `DCHAIN_DISABLE_UI` / `DCHAIN_DISABLE_SWAGGER` в [deploy/single/README.md](../deploy/single/README.md)
|
||||||
|
|
||||||
|
## Путь 2: single-node с TLS и доменом
|
||||||
|
|
||||||
|
Полный operator runbook — [deploy/single/README.md](../deploy/single/README.md).
|
||||||
|
Там 6 готовых сценариев: публичная с UI, headless API, полностью приватная,
|
||||||
|
genesis, joiner, auto-update от Gitea.
|
||||||
|
|
||||||
|
## Путь 3: multi-validator federation
|
||||||
|
|
||||||
|
3 ноды в PBFT quorum, Caddy LB с ip_hash для WS — [deploy/prod/README.md](../deploy/prod/README.md).
|
||||||
|
|
||||||
## Требования
|
## Требования
|
||||||
|
|
||||||
- Docker Desktop (или Docker Engine + Compose v2)
|
- Docker Desktop / Docker Engine + Compose v2
|
||||||
- 4 GB RAM, 2 CPU
|
- 2 GB RAM (одна нода), 4 GB (3-node dev cluster)
|
||||||
|
- 1 CPU
|
||||||
|
|
||||||
Для разработки контрактов дополнительно:
|
Для разработки из исходников:
|
||||||
- Go 1.21+
|
|
||||||
- TinyGo 0.30+ (только для TinyGo-контрактов)
|
|
||||||
|
|
||||||
---
|
- Go 1.24+ (модуль использует новые фичи)
|
||||||
|
- TinyGo 0.30+ — только если собираете WASM-контракты вручную
|
||||||
|
|
||||||
## 1. Запустить сеть
|
## Проверка жизни
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone <repo>
|
# высота тип
|
||||||
cd go-blockchain
|
curl -s http://localhost:8080/api/netstats | jq '.total_blocks'
|
||||||
|
|
||||||
docker compose up --build -d
|
# версия бинаря
|
||||||
|
docker exec dchain /usr/local/bin/node --version
|
||||||
|
# → dchain-node v0.5.0-dev (commit=abc1234 date=… dirty=false)
|
||||||
|
|
||||||
|
# /api/well-known-version — те же данные + features[]
|
||||||
|
curl -s http://localhost:8080/api/well-known-version | jq .
|
||||||
|
|
||||||
|
# лист возможностей
|
||||||
|
curl -s http://localhost:8080/api/well-known-version | jq '.features'
|
||||||
```
|
```
|
||||||
|
|
||||||
Запускается три ноды:
|
Если `total_blocks` растёт каждые ~5 сек — всё ОК.
|
||||||
|
|
||||||
| Контейнер | Роль | Explorer |
|
|
||||||
|-----------|------|---------|
|
|
||||||
| node1 | genesis + validator + relay | http://localhost:8081 |
|
|
||||||
| node2 | validator + relay | http://localhost:8082 |
|
|
||||||
| node3 | relay-only observer | http://localhost:8083 |
|
|
||||||
|
|
||||||
Дождитесь пока в Explorer появятся блоки (~10 секунд).
|
|
||||||
|
|
||||||
Swagger: http://localhost:8081/swagger
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Задеплоить контракты
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose --profile deploy run --rm deploy
|
|
||||||
```
|
|
||||||
|
|
||||||
Скрипт:
|
|
||||||
1. Ждёт готовности node1
|
|
||||||
2. Деплоит 4 контракта из genesis-ключа `/keys/node1.json`
|
|
||||||
3. Вызывает `init` на governance и escrow
|
|
||||||
4. Привязывает governance к нодам через `/api/governance/link`
|
|
||||||
5. Выводит contract ID и сохраняет в `/tmp/contracts.env`
|
|
||||||
|
|
||||||
Пример вывода:
|
|
||||||
```
|
|
||||||
══════════════════════════════════════════════════
|
|
||||||
DChain — деплой production-контрактов
|
|
||||||
══════════════════════════════════════════════════
|
|
||||||
|
|
||||||
▶ Деплой username_registry
|
|
||||||
✓ username_registry contract_id: a1b2c3d4e5f60718
|
|
||||||
|
|
||||||
▶ Деплой governance
|
|
||||||
✓ governance contract_id: 9f8e7d6c5b4a3210
|
|
||||||
|
|
||||||
▶ Деплой auction
|
|
||||||
✓ auction contract_id: 1a2b3c4d5e6f7089
|
|
||||||
|
|
||||||
▶ Деплой escrow
|
|
||||||
✓ escrow contract_id: fedcba9876543210
|
|
||||||
|
|
||||||
✓ username_registry : a1b2c3d4e5f60718
|
|
||||||
✓ governance : 9f8e7d6c5b4a3210
|
|
||||||
✓ auction : 1a2b3c4d5e6f7089
|
|
||||||
✓ escrow : fedcba9876543210
|
|
||||||
```
|
|
||||||
|
|
||||||
Сохраните ID для последующего использования:
|
|
||||||
```bash
|
|
||||||
# Запомнить ID
|
|
||||||
export UR_ID=a1b2c3d4e5f60718
|
|
||||||
export GOV_ID=9f8e7d6c5b4a3210
|
|
||||||
export AUC_ID=1a2b3c4d5e6f7089
|
|
||||||
export ESC_ID=fedcba9876543210
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Первые операции
|
|
||||||
|
|
||||||
### Проверить баланс genesis-кошелька
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker exec node1 client balance \
|
|
||||||
--key /keys/node1.json \
|
|
||||||
--node http://node1:8080
|
|
||||||
```
|
|
||||||
|
|
||||||
### Создать новый кошелёк
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker exec node1 wallet keygen --out /tmp/alice.json
|
|
||||||
docker exec node1 client balance \
|
|
||||||
--key /tmp/alice.json \
|
|
||||||
--node http://node1:8080
|
|
||||||
```
|
|
||||||
|
|
||||||
### Перевести токены
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Получить pubkey Alice
|
|
||||||
ALICE_PUB=$(docker exec node1 sh -c 'cat /tmp/alice.json | grep pub_key' | grep -oP '"pub_key":\s*"\K[^"]+')
|
|
||||||
|
|
||||||
docker exec node1 client transfer \
|
|
||||||
--key /keys/node1.json \
|
|
||||||
--to $ALICE_PUB \
|
|
||||||
--amount 1000000 \
|
|
||||||
--node http://node1:8080
|
|
||||||
```
|
|
||||||
|
|
||||||
### Зарегистрировать username
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker exec node1 client call-contract \
|
|
||||||
--key /keys/node1.json \
|
|
||||||
--contract $UR_ID \
|
|
||||||
--method register \
|
|
||||||
--arg alice \
|
|
||||||
--gas 20000 \
|
|
||||||
--node http://node1:8080
|
|
||||||
```
|
|
||||||
|
|
||||||
### Отправить сообщение (по username)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker exec node1 client send-msg \
|
|
||||||
--key /keys/node1.json \
|
|
||||||
--to @alice \
|
|
||||||
--registry $UR_ID \
|
|
||||||
--msg "Привет!" \
|
|
||||||
--node http://node1:8080
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. Explorer
|
|
||||||
|
|
||||||
После деплоя контракты видны в Explorer:
|
|
||||||
|
|
||||||
```
|
|
||||||
http://localhost:8081/contracts — все контракты
|
|
||||||
http://localhost:8081/contract?id=$UR_ID — username_registry
|
|
||||||
http://localhost:8081/contract?id=$GOV_ID — governance
|
|
||||||
http://localhost:8081/contract?id=$AUC_ID — auction
|
|
||||||
http://localhost:8081/contract?id=$ESC_ID — escrow
|
|
||||||
```
|
|
||||||
|
|
||||||
Вкладки в Explorer на странице контракта:
|
|
||||||
- **Overview** — метаданные, ABI-методы
|
|
||||||
- **State** — query state по ключу
|
|
||||||
- **Logs** — history вызовов с логами
|
|
||||||
- **Raw** — сырой JSON ContractRecord
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. Полный сброс
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose down -v && docker compose up --build -d
|
|
||||||
```
|
|
||||||
|
|
||||||
Флаг `-v` удаляет тома BadgerDB. После пересборки сеть стартует с чистого genesis.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Следующие шаги
|
|
||||||
|
|
||||||
- [Контракты](contracts/README.md) — использование всех 4 контрактов
|
|
||||||
- [Разработка контрактов](development/README.md) — написать свой контракт
|
|
||||||
- [CLI](cli/README.md) — все команды клиента
|
|
||||||
- [API](api/README.md) — REST-интерфейс
|
|
||||||
|
|||||||
201
docs/update-system.md
Normal file
201
docs/update-system.md
Normal file
@@ -0,0 +1,201 @@
|
|||||||
|
# Система обновлений и версионирование
|
||||||
|
|
||||||
|
DChain поставляется с полноценной системой update-detection, которая
|
||||||
|
включает **пять слоёв**: build-time версия в бинаре, HTTP-эндпоинты для
|
||||||
|
обнаружения, peer-gossip версий соседей, `update-check` от Gitea, и
|
||||||
|
rolling-restart скрипт. Ниже — как это работает и как использовать.
|
||||||
|
|
||||||
|
## Слой 1. Build-time версия
|
||||||
|
|
||||||
|
Бинарь хранит 4 поля, инжектимые через `-ldflags -X`:
|
||||||
|
|
||||||
|
- `Tag` — человекочитаемый тег, обычно `git describe --tags --always --dirty`
|
||||||
|
- `Commit` — полный 40-символьный SHA коммита
|
||||||
|
- `Date` — RFC 3339 timestamp сборки (UTC)
|
||||||
|
- `Dirty` — `"true"` если сборка была из грязного worktree
|
||||||
|
|
||||||
|
Печать:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node --version
|
||||||
|
# dchain-node v0.0.1 (commit=abc1234 date=2026-04-17T10:00:00Z dirty=false)
|
||||||
|
|
||||||
|
client --version
|
||||||
|
# тот же формат
|
||||||
|
```
|
||||||
|
|
||||||
|
Все 4 образа (`Dockerfile` и `deploy/prod/Dockerfile.slim`) принимают эти
|
||||||
|
значения через `--build-arg VERSION_TAG=…` и т.д. `update.sh`
|
||||||
|
вычисляет их автоматически перед ребилдом.
|
||||||
|
|
||||||
|
## Слой 2. `/api/well-known-version`
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s http://localhost:8080/api/well-known-version | jq .
|
||||||
|
```
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"node_version": "v0.0.1",
|
||||||
|
"build": {
|
||||||
|
"tag": "v0.0.1",
|
||||||
|
"commit": "abc1234…",
|
||||||
|
"date": "2026-04-17T10:00:00Z",
|
||||||
|
"dirty": "false"
|
||||||
|
},
|
||||||
|
"protocol_version": 1,
|
||||||
|
"features": [
|
||||||
|
"access_token", "chain_id", "channels_v1", "contract_logs",
|
||||||
|
"fan_out", "identity_registry", "native_username_registry",
|
||||||
|
"onboarding_api", "payment_channels", "relay_mailbox",
|
||||||
|
"ws_submit_tx"
|
||||||
|
],
|
||||||
|
"chain_id": "dchain-ddb9a7e37fc8"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Клиент использует это для feature-detection: зная `features[]`, он знает
|
||||||
|
какие экраны/флоу рендерить. Пример в `client-app/lib/api.ts` —
|
||||||
|
функция `checkNodeVersion()`.
|
||||||
|
|
||||||
|
**Protocol version** — отдельная ось от `node_version`. Меняется только
|
||||||
|
при несовместимых изменениях wire-протокола (новый формат PBFT-message,
|
||||||
|
breaking change в tx encoding). Клиент, который ожидает `protocol_version: 1`,
|
||||||
|
не должен работать с нодой `protocol_version: 2`.
|
||||||
|
|
||||||
|
## Слой 3. Peer-gossip версий
|
||||||
|
|
||||||
|
Каждая нода публикует своё `{peer_id, tag, commit, protocol_version,
|
||||||
|
timestamp}` в gossipsub-топик `dchain/version/v1` раз в 60 секунд. Другие
|
||||||
|
ноды эту информацию получают, хранят 15 минут, и отдают через
|
||||||
|
`/api/peers`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s http://localhost:8080/api/peers | jq .
|
||||||
|
```
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"peers": [
|
||||||
|
{
|
||||||
|
"id": "12D3KooW…",
|
||||||
|
"addrs": ["/ip4/…/tcp/4001/p2p/12D3KooW…"],
|
||||||
|
"version": {
|
||||||
|
"tag": "v0.0.1",
|
||||||
|
"commit": "abc1234…",
|
||||||
|
"protocol_version": 1,
|
||||||
|
"timestamp": 1745000000,
|
||||||
|
"received_at": "2026-04-17T10:01:00Z"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Зачем это:
|
||||||
|
|
||||||
|
- Operator видит «какая версия у моих соседей» одним запросом.
|
||||||
|
- Client видит «стоит ли переключиться на другую ноду».
|
||||||
|
- Feature-flag activation (например, `EventChannelBan`) можно запускать
|
||||||
|
только когда ≥80% сети на новой версии.
|
||||||
|
|
||||||
|
## Слой 4. `/api/update-check`
|
||||||
|
|
||||||
|
Оператор настраивает `DCHAIN_UPDATE_SOURCE_URL` на ссылку вида:
|
||||||
|
|
||||||
|
```
|
||||||
|
https://<your-gitea>/api/v1/repos/<owner>/<repo>/releases/latest
|
||||||
|
```
|
||||||
|
|
||||||
|
Нода опрашивает этот URL (15 мин cache, 5 сек timeout) и возвращает:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s http://localhost:8080/api/update-check | jq .
|
||||||
|
```
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"current": { "tag": "v0.0.1", "commit": "…", "date": "…", "dirty": "false" },
|
||||||
|
"latest": { "tag": "v0.0.2", "commit": "…", "url": "https://…", "published_at": "…" },
|
||||||
|
"update_available": true,
|
||||||
|
"checked_at": "2026-04-17T11:00:00Z",
|
||||||
|
"source": "https://git.vsecoder.vodka/api/v1/repos/vsecoder/dchain/releases/latest"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- 503 — не настроен `DCHAIN_UPDATE_SOURCE_URL`.
|
||||||
|
- 502 — upstream (Gitea) недоступен или ответил non-2xx.
|
||||||
|
- `update_available: false` — либо HEAD совпадает, либо Gitea вернула
|
||||||
|
draft/prerelease (оба игнорируются).
|
||||||
|
|
||||||
|
Токен для приватного репо: `DCHAIN_UPDATE_SOURCE_TOKEN=<Gitea PAT>`
|
||||||
|
(scope: `read:repository` достаточно).
|
||||||
|
|
||||||
|
## Слой 5. `update.sh` + systemd timer
|
||||||
|
|
||||||
|
Скрипт в `deploy/single/update.sh`. Флоу:
|
||||||
|
|
||||||
|
1. Если `DCHAIN_UPDATE_SOURCE_URL` задан — сначала спрашивает
|
||||||
|
`/api/update-check`. Если `update_available: false` — выход с кодом 0.
|
||||||
|
2. `git fetch --tags`.
|
||||||
|
3. **Semver guard:** если `UPDATE_ALLOW_MAJOR != true` и major-версия
|
||||||
|
меняется (v1.x → v2.y) — блокирует обновление с exit code 4.
|
||||||
|
4. `git checkout <tag>` (в detached HEAD) или ff-merge на `origin/main`.
|
||||||
|
5. Ребилд образа с правильными `--build-arg VERSION_*`.
|
||||||
|
6. Smoke-test: `docker run --rm … node --version` — должен напечатать
|
||||||
|
новую версию без ошибок.
|
||||||
|
7. `docker compose up -d --force-recreate node`.
|
||||||
|
8. Polling `/api/netstats` до 60 сек — если не ожил, `exit 1`.
|
||||||
|
|
||||||
|
Systemd-интеграция — в `deploy/single/systemd/`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo cp deploy/single/systemd/dchain-update.{service,timer} /etc/systemd/system/
|
||||||
|
sudo systemctl daemon-reload
|
||||||
|
sudo systemctl enable --now dchain-update.timer
|
||||||
|
```
|
||||||
|
|
||||||
|
Таймер: `OnUnitActiveSec=1h` + `RandomizedDelaySec=15min` — чтобы
|
||||||
|
федерация не рестартовала вся одновременно и не уронила PBFT quorum.
|
||||||
|
|
||||||
|
## Schema migrations (BadgerDB)
|
||||||
|
|
||||||
|
Отдельный слой, относящийся к on-disk формату данных. См.
|
||||||
|
`blockchain/schema_migrations.go`:
|
||||||
|
|
||||||
|
- `CurrentSchemaVersion` — const в Go-коде, bumpается с каждой
|
||||||
|
миграцией.
|
||||||
|
- `schemaMetaKey = "schema:ver"` — ключ в BadgerDB хранит фактическую
|
||||||
|
версию данных.
|
||||||
|
- `runMigrations(db)` вызывается при `NewChain()`, применяет каждый
|
||||||
|
шаг форвард-миграций атомарно (data rewrite + version bump в одной
|
||||||
|
badger.Update транзакции).
|
||||||
|
- Если stored version > CurrentSchemaVersion — ошибка (запускаете
|
||||||
|
старый бинарь на новом DB). Fix: обновите бинарь или восстановите
|
||||||
|
из бэкапа.
|
||||||
|
|
||||||
|
На текущий релиз `CurrentSchemaVersion = 0`, миграций нет — scaffold
|
||||||
|
живёт и готов к первому реальному изменению формата.
|
||||||
|
|
||||||
|
## Forward-compat для EventType
|
||||||
|
|
||||||
|
В `blockchain/chain.go` → `applyTx()` добавлен `default:` case для
|
||||||
|
неизвестных `EventType`:
|
||||||
|
|
||||||
|
- Fee дебитуется с отправителя (не спам-вектор).
|
||||||
|
- Tx применяется как **no-op**.
|
||||||
|
- В логе warning «unknown event type … — binary is older than this tx».
|
||||||
|
|
||||||
|
Это значит: новую `EventType` можно подать в сеть, она будет
|
||||||
|
обработана на новых нодах и проигнорирована на старых, без split'а
|
||||||
|
консенсуса. Full design — `deploy/UPDATE_STRATEGY.md` → §5.1.
|
||||||
|
|
||||||
|
## Checklist при релизе
|
||||||
|
|
||||||
|
1. В Git `git tag -a vX.Y.Z -m "release notes"` + `git push origin vX.Y.Z`.
|
||||||
|
2. В Gitea UI: `Releases → New Release → Tag: vX.Y.Z → Publish`.
|
||||||
|
3. На всех нодах с настроенным `update.sh`:
|
||||||
|
- systemd-таймер подхватит через ~1 час (± 15 мин jitter).
|
||||||
|
- Operator может форсить: `sudo systemctl start dchain-update.service`.
|
||||||
|
4. Проверка:
|
||||||
|
```bash
|
||||||
|
curl -s https://<node>/api/well-known-version | jq .build.tag
|
||||||
|
# должно вернуть vX.Y.Z
|
||||||
|
```
|
||||||
Reference in New Issue
Block a user