chore: initial commit for v0.0.1
DChain single-node blockchain + React Native messenger client. Core: - PBFT consensus with multi-sig validator admission + equivocation slashing - BadgerDB + schema migration scaffold (CurrentSchemaVersion=0) - libp2p gossipsub (tx/v1, blocks/v1, relay/v1, version/v1) - Native Go contracts (username_registry) alongside WASM (wazero) - WebSocket gateway with topic-based fanout + Ed25519-nonce auth - Relay mailbox with NaCl envelope encryption (X25519 + Ed25519) - Prometheus /metrics, per-IP rate limit, body-size cap Deployment: - Single-node compose (deploy/single/) with Caddy TLS + optional Prometheus - 3-node dev compose (docker-compose.yml) with mocked internet topology - 3-validator prod compose (deploy/prod/) for federation - Auto-update from Gitea via /api/update-check + systemd timer - Build-time version injection (ldflags → node --version) - UI / Swagger toggle flags (DCHAIN_DISABLE_UI, DCHAIN_DISABLE_SWAGGER) Client (client-app/): - Expo / React Native / NativeWind - E2E NaCl encryption, typing indicator, contact requests - Auto-discovery of canonical contracts, chain_id aware, WS reconnect on node switch Documentation: - README.md, CHANGELOG.md, CONTEXT.md - deploy/single/README.md with 6 operator scenarios - deploy/UPDATE_STRATEGY.md with 4-layer forward-compat design - docs/contracts/*.md per contract
This commit is contained in:
34
.dockerignore
Normal file
34
.dockerignore
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
# Git history
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
|
||||||
|
# Build artifacts
|
||||||
|
bin/
|
||||||
|
*.exe
|
||||||
|
|
||||||
|
# BadgerDB data directories (may exist locally)
|
||||||
|
chaindata/
|
||||||
|
mailboxdata/
|
||||||
|
**/chaindata/
|
||||||
|
**/mailboxdata/
|
||||||
|
|
||||||
|
# Key files (mounted as volume in compose)
|
||||||
|
node*.json
|
||||||
|
relay*.json
|
||||||
|
wallet*.json
|
||||||
|
|
||||||
|
# IDE / editor
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.code-workspace
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# Go test cache
|
||||||
|
*_test.go.out
|
||||||
|
coverage.out
|
||||||
58
.gitignore
vendored
Normal file
58
.gitignore
vendored
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
# Build output
|
||||||
|
/bin/
|
||||||
|
/node.exe
|
||||||
|
/client.exe
|
||||||
|
/peerid.exe
|
||||||
|
/wallet.exe
|
||||||
|
*.test
|
||||||
|
*.out
|
||||||
|
|
||||||
|
# Local state from running node/relay directly (NOT in docker)
|
||||||
|
/chaindata/
|
||||||
|
/mailboxdata/
|
||||||
|
/node.json
|
||||||
|
/relay.json
|
||||||
|
/seeds.json
|
||||||
|
|
||||||
|
# Go tool caches
|
||||||
|
.gobin/
|
||||||
|
.gocache/
|
||||||
|
.golangci-cache/
|
||||||
|
.gomodcache/
|
||||||
|
.gopath/
|
||||||
|
|
||||||
|
# IDE / editor
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
|
||||||
|
# Docker compose local overrides
|
||||||
|
docker-compose.override.yml
|
||||||
|
|
||||||
|
# Prod deploy secrets (operators must generate their own; never commit)
|
||||||
|
/deploy/prod/keys/
|
||||||
|
/deploy/prod/node*.env
|
||||||
|
!/deploy/prod/node.env.example
|
||||||
|
/deploy/single/keys/
|
||||||
|
/deploy/single/node.env
|
||||||
|
!/deploy/single/node.env.example
|
||||||
|
# Single-node update marker written by update.sh
|
||||||
|
/deploy/single/.last-update
|
||||||
|
|
||||||
|
# Node modules (client-app has its own .gitignore too, this is belt+braces)
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# Expo / React Native
|
||||||
|
.expo/
|
||||||
|
*.log
|
||||||
|
dist/
|
||||||
|
web-build/
|
||||||
|
|
||||||
|
# macOS / Windows cruft
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Claude Code / agent local state
|
||||||
|
.claude/
|
||||||
181
CHANGELOG.md
Normal file
181
CHANGELOG.md
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
# 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
Normal file
229
CONTEXT.md
Normal file
@@ -0,0 +1,229 @@
|
|||||||
|
# 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)
|
||||||
63
Dockerfile
Normal file
63
Dockerfile
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
# ---- build stage ----
|
||||||
|
FROM golang:1.24-alpine AS builder
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Cache module downloads separately from source changes
|
||||||
|
COPY go.mod go.sum ./
|
||||||
|
RUN go mod download
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Build-time version metadata (same convention as deploy/prod/Dockerfile.slim).
|
||||||
|
ARG VERSION_TAG=dev
|
||||||
|
ARG VERSION_COMMIT=none
|
||||||
|
ARG VERSION_DATE=unknown
|
||||||
|
ARG VERSION_DIRTY=false
|
||||||
|
|
||||||
|
RUN LDFLAGS="\
|
||||||
|
-X go-blockchain/node/version.Tag=${VERSION_TAG} \
|
||||||
|
-X go-blockchain/node/version.Commit=${VERSION_COMMIT} \
|
||||||
|
-X go-blockchain/node/version.Date=${VERSION_DATE} \
|
||||||
|
-X go-blockchain/node/version.Dirty=${VERSION_DIRTY}" && \
|
||||||
|
CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="$LDFLAGS" -o /bin/node ./cmd/node && \
|
||||||
|
CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="$LDFLAGS" -o /bin/client ./cmd/client && \
|
||||||
|
CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="$LDFLAGS" -o /bin/wallet ./cmd/wallet && \
|
||||||
|
CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="$LDFLAGS" -o /bin/peerid ./cmd/peerid
|
||||||
|
|
||||||
|
# ---- runtime stage ----
|
||||||
|
FROM alpine:3.19
|
||||||
|
|
||||||
|
RUN apk add --no-cache ca-certificates tzdata netcat-openbsd
|
||||||
|
|
||||||
|
COPY --from=builder /bin/node /usr/local/bin/node
|
||||||
|
COPY --from=builder /bin/client /usr/local/bin/client
|
||||||
|
COPY --from=builder /bin/wallet /usr/local/bin/wallet
|
||||||
|
COPY --from=builder /bin/peerid /usr/local/bin/peerid
|
||||||
|
|
||||||
|
# Bake testnet keys into image so nodes always load consistent identities
|
||||||
|
# (avoids Windows volume-mount failures with ./testdata:/keys:ro)
|
||||||
|
COPY --from=builder /app/testdata/ /keys/
|
||||||
|
|
||||||
|
# Bake username_registry contract (messenger username with pricing)
|
||||||
|
COPY --from=builder /app/contracts/username_registry/username_registry.wasm /keys/username_registry.wasm
|
||||||
|
COPY --from=builder /app/contracts/username_registry/username_registry_abi.json /keys/username_registry_abi.json
|
||||||
|
|
||||||
|
# Bake governance contract (on-chain parameter governance)
|
||||||
|
COPY --from=builder /app/contracts/governance/governance.wasm /keys/governance.wasm
|
||||||
|
COPY --from=builder /app/contracts/governance/governance_abi.json /keys/governance_abi.json
|
||||||
|
|
||||||
|
# Bake auction contract (English auction with token escrow)
|
||||||
|
COPY --from=builder /app/contracts/auction/auction.wasm /keys/auction.wasm
|
||||||
|
COPY --from=builder /app/contracts/auction/auction_abi.json /keys/auction_abi.json
|
||||||
|
|
||||||
|
# Bake escrow contract (two-party trustless escrow)
|
||||||
|
COPY --from=builder /app/contracts/escrow/escrow.wasm /keys/escrow.wasm
|
||||||
|
COPY --from=builder /app/contracts/escrow/escrow_abi.json /keys/escrow_abi.json
|
||||||
|
|
||||||
|
# libp2p P2P port
|
||||||
|
EXPOSE 4001/tcp
|
||||||
|
# HTTP stats + explorer + relay API
|
||||||
|
EXPOSE 8080/tcp
|
||||||
|
|
||||||
|
ENTRYPOINT ["/usr/local/bin/node"]
|
||||||
94
Makefile
Normal file
94
Makefile
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
.PHONY: build test up down reset deploy logs logs-node1 logs-node2 logs-node3 \
|
||||||
|
status peer-ids rebuild docker-clean test-local
|
||||||
|
|
||||||
|
# ── Сборка ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
build:
|
||||||
|
go build -o bin/node$(EXE) ./cmd/node
|
||||||
|
go build -o bin/client$(EXE) ./cmd/client
|
||||||
|
go build -o bin/wallet$(EXE) ./cmd/wallet
|
||||||
|
go build -o bin/peerid$(EXE) ./cmd/peerid
|
||||||
|
|
||||||
|
test:
|
||||||
|
go test ./...
|
||||||
|
|
||||||
|
# ── Docker: запуск / остановка ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
## Собрать образ и запустить все три ноды
|
||||||
|
up:
|
||||||
|
docker compose up --build -d
|
||||||
|
@printf "\n Explorer → http://localhost:8081\n"
|
||||||
|
@printf " node2 → http://localhost:8082\n"
|
||||||
|
@printf " node3 → http://localhost:8083\n\n"
|
||||||
|
@printf " Задеплоить контракты: make deploy\n\n"
|
||||||
|
|
||||||
|
## Остановить ноды (данные сохраняются)
|
||||||
|
down:
|
||||||
|
docker compose down
|
||||||
|
|
||||||
|
## Полный сброс: остановить + удалить тома с данными
|
||||||
|
reset:
|
||||||
|
docker compose down -v
|
||||||
|
@printf "\n Данные удалены. Запустите 'make up' для чистого старта.\n\n"
|
||||||
|
|
||||||
|
## Задеплоить 4 production-контракта
|
||||||
|
deploy:
|
||||||
|
docker compose --profile deploy run --rm deploy
|
||||||
|
|
||||||
|
# ── Логи ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
logs:
|
||||||
|
docker compose logs -f
|
||||||
|
|
||||||
|
logs-node1:
|
||||||
|
docker compose logs -f node1
|
||||||
|
|
||||||
|
logs-node2:
|
||||||
|
docker compose logs -f node2
|
||||||
|
|
||||||
|
logs-node3:
|
||||||
|
docker compose logs -f node3
|
||||||
|
|
||||||
|
# ── Статус ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
status:
|
||||||
|
@printf "\n── Контейнеры ──────────────────────────────────────────\n"
|
||||||
|
docker compose ps
|
||||||
|
@printf "\n── Сети ────────────────────────────────────────────────\n"
|
||||||
|
docker network ls | grep dchain || true
|
||||||
|
@printf "\n── Backbone IP-адреса ──────────────────────────────────\n"
|
||||||
|
@docker inspect -f '{{.Name}} {{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' \
|
||||||
|
node1 node2 node3 2>/dev/null | grep 172.30 || true
|
||||||
|
@printf "\n"
|
||||||
|
|
||||||
|
# ── Peer IDs ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
## Показать peer ID и backbone multiaddr для всех нод
|
||||||
|
peer-ids: build
|
||||||
|
@printf "\n── node1 ────────────────────────────────────────────────\n"
|
||||||
|
bin/peerid$(EXE) --key testdata/node1.json --ip 172.30.0.11 --port 4001
|
||||||
|
@printf "\n── node2 ────────────────────────────────────────────────\n"
|
||||||
|
bin/peerid$(EXE) --key testdata/node2.json --ip 172.30.0.12 --port 4001
|
||||||
|
@printf "\n── node3 ────────────────────────────────────────────────\n"
|
||||||
|
bin/peerid$(EXE) --key testdata/node3.json --ip 172.30.0.13 --port 4001
|
||||||
|
@printf "\n"
|
||||||
|
|
||||||
|
# ── Docker: служебные ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
rebuild:
|
||||||
|
docker compose build --no-cache
|
||||||
|
docker compose up -d
|
||||||
|
|
||||||
|
docker-clean:
|
||||||
|
docker compose down -v --rmi local
|
||||||
|
|
||||||
|
# ── Локальный тест (одна нода) ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
test-local: build
|
||||||
|
@rm -rf /tmp/testchain
|
||||||
|
MSYS_NO_PATHCONV=1 bin/node$(EXE) \
|
||||||
|
--genesis --db /tmp/testchain --key testdata/node1.json \
|
||||||
|
--listen /ip4/0.0.0.0/tcp/4001 &
|
||||||
|
@sleep 8 && kill $$(pgrep -f "bin/node") 2>/dev/null || true
|
||||||
|
@echo "--- chain info ---"
|
||||||
|
bin/client$(EXE) info --db /tmp/testchain
|
||||||
310
README.md
Normal file
310
README.md
Normal file
@@ -0,0 +1,310 @@
|
|||||||
|
# DChain
|
||||||
|
|
||||||
|
Блокчейн-стек для децентрализованного мессенджера:
|
||||||
|
- **PBFT** консенсус с multi-sig validator governance и equivocation slashing
|
||||||
|
- **Native Go контракты** рядом с WASM (wazero) — нулевая задержка для
|
||||||
|
системных сервисов типа username registry
|
||||||
|
- **WebSocket push API** — клиент не опрашивает, все события прилетают
|
||||||
|
на соединение
|
||||||
|
- **E2E-шифрованный relay mailbox** на libp2p gossipsub с TTL live-detection
|
||||||
|
- **Prometheus `/metrics`**, Caddy auto-HTTPS, observer mode, load-test
|
||||||
|
- React Native / Expo мессенджер (`client-app/`)
|
||||||
|
|
||||||
|
## Содержание
|
||||||
|
|
||||||
|
- [Быстрый старт (dev)](#быстрый-старт-dev)
|
||||||
|
- [Продакшен деплой](#продакшен-деплой)
|
||||||
|
- [Клиент](#клиент)
|
||||||
|
- [Архитектура](#архитектура)
|
||||||
|
- [Контракты](#контракты)
|
||||||
|
- [REST / WebSocket API](#rest--websocket-api)
|
||||||
|
- [CLI](#cli)
|
||||||
|
- [Мониторинг](#мониторинг)
|
||||||
|
- [Тесты](#тесты)
|
||||||
|
- [История изменений](#история-изменений)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Быстрый старт (dev)
|
||||||
|
|
||||||
|
3 валидатора в docker-compose с замоканной «интернет» топологией:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up --build -d
|
||||||
|
open http://localhost:8081 # Explorer главной ноды
|
||||||
|
curl -s http://localhost:8081/api/netstats # синхронность ноды
|
||||||
|
```
|
||||||
|
|
||||||
|
После поднятия нативный `username_registry` уже доступен — отдельный
|
||||||
|
`--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
|
||||||
|
docker run --rm --entrypoint /usr/local/bin/client \
|
||||||
|
-v "$PWD/keys:/out" dchain-node-slim \
|
||||||
|
keygen --out /out/node.json
|
||||||
|
|
||||||
|
# 2. Конфиг
|
||||||
|
cp node.env.example node.env && $EDITOR node.env
|
||||||
|
# минимум: DCHAIN_ANNOUNCE=<public ip>, DOMAIN=<fqdn>, ACME_EMAIL=<your>
|
||||||
|
|
||||||
|
# 3. Вверх
|
||||||
|
docker compose up -d
|
||||||
|
|
||||||
|
# 4. Проверка
|
||||||
|
curl -s https://$DOMAIN/api/netstats
|
||||||
|
curl -s https://$DOMAIN/api/well-known-version
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Модели доступа
|
||||||
|
|
||||||
|
| Режим | `DCHAIN_API_TOKEN` | `DCHAIN_API_PRIVATE` | Поведение |
|
||||||
|
|-------|:------------------:|:--------------------:|-----------|
|
||||||
|
| Public (default) | не задан | — | Все могут читать и писать |
|
||||||
|
| Public reads / token writes | задан | `false` | Читать — любой; submit tx — только с токеном |
|
||||||
|
| Fully private | задан | `true` | Всё требует `Authorization: Bearer <token>` |
|
||||||
|
|
||||||
|
#### UI / Swagger — включать или нет?
|
||||||
|
|
||||||
|
| Нужно | `DCHAIN_DISABLE_UI` | `DCHAIN_DISABLE_SWAGGER` | Где что открыто |
|
||||||
|
|-------|:-------------------:|:------------------------:|-----------------|
|
||||||
|
| Публичная с эксплорером + живой docs | не задано | не задано | `/` Explorer, `/swagger` UI, `/api/*`, `/metrics` |
|
||||||
|
| Headless API-нода с открытой OpenAPI | `true` | не задано | `/swagger` UI, `/api/*`, `/metrics` (Explorer выкл.) |
|
||||||
|
| Личная hardened | `true` | `true` | только `/api/*` + `/metrics` |
|
||||||
|
|
||||||
|
Флаги читаются и из CLI (`--disable-ui`, `--disable-swagger`), и из env.
|
||||||
|
`/api/*` JSON-поверхность регистрируется всегда — отключить её можно
|
||||||
|
только на уровне Caddy / firewall.
|
||||||
|
|
||||||
|
#### Auto-update
|
||||||
|
|
||||||
|
```ini
|
||||||
|
# node.env — когда проект у вас в Gitea
|
||||||
|
DCHAIN_UPDATE_SOURCE_URL=https://gitea.example.com/api/v1/repos/OWNER/REPO/releases/latest
|
||||||
|
UPDATE_ALLOW_MAJOR=false
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Hourly systemd timer с 15-мин jitter
|
||||||
|
sudo cp deploy/single/systemd/dchain-update.{service,timer} /etc/systemd/system/
|
||||||
|
sudo systemctl daemon-reload
|
||||||
|
sudo systemctl enable --now dchain-update.timer
|
||||||
|
```
|
||||||
|
|
||||||
|
Скрипт `deploy/single/update.sh` читает `/api/update-check`, делает
|
||||||
|
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/`)
|
||||||
|
|
||||||
|
3 validator'а в PBFT-кворуме, Caddy с ip_hash для WS-стикинесса и
|
||||||
|
least-conn для REST. Для федераций / консорциумов — см.
|
||||||
|
`deploy/prod/README.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 ┌────────────┐
|
||||||
|
│ node1 │◄─────pubsub──►│ node2 │◄─────pubsub──►│ node3 │
|
||||||
|
│ validator │ │ validator │ │ validator │
|
||||||
|
│ + relay │ │ + relay │ │ + relay │
|
||||||
|
└─────┬──────┘ └─────┬──────┘ └─────┬──────┘
|
||||||
|
│ │ │
|
||||||
|
│ HTTPS / wss (via Caddy) │ │
|
||||||
|
▼ ▼ ▼
|
||||||
|
mobile / web / CLI clients (load-balanced ip_hash for WS)
|
||||||
|
```
|
||||||
|
|
||||||
|
Слои (`blockchain/`):
|
||||||
|
- `chain.go` — блочная машина (applyTx, AddBlock, BadgerDB)
|
||||||
|
- `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`)
|
||||||
|
- per-sender FIFO мемпул + round-robin drain до `MaxTxsPerBlock`
|
||||||
|
|
||||||
|
Node service layer (`node/`):
|
||||||
|
- HTTP API + SSE + **WebSocket hub** с auth и topic-based fanout
|
||||||
|
- Prometheus `/metrics` (zero-dep)
|
||||||
|
- event bus (`events.go`) — SSE + WS + future consumers с одного emit'а
|
||||||
|
- rate-limiter + body-size cap на tx submit
|
||||||
|
|
||||||
|
Relay (`relay/`):
|
||||||
|
- 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
|
||||||
|
|
||||||
|
### Chain
|
||||||
|
|
||||||
|
| Endpoint | Описание |
|
||||||
|
|----------|----------|
|
||||||
|
| `GET /api/netstats` | total_blocks, tx_count, supply, peer count |
|
||||||
|
| `GET /api/blocks?limit=N` | Последние блоки |
|
||||||
|
| `GET /api/block/{index}` | Конкретный блок |
|
||||||
|
| `GET /api/tx/{id}` | Транзакция по ID |
|
||||||
|
| `GET /api/txs/recent?limit=N` | Последние tx (O(limit)) |
|
||||||
|
| `GET /api/address/{pubkey}` | Баланс + история tx |
|
||||||
|
| `GET /api/validators` | Активный validator set |
|
||||||
|
| `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) |
|
||||||
|
|
||||||
|
### Real-time
|
||||||
|
|
||||||
|
- `GET /api/events` — Server-Sent Events (classic, 1-way)
|
||||||
|
- `GET /api/ws` — **WebSocket** (bidirectional, recommended)
|
||||||
|
|
||||||
|
WS protocol — см. `node/ws.go`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "op": "auth", "pubkey": "...", "sig": "..." }
|
||||||
|
{ "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
|
||||||
|
|
||||||
|
```bash
|
||||||
|
client keygen --out key.json
|
||||||
|
client balance --key key.json --node URL
|
||||||
|
client transfer --key key.json --to <pub> --amount <µT> --node URL
|
||||||
|
client call-contract --key key.json --contract native:username_registry \
|
||||||
|
--method register --args '["alice"]' --amount 10000 \
|
||||||
|
--node URL
|
||||||
|
client add-validator --key key.json --target <pub> --cosigs pub:sig,pub:sig
|
||||||
|
client admit-sign --key validator.json --target <candidate-pub>
|
||||||
|
client remove-validator --key key.json --target <pub>
|
||||||
|
# ... полный список — `client` без аргументов
|
||||||
|
```
|
||||||
|
|
||||||
|
Node flags (все читают `DCHAIN_*` env fallbacks):
|
||||||
|
- `--db`, `--listen`, `--announce`, `--peers`, `--validators`
|
||||||
|
- `--join http://seed1:8080,http://seed2:8080` (multi-seed, persisted)
|
||||||
|
- `--genesis`, `--observer`, `--register-relay`
|
||||||
|
- `--log-format text|json`, `--allow-genesis-mismatch`
|
||||||
|
|
||||||
|
## Мониторинг
|
||||||
|
|
||||||
|
Prometheus endpoint `/metrics` на каждой ноде. Ключевые метрики:
|
||||||
|
|
||||||
|
```
|
||||||
|
dchain_blocks_total # committed blocks count
|
||||||
|
dchain_txs_total # tx count
|
||||||
|
dchain_tx_submit_accepted_total
|
||||||
|
dchain_tx_submit_rejected_total
|
||||||
|
dchain_ws_connections # current WS sockets
|
||||||
|
dchain_peer_count_live # live libp2p peer count
|
||||||
|
dchain_max_missed_blocks # worst validator liveness gap
|
||||||
|
dchain_block_commit_seconds # histogram of AddBlock time
|
||||||
|
```
|
||||||
|
|
||||||
|
Grafana dashboard + provisioning — `deploy/prod/grafana/` (create as
|
||||||
|
needed; Prometheus data source is auto-wired in compose profile
|
||||||
|
`monitor`).
|
||||||
|
|
||||||
|
## Тесты
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go test ./... # blockchain + consensus + relay + identity + vm
|
||||||
|
go run ./cmd/loadtest \
|
||||||
|
--node http://localhost:8081 \
|
||||||
|
--funder testdata/node1.json \
|
||||||
|
--clients 50 --duration 60s # end-to-end WS + submit_tx + native contract path
|
||||||
|
```
|
||||||
|
|
||||||
|
## История изменений
|
||||||
|
|
||||||
|
Подробный список того, что сделано за последнюю итерацию (стабилизация
|
||||||
|
чейна, governance, WS gateway, observability, native contracts,
|
||||||
|
deployment) — `CHANGELOG.md`.
|
||||||
139
blockchain/block.go
Normal file
139
blockchain/block.go
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
package blockchain
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"crypto/ed25519"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/binary"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Block is the fundamental unit of the chain.
|
||||||
|
type Block struct {
|
||||||
|
Index uint64 `json:"index"`
|
||||||
|
Timestamp time.Time `json:"timestamp"`
|
||||||
|
Transactions []*Transaction `json:"transactions"`
|
||||||
|
PrevHash []byte `json:"prev_hash"`
|
||||||
|
Hash []byte `json:"hash"` // SHA-256 over canonical fields
|
||||||
|
ValidatorSig []byte `json:"validator_sig"` // Ed25519 sig over Hash
|
||||||
|
Validator string `json:"validator"` // hex pub key of signing validator
|
||||||
|
// TotalFees collected in this block (credited to Validator)
|
||||||
|
TotalFees uint64 `json:"total_fees"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// canonicalBytes returns a deterministic byte slice for hashing.
|
||||||
|
// Order: index | timestamp | prev_hash | tx_hashes | total_fees | validator
|
||||||
|
func (b *Block) canonicalBytes() []byte {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
|
||||||
|
// 8-byte big-endian index
|
||||||
|
idxBuf := make([]byte, 8)
|
||||||
|
binary.BigEndian.PutUint64(idxBuf, b.Index)
|
||||||
|
buf.Write(idxBuf)
|
||||||
|
|
||||||
|
// 8-byte unix nano timestamp
|
||||||
|
tsBuf := make([]byte, 8)
|
||||||
|
binary.BigEndian.PutUint64(tsBuf, uint64(b.Timestamp.UnixNano()))
|
||||||
|
buf.Write(tsBuf)
|
||||||
|
|
||||||
|
buf.Write(b.PrevHash)
|
||||||
|
|
||||||
|
// Hash each transaction and include its hash
|
||||||
|
for _, tx := range b.Transactions {
|
||||||
|
h := txHash(tx)
|
||||||
|
buf.Write(h)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 8-byte fees
|
||||||
|
feesBuf := make([]byte, 8)
|
||||||
|
binary.BigEndian.PutUint64(feesBuf, b.TotalFees)
|
||||||
|
buf.Write(feesBuf)
|
||||||
|
|
||||||
|
buf.WriteString(b.Validator)
|
||||||
|
|
||||||
|
return buf.Bytes()
|
||||||
|
}
|
||||||
|
|
||||||
|
// txHash returns SHA-256 of the canonical transaction bytes.
|
||||||
|
func txHash(tx *Transaction) []byte {
|
||||||
|
data, _ := json.Marshal(tx)
|
||||||
|
h := sha256.Sum256(data)
|
||||||
|
return h[:]
|
||||||
|
}
|
||||||
|
|
||||||
|
// ComputeHash fills b.Hash from the canonical bytes.
|
||||||
|
func (b *Block) ComputeHash() {
|
||||||
|
sum := sha256.Sum256(b.canonicalBytes())
|
||||||
|
b.Hash = sum[:]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sign signs b.Hash with the given Ed25519 private key and stores the signature.
|
||||||
|
func (b *Block) Sign(privKey ed25519.PrivateKey) {
|
||||||
|
b.ValidatorSig = ed25519.Sign(privKey, b.Hash)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate checks the block's structural integrity:
|
||||||
|
// 1. Hash matches canonical bytes
|
||||||
|
// 2. ValidatorSig is a valid Ed25519 signature over Hash
|
||||||
|
// 3. PrevHash is provided (except genesis)
|
||||||
|
func (b *Block) Validate(prevHash []byte) error {
|
||||||
|
// Recompute and compare hash
|
||||||
|
sum := sha256.Sum256(b.canonicalBytes())
|
||||||
|
if !bytes.Equal(sum[:], b.Hash) {
|
||||||
|
return errors.New("block hash mismatch")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify validator signature
|
||||||
|
pubKeyBytes, err := hex.DecodeString(b.Validator)
|
||||||
|
if err != nil {
|
||||||
|
return errors.New("invalid validator pub key hex")
|
||||||
|
}
|
||||||
|
if !ed25519.Verify(ed25519.PublicKey(pubKeyBytes), b.Hash, b.ValidatorSig) {
|
||||||
|
return errors.New("invalid validator signature")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check chain linkage (skip for genesis)
|
||||||
|
if b.Index > 0 {
|
||||||
|
if !bytes.Equal(b.PrevHash, prevHash) {
|
||||||
|
return errors.New("prev_hash mismatch")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate each transaction's fee minimum
|
||||||
|
var totalFees uint64
|
||||||
|
for _, tx := range b.Transactions {
|
||||||
|
if tx.Fee < MinFee {
|
||||||
|
return errors.New("transaction fee below minimum")
|
||||||
|
}
|
||||||
|
totalFees += tx.Fee
|
||||||
|
}
|
||||||
|
if totalFees != b.TotalFees {
|
||||||
|
return errors.New("total_fees mismatch")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenesisBlock creates the first block with no transactions.
|
||||||
|
// It is signed by the bootstrap validator.
|
||||||
|
func GenesisBlock(validatorPubHex string, privKey ed25519.PrivateKey) *Block {
|
||||||
|
b := &Block{
|
||||||
|
Index: 0,
|
||||||
|
Timestamp: time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC),
|
||||||
|
Transactions: []*Transaction{},
|
||||||
|
PrevHash: bytes.Repeat([]byte{0}, 32),
|
||||||
|
Validator: validatorPubHex,
|
||||||
|
TotalFees: 0,
|
||||||
|
}
|
||||||
|
b.ComputeHash()
|
||||||
|
b.Sign(privKey)
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
// HashHex returns the block hash as a hex string.
|
||||||
|
func (b *Block) HashHex() string {
|
||||||
|
return hex.EncodeToString(b.Hash)
|
||||||
|
}
|
||||||
2589
blockchain/chain.go
Normal file
2589
blockchain/chain.go
Normal file
File diff suppressed because it is too large
Load Diff
796
blockchain/chain_test.go
Normal file
796
blockchain/chain_test.go
Normal file
@@ -0,0 +1,796 @@
|
|||||||
|
package blockchain_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/ed25519"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"go-blockchain/blockchain"
|
||||||
|
"go-blockchain/identity"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ─── helpers ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// newChain opens a fresh BadgerDB-backed chain in a temp directory and
|
||||||
|
// registers a cleanup that closes the DB then removes the directory.
|
||||||
|
// We avoid t.TempDir() because on Windows, BadgerDB's mmap'd value-log files
|
||||||
|
// may still be held open for a brief moment after Close() returns, causing
|
||||||
|
// the automatic TempDir cleanup to fail with "directory not empty".
|
||||||
|
// Using os.MkdirTemp + a retry loop works around this race.
|
||||||
|
func newChain(t *testing.T) *blockchain.Chain {
|
||||||
|
t.Helper()
|
||||||
|
dir, err := os.MkdirTemp("", "dchain-test-*")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("MkdirTemp: %v", err)
|
||||||
|
}
|
||||||
|
c, err := blockchain.NewChain(dir)
|
||||||
|
if err != nil {
|
||||||
|
_ = os.RemoveAll(dir)
|
||||||
|
t.Fatalf("NewChain: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() {
|
||||||
|
_ = c.Close()
|
||||||
|
// Retry removal to handle Windows mmap handle release delay.
|
||||||
|
for i := 0; i < 20; i++ {
|
||||||
|
if err := os.RemoveAll(dir); err == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
time.Sleep(10 * time.Millisecond)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
// newIdentity generates a fresh Ed25519 + X25519 keypair for test use.
|
||||||
|
func newIdentity(t *testing.T) *identity.Identity {
|
||||||
|
t.Helper()
|
||||||
|
id, err := identity.Generate()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("identity.Generate: %v", err)
|
||||||
|
}
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
// addGenesis creates and commits the genesis block signed by validator.
|
||||||
|
func addGenesis(t *testing.T, c *blockchain.Chain, validator *identity.Identity) *blockchain.Block {
|
||||||
|
t.Helper()
|
||||||
|
b := blockchain.GenesisBlock(validator.PubKeyHex(), validator.PrivKey)
|
||||||
|
if err := c.AddBlock(b); err != nil {
|
||||||
|
t.Fatalf("AddBlock(genesis): %v", err)
|
||||||
|
}
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
// txID produces a short deterministic transaction ID.
|
||||||
|
func txID(from string, typ blockchain.EventType) string {
|
||||||
|
h := sha256.Sum256([]byte(fmt.Sprintf("%s:%s:%d", from, typ, time.Now().UnixNano())))
|
||||||
|
return hex.EncodeToString(h[:16])
|
||||||
|
}
|
||||||
|
|
||||||
|
// makeTx builds a minimal transaction with all required fields set.
|
||||||
|
// Signature is intentionally left nil — chain.applyTx does not re-verify
|
||||||
|
// Ed25519 tx signatures (that is the consensus engine's job).
|
||||||
|
func makeTx(typ blockchain.EventType, from, to string, amount, fee uint64, payload []byte) *blockchain.Transaction {
|
||||||
|
return &blockchain.Transaction{
|
||||||
|
ID: txID(from, typ),
|
||||||
|
Type: typ,
|
||||||
|
From: from,
|
||||||
|
To: to,
|
||||||
|
Amount: amount,
|
||||||
|
Fee: fee,
|
||||||
|
Payload: payload,
|
||||||
|
Timestamp: time.Now().UTC(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// mustJSON marshals v and panics on error (test helper only).
|
||||||
|
func mustJSON(v any) []byte {
|
||||||
|
b, err := json.Marshal(v)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildBlock wraps txs in a block that follows prev, computes hash, and signs
|
||||||
|
// it with validatorPriv. TotalFees is computed from the tx slice.
|
||||||
|
func buildBlock(t *testing.T, prev *blockchain.Block, validator *identity.Identity, txs []*blockchain.Transaction) *blockchain.Block {
|
||||||
|
t.Helper()
|
||||||
|
var totalFees uint64
|
||||||
|
for _, tx := range txs {
|
||||||
|
totalFees += tx.Fee
|
||||||
|
}
|
||||||
|
b := &blockchain.Block{
|
||||||
|
Index: prev.Index + 1,
|
||||||
|
Timestamp: time.Now().UTC(),
|
||||||
|
Transactions: txs,
|
||||||
|
PrevHash: prev.Hash,
|
||||||
|
Validator: validator.PubKeyHex(),
|
||||||
|
TotalFees: totalFees,
|
||||||
|
}
|
||||||
|
b.ComputeHash()
|
||||||
|
b.Sign(validator.PrivKey)
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
// mustAddBlock calls c.AddBlock and fails the test on error.
|
||||||
|
func mustAddBlock(t *testing.T, c *blockchain.Chain, b *blockchain.Block) {
|
||||||
|
t.Helper()
|
||||||
|
if err := c.AddBlock(b); err != nil {
|
||||||
|
t.Fatalf("AddBlock (index %d): %v", b.Index, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// mustBalance reads the balance and fails on error.
|
||||||
|
func mustBalance(t *testing.T, c *blockchain.Chain, pubHex string) uint64 {
|
||||||
|
t.Helper()
|
||||||
|
bal, err := c.Balance(pubHex)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Balance(%s): %v", pubHex[:8], err)
|
||||||
|
}
|
||||||
|
return bal
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── tests ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// 1. Genesis block credits GenesisAllocation to the validator.
|
||||||
|
func TestGenesisCreatesBalance(t *testing.T) {
|
||||||
|
c := newChain(t)
|
||||||
|
val := newIdentity(t)
|
||||||
|
|
||||||
|
addGenesis(t, c, val)
|
||||||
|
|
||||||
|
bal := mustBalance(t, c, val.PubKeyHex())
|
||||||
|
if bal != blockchain.GenesisAllocation {
|
||||||
|
t.Errorf("expected GenesisAllocation=%d, got %d", blockchain.GenesisAllocation, bal)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Transfer moves tokens between two identities and leaves correct balances.
|
||||||
|
func TestTransfer(t *testing.T) {
|
||||||
|
c := newChain(t)
|
||||||
|
val := newIdentity(t)
|
||||||
|
alice := newIdentity(t)
|
||||||
|
|
||||||
|
genesis := addGenesis(t, c, val)
|
||||||
|
|
||||||
|
// Fund alice via a transfer from validator.
|
||||||
|
const sendAmount = 100 * blockchain.Token
|
||||||
|
const fee = blockchain.MinFee
|
||||||
|
|
||||||
|
tx := makeTx(
|
||||||
|
blockchain.EventTransfer,
|
||||||
|
val.PubKeyHex(),
|
||||||
|
alice.PubKeyHex(),
|
||||||
|
sendAmount, fee,
|
||||||
|
mustJSON(blockchain.TransferPayload{}),
|
||||||
|
)
|
||||||
|
|
||||||
|
b1 := buildBlock(t, genesis, val, []*blockchain.Transaction{tx})
|
||||||
|
mustAddBlock(t, c, b1)
|
||||||
|
|
||||||
|
valBal := mustBalance(t, c, val.PubKeyHex())
|
||||||
|
aliceBal := mustBalance(t, c, alice.PubKeyHex())
|
||||||
|
|
||||||
|
// Validator: genesis - sendAmount - fee + fee (validator earns TotalFees back)
|
||||||
|
expectedVal := blockchain.GenesisAllocation - sendAmount - fee + fee
|
||||||
|
if valBal != expectedVal {
|
||||||
|
t.Errorf("validator balance: got %d, want %d", valBal, expectedVal)
|
||||||
|
}
|
||||||
|
if aliceBal != sendAmount {
|
||||||
|
t.Errorf("alice balance: got %d, want %d", aliceBal, sendAmount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Transfer that exceeds sender's balance must fail AddBlock.
|
||||||
|
func TestTransferInsufficientFunds(t *testing.T) {
|
||||||
|
c := newChain(t)
|
||||||
|
val := newIdentity(t)
|
||||||
|
alice := newIdentity(t)
|
||||||
|
|
||||||
|
genesis := addGenesis(t, c, val)
|
||||||
|
|
||||||
|
// alice has 0 balance — try to spend 1 token
|
||||||
|
tx := makeTx(
|
||||||
|
blockchain.EventTransfer,
|
||||||
|
alice.PubKeyHex(),
|
||||||
|
val.PubKeyHex(),
|
||||||
|
1*blockchain.Token, blockchain.MinFee,
|
||||||
|
mustJSON(blockchain.TransferPayload{}),
|
||||||
|
)
|
||||||
|
b := buildBlock(t, genesis, val, []*blockchain.Transaction{tx})
|
||||||
|
|
||||||
|
// AddBlock must succeed — the bad tx is skipped rather than rejecting the block.
|
||||||
|
if err := c.AddBlock(b); err != nil {
|
||||||
|
t.Fatalf("AddBlock returned unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
// Alice's balance must still be 0 — the skipped tx had no effect.
|
||||||
|
bal, err := c.Balance(alice.PubKeyHex())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Balance: %v", err)
|
||||||
|
}
|
||||||
|
if bal != 0 {
|
||||||
|
t.Errorf("expected alice balance 0, got %d", bal)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. EventRegisterKey stores X25519 key in IdentityInfo.
|
||||||
|
func TestRegisterKeyStoresIdentity(t *testing.T) {
|
||||||
|
c := newChain(t)
|
||||||
|
val := newIdentity(t)
|
||||||
|
alice := newIdentity(t)
|
||||||
|
|
||||||
|
genesis := addGenesis(t, c, val)
|
||||||
|
|
||||||
|
payload := blockchain.RegisterKeyPayload{
|
||||||
|
PubKey: alice.PubKeyHex(),
|
||||||
|
Nickname: "alice",
|
||||||
|
PowNonce: 0,
|
||||||
|
PowTarget: "0",
|
||||||
|
X25519PubKey: alice.X25519PubHex(),
|
||||||
|
}
|
||||||
|
tx := makeTx(
|
||||||
|
blockchain.EventRegisterKey,
|
||||||
|
alice.PubKeyHex(),
|
||||||
|
"",
|
||||||
|
0, blockchain.RegistrationFee,
|
||||||
|
mustJSON(payload),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Fund alice with enough to cover RegistrationFee before she registers.
|
||||||
|
fundTx := makeTx(
|
||||||
|
blockchain.EventTransfer,
|
||||||
|
val.PubKeyHex(),
|
||||||
|
alice.PubKeyHex(),
|
||||||
|
blockchain.RegistrationFee, blockchain.MinFee,
|
||||||
|
mustJSON(blockchain.TransferPayload{}),
|
||||||
|
)
|
||||||
|
b1 := buildBlock(t, genesis, val, []*blockchain.Transaction{fundTx})
|
||||||
|
mustAddBlock(t, c, b1)
|
||||||
|
|
||||||
|
b2 := buildBlock(t, b1, val, []*blockchain.Transaction{tx})
|
||||||
|
mustAddBlock(t, c, b2)
|
||||||
|
|
||||||
|
info, err := c.IdentityInfo(alice.PubKeyHex())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("IdentityInfo: %v", err)
|
||||||
|
}
|
||||||
|
if !info.Registered {
|
||||||
|
t.Error("expected Registered=true after REGISTER_KEY tx")
|
||||||
|
}
|
||||||
|
if info.Nickname != "alice" {
|
||||||
|
t.Errorf("nickname: got %q, want %q", info.Nickname, "alice")
|
||||||
|
}
|
||||||
|
if info.X25519Pub != alice.X25519PubHex() {
|
||||||
|
t.Errorf("X25519Pub: got %q, want %q", info.X25519Pub, alice.X25519PubHex())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. ContactRequest flow: pending → accepted → blocked.
|
||||||
|
func TestContactRequestFlow(t *testing.T) {
|
||||||
|
c := newChain(t)
|
||||||
|
val := newIdentity(t)
|
||||||
|
alice := newIdentity(t) // requester
|
||||||
|
bob := newIdentity(t) // target
|
||||||
|
|
||||||
|
genesis := addGenesis(t, c, val)
|
||||||
|
|
||||||
|
// Fund alice and bob for fees.
|
||||||
|
const contactAmt = blockchain.MinContactFee
|
||||||
|
fundAlice := makeTx(blockchain.EventTransfer, val.PubKeyHex(), alice.PubKeyHex(),
|
||||||
|
contactAmt+2*blockchain.MinFee, blockchain.MinFee, mustJSON(blockchain.TransferPayload{}))
|
||||||
|
fundBob := makeTx(blockchain.EventTransfer, val.PubKeyHex(), bob.PubKeyHex(),
|
||||||
|
2*blockchain.MinFee, blockchain.MinFee, mustJSON(blockchain.TransferPayload{}))
|
||||||
|
|
||||||
|
b1 := buildBlock(t, genesis, val, []*blockchain.Transaction{fundAlice, fundBob})
|
||||||
|
mustAddBlock(t, c, b1)
|
||||||
|
|
||||||
|
// Alice sends contact request to Bob.
|
||||||
|
reqTx := makeTx(
|
||||||
|
blockchain.EventContactRequest,
|
||||||
|
alice.PubKeyHex(),
|
||||||
|
bob.PubKeyHex(),
|
||||||
|
contactAmt, blockchain.MinFee,
|
||||||
|
mustJSON(blockchain.ContactRequestPayload{Intro: "Hey Bob!"}),
|
||||||
|
)
|
||||||
|
b2 := buildBlock(t, b1, val, []*blockchain.Transaction{reqTx})
|
||||||
|
mustAddBlock(t, c, b2)
|
||||||
|
|
||||||
|
contacts, err := c.ContactRequests(bob.PubKeyHex())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ContactRequests: %v", err)
|
||||||
|
}
|
||||||
|
if len(contacts) != 1 {
|
||||||
|
t.Fatalf("expected 1 contact record, got %d", len(contacts))
|
||||||
|
}
|
||||||
|
if contacts[0].Status != blockchain.ContactPending {
|
||||||
|
t.Errorf("status: got %q, want %q", contacts[0].Status, blockchain.ContactPending)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bob accepts.
|
||||||
|
acceptTx := makeTx(
|
||||||
|
blockchain.EventAcceptContact,
|
||||||
|
bob.PubKeyHex(),
|
||||||
|
alice.PubKeyHex(),
|
||||||
|
0, blockchain.MinFee,
|
||||||
|
mustJSON(blockchain.AcceptContactPayload{}),
|
||||||
|
)
|
||||||
|
b3 := buildBlock(t, b2, val, []*blockchain.Transaction{acceptTx})
|
||||||
|
mustAddBlock(t, c, b3)
|
||||||
|
|
||||||
|
contacts, err = c.ContactRequests(bob.PubKeyHex())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ContactRequests after accept: %v", err)
|
||||||
|
}
|
||||||
|
if len(contacts) != 1 || contacts[0].Status != blockchain.ContactAccepted {
|
||||||
|
t.Errorf("expected accepted, got %v", contacts)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bob then blocks Alice (status transitions from accepted → blocked).
|
||||||
|
blockTx := makeTx(
|
||||||
|
blockchain.EventBlockContact,
|
||||||
|
bob.PubKeyHex(),
|
||||||
|
alice.PubKeyHex(),
|
||||||
|
0, blockchain.MinFee,
|
||||||
|
mustJSON(blockchain.BlockContactPayload{}),
|
||||||
|
)
|
||||||
|
b4 := buildBlock(t, b3, val, []*blockchain.Transaction{blockTx})
|
||||||
|
mustAddBlock(t, c, b4)
|
||||||
|
|
||||||
|
contacts, err = c.ContactRequests(bob.PubKeyHex())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ContactRequests after block: %v", err)
|
||||||
|
}
|
||||||
|
if len(contacts) != 1 || contacts[0].Status != blockchain.ContactBlocked {
|
||||||
|
t.Errorf("expected blocked, got %v", contacts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. ContactRequest with amount below MinContactFee must fail.
|
||||||
|
func TestContactRequestInsufficientFee(t *testing.T) {
|
||||||
|
c := newChain(t)
|
||||||
|
val := newIdentity(t)
|
||||||
|
alice := newIdentity(t)
|
||||||
|
bob := newIdentity(t)
|
||||||
|
|
||||||
|
genesis := addGenesis(t, c, val)
|
||||||
|
|
||||||
|
// Fund alice.
|
||||||
|
fundAlice := makeTx(blockchain.EventTransfer, val.PubKeyHex(), alice.PubKeyHex(),
|
||||||
|
blockchain.MinContactFee+blockchain.MinFee, blockchain.MinFee,
|
||||||
|
mustJSON(blockchain.TransferPayload{}))
|
||||||
|
b1 := buildBlock(t, genesis, val, []*blockchain.Transaction{fundAlice})
|
||||||
|
mustAddBlock(t, c, b1)
|
||||||
|
|
||||||
|
// Amount is one µT below MinContactFee.
|
||||||
|
reqTx := makeTx(
|
||||||
|
blockchain.EventContactRequest,
|
||||||
|
alice.PubKeyHex(),
|
||||||
|
bob.PubKeyHex(),
|
||||||
|
blockchain.MinContactFee-1, blockchain.MinFee,
|
||||||
|
mustJSON(blockchain.ContactRequestPayload{}),
|
||||||
|
)
|
||||||
|
b := buildBlock(t, b1, val, []*blockchain.Transaction{reqTx})
|
||||||
|
// AddBlock must succeed — the bad tx is skipped rather than rejecting the block.
|
||||||
|
if err := c.AddBlock(b); err != nil {
|
||||||
|
t.Fatalf("AddBlock returned unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
// No pending contact record must exist for bob←alice.
|
||||||
|
contacts, err := c.ContactRequests(bob.PubKeyHex())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ContactRequests: %v", err)
|
||||||
|
}
|
||||||
|
if len(contacts) != 0 {
|
||||||
|
t.Errorf("expected 0 pending contacts, got %d (tx should have been skipped)", len(contacts))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7. InitValidators seeds keys; ValidatorSet returns them all.
|
||||||
|
func TestValidatorSetInit(t *testing.T) {
|
||||||
|
c := newChain(t)
|
||||||
|
ids := []*identity.Identity{newIdentity(t), newIdentity(t), newIdentity(t)}
|
||||||
|
keys := make([]string, len(ids))
|
||||||
|
for i, id := range ids {
|
||||||
|
keys[i] = id.PubKeyHex()
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := c.InitValidators(keys); err != nil {
|
||||||
|
t.Fatalf("InitValidators: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
set, err := c.ValidatorSet()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ValidatorSet: %v", err)
|
||||||
|
}
|
||||||
|
if len(set) != len(keys) {
|
||||||
|
t.Fatalf("expected %d validators, got %d", len(keys), len(set))
|
||||||
|
}
|
||||||
|
got := make(map[string]bool, len(set))
|
||||||
|
for _, k := range set {
|
||||||
|
got[k] = true
|
||||||
|
}
|
||||||
|
for _, k := range keys {
|
||||||
|
if !got[k] {
|
||||||
|
t.Errorf("key %s missing from validator set", k[:8])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 8. EventAddValidator adds a new validator via a real block.
|
||||||
|
//
|
||||||
|
// Updated for P2.1 (stake-gated admission): the candidate must first have
|
||||||
|
// at least MinValidatorStake (1 T = 1_000_000 µT) locked via a STAKE tx
|
||||||
|
// and be credited enough balance to do so. Multi-sig approval is trivially
|
||||||
|
// met here because the initial set has only one validator — ⌈2/3⌉ of 1
|
||||||
|
// is 1, which the tx sender provides implicitly.
|
||||||
|
func TestAddValidatorTx(t *testing.T) {
|
||||||
|
c := newChain(t)
|
||||||
|
val := newIdentity(t) // initial validator
|
||||||
|
newVal := newIdentity(t) // to be added
|
||||||
|
|
||||||
|
// Seed the initial validator.
|
||||||
|
if err := c.InitValidators([]string{val.PubKeyHex()}); err != nil {
|
||||||
|
t.Fatalf("InitValidators: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
genesis := addGenesis(t, c, val)
|
||||||
|
|
||||||
|
// Fund the candidate enough to stake.
|
||||||
|
fundTx := makeTx(
|
||||||
|
blockchain.EventTransfer,
|
||||||
|
val.PubKeyHex(),
|
||||||
|
newVal.PubKeyHex(),
|
||||||
|
2*blockchain.MinValidatorStake, blockchain.MinFee,
|
||||||
|
mustJSON(blockchain.TransferPayload{}),
|
||||||
|
)
|
||||||
|
// Candidate stakes the minimum.
|
||||||
|
stakeTx := makeTx(
|
||||||
|
blockchain.EventStake,
|
||||||
|
newVal.PubKeyHex(),
|
||||||
|
newVal.PubKeyHex(),
|
||||||
|
blockchain.MinValidatorStake, blockchain.MinFee,
|
||||||
|
nil,
|
||||||
|
)
|
||||||
|
preBlock := buildBlock(t, genesis, val, []*blockchain.Transaction{fundTx, stakeTx})
|
||||||
|
mustAddBlock(t, c, preBlock)
|
||||||
|
|
||||||
|
tx := makeTx(
|
||||||
|
blockchain.EventAddValidator,
|
||||||
|
val.PubKeyHex(),
|
||||||
|
newVal.PubKeyHex(),
|
||||||
|
0, blockchain.MinFee,
|
||||||
|
mustJSON(blockchain.AddValidatorPayload{Reason: "test"}),
|
||||||
|
)
|
||||||
|
b1 := buildBlock(t, preBlock, val, []*blockchain.Transaction{tx})
|
||||||
|
mustAddBlock(t, c, b1)
|
||||||
|
|
||||||
|
set, err := c.ValidatorSet()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ValidatorSet: %v", err)
|
||||||
|
}
|
||||||
|
found := false
|
||||||
|
for _, k := range set {
|
||||||
|
if k == newVal.PubKeyHex() {
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Errorf("new validator %s not found in set after ADD_VALIDATOR tx", newVal.PubKeyHex()[:8])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 9. EventRemoveValidator removes a key from the set.
|
||||||
|
//
|
||||||
|
// Updated for P2.2 (multi-sig forced removal): the sender and the
|
||||||
|
// cosigners must together reach ⌈2/3⌉ of the current set. Here we have
|
||||||
|
// 3 validators, so 2 approvals are needed. `val` sends, `coSigner` adds
|
||||||
|
// a signature for RemoveDigest(removeMe.Pub).
|
||||||
|
func TestRemoveValidatorTx(t *testing.T) {
|
||||||
|
c := newChain(t)
|
||||||
|
val := newIdentity(t)
|
||||||
|
coSigner := newIdentity(t)
|
||||||
|
removeMe := newIdentity(t)
|
||||||
|
|
||||||
|
// All three start as validators (ceil(2/3 * 3) = 2 approvals needed).
|
||||||
|
if err := c.InitValidators([]string{val.PubKeyHex(), coSigner.PubKeyHex(), removeMe.PubKeyHex()}); err != nil {
|
||||||
|
t.Fatalf("InitValidators: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
genesis := addGenesis(t, c, val)
|
||||||
|
|
||||||
|
// coSigner produces an off-chain approval for removing removeMe.
|
||||||
|
sig := coSigner.Sign(blockchain.RemoveDigest(removeMe.PubKeyHex()))
|
||||||
|
|
||||||
|
tx := makeTx(
|
||||||
|
blockchain.EventRemoveValidator,
|
||||||
|
val.PubKeyHex(),
|
||||||
|
removeMe.PubKeyHex(),
|
||||||
|
0, blockchain.MinFee,
|
||||||
|
mustJSON(blockchain.RemoveValidatorPayload{
|
||||||
|
Reason: "test",
|
||||||
|
CoSignatures: []blockchain.ValidatorCoSig{
|
||||||
|
{PubKey: coSigner.PubKeyHex(), Signature: sig},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
b1 := buildBlock(t, genesis, val, []*blockchain.Transaction{tx})
|
||||||
|
mustAddBlock(t, c, b1)
|
||||||
|
|
||||||
|
set, err := c.ValidatorSet()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ValidatorSet: %v", err)
|
||||||
|
}
|
||||||
|
for _, k := range set {
|
||||||
|
if k == removeMe.PubKeyHex() {
|
||||||
|
t.Errorf("removed validator %s still in set", removeMe.PubKeyHex()[:8])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 10. ADD_VALIDATOR tx from a non-validator must fail.
|
||||||
|
func TestAddValidatorNotAValidator(t *testing.T) {
|
||||||
|
c := newChain(t)
|
||||||
|
val := newIdentity(t)
|
||||||
|
nonVal := newIdentity(t)
|
||||||
|
target := newIdentity(t)
|
||||||
|
|
||||||
|
if err := c.InitValidators([]string{val.PubKeyHex()}); err != nil {
|
||||||
|
t.Fatalf("InitValidators: %v", err)
|
||||||
|
}
|
||||||
|
genesis := addGenesis(t, c, val)
|
||||||
|
|
||||||
|
// Fund nonVal so the debit doesn't fail first (it should fail on validator check).
|
||||||
|
fundTx := makeTx(blockchain.EventTransfer, val.PubKeyHex(), nonVal.PubKeyHex(),
|
||||||
|
10*blockchain.Token, blockchain.MinFee, mustJSON(blockchain.TransferPayload{}))
|
||||||
|
b1 := buildBlock(t, genesis, val, []*blockchain.Transaction{fundTx})
|
||||||
|
mustAddBlock(t, c, b1)
|
||||||
|
|
||||||
|
badTx := makeTx(
|
||||||
|
blockchain.EventAddValidator,
|
||||||
|
nonVal.PubKeyHex(), // not a validator
|
||||||
|
target.PubKeyHex(),
|
||||||
|
0, blockchain.MinFee,
|
||||||
|
mustJSON(blockchain.AddValidatorPayload{}),
|
||||||
|
)
|
||||||
|
b2 := buildBlock(t, b1, val, []*blockchain.Transaction{badTx})
|
||||||
|
// AddBlock must succeed — the bad tx is skipped rather than rejecting the block.
|
||||||
|
if err := c.AddBlock(b2); err != nil {
|
||||||
|
t.Fatalf("AddBlock returned unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
// target must NOT have been added as a validator (tx was skipped).
|
||||||
|
vset, err := c.ValidatorSet()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ValidatorSet: %v", err)
|
||||||
|
}
|
||||||
|
for _, v := range vset {
|
||||||
|
if v == target.PubKeyHex() {
|
||||||
|
t.Error("target was added as validator despite tx being from a non-validator (should have been skipped)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 11. RelayProof with valid FeeSig transfers the relay fee from sender to relay.
|
||||||
|
func TestRelayProofClaimsFee(t *testing.T) {
|
||||||
|
c := newChain(t)
|
||||||
|
val := newIdentity(t)
|
||||||
|
sender := newIdentity(t)
|
||||||
|
relay := newIdentity(t)
|
||||||
|
|
||||||
|
genesis := addGenesis(t, c, val)
|
||||||
|
|
||||||
|
const relayFeeUT = 5_000 * blockchain.MicroToken
|
||||||
|
|
||||||
|
// Fund sender with enough to cover relay fee and tx fee.
|
||||||
|
fundTx := makeTx(blockchain.EventTransfer, val.PubKeyHex(), sender.PubKeyHex(),
|
||||||
|
relayFeeUT+blockchain.MinFee, blockchain.MinFee, mustJSON(blockchain.TransferPayload{}))
|
||||||
|
b1 := buildBlock(t, genesis, val, []*blockchain.Transaction{fundTx})
|
||||||
|
mustAddBlock(t, c, b1)
|
||||||
|
|
||||||
|
senderBalBefore := mustBalance(t, c, sender.PubKeyHex())
|
||||||
|
relayBalBefore := mustBalance(t, c, relay.PubKeyHex())
|
||||||
|
|
||||||
|
envelopeID := "env-abc123"
|
||||||
|
authBytes := blockchain.FeeAuthBytes(envelopeID, relayFeeUT)
|
||||||
|
feeSig := sender.Sign(authBytes)
|
||||||
|
|
||||||
|
envelopeHash := sha256.Sum256([]byte("fake-ciphertext"))
|
||||||
|
proofPayload := blockchain.RelayProofPayload{
|
||||||
|
EnvelopeID: envelopeID,
|
||||||
|
EnvelopeHash: envelopeHash[:],
|
||||||
|
SenderPubKey: sender.PubKeyHex(),
|
||||||
|
FeeUT: relayFeeUT,
|
||||||
|
FeeSig: feeSig,
|
||||||
|
RelayPubKey: relay.PubKeyHex(),
|
||||||
|
DeliveredAt: time.Now().Unix(),
|
||||||
|
}
|
||||||
|
tx := makeTx(
|
||||||
|
blockchain.EventRelayProof,
|
||||||
|
relay.PubKeyHex(),
|
||||||
|
"",
|
||||||
|
0, blockchain.MinFee,
|
||||||
|
mustJSON(proofPayload),
|
||||||
|
)
|
||||||
|
|
||||||
|
b2 := buildBlock(t, b1, val, []*blockchain.Transaction{tx})
|
||||||
|
mustAddBlock(t, c, b2)
|
||||||
|
|
||||||
|
senderBalAfter := mustBalance(t, c, sender.PubKeyHex())
|
||||||
|
relayBalAfter := mustBalance(t, c, relay.PubKeyHex())
|
||||||
|
|
||||||
|
if senderBalAfter != senderBalBefore-relayFeeUT {
|
||||||
|
t.Errorf("sender balance: got %d, want %d (before %d - fee %d)",
|
||||||
|
senderBalAfter, senderBalBefore-relayFeeUT, senderBalBefore, relayFeeUT)
|
||||||
|
}
|
||||||
|
if relayBalAfter != relayBalBefore+relayFeeUT {
|
||||||
|
t.Errorf("relay balance: got %d, want %d (before %d + fee %d)",
|
||||||
|
relayBalAfter, relayBalBefore+relayFeeUT, relayBalBefore, relayFeeUT)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 12. RelayProof with wrong FeeSig must fail AddBlock.
|
||||||
|
func TestRelayProofBadSig(t *testing.T) {
|
||||||
|
c := newChain(t)
|
||||||
|
val := newIdentity(t)
|
||||||
|
sender := newIdentity(t)
|
||||||
|
relay := newIdentity(t)
|
||||||
|
imposter := newIdentity(t) // signs instead of sender
|
||||||
|
|
||||||
|
genesis := addGenesis(t, c, val)
|
||||||
|
|
||||||
|
const relayFeeUT = 5_000 * blockchain.MicroToken
|
||||||
|
|
||||||
|
// Fund sender.
|
||||||
|
fundTx := makeTx(blockchain.EventTransfer, val.PubKeyHex(), sender.PubKeyHex(),
|
||||||
|
relayFeeUT+blockchain.MinFee, blockchain.MinFee, mustJSON(blockchain.TransferPayload{}))
|
||||||
|
b1 := buildBlock(t, genesis, val, []*blockchain.Transaction{fundTx})
|
||||||
|
mustAddBlock(t, c, b1)
|
||||||
|
|
||||||
|
senderBalBefore := mustBalance(t, c, sender.PubKeyHex())
|
||||||
|
|
||||||
|
envelopeID := "env-xyz"
|
||||||
|
authBytes := blockchain.FeeAuthBytes(envelopeID, relayFeeUT)
|
||||||
|
// Imposter signs, not the actual sender.
|
||||||
|
badFeeSig := imposter.Sign(authBytes)
|
||||||
|
|
||||||
|
envelopeHash := sha256.Sum256([]byte("ciphertext"))
|
||||||
|
proofPayload := blockchain.RelayProofPayload{
|
||||||
|
EnvelopeID: envelopeID,
|
||||||
|
EnvelopeHash: envelopeHash[:],
|
||||||
|
SenderPubKey: sender.PubKeyHex(), // claims sender, but sig is from imposter
|
||||||
|
FeeUT: relayFeeUT,
|
||||||
|
FeeSig: badFeeSig,
|
||||||
|
RelayPubKey: relay.PubKeyHex(),
|
||||||
|
DeliveredAt: time.Now().Unix(),
|
||||||
|
}
|
||||||
|
tx := makeTx(
|
||||||
|
blockchain.EventRelayProof,
|
||||||
|
relay.PubKeyHex(),
|
||||||
|
"",
|
||||||
|
0, blockchain.MinFee,
|
||||||
|
mustJSON(proofPayload),
|
||||||
|
)
|
||||||
|
b2 := buildBlock(t, b1, val, []*blockchain.Transaction{tx})
|
||||||
|
// AddBlock must succeed — the bad tx is skipped rather than rejecting the block.
|
||||||
|
if err := c.AddBlock(b2); err != nil {
|
||||||
|
t.Fatalf("AddBlock returned unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
// Sender's balance must be unchanged — the skipped tx had no effect.
|
||||||
|
senderBalAfter, err := c.Balance(sender.PubKeyHex())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Balance: %v", err)
|
||||||
|
}
|
||||||
|
if senderBalAfter != senderBalBefore {
|
||||||
|
t.Errorf("sender balance changed despite bad-sig tx: before=%d after=%d",
|
||||||
|
senderBalBefore, senderBalAfter)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 13. Adding the same block index twice must fail.
|
||||||
|
func TestDuplicateBlockRejected(t *testing.T) {
|
||||||
|
c := newChain(t)
|
||||||
|
val := newIdentity(t)
|
||||||
|
|
||||||
|
genesis := addGenesis(t, c, val)
|
||||||
|
|
||||||
|
// Build block 1.
|
||||||
|
b1 := buildBlock(t, genesis, val, nil)
|
||||||
|
mustAddBlock(t, c, b1)
|
||||||
|
|
||||||
|
// Build an independent block also claiming index 1 (different hash).
|
||||||
|
b1dup := &blockchain.Block{
|
||||||
|
Index: 1,
|
||||||
|
Timestamp: time.Now().Add(time.Millisecond).UTC(),
|
||||||
|
Transactions: []*blockchain.Transaction{},
|
||||||
|
PrevHash: genesis.Hash,
|
||||||
|
Validator: val.PubKeyHex(),
|
||||||
|
TotalFees: 0,
|
||||||
|
}
|
||||||
|
b1dup.ComputeHash()
|
||||||
|
b1dup.Sign(val.PrivKey)
|
||||||
|
|
||||||
|
// The chain tip is already at index 1; the new block has index 1 but a
|
||||||
|
// different prevHash (its own prev is genesis too but tip.Hash ≠ genesis.Hash).
|
||||||
|
if err := c.AddBlock(b1dup); err == nil {
|
||||||
|
t.Fatal("expected AddBlock to fail for duplicate index, but it succeeded")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 14. Block with wrong prevHash must fail.
|
||||||
|
func TestChainLinkageRejected(t *testing.T) {
|
||||||
|
c := newChain(t)
|
||||||
|
val := newIdentity(t)
|
||||||
|
|
||||||
|
genesis := addGenesis(t, c, val)
|
||||||
|
|
||||||
|
// Create a block with a garbage prevHash.
|
||||||
|
garbagePrev := make([]byte, 32)
|
||||||
|
if _, err := rand.Read(garbagePrev); err != nil {
|
||||||
|
t.Fatalf("rand.Read: %v", err)
|
||||||
|
}
|
||||||
|
badBlock := &blockchain.Block{
|
||||||
|
Index: 1,
|
||||||
|
Timestamp: time.Now().UTC(),
|
||||||
|
Transactions: []*blockchain.Transaction{},
|
||||||
|
PrevHash: garbagePrev,
|
||||||
|
Validator: val.PubKeyHex(),
|
||||||
|
TotalFees: 0,
|
||||||
|
}
|
||||||
|
badBlock.ComputeHash()
|
||||||
|
badBlock.Sign(val.PrivKey)
|
||||||
|
|
||||||
|
if err := c.AddBlock(badBlock); err == nil {
|
||||||
|
t.Fatal("expected AddBlock to fail for wrong prevHash, but it succeeded")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tip must still be genesis.
|
||||||
|
tip := c.Tip()
|
||||||
|
if tip.Index != genesis.Index {
|
||||||
|
t.Errorf("tip index after rejection: got %d, want %d", tip.Index, genesis.Index)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 15. Tip advances with each successfully committed block.
|
||||||
|
func TestTipUpdates(t *testing.T) {
|
||||||
|
c := newChain(t)
|
||||||
|
val := newIdentity(t)
|
||||||
|
|
||||||
|
if tip := c.Tip(); tip != nil {
|
||||||
|
t.Fatalf("tip on empty chain: expected nil, got index %d", tip.Index)
|
||||||
|
}
|
||||||
|
|
||||||
|
genesis := addGenesis(t, c, val)
|
||||||
|
if tip := c.Tip(); tip == nil || tip.Index != 0 {
|
||||||
|
t.Fatalf("tip after genesis: expected index 0, got %v", tip)
|
||||||
|
}
|
||||||
|
|
||||||
|
prev := genesis
|
||||||
|
for i := uint64(1); i <= 3; i++ {
|
||||||
|
b := buildBlock(t, prev, val, nil)
|
||||||
|
mustAddBlock(t, c, b)
|
||||||
|
|
||||||
|
tip := c.Tip()
|
||||||
|
if tip == nil {
|
||||||
|
t.Fatalf("tip is nil after block %d", i)
|
||||||
|
}
|
||||||
|
if tip.Index != i {
|
||||||
|
t.Errorf("tip.Index after block %d: got %d, want %d", i, tip.Index, i)
|
||||||
|
}
|
||||||
|
prev = b
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── compile-time guard ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// Ensure the identity package is used directly so the import is not trimmed.
|
||||||
|
var _ = identity.Generate
|
||||||
|
|
||||||
|
// Ensure ed25519 and hex are used directly (they may be used via helpers).
|
||||||
|
var _ = ed25519.PublicKey(nil)
|
||||||
|
var _ = hex.EncodeToString
|
||||||
101
blockchain/equivocation.go
Normal file
101
blockchain/equivocation.go
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
// Package blockchain — equivocation evidence verification for SLASH txs.
|
||||||
|
//
|
||||||
|
// "Equivocation" = a validator signing two different consensus messages
|
||||||
|
// at the same height+view+phase, each endorsing a different block hash.
|
||||||
|
// PBFT safety depends on validators NOT doing this; a malicious validator
|
||||||
|
// that equivocates can split honest nodes into disagreeing majorities.
|
||||||
|
//
|
||||||
|
// The SLASH tx embeds an EquivocationEvidence payload carrying both
|
||||||
|
// conflicting messages. Any node (not just the victim) can submit it;
|
||||||
|
// on-chain verification is purely cryptographic — no "trust me" from the
|
||||||
|
// submitter. If the evidence is valid, the offender's stake is burned and
|
||||||
|
// they're removed from the validator set.
|
||||||
|
package blockchain
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"crypto/ed25519"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// EquivocationEvidence is embedded (as JSON bytes) in SlashPayload.Evidence
|
||||||
|
// when Reason == "equivocation". Two distinct consensus messages from the
|
||||||
|
// same validator at the same consensus position prove they are trying to
|
||||||
|
// fork the chain.
|
||||||
|
type EquivocationEvidence struct {
|
||||||
|
A *ConsensusMsg `json:"a"`
|
||||||
|
B *ConsensusMsg `json:"b"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateEquivocation verifies that the two messages constitute genuine
|
||||||
|
// equivocation evidence against `offender`. Returns nil on success;
|
||||||
|
// errors are returned with enough detail for the applyTx caller to log
|
||||||
|
// why a slash was rejected.
|
||||||
|
//
|
||||||
|
// Rules:
|
||||||
|
// - Both messages must be signed by `offender` (From = offender,
|
||||||
|
// signature verifies against the offender's Ed25519 pubkey).
|
||||||
|
// - Same Type (MsgPrepare or MsgCommit — we don't slash for equivocating
|
||||||
|
// on PrePrepare since leaders can legitimately re-propose).
|
||||||
|
// - Same View, same SeqNum — equivocation is about the same consensus
|
||||||
|
// round.
|
||||||
|
// - Distinct BlockHash — otherwise the two messages are identical and
|
||||||
|
// not actually contradictory.
|
||||||
|
// - Both sigs verify against the offender's pubkey.
|
||||||
|
func ValidateEquivocation(offender string, ev *EquivocationEvidence) error {
|
||||||
|
if ev == nil || ev.A == nil || ev.B == nil {
|
||||||
|
return fmt.Errorf("equivocation: missing message(s)")
|
||||||
|
}
|
||||||
|
if ev.A.From != offender || ev.B.From != offender {
|
||||||
|
return fmt.Errorf("equivocation: messages not from offender %s", offender[:8])
|
||||||
|
}
|
||||||
|
// Only PREPARE / COMMIT equivocation is slashable. PRE-PREPARE double-
|
||||||
|
// proposals are expected during view changes — the protocol tolerates
|
||||||
|
// them.
|
||||||
|
if ev.A.Type != ev.B.Type {
|
||||||
|
return fmt.Errorf("equivocation: messages are different types (%v vs %v)", ev.A.Type, ev.B.Type)
|
||||||
|
}
|
||||||
|
if ev.A.Type != MsgPrepare && ev.A.Type != MsgCommit {
|
||||||
|
return fmt.Errorf("equivocation: only PREPARE/COMMIT are slashable (got %v)", ev.A.Type)
|
||||||
|
}
|
||||||
|
if ev.A.View != ev.B.View {
|
||||||
|
return fmt.Errorf("equivocation: different views (%d vs %d)", ev.A.View, ev.B.View)
|
||||||
|
}
|
||||||
|
if ev.A.SeqNum != ev.B.SeqNum {
|
||||||
|
return fmt.Errorf("equivocation: different seqnums (%d vs %d)", ev.A.SeqNum, ev.B.SeqNum)
|
||||||
|
}
|
||||||
|
if bytes.Equal(ev.A.BlockHash, ev.B.BlockHash) {
|
||||||
|
return fmt.Errorf("equivocation: messages endorse the same block")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decode pubkey + verify both signatures over the canonical bytes.
|
||||||
|
pubBytes, err := hex.DecodeString(offender)
|
||||||
|
if err != nil || len(pubBytes) != ed25519.PublicKeySize {
|
||||||
|
return fmt.Errorf("equivocation: bad offender pubkey")
|
||||||
|
}
|
||||||
|
pub := ed25519.PublicKey(pubBytes)
|
||||||
|
|
||||||
|
if !ed25519.Verify(pub, consensusMsgSignBytes(ev.A), ev.A.Signature) {
|
||||||
|
return fmt.Errorf("equivocation: signature A does not verify")
|
||||||
|
}
|
||||||
|
if !ed25519.Verify(pub, consensusMsgSignBytes(ev.B), ev.B.Signature) {
|
||||||
|
return fmt.Errorf("equivocation: signature B does not verify")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// consensusMsgSignBytes MUST match consensus/pbft.go:msgSignBytes exactly.
|
||||||
|
// We duplicate it here (instead of importing consensus) to keep the
|
||||||
|
// blockchain package free of a consensus dependency — consensus already
|
||||||
|
// imports blockchain for types.
|
||||||
|
func consensusMsgSignBytes(msg *ConsensusMsg) []byte {
|
||||||
|
tmp := *msg
|
||||||
|
tmp.Signature = nil
|
||||||
|
tmp.Block = nil
|
||||||
|
data, _ := json.Marshal(tmp)
|
||||||
|
h := sha256.Sum256(data)
|
||||||
|
return h[:]
|
||||||
|
}
|
||||||
562
blockchain/index.go
Normal file
562
blockchain/index.go
Normal file
@@ -0,0 +1,562 @@
|
|||||||
|
package blockchain
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
badger "github.com/dgraph-io/badger/v4"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Index key prefixes
|
||||||
|
const (
|
||||||
|
prefixTxRecord = "tx:" // tx:<txid> → TxRecord JSON
|
||||||
|
prefixTxByAddr = "txaddr:" // txaddr:<pubkey>:<block020d>:<txid> → "" (empty value)
|
||||||
|
prefixAddrMap = "addrmap:" // addrmap:<DCaddr> → pubkey hex
|
||||||
|
prefixNetStats = "netstats" // netstats → NetStats JSON
|
||||||
|
syntheticRewardIDPrefix = "sys-reward-"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TxRecord wraps a Transaction with its on-chain context.
|
||||||
|
type TxRecord struct {
|
||||||
|
Tx *Transaction `json:"tx"`
|
||||||
|
BlockIndex uint64 `json:"block_index"`
|
||||||
|
BlockHash string `json:"block_hash"`
|
||||||
|
BlockTime time.Time `json:"block_time"`
|
||||||
|
GasUsed uint64 `json:"gas_used,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// NetStats are aggregate counters updated every block.
|
||||||
|
type NetStats struct {
|
||||||
|
TotalBlocks uint64 `json:"total_blocks"`
|
||||||
|
TotalTxs uint64 `json:"total_txs"`
|
||||||
|
TotalTransfers uint64 `json:"total_transfers"`
|
||||||
|
TotalRelayProofs uint64 `json:"total_relay_proofs"`
|
||||||
|
TotalSupply uint64 `json:"total_supply"` // µT ever minted via rewards + grants
|
||||||
|
ValidatorCount int `json:"validator_count"`
|
||||||
|
RelayCount int `json:"relay_count"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// indexBlock is called inside AddBlock's db.Update() — indexes all transactions
|
||||||
|
// in the block and updates aggregate stats.
|
||||||
|
// gasUsed maps tx.ID → gas consumed for CALL_CONTRACT transactions.
|
||||||
|
func (c *Chain) indexBlock(txn *badger.Txn, b *Block, gasUsed map[string]uint64) error {
|
||||||
|
// Load existing stats
|
||||||
|
stats, err := c.readNetStats(txn)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
stats.TotalBlocks = b.Index + 1
|
||||||
|
// TotalSupply is fixed at GenesisAllocation; update it once at genesis.
|
||||||
|
if b.Index == 0 {
|
||||||
|
stats.TotalSupply = GenesisAllocation
|
||||||
|
}
|
||||||
|
|
||||||
|
for seq, tx := range b.Transactions {
|
||||||
|
// Store full TxRecord — but never overwrite an existing record.
|
||||||
|
// The same TX can appear in multiple gossiped blocks due to a mempool/PBFT
|
||||||
|
// race; the first block that actually applies it (via applyTx) will have
|
||||||
|
// gasUsed > 0. Subsequent re-indexings with an empty gasUsedByTx map
|
||||||
|
// would zero out the stored GasUsed. Skip if the record already exists.
|
||||||
|
recKey := []byte(prefixTxRecord + tx.ID)
|
||||||
|
if _, existErr := txn.Get(recKey); existErr == nil {
|
||||||
|
// TxRecord already written (from an earlier block or earlier call);
|
||||||
|
// do not overwrite it.
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Chronological index entry (txchron:<block20d>:<seq04d> → tx_id).
|
||||||
|
// Lets RecentTxs iterate tx-by-tx instead of block-by-block so chains
|
||||||
|
// with many empty blocks still answer /api/txs/recent in O(limit).
|
||||||
|
chronKey := fmt.Sprintf("%s%020d:%04d", prefixTxChron, b.Index, seq)
|
||||||
|
if err := txn.Set([]byte(chronKey), []byte(tx.ID)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
gasForTx := gasUsed[tx.ID]
|
||||||
|
rec := TxRecord{
|
||||||
|
Tx: tx,
|
||||||
|
BlockIndex: b.Index,
|
||||||
|
BlockHash: b.HashHex(),
|
||||||
|
BlockTime: b.Timestamp,
|
||||||
|
GasUsed: gasForTx,
|
||||||
|
}
|
||||||
|
val, err := json.Marshal(rec)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := txn.Set(recKey, val); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Index by sender
|
||||||
|
if tx.From != "" {
|
||||||
|
addrKey := txAddrKey(tx.From, b.Index, tx.ID)
|
||||||
|
if err := txn.Set([]byte(addrKey), []byte{}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// Store addr → pubkey mapping
|
||||||
|
if err := c.storeAddrMap(txn, tx.From); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Index by recipient
|
||||||
|
if tx.To != "" && tx.To != tx.From {
|
||||||
|
addrKey := txAddrKey(tx.To, b.Index, tx.ID)
|
||||||
|
if err := txn.Set([]byte(addrKey), []byte{}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := c.storeAddrMap(txn, tx.To); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update aggregate counters
|
||||||
|
stats.TotalTxs++
|
||||||
|
switch tx.Type {
|
||||||
|
case EventTransfer:
|
||||||
|
stats.TotalTransfers++
|
||||||
|
case EventRelayProof:
|
||||||
|
stats.TotalRelayProofs++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Index synthetic block reward only when the validator actually earned fees,
|
||||||
|
// or for the genesis block (one-time allocation). Empty blocks produce no
|
||||||
|
// state change and no income, so there is nothing useful to show.
|
||||||
|
if b.TotalFees > 0 || b.Index == 0 {
|
||||||
|
rewardTarget, err := c.resolveRewardTarget(txn, b.Validator)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
rewardTx, err := makeBlockRewardTx(b, rewardTarget)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
rewardRec := TxRecord{
|
||||||
|
Tx: rewardTx,
|
||||||
|
BlockIndex: b.Index,
|
||||||
|
BlockHash: b.HashHex(),
|
||||||
|
BlockTime: b.Timestamp,
|
||||||
|
}
|
||||||
|
rewardVal, err := json.Marshal(rewardRec)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := txn.Set([]byte(prefixTxRecord+rewardTx.ID), rewardVal); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if rewardTx.From != "" {
|
||||||
|
if err := txn.Set([]byte(txAddrKey(rewardTx.From, b.Index, rewardTx.ID)), []byte{}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := c.storeAddrMap(txn, rewardTx.From); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if rewardTx.To != "" && rewardTx.To != rewardTx.From {
|
||||||
|
if err := txn.Set([]byte(txAddrKey(rewardTx.To, b.Index, rewardTx.ID)), []byte{}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := c.storeAddrMap(txn, rewardTx.To); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Persist updated stats
|
||||||
|
return c.writeNetStats(txn, stats)
|
||||||
|
}
|
||||||
|
|
||||||
|
func makeBlockRewardTx(b *Block, rewardTarget string) (*Transaction, error) {
|
||||||
|
var memo string
|
||||||
|
if b.Index == 0 {
|
||||||
|
memo = fmt.Sprintf("Genesis allocation: %d µT", GenesisAllocation)
|
||||||
|
} else {
|
||||||
|
memo = fmt.Sprintf("Block fees: %d µT", b.TotalFees)
|
||||||
|
}
|
||||||
|
|
||||||
|
total := b.TotalFees
|
||||||
|
if b.Index == 0 {
|
||||||
|
total = GenesisAllocation
|
||||||
|
}
|
||||||
|
|
||||||
|
payload, err := json.Marshal(BlockRewardPayload{
|
||||||
|
ValidatorPubKey: b.Validator,
|
||||||
|
TargetPubKey: rewardTarget,
|
||||||
|
FeeReward: b.TotalFees,
|
||||||
|
TotalReward: total,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
// From is intentionally left empty: a block reward is a synthetic, freshly
|
||||||
|
// minted allocation (fees collected by the network) rather than a transfer
|
||||||
|
// from an actual account. Leaving From="" prevents the reward from appearing
|
||||||
|
// as "validator paid themselves" in the explorer/client when the validator
|
||||||
|
// has no separate wallet binding (rewardTarget == b.Validator).
|
||||||
|
// b.Validator is still recorded inside the payload (BlockRewardPayload).
|
||||||
|
return &Transaction{
|
||||||
|
ID: fmt.Sprintf("%s%020d", syntheticRewardIDPrefix, b.Index),
|
||||||
|
Type: EventBlockReward,
|
||||||
|
From: "",
|
||||||
|
To: rewardTarget,
|
||||||
|
Amount: total,
|
||||||
|
Fee: 0,
|
||||||
|
Memo: memo,
|
||||||
|
Payload: payload,
|
||||||
|
Timestamp: b.Timestamp,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// txAddrKey builds the composite key: txaddr:<pubkey>:<block_020d>:<txid>
|
||||||
|
func txAddrKey(pubKey string, blockIdx uint64, txID string) string {
|
||||||
|
return fmt.Sprintf("%s%s:%020d:%s", prefixTxByAddr, pubKey, blockIdx, txID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// storeAddrMap stores a DC address → pubkey mapping.
|
||||||
|
func (c *Chain) storeAddrMap(txn *badger.Txn, pubKey string) error {
|
||||||
|
addr := pubKeyToAddr(pubKey)
|
||||||
|
return txn.Set([]byte(prefixAddrMap+addr), []byte(pubKey))
|
||||||
|
}
|
||||||
|
|
||||||
|
// pubKeyToAddr converts a hex Ed25519 public key to a DC address.
|
||||||
|
// Replicates wallet.PubKeyToAddress without importing the wallet package.
|
||||||
|
func pubKeyToAddr(pubKeyHex string) string {
|
||||||
|
raw, err := hex.DecodeString(pubKeyHex)
|
||||||
|
if err != nil {
|
||||||
|
return pubKeyHex // fallback: use pubkey as-is
|
||||||
|
}
|
||||||
|
h := sha256.Sum256(raw)
|
||||||
|
return "DC" + hex.EncodeToString(h[:12])
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Public query methods ---
|
||||||
|
|
||||||
|
// TxByID returns a TxRecord by transaction ID.
|
||||||
|
func (c *Chain) TxByID(txID string) (*TxRecord, error) {
|
||||||
|
var rec TxRecord
|
||||||
|
err := c.db.View(func(txn *badger.Txn) error {
|
||||||
|
item, err := txn.Get([]byte(prefixTxRecord + txID))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return item.Value(func(val []byte) error {
|
||||||
|
return json.Unmarshal(val, &rec)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
if errors.Is(err, badger.ErrKeyNotFound) {
|
||||||
|
synth, synthErr := c.syntheticTxByID(txID)
|
||||||
|
if synthErr != nil {
|
||||||
|
return nil, synthErr
|
||||||
|
}
|
||||||
|
if synth != nil {
|
||||||
|
return synth, nil
|
||||||
|
}
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return &rec, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseSyntheticRewardIndex(txID string) (uint64, bool) {
|
||||||
|
if !strings.HasPrefix(txID, syntheticRewardIDPrefix) {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
part := strings.TrimPrefix(txID, syntheticRewardIDPrefix)
|
||||||
|
idx, err := strconv.ParseUint(part, 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
return idx, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Chain) syntheticTxByID(txID string) (*TxRecord, error) {
|
||||||
|
idx, ok := parseSyntheticRewardIndex(txID)
|
||||||
|
if !ok {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
b, err := c.GetBlock(idx)
|
||||||
|
if errors.Is(err, badger.ErrKeyNotFound) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
rewardTarget := b.Validator
|
||||||
|
binding, err := c.WalletBinding(b.Validator)
|
||||||
|
if err == nil && binding != "" {
|
||||||
|
rewardTarget = binding
|
||||||
|
}
|
||||||
|
rewardTx, err := makeBlockRewardTx(b, rewardTarget)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &TxRecord{
|
||||||
|
Tx: rewardTx,
|
||||||
|
BlockIndex: b.Index,
|
||||||
|
BlockHash: b.HashHex(),
|
||||||
|
BlockTime: b.Timestamp,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// TxsByAddress returns up to limit TxRecords for a public key, newest first,
|
||||||
|
// skipping the first offset results (for pagination).
|
||||||
|
func (c *Chain) TxsByAddress(pubKey string, limit, offset int) ([]*TxRecord, error) {
|
||||||
|
if limit <= 0 {
|
||||||
|
limit = 50
|
||||||
|
}
|
||||||
|
if offset < 0 {
|
||||||
|
offset = 0
|
||||||
|
}
|
||||||
|
prefix := prefixTxByAddr + pubKey + ":"
|
||||||
|
|
||||||
|
// First: collect TxID keys for this address (newest first via reverse iter),
|
||||||
|
// skipping `offset` entries.
|
||||||
|
var txIDs []string
|
||||||
|
err := c.db.View(func(txn *badger.Txn) error {
|
||||||
|
opts := badger.DefaultIteratorOptions
|
||||||
|
opts.Reverse = true
|
||||||
|
opts.PrefetchValues = false
|
||||||
|
it := txn.NewIterator(opts)
|
||||||
|
defer it.Close()
|
||||||
|
|
||||||
|
seekKey := prefix + "\xff\xff\xff\xff\xff\xff\xff\xff"
|
||||||
|
skipped := 0
|
||||||
|
for it.Seek([]byte(seekKey)); it.Valid(); it.Next() {
|
||||||
|
key := string(it.Item().Key())
|
||||||
|
if !strings.HasPrefix(key, prefix) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
parts := strings.SplitN(key[len(prefix):], ":", 2)
|
||||||
|
if len(parts) != 2 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if skipped < offset {
|
||||||
|
skipped++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
txIDs = append(txIDs, parts[1])
|
||||||
|
if len(txIDs) >= limit {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Now fetch each TxRecord
|
||||||
|
var records []*TxRecord
|
||||||
|
err = c.db.View(func(txn *badger.Txn) error {
|
||||||
|
for _, txID := range txIDs {
|
||||||
|
item, err := txn.Get([]byte(prefixTxRecord + txID))
|
||||||
|
if errors.Is(err, badger.ErrKeyNotFound) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var rec TxRecord
|
||||||
|
if err := item.Value(func(val []byte) error {
|
||||||
|
return json.Unmarshal(val, &rec)
|
||||||
|
}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
records = append(records, &rec)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
return records, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// RecentTxs returns the N most recent transactions across all blocks.
|
||||||
|
func (c *Chain) RecentTxs(limit int) ([]*TxRecord, error) {
|
||||||
|
if limit <= 0 {
|
||||||
|
limit = 20
|
||||||
|
}
|
||||||
|
// Primary path: iterate the chronological tx index in reverse. This is
|
||||||
|
// O(limit) regardless of how many empty blocks sit between txs.
|
||||||
|
var records []*TxRecord
|
||||||
|
err := c.db.View(func(txn *badger.Txn) error {
|
||||||
|
opts := badger.DefaultIteratorOptions
|
||||||
|
opts.Reverse = true
|
||||||
|
opts.PrefetchValues = true
|
||||||
|
it := txn.NewIterator(opts)
|
||||||
|
defer it.Close()
|
||||||
|
|
||||||
|
// Seek to the highest possible key under this prefix.
|
||||||
|
seekKey := []byte(prefixTxChron + "\xff")
|
||||||
|
for it.Seek(seekKey); it.ValidForPrefix([]byte(prefixTxChron)); it.Next() {
|
||||||
|
if len(records) >= limit {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
var txID string
|
||||||
|
err := it.Item().Value(func(v []byte) error {
|
||||||
|
txID = string(v)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil || txID == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
recItem, err := txn.Get([]byte(prefixTxRecord + txID))
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var rec TxRecord
|
||||||
|
if err := recItem.Value(func(v []byte) error { return json.Unmarshal(v, &rec) }); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
records = append(records, &rec)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err == nil && len(records) >= limit {
|
||||||
|
return records, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback (legacy + reward-tx injection): reverse-scan blocks.
|
||||||
|
// Only blocks committed BEFORE the chronological index existed will be
|
||||||
|
// found this way; we cap the scan so it can't hang.
|
||||||
|
tipIdx := c.TipIndex()
|
||||||
|
const maxBlockScan = 5000
|
||||||
|
|
||||||
|
seen := make(map[string]bool, len(records))
|
||||||
|
for _, r := range records {
|
||||||
|
seen[r.Tx.ID] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
scanned := 0
|
||||||
|
for idx := int64(tipIdx); idx >= 0 && len(records) < limit && scanned < maxBlockScan; idx-- {
|
||||||
|
scanned++
|
||||||
|
b, err := c.GetBlock(uint64(idx))
|
||||||
|
if err != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
for i := len(b.Transactions) - 1; i >= 0 && len(records) < limit; i-- {
|
||||||
|
tx := b.Transactions[i]
|
||||||
|
if seen[tx.ID] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
records = append(records, &TxRecord{
|
||||||
|
Tx: tx,
|
||||||
|
BlockIndex: b.Index,
|
||||||
|
BlockHash: b.HashHex(),
|
||||||
|
BlockTime: b.Timestamp,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
// Include BLOCK_REWARD only for fee-earning blocks and genesis.
|
||||||
|
if len(records) < limit && (b.TotalFees > 0 || b.Index == 0) {
|
||||||
|
rewardTarget := b.Validator
|
||||||
|
if binding, err2 := c.WalletBinding(b.Validator); err2 == nil && binding != "" {
|
||||||
|
rewardTarget = binding
|
||||||
|
}
|
||||||
|
if rewardTx, err2 := makeBlockRewardTx(b, rewardTarget); err2 == nil {
|
||||||
|
records = append(records, &TxRecord{
|
||||||
|
Tx: rewardTx,
|
||||||
|
BlockIndex: b.Index,
|
||||||
|
BlockHash: b.HashHex(),
|
||||||
|
BlockTime: b.Timestamp,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return records, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RecentBlocks returns the N most recent blocks (tip first).
|
||||||
|
func (c *Chain) RecentBlocks(limit int) ([]*Block, error) {
|
||||||
|
if limit <= 0 {
|
||||||
|
limit = 10
|
||||||
|
}
|
||||||
|
// Lock-free tip lookup so this endpoint never blocks on consensus work.
|
||||||
|
tipIdx := c.TipIndex()
|
||||||
|
var blocks []*Block
|
||||||
|
for idx := int64(tipIdx); idx >= 0 && len(blocks) < limit; idx-- {
|
||||||
|
b, err := c.GetBlock(uint64(idx))
|
||||||
|
if err != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
blocks = append(blocks, b)
|
||||||
|
}
|
||||||
|
return blocks, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// NetworkStats returns aggregate counters for the chain.
|
||||||
|
// ValidatorCount and RelayCount are always live-counted from the DB so they
|
||||||
|
// are accurate even after InitValidators replaced the set or relays registered.
|
||||||
|
func (c *Chain) NetworkStats() (NetStats, error) {
|
||||||
|
var stats NetStats
|
||||||
|
err := c.db.View(func(txn *badger.Txn) error {
|
||||||
|
s, err := c.readNetStats(txn)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
stats = s
|
||||||
|
|
||||||
|
opts := badger.DefaultIteratorOptions
|
||||||
|
opts.PrefetchValues = false
|
||||||
|
it := txn.NewIterator(opts)
|
||||||
|
defer it.Close()
|
||||||
|
|
||||||
|
vPrefix := []byte(prefixValidator)
|
||||||
|
for it.Seek(vPrefix); it.ValidForPrefix(vPrefix); it.Next() {
|
||||||
|
stats.ValidatorCount++
|
||||||
|
}
|
||||||
|
rPrefix := []byte(prefixRelay)
|
||||||
|
for it.Seek(rPrefix); it.ValidForPrefix(rPrefix); it.Next() {
|
||||||
|
stats.RelayCount++
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
return stats, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddressToPubKey resolves a DC address to a pub key.
|
||||||
|
// Returns "" if not found.
|
||||||
|
func (c *Chain) AddressToPubKey(addr string) (string, error) {
|
||||||
|
var pubKey string
|
||||||
|
err := c.db.View(func(txn *badger.Txn) error {
|
||||||
|
item, err := txn.Get([]byte(prefixAddrMap + addr))
|
||||||
|
if errors.Is(err, badger.ErrKeyNotFound) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return item.Value(func(val []byte) error {
|
||||||
|
pubKey = string(val)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
})
|
||||||
|
return pubKey, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- internal ---
|
||||||
|
|
||||||
|
func (c *Chain) readNetStats(txn *badger.Txn) (NetStats, error) {
|
||||||
|
var s NetStats
|
||||||
|
item, err := txn.Get([]byte(prefixNetStats))
|
||||||
|
if errors.Is(err, badger.ErrKeyNotFound) {
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return s, err
|
||||||
|
}
|
||||||
|
err = item.Value(func(val []byte) error {
|
||||||
|
return json.Unmarshal(val, &s)
|
||||||
|
})
|
||||||
|
return s, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Chain) writeNetStats(txn *badger.Txn, s NetStats) error {
|
||||||
|
val, err := json.Marshal(s)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return txn.Set([]byte(prefixNetStats), val)
|
||||||
|
}
|
||||||
238
blockchain/native.go
Normal file
238
blockchain/native.go
Normal file
@@ -0,0 +1,238 @@
|
|||||||
|
// Package blockchain — native (non-WASM) contract infrastructure.
|
||||||
|
//
|
||||||
|
// System contracts like `username_registry` are latency-sensitive and must
|
||||||
|
// never hang the chain. Running them through the WASM VM means:
|
||||||
|
//
|
||||||
|
// - 100× the CPU cost of equivalent Go code;
|
||||||
|
// - a bug in gas metering / opcode instrumentation can freeze AddBlock
|
||||||
|
// indefinitely (see the hangs that motivated this rewrite);
|
||||||
|
// - every node needs an identical wazero build — extra supply-chain risk.
|
||||||
|
//
|
||||||
|
// Native contracts are written as plain Go code against a narrow interface
|
||||||
|
// (NativeContract below). They share the same contract_id space, ABI, and
|
||||||
|
// explorer views as WASM contracts, so clients can't tell them apart — the
|
||||||
|
// dispatcher in applyTx just routes the call to Go instead of wazero when
|
||||||
|
// it sees a native contract_id.
|
||||||
|
//
|
||||||
|
// Authorship notes:
|
||||||
|
// - A native contract has full, direct access to the current BadgerDB txn
|
||||||
|
// and chain helpers via NativeContext. It MUST only read/write keys
|
||||||
|
// prefixed with `cstate:<contractID>:` or `clog:<contractID>:…` — same
|
||||||
|
// as WASM contracts see. This keeps on-chain state cleanly segregated
|
||||||
|
// so one day we can migrate a native contract back to WASM (or vice
|
||||||
|
// versa) without a storage migration.
|
||||||
|
// - A native contract MUST return deterministic errors. The dispatcher
|
||||||
|
// treats any returned error as `ErrTxFailed`-wrapped — fees stay
|
||||||
|
// debited, but state changes roll back with the enclosing Badger txn.
|
||||||
|
package blockchain
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
badger "github.com/dgraph-io/badger/v4"
|
||||||
|
)
|
||||||
|
|
||||||
|
// NativeContract is the Go-side counterpart of a WASM smart contract.
|
||||||
|
//
|
||||||
|
// Implementors are expected to be stateless (all state lives in BadgerDB
|
||||||
|
// under cstate:<ContractID>:…). An instance is created once per chain and
|
||||||
|
// reused across all calls.
|
||||||
|
type NativeContract interface {
|
||||||
|
// ID returns the deterministic contract ID used in CALL_CONTRACT txs.
|
||||||
|
// Must be stable across node restarts and identical on every node.
|
||||||
|
ID() string
|
||||||
|
|
||||||
|
// ABI returns a JSON document describing the contract's methods.
|
||||||
|
// Identical shape to the WASM contracts' *_abi.json files so the
|
||||||
|
// well-known endpoint and explorer can discover it uniformly.
|
||||||
|
ABI() string
|
||||||
|
|
||||||
|
// Call dispatches a method invocation. Returns the gas it wants to
|
||||||
|
// charge (will be multiplied by the current gas price). Returning an
|
||||||
|
// error aborts the tx; returning (0, nil) means free success.
|
||||||
|
Call(ctx *NativeContext, method string, argsJSON []byte) (gasUsed uint64, err error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// NativeContext hands a native contract the minimum it needs to run,
|
||||||
|
// without exposing the full Chain type (which would tempt contracts to
|
||||||
|
// touch state they shouldn't).
|
||||||
|
type NativeContext struct {
|
||||||
|
Txn *badger.Txn
|
||||||
|
ContractID string
|
||||||
|
Caller string // hex Ed25519 pubkey of the tx sender
|
||||||
|
TxID string
|
||||||
|
BlockHeight uint64
|
||||||
|
|
||||||
|
// TxAmount is tx.Amount — the payment the caller attached to this call
|
||||||
|
// in µT. It is NOT auto-debited from the caller; the contract decides
|
||||||
|
// whether to collect it (via ctx.Debit), refund, or ignore. Exposing
|
||||||
|
// payment via tx.Amount (instead of an implicit debit inside the
|
||||||
|
// contract) makes contract costs visible in the explorer — a user can
|
||||||
|
// see exactly what a call charges by reading the tx envelope.
|
||||||
|
TxAmount uint64
|
||||||
|
|
||||||
|
// chain is kept unexported; contract code uses the helper methods below
|
||||||
|
// rather than reaching into Chain directly.
|
||||||
|
chain *Chain
|
||||||
|
}
|
||||||
|
|
||||||
|
// Balance returns the balance of the given pubkey in µT.
|
||||||
|
func (ctx *NativeContext) Balance(pubHex string) uint64 {
|
||||||
|
var bal uint64
|
||||||
|
item, err := ctx.Txn.Get([]byte(prefixBalance + pubHex))
|
||||||
|
if errors.Is(err, badger.ErrKeyNotFound) {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
_ = item.Value(func(val []byte) error {
|
||||||
|
return unmarshalUint64(val, &bal)
|
||||||
|
})
|
||||||
|
return bal
|
||||||
|
}
|
||||||
|
|
||||||
|
// Debit removes amt µT from pub's balance, or returns an error if insufficient.
|
||||||
|
func (ctx *NativeContext) Debit(pub string, amt uint64) error {
|
||||||
|
return ctx.chain.debitBalance(ctx.Txn, pub, amt)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Credit adds amt µT to pub's balance.
|
||||||
|
func (ctx *NativeContext) Credit(pub string, amt uint64) error {
|
||||||
|
return ctx.chain.creditBalance(ctx.Txn, pub, amt)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get reads a contract-scoped state value. Returns nil if not set.
|
||||||
|
func (ctx *NativeContext) Get(key string) ([]byte, error) {
|
||||||
|
item, err := ctx.Txn.Get([]byte(prefixContractState + ctx.ContractID + ":" + key))
|
||||||
|
if errors.Is(err, badger.ErrKeyNotFound) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var out []byte
|
||||||
|
err = item.Value(func(v []byte) error {
|
||||||
|
out = append([]byte(nil), v...)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set writes a contract-scoped state value.
|
||||||
|
func (ctx *NativeContext) Set(key string, value []byte) error {
|
||||||
|
return ctx.Txn.Set([]byte(prefixContractState+ctx.ContractID+":"+key), value)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete removes a contract-scoped state value.
|
||||||
|
func (ctx *NativeContext) Delete(key string) error {
|
||||||
|
return ctx.Txn.Delete([]byte(prefixContractState + ctx.ContractID + ":" + key))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log emits a contract log line for the explorer. Uses the same storage as
|
||||||
|
// WASM contracts' env.log() so the explorer renders them identically.
|
||||||
|
func (ctx *NativeContext) Log(msg string) error {
|
||||||
|
return ctx.chain.writeContractLog(ctx.Txn, ctx.ContractID, ctx.BlockHeight, ctx.TxID, msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Native contract registry ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// Native contracts are registered into the chain once during chain setup
|
||||||
|
// (typically right after `NewChain`). Lookups happen on every CALL_CONTRACT
|
||||||
|
// and DEPLOY_CONTRACT — they're hot path, so the registry is a plain map
|
||||||
|
// guarded by a RW mutex.
|
||||||
|
|
||||||
|
// RegisterNative associates a NativeContract with its ID on this chain.
|
||||||
|
// Panics if two contracts share an ID (clear programmer error).
|
||||||
|
// Must be called before AddBlock begins processing user transactions.
|
||||||
|
//
|
||||||
|
// Uses a DEDICATED mutex (c.nativeMu) rather than c.mu, because
|
||||||
|
// lookupNative is called from inside applyTx which runs under c.mu.Lock().
|
||||||
|
// sync.RWMutex is non-reentrant — reusing c.mu would deadlock.
|
||||||
|
func (c *Chain) RegisterNative(nc NativeContract) {
|
||||||
|
c.nativeMu.Lock()
|
||||||
|
defer c.nativeMu.Unlock()
|
||||||
|
if c.native == nil {
|
||||||
|
c.native = make(map[string]NativeContract)
|
||||||
|
}
|
||||||
|
if _, exists := c.native[nc.ID()]; exists {
|
||||||
|
panic(fmt.Sprintf("native contract %s registered twice", nc.ID()))
|
||||||
|
}
|
||||||
|
c.native[nc.ID()] = nc
|
||||||
|
}
|
||||||
|
|
||||||
|
// lookupNative returns the registered native contract for id, or nil.
|
||||||
|
// Hot path — called on every CALL_CONTRACT from applyTx. Safe to call while
|
||||||
|
// c.mu is held because we use a separate RWMutex here.
|
||||||
|
func (c *Chain) lookupNative(id string) NativeContract {
|
||||||
|
c.nativeMu.RLock()
|
||||||
|
defer c.nativeMu.RUnlock()
|
||||||
|
return c.native[id]
|
||||||
|
}
|
||||||
|
|
||||||
|
// NativeContracts returns a snapshot of every native contract registered on
|
||||||
|
// this chain. Used by the well-known endpoint so clients auto-discover
|
||||||
|
// system services without the user having to paste contract IDs.
|
||||||
|
func (c *Chain) NativeContracts() []NativeContract {
|
||||||
|
c.nativeMu.RLock()
|
||||||
|
defer c.nativeMu.RUnlock()
|
||||||
|
out := make([]NativeContract, 0, len(c.native))
|
||||||
|
for _, nc := range c.native {
|
||||||
|
out = append(out, nc)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeContractLog is the shared log emitter for both WASM and native
|
||||||
|
// contracts. Keeping it here (on Chain) means we can change the log key
|
||||||
|
// layout in one place.
|
||||||
|
func (c *Chain) writeContractLog(txn *badger.Txn, contractID string, blockHeight uint64, txID, msg string) error {
|
||||||
|
// Best-effort: match the existing WASM log format so the explorer's
|
||||||
|
// renderer doesn't need to branch.
|
||||||
|
seq := c.nextContractLogSeq(txn, contractID, blockHeight)
|
||||||
|
entry := ContractLogEntry{
|
||||||
|
ContractID: contractID,
|
||||||
|
BlockHeight: blockHeight,
|
||||||
|
TxID: txID,
|
||||||
|
Seq: int(seq),
|
||||||
|
Message: msg,
|
||||||
|
}
|
||||||
|
val, err := json.Marshal(entry)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
key := fmt.Sprintf("%s%s:%020d:%05d", prefixContractLog, contractID, blockHeight, seq)
|
||||||
|
return txn.Set([]byte(key), val)
|
||||||
|
}
|
||||||
|
|
||||||
|
// nextContractLogSeq returns the next sequence number for a (contract,block)
|
||||||
|
// pair by counting existing entries under the prefix.
|
||||||
|
func (c *Chain) nextContractLogSeq(txn *badger.Txn, contractID string, blockHeight uint64) uint32 {
|
||||||
|
prefix := []byte(fmt.Sprintf("%s%s:%020d:", prefixContractLog, contractID, blockHeight))
|
||||||
|
opts := badger.DefaultIteratorOptions
|
||||||
|
opts.PrefetchValues = false
|
||||||
|
opts.Prefix = prefix
|
||||||
|
it := txn.NewIterator(opts)
|
||||||
|
defer it.Close()
|
||||||
|
var count uint32
|
||||||
|
for it.Rewind(); it.Valid(); it.Next() {
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
return count
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Small helpers used by native contracts ──────────────────────────────────
|
||||||
|
|
||||||
|
// Uint64 is a tiny helper for reading a uint64 stored as 8 big-endian bytes.
|
||||||
|
// (We deliberately don't use JSON for hot state keys.)
|
||||||
|
func unmarshalUint64(b []byte, dst *uint64) error {
|
||||||
|
if len(b) != 8 {
|
||||||
|
return fmt.Errorf("not a uint64")
|
||||||
|
}
|
||||||
|
*dst = uint64(b[0])<<56 | uint64(b[1])<<48 | uint64(b[2])<<40 | uint64(b[3])<<32 |
|
||||||
|
uint64(b[4])<<24 | uint64(b[5])<<16 | uint64(b[6])<<8 | uint64(b[7])
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
371
blockchain/native_username.go
Normal file
371
blockchain/native_username.go
Normal file
@@ -0,0 +1,371 @@
|
|||||||
|
// Package blockchain — native username registry.
|
||||||
|
//
|
||||||
|
// Deterministic, in-process replacement for the WASM username_registry
|
||||||
|
// contract. Every node runs exactly the same Go code against the same
|
||||||
|
// BadgerDB txn, so state transitions are byte-identical across the network.
|
||||||
|
//
|
||||||
|
// Why native instead of WASM:
|
||||||
|
// - A single register() call via wazero takes ~10 ms; native takes ~50 µs.
|
||||||
|
// - No gas-metering edge cases (an opcode loop the listener misses would
|
||||||
|
// otherwise wedge AddBlock — which is how we wound up here).
|
||||||
|
// - We own the API surface — upgrades don't require re-deploying WASM
|
||||||
|
// and renegotiating the well-known contract_id.
|
||||||
|
//
|
||||||
|
// State layout (all keys prefixed with cstate:<ID>: by NativeContext helpers):
|
||||||
|
//
|
||||||
|
// name:<name> → owner pubkey (raw hex bytes, 64 chars)
|
||||||
|
// addr:<owner_pub> → name (raw UTF-8 bytes)
|
||||||
|
// meta:version → ABI version string (debug only)
|
||||||
|
//
|
||||||
|
// Methods:
|
||||||
|
//
|
||||||
|
// register(name) — claim a name; caller becomes owner
|
||||||
|
// resolve(name) — read-only, returns owner via log
|
||||||
|
// lookup(pub) — read-only, returns name via log
|
||||||
|
// transfer(name, new_owner_pub) — current owner transfers
|
||||||
|
// release(name) — current owner releases
|
||||||
|
//
|
||||||
|
// The same ABI JSON the WASM build exposes is reported here so the
|
||||||
|
// well-known endpoint + explorer work without modification.
|
||||||
|
package blockchain
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// UsernameRegistryID is the deterministic on-chain ID for the native
|
||||||
|
// username registry. We pin it to a readable short string instead of a
|
||||||
|
// hash because there is only ever one registry per chain, and a stable
|
||||||
|
// well-known ID makes debug URLs easier (/api/contracts/username_registry).
|
||||||
|
const UsernameRegistryID = "native:username_registry"
|
||||||
|
|
||||||
|
// MinUsernameLength caps how short a name can be. Shorter names would be
|
||||||
|
// cheaper to register and quicker to grab, incentivising squatters. 4 is
|
||||||
|
// the sweet spot: long enough to avoid 2-char grabs, short enough to allow
|
||||||
|
// "alice" / "bob1" / common initials.
|
||||||
|
const MinUsernameLength = 4
|
||||||
|
|
||||||
|
// MaxUsernameLength is the upper bound. Anything longer is wasteful.
|
||||||
|
const MaxUsernameLength = 32
|
||||||
|
|
||||||
|
// UsernameRegistrationFee is a flat fee per register() call, in µT. Paid
|
||||||
|
// by the caller and burned (reduces total supply) — simpler than routing
|
||||||
|
// to a treasury account and avoids the "contract treasury" concept for
|
||||||
|
// the first native contract.
|
||||||
|
//
|
||||||
|
// 10_000 µT (0.01 T) is low enough for genuine users and high enough
|
||||||
|
// that a griefer can't squat thousands of names for nothing.
|
||||||
|
const UsernameRegistrationFee = 10_000
|
||||||
|
|
||||||
|
// usernameABI is returned by ABI(). Fields mirror the WASM registry's ABI
|
||||||
|
// JSON so the well-known endpoint / explorer discover it the same way.
|
||||||
|
const usernameABI = `{
|
||||||
|
"contract": "username_registry",
|
||||||
|
"version": "2.1.0-native",
|
||||||
|
"description": "Maps human-readable usernames (min 4 chars, lowercase a-z 0-9 _ -, must start with a letter) to wallet addresses. register requires tx.amount = 10 000 µT which is burned.",
|
||||||
|
"methods": [
|
||||||
|
{"name":"register","description":"Claim a username. Send tx.amount=10000 as the registration fee (burned). Caller becomes owner.","args":[{"name":"name","type":"string"}],"payable":10000},
|
||||||
|
{"name":"resolve","description":"Look up owner address by name. Free (tx.amount=0).","args":[{"name":"name","type":"string"}]},
|
||||||
|
{"name":"lookup","description":"Look up name by owner address. Free.","args":[{"name":"address","type":"string"}]},
|
||||||
|
{"name":"transfer","description":"Transfer ownership to a new address. Free; only current owner may call.","args":[{"name":"name","type":"string"},{"name":"new_owner","type":"string"}]},
|
||||||
|
{"name":"release","description":"Release a registered name. Free; only current owner may call.","args":[{"name":"name","type":"string"}]}
|
||||||
|
]
|
||||||
|
}`
|
||||||
|
|
||||||
|
// UsernameRegistry is the native implementation of the registry contract.
|
||||||
|
// Stateless — all state lives in the chain's BadgerDB txn passed via
|
||||||
|
// NativeContext on each call.
|
||||||
|
type UsernameRegistry struct{}
|
||||||
|
|
||||||
|
// NewUsernameRegistry returns a contract ready to register with the chain.
|
||||||
|
func NewUsernameRegistry() *UsernameRegistry { return &UsernameRegistry{} }
|
||||||
|
|
||||||
|
// Compile-time check that we satisfy the interface.
|
||||||
|
var _ NativeContract = (*UsernameRegistry)(nil)
|
||||||
|
|
||||||
|
// ID implements NativeContract.
|
||||||
|
func (UsernameRegistry) ID() string { return UsernameRegistryID }
|
||||||
|
|
||||||
|
// ABI implements NativeContract.
|
||||||
|
func (UsernameRegistry) ABI() string { return usernameABI }
|
||||||
|
|
||||||
|
// Call implements NativeContract — dispatches to the per-method handlers.
|
||||||
|
// Gas cost is a flat 1_000 units per call (native is cheap, but we charge
|
||||||
|
// something so the fee mechanics match the WASM path).
|
||||||
|
func (r UsernameRegistry) Call(ctx *NativeContext, method string, argsJSON []byte) (uint64, error) {
|
||||||
|
const gasCost uint64 = 1_000
|
||||||
|
|
||||||
|
args, err := parseArgs(argsJSON)
|
||||||
|
if err != nil {
|
||||||
|
return gasCost, fmt.Errorf("%w: bad args: %v", ErrTxFailed, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
switch method {
|
||||||
|
case "register":
|
||||||
|
return gasCost, r.register(ctx, args)
|
||||||
|
case "resolve":
|
||||||
|
return gasCost, r.resolve(ctx, args)
|
||||||
|
case "lookup":
|
||||||
|
return gasCost, r.lookup(ctx, args)
|
||||||
|
case "transfer":
|
||||||
|
return gasCost, r.transfer(ctx, args)
|
||||||
|
case "release":
|
||||||
|
return gasCost, r.release(ctx, args)
|
||||||
|
default:
|
||||||
|
return gasCost, fmt.Errorf("%w: unknown method %q", ErrTxFailed, method)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Method handlers ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// register claims a name for ctx.Caller. Preconditions:
|
||||||
|
// - name validates (length, charset, not reserved)
|
||||||
|
// - name is not already taken
|
||||||
|
// - caller has no existing registration (one-per-address rule)
|
||||||
|
// - tx.Amount (ctx.TxAmount) must be exactly UsernameRegistrationFee;
|
||||||
|
// that payment is debited from the caller and burned
|
||||||
|
//
|
||||||
|
// Pay-via-tx.Amount (instead of an invisible debit inside the contract)
|
||||||
|
// makes the cost explicit: the registration fee shows up as `amount_ut`
|
||||||
|
// in the transaction envelope and in the explorer, so callers know
|
||||||
|
// exactly what they paid. See the module-level doc for the full rationale.
|
||||||
|
//
|
||||||
|
// On success:
|
||||||
|
// - debit ctx.TxAmount from caller (burn — no recipient)
|
||||||
|
// - write name → caller pubkey mapping (key "name:<name>")
|
||||||
|
// - write caller → name mapping (key "addr:<caller>")
|
||||||
|
// - emit `registered: <name>` log
|
||||||
|
func (UsernameRegistry) register(ctx *NativeContext, args []json.RawMessage) error {
|
||||||
|
name, err := argString(args, 0, "name")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := validateName(name); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Payment check — must be EXACTLY the registration fee. Under-payment
|
||||||
|
// is rejected (obvious); over-payment is also rejected to avoid
|
||||||
|
// accidental overpayment from a buggy client, and to keep the fee
|
||||||
|
// structure simple. A future `transfer` method may introduce other
|
||||||
|
// pricing.
|
||||||
|
if ctx.TxAmount != UsernameRegistrationFee {
|
||||||
|
return fmt.Errorf("%w: register requires tx.amount = %d µT (got %d µT)",
|
||||||
|
ErrTxFailed, UsernameRegistrationFee, ctx.TxAmount)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Already taken?
|
||||||
|
existing, err := ctx.Get("name:" + name)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if existing != nil {
|
||||||
|
return fmt.Errorf("%w: name %q already registered", ErrTxFailed, name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Caller already has a name?
|
||||||
|
ownerKey := "addr:" + ctx.Caller
|
||||||
|
prior, err := ctx.Get(ownerKey)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if prior != nil {
|
||||||
|
return fmt.Errorf("%w: address already owns %q; release it first", ErrTxFailed, string(prior))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect the registration fee (burn — no recipient).
|
||||||
|
if err := ctx.Debit(ctx.Caller, ctx.TxAmount); err != nil {
|
||||||
|
return fmt.Errorf("payment debit: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Persist both directions.
|
||||||
|
if err := ctx.Set("name:"+name, []byte(ctx.Caller)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := ctx.Set(ownerKey, []byte(name)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return ctx.Log("registered: " + name + " → " + ctx.Caller)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (UsernameRegistry) resolve(ctx *NativeContext, args []json.RawMessage) error {
|
||||||
|
name, err := argString(args, 0, "name")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
val, err := ctx.Get("name:" + name)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if val == nil {
|
||||||
|
return ctx.Log("not found: " + name)
|
||||||
|
}
|
||||||
|
return ctx.Log("owner: " + string(val))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (UsernameRegistry) lookup(ctx *NativeContext, args []json.RawMessage) error {
|
||||||
|
addr, err := argString(args, 0, "address")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
val, err := ctx.Get("addr:" + addr)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if val == nil {
|
||||||
|
return ctx.Log("no name: " + addr)
|
||||||
|
}
|
||||||
|
return ctx.Log("name: " + string(val))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (UsernameRegistry) transfer(ctx *NativeContext, args []json.RawMessage) error {
|
||||||
|
name, err := argString(args, 0, "name")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
newOwner, err := argString(args, 1, "new_owner")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := validatePubKey(newOwner); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
cur, err := ctx.Get("name:" + name)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if cur == nil {
|
||||||
|
return fmt.Errorf("%w: name %q not registered", ErrTxFailed, name)
|
||||||
|
}
|
||||||
|
if string(cur) != ctx.Caller {
|
||||||
|
return fmt.Errorf("%w: only current owner can transfer", ErrTxFailed)
|
||||||
|
}
|
||||||
|
// New owner must not already have a name.
|
||||||
|
if existing, err := ctx.Get("addr:" + newOwner); err != nil {
|
||||||
|
return err
|
||||||
|
} else if existing != nil {
|
||||||
|
return fmt.Errorf("%w: new owner already owns %q", ErrTxFailed, string(existing))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update both directions.
|
||||||
|
if err := ctx.Set("name:"+name, []byte(newOwner)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := ctx.Delete("addr:" + ctx.Caller); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := ctx.Set("addr:"+newOwner, []byte(name)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return ctx.Log("transferred: " + name + " → " + newOwner)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (UsernameRegistry) release(ctx *NativeContext, args []json.RawMessage) error {
|
||||||
|
name, err := argString(args, 0, "name")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
cur, err := ctx.Get("name:" + name)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if cur == nil {
|
||||||
|
return fmt.Errorf("%w: name %q not registered", ErrTxFailed, name)
|
||||||
|
}
|
||||||
|
if string(cur) != ctx.Caller {
|
||||||
|
return fmt.Errorf("%w: only current owner can release", ErrTxFailed)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := ctx.Delete("name:" + name); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := ctx.Delete("addr:" + ctx.Caller); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return ctx.Log("released: " + name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Validation helpers ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// validateName enforces our naming rules. Policies that appear here must
|
||||||
|
// match the client-side preview in settings.tsx: lowercase alphanumeric
|
||||||
|
// plus underscore/hyphen, length 4-32, cannot start with a digit or hyphen.
|
||||||
|
func validateName(name string) error {
|
||||||
|
if len(name) < MinUsernameLength {
|
||||||
|
return fmt.Errorf("%w: name too short: min %d chars", ErrTxFailed, MinUsernameLength)
|
||||||
|
}
|
||||||
|
if len(name) > MaxUsernameLength {
|
||||||
|
return fmt.Errorf("%w: name too long: max %d chars", ErrTxFailed, MaxUsernameLength)
|
||||||
|
}
|
||||||
|
// First char must be a-z (avoid leading digits, hyphens, underscores).
|
||||||
|
first := name[0]
|
||||||
|
if !(first >= 'a' && first <= 'z') {
|
||||||
|
return fmt.Errorf("%w: name must start with a letter a-z", ErrTxFailed)
|
||||||
|
}
|
||||||
|
for i := 0; i < len(name); i++ {
|
||||||
|
c := name[i]
|
||||||
|
switch {
|
||||||
|
case c >= 'a' && c <= 'z':
|
||||||
|
case c >= '0' && c <= '9':
|
||||||
|
case c == '_' || c == '-':
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("%w: invalid character %q (lowercase letters, digits, _ and - only)", ErrTxFailed, c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Reserved names — clients that show system labels shouldn't be spoofable.
|
||||||
|
reserved := []string{"system", "admin", "root", "dchain", "null", "none"}
|
||||||
|
for _, r := range reserved {
|
||||||
|
if name == r {
|
||||||
|
return fmt.Errorf("%w: %q is reserved", ErrTxFailed, name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// validatePubKey accepts a 64-char lowercase hex string (Ed25519 pubkey).
|
||||||
|
func validatePubKey(s string) error {
|
||||||
|
if len(s) != 64 {
|
||||||
|
return fmt.Errorf("%w: pubkey must be 64 hex chars", ErrTxFailed)
|
||||||
|
}
|
||||||
|
for i := 0; i < len(s); i++ {
|
||||||
|
c := s[i]
|
||||||
|
switch {
|
||||||
|
case c >= '0' && c <= '9':
|
||||||
|
case c >= 'a' && c <= 'f':
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("%w: pubkey has non-hex character", ErrTxFailed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseArgs turns the CallContractPayload.ArgsJSON string into a slice of
|
||||||
|
// raw JSON messages. Empty/whitespace-only input parses to an empty slice.
|
||||||
|
func parseArgs(argsJSON []byte) ([]json.RawMessage, error) {
|
||||||
|
if len(argsJSON) == 0 || strings.TrimSpace(string(argsJSON)) == "" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
var out []json.RawMessage
|
||||||
|
if err := json.Unmarshal(argsJSON, &out); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// argString reads args[idx] as a JSON string and returns its value.
|
||||||
|
func argString(args []json.RawMessage, idx int, name string) (string, error) {
|
||||||
|
if idx >= len(args) {
|
||||||
|
return "", fmt.Errorf("%w: missing argument %q (index %d)", ErrTxFailed, name, idx)
|
||||||
|
}
|
||||||
|
var s string
|
||||||
|
if err := json.Unmarshal(args[idx], &s); err != nil {
|
||||||
|
return "", fmt.Errorf("%w: argument %q must be a string", ErrTxFailed, name)
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(s), nil
|
||||||
|
}
|
||||||
197
blockchain/schema_migrations.go
Normal file
197
blockchain/schema_migrations.go
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
// Package blockchain — BadgerDB schema version tracking + migration scaffold.
|
||||||
|
//
|
||||||
|
// Why this exists
|
||||||
|
// ───────────────
|
||||||
|
// The chain's on-disk layout is a flat KV store with string-prefixed keys
|
||||||
|
// (see chain.go: prefixBalance, prefixChannel, etc.). Every breaking change
|
||||||
|
// to those prefixes or value shapes would otherwise require operators to
|
||||||
|
// wipe their volume and re-sync from scratch. That's painful at 10 nodes;
|
||||||
|
// catastrophic at 1000.
|
||||||
|
//
|
||||||
|
// This file introduces a single meta-key — `schema:ver` → uint32 — that
|
||||||
|
// records the layout version the data was written in. On every chain open:
|
||||||
|
//
|
||||||
|
// 1. We read the current version (0 if missing = fresh DB or pre-migration).
|
||||||
|
// 2. We iterate forward, running each migration[k→k+1] in order, bumping
|
||||||
|
// the stored version after each successful step.
|
||||||
|
// 3. If CurrentSchemaVersion is already reached, zero migrations run, the
|
||||||
|
// call is ~1 µs (single KV read).
|
||||||
|
//
|
||||||
|
// Design principles
|
||||||
|
// ────────────────
|
||||||
|
// • Idempotent: a crashed migration can be re-run from scratch. Every
|
||||||
|
// migration either completes its write AND updates the version in the
|
||||||
|
// SAME transaction, or neither.
|
||||||
|
// • Forward-only: downgrade is not supported. If an operator needs to
|
||||||
|
// roll back the binary, they restore from a pre-upgrade backup. The
|
||||||
|
// `update.sh` operator script checkpoints before restart for this.
|
||||||
|
// • Tiny: the migration registry is a plain Go slice, not a framework.
|
||||||
|
// Each migration is ~20 lines. Adding one is purely additive.
|
||||||
|
//
|
||||||
|
// As of this commit there are ZERO migrations (CurrentSchemaVersion = 0).
|
||||||
|
// The scaffolding ships empty so the very first real migration — whenever
|
||||||
|
// it lands — has a home that all deployed nodes already understand.
|
||||||
|
package blockchain
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/binary"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
|
||||||
|
badger "github.com/dgraph-io/badger/v4"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// schemaMetaKey is the single BadgerDB key that stores this DB's current
|
||||||
|
// schema version. Not prefixed like other keys — it's a bootstrap marker
|
||||||
|
// read before any prefixed query, so conflicts with userland prefixes
|
||||||
|
// are impossible by construction.
|
||||||
|
schemaMetaKey = "schema:ver"
|
||||||
|
|
||||||
|
// CurrentSchemaVersion is the layout this binary writes. Bumped in lockstep
|
||||||
|
// with every migration added below. A fresh DB is written at this version
|
||||||
|
// directly (no migration chain to run).
|
||||||
|
CurrentSchemaVersion uint32 = 0
|
||||||
|
)
|
||||||
|
|
||||||
|
// migration represents a single step from version v to v+1.
|
||||||
|
// Apply runs inside a single badger.Update — if it returns error, nothing
|
||||||
|
// is written, and the migration can be safely retried.
|
||||||
|
type migration struct {
|
||||||
|
From uint32
|
||||||
|
To uint32
|
||||||
|
Description string
|
||||||
|
Apply func(txn *badger.Txn) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// migrations is the ordered forward-migration registry.
|
||||||
|
//
|
||||||
|
// To add a migration:
|
||||||
|
//
|
||||||
|
// 1. Bump CurrentSchemaVersion above.
|
||||||
|
// 2. Append an entry here with From = previous, To = new.
|
||||||
|
// 3. In Apply, walk the relevant prefixes and rewrite keys/values.
|
||||||
|
// 4. Add a unit test in schema_migrations_test.go seeding a vN-1 DB
|
||||||
|
// and asserting the vN invariants after one NewChain open.
|
||||||
|
//
|
||||||
|
// The slice is intentionally empty right now: the scaffold ships first,
|
||||||
|
// migrations land per-feature as needed.
|
||||||
|
var migrations = []migration{
|
||||||
|
// no migrations yet
|
||||||
|
}
|
||||||
|
|
||||||
|
// readSchemaVersion returns the version stored at schemaMetaKey, or 0 if the
|
||||||
|
// key is absent (interpretation: "pre-migration DB / fresh DB treat as v0").
|
||||||
|
func readSchemaVersion(db *badger.DB) (uint32, error) {
|
||||||
|
var v uint32
|
||||||
|
err := db.View(func(txn *badger.Txn) error {
|
||||||
|
item, err := txn.Get([]byte(schemaMetaKey))
|
||||||
|
if err == badger.ErrKeyNotFound {
|
||||||
|
v = 0
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return item.Value(func(val []byte) error {
|
||||||
|
if len(val) != 4 {
|
||||||
|
return fmt.Errorf("schema version has unexpected length %d (want 4)", len(val))
|
||||||
|
}
|
||||||
|
v = binary.BigEndian.Uint32(val)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
})
|
||||||
|
return v, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeSchemaVersion persists the given version under schemaMetaKey. Usually
|
||||||
|
// called inside the same txn that applied the corresponding migration, so
|
||||||
|
// version bump + data rewrite are atomic. runMigrations handles that.
|
||||||
|
func writeSchemaVersion(txn *badger.Txn, v uint32) error {
|
||||||
|
var buf [4]byte
|
||||||
|
binary.BigEndian.PutUint32(buf[:], v)
|
||||||
|
return txn.Set([]byte(schemaMetaKey), buf[:])
|
||||||
|
}
|
||||||
|
|
||||||
|
// runMigrations applies every registered migration forward from the stored
|
||||||
|
// version to CurrentSchemaVersion. Called by NewChain after badger.Open.
|
||||||
|
//
|
||||||
|
// Behavior:
|
||||||
|
// - stored == target → no-op, returns nil
|
||||||
|
// - stored < target → runs each migration[k→k+1] in sequence; if ANY
|
||||||
|
// returns error, the DB is left at the last successful version and the
|
||||||
|
// error is returned (no partial-migration corruption).
|
||||||
|
// - stored > target → FATAL: operator is running an older binary on a
|
||||||
|
// newer DB. Refuse to open rather than silently mis-interpret data.
|
||||||
|
func runMigrations(db *badger.DB) error {
|
||||||
|
cur, err := readSchemaVersion(db)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("read schema version: %w", err)
|
||||||
|
}
|
||||||
|
if cur == CurrentSchemaVersion {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if cur > CurrentSchemaVersion {
|
||||||
|
return fmt.Errorf(
|
||||||
|
"chain DB is at schema v%d but this binary only understands v%d — "+
|
||||||
|
"run a newer binary OR restore from a pre-upgrade backup",
|
||||||
|
cur, CurrentSchemaVersion)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("[CHAIN] migrating schema v%d → v%d (%d steps)",
|
||||||
|
cur, CurrentSchemaVersion, CurrentSchemaVersion-cur)
|
||||||
|
|
||||||
|
for _, m := range migrations {
|
||||||
|
if m.From < cur {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if m.From != cur {
|
||||||
|
return fmt.Errorf("migration gap: stored=v%d, next migration expects v%d",
|
||||||
|
cur, m.From)
|
||||||
|
}
|
||||||
|
if m.To != m.From+1 {
|
||||||
|
return fmt.Errorf("migration %d→%d is not a single step", m.From, m.To)
|
||||||
|
}
|
||||||
|
log.Printf("[CHAIN] migration v%d→v%d: %s", m.From, m.To, m.Description)
|
||||||
|
err := db.Update(func(txn *badger.Txn) error {
|
||||||
|
if err := m.Apply(txn); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return writeSchemaVersion(txn, m.To)
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("migration v%d→v%d failed: %w", m.From, m.To, err)
|
||||||
|
}
|
||||||
|
cur = m.To
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fresh DB with no migrations yet to run — stamp the current version so
|
||||||
|
// we don't re-read "0 = no key" forever on later opens.
|
||||||
|
if cur < CurrentSchemaVersion {
|
||||||
|
err := db.Update(func(txn *badger.Txn) error {
|
||||||
|
return writeSchemaVersion(txn, CurrentSchemaVersion)
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("stamp schema version %d: %w", CurrentSchemaVersion, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// On a brand-new DB (no chain yet) cur is still 0 but
|
||||||
|
// CurrentSchemaVersion is also 0 (today), so nothing to stamp. When the
|
||||||
|
// first real migration lands, this stamp becomes active.
|
||||||
|
if CurrentSchemaVersion == 0 && cur == 0 {
|
||||||
|
err := db.Update(func(txn *badger.Txn) error {
|
||||||
|
// Only stamp if the key is absent — otherwise we already wrote it
|
||||||
|
// in the loop above.
|
||||||
|
if _, getErr := txn.Get([]byte(schemaMetaKey)); getErr == badger.ErrKeyNotFound {
|
||||||
|
return writeSchemaVersion(txn, CurrentSchemaVersion)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("stamp initial schema version 0: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
509
blockchain/types.go
Normal file
509
blockchain/types.go
Normal file
@@ -0,0 +1,509 @@
|
|||||||
|
package blockchain
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/binary"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// EventType defines what kind of event a transaction represents.
|
||||||
|
type EventType string
|
||||||
|
|
||||||
|
const (
|
||||||
|
EventRegisterKey EventType = "REGISTER_KEY"
|
||||||
|
EventCreateChannel EventType = "CREATE_CHANNEL"
|
||||||
|
EventAddMember EventType = "ADD_MEMBER"
|
||||||
|
EventOpenPayChan EventType = "OPEN_PAY_CHAN"
|
||||||
|
EventClosePayChan EventType = "CLOSE_PAY_CHAN"
|
||||||
|
EventTransfer EventType = "TRANSFER"
|
||||||
|
EventRelayProof EventType = "RELAY_PROOF"
|
||||||
|
EventRegisterRelay EventType = "REGISTER_RELAY" // node advertises relay service
|
||||||
|
EventBindWallet EventType = "BIND_WALLET" // node binds a payout wallet address
|
||||||
|
EventSlash EventType = "SLASH" // penalise a misbehaving validator
|
||||||
|
EventHeartbeat EventType = "HEARTBEAT" // liveness ping from a node
|
||||||
|
EventBlockReward EventType = "BLOCK_REWARD" // synthetic tx indexed on block commit
|
||||||
|
EventContactRequest EventType = "CONTACT_REQUEST" // paid first-contact request (ICQ-style)
|
||||||
|
EventAcceptContact EventType = "ACCEPT_CONTACT" // recipient accepts a pending request
|
||||||
|
EventBlockContact EventType = "BLOCK_CONTACT" // recipient blocks a sender
|
||||||
|
EventAddValidator EventType = "ADD_VALIDATOR" // existing validator adds a new one
|
||||||
|
EventRemoveValidator EventType = "REMOVE_VALIDATOR" // existing validator removes one (or self-removal)
|
||||||
|
EventDeployContract EventType = "DEPLOY_CONTRACT" // deploy a WASM smart contract
|
||||||
|
EventCallContract EventType = "CALL_CONTRACT" // call a method on a deployed contract
|
||||||
|
EventStake EventType = "STAKE" // lock tokens as validator stake
|
||||||
|
EventUnstake EventType = "UNSTAKE" // release staked tokens back to balance
|
||||||
|
EventIssueToken EventType = "ISSUE_TOKEN" // create a new fungible token
|
||||||
|
EventTransferToken EventType = "TRANSFER_TOKEN" // transfer fungible tokens between addresses
|
||||||
|
EventBurnToken EventType = "BURN_TOKEN" // destroy fungible tokens
|
||||||
|
EventMintNFT EventType = "MINT_NFT" // mint a new non-fungible token
|
||||||
|
EventTransferNFT EventType = "TRANSFER_NFT" // transfer NFT ownership
|
||||||
|
EventBurnNFT EventType = "BURN_NFT" // burn (destroy) an NFT
|
||||||
|
)
|
||||||
|
|
||||||
|
// Token amounts are stored in micro-tokens (µT).
|
||||||
|
// 1 token = 1_000_000 µT
|
||||||
|
const (
|
||||||
|
MicroToken uint64 = 1
|
||||||
|
Token uint64 = 1_000_000
|
||||||
|
|
||||||
|
// MinFee is the minimum transaction fee paid to the block validator.
|
||||||
|
// Validators earn fees as their only income — no block reward minting.
|
||||||
|
MinFee uint64 = 1_000 // 0.001 T per transaction
|
||||||
|
|
||||||
|
// GenesisAllocation is a one-time mint at block 0 for the bootstrap validator.
|
||||||
|
// All subsequent token supply comes only from re-distribution of existing balances.
|
||||||
|
GenesisAllocation uint64 = 21_000_000 * Token // 21 million T, fixed supply
|
||||||
|
|
||||||
|
// SlashAmount is the penalty deducted from a misbehaving validator's balance.
|
||||||
|
SlashAmount uint64 = 50 * Token
|
||||||
|
|
||||||
|
// RegistrationFee is the one-time fee to register an identity on-chain
|
||||||
|
// (EventRegisterKey). Paid to the block validator. High enough to deter
|
||||||
|
// Sybil attacks while remaining affordable.
|
||||||
|
RegistrationFee uint64 = 1_000_000 // 1 T
|
||||||
|
|
||||||
|
// MinContactFee is the minimum amount a sender must pay the recipient when
|
||||||
|
// submitting an EventContactRequest (anti-spam; goes directly to recipient).
|
||||||
|
MinContactFee uint64 = 5_000 // 0.005 T
|
||||||
|
)
|
||||||
|
|
||||||
|
// Transaction is the atomic unit recorded in a block.
|
||||||
|
// Bodies of messages are NEVER stored here — only identity/channel events.
|
||||||
|
type Transaction struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Type EventType `json:"type"`
|
||||||
|
From string `json:"from"` // hex-encoded Ed25519 public key
|
||||||
|
To string `json:"to"` // hex-encoded Ed25519 public key (if applicable)
|
||||||
|
Amount uint64 `json:"amount"` // µT to transfer (for TRANSFER type)
|
||||||
|
Fee uint64 `json:"fee"` // µT paid to the block validator
|
||||||
|
Memo string `json:"memo,omitempty"`
|
||||||
|
Payload []byte `json:"payload"` // JSON-encoded event-specific data
|
||||||
|
Signature []byte `json:"signature"` // Ed25519 sig over canonical bytes
|
||||||
|
Timestamp time.Time `json:"timestamp"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RegisterKeyPayload is embedded in EventRegisterKey transactions.
|
||||||
|
type RegisterKeyPayload struct {
|
||||||
|
PubKey string `json:"pub_key"` // hex-encoded Ed25519 public key
|
||||||
|
Nickname string `json:"nickname"` // human-readable, non-unique
|
||||||
|
PowNonce uint64 `json:"pow_nonce"` // proof-of-work nonce (Sybil barrier)
|
||||||
|
PowTarget string `json:"pow_target"`
|
||||||
|
X25519PubKey string `json:"x25519_pub_key,omitempty"` // hex Curve25519 key for E2E messaging
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateChannelPayload is embedded in EventCreateChannel transactions.
|
||||||
|
type CreateChannelPayload struct {
|
||||||
|
ChannelID string `json:"channel_id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
IsPublic bool `json:"is_public"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RegisterRelayPayload is embedded in EventRegisterRelay transactions.
|
||||||
|
// A node publishes this to advertise itself as a relay service provider.
|
||||||
|
// Clients look up relay nodes via GET /api/relays.
|
||||||
|
type RegisterRelayPayload struct {
|
||||||
|
// X25519PubKey is the hex-encoded Curve25519 public key for NaCl envelope encryption.
|
||||||
|
// Senders use this key to seal messages addressed to this relay node.
|
||||||
|
X25519PubKey string `json:"x25519_pub_key"`
|
||||||
|
// FeePerMsgUT is the relay fee the node charges per delivered envelope (in µT).
|
||||||
|
FeePerMsgUT uint64 `json:"fee_per_msg_ut"`
|
||||||
|
// Multiaddr is the optional libp2p multiaddr string for direct connections.
|
||||||
|
Multiaddr string `json:"multiaddr,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RelayProofPayload proves that a relay/recipient node received an envelope.
|
||||||
|
// The sender pre-authorises the fee by signing FeeAuthBytes(EnvelopeID, FeeUT).
|
||||||
|
// On-chain the fee is pulled from the sender's balance and credited to the relay.
|
||||||
|
type RelayProofPayload struct {
|
||||||
|
// EnvelopeID is the stable identifier of the delivered envelope (hex).
|
||||||
|
EnvelopeID string `json:"envelope_id"`
|
||||||
|
// EnvelopeHash is SHA-256(nonce || ciphertext) — prevents double-claiming.
|
||||||
|
EnvelopeHash []byte `json:"envelope_hash"`
|
||||||
|
// SenderPubKey is the Ed25519 public key of the envelope sender (hex).
|
||||||
|
SenderPubKey string `json:"sender_pub_key"`
|
||||||
|
// FeeUT is the delivery fee the relay claims from the sender's balance.
|
||||||
|
FeeUT uint64 `json:"fee_ut"`
|
||||||
|
// FeeSig is the sender's Ed25519 signature over FeeAuthBytes(EnvelopeID, FeeUT).
|
||||||
|
// This authorises the relay to pull FeeUT from the sender's on-chain balance.
|
||||||
|
FeeSig []byte `json:"fee_sig"`
|
||||||
|
// RelayPubKey is the Ed25519 public key of the relay claiming the fee (hex).
|
||||||
|
RelayPubKey string `json:"relay_pub_key"`
|
||||||
|
// DeliveredAt is the unix timestamp of delivery.
|
||||||
|
DeliveredAt int64 `json:"delivered_at"`
|
||||||
|
// RecipientSig is the recipient's optional Ed25519 sig over EnvelopeHash,
|
||||||
|
// proving the message was successfully decrypted (not required for fee claim).
|
||||||
|
RecipientSig []byte `json:"recipient_sig,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// FeeAuthBytes returns the canonical byte string that the sender must sign
|
||||||
|
// to pre-authorise a relay fee pull. The relay includes this signature in
|
||||||
|
// RelayProofPayload.FeeSig when submitting the proof on-chain.
|
||||||
|
//
|
||||||
|
// Format: SHA-256("relay-fee:" || envelopeID || uint64BE(feeUT))
|
||||||
|
func FeeAuthBytes(envelopeID string, feeUT uint64) []byte {
|
||||||
|
h := sha256.New()
|
||||||
|
h.Write([]byte("relay-fee:"))
|
||||||
|
h.Write([]byte(envelopeID))
|
||||||
|
var b [8]byte
|
||||||
|
binary.BigEndian.PutUint64(b[:], feeUT)
|
||||||
|
h.Write(b[:])
|
||||||
|
return h.Sum(nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TransferPayload carries an optional memo for token transfers.
|
||||||
|
type TransferPayload struct {
|
||||||
|
Memo string `json:"memo,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// BindWalletPayload links a node's signing key to a separate payout wallet.
|
||||||
|
// After this tx is committed, block fees and relay fees are credited to
|
||||||
|
// WalletPubKey instead of the node's own pub key.
|
||||||
|
type BindWalletPayload struct {
|
||||||
|
WalletPubKey string `json:"wallet_pub_key"`
|
||||||
|
WalletAddr string `json:"wallet_addr"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SlashPayload is submitted by a validator to penalise a misbehaving peer.
|
||||||
|
type SlashPayload struct {
|
||||||
|
OffenderPubKey string `json:"offender_pub_key"`
|
||||||
|
Reason string `json:"reason"` // "double_vote" | "downtime" | "equivocation"
|
||||||
|
Evidence []byte `json:"evidence,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// HeartbeatPayload is a periodic liveness signal published by active nodes.
|
||||||
|
// It carries the node's current chain height so peers can detect lagging nodes.
|
||||||
|
// Heartbeats cost MinFee (paid to the block validator) and earn no reward —
|
||||||
|
// they exist to build reputation and prove liveness.
|
||||||
|
type HeartbeatPayload struct {
|
||||||
|
PubKey string `json:"pub_key"`
|
||||||
|
ChainHeight uint64 `json:"chain_height"`
|
||||||
|
PeerCount int `json:"peer_count"`
|
||||||
|
Version string `json:"version"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// OpenPayChanPayload locks deposits from two parties into a payment channel.
|
||||||
|
type OpenPayChanPayload struct {
|
||||||
|
ChannelID string `json:"channel_id"`
|
||||||
|
PartyA string `json:"party_a"`
|
||||||
|
PartyB string `json:"party_b"`
|
||||||
|
DepositA uint64 `json:"deposit_a_ut"`
|
||||||
|
DepositB uint64 `json:"deposit_b_ut"`
|
||||||
|
ExpiryBlock uint64 `json:"expiry_block"`
|
||||||
|
SigB []byte `json:"sig_b"` // PartyB's Ed25519 sig over channel params
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClosePayChanPayload settles a payment channel and distributes balances.
|
||||||
|
type ClosePayChanPayload struct {
|
||||||
|
ChannelID string `json:"channel_id"`
|
||||||
|
BalanceA uint64 `json:"balance_a_ut"`
|
||||||
|
BalanceB uint64 `json:"balance_b_ut"`
|
||||||
|
Nonce uint64 `json:"nonce"`
|
||||||
|
SigA []byte `json:"sig_a"`
|
||||||
|
SigB []byte `json:"sig_b"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PayChanState is stored on-chain for each open payment channel.
|
||||||
|
type PayChanState struct {
|
||||||
|
ChannelID string `json:"channel_id"`
|
||||||
|
PartyA string `json:"party_a"`
|
||||||
|
PartyB string `json:"party_b"`
|
||||||
|
DepositA uint64 `json:"deposit_a_ut"`
|
||||||
|
DepositB uint64 `json:"deposit_b_ut"`
|
||||||
|
ExpiryBlock uint64 `json:"expiry_block"`
|
||||||
|
OpenedBlock uint64 `json:"opened_block"`
|
||||||
|
Nonce uint64 `json:"nonce"`
|
||||||
|
Closed bool `json:"closed"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// BlockRewardPayload is attached to synthetic BLOCK_REWARD transactions.
|
||||||
|
// These are index-only records so the explorer can show validator fee income.
|
||||||
|
// There is no minting — the FeeReward comes from existing transaction fees.
|
||||||
|
type BlockRewardPayload struct {
|
||||||
|
ValidatorPubKey string `json:"validator_pub_key"`
|
||||||
|
TargetPubKey string `json:"target_pub_key"`
|
||||||
|
FeeReward uint64 `json:"fee_reward_ut"`
|
||||||
|
TotalReward uint64 `json:"total_reward_ut"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ContactRequestPayload is embedded in EventContactRequest transactions.
|
||||||
|
// The sender pays tx.Amount directly to the recipient (anti-spam fee).
|
||||||
|
// A pending contact record is stored on-chain for the recipient to accept or block.
|
||||||
|
type ContactRequestPayload struct {
|
||||||
|
Intro string `json:"intro,omitempty"` // optional plaintext intro (≤ 280 chars)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AcceptContactPayload is embedded in EventAcceptContact transactions.
|
||||||
|
// tx.From accepts a pending request from tx.To.
|
||||||
|
type AcceptContactPayload struct{}
|
||||||
|
|
||||||
|
// BlockContactPayload is embedded in EventBlockContact transactions.
|
||||||
|
// tx.From blocks tx.To; future contact requests from tx.To are rejected.
|
||||||
|
type BlockContactPayload struct {
|
||||||
|
Reason string `json:"reason,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ChannelMember records a participant in a channel together with their
|
||||||
|
// X25519 public key. The key is cached on-chain (written during ADD_MEMBER)
|
||||||
|
// so channel senders don't have to fan out a separate /api/identity lookup
|
||||||
|
// per recipient on every message — they GET /api/channels/:id/members
|
||||||
|
// once and seal N envelopes in a loop.
|
||||||
|
type ChannelMember struct {
|
||||||
|
PubKey string `json:"pub_key"` // Ed25519 hex
|
||||||
|
X25519PubKey string `json:"x25519_pub_key"` // optional; empty if member hasn't registered
|
||||||
|
Address string `json:"address"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddMemberPayload is embedded in EventAddMember transactions.
|
||||||
|
// tx.From adds tx.To as a member of the specified channel.
|
||||||
|
// If tx.To is empty, tx.From is added (self-join for public channels).
|
||||||
|
type AddMemberPayload struct {
|
||||||
|
ChannelID string `json:"channel_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddValidatorPayload is embedded in EventAddValidator transactions.
|
||||||
|
// tx.From must already be a validator; tx.To is the new validator's pub key.
|
||||||
|
//
|
||||||
|
// Admission is gated by two things:
|
||||||
|
// 1. Stake: the candidate (tx.To) must have STAKE'd at least
|
||||||
|
// MinValidatorStake beforehand. Prevents anyone spinning up a free
|
||||||
|
// validator without economic buy-in.
|
||||||
|
// 2. Multi-sig: at least ⌈2/3⌉ of the CURRENT validator set must approve.
|
||||||
|
// The tx sender counts as one; remaining approvals go in CoSignatures.
|
||||||
|
// For a 1-validator chain (fresh genesis / tests) sender alone is 2/3,
|
||||||
|
// so CoSignatures can be empty — backward-compat is preserved.
|
||||||
|
type AddValidatorPayload struct {
|
||||||
|
Reason string `json:"reason,omitempty"`
|
||||||
|
CoSignatures []ValidatorCoSig `json:"cosigs,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidatorCoSig is an off-chain-assembled approval from one existing
|
||||||
|
// validator for a specific candidate admission. The signature is over the
|
||||||
|
// canonical digest returned by AdmitDigest(candidatePubKeyHex).
|
||||||
|
type ValidatorCoSig struct {
|
||||||
|
PubKey string `json:"pubkey"` // Ed25519 hex of a current validator
|
||||||
|
Signature []byte `json:"signature"` // Ed25519 signature over AdmitDigest(candidate)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdmitDigest returns the canonical bytes a validator signs to approve
|
||||||
|
// admitting `candidatePubHex` as a new validator. Stable across implementations
|
||||||
|
// so co-sigs collected off-chain verify identically on-chain.
|
||||||
|
func AdmitDigest(candidatePubHex string) []byte {
|
||||||
|
h := sha256.New()
|
||||||
|
h.Write([]byte("DCHAIN-ADD-VALIDATOR\x00"))
|
||||||
|
h.Write([]byte(candidatePubHex))
|
||||||
|
return h.Sum(nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MinValidatorStake is the minimum µT a candidate must have locked in
|
||||||
|
// `stake:<pubkey>` before an ADD_VALIDATOR naming them is accepted.
|
||||||
|
// 1 T = 1_000_000 µT — small enough that testnets can afford it easily,
|
||||||
|
// large enough to deter "register 100 fake validators to 51%-attack".
|
||||||
|
const MinValidatorStake uint64 = 1_000_000
|
||||||
|
|
||||||
|
// RemoveValidatorPayload is embedded in EventRemoveValidator transactions.
|
||||||
|
// tx.From must be a validator; tx.To is the validator to remove.
|
||||||
|
//
|
||||||
|
// Two legitimate use cases:
|
||||||
|
// 1. Self-removal (tx.From == tx.To): always allowed, no cosigs needed.
|
||||||
|
// Lets a validator gracefully leave the set without requiring others.
|
||||||
|
// 2. Forced removal (tx.From != tx.To): requires ⌈2/3⌉ cosigs of the
|
||||||
|
// current validator set — same pattern as ADD_VALIDATOR. Stops a
|
||||||
|
// single validator from unilaterally kicking peers.
|
||||||
|
//
|
||||||
|
// The signed payload is AdmitDigest(tx.To) but with the domain byte flipped
|
||||||
|
// — see RemoveDigest below. This prevents a cosig collected for "admit X"
|
||||||
|
// from being replayed as "remove X".
|
||||||
|
type RemoveValidatorPayload struct {
|
||||||
|
Reason string `json:"reason,omitempty"`
|
||||||
|
CoSignatures []ValidatorCoSig `json:"cosigs,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemoveDigest is the canonical bytes a validator signs to approve removing
|
||||||
|
// `targetPubHex` from the set. Distinct from AdmitDigest so signatures
|
||||||
|
// can't be cross-replayed between add and remove operations.
|
||||||
|
func RemoveDigest(targetPubHex string) []byte {
|
||||||
|
h := sha256.New()
|
||||||
|
h.Write([]byte("DCHAIN-REMOVE-VALIDATOR\x00"))
|
||||||
|
h.Write([]byte(targetPubHex))
|
||||||
|
return h.Sum(nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeployContractPayload is embedded in EventDeployContract transactions.
|
||||||
|
// WASMBase64 is the base64-encoded WASM binary. It is stored in the tx so that
|
||||||
|
// nodes can replay the chain from genesis and re-derive contract state.
|
||||||
|
type DeployContractPayload struct {
|
||||||
|
WASMBase64 string `json:"wasm_b64"`
|
||||||
|
ABIJson string `json:"abi_json"`
|
||||||
|
InitArgs string `json:"init_args_json,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CallContractPayload is embedded in EventCallContract transactions.
|
||||||
|
type CallContractPayload struct {
|
||||||
|
ContractID string `json:"contract_id"`
|
||||||
|
Method string `json:"method"`
|
||||||
|
ArgsJSON string `json:"args_json,omitempty"`
|
||||||
|
GasLimit uint64 `json:"gas_limit"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ContractRecord is stored in BadgerDB at contract:<contractID>.
|
||||||
|
// WASMBytes is NOT in the block; it is derived from the deploy tx payload on replay.
|
||||||
|
type ContractRecord struct {
|
||||||
|
ContractID string `json:"contract_id"`
|
||||||
|
WASMBytes []byte `json:"wasm_bytes"`
|
||||||
|
ABIJson string `json:"abi_json"`
|
||||||
|
DeployerPub string `json:"deployer_pub"`
|
||||||
|
DeployedAt uint64 `json:"deployed_at"` // block height
|
||||||
|
}
|
||||||
|
|
||||||
|
// MinDeployFee is the minimum fee for a DEPLOY_CONTRACT transaction.
|
||||||
|
// Covers storage costs for the WASM binary.
|
||||||
|
const MinDeployFee uint64 = 10_000 // 0.01 T
|
||||||
|
|
||||||
|
// MinCallFee is the minimum base fee for a CALL_CONTRACT transaction.
|
||||||
|
// Gas costs are billed on top of this.
|
||||||
|
const MinCallFee uint64 = MinFee
|
||||||
|
|
||||||
|
// ContractLogEntry is one log message emitted by a contract via env.log().
|
||||||
|
// Stored in BadgerDB at clog:<contractID>:<blockHeight_20d>:<seq_05d>.
|
||||||
|
type ContractLogEntry struct {
|
||||||
|
ContractID string `json:"contract_id"`
|
||||||
|
BlockHeight uint64 `json:"block_height"`
|
||||||
|
TxID string `json:"tx_id"`
|
||||||
|
Seq int `json:"seq"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GasPrice is the cost in µT per 1 gas unit consumed during contract execution.
|
||||||
|
const GasPrice uint64 = 1 // 1 µT per gas unit
|
||||||
|
|
||||||
|
// MinStake is the minimum amount a validator must stake.
|
||||||
|
const MinStake uint64 = 1_000 * Token // 1000 T
|
||||||
|
|
||||||
|
// MinIssueTokenFee is the fee required to issue a new token.
|
||||||
|
const MinIssueTokenFee uint64 = 100_000 // 0.1 T
|
||||||
|
|
||||||
|
// StakePayload is embedded in EventStake transactions.
|
||||||
|
// tx.Amount holds the amount to stake; tx.Fee is the transaction fee.
|
||||||
|
type StakePayload struct{}
|
||||||
|
|
||||||
|
// UnstakePayload is embedded in EventUnstake transactions.
|
||||||
|
// The entire current stake is returned to the staker's balance.
|
||||||
|
type UnstakePayload struct{}
|
||||||
|
|
||||||
|
// IssueTokenPayload is embedded in EventIssueToken transactions.
|
||||||
|
// The new token is credited to tx.From with TotalSupply units.
|
||||||
|
type IssueTokenPayload struct {
|
||||||
|
Name string `json:"name"` // human-readable token name, e.g. "My Token"
|
||||||
|
Symbol string `json:"symbol"` // ticker symbol, e.g. "MTK"
|
||||||
|
Decimals uint8 `json:"decimals"` // decimal places, e.g. 6 → 1 token = 1_000_000 base units
|
||||||
|
TotalSupply uint64 `json:"total_supply"` // initial supply in base units
|
||||||
|
}
|
||||||
|
|
||||||
|
// TransferTokenPayload is embedded in EventTransferToken transactions.
|
||||||
|
// tx.To is the recipient; tx.Amount is ignored (use payload Amount).
|
||||||
|
type TransferTokenPayload struct {
|
||||||
|
TokenID string `json:"token_id"`
|
||||||
|
Amount uint64 `json:"amount"` // in base units
|
||||||
|
}
|
||||||
|
|
||||||
|
// BurnTokenPayload is embedded in EventBurnToken transactions.
|
||||||
|
type BurnTokenPayload struct {
|
||||||
|
TokenID string `json:"token_id"`
|
||||||
|
Amount uint64 `json:"amount"` // in base units
|
||||||
|
}
|
||||||
|
|
||||||
|
// TokenRecord is stored in BadgerDB at token:<tokenID>.
|
||||||
|
type TokenRecord struct {
|
||||||
|
TokenID string `json:"token_id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Symbol string `json:"symbol"`
|
||||||
|
Decimals uint8 `json:"decimals"`
|
||||||
|
TotalSupply uint64 `json:"total_supply"` // current (may decrease via burns)
|
||||||
|
Issuer string `json:"issuer"` // creator pubkey
|
||||||
|
IssuedAt uint64 `json:"issued_at"` // block height
|
||||||
|
}
|
||||||
|
|
||||||
|
// MinMintNFTFee is the fee required to mint a new NFT.
|
||||||
|
const MinMintNFTFee uint64 = 10_000 // 0.01 T
|
||||||
|
|
||||||
|
// MintNFTPayload is embedded in EventMintNFT transactions.
|
||||||
|
type MintNFTPayload struct {
|
||||||
|
Name string `json:"name"` // human-readable name
|
||||||
|
Description string `json:"description,omitempty"`
|
||||||
|
URI string `json:"uri,omitempty"` // off-chain metadata URI (IPFS, https, etc.)
|
||||||
|
Attributes string `json:"attributes,omitempty"` // JSON string of trait attributes
|
||||||
|
}
|
||||||
|
|
||||||
|
// TransferNFTPayload is embedded in EventTransferNFT transactions.
|
||||||
|
// tx.To is the new owner; tx.From must be current owner.
|
||||||
|
type TransferNFTPayload struct {
|
||||||
|
NFTID string `json:"nft_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// BurnNFTPayload is embedded in EventBurnNFT transactions.
|
||||||
|
type BurnNFTPayload struct {
|
||||||
|
NFTID string `json:"nft_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// NFTRecord is stored in BadgerDB at nft:<nftID>.
|
||||||
|
type NFTRecord struct {
|
||||||
|
NFTID string `json:"nft_id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Description string `json:"description,omitempty"`
|
||||||
|
URI string `json:"uri,omitempty"`
|
||||||
|
Attributes string `json:"attributes,omitempty"`
|
||||||
|
Owner string `json:"owner"` // current owner pubkey
|
||||||
|
Issuer string `json:"issuer"` // original minter pubkey
|
||||||
|
MintedAt uint64 `json:"minted_at"` // block height
|
||||||
|
Burned bool `json:"burned,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ContactStatus is the state of a contact relationship.
|
||||||
|
type ContactStatus string
|
||||||
|
|
||||||
|
const (
|
||||||
|
ContactPending ContactStatus = "pending"
|
||||||
|
ContactAccepted ContactStatus = "accepted"
|
||||||
|
ContactBlocked ContactStatus = "blocked"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ContactInfo is returned by the contacts API.
|
||||||
|
type ContactInfo struct {
|
||||||
|
RequesterPub string `json:"requester_pub"`
|
||||||
|
RequesterAddr string `json:"requester_addr"`
|
||||||
|
Status ContactStatus `json:"status"`
|
||||||
|
Intro string `json:"intro,omitempty"`
|
||||||
|
FeeUT uint64 `json:"fee_ut"`
|
||||||
|
TxID string `json:"tx_id"`
|
||||||
|
CreatedAt int64 `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// IdentityInfo is returned by GET /api/identity/{pubkey}.
|
||||||
|
type IdentityInfo struct {
|
||||||
|
PubKey string `json:"pub_key"`
|
||||||
|
Address string `json:"address"`
|
||||||
|
X25519Pub string `json:"x25519_pub"` // hex Curve25519 key; empty if not published
|
||||||
|
Nickname string `json:"nickname"`
|
||||||
|
Registered bool `json:"registered"` // true if REGISTER_KEY tx was committed
|
||||||
|
}
|
||||||
|
|
||||||
|
// ConsensusMessage types used by the PBFT engine over the P2P layer.
|
||||||
|
type MsgType string
|
||||||
|
|
||||||
|
const (
|
||||||
|
MsgPrePrepare MsgType = "PRE_PREPARE"
|
||||||
|
MsgPrepare MsgType = "PREPARE"
|
||||||
|
MsgCommit MsgType = "COMMIT"
|
||||||
|
MsgViewChange MsgType = "VIEW_CHANGE"
|
||||||
|
MsgNewView MsgType = "NEW_VIEW"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ConsensusMsg is the envelope sent between validators.
|
||||||
|
type ConsensusMsg struct {
|
||||||
|
Type MsgType `json:"type"`
|
||||||
|
View uint64 `json:"view"`
|
||||||
|
SeqNum uint64 `json:"seq_num"`
|
||||||
|
BlockHash []byte `json:"block_hash"`
|
||||||
|
Block *Block `json:"block,omitempty"`
|
||||||
|
From string `json:"from"`
|
||||||
|
Signature []byte `json:"signature"`
|
||||||
|
}
|
||||||
0
client-app/.gitignore
vendored
Normal file
0
client-app/.gitignore
vendored
Normal file
93
client-app/README.md
Normal file
93
client-app/README.md
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
# DChain Messenger — React Native Client
|
||||||
|
|
||||||
|
E2E-encrypted mobile/desktop messenger built on the DChain blockchain stack.
|
||||||
|
|
||||||
|
**Stack:** React Native · Expo · NativeWind (Tailwind) · TweetNaCl · Zustand
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd client-app
|
||||||
|
npm install
|
||||||
|
npx expo start # opens Expo Dev Tools
|
||||||
|
# Press 'i' for iOS simulator, 'a' for Android, 'w' for web
|
||||||
|
```
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- Node.js 18+
|
||||||
|
- [Expo Go](https://expo.dev/client) on your phone (for Expo tunnel), or iOS/Android emulator
|
||||||
|
- A running DChain node (see root README for `docker compose up --build -d`)
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
client-app/
|
||||||
|
├── app/
|
||||||
|
│ ├── _layout.tsx # Root layout — loads keys, sets up nav
|
||||||
|
│ ├── index.tsx # Welcome / onboarding
|
||||||
|
│ ├── (auth)/
|
||||||
|
│ │ ├── create.tsx # Generate new Ed25519 + X25519 keys
|
||||||
|
│ │ ├── created.tsx # Key created — export reminder
|
||||||
|
│ │ └── import.tsx # Import existing key.json
|
||||||
|
│ └── (app)/
|
||||||
|
│ ├── _layout.tsx # Tab bar — Chats · Wallet · Settings
|
||||||
|
│ ├── chats/
|
||||||
|
│ │ ├── index.tsx # Chat list with contacts
|
||||||
|
│ │ └── [id].tsx # Individual chat with E2E encryption
|
||||||
|
│ ├── requests.tsx # Incoming contact requests
|
||||||
|
│ ├── new-contact.tsx # Add contact by @username or address
|
||||||
|
│ ├── wallet.tsx # Balance + TX history + send
|
||||||
|
│ └── settings.tsx # Node URL, key export, profile
|
||||||
|
├── components/ui/ # shadcn-style components (Button, Card, Input…)
|
||||||
|
├── hooks/
|
||||||
|
│ ├── useMessages.ts # Poll relay inbox, decrypt messages
|
||||||
|
│ ├── useBalance.ts # Poll token balance
|
||||||
|
│ └── useContacts.ts # Load contacts + poll contact requests
|
||||||
|
└── lib/
|
||||||
|
├── api.ts # REST client for all DChain endpoints
|
||||||
|
├── crypto.ts # NaCl box encrypt/decrypt, Ed25519 sign
|
||||||
|
├── storage.ts # SecureStore (keys) + AsyncStorage (data)
|
||||||
|
├── store.ts # Zustand global state
|
||||||
|
├── types.ts # TypeScript interfaces
|
||||||
|
└── utils.ts # cn(), formatAmount(), relativeTime()
|
||||||
|
```
|
||||||
|
|
||||||
|
## Cryptography
|
||||||
|
|
||||||
|
| Operation | Algorithm | Library |
|
||||||
|
|-----------|-----------|---------|
|
||||||
|
| Transaction signing | Ed25519 | TweetNaCl `sign` |
|
||||||
|
| Key exchange | X25519 (Curve25519) | TweetNaCl `box` |
|
||||||
|
| Message encryption | NaCl box (XSalsa20-Poly1305) | TweetNaCl `box` |
|
||||||
|
| Key storage | Device secure enclave | expo-secure-store |
|
||||||
|
|
||||||
|
Messages are encrypted as:
|
||||||
|
```
|
||||||
|
Envelope {
|
||||||
|
sender_pub: <X25519 hex> // sender's public key
|
||||||
|
recipient_pub: <X25519 hex> // recipient's public key
|
||||||
|
nonce: <24-byte hex> // random per message
|
||||||
|
ciphertext: <hex> // NaCl box(plaintext, nonce, sender_priv, recipient_pub)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Connect to your node
|
||||||
|
|
||||||
|
1. Start the DChain node: `docker compose up --build -d`
|
||||||
|
2. Open the app → Settings → Node URL → `http://YOUR_IP:8081`
|
||||||
|
3. If using Expo Go on physical device: your PC and phone must be on the same network, or use `npx expo start --tunnel`
|
||||||
|
|
||||||
|
## Key File Format
|
||||||
|
|
||||||
|
The `key.json` exported/imported by the app:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"pub_key": "26018d40...", // Ed25519 public key (64 hex chars)
|
||||||
|
"priv_key": "...", // Ed25519 private key (128 hex chars)
|
||||||
|
"x25519_pub": "...", // X25519 public key (64 hex chars)
|
||||||
|
"x25519_priv": "..." // X25519 private key (64 hex chars)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
This is the same format as the Go node's `--key` flag.
|
||||||
36
client-app/app.json
Normal file
36
client-app/app.json
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
{
|
||||||
|
"expo": {
|
||||||
|
"name": "DChain Messenger",
|
||||||
|
"slug": "dchain-messenger",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"orientation": "portrait",
|
||||||
|
"userInterfaceStyle": "dark",
|
||||||
|
"backgroundColor": "#0d1117",
|
||||||
|
"ios": {
|
||||||
|
"supportsTablet": false,
|
||||||
|
"bundleIdentifier": "com.dchain.messenger"
|
||||||
|
},
|
||||||
|
"android": {
|
||||||
|
"package": "com.dchain.messenger",
|
||||||
|
"softwareKeyboardLayoutMode": "pan"
|
||||||
|
},
|
||||||
|
"web": {
|
||||||
|
"bundler": "metro",
|
||||||
|
"output": "static"
|
||||||
|
},
|
||||||
|
"plugins": [
|
||||||
|
"expo-router",
|
||||||
|
"expo-secure-store",
|
||||||
|
[
|
||||||
|
"expo-camera",
|
||||||
|
{
|
||||||
|
"cameraPermission": "Allow DChain to scan QR codes for node configuration."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
],
|
||||||
|
"experiments": {
|
||||||
|
"typedRoutes": false
|
||||||
|
},
|
||||||
|
"scheme": "dchain"
|
||||||
|
}
|
||||||
|
}
|
||||||
127
client-app/app/(app)/_layout.tsx
Normal file
127
client-app/app/(app)/_layout.tsx
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
/**
|
||||||
|
* Main app tab layout.
|
||||||
|
* Redirects to welcome if no key found.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useEffect } from 'react';
|
||||||
|
import { Tabs, router } from 'expo-router';
|
||||||
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
|
import { useStore } from '@/lib/store';
|
||||||
|
import { useBalance } from '@/hooks/useBalance';
|
||||||
|
import { useContacts } from '@/hooks/useContacts';
|
||||||
|
import { useWellKnownContracts } from '@/hooks/useWellKnownContracts';
|
||||||
|
import { getWSClient } from '@/lib/ws';
|
||||||
|
|
||||||
|
const C_ACCENT = '#7db5ff';
|
||||||
|
const C_MUTED = '#98a7c2';
|
||||||
|
const C_BG = '#111a2b';
|
||||||
|
const C_BORDER = '#1c2840';
|
||||||
|
|
||||||
|
export default function AppLayout() {
|
||||||
|
const keyFile = useStore(s => s.keyFile);
|
||||||
|
const requests = useStore(s => s.requests);
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
|
useBalance();
|
||||||
|
useContacts();
|
||||||
|
useWellKnownContracts(); // auto-discover canonical system contracts from node
|
||||||
|
|
||||||
|
// Arm the WS client with this user's Ed25519 keypair. The client signs the
|
||||||
|
// server's auth nonce on every (re)connect so scoped subscriptions
|
||||||
|
// (addr:<my_pub>, inbox:<my_x25519>) are accepted. Without this the
|
||||||
|
// server would still accept global topic subs but reject scoped ones.
|
||||||
|
useEffect(() => {
|
||||||
|
const ws = getWSClient();
|
||||||
|
if (keyFile) {
|
||||||
|
ws.setAuthCreds({ pubKey: keyFile.pub_key, privKey: keyFile.priv_key });
|
||||||
|
} else {
|
||||||
|
ws.setAuthCreds(null);
|
||||||
|
}
|
||||||
|
}, [keyFile]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (keyFile === null) {
|
||||||
|
const t = setTimeout(() => {
|
||||||
|
if (!useStore.getState().keyFile) router.replace('/');
|
||||||
|
}, 300);
|
||||||
|
return () => clearTimeout(t);
|
||||||
|
}
|
||||||
|
}, [keyFile]);
|
||||||
|
|
||||||
|
// Tab bar layout math:
|
||||||
|
// icon (22) + gap (4) + label (~13) = ~39px of content
|
||||||
|
// We add a 12px visual margin above, and pad the bottom by the larger of
|
||||||
|
// the platform safe-area inset or 10px so the bar never sits flush on the
|
||||||
|
// home indicator.
|
||||||
|
const BAR_CONTENT_HEIGHT = 52;
|
||||||
|
const bottomPad = Math.max(insets.bottom, 10);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tabs
|
||||||
|
screenOptions={{
|
||||||
|
headerShown: false,
|
||||||
|
tabBarActiveTintColor: C_ACCENT,
|
||||||
|
tabBarInactiveTintColor: C_MUTED,
|
||||||
|
tabBarLabelStyle: {
|
||||||
|
fontSize: 10,
|
||||||
|
fontWeight: '500',
|
||||||
|
marginTop: 2,
|
||||||
|
},
|
||||||
|
tabBarStyle: {
|
||||||
|
backgroundColor: C_BG,
|
||||||
|
borderTopColor: C_BORDER,
|
||||||
|
borderTopWidth: 1,
|
||||||
|
height: BAR_CONTENT_HEIGHT + bottomPad,
|
||||||
|
paddingTop: 8,
|
||||||
|
paddingBottom: bottomPad,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Tabs.Screen
|
||||||
|
name="chats"
|
||||||
|
options={{
|
||||||
|
tabBarLabel: 'Чаты',
|
||||||
|
tabBarIcon: ({ color, focused }) => (
|
||||||
|
<Ionicons
|
||||||
|
name={focused ? 'chatbubbles' : 'chatbubbles-outline'}
|
||||||
|
size={22}
|
||||||
|
color={color}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
tabBarBadge: requests.length > 0 ? requests.length : undefined,
|
||||||
|
tabBarBadgeStyle: { backgroundColor: C_ACCENT, fontSize: 10 },
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Tabs.Screen
|
||||||
|
name="wallet"
|
||||||
|
options={{
|
||||||
|
tabBarLabel: 'Кошелёк',
|
||||||
|
tabBarIcon: ({ color, focused }) => (
|
||||||
|
<Ionicons
|
||||||
|
name={focused ? 'wallet' : 'wallet-outline'}
|
||||||
|
size={22}
|
||||||
|
color={color}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Tabs.Screen
|
||||||
|
name="settings"
|
||||||
|
options={{
|
||||||
|
tabBarLabel: 'Настройки',
|
||||||
|
tabBarIcon: ({ color, focused }) => (
|
||||||
|
<Ionicons
|
||||||
|
name={focused ? 'settings' : 'settings-outline'}
|
||||||
|
size={22}
|
||||||
|
color={color}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{/* Non-tab screens — hidden from tab bar */}
|
||||||
|
<Tabs.Screen name="requests" options={{ href: null }} />
|
||||||
|
<Tabs.Screen name="new-contact" options={{ href: null }} />
|
||||||
|
</Tabs>
|
||||||
|
);
|
||||||
|
}
|
||||||
413
client-app/app/(app)/chats/[id].tsx
Normal file
413
client-app/app/(app)/chats/[id].tsx
Normal file
@@ -0,0 +1,413 @@
|
|||||||
|
/**
|
||||||
|
* Chat view — DChain messenger.
|
||||||
|
* Safe-area aware header/input, smooth scroll, proper E2E indicators,
|
||||||
|
* responsive send button with press feedback.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useState, useRef, useEffect, useCallback } from 'react';
|
||||||
|
import {
|
||||||
|
View, Text, FlatList, TextInput, TouchableOpacity, Pressable,
|
||||||
|
KeyboardAvoidingView, Platform, ActivityIndicator, Alert,
|
||||||
|
} from 'react-native';
|
||||||
|
import { router, useLocalSearchParams } from 'expo-router';
|
||||||
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
|
import { useStore } from '@/lib/store';
|
||||||
|
import { useMessages } from '@/hooks/useMessages';
|
||||||
|
import { encryptMessage } from '@/lib/crypto';
|
||||||
|
import { sendEnvelope } from '@/lib/api';
|
||||||
|
import { getWSClient } from '@/lib/ws';
|
||||||
|
import { appendMessage, loadMessages } from '@/lib/storage';
|
||||||
|
import { formatTime, randomId } from '@/lib/utils';
|
||||||
|
import { Avatar } from '@/components/ui/Avatar';
|
||||||
|
import type { Message } from '@/lib/types';
|
||||||
|
|
||||||
|
// ─── Design tokens ────────────────────────────────────────────────────────────
|
||||||
|
const C = {
|
||||||
|
bg: '#0b1220',
|
||||||
|
surface: '#111a2b',
|
||||||
|
surface2:'#162035',
|
||||||
|
surface3:'#1a2640',
|
||||||
|
line: '#1c2840',
|
||||||
|
text: '#e6edf9',
|
||||||
|
muted: '#98a7c2',
|
||||||
|
accent: '#7db5ff',
|
||||||
|
ok: '#41c98a',
|
||||||
|
warn: '#f0b35a',
|
||||||
|
err: '#ff7a87',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function shortAddr(a: string, n = 5): string {
|
||||||
|
if (!a) return '—';
|
||||||
|
return a.length <= n * 2 + 1 ? a : `${a.slice(0, n)}…${a.slice(-n)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Group messages by calendar day for day-separator labels. */
|
||||||
|
function dateBucket(ts: number): string {
|
||||||
|
const d = new Date(ts * 1000);
|
||||||
|
const now = new Date();
|
||||||
|
const yday = new Date(); yday.setDate(now.getDate() - 1);
|
||||||
|
const same = (a: Date, b: Date) =>
|
||||||
|
a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate();
|
||||||
|
if (same(d, now)) return 'Сегодня';
|
||||||
|
if (same(d, yday)) return 'Вчера';
|
||||||
|
return d.toLocaleDateString('ru', { day: 'numeric', month: 'long' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// A row in the FlatList: either a message or a date separator we inject.
|
||||||
|
type Row =
|
||||||
|
| { kind: 'msg'; msg: Message }
|
||||||
|
| { kind: 'sep'; id: string; label: string };
|
||||||
|
|
||||||
|
function buildRows(msgs: Message[]): Row[] {
|
||||||
|
const rows: Row[] = [];
|
||||||
|
let lastBucket = '';
|
||||||
|
for (const m of msgs) {
|
||||||
|
const b = dateBucket(m.timestamp);
|
||||||
|
if (b !== lastBucket) {
|
||||||
|
rows.push({ kind: 'sep', id: `sep_${b}_${m.id}`, label: b });
|
||||||
|
lastBucket = b;
|
||||||
|
}
|
||||||
|
rows.push({ kind: 'msg', msg: m });
|
||||||
|
}
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Screen ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export default function ChatScreen() {
|
||||||
|
const { id: contactAddress } = useLocalSearchParams<{ id: string }>();
|
||||||
|
const keyFile = useStore(s => s.keyFile);
|
||||||
|
const contacts = useStore(s => s.contacts);
|
||||||
|
const messages = useStore(s => s.messages);
|
||||||
|
const setMsgs = useStore(s => s.setMessages);
|
||||||
|
const appendMsg = useStore(s => s.appendMessage);
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
|
const contact = contacts.find(c => c.address === contactAddress);
|
||||||
|
const chatMsgs = messages[contactAddress ?? ''] ?? [];
|
||||||
|
const listRef = useRef<FlatList>(null);
|
||||||
|
|
||||||
|
const [text, setText] = useState('');
|
||||||
|
const [sending, setSending] = useState(false);
|
||||||
|
const [peerTyping, setPeerTyping] = useState(false);
|
||||||
|
|
||||||
|
// Poll relay inbox for messages from this contact
|
||||||
|
useMessages(contact?.x25519Pub ?? '');
|
||||||
|
|
||||||
|
// Subscribe to typing indicators sent to us. Clears after 3 seconds of
|
||||||
|
// silence so the "typing…" bubble disappears naturally when the peer
|
||||||
|
// stops. sendTyping on our side happens per-keystroke (throttled below).
|
||||||
|
useEffect(() => {
|
||||||
|
if (!keyFile?.x25519_pub) return;
|
||||||
|
const ws = getWSClient();
|
||||||
|
let clearTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
const off = ws.subscribe('typing:' + keyFile.x25519_pub, (frame) => {
|
||||||
|
if (frame.event !== 'typing') return;
|
||||||
|
const d = frame.data as { from?: string } | undefined;
|
||||||
|
// Only show typing for the contact currently open in this view.
|
||||||
|
if (!contact?.x25519Pub || d?.from !== contact.x25519Pub) return;
|
||||||
|
setPeerTyping(true);
|
||||||
|
if (clearTimer) clearTimeout(clearTimer);
|
||||||
|
clearTimer = setTimeout(() => setPeerTyping(false), 3_000);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
off();
|
||||||
|
if (clearTimer) clearTimeout(clearTimer);
|
||||||
|
};
|
||||||
|
}, [keyFile?.x25519_pub, contact?.x25519Pub]);
|
||||||
|
|
||||||
|
// Throttled sendTyping: fire on every keystroke but no more than every 2s.
|
||||||
|
const lastSentTyping = useRef(0);
|
||||||
|
const handleTextChange = useCallback((t: string) => {
|
||||||
|
setText(t);
|
||||||
|
if (!contact?.x25519Pub || !t.trim()) return;
|
||||||
|
const now = Date.now();
|
||||||
|
if (now - lastSentTyping.current < 2_000) return;
|
||||||
|
lastSentTyping.current = now;
|
||||||
|
getWSClient().sendTyping(contact.x25519Pub);
|
||||||
|
}, [contact?.x25519Pub]);
|
||||||
|
|
||||||
|
// Load cached messages on mount
|
||||||
|
useEffect(() => {
|
||||||
|
if (contactAddress) {
|
||||||
|
loadMessages(contactAddress).then(cached => {
|
||||||
|
setMsgs(contactAddress, cached as Message[]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [contactAddress]);
|
||||||
|
|
||||||
|
const displayName = contact?.username
|
||||||
|
? `@${contact.username}`
|
||||||
|
: contact?.alias ?? shortAddr(contactAddress ?? '', 6);
|
||||||
|
|
||||||
|
const canSend = !!text.trim() && !sending && !!contact?.x25519Pub;
|
||||||
|
|
||||||
|
const send = useCallback(async () => {
|
||||||
|
if (!text.trim() || !keyFile || !contact) return;
|
||||||
|
if (!contact.x25519Pub) {
|
||||||
|
Alert.alert(
|
||||||
|
'Ключ ещё не опубликован',
|
||||||
|
'Контакт пока не опубликовал ключ шифрования. Попробуйте позже.',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSending(true);
|
||||||
|
try {
|
||||||
|
const { nonce, ciphertext } = encryptMessage(
|
||||||
|
text.trim(),
|
||||||
|
keyFile.x25519_priv,
|
||||||
|
contact.x25519Pub,
|
||||||
|
);
|
||||||
|
await sendEnvelope({
|
||||||
|
sender_pub: keyFile.x25519_pub,
|
||||||
|
recipient_pub: contact.x25519Pub,
|
||||||
|
nonce,
|
||||||
|
ciphertext,
|
||||||
|
});
|
||||||
|
const msg: Message = {
|
||||||
|
id: randomId(),
|
||||||
|
from: keyFile.x25519_pub,
|
||||||
|
text: text.trim(),
|
||||||
|
timestamp: Math.floor(Date.now() / 1000),
|
||||||
|
mine: true,
|
||||||
|
};
|
||||||
|
appendMsg(contact.address, msg);
|
||||||
|
await appendMessage(contact.address, msg);
|
||||||
|
setText('');
|
||||||
|
setTimeout(() => listRef.current?.scrollToEnd({ animated: true }), 50);
|
||||||
|
} catch (e: any) {
|
||||||
|
Alert.alert('Ошибка отправки', e.message);
|
||||||
|
} finally {
|
||||||
|
setSending(false);
|
||||||
|
}
|
||||||
|
}, [text, keyFile, contact]);
|
||||||
|
|
||||||
|
const rows = buildRows(chatMsgs);
|
||||||
|
|
||||||
|
const renderRow = ({ item }: { item: Row }) => {
|
||||||
|
if (item.kind === 'sep') {
|
||||||
|
return (
|
||||||
|
<View style={{ alignItems: 'center', marginVertical: 12 }}>
|
||||||
|
<View style={{
|
||||||
|
backgroundColor: C.surface2, borderRadius: 999,
|
||||||
|
paddingHorizontal: 12, paddingVertical: 4,
|
||||||
|
}}>
|
||||||
|
<Text style={{ color: C.muted, fontSize: 11, fontWeight: '500' }}>{item.label}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const m = item.msg;
|
||||||
|
return (
|
||||||
|
<View style={{
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: m.mine ? 'flex-end' : 'flex-start',
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
marginBottom: 6,
|
||||||
|
}}>
|
||||||
|
{!m.mine && (
|
||||||
|
<View style={{ marginRight: 8, marginBottom: 4, flexShrink: 0 }}>
|
||||||
|
<Avatar name={displayName} size="sm" />
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
<View style={{
|
||||||
|
maxWidth: '78%',
|
||||||
|
backgroundColor: m.mine ? C.accent : C.surface,
|
||||||
|
borderRadius: 18,
|
||||||
|
borderTopRightRadius: m.mine ? 6 : 18,
|
||||||
|
borderTopLeftRadius: m.mine ? 18 : 6,
|
||||||
|
paddingHorizontal: 14,
|
||||||
|
paddingTop: 9, paddingBottom: 7,
|
||||||
|
borderWidth: m.mine ? 0 : 1,
|
||||||
|
borderColor: C.line,
|
||||||
|
}}>
|
||||||
|
<Text style={{
|
||||||
|
color: m.mine ? C.bg : C.text,
|
||||||
|
fontSize: 15, lineHeight: 21,
|
||||||
|
}}>
|
||||||
|
{m.text}
|
||||||
|
</Text>
|
||||||
|
<View style={{
|
||||||
|
flexDirection: 'row', alignItems: 'center', justifyContent: 'flex-end',
|
||||||
|
marginTop: 3, gap: 4,
|
||||||
|
}}>
|
||||||
|
<Text style={{
|
||||||
|
color: m.mine ? 'rgba(11,18,32,0.65)' : C.muted,
|
||||||
|
fontSize: 10,
|
||||||
|
}}>
|
||||||
|
{formatTime(m.timestamp)}
|
||||||
|
</Text>
|
||||||
|
{m.mine && (
|
||||||
|
<Ionicons
|
||||||
|
name="checkmark-done"
|
||||||
|
size={12}
|
||||||
|
color="rgba(11,18,32,0.65)"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<KeyboardAvoidingView
|
||||||
|
style={{ flex: 1, backgroundColor: C.bg }}
|
||||||
|
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
|
||||||
|
keyboardVerticalOffset={0}
|
||||||
|
>
|
||||||
|
{/* ── Header ── */}
|
||||||
|
<View style={{
|
||||||
|
flexDirection: 'row', alignItems: 'center',
|
||||||
|
paddingHorizontal: 10,
|
||||||
|
paddingTop: insets.top + 8,
|
||||||
|
paddingBottom: 10,
|
||||||
|
borderBottomWidth: 1, borderBottomColor: C.line,
|
||||||
|
backgroundColor: C.surface,
|
||||||
|
}}>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => router.back()}
|
||||||
|
activeOpacity={0.6}
|
||||||
|
style={{ padding: 8, marginRight: 4 }}
|
||||||
|
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
|
||||||
|
>
|
||||||
|
<Ionicons name="chevron-back" size={24} color={C.accent} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
<View style={{ marginRight: 10, position: 'relative' }}>
|
||||||
|
<Avatar name={displayName} size="sm" />
|
||||||
|
{contact?.x25519Pub && (
|
||||||
|
<View style={{
|
||||||
|
position: 'absolute', right: -2, bottom: -2,
|
||||||
|
width: 12, height: 12, borderRadius: 6,
|
||||||
|
backgroundColor: C.ok,
|
||||||
|
borderWidth: 2, borderColor: C.surface,
|
||||||
|
}} />
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<Text style={{ color: C.text, fontWeight: '600', fontSize: 15 }} numberOfLines={1}>
|
||||||
|
{displayName}
|
||||||
|
</Text>
|
||||||
|
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 4, marginTop: 1 }}>
|
||||||
|
{peerTyping ? (
|
||||||
|
<>
|
||||||
|
<Ionicons name="ellipsis-horizontal" size={12} color={C.accent} />
|
||||||
|
<Text style={{ color: C.accent, fontSize: 11, fontWeight: '500' }}>печатает…</Text>
|
||||||
|
</>
|
||||||
|
) : contact?.x25519Pub ? (
|
||||||
|
<>
|
||||||
|
<Ionicons name="lock-closed" size={10} color={C.ok} />
|
||||||
|
<Text style={{ color: C.ok, fontSize: 11, fontWeight: '500' }}>E2E шифрование</Text>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Ionicons name="time-outline" size={10} color={C.warn} />
|
||||||
|
<Text style={{ color: C.warn, fontSize: 11 }}>Ожидание ключа шифрования</Text>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
activeOpacity={0.6}
|
||||||
|
style={{ padding: 8 }}
|
||||||
|
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
|
||||||
|
>
|
||||||
|
<Ionicons name="ellipsis-vertical" size={18} color={C.muted} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* ── Messages ── */}
|
||||||
|
<FlatList
|
||||||
|
ref={listRef}
|
||||||
|
data={rows}
|
||||||
|
keyExtractor={r => r.kind === 'sep' ? r.id : r.msg.id}
|
||||||
|
renderItem={renderRow}
|
||||||
|
contentContainerStyle={{ paddingTop: 14, paddingBottom: 10, flexGrow: 1 }}
|
||||||
|
onContentSizeChange={() => listRef.current?.scrollToEnd({ animated: false })}
|
||||||
|
ListEmptyComponent={() => (
|
||||||
|
<View style={{ flex: 1, alignItems: 'center', justifyContent: 'center', paddingHorizontal: 32, gap: 14 }}>
|
||||||
|
<View style={{
|
||||||
|
width: 76, height: 76, borderRadius: 38,
|
||||||
|
backgroundColor: 'rgba(125,181,255,0.08)',
|
||||||
|
alignItems: 'center', justifyContent: 'center',
|
||||||
|
}}>
|
||||||
|
<Ionicons name="lock-closed-outline" size={34} color={C.accent} />
|
||||||
|
</View>
|
||||||
|
<Text style={{ color: C.text, fontSize: 15, fontWeight: '600', textAlign: 'center' }}>
|
||||||
|
Начните разговор
|
||||||
|
</Text>
|
||||||
|
<Text style={{ color: C.muted, fontSize: 13, textAlign: 'center', lineHeight: 20 }}>
|
||||||
|
Сообщения зашифрованы end-to-end.{'\n'}Только вы и {displayName} их прочитаете.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* ── Input bar ── */}
|
||||||
|
<View style={{
|
||||||
|
flexDirection: 'row', alignItems: 'flex-end', gap: 8,
|
||||||
|
paddingHorizontal: 10,
|
||||||
|
paddingTop: 8,
|
||||||
|
paddingBottom: Math.max(insets.bottom, 8),
|
||||||
|
borderTopWidth: 1, borderTopColor: C.line,
|
||||||
|
backgroundColor: C.surface,
|
||||||
|
}}>
|
||||||
|
<View style={{
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: C.surface2,
|
||||||
|
borderRadius: 22,
|
||||||
|
borderWidth: 1, borderColor: C.line,
|
||||||
|
paddingHorizontal: 14, paddingVertical: 8,
|
||||||
|
minHeight: 42, maxHeight: 140,
|
||||||
|
justifyContent: 'center',
|
||||||
|
}}>
|
||||||
|
<TextInput
|
||||||
|
value={text}
|
||||||
|
onChangeText={handleTextChange}
|
||||||
|
placeholder="Сообщение…"
|
||||||
|
placeholderTextColor={C.muted}
|
||||||
|
multiline
|
||||||
|
maxLength={2000}
|
||||||
|
style={{
|
||||||
|
color: C.text, fontSize: 15, lineHeight: 21,
|
||||||
|
// iOS needs explicit padding:0 to avoid extra vertical space
|
||||||
|
paddingTop: 0, paddingBottom: 0,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Pressable
|
||||||
|
onPress={send}
|
||||||
|
disabled={!canSend}
|
||||||
|
style={({ pressed }) => ({
|
||||||
|
width: 42, height: 42, borderRadius: 21,
|
||||||
|
backgroundColor: canSend ? C.accent : C.surface2,
|
||||||
|
alignItems: 'center', justifyContent: 'center',
|
||||||
|
flexShrink: 0,
|
||||||
|
opacity: pressed && canSend ? 0.7 : 1,
|
||||||
|
transform: [{ scale: pressed && canSend ? 0.95 : 1 }],
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{sending
|
||||||
|
? <ActivityIndicator color={C.bg} size="small" />
|
||||||
|
: <Ionicons
|
||||||
|
name="send"
|
||||||
|
size={18}
|
||||||
|
color={canSend ? C.bg : C.muted}
|
||||||
|
style={{ marginLeft: 2 }} // visual centre of the paper-plane glyph
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
</KeyboardAvoidingView>
|
||||||
|
);
|
||||||
|
}
|
||||||
7
client-app/app/(app)/chats/_layout.tsx
Normal file
7
client-app/app/(app)/chats/_layout.tsx
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { Stack } from 'expo-router';
|
||||||
|
|
||||||
|
export default function ChatsLayout() {
|
||||||
|
return (
|
||||||
|
<Stack screenOptions={{ headerShown: false }} />
|
||||||
|
);
|
||||||
|
}
|
||||||
337
client-app/app/(app)/chats/index.tsx
Normal file
337
client-app/app/(app)/chats/index.tsx
Normal file
@@ -0,0 +1,337 @@
|
|||||||
|
/**
|
||||||
|
* Chat list — DChain messenger.
|
||||||
|
* Safe-area aware, Ionicons, polished empty states, responsive press feedback.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useCallback, useMemo, useEffect, useState } from 'react';
|
||||||
|
import { View, Text, FlatList, Pressable, TouchableOpacity } from 'react-native';
|
||||||
|
import { router } from 'expo-router';
|
||||||
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
|
import { useStore } from '@/lib/store';
|
||||||
|
import { getWSClient } from '@/lib/ws';
|
||||||
|
import { Avatar } from '@/components/ui/Avatar';
|
||||||
|
import { formatTime, formatAmount } from '@/lib/utils';
|
||||||
|
import type { Contact } from '@/lib/types';
|
||||||
|
|
||||||
|
const MIN_FEE = 5000;
|
||||||
|
|
||||||
|
// ─── Design tokens ────────────────────────────────────────────────────────────
|
||||||
|
const C = {
|
||||||
|
bg: '#0b1220',
|
||||||
|
surface: '#111a2b',
|
||||||
|
surface2: '#162035',
|
||||||
|
surface3: '#1a2640',
|
||||||
|
line: '#1c2840',
|
||||||
|
text: '#e6edf9',
|
||||||
|
muted: '#98a7c2',
|
||||||
|
accent: '#7db5ff',
|
||||||
|
ok: '#41c98a',
|
||||||
|
warn: '#f0b35a',
|
||||||
|
err: '#ff7a87',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Truncate a message preview without breaking words too awkwardly. */
|
||||||
|
function previewText(s: string, max = 60): string {
|
||||||
|
if (s.length <= max) return s;
|
||||||
|
return s.slice(0, max).trimEnd() + '…';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Short address helper matching the rest of the app. */
|
||||||
|
function shortAddr(a: string, n = 6): string {
|
||||||
|
if (!a) return '—';
|
||||||
|
return a.length <= n * 2 + 1 ? a : `${a.slice(0, n)}…${a.slice(-n)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ChatsScreen() {
|
||||||
|
const contacts = useStore(s => s.contacts);
|
||||||
|
const messages = useStore(s => s.messages);
|
||||||
|
const requests = useStore(s => s.requests);
|
||||||
|
const balance = useStore(s => s.balance);
|
||||||
|
const keyFile = useStore(s => s.keyFile);
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
|
// Real-time transport indicator (green dot when WS is live, yellow when
|
||||||
|
// using HTTP polling fallback).
|
||||||
|
const [wsLive, setWsLive] = useState(false);
|
||||||
|
useEffect(() => {
|
||||||
|
const ws = getWSClient();
|
||||||
|
setWsLive(ws.isConnected());
|
||||||
|
return ws.onConnectionChange(ok => setWsLive(ok));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const hasBalance = balance >= MIN_FEE;
|
||||||
|
|
||||||
|
const displayName = (c: Contact) =>
|
||||||
|
c.username ? `@${c.username}` : c.alias ?? shortAddr(c.address, 5);
|
||||||
|
|
||||||
|
const lastMsg = (c: Contact) => {
|
||||||
|
const msgs = messages[c.address];
|
||||||
|
return msgs?.length ? msgs[msgs.length - 1] : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Sort contacts: most recent activity first.
|
||||||
|
const sortedContacts = useMemo(() => {
|
||||||
|
const withTime = contacts.map(c => {
|
||||||
|
const last = lastMsg(c);
|
||||||
|
return {
|
||||||
|
contact: c,
|
||||||
|
sortKey: last ? last.timestamp : (c.addedAt / 1000),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
return withTime
|
||||||
|
.sort((a, b) => b.sortKey - a.sortKey)
|
||||||
|
.map(x => x.contact);
|
||||||
|
}, [contacts, messages]);
|
||||||
|
|
||||||
|
const renderItem = useCallback(({ item: c, index }: { item: Contact; index: number }) => {
|
||||||
|
const last = lastMsg(c);
|
||||||
|
const name = displayName(c);
|
||||||
|
const hasKey = !!c.x25519Pub;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
onPress={() => router.push(`/(app)/chats/${c.address}`)}
|
||||||
|
android_ripple={{ color: C.surface2 }}
|
||||||
|
style={({ pressed }) => ({
|
||||||
|
flexDirection: 'row', alignItems: 'center',
|
||||||
|
paddingHorizontal: 14, paddingVertical: 12,
|
||||||
|
borderTopWidth: index === 0 ? 0 : 1, borderTopColor: C.line,
|
||||||
|
backgroundColor: pressed ? C.surface2 : 'transparent',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{/* Avatar with E2E status pip */}
|
||||||
|
<View style={{ position: 'relative', marginRight: 12 }}>
|
||||||
|
<Avatar name={name} size="md" />
|
||||||
|
{hasKey && (
|
||||||
|
<View style={{
|
||||||
|
position: 'absolute', right: -2, bottom: -2,
|
||||||
|
width: 14, height: 14, borderRadius: 7,
|
||||||
|
backgroundColor: C.ok,
|
||||||
|
borderWidth: 2, borderColor: C.bg,
|
||||||
|
alignItems: 'center', justifyContent: 'center',
|
||||||
|
}}>
|
||||||
|
<Ionicons name="lock-closed" size={7} color="#0b1220" />
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Text column */}
|
||||||
|
<View style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
|
||||||
|
<Text
|
||||||
|
style={{ color: C.text, fontWeight: '600', fontSize: 15, flex: 1 }}
|
||||||
|
numberOfLines={1}
|
||||||
|
>
|
||||||
|
{name}
|
||||||
|
</Text>
|
||||||
|
{last && (
|
||||||
|
<Text style={{ color: C.muted, fontSize: 11, marginLeft: 8 }}>
|
||||||
|
{formatTime(last.timestamp)}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
<View style={{ flexDirection: 'row', alignItems: 'center', marginTop: 2 }}>
|
||||||
|
{last?.mine && (
|
||||||
|
<Ionicons
|
||||||
|
name="checkmark-done-outline"
|
||||||
|
size={13}
|
||||||
|
color={C.muted}
|
||||||
|
style={{ marginRight: 4 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Text
|
||||||
|
style={{ color: C.muted, fontSize: 13, flex: 1 }}
|
||||||
|
numberOfLines={1}
|
||||||
|
>
|
||||||
|
{last ? previewText(last.text) : (hasKey ? 'Напишите первое сообщение' : 'Ожидание публикации ключа…')}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
}, [messages, lastMsg]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={{ flex: 1, backgroundColor: C.bg }}>
|
||||||
|
|
||||||
|
{/* ── Header ── */}
|
||||||
|
<View style={{
|
||||||
|
flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between',
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
paddingTop: insets.top + 10,
|
||||||
|
paddingBottom: 14,
|
||||||
|
borderBottomWidth: 1, borderBottomColor: C.line,
|
||||||
|
}}>
|
||||||
|
<View>
|
||||||
|
<Text style={{ color: C.text, fontSize: 22, fontWeight: '700', letterSpacing: -0.3 }}>
|
||||||
|
Сообщения
|
||||||
|
</Text>
|
||||||
|
{contacts.length > 0 && (
|
||||||
|
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 5, marginTop: 2 }}>
|
||||||
|
<Text style={{ color: C.muted, fontSize: 12 }}>
|
||||||
|
{contacts.length} {contacts.length === 1 ? 'контакт' : contacts.length < 5 ? 'контакта' : 'контактов'}
|
||||||
|
{' · '}
|
||||||
|
<Text style={{ color: C.ok }}>E2E</Text>
|
||||||
|
{' · '}
|
||||||
|
</Text>
|
||||||
|
<View style={{
|
||||||
|
width: 6, height: 6, borderRadius: 3,
|
||||||
|
backgroundColor: wsLive ? C.ok : C.warn,
|
||||||
|
}} />
|
||||||
|
<Text style={{ color: wsLive ? C.ok : C.warn, fontSize: 11 }}>
|
||||||
|
{wsLive ? 'live' : 'polling'}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={{ flexDirection: 'row', gap: 8, alignItems: 'center' }}>
|
||||||
|
{/* Incoming requests chip */}
|
||||||
|
{requests.length > 0 && (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => router.push('/(app)/requests')}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
style={{
|
||||||
|
flexDirection: 'row', alignItems: 'center', gap: 6,
|
||||||
|
backgroundColor: 'rgba(125,181,255,0.14)', borderRadius: 999,
|
||||||
|
paddingHorizontal: 12, paddingVertical: 7,
|
||||||
|
borderWidth: 1, borderColor: 'rgba(125,181,255,0.25)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Ionicons name="mail-unread-outline" size={14} color={C.accent} />
|
||||||
|
<Text style={{ color: C.accent, fontSize: 12, fontWeight: '600' }}>
|
||||||
|
{requests.length}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Add contact button */}
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => hasBalance ? router.push('/(app)/new-contact') : router.push('/(app)/wallet')}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
style={{
|
||||||
|
width: 38, height: 38, borderRadius: 19,
|
||||||
|
backgroundColor: hasBalance ? C.accent : C.surface2,
|
||||||
|
alignItems: 'center', justifyContent: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Ionicons
|
||||||
|
name={hasBalance ? 'add-outline' : 'lock-closed-outline'}
|
||||||
|
size={20}
|
||||||
|
color={hasBalance ? C.bg : C.muted}
|
||||||
|
/>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* ── No balance gate (no contacts) ── */}
|
||||||
|
{!hasBalance && contacts.length === 0 && (
|
||||||
|
<View style={{
|
||||||
|
margin: 16, padding: 18,
|
||||||
|
backgroundColor: 'rgba(125,181,255,0.07)',
|
||||||
|
borderRadius: 14,
|
||||||
|
borderWidth: 1, borderColor: 'rgba(125,181,255,0.15)',
|
||||||
|
}}>
|
||||||
|
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 10, marginBottom: 8 }}>
|
||||||
|
<View style={{
|
||||||
|
width: 32, height: 32, borderRadius: 16,
|
||||||
|
backgroundColor: 'rgba(125,181,255,0.18)',
|
||||||
|
alignItems: 'center', justifyContent: 'center',
|
||||||
|
}}>
|
||||||
|
<Ionicons name="diamond-outline" size={16} color={C.accent} />
|
||||||
|
</View>
|
||||||
|
<Text style={{ color: C.text, fontWeight: '700', fontSize: 15 }}>
|
||||||
|
Пополните баланс
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Text style={{ color: C.muted, fontSize: 13, lineHeight: 20, marginBottom: 14 }}>
|
||||||
|
Отправка запроса контакта стоит{' '}
|
||||||
|
<Text style={{ color: C.text, fontWeight: '600' }}>{formatAmount(MIN_FEE)}</Text>
|
||||||
|
{' '}— антиспам-сбор идёт напрямую получателю.
|
||||||
|
</Text>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => router.push('/(app)/wallet')}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
style={{
|
||||||
|
flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 8,
|
||||||
|
paddingVertical: 11, borderRadius: 10,
|
||||||
|
backgroundColor: C.accent,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Ionicons name="wallet-outline" size={16} color={C.bg} />
|
||||||
|
<Text style={{ color: C.bg, fontWeight: '700', fontSize: 13 }}>Перейти в кошелёк</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Low balance warning (has contacts) ── */}
|
||||||
|
{!hasBalance && contacts.length > 0 && (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => router.push('/(app)/wallet')}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
style={{
|
||||||
|
flexDirection: 'row', alignItems: 'center', gap: 10,
|
||||||
|
marginHorizontal: 16, marginTop: 12,
|
||||||
|
backgroundColor: 'rgba(255,122,135,0.08)',
|
||||||
|
borderRadius: 10, paddingHorizontal: 12, paddingVertical: 10,
|
||||||
|
borderWidth: 1, borderColor: 'rgba(255,122,135,0.18)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Ionicons name="warning-outline" size={16} color={C.err} />
|
||||||
|
<Text style={{ color: C.err, fontSize: 13, flex: 1 }}>
|
||||||
|
Недостаточно токенов для добавления контакта
|
||||||
|
</Text>
|
||||||
|
<Ionicons name="chevron-forward" size={14} color={C.err} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Empty state (has balance, no contacts) ── */}
|
||||||
|
{contacts.length === 0 && hasBalance && (
|
||||||
|
<View style={{ flex: 1, alignItems: 'center', justifyContent: 'center', paddingHorizontal: 32 }}>
|
||||||
|
<View style={{
|
||||||
|
width: 88, height: 88, borderRadius: 44,
|
||||||
|
backgroundColor: C.surface,
|
||||||
|
alignItems: 'center', justifyContent: 'center',
|
||||||
|
marginBottom: 18,
|
||||||
|
}}>
|
||||||
|
<Ionicons name="chatbubbles-outline" size={40} color={C.accent} />
|
||||||
|
</View>
|
||||||
|
<Text style={{ color: C.text, fontSize: 19, fontWeight: '700', textAlign: 'center', marginBottom: 8 }}>
|
||||||
|
Нет диалогов
|
||||||
|
</Text>
|
||||||
|
<Text style={{ color: C.muted, fontSize: 14, textAlign: 'center', lineHeight: 22, marginBottom: 20 }}>
|
||||||
|
Добавьте контакт по адресу или{' '}
|
||||||
|
<Text style={{ color: C.accent, fontWeight: '600' }}>@username</Text>
|
||||||
|
{' '}для начала зашифрованной переписки.
|
||||||
|
</Text>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => router.push('/(app)/new-contact')}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
style={{
|
||||||
|
flexDirection: 'row', alignItems: 'center', gap: 8,
|
||||||
|
paddingHorizontal: 22, paddingVertical: 12, borderRadius: 12,
|
||||||
|
backgroundColor: C.accent,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Ionicons name="person-add-outline" size={16} color={C.bg} />
|
||||||
|
<Text style={{ color: C.bg, fontWeight: '700', fontSize: 14 }}>Добавить контакт</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Chat list ── */}
|
||||||
|
{contacts.length > 0 && (
|
||||||
|
<FlatList
|
||||||
|
data={sortedContacts}
|
||||||
|
keyExtractor={c => c.address}
|
||||||
|
renderItem={renderItem}
|
||||||
|
contentContainerStyle={{ paddingBottom: 20 }}
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
332
client-app/app/(app)/new-contact.tsx
Normal file
332
client-app/app/(app)/new-contact.tsx
Normal file
@@ -0,0 +1,332 @@
|
|||||||
|
/**
|
||||||
|
* Add New Contact — DChain explorer design style.
|
||||||
|
* Sends CONTACT_REQUEST on-chain with correct amount/fee fields.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import {
|
||||||
|
View, Text, ScrollView, Alert, TouchableOpacity, TextInput,
|
||||||
|
} from 'react-native';
|
||||||
|
import { router } from 'expo-router';
|
||||||
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
|
import { useStore } from '@/lib/store';
|
||||||
|
import { getIdentity, buildContactRequestTx, submitTx } from '@/lib/api';
|
||||||
|
import { shortAddr } from '@/lib/crypto';
|
||||||
|
import { formatAmount } from '@/lib/utils';
|
||||||
|
import { Avatar } from '@/components/ui/Avatar';
|
||||||
|
|
||||||
|
const C = {
|
||||||
|
bg: '#0b1220',
|
||||||
|
surface: '#111a2b',
|
||||||
|
surface2:'#162035',
|
||||||
|
line: '#1c2840',
|
||||||
|
text: '#e6edf9',
|
||||||
|
muted: '#98a7c2',
|
||||||
|
accent: '#7db5ff',
|
||||||
|
ok: '#41c98a',
|
||||||
|
warn: '#f0b35a',
|
||||||
|
err: '#ff7a87',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const MIN_CONTACT_FEE = 5000;
|
||||||
|
|
||||||
|
const FEE_OPTIONS = [
|
||||||
|
{ label: '5 000 µT', value: 5_000, note: 'минимум' },
|
||||||
|
{ label: '10 000 µT', value: 10_000, note: 'стандарт' },
|
||||||
|
{ label: '50 000 µT', value: 50_000, note: 'приоритет' },
|
||||||
|
];
|
||||||
|
|
||||||
|
interface Resolved {
|
||||||
|
address: string;
|
||||||
|
nickname?: string;
|
||||||
|
x25519?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function NewContactScreen() {
|
||||||
|
const keyFile = useStore(s => s.keyFile);
|
||||||
|
const settings = useStore(s => s.settings);
|
||||||
|
const balance = useStore(s => s.balance);
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
|
const [query, setQuery] = useState('');
|
||||||
|
const [intro, setIntro] = useState('');
|
||||||
|
const [fee, setFee] = useState(MIN_CONTACT_FEE);
|
||||||
|
const [resolved, setResolved] = useState<Resolved | null>(null);
|
||||||
|
const [searching, setSearching] = useState(false);
|
||||||
|
const [sending, setSending] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
async function search() {
|
||||||
|
const q = query.trim();
|
||||||
|
if (!q) return;
|
||||||
|
setSearching(true);
|
||||||
|
setResolved(null);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
let address = q;
|
||||||
|
|
||||||
|
// @username lookup via registry contract
|
||||||
|
if (q.startsWith('@') || (!q.match(/^[0-9a-f]{64}$/i) && !q.startsWith('DC'))) {
|
||||||
|
const name = q.replace('@', '');
|
||||||
|
const { resolveUsername } = await import('@/lib/api');
|
||||||
|
const addr = await resolveUsername(settings.contractId, name);
|
||||||
|
if (!addr) {
|
||||||
|
setError(`@${name} не зарегистрирован в сети.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
address = addr;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch identity to get nickname and x25519 key
|
||||||
|
const identity = await getIdentity(address);
|
||||||
|
setResolved({
|
||||||
|
address: identity?.pub_key ?? address,
|
||||||
|
nickname: identity?.nickname || undefined,
|
||||||
|
x25519: identity?.x25519_pub || undefined,
|
||||||
|
});
|
||||||
|
} catch (e: any) {
|
||||||
|
setError(e.message);
|
||||||
|
} finally {
|
||||||
|
setSearching(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendRequest() {
|
||||||
|
if (!resolved || !keyFile) return;
|
||||||
|
if (balance < fee + 1000) {
|
||||||
|
Alert.alert('Недостаточно средств', `Нужно ${formatAmount(fee + 1000)} (fee + комиссия сети).`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSending(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const tx = buildContactRequestTx({
|
||||||
|
from: keyFile.pub_key,
|
||||||
|
to: resolved.address,
|
||||||
|
contactFee: fee,
|
||||||
|
intro: intro.trim() || undefined,
|
||||||
|
privKey: keyFile.priv_key,
|
||||||
|
});
|
||||||
|
await submitTx(tx);
|
||||||
|
Alert.alert(
|
||||||
|
'Запрос отправлен',
|
||||||
|
`Контакту ${resolved.nickname ? '@' + resolved.nickname : shortAddr(resolved.address)} отправлен запрос.`,
|
||||||
|
[{ text: 'OK', onPress: () => router.back() }],
|
||||||
|
);
|
||||||
|
} catch (e: any) {
|
||||||
|
setError(e.message);
|
||||||
|
} finally {
|
||||||
|
setSending(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const displayName = resolved
|
||||||
|
? (resolved.nickname ? `@${resolved.nickname}` : shortAddr(resolved.address))
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollView
|
||||||
|
style={{ flex: 1, backgroundColor: C.bg }}
|
||||||
|
contentContainerStyle={{
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
paddingTop: insets.top + 8,
|
||||||
|
paddingBottom: Math.max(insets.bottom, 32),
|
||||||
|
}}
|
||||||
|
keyboardShouldPersistTaps="handled"
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 6, marginBottom: 24, marginLeft: -8 }}>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => router.back()}
|
||||||
|
activeOpacity={0.6}
|
||||||
|
style={{ padding: 8 }}
|
||||||
|
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
|
||||||
|
>
|
||||||
|
<Ionicons name="chevron-back" size={24} color={C.accent} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
<Text style={{ color: C.text, fontSize: 20, fontWeight: '700' }}>Добавить контакт</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Search */}
|
||||||
|
<Text style={{ color: C.muted, fontSize: 11, letterSpacing: 1, textTransform: 'uppercase', marginBottom: 8 }}>
|
||||||
|
Адрес или @username
|
||||||
|
</Text>
|
||||||
|
<View style={{ flexDirection: 'row', gap: 8, marginBottom: 16 }}>
|
||||||
|
<View style={{
|
||||||
|
flex: 1, backgroundColor: C.surface, borderRadius: 10,
|
||||||
|
paddingHorizontal: 12, paddingVertical: 2,
|
||||||
|
}}>
|
||||||
|
<TextInput
|
||||||
|
value={query}
|
||||||
|
onChangeText={t => { setQuery(t); setError(null); }}
|
||||||
|
onSubmitEditing={search}
|
||||||
|
placeholder="@username или 64-символьный hex"
|
||||||
|
placeholderTextColor={C.muted}
|
||||||
|
autoCapitalize="none"
|
||||||
|
autoCorrect={false}
|
||||||
|
style={{ color: C.text, fontSize: 14, height: 44 }}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={search}
|
||||||
|
disabled={searching || !query.trim()}
|
||||||
|
style={{
|
||||||
|
paddingHorizontal: 16, borderRadius: 10,
|
||||||
|
backgroundColor: searching || !query.trim() ? C.surface2 : C.accent,
|
||||||
|
alignItems: 'center', justifyContent: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text style={{ color: searching || !query.trim() ? C.muted : '#0b1220', fontWeight: '700', fontSize: 14 }}>
|
||||||
|
{searching ? '…' : 'Найти'}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Error */}
|
||||||
|
{error && (
|
||||||
|
<View style={{
|
||||||
|
backgroundColor: 'rgba(255,122,135,0.08)', borderRadius: 10,
|
||||||
|
paddingHorizontal: 12, paddingVertical: 10, marginBottom: 16,
|
||||||
|
}}>
|
||||||
|
<Text style={{ color: C.err, fontSize: 13 }}>⚠ {error}</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Resolved contact card */}
|
||||||
|
{resolved && (
|
||||||
|
<View style={{
|
||||||
|
backgroundColor: C.surface, borderRadius: 12,
|
||||||
|
padding: 14, marginBottom: 20,
|
||||||
|
flexDirection: 'row', alignItems: 'center', gap: 12,
|
||||||
|
}}>
|
||||||
|
<Avatar name={displayName ?? resolved.address} size="md" />
|
||||||
|
<View style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
{resolved.nickname && (
|
||||||
|
<Text style={{ color: C.text, fontWeight: '600', fontSize: 15 }}>
|
||||||
|
@{resolved.nickname}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
<Text style={{ color: C.muted, fontFamily: 'monospace', fontSize: 11 }} numberOfLines={1}>
|
||||||
|
{resolved.address}
|
||||||
|
</Text>
|
||||||
|
{resolved.x25519 && (
|
||||||
|
<Text style={{ color: C.ok, fontSize: 11, marginTop: 2 }}>
|
||||||
|
✓ Зашифрованные сообщения поддерживаются
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
<View style={{
|
||||||
|
paddingHorizontal: 8, paddingVertical: 3,
|
||||||
|
backgroundColor: 'rgba(65,201,138,0.12)', borderRadius: 999,
|
||||||
|
}}>
|
||||||
|
<Text style={{ color: C.ok, fontSize: 11, fontWeight: '600' }}>Найден</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Intro + fee (shown after search succeeds) */}
|
||||||
|
{resolved && (
|
||||||
|
<>
|
||||||
|
{/* Intro */}
|
||||||
|
<Text style={{ color: C.muted, fontSize: 11, letterSpacing: 1, textTransform: 'uppercase', marginBottom: 8 }}>
|
||||||
|
Сообщение (необязательно)
|
||||||
|
</Text>
|
||||||
|
<View style={{
|
||||||
|
backgroundColor: C.surface, borderRadius: 10,
|
||||||
|
paddingHorizontal: 12, paddingVertical: 8, marginBottom: 20,
|
||||||
|
}}>
|
||||||
|
<TextInput
|
||||||
|
value={intro}
|
||||||
|
onChangeText={setIntro}
|
||||||
|
placeholder="Привет, хочу добавить тебя в контакты…"
|
||||||
|
placeholderTextColor={C.muted}
|
||||||
|
multiline
|
||||||
|
numberOfLines={3}
|
||||||
|
maxLength={280}
|
||||||
|
style={{
|
||||||
|
color: C.text, fontSize: 14, lineHeight: 20,
|
||||||
|
minHeight: 72, textAlignVertical: 'top',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Text style={{ color: C.muted, fontSize: 11, textAlign: 'right', marginTop: 4 }}>
|
||||||
|
{intro.length}/280
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Fee selector */}
|
||||||
|
<Text style={{ color: C.muted, fontSize: 11, letterSpacing: 1, textTransform: 'uppercase', marginBottom: 8 }}>
|
||||||
|
Антиспам-сбор (отправляется контакту)
|
||||||
|
</Text>
|
||||||
|
<View style={{ flexDirection: 'row', gap: 8, marginBottom: 8 }}>
|
||||||
|
{FEE_OPTIONS.map(opt => (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={opt.value}
|
||||||
|
onPress={() => setFee(opt.value)}
|
||||||
|
style={{
|
||||||
|
flex: 1, borderRadius: 10, paddingVertical: 10, alignItems: 'center',
|
||||||
|
backgroundColor: fee === opt.value
|
||||||
|
? 'rgba(125,181,255,0.15)' : C.surface,
|
||||||
|
borderWidth: fee === opt.value ? 1 : 0,
|
||||||
|
borderColor: fee === opt.value ? C.accent : 'transparent',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text style={{
|
||||||
|
color: fee === opt.value ? C.accent : C.text,
|
||||||
|
fontWeight: '600', fontSize: 13,
|
||||||
|
}}>
|
||||||
|
{opt.label}
|
||||||
|
</Text>
|
||||||
|
<Text style={{ color: C.muted, fontSize: 11, marginTop: 2 }}>{opt.note}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Balance info */}
|
||||||
|
<View style={{
|
||||||
|
flexDirection: 'row', alignItems: 'center', gap: 8,
|
||||||
|
backgroundColor: C.surface, borderRadius: 8,
|
||||||
|
paddingHorizontal: 12, paddingVertical: 9, marginBottom: 20,
|
||||||
|
}}>
|
||||||
|
<Text style={{ color: C.muted, fontSize: 13 }}>Ваш баланс:</Text>
|
||||||
|
<Text style={{ color: balance >= fee + 1000 ? C.text : C.err, fontSize: 13, fontWeight: '600' }}>
|
||||||
|
{formatAmount(balance)}
|
||||||
|
</Text>
|
||||||
|
<Text style={{ color: C.muted, fontSize: 13, marginLeft: 'auto' as any }}>
|
||||||
|
Итого: {formatAmount(fee + 1000)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Send button */}
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={sendRequest}
|
||||||
|
disabled={sending || balance < fee + 1000}
|
||||||
|
style={{
|
||||||
|
paddingVertical: 15, borderRadius: 10, alignItems: 'center',
|
||||||
|
backgroundColor: (sending || balance < fee + 1000) ? C.surface2 : C.accent,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text style={{
|
||||||
|
color: (sending || balance < fee + 1000) ? C.muted : '#0b1220',
|
||||||
|
fontWeight: '700', fontSize: 15,
|
||||||
|
}}>
|
||||||
|
{sending ? 'Отправка…' : 'Отправить запрос'}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Hint when no search yet */}
|
||||||
|
{!resolved && !error && (
|
||||||
|
<View style={{
|
||||||
|
backgroundColor: C.surface, borderRadius: 12,
|
||||||
|
padding: 16, marginTop: 8, alignItems: 'center',
|
||||||
|
}}>
|
||||||
|
<Text style={{ color: C.muted, fontSize: 14, textAlign: 'center', lineHeight: 21 }}>
|
||||||
|
Введите @username или вставьте 64-символьный hex-адрес пользователя DChain.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</ScrollView>
|
||||||
|
);
|
||||||
|
}
|
||||||
236
client-app/app/(app)/requests.tsx
Normal file
236
client-app/app/(app)/requests.tsx
Normal file
@@ -0,0 +1,236 @@
|
|||||||
|
/**
|
||||||
|
* Contact requests screen — DChain explorer design style.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import { View, Text, FlatList, Alert, TouchableOpacity } from 'react-native';
|
||||||
|
import { router } from 'expo-router';
|
||||||
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
|
import { useStore } from '@/lib/store';
|
||||||
|
import { buildAcceptContactTx, submitTx, getIdentity } from '@/lib/api';
|
||||||
|
import { saveContact } from '@/lib/storage';
|
||||||
|
import { shortAddr } from '@/lib/crypto';
|
||||||
|
import { relativeTime } from '@/lib/utils';
|
||||||
|
import { Avatar } from '@/components/ui/Avatar';
|
||||||
|
import type { ContactRequest } from '@/lib/types';
|
||||||
|
|
||||||
|
const C = {
|
||||||
|
bg: '#0b1220',
|
||||||
|
surface: '#111a2b',
|
||||||
|
surface2:'#162035',
|
||||||
|
line: '#1c2840',
|
||||||
|
text: '#e6edf9',
|
||||||
|
muted: '#98a7c2',
|
||||||
|
accent: '#7db5ff',
|
||||||
|
ok: '#41c98a',
|
||||||
|
warn: '#f0b35a',
|
||||||
|
err: '#ff7a87',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export default function RequestsScreen() {
|
||||||
|
const keyFile = useStore(s => s.keyFile);
|
||||||
|
const requests = useStore(s => s.requests);
|
||||||
|
const setRequests = useStore(s => s.setRequests);
|
||||||
|
const upsertContact = useStore(s => s.upsertContact);
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
|
const [accepting, setAccepting] = useState<string | null>(null);
|
||||||
|
|
||||||
|
async function accept(req: ContactRequest) {
|
||||||
|
if (!keyFile) return;
|
||||||
|
setAccepting(req.txHash);
|
||||||
|
try {
|
||||||
|
// Fetch requester's identity to get their x25519 key for messaging
|
||||||
|
const identity = await getIdentity(req.from);
|
||||||
|
const x25519Pub = identity?.x25519_pub ?? '';
|
||||||
|
|
||||||
|
const tx = buildAcceptContactTx({
|
||||||
|
from: keyFile.pub_key,
|
||||||
|
to: req.from,
|
||||||
|
privKey: keyFile.priv_key,
|
||||||
|
});
|
||||||
|
await submitTx(tx);
|
||||||
|
|
||||||
|
// Save contact with x25519 key (empty if they haven't registered one)
|
||||||
|
const contact = {
|
||||||
|
address: req.from,
|
||||||
|
x25519Pub,
|
||||||
|
username: req.username,
|
||||||
|
addedAt: Date.now(),
|
||||||
|
};
|
||||||
|
upsertContact(contact);
|
||||||
|
await saveContact(contact);
|
||||||
|
|
||||||
|
setRequests(requests.filter(r => r.txHash !== req.txHash));
|
||||||
|
Alert.alert('Принято', `${req.username ? '@' + req.username : shortAddr(req.from)} добавлен в контакты.`);
|
||||||
|
} catch (e: any) {
|
||||||
|
Alert.alert('Ошибка', e.message);
|
||||||
|
} finally {
|
||||||
|
setAccepting(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function decline(req: ContactRequest) {
|
||||||
|
Alert.alert(
|
||||||
|
'Отклонить запрос',
|
||||||
|
`Отклонить запрос от ${req.username ? '@' + req.username : shortAddr(req.from)}?`,
|
||||||
|
[
|
||||||
|
{ text: 'Отмена', style: 'cancel' },
|
||||||
|
{
|
||||||
|
text: 'Отклонить',
|
||||||
|
style: 'destructive',
|
||||||
|
onPress: () => setRequests(requests.filter(r => r.txHash !== req.txHash)),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={{ flex: 1, backgroundColor: C.bg }}>
|
||||||
|
{/* Header */}
|
||||||
|
<View style={{
|
||||||
|
flexDirection: 'row', alignItems: 'center', gap: 10,
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
paddingTop: insets.top + 8, paddingBottom: 12,
|
||||||
|
borderBottomWidth: 1, borderBottomColor: C.line,
|
||||||
|
}}>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => router.back()}
|
||||||
|
activeOpacity={0.6}
|
||||||
|
style={{ padding: 8 }}
|
||||||
|
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
|
||||||
|
>
|
||||||
|
<Ionicons name="chevron-back" size={24} color={C.accent} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
<Text style={{ color: C.text, fontSize: 18, fontWeight: '700', flex: 1 }}>
|
||||||
|
Запросы контактов
|
||||||
|
</Text>
|
||||||
|
{requests.length > 0 && (
|
||||||
|
<View style={{
|
||||||
|
backgroundColor: C.accent, borderRadius: 999,
|
||||||
|
paddingHorizontal: 10, paddingVertical: 3,
|
||||||
|
minWidth: 24, alignItems: 'center',
|
||||||
|
}}>
|
||||||
|
<Text style={{ color: C.bg, fontSize: 12, fontWeight: '700' }}>
|
||||||
|
{requests.length}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{requests.length === 0 ? (
|
||||||
|
<View style={{ flex: 1, alignItems: 'center', justifyContent: 'center', paddingHorizontal: 32 }}>
|
||||||
|
<View style={{
|
||||||
|
width: 80, height: 80, borderRadius: 40,
|
||||||
|
backgroundColor: C.surface,
|
||||||
|
alignItems: 'center', justifyContent: 'center',
|
||||||
|
marginBottom: 16,
|
||||||
|
}}>
|
||||||
|
<Ionicons name="mail-outline" size={36} color={C.muted} />
|
||||||
|
</View>
|
||||||
|
<Text style={{ color: C.text, fontSize: 17, fontWeight: '600', marginBottom: 6 }}>
|
||||||
|
Нет входящих запросов
|
||||||
|
</Text>
|
||||||
|
<Text style={{ color: C.muted, fontSize: 13, textAlign: 'center', lineHeight: 20 }}>
|
||||||
|
Когда кто-то пришлёт вам запрос в контакты, он появится здесь.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<FlatList
|
||||||
|
data={requests}
|
||||||
|
keyExtractor={r => r.txHash}
|
||||||
|
contentContainerStyle={{ padding: 14, paddingBottom: 24, gap: 12 }}
|
||||||
|
renderItem={({ item: req }) => (
|
||||||
|
<RequestCard
|
||||||
|
req={req}
|
||||||
|
isAccepting={accepting === req.txHash}
|
||||||
|
onAccept={() => accept(req)}
|
||||||
|
onDecline={() => decline(req)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function RequestCard({
|
||||||
|
req, isAccepting, onAccept, onDecline,
|
||||||
|
}: {
|
||||||
|
req: ContactRequest;
|
||||||
|
isAccepting: boolean;
|
||||||
|
onAccept: () => void;
|
||||||
|
onDecline: () => void;
|
||||||
|
}) {
|
||||||
|
const displayName = req.username ? `@${req.username}` : shortAddr(req.from);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={{ backgroundColor: C.surface, borderRadius: 12, padding: 14 }}>
|
||||||
|
{/* Sender info */}
|
||||||
|
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 12, marginBottom: 12 }}>
|
||||||
|
<Avatar name={displayName} size="md" />
|
||||||
|
<View style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<Text style={{ color: C.text, fontWeight: '600', fontSize: 15 }} numberOfLines={1}>
|
||||||
|
{displayName}
|
||||||
|
</Text>
|
||||||
|
<Text style={{ color: C.muted, fontFamily: 'monospace', fontSize: 11 }} numberOfLines={1}>
|
||||||
|
{req.from}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Text style={{ color: C.muted, fontSize: 12 }}>{relativeTime(req.timestamp)}</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Intro message */}
|
||||||
|
{!!req.intro && (
|
||||||
|
<View style={{
|
||||||
|
backgroundColor: C.surface2, borderRadius: 8,
|
||||||
|
paddingHorizontal: 12, paddingVertical: 10, marginBottom: 12,
|
||||||
|
}}>
|
||||||
|
<Text style={{ color: C.muted, fontSize: 11, marginBottom: 4 }}>Приветствие</Text>
|
||||||
|
<Text style={{ color: C.text, fontSize: 13, lineHeight: 19 }}>{req.intro}</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Divider */}
|
||||||
|
<View style={{ height: 1, backgroundColor: C.line, marginBottom: 12 }} />
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<View style={{ flexDirection: 'row', gap: 8 }}>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={onAccept}
|
||||||
|
disabled={isAccepting}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
style={{
|
||||||
|
flex: 1, paddingVertical: 11, borderRadius: 9,
|
||||||
|
flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 6,
|
||||||
|
backgroundColor: isAccepting ? C.surface2 : C.ok,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Ionicons
|
||||||
|
name={isAccepting ? 'hourglass-outline' : 'checkmark-outline'}
|
||||||
|
size={16}
|
||||||
|
color={isAccepting ? C.muted : C.bg}
|
||||||
|
/>
|
||||||
|
<Text style={{ color: isAccepting ? C.muted : C.bg, fontWeight: '700', fontSize: 14 }}>
|
||||||
|
{isAccepting ? 'Принятие…' : 'Принять'}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={onDecline}
|
||||||
|
disabled={isAccepting}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
style={{
|
||||||
|
flex: 1, paddingVertical: 11, borderRadius: 9,
|
||||||
|
flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 6,
|
||||||
|
backgroundColor: 'rgba(255,122,135,0.1)',
|
||||||
|
borderWidth: 1, borderColor: 'rgba(255,122,135,0.18)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Ionicons name="close-outline" size={16} color={C.err} />
|
||||||
|
<Text style={{ color: C.err, fontWeight: '700', fontSize: 14 }}>Отклонить</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
732
client-app/app/(app)/settings.tsx
Normal file
732
client-app/app/(app)/settings.tsx
Normal file
@@ -0,0 +1,732 @@
|
|||||||
|
/**
|
||||||
|
* Settings screen — DChain explorer style, inline styles, Russian locale.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import {
|
||||||
|
View, Text, ScrollView, TextInput, Alert, TouchableOpacity,
|
||||||
|
} from 'react-native';
|
||||||
|
import * as Clipboard from 'expo-clipboard';
|
||||||
|
import * as FileSystem from 'expo-file-system';
|
||||||
|
import * as Sharing from 'expo-sharing';
|
||||||
|
import { router } from 'expo-router';
|
||||||
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
|
import { useStore } from '@/lib/store';
|
||||||
|
import { saveSettings, deleteKeyFile } from '@/lib/storage';
|
||||||
|
import {
|
||||||
|
setNodeUrl, getNetStats, resolveUsername, reverseResolve,
|
||||||
|
buildCallContractTx, submitTx,
|
||||||
|
USERNAME_REGISTRATION_FEE, MIN_USERNAME_LENGTH, MAX_USERNAME_LENGTH,
|
||||||
|
humanizeTxError,
|
||||||
|
} from '@/lib/api';
|
||||||
|
import { shortAddr } from '@/lib/crypto';
|
||||||
|
import { formatAmount } from '@/lib/utils';
|
||||||
|
import { Avatar } from '@/components/ui/Avatar';
|
||||||
|
|
||||||
|
// ─── Design tokens ────────────────────────────────────────────────────────────
|
||||||
|
const C = {
|
||||||
|
bg: '#0b1220',
|
||||||
|
surface: '#111a2b',
|
||||||
|
surface2:'#162035',
|
||||||
|
surface3:'#1a2640',
|
||||||
|
line: '#1c2840',
|
||||||
|
text: '#e6edf9',
|
||||||
|
muted: '#98a7c2',
|
||||||
|
accent: '#7db5ff',
|
||||||
|
ok: '#41c98a',
|
||||||
|
warn: '#f0b35a',
|
||||||
|
err: '#ff7a87',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// ─── Reusable sub-components ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function SectionLabel({ children }: { children: string }) {
|
||||||
|
return (
|
||||||
|
<Text style={{
|
||||||
|
color: C.muted, fontSize: 11, letterSpacing: 1,
|
||||||
|
textTransform: 'uppercase', marginBottom: 8,
|
||||||
|
}}>
|
||||||
|
{children}
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Card({ children, style }: { children: React.ReactNode; style?: object }) {
|
||||||
|
return (
|
||||||
|
<View style={{
|
||||||
|
backgroundColor: C.surface, borderRadius: 12,
|
||||||
|
overflow: 'hidden', marginBottom: 20, ...style,
|
||||||
|
}}>
|
||||||
|
{children}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardRow({
|
||||||
|
icon, label, value, first,
|
||||||
|
}: {
|
||||||
|
icon: keyof typeof Ionicons.glyphMap;
|
||||||
|
label: string;
|
||||||
|
value?: string;
|
||||||
|
first?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<View style={{
|
||||||
|
flexDirection: 'row', alignItems: 'center', gap: 12,
|
||||||
|
paddingHorizontal: 14, paddingVertical: 12,
|
||||||
|
borderTopWidth: first ? 0 : 1, borderTopColor: C.line,
|
||||||
|
}}>
|
||||||
|
<View style={{
|
||||||
|
width: 34, height: 34, borderRadius: 17,
|
||||||
|
backgroundColor: C.surface2,
|
||||||
|
alignItems: 'center', justifyContent: 'center', flexShrink: 0,
|
||||||
|
}}>
|
||||||
|
<Ionicons name={icon} size={16} color={C.muted} />
|
||||||
|
</View>
|
||||||
|
<View style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<Text style={{ color: C.muted, fontSize: 12 }}>{label}</Text>
|
||||||
|
{value !== undefined && (
|
||||||
|
<Text style={{ color: C.text, fontSize: 13, fontWeight: '500', marginTop: 1 }} numberOfLines={1}>
|
||||||
|
{value}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FieldInput({
|
||||||
|
label, value, onChangeText, placeholder, keyboardType, autoCapitalize, autoCorrect,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
onChangeText: (v: string) => void;
|
||||||
|
placeholder?: string;
|
||||||
|
keyboardType?: any;
|
||||||
|
autoCapitalize?: any;
|
||||||
|
autoCorrect?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<View style={{ paddingHorizontal: 14, paddingVertical: 10 }}>
|
||||||
|
<Text style={{ color: C.muted, fontSize: 11, marginBottom: 6 }}>{label}</Text>
|
||||||
|
<View style={{
|
||||||
|
backgroundColor: C.surface2, borderRadius: 8,
|
||||||
|
paddingHorizontal: 12, paddingVertical: 8,
|
||||||
|
borderWidth: 1, borderColor: C.line,
|
||||||
|
}}>
|
||||||
|
<TextInput
|
||||||
|
value={value}
|
||||||
|
onChangeText={onChangeText}
|
||||||
|
placeholder={placeholder}
|
||||||
|
placeholderTextColor={C.muted}
|
||||||
|
keyboardType={keyboardType}
|
||||||
|
autoCapitalize={autoCapitalize ?? 'none'}
|
||||||
|
autoCorrect={autoCorrect ?? false}
|
||||||
|
style={{ color: C.text, fontSize: 14, height: 36 }}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Screen ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export default function SettingsScreen() {
|
||||||
|
const keyFile = useStore(s => s.keyFile);
|
||||||
|
const settings = useStore(s => s.settings);
|
||||||
|
const setSettings = useStore(s => s.setSettings);
|
||||||
|
const username = useStore(s => s.username);
|
||||||
|
const setUsername = useStore(s => s.setUsername);
|
||||||
|
const setKeyFile = useStore(s => s.setKeyFile);
|
||||||
|
const balance = useStore(s => s.balance);
|
||||||
|
|
||||||
|
const [nodeUrl, setNodeUrlLocal] = useState(settings.nodeUrl);
|
||||||
|
const [contractId, setContractId] = useState(settings.contractId);
|
||||||
|
const [nodeStatus, setNodeStatus] = useState<'checking' | 'ok' | 'error'>('checking');
|
||||||
|
const [peerCount, setPeerCount] = useState<number | null>(null);
|
||||||
|
const [blockCount, setBlockCount] = useState<number | null>(null);
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
|
// Username registration state
|
||||||
|
const [nameInput, setNameInput] = useState('');
|
||||||
|
const [registering, setRegistering] = useState(false);
|
||||||
|
const [nameError, setNameError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Sanitize: lowercase, only a-z 0-9 _ -, max 32 (contract limit)
|
||||||
|
function onNameInputChange(v: string) {
|
||||||
|
const cleaned = v.toLowerCase().replace(/[^a-z0-9_\-]/g, '').slice(0, MAX_USERNAME_LENGTH);
|
||||||
|
setNameInput(cleaned);
|
||||||
|
setNameError(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function registerUsername() {
|
||||||
|
if (!keyFile) return;
|
||||||
|
const name = nameInput.trim();
|
||||||
|
// Mirror blockchain/native_username.go validateName so the UI gives
|
||||||
|
// immediate feedback without a round trip to the chain.
|
||||||
|
if (name.length < MIN_USERNAME_LENGTH) {
|
||||||
|
setNameError(`Минимум ${MIN_USERNAME_LENGTH} символа`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!/^[a-z]/.test(name)) {
|
||||||
|
setNameError('Имя должно начинаться с буквы a-z');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!settings.contractId) {
|
||||||
|
setNameError('Не задан ID контракта реестра в настройках ноды.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const fee = USERNAME_REGISTRATION_FEE;
|
||||||
|
// Reserve for: registry fee (burned) + MIN_CALL_FEE (to validator)
|
||||||
|
// + small gas headroom (native contract is cheap, but gas is pre-charged).
|
||||||
|
const GAS_HEADROOM = 2_000;
|
||||||
|
const total = fee + 1000 + GAS_HEADROOM;
|
||||||
|
if (balance < total) {
|
||||||
|
setNameError(`Нужно ${formatAmount(total)} (с запасом на газ), доступно ${formatAmount(balance)}.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Check if name is already taken
|
||||||
|
try {
|
||||||
|
const existing = await resolveUsername(settings.contractId, name);
|
||||||
|
if (existing) {
|
||||||
|
setNameError(`@${name} уже зарегистрировано.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore — lookup failure is OK, contract will reject on duplicate
|
||||||
|
}
|
||||||
|
|
||||||
|
Alert.alert(
|
||||||
|
'Купить @' + name + '?',
|
||||||
|
`Стоимость: ${formatAmount(fee)} + комиссия ${formatAmount(1000)}.\nИмя привязывается к вашему адресу навсегда (до release).`,
|
||||||
|
[
|
||||||
|
{ text: 'Отмена', style: 'cancel' },
|
||||||
|
{
|
||||||
|
text: 'Купить', onPress: async () => {
|
||||||
|
setRegistering(true);
|
||||||
|
setNameError(null);
|
||||||
|
try {
|
||||||
|
const tx = buildCallContractTx({
|
||||||
|
from: keyFile.pub_key,
|
||||||
|
contractId: settings.contractId,
|
||||||
|
method: 'register',
|
||||||
|
args: [name],
|
||||||
|
// Attach the registration fee in tx.amount — the contract
|
||||||
|
// requires exactly this much and burns it. Visible in the
|
||||||
|
// explorer so the user sees the real cost.
|
||||||
|
amount: USERNAME_REGISTRATION_FEE,
|
||||||
|
privKey: keyFile.priv_key,
|
||||||
|
});
|
||||||
|
await submitTx(tx);
|
||||||
|
Alert.alert(
|
||||||
|
'Отправлено',
|
||||||
|
`Транзакция покупки @${name} принята. Имя появится в профиле через несколько секунд.`,
|
||||||
|
);
|
||||||
|
setNameInput('');
|
||||||
|
// Poll every 2s for up to 20s until the address ↔ name binding is visible.
|
||||||
|
let attempts = 0;
|
||||||
|
const iv = setInterval(async () => {
|
||||||
|
attempts++;
|
||||||
|
const got = keyFile
|
||||||
|
? await reverseResolve(settings.contractId, keyFile.pub_key)
|
||||||
|
: null;
|
||||||
|
if (got) {
|
||||||
|
setUsername(got);
|
||||||
|
clearInterval(iv);
|
||||||
|
} else if (attempts >= 10) {
|
||||||
|
clearInterval(iv);
|
||||||
|
}
|
||||||
|
}, 2000);
|
||||||
|
} catch (e: any) {
|
||||||
|
setNameError(humanizeTxError(e));
|
||||||
|
} finally {
|
||||||
|
setRegistering(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flat fee — same for every name that passes validation.
|
||||||
|
const nameFee = USERNAME_REGISTRATION_FEE;
|
||||||
|
const nameIsValid = nameInput.length >= MIN_USERNAME_LENGTH && /^[a-z]/.test(nameInput);
|
||||||
|
|
||||||
|
useEffect(() => { checkNode(); }, []);
|
||||||
|
|
||||||
|
// Pick up auto-discovered contract IDs (useWellKnownContracts updates the
|
||||||
|
// store; reflect it into the local TextInput state so the UI stays consistent).
|
||||||
|
useEffect(() => {
|
||||||
|
setContractId(settings.contractId);
|
||||||
|
}, [settings.contractId]);
|
||||||
|
|
||||||
|
// When the registry contract becomes known (either via manual save or
|
||||||
|
// auto-discovery), look up the user's registered username reactively.
|
||||||
|
// Sets username unconditionally — a null result CLEARS the cached name,
|
||||||
|
// which matters when the user switches nodes / chains: a name on the
|
||||||
|
// previous chain should no longer show when connected to a chain where
|
||||||
|
// the same pubkey isn't registered.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!settings.contractId || !keyFile) {
|
||||||
|
setUsername(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
(async () => {
|
||||||
|
const name = await reverseResolve(settings.contractId, keyFile.pub_key);
|
||||||
|
setUsername(name);
|
||||||
|
})();
|
||||||
|
}, [settings.contractId, keyFile, setUsername]);
|
||||||
|
|
||||||
|
async function checkNode() {
|
||||||
|
setNodeStatus('checking');
|
||||||
|
try {
|
||||||
|
const stats = await getNetStats();
|
||||||
|
setNodeStatus('ok');
|
||||||
|
setPeerCount(stats.peer_count);
|
||||||
|
setBlockCount(stats.total_blocks);
|
||||||
|
if (settings.contractId && keyFile) {
|
||||||
|
// Address → username: must use reverseResolve, not resolveUsername
|
||||||
|
// (resolveUsername goes username → address).
|
||||||
|
const name = await reverseResolve(settings.contractId, keyFile.pub_key);
|
||||||
|
if (name) setUsername(name);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setNodeStatus('error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveNode() {
|
||||||
|
const url = nodeUrl.trim().replace(/\/$/, '');
|
||||||
|
setNodeUrl(url);
|
||||||
|
const next = { nodeUrl: url, contractId: contractId.trim() };
|
||||||
|
setSettings(next);
|
||||||
|
await saveSettings(next);
|
||||||
|
Alert.alert('Сохранено', 'Настройки ноды обновлены.');
|
||||||
|
checkNode();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function exportKey() {
|
||||||
|
if (!keyFile) return;
|
||||||
|
try {
|
||||||
|
const json = JSON.stringify(keyFile, null, 2);
|
||||||
|
const path = FileSystem.cacheDirectory + 'dchain_key.json';
|
||||||
|
await FileSystem.writeAsStringAsync(path, json);
|
||||||
|
if (await Sharing.isAvailableAsync()) {
|
||||||
|
await Sharing.shareAsync(path, {
|
||||||
|
mimeType: 'application/json',
|
||||||
|
dialogTitle: 'Экспорт ключа DChain',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
Alert.alert('Ошибка экспорта', e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyAddress() {
|
||||||
|
if (!keyFile) return;
|
||||||
|
await Clipboard.setStringAsync(keyFile.pub_key);
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function logout() {
|
||||||
|
Alert.alert(
|
||||||
|
'Удалить аккаунт',
|
||||||
|
'Ключ будет удалён с устройства. Убедитесь, что у вас есть резервная копия!',
|
||||||
|
[
|
||||||
|
{ text: 'Отмена', style: 'cancel' },
|
||||||
|
{
|
||||||
|
text: 'Удалить',
|
||||||
|
style: 'destructive',
|
||||||
|
onPress: async () => {
|
||||||
|
await deleteKeyFile();
|
||||||
|
setKeyFile(null);
|
||||||
|
router.replace('/');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusColor = nodeStatus === 'ok' ? C.ok : nodeStatus === 'error' ? C.err : C.warn;
|
||||||
|
const statusLabel = nodeStatus === 'ok' ? 'Подключена' : nodeStatus === 'error' ? 'Недоступна' : 'Проверка…';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollView
|
||||||
|
style={{ flex: 1, backgroundColor: C.bg }}
|
||||||
|
contentContainerStyle={{
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
paddingTop: insets.top + 12,
|
||||||
|
paddingBottom: Math.max(insets.bottom, 24) + 20,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text style={{ color: C.text, fontSize: 22, fontWeight: '700', marginBottom: 24 }}>
|
||||||
|
Настройки
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{/* ── Профиль ── */}
|
||||||
|
<SectionLabel>Профиль</SectionLabel>
|
||||||
|
<Card>
|
||||||
|
{/* Avatar row */}
|
||||||
|
<View style={{
|
||||||
|
flexDirection: 'row', alignItems: 'center', gap: 14,
|
||||||
|
paddingHorizontal: 14, paddingVertical: 14,
|
||||||
|
}}>
|
||||||
|
<Avatar
|
||||||
|
name={username ? `@${username}` : (keyFile?.pub_key ?? '?')}
|
||||||
|
size="md"
|
||||||
|
/>
|
||||||
|
<View style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
{username ? (
|
||||||
|
<Text style={{ color: C.text, fontWeight: '700', fontSize: 16 }}>@{username}</Text>
|
||||||
|
) : (
|
||||||
|
<Text style={{ color: C.muted, fontSize: 13 }}>Имя не зарегистрировано</Text>
|
||||||
|
)}
|
||||||
|
<Text style={{ color: C.muted, fontFamily: 'monospace', fontSize: 11 }} numberOfLines={1}>
|
||||||
|
{keyFile ? shortAddr(keyFile.pub_key, 10) : '—'}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Copy address */}
|
||||||
|
<View style={{ paddingHorizontal: 14, paddingBottom: 12, borderTopWidth: 1, borderTopColor: C.line, paddingTop: 10 }}>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={copyAddress}
|
||||||
|
style={{
|
||||||
|
flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 8,
|
||||||
|
paddingVertical: 10, borderRadius: 8,
|
||||||
|
backgroundColor: copied ? 'rgba(65,201,138,0.12)' : C.surface2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Ionicons
|
||||||
|
name={copied ? 'checkmark-outline' : 'copy-outline'}
|
||||||
|
size={15}
|
||||||
|
color={copied ? C.ok : C.muted}
|
||||||
|
/>
|
||||||
|
<Text style={{ color: copied ? C.ok : C.muted, fontSize: 13, fontWeight: '600' }}>
|
||||||
|
{copied ? 'Скопировано' : 'Скопировать адрес'}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* ── Имя пользователя ── */}
|
||||||
|
<SectionLabel>Имя пользователя</SectionLabel>
|
||||||
|
<Card>
|
||||||
|
{username ? (
|
||||||
|
// Already registered
|
||||||
|
<View style={{
|
||||||
|
flexDirection: 'row', alignItems: 'center', gap: 12,
|
||||||
|
paddingHorizontal: 14, paddingVertical: 14,
|
||||||
|
}}>
|
||||||
|
<View style={{
|
||||||
|
width: 34, height: 34, borderRadius: 17,
|
||||||
|
backgroundColor: 'rgba(65,201,138,0.12)',
|
||||||
|
alignItems: 'center', justifyContent: 'center', flexShrink: 0,
|
||||||
|
}}>
|
||||||
|
<Ionicons name="at-outline" size={16} color={C.ok} />
|
||||||
|
</View>
|
||||||
|
<View style={{ flex: 1 }}>
|
||||||
|
<Text style={{ color: C.text, fontSize: 15, fontWeight: '700' }}>@{username}</Text>
|
||||||
|
<Text style={{ color: C.muted, fontSize: 12, marginTop: 2 }}>Привязано к вашему адресу</Text>
|
||||||
|
</View>
|
||||||
|
<View style={{
|
||||||
|
paddingHorizontal: 10, paddingVertical: 4,
|
||||||
|
borderRadius: 999, backgroundColor: 'rgba(65,201,138,0.12)',
|
||||||
|
}}>
|
||||||
|
<Text style={{ color: C.ok, fontSize: 11, fontWeight: '600' }}>Активно</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
// Register new
|
||||||
|
<>
|
||||||
|
<View style={{ paddingHorizontal: 14, paddingTop: 12, paddingBottom: 6 }}>
|
||||||
|
<Text style={{ color: C.text, fontSize: 14, fontWeight: '600', marginBottom: 3 }}>
|
||||||
|
Купить никнейм
|
||||||
|
</Text>
|
||||||
|
<Text style={{ color: C.muted, fontSize: 12, lineHeight: 17 }}>
|
||||||
|
Короткие имена дороже. Оплата идёт в казну контракта реестра.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Input */}
|
||||||
|
<View style={{ paddingHorizontal: 14, paddingTop: 10 }}>
|
||||||
|
<View style={{
|
||||||
|
flexDirection: 'row', alignItems: 'center',
|
||||||
|
backgroundColor: C.surface2, borderRadius: 8,
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
borderWidth: 1, borderColor: nameError ? C.err : C.line,
|
||||||
|
}}>
|
||||||
|
<Text style={{ color: C.muted, fontSize: 15, marginRight: 4 }}>@</Text>
|
||||||
|
<TextInput
|
||||||
|
value={nameInput}
|
||||||
|
onChangeText={onNameInputChange}
|
||||||
|
placeholder="alice"
|
||||||
|
placeholderTextColor={C.muted}
|
||||||
|
autoCapitalize="none"
|
||||||
|
autoCorrect={false}
|
||||||
|
maxLength={64}
|
||||||
|
style={{ color: C.text, fontSize: 15, height: 40, flex: 1 }}
|
||||||
|
/>
|
||||||
|
{nameInput.length > 0 && (
|
||||||
|
<Text style={{ color: C.muted, fontSize: 11 }}>{nameInput.length}</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
{nameError && (
|
||||||
|
<Text style={{ color: C.err, fontSize: 12, marginTop: 6 }}>⚠ {nameError}</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Fee breakdown + rules */}
|
||||||
|
<View style={{ paddingHorizontal: 14, paddingTop: 10 }}>
|
||||||
|
<View style={{
|
||||||
|
backgroundColor: C.surface2, borderRadius: 8,
|
||||||
|
paddingHorizontal: 12, paddingVertical: 10,
|
||||||
|
}}>
|
||||||
|
{/* Primary cost line */}
|
||||||
|
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 8, marginBottom: 6 }}>
|
||||||
|
<Ionicons name="flame-outline" size={14} color={C.warn} />
|
||||||
|
<Text style={{ color: C.muted, fontSize: 12, flex: 1 }}>Плата за ник (сгорает)</Text>
|
||||||
|
<Text style={{ color: C.text, fontSize: 13, fontWeight: '600' }}>
|
||||||
|
{formatAmount(nameFee)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 8, marginBottom: 8 }}>
|
||||||
|
<Ionicons name="cube-outline" size={14} color={C.muted} />
|
||||||
|
<Text style={{ color: C.muted, fontSize: 12, flex: 1 }}>Комиссия сети (валидатору)</Text>
|
||||||
|
<Text style={{ color: C.muted, fontSize: 13 }}>
|
||||||
|
{formatAmount(1000)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Total */}
|
||||||
|
<View style={{
|
||||||
|
flexDirection: 'row', alignItems: 'center', gap: 8,
|
||||||
|
paddingTop: 6,
|
||||||
|
borderTopWidth: 1, borderTopColor: C.line,
|
||||||
|
}}>
|
||||||
|
<Text style={{ color: C.text, fontSize: 12, fontWeight: '600', flex: 1 }}>Итого</Text>
|
||||||
|
<Text style={{ color: C.text, fontSize: 13, fontWeight: '700' }}>
|
||||||
|
{formatAmount(nameFee + 1000)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Rules */}
|
||||||
|
<Text style={{ color: C.muted, fontSize: 11, lineHeight: 17, marginTop: 8 }}>
|
||||||
|
Минимум {MIN_USERNAME_LENGTH} символа, только{' '}
|
||||||
|
<Text style={{ color: C.text }}>a-z 0-9 _ -</Text>
|
||||||
|
, первый символ — буква.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Register button */}
|
||||||
|
<View style={{ paddingHorizontal: 14, paddingVertical: 12 }}>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={registerUsername}
|
||||||
|
disabled={registering || !nameIsValid || !settings.contractId}
|
||||||
|
style={{
|
||||||
|
flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 8,
|
||||||
|
paddingVertical: 12, borderRadius: 8,
|
||||||
|
backgroundColor: (registering || !nameIsValid || !settings.contractId)
|
||||||
|
? C.surface2 : C.accent,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Ionicons
|
||||||
|
name={registering ? 'hourglass-outline' : 'at-outline'}
|
||||||
|
size={16}
|
||||||
|
color={(registering || !nameIsValid || !settings.contractId) ? C.muted : C.bg}
|
||||||
|
/>
|
||||||
|
<Text style={{
|
||||||
|
color: (registering || !nameIsValid || !settings.contractId) ? C.muted : C.bg,
|
||||||
|
fontWeight: '700', fontSize: 14,
|
||||||
|
}}>
|
||||||
|
{registering ? 'Покупка…' : 'Купить никнейм'}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
{!settings.contractId && (
|
||||||
|
<Text style={{ color: C.warn, fontSize: 11, marginTop: 6, textAlign: 'center' }}>
|
||||||
|
Укажите ID контракта реестра в настройках ноды ниже
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* ── Нода ── */}
|
||||||
|
<SectionLabel>Нода</SectionLabel>
|
||||||
|
<Card>
|
||||||
|
{/* Connection status */}
|
||||||
|
<View style={{
|
||||||
|
flexDirection: 'row', alignItems: 'center', gap: 12,
|
||||||
|
paddingHorizontal: 14, paddingVertical: 12,
|
||||||
|
}}>
|
||||||
|
<View style={{
|
||||||
|
width: 34, height: 34, borderRadius: 17,
|
||||||
|
backgroundColor: `${statusColor}15`,
|
||||||
|
alignItems: 'center', justifyContent: 'center',
|
||||||
|
}}>
|
||||||
|
<Ionicons
|
||||||
|
name={nodeStatus === 'ok' ? 'cloud-done-outline' : nodeStatus === 'error' ? 'cloud-offline-outline' : 'cloud-outline'}
|
||||||
|
size={16}
|
||||||
|
color={statusColor}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<View style={{ flex: 1 }}>
|
||||||
|
<Text style={{ color: C.text, fontSize: 14, fontWeight: '600' }}>Подключение</Text>
|
||||||
|
<Text style={{ color: statusColor, fontSize: 12 }}>{statusLabel}</Text>
|
||||||
|
</View>
|
||||||
|
{nodeStatus === 'ok' && (
|
||||||
|
<View style={{ alignItems: 'flex-end', gap: 2 }}>
|
||||||
|
{peerCount !== null && (
|
||||||
|
<Text style={{ color: C.muted, fontSize: 11 }}>
|
||||||
|
<Text style={{ color: C.text, fontWeight: '600' }}>{peerCount}</Text> пиров
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{blockCount !== null && (
|
||||||
|
<Text style={{ color: C.muted, fontSize: 11 }}>
|
||||||
|
<Text style={{ color: C.text, fontWeight: '600' }}>{blockCount.toLocaleString()}</Text> блоков
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Node URL input */}
|
||||||
|
<View style={{ borderTopWidth: 1, borderTopColor: C.line }}>
|
||||||
|
<FieldInput
|
||||||
|
label="URL ноды"
|
||||||
|
value={nodeUrl}
|
||||||
|
onChangeText={setNodeUrlLocal}
|
||||||
|
keyboardType="url"
|
||||||
|
placeholder="http://localhost:8080"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Registry contract — auto-detected from node; manual override under advanced */}
|
||||||
|
<View style={{ borderTopWidth: 1, borderTopColor: C.line, paddingHorizontal: 14, paddingVertical: 12 }}>
|
||||||
|
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 8, marginBottom: 6 }}>
|
||||||
|
<Ionicons
|
||||||
|
name={settings.contractId ? 'checkmark-circle' : 'help-circle-outline'}
|
||||||
|
size={14}
|
||||||
|
color={settings.contractId ? C.ok : C.warn}
|
||||||
|
/>
|
||||||
|
<Text style={{ color: C.muted, fontSize: 11, letterSpacing: 0.5 }}>
|
||||||
|
КОНТРАКТ РЕЕСТРА ИМЁН
|
||||||
|
</Text>
|
||||||
|
<Text style={{
|
||||||
|
marginLeft: 'auto' as any, color: settings.contractId ? C.ok : C.warn, fontSize: 11, fontWeight: '600',
|
||||||
|
}}>
|
||||||
|
{settings.contractId ? 'Авто-обнаружен' : 'Не найден'}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Text style={{ color: C.text, fontSize: 12, fontFamily: 'monospace' }} numberOfLines={1}>
|
||||||
|
{settings.contractId || '—'}
|
||||||
|
</Text>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => setShowAdvanced(v => !v)}
|
||||||
|
style={{ marginTop: 8 }}
|
||||||
|
>
|
||||||
|
<Text style={{ color: C.accent, fontSize: 11 }}>
|
||||||
|
{showAdvanced ? '▾ Скрыть ручной ввод' : '▸ Указать вручную (не требуется)'}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
{showAdvanced && (
|
||||||
|
<View style={{
|
||||||
|
marginTop: 8,
|
||||||
|
backgroundColor: C.surface2, borderRadius: 8,
|
||||||
|
paddingHorizontal: 12, paddingVertical: 8,
|
||||||
|
borderWidth: 1, borderColor: C.line,
|
||||||
|
}}>
|
||||||
|
<TextInput
|
||||||
|
value={contractId}
|
||||||
|
onChangeText={setContractId}
|
||||||
|
placeholder="hex contract ID"
|
||||||
|
placeholderTextColor={C.muted}
|
||||||
|
autoCapitalize="none"
|
||||||
|
autoCorrect={false}
|
||||||
|
style={{ color: C.text, fontSize: 13, fontFamily: 'monospace', height: 36 }}
|
||||||
|
/>
|
||||||
|
<Text style={{ color: C.muted, fontSize: 10, marginTop: 4 }}>
|
||||||
|
Оставьте пустым — клиент запросит канонический контракт у ноды.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Save button */}
|
||||||
|
<View style={{ paddingHorizontal: 14, paddingBottom: 12, paddingTop: 4, borderTopWidth: 1, borderTopColor: C.line }}>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={saveNode}
|
||||||
|
style={{
|
||||||
|
flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 8,
|
||||||
|
paddingVertical: 11, borderRadius: 8, backgroundColor: C.accent,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Ionicons name="save-outline" size={16} color={C.bg} />
|
||||||
|
<Text style={{ color: C.bg, fontWeight: '700', fontSize: 14 }}>Сохранить и переподключиться</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* ── Безопасность ── */}
|
||||||
|
<SectionLabel>Безопасность</SectionLabel>
|
||||||
|
<Card>
|
||||||
|
<View style={{
|
||||||
|
flexDirection: 'row', alignItems: 'center', gap: 12,
|
||||||
|
paddingHorizontal: 14, paddingVertical: 14,
|
||||||
|
}}>
|
||||||
|
<View style={{
|
||||||
|
width: 34, height: 34, borderRadius: 17,
|
||||||
|
backgroundColor: 'rgba(125,181,255,0.10)',
|
||||||
|
alignItems: 'center', justifyContent: 'center', flexShrink: 0,
|
||||||
|
}}>
|
||||||
|
<Ionicons name="shield-outline" size={16} color={C.accent} />
|
||||||
|
</View>
|
||||||
|
<View style={{ flex: 1 }}>
|
||||||
|
<Text style={{ color: C.text, fontSize: 14, fontWeight: '600' }}>Экспорт ключа</Text>
|
||||||
|
<Text style={{ color: C.muted, fontSize: 12, marginTop: 1 }}>Сохранить приватный ключ как key.json</Text>
|
||||||
|
</View>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={exportKey}
|
||||||
|
style={{
|
||||||
|
paddingHorizontal: 14, paddingVertical: 8, borderRadius: 8,
|
||||||
|
backgroundColor: C.surface2, borderWidth: 1, borderColor: C.line,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text style={{ color: C.text, fontSize: 13, fontWeight: '600' }}>Экспорт</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* ── Опасная зона ── */}
|
||||||
|
<SectionLabel>Опасная зона</SectionLabel>
|
||||||
|
<Card style={{ backgroundColor: 'rgba(255,122,135,0.04)', borderWidth: 1, borderColor: 'rgba(255,122,135,0.20)' }}>
|
||||||
|
<View style={{ paddingHorizontal: 14, paddingVertical: 14 }}>
|
||||||
|
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 10, marginBottom: 8 }}>
|
||||||
|
<Ionicons name="warning-outline" size={16} color={C.err} />
|
||||||
|
<Text style={{ color: C.err, fontSize: 14, fontWeight: '700' }}>Удалить аккаунт</Text>
|
||||||
|
</View>
|
||||||
|
<Text style={{ color: C.muted, fontSize: 13, lineHeight: 19, marginBottom: 12 }}>
|
||||||
|
Удаляет ключ с устройства. Онлайн-идентичность сохраняется, но доступ будет потерян без резервной копии.
|
||||||
|
</Text>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={logout}
|
||||||
|
style={{
|
||||||
|
flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 8,
|
||||||
|
paddingVertical: 11, borderRadius: 8,
|
||||||
|
backgroundColor: 'rgba(255,122,135,0.12)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Ionicons name="trash-outline" size={16} color={C.err} />
|
||||||
|
<Text style={{ color: C.err, fontWeight: '700', fontSize: 14 }}>Удалить с устройства</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</Card>
|
||||||
|
</ScrollView>
|
||||||
|
);
|
||||||
|
}
|
||||||
596
client-app/app/(app)/wallet.tsx
Normal file
596
client-app/app/(app)/wallet.tsx
Normal file
@@ -0,0 +1,596 @@
|
|||||||
|
/**
|
||||||
|
* Wallet screen — DChain explorer style.
|
||||||
|
* Balance block inspired by Tinkoff/Gravity UI reference.
|
||||||
|
* Icons: Ionicons from @expo/vector-icons.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useState, useCallback, useEffect } from 'react';
|
||||||
|
import {
|
||||||
|
View, Text, ScrollView, Modal, TouchableOpacity,
|
||||||
|
Alert, RefreshControl,
|
||||||
|
} from 'react-native';
|
||||||
|
import * as Clipboard from 'expo-clipboard';
|
||||||
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
|
import { useStore } from '@/lib/store';
|
||||||
|
import { useBalance } from '@/hooks/useBalance';
|
||||||
|
import { buildTransferTx, submitTx, getTxHistory, getBalance } from '@/lib/api';
|
||||||
|
import { shortAddr } from '@/lib/crypto';
|
||||||
|
import { formatAmount, relativeTime } from '@/lib/utils';
|
||||||
|
import { Input } from '@/components/ui/Input';
|
||||||
|
import type { TxRecord } from '@/lib/types';
|
||||||
|
|
||||||
|
// ─── Design tokens ────────────────────────────────────────────────────────────
|
||||||
|
const C = {
|
||||||
|
bg: '#0b1220',
|
||||||
|
surface: '#111a2b',
|
||||||
|
surface2:'#162035',
|
||||||
|
surface3:'#1a2640',
|
||||||
|
line: '#1c2840',
|
||||||
|
text: '#e6edf9',
|
||||||
|
muted: '#98a7c2',
|
||||||
|
accent: '#7db5ff',
|
||||||
|
ok: '#41c98a',
|
||||||
|
warn: '#f0b35a',
|
||||||
|
err: '#ff7a87',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// ─── TX metadata ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const TX_META: Record<string, { label: string; icon: keyof typeof Ionicons.glyphMap; color: string }> = {
|
||||||
|
TRANSFER: { label: 'Перевод', icon: 'paper-plane-outline', color: C.accent },
|
||||||
|
CONTACT_REQUEST: { label: 'Запрос контакта', icon: 'person-add-outline', color: C.ok },
|
||||||
|
ACCEPT_CONTACT: { label: 'Принят контакт', icon: 'person-outline', color: C.ok },
|
||||||
|
BLOCK_CONTACT: { label: 'Блокировка', icon: 'ban-outline', color: C.err },
|
||||||
|
DEPLOY_CONTRACT: { label: 'Деплой контракта',icon: 'document-text-outline', color: C.warn },
|
||||||
|
CALL_CONTRACT: { label: 'Вызов контракта', icon: 'flash-outline', color: C.warn },
|
||||||
|
STAKE: { label: 'Стейкинг', icon: 'lock-closed-outline', color: C.accent},
|
||||||
|
UNSTAKE: { label: 'Вывод стейка', icon: 'lock-open-outline', color: C.muted },
|
||||||
|
REGISTER_KEY: { label: 'Регистрация', icon: 'key-outline', color: C.muted },
|
||||||
|
BLOCK_REWARD: { label: 'Награда', icon: 'diamond-outline', color: C.ok },
|
||||||
|
};
|
||||||
|
|
||||||
|
function txMeta(type: string) {
|
||||||
|
return TX_META[type] ?? { label: type.replace(/_/g, ' '), icon: 'ellipse-outline' as any, color: C.muted };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Component ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export default function WalletScreen() {
|
||||||
|
const keyFile = useStore(s => s.keyFile);
|
||||||
|
const balance = useStore(s => s.balance);
|
||||||
|
const setBalance = useStore(s => s.setBalance);
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
|
useBalance();
|
||||||
|
|
||||||
|
const [txHistory, setTxHistory] = useState<TxRecord[]>([]);
|
||||||
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
|
const [showSend, setShowSend] = useState(false);
|
||||||
|
const [selectedTx, setSelectedTx] = useState<TxRecord | null>(null);
|
||||||
|
const [toAddress, setToAddress] = useState('');
|
||||||
|
const [amount, setAmount] = useState('');
|
||||||
|
const [fee, setFee] = useState('1000');
|
||||||
|
const [sending, setSending] = useState(false);
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
if (!keyFile) return;
|
||||||
|
setRefreshing(true);
|
||||||
|
try {
|
||||||
|
const [hist, bal] = await Promise.all([
|
||||||
|
getTxHistory(keyFile.pub_key),
|
||||||
|
getBalance(keyFile.pub_key),
|
||||||
|
]);
|
||||||
|
setTxHistory(hist);
|
||||||
|
setBalance(bal);
|
||||||
|
} catch {}
|
||||||
|
setRefreshing(false);
|
||||||
|
}, [keyFile]);
|
||||||
|
|
||||||
|
useEffect(() => { load(); }, [load]);
|
||||||
|
|
||||||
|
const copyAddress = async () => {
|
||||||
|
if (!keyFile) return;
|
||||||
|
await Clipboard.setStringAsync(keyFile.pub_key);
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 2000);
|
||||||
|
};
|
||||||
|
|
||||||
|
const send = async () => {
|
||||||
|
if (!keyFile) return;
|
||||||
|
const amt = parseInt(amount);
|
||||||
|
const f = parseInt(fee);
|
||||||
|
if (!toAddress.trim() || isNaN(amt) || amt <= 0) {
|
||||||
|
Alert.alert('Неверные данные', 'Введите корректный адрес и сумму.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (amt + f > balance) {
|
||||||
|
Alert.alert('Недостаточно средств', `Нужно ${formatAmount(amt + f)}, доступно ${formatAmount(balance)}.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSending(true);
|
||||||
|
try {
|
||||||
|
const tx = buildTransferTx({ from: keyFile.pub_key, to: toAddress.trim(), amount: amt, fee: f, privKey: keyFile.priv_key });
|
||||||
|
await submitTx(tx);
|
||||||
|
setShowSend(false);
|
||||||
|
setToAddress('');
|
||||||
|
setAmount('');
|
||||||
|
Alert.alert('Отправлено', 'Транзакция принята нодой.');
|
||||||
|
setTimeout(load, 1500);
|
||||||
|
} catch (e: any) {
|
||||||
|
Alert.alert('Ошибка', e.message);
|
||||||
|
} finally {
|
||||||
|
setSending(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={{ flex: 1, backgroundColor: C.bg }}>
|
||||||
|
<ScrollView
|
||||||
|
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={load} tintColor={C.accent} />}
|
||||||
|
contentContainerStyle={{ paddingBottom: 32 }}
|
||||||
|
>
|
||||||
|
{/* ── Balance hero ── */}
|
||||||
|
<BalanceHero
|
||||||
|
balance={balance}
|
||||||
|
address={keyFile?.pub_key ?? ''}
|
||||||
|
copied={copied}
|
||||||
|
topInset={insets.top}
|
||||||
|
onSend={() => setShowSend(true)}
|
||||||
|
onReceive={copyAddress}
|
||||||
|
onRefresh={load}
|
||||||
|
onCopy={copyAddress}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* ── Transaction list ── */}
|
||||||
|
<View style={{ paddingHorizontal: 16, paddingTop: 8 }}>
|
||||||
|
<Text style={{ color: C.muted, fontSize: 11, letterSpacing: 1, textTransform: 'uppercase', marginBottom: 10 }}>
|
||||||
|
История транзакций
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{txHistory.length === 0 ? (
|
||||||
|
<View style={{ backgroundColor: C.surface, borderRadius: 12, paddingVertical: 36, alignItems: 'center' }}>
|
||||||
|
<Ionicons name="receipt-outline" size={32} color={C.muted} style={{ marginBottom: 10 }} />
|
||||||
|
<Text style={{ color: C.muted, fontSize: 14 }}>Нет транзакций</Text>
|
||||||
|
<Text style={{ color: C.muted, fontSize: 12, marginTop: 4 }}>Потяните вниз, чтобы обновить</Text>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<View style={{ backgroundColor: C.surface, borderRadius: 12, overflow: 'hidden' }}>
|
||||||
|
{/* Table header */}
|
||||||
|
<View style={{
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingHorizontal: 14,
|
||||||
|
paddingVertical: 8,
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: C.line,
|
||||||
|
}}>
|
||||||
|
<Text style={{ color: C.muted, fontSize: 10, fontWeight: '600', letterSpacing: 0.5, width: 140 }}>
|
||||||
|
ТИП
|
||||||
|
</Text>
|
||||||
|
<Text style={{ color: C.muted, fontSize: 10, fontWeight: '600', letterSpacing: 0.5, flex: 1 }}>
|
||||||
|
АДРЕС
|
||||||
|
</Text>
|
||||||
|
<Text style={{ color: C.muted, fontSize: 10, fontWeight: '600', letterSpacing: 0.5, width: 84, textAlign: 'right' }}>
|
||||||
|
СУММА
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{txHistory.map((tx, i) => (
|
||||||
|
<TxRow
|
||||||
|
key={tx.hash}
|
||||||
|
tx={tx}
|
||||||
|
myPubKey={keyFile?.pub_key ?? ''}
|
||||||
|
isLast={i === txHistory.length - 1}
|
||||||
|
onPress={() => setSelectedTx(tx)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
|
||||||
|
{/* Send modal */}
|
||||||
|
<Modal visible={showSend} animationType="slide" presentationStyle="pageSheet" onRequestClose={() => setShowSend(false)}>
|
||||||
|
<SendSheet
|
||||||
|
balance={balance}
|
||||||
|
toAddress={toAddress} setToAddress={setToAddress}
|
||||||
|
amount={amount} setAmount={setAmount}
|
||||||
|
fee={fee} setFee={setFee}
|
||||||
|
sending={sending}
|
||||||
|
onSend={send}
|
||||||
|
onClose={() => setShowSend(false)}
|
||||||
|
/>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
{/* TX Detail modal */}
|
||||||
|
<Modal visible={!!selectedTx} animationType="slide" presentationStyle="pageSheet" onRequestClose={() => setSelectedTx(null)}>
|
||||||
|
{selectedTx && (
|
||||||
|
<TxDetailSheet tx={selectedTx} myPubKey={keyFile?.pub_key ?? ''} onClose={() => setSelectedTx(null)} />
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Balance Hero ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function BalanceHero({
|
||||||
|
balance, address, copied, topInset, onSend, onReceive, onRefresh, onCopy,
|
||||||
|
}: {
|
||||||
|
balance: number; address: string; copied: boolean;
|
||||||
|
topInset: number;
|
||||||
|
onSend: () => void; onReceive: () => void;
|
||||||
|
onRefresh: () => void; onCopy: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<View style={{
|
||||||
|
backgroundColor: C.surface,
|
||||||
|
paddingTop: topInset + 16, paddingBottom: 28,
|
||||||
|
paddingHorizontal: 20,
|
||||||
|
borderBottomLeftRadius: 20,
|
||||||
|
borderBottomRightRadius: 20,
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: 20,
|
||||||
|
}}>
|
||||||
|
{/* Label */}
|
||||||
|
<Text style={{ color: C.muted, fontSize: 13, marginBottom: 8, letterSpacing: 0.3 }}>
|
||||||
|
Баланс
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{/* Main balance */}
|
||||||
|
<Text style={{ color: C.text, fontSize: 42, fontWeight: '700', letterSpacing: -1, lineHeight: 50 }}>
|
||||||
|
{formatAmount(balance)}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{/* µT sub-label */}
|
||||||
|
<Text style={{ color: C.muted, fontSize: 13, marginTop: 4, marginBottom: 20 }}>
|
||||||
|
{(balance ?? 0).toLocaleString()} µT
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{/* Address chip */}
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={onCopy}
|
||||||
|
style={{
|
||||||
|
flexDirection: 'row', alignItems: 'center', gap: 6,
|
||||||
|
backgroundColor: copied ? 'rgba(65,201,138,0.12)' : C.surface2,
|
||||||
|
borderRadius: 999, paddingHorizontal: 12, paddingVertical: 6,
|
||||||
|
marginBottom: 24,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Ionicons
|
||||||
|
name={copied ? 'checkmark-outline' : 'copy-outline'}
|
||||||
|
size={13}
|
||||||
|
color={copied ? C.ok : C.muted}
|
||||||
|
/>
|
||||||
|
<Text style={{ color: copied ? C.ok : C.muted, fontSize: 12, fontFamily: 'monospace' }}>
|
||||||
|
{copied ? 'Скопировано' : (address ? shortAddr(address, 8) : '—')}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
{/* Action buttons */}
|
||||||
|
<View style={{ flexDirection: 'row', gap: 20 }}>
|
||||||
|
<ActionButton icon="paper-plane-outline" label="Отправить" color={C.accent} onPress={onSend} />
|
||||||
|
<ActionButton icon="arrow-down-circle-outline" label="Получить" color={C.accent} onPress={onReceive} />
|
||||||
|
<ActionButton icon="refresh-outline" label="Обновить" color={C.muted} onPress={onRefresh} />
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ActionButton({
|
||||||
|
icon, label, color, onPress,
|
||||||
|
}: {
|
||||||
|
icon: keyof typeof Ionicons.glyphMap; label: string; color: string; onPress: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<TouchableOpacity onPress={onPress} style={{ alignItems: 'center', gap: 8 }}>
|
||||||
|
<View style={{
|
||||||
|
width: 52, height: 52, borderRadius: 26,
|
||||||
|
backgroundColor: C.surface3,
|
||||||
|
alignItems: 'center', justifyContent: 'center',
|
||||||
|
}}>
|
||||||
|
<Ionicons name={icon} size={22} color={color} />
|
||||||
|
</View>
|
||||||
|
<Text style={{ color: C.muted, fontSize: 12 }}>{label}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Transaction Row ──────────────────────────────────────────────────────────
|
||||||
|
// Column widths must match the header above:
|
||||||
|
// col1 (type+icon): 140 col2 (address): flex:1 col3 (amount): 84
|
||||||
|
|
||||||
|
// tx types where "from" is always the owner but it's income, not a send
|
||||||
|
const RECEIVED_TYPES = new Set(['BLOCK_REWARD', 'STAKE_REWARD']);
|
||||||
|
|
||||||
|
function TxRow({
|
||||||
|
tx, myPubKey, isLast, onPress,
|
||||||
|
}: {
|
||||||
|
tx: TxRecord; myPubKey: string; isLast: boolean; onPress: () => void;
|
||||||
|
}) {
|
||||||
|
const meta = txMeta(tx.type);
|
||||||
|
const isSynthetic = !tx.from; // block reward / mint
|
||||||
|
const isSent = !isSynthetic && !RECEIVED_TYPES.has(tx.type) && tx.from === myPubKey;
|
||||||
|
const amt = tx.amount ?? 0;
|
||||||
|
const amtText = amt === 0 ? '' : `${isSent ? '−' : '+'}${formatAmount(amt)}`;
|
||||||
|
const amtColor = isSent ? C.err : C.ok;
|
||||||
|
|
||||||
|
// Counterpart label: for synthetic (empty from) rewards → "Сеть",
|
||||||
|
// otherwise show the short address of the other side.
|
||||||
|
const counterpart = isSynthetic
|
||||||
|
? 'Сеть'
|
||||||
|
: isSent
|
||||||
|
? (tx.to ? shortAddr(tx.to, 6) : '—')
|
||||||
|
: shortAddr(tx.from, 6);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={onPress}
|
||||||
|
activeOpacity={0.6}
|
||||||
|
style={{
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingHorizontal: 14,
|
||||||
|
paddingVertical: 11,
|
||||||
|
borderBottomWidth: isLast ? 0 : 1,
|
||||||
|
borderBottomColor: C.line,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Col 1 — icon + type label, fixed 140px */}
|
||||||
|
<View style={{ width: 140, flexDirection: 'row', alignItems: 'center' }}>
|
||||||
|
<View style={{
|
||||||
|
width: 28, height: 28, borderRadius: 14,
|
||||||
|
backgroundColor: `${meta.color}1a`,
|
||||||
|
alignItems: 'center', justifyContent: 'center',
|
||||||
|
marginRight: 8, flexShrink: 0,
|
||||||
|
}}>
|
||||||
|
<Ionicons name={meta.icon} size={14} color={meta.color} />
|
||||||
|
</View>
|
||||||
|
<Text style={{ color: C.text, fontSize: 13, fontWeight: '500', flexShrink: 1 }} numberOfLines={1}>
|
||||||
|
{meta.label}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Col 2 — address, flex */}
|
||||||
|
<View style={{ flex: 1 }}>
|
||||||
|
<Text style={{ color: C.muted, fontSize: 12 }} numberOfLines={1}>
|
||||||
|
{isSent ? `→ ${counterpart}` : `← ${counterpart}`}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Col 3 — amount + time, fixed 84px, right-aligned */}
|
||||||
|
<View style={{ width: 84, alignItems: 'flex-end' }}>
|
||||||
|
{!!amtText && (
|
||||||
|
<Text style={{ color: amtColor, fontSize: 12, fontWeight: '600' }} numberOfLines={1}>
|
||||||
|
{amtText}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
<Text style={{ color: C.muted, fontSize: 11, marginTop: amtText ? 1 : 0 }} numberOfLines={1}>
|
||||||
|
{relativeTime(tx.timestamp)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Send Sheet ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function SendSheet({
|
||||||
|
balance, toAddress, setToAddress, amount, setAmount, fee, setFee, sending, onSend, onClose,
|
||||||
|
}: {
|
||||||
|
balance: number;
|
||||||
|
toAddress: string; setToAddress: (v: string) => void;
|
||||||
|
amount: string; setAmount: (v: string) => void;
|
||||||
|
fee: string; setFee: (v: string) => void;
|
||||||
|
sending: boolean; onSend: () => void; onClose: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<View style={{ flex: 1, backgroundColor: C.bg, paddingHorizontal: 16, paddingTop: 20 }}>
|
||||||
|
<View style={{ flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginBottom: 20 }}>
|
||||||
|
<Text style={{ color: C.text, fontSize: 20, fontWeight: '700' }}>Отправить</Text>
|
||||||
|
<TouchableOpacity onPress={onClose} style={{ padding: 8, backgroundColor: C.surface2, borderRadius: 8 }}>
|
||||||
|
<Ionicons name="close-outline" size={18} color={C.muted} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={{
|
||||||
|
flexDirection: 'row', alignItems: 'center', gap: 8,
|
||||||
|
backgroundColor: C.surface, borderRadius: 8,
|
||||||
|
paddingHorizontal: 12, paddingVertical: 10, marginBottom: 20,
|
||||||
|
}}>
|
||||||
|
<Ionicons name="wallet-outline" size={15} color={C.muted} />
|
||||||
|
<Text style={{ color: C.muted, fontSize: 13 }}>Доступно:</Text>
|
||||||
|
<Text style={{ color: C.text, fontSize: 13, fontWeight: '600' }}>{formatAmount(balance)}</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Input label="Адрес получателя" placeholder="64-символьный hex" value={toAddress}
|
||||||
|
onChangeText={setToAddress} autoCapitalize="none" autoCorrect={false} className="mb-4" />
|
||||||
|
<Input label="Сумма (µT)" placeholder="например 100000" value={amount}
|
||||||
|
onChangeText={setAmount} keyboardType="numeric" className="mb-4" />
|
||||||
|
<Input label="Комиссия (µT)" value={fee}
|
||||||
|
onChangeText={setFee} keyboardType="numeric" className="mb-8" />
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={onSend}
|
||||||
|
disabled={sending}
|
||||||
|
style={{
|
||||||
|
paddingVertical: 15, borderRadius: 10, alignItems: 'center', flexDirection: 'row',
|
||||||
|
justifyContent: 'center', gap: 8,
|
||||||
|
backgroundColor: sending ? C.surface2 : C.accent,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{!sending && <Ionicons name="paper-plane-outline" size={18} color="#0b1220" />}
|
||||||
|
<Text style={{ color: sending ? C.muted : '#0b1220', fontWeight: '700', fontSize: 15 }}>
|
||||||
|
{sending ? 'Отправка…' : 'Подтвердить'}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── TX Detail Sheet ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function TxDetailSheet({
|
||||||
|
tx, myPubKey, onClose,
|
||||||
|
}: {
|
||||||
|
tx: TxRecord; myPubKey: string; onClose: () => void;
|
||||||
|
}) {
|
||||||
|
const [copiedHash, setCopiedHash] = useState(false);
|
||||||
|
const meta = txMeta(tx.type);
|
||||||
|
const isSent = tx.from === myPubKey;
|
||||||
|
const amtValue = tx.amount ?? 0;
|
||||||
|
|
||||||
|
const copyHash = async () => {
|
||||||
|
await Clipboard.setStringAsync(tx.hash);
|
||||||
|
setCopiedHash(true);
|
||||||
|
setTimeout(() => setCopiedHash(false), 2000);
|
||||||
|
};
|
||||||
|
|
||||||
|
const amtColor = amtValue === 0 ? C.muted : isSent ? C.err : C.ok;
|
||||||
|
const amtSign = amtValue === 0 ? '' : isSent ? '−' : '+';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={{ flex: 1, backgroundColor: C.bg }}>
|
||||||
|
{/* Handle */}
|
||||||
|
<View style={{ alignItems: 'center', paddingTop: 12, marginBottom: 4 }}>
|
||||||
|
<View style={{ width: 36, height: 4, borderRadius: 2, backgroundColor: C.line }} />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<ScrollView contentContainerStyle={{ paddingHorizontal: 16, paddingTop: 8, paddingBottom: 32 }}>
|
||||||
|
{/* Header */}
|
||||||
|
<View style={{ flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginBottom: 20 }}>
|
||||||
|
<Text style={{ color: C.text, fontSize: 17, fontWeight: '700' }}>Транзакция</Text>
|
||||||
|
<TouchableOpacity onPress={onClose} style={{ padding: 8, backgroundColor: C.surface2, borderRadius: 8 }}>
|
||||||
|
<Ionicons name="close-outline" size={18} color={C.muted} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Hero */}
|
||||||
|
<View style={{ backgroundColor: C.surface, borderRadius: 12, padding: 20, alignItems: 'center', marginBottom: 16 }}>
|
||||||
|
<View style={{
|
||||||
|
width: 56, height: 56, borderRadius: 28,
|
||||||
|
backgroundColor: `${meta.color}18`,
|
||||||
|
alignItems: 'center', justifyContent: 'center', marginBottom: 12,
|
||||||
|
}}>
|
||||||
|
<Ionicons name={meta.icon} size={26} color={meta.color} />
|
||||||
|
</View>
|
||||||
|
<Text style={{ color: C.text, fontSize: 16, fontWeight: '600', marginBottom: amtValue > 0 ? 8 : 0 }}>
|
||||||
|
{meta.label}
|
||||||
|
</Text>
|
||||||
|
{amtValue > 0 && (
|
||||||
|
<Text style={{ color: amtColor, fontSize: 30, fontWeight: '700', letterSpacing: -0.5 }}>
|
||||||
|
{amtSign}{formatAmount(amtValue)}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
<View style={{
|
||||||
|
marginTop: 12, paddingHorizontal: 12, paddingVertical: 4,
|
||||||
|
borderRadius: 999,
|
||||||
|
backgroundColor: tx.status === 'confirmed' ? 'rgba(65,201,138,0.12)' : 'rgba(240,179,90,0.12)',
|
||||||
|
}}>
|
||||||
|
<Text style={{ color: tx.status === 'confirmed' ? C.ok : C.warn, fontSize: 12, fontWeight: '600' }}>
|
||||||
|
{tx.status === 'confirmed' ? '✓ Подтверждена' : '⏳ В обработке'}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Details table */}
|
||||||
|
<View style={{ backgroundColor: C.surface, borderRadius: 12, overflow: 'hidden', marginBottom: 16 }}>
|
||||||
|
<DetailRow icon="code-outline" label="Тип" value={tx.type} mono first />
|
||||||
|
{tx.amount !== undefined && (
|
||||||
|
<DetailRow icon="cash-outline" label="Сумма" value={`${tx.amount.toLocaleString()} µT`} />
|
||||||
|
)}
|
||||||
|
<DetailRow icon="pricetag-outline" label="Комиссия" value={`${(tx.fee ?? 0).toLocaleString()} µT`} />
|
||||||
|
{tx.timestamp > 0 && (
|
||||||
|
<DetailRow icon="time-outline" label="Время" value={new Date(tx.timestamp * 1000).toLocaleString()} />
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Addresses */}
|
||||||
|
<View style={{ backgroundColor: C.surface, borderRadius: 12, overflow: 'hidden', marginBottom: 16 }}>
|
||||||
|
{/* "From" for synthetic txs (empty tx.from) reads "Сеть" rather than an empty row. */}
|
||||||
|
<DetailRow
|
||||||
|
icon="person-outline"
|
||||||
|
label="От"
|
||||||
|
value={tx.from || 'Сеть (синтетическая tx)'}
|
||||||
|
mono={!!tx.from}
|
||||||
|
truncate={!!tx.from}
|
||||||
|
first
|
||||||
|
/>
|
||||||
|
{tx.to && (
|
||||||
|
<DetailRow icon="arrow-forward-outline" label="Кому" value={tx.to} mono truncate />
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* TX Hash */}
|
||||||
|
<View style={{ backgroundColor: C.surface, borderRadius: 12, overflow: 'hidden', marginBottom: 20 }}>
|
||||||
|
<View style={{ paddingHorizontal: 14, paddingVertical: 12 }}>
|
||||||
|
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 6, marginBottom: 6 }}>
|
||||||
|
<Ionicons name="receipt-outline" size={13} color={C.muted} />
|
||||||
|
<Text style={{ color: C.muted, fontSize: 11, letterSpacing: 0.5, textTransform: 'uppercase' }}>
|
||||||
|
TX ID / Hash
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Text style={{ color: C.text, fontFamily: 'monospace', fontSize: 11, lineHeight: 17 }}>
|
||||||
|
{tx.hash}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={copyHash}
|
||||||
|
style={{
|
||||||
|
paddingVertical: 13, borderRadius: 10, alignItems: 'center',
|
||||||
|
flexDirection: 'row', justifyContent: 'center', gap: 8,
|
||||||
|
backgroundColor: copiedHash ? 'rgba(65,201,138,0.12)' : C.surface2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Ionicons
|
||||||
|
name={copiedHash ? 'checkmark-outline' : 'copy-outline'}
|
||||||
|
size={16}
|
||||||
|
color={copiedHash ? C.ok : C.text}
|
||||||
|
/>
|
||||||
|
<Text style={{ color: copiedHash ? C.ok : C.text, fontWeight: '600', fontSize: 14 }}>
|
||||||
|
{copiedHash ? 'Скопировано' : 'Копировать хеш'}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Detail Row ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function DetailRow({
|
||||||
|
icon, label, value, mono, truncate, first,
|
||||||
|
}: {
|
||||||
|
icon: keyof typeof Ionicons.glyphMap;
|
||||||
|
label: string; value: string;
|
||||||
|
mono?: boolean; truncate?: boolean; first?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<View style={{
|
||||||
|
flexDirection: 'row', alignItems: 'center',
|
||||||
|
paddingHorizontal: 14, paddingVertical: 11,
|
||||||
|
borderTopWidth: first ? 0 : 1, borderTopColor: C.line,
|
||||||
|
gap: 10,
|
||||||
|
}}>
|
||||||
|
<Ionicons name={icon} size={14} color={C.muted} style={{ width: 16 }} />
|
||||||
|
<Text style={{ color: C.muted, fontSize: 13, width: 72 }}>{label}</Text>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
color: C.text,
|
||||||
|
flex: 1,
|
||||||
|
textAlign: 'right',
|
||||||
|
fontFamily: mono ? 'monospace' : undefined,
|
||||||
|
fontSize: mono ? 11 : 13,
|
||||||
|
}}
|
||||||
|
numberOfLines={truncate ? 1 : undefined}
|
||||||
|
ellipsizeMode="middle"
|
||||||
|
>
|
||||||
|
{value}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
82
client-app/app/(auth)/create.tsx
Normal file
82
client-app/app/(auth)/create.tsx
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
/**
|
||||||
|
* Create Account screen.
|
||||||
|
* Generates a new Ed25519 + X25519 keypair and saves it securely.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import { View, Text, ScrollView, Alert } from 'react-native';
|
||||||
|
import { router } from 'expo-router';
|
||||||
|
import { generateKeyFile } from '@/lib/crypto';
|
||||||
|
import { saveKeyFile } from '@/lib/storage';
|
||||||
|
import { useStore } from '@/lib/store';
|
||||||
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import { Input } from '@/components/ui/Input';
|
||||||
|
import { Card } from '@/components/ui/Card';
|
||||||
|
|
||||||
|
export default function CreateAccountScreen() {
|
||||||
|
const setKeyFile = useStore(s => s.setKeyFile);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
async function handleCreate() {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const kf = generateKeyFile();
|
||||||
|
await saveKeyFile(kf);
|
||||||
|
setKeyFile(kf);
|
||||||
|
router.replace('/(auth)/created');
|
||||||
|
} catch (e: any) {
|
||||||
|
Alert.alert('Error', e.message);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollView
|
||||||
|
className="flex-1 bg-background"
|
||||||
|
contentContainerClassName="px-6 pt-16 pb-10"
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<Button variant="ghost" size="sm" onPress={() => router.back()} className="self-start mb-6 -ml-2">
|
||||||
|
← Back
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Text className="text-white text-3xl font-bold mb-2">Create Account</Text>
|
||||||
|
<Text className="text-muted text-base mb-8 leading-6">
|
||||||
|
A new identity will be generated on your device.
|
||||||
|
Your private key never leaves this app.
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{/* Info cards */}
|
||||||
|
<Card className="mb-4 gap-3">
|
||||||
|
<InfoRow icon="🔑" label="Ed25519 signing key" desc="Your blockchain address and tx signing key" />
|
||||||
|
<InfoRow icon="🔒" label="X25519 encryption key" desc="End-to-end encryption for messages" />
|
||||||
|
<InfoRow icon="📱" label="Stored on device" desc="Keys are encrypted in the device secure store" />
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="mb-8 border-primary/30 bg-primary/10">
|
||||||
|
<Text className="text-accent text-sm font-semibold mb-1">⚠ Important</Text>
|
||||||
|
<Text className="text-muted text-sm leading-5">
|
||||||
|
After creation, export and backup your key file.
|
||||||
|
If you lose it there is no recovery — the blockchain has no password reset.
|
||||||
|
</Text>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Button onPress={handleCreate} loading={loading} size="lg">
|
||||||
|
Generate Keys & Create Account
|
||||||
|
</Button>
|
||||||
|
</ScrollView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function InfoRow({ icon, label, desc }: { icon: string; label: string; desc: string }) {
|
||||||
|
return (
|
||||||
|
<View className="flex-row items-start gap-3">
|
||||||
|
<Text className="text-xl">{icon}</Text>
|
||||||
|
<View className="flex-1">
|
||||||
|
<Text className="text-white text-sm font-semibold">{label}</Text>
|
||||||
|
<Text className="text-muted text-xs mt-0.5">{desc}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
118
client-app/app/(auth)/created.tsx
Normal file
118
client-app/app/(auth)/created.tsx
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
/**
|
||||||
|
* Account Created confirmation screen.
|
||||||
|
* Shows address, pubkeys, and export options.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import { View, Text, ScrollView, Alert, Share } from 'react-native';
|
||||||
|
import { router } from 'expo-router';
|
||||||
|
import * as Clipboard from 'expo-clipboard';
|
||||||
|
import * as FileSystem from 'expo-file-system';
|
||||||
|
import * as Sharing from 'expo-sharing';
|
||||||
|
import { useStore } from '@/lib/store';
|
||||||
|
import { shortAddr } from '@/lib/crypto';
|
||||||
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import { Card } from '@/components/ui/Card';
|
||||||
|
import { Separator } from '@/components/ui/Separator';
|
||||||
|
|
||||||
|
export default function AccountCreatedScreen() {
|
||||||
|
const keyFile = useStore(s => s.keyFile);
|
||||||
|
const [copied, setCopied] = useState<string | null>(null);
|
||||||
|
|
||||||
|
if (!keyFile) {
|
||||||
|
router.replace('/');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copy(value: string, label: string) {
|
||||||
|
await Clipboard.setStringAsync(value);
|
||||||
|
setCopied(label);
|
||||||
|
setTimeout(() => setCopied(null), 2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function exportKey() {
|
||||||
|
try {
|
||||||
|
const json = JSON.stringify(keyFile, null, 2);
|
||||||
|
const path = FileSystem.cacheDirectory + 'dchain_key.json';
|
||||||
|
await FileSystem.writeAsStringAsync(path, json);
|
||||||
|
if (await Sharing.isAvailableAsync()) {
|
||||||
|
await Sharing.shareAsync(path, {
|
||||||
|
mimeType: 'application/json',
|
||||||
|
dialogTitle: 'Save your DChain key file',
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
Alert.alert('Export', 'Sharing not available on this device.');
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
Alert.alert('Export failed', e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollView
|
||||||
|
className="flex-1 bg-background"
|
||||||
|
contentContainerClassName="px-6 pt-16 pb-10"
|
||||||
|
>
|
||||||
|
{/* Success header */}
|
||||||
|
<View className="items-center mb-8">
|
||||||
|
<View className="w-20 h-20 rounded-full bg-success/20 items-center justify-center mb-4">
|
||||||
|
<Text className="text-4xl">✓</Text>
|
||||||
|
</View>
|
||||||
|
<Text className="text-white text-2xl font-bold">Account Created!</Text>
|
||||||
|
<Text className="text-muted text-sm mt-2 text-center">
|
||||||
|
Your keys have been generated and stored securely.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Address card */}
|
||||||
|
<Card className="mb-4">
|
||||||
|
<Text className="text-muted text-xs uppercase tracking-widest mb-3 font-semibold">
|
||||||
|
Your Address (Ed25519)
|
||||||
|
</Text>
|
||||||
|
<Text className="text-white font-mono text-xs leading-5 mb-3">
|
||||||
|
{keyFile.pub_key}
|
||||||
|
</Text>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onPress={() => copy(keyFile.pub_key, 'address')}
|
||||||
|
>
|
||||||
|
{copied === 'address' ? '✓ Copied' : 'Copy Address'}
|
||||||
|
</Button>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* X25519 key */}
|
||||||
|
<Card className="mb-4">
|
||||||
|
<Text className="text-muted text-xs uppercase tracking-widest mb-3 font-semibold">
|
||||||
|
Encryption Key (X25519)
|
||||||
|
</Text>
|
||||||
|
<Text className="text-white font-mono text-xs leading-5 mb-3">
|
||||||
|
{keyFile.x25519_pub}
|
||||||
|
</Text>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onPress={() => copy(keyFile.x25519_pub, 'x25519')}
|
||||||
|
>
|
||||||
|
{copied === 'x25519' ? '✓ Copied' : 'Copy Encryption Key'}
|
||||||
|
</Button>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Export warning */}
|
||||||
|
<Card className="mb-8 border-yellow-500/30 bg-yellow-500/10">
|
||||||
|
<Text className="text-yellow-400 text-sm font-semibold mb-2">🔐 Backup your key file</Text>
|
||||||
|
<Text className="text-muted text-xs leading-5 mb-3">
|
||||||
|
Export <Text className="text-white font-mono">dchain_key.json</Text> and store it safely.
|
||||||
|
This file contains your private keys — keep it secret.
|
||||||
|
</Text>
|
||||||
|
<Button variant="outline" onPress={exportKey}>
|
||||||
|
Export key.json
|
||||||
|
</Button>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Button size="lg" onPress={() => router.replace('/(app)/chats')}>
|
||||||
|
Open Messenger
|
||||||
|
</Button>
|
||||||
|
</ScrollView>
|
||||||
|
);
|
||||||
|
}
|
||||||
301
client-app/app/(auth)/import.tsx
Normal file
301
client-app/app/(auth)/import.tsx
Normal file
@@ -0,0 +1,301 @@
|
|||||||
|
/**
|
||||||
|
* Import Existing Key screen.
|
||||||
|
* Two methods:
|
||||||
|
* 1. Paste JSON directly into a text field
|
||||||
|
* 2. Pick key.json file via document picker
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import {
|
||||||
|
View, Text, ScrollView, TextInput,
|
||||||
|
TouchableOpacity, Alert, Pressable,
|
||||||
|
} from 'react-native';
|
||||||
|
import { router } from 'expo-router';
|
||||||
|
import * as DocumentPicker from 'expo-document-picker';
|
||||||
|
import * as Clipboard from 'expo-clipboard';
|
||||||
|
import { saveKeyFile } from '@/lib/storage';
|
||||||
|
import { useStore } from '@/lib/store';
|
||||||
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import type { KeyFile } from '@/lib/types';
|
||||||
|
|
||||||
|
type Tab = 'paste' | 'file';
|
||||||
|
|
||||||
|
const REQUIRED_FIELDS: (keyof KeyFile)[] = ['pub_key', 'priv_key', 'x25519_pub', 'x25519_priv'];
|
||||||
|
|
||||||
|
function validateKeyFile(raw: string): KeyFile {
|
||||||
|
let parsed: any;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(raw.trim());
|
||||||
|
} catch {
|
||||||
|
throw new Error('Invalid JSON — check that you copied the full key file contents.');
|
||||||
|
}
|
||||||
|
for (const field of REQUIRED_FIELDS) {
|
||||||
|
if (!parsed[field] || typeof parsed[field] !== 'string') {
|
||||||
|
throw new Error(`Missing or invalid field: "${field}"`);
|
||||||
|
}
|
||||||
|
if (!/^[0-9a-f]+$/i.test(parsed[field])) {
|
||||||
|
throw new Error(`Field "${field}" must be a hex string.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return parsed as KeyFile;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ImportKeyScreen() {
|
||||||
|
const setKeyFile = useStore(s => s.setKeyFile);
|
||||||
|
|
||||||
|
const [tab, setTab] = useState<Tab>('paste');
|
||||||
|
const [jsonText, setJsonText] = useState('');
|
||||||
|
const [fileName, setFileName] = useState<string | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// ── Shared: save validated key and navigate ──────────────────────────────
|
||||||
|
async function applyKey(kf: KeyFile) {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await saveKeyFile(kf);
|
||||||
|
setKeyFile(kf);
|
||||||
|
router.replace('/(app)/chats');
|
||||||
|
} catch (e: any) {
|
||||||
|
setError(e.message);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Method 1: paste JSON ─────────────────────────────────────────────────
|
||||||
|
async function handlePasteImport() {
|
||||||
|
setError(null);
|
||||||
|
const text = jsonText.trim();
|
||||||
|
if (!text) {
|
||||||
|
// Try reading clipboard if field is empty
|
||||||
|
const clip = await Clipboard.getStringAsync();
|
||||||
|
if (clip) setJsonText(clip);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const kf = validateKeyFile(text);
|
||||||
|
await applyKey(kf);
|
||||||
|
} catch (e: any) {
|
||||||
|
setError(e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Method 2: pick file ──────────────────────────────────────────────────
|
||||||
|
async function pickFile() {
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const result = await DocumentPicker.getDocumentAsync({
|
||||||
|
type: ['application/json', 'text/plain', '*/*'],
|
||||||
|
copyToCacheDirectory: true,
|
||||||
|
});
|
||||||
|
if (result.canceled) return;
|
||||||
|
|
||||||
|
const asset = result.assets[0];
|
||||||
|
setFileName(asset.name);
|
||||||
|
|
||||||
|
// Use fetch() — readAsStringAsync is deprecated in newer expo-file-system
|
||||||
|
const response = await fetch(asset.uri);
|
||||||
|
const raw = await response.text();
|
||||||
|
|
||||||
|
const kf = validateKeyFile(raw);
|
||||||
|
await applyKey(kf);
|
||||||
|
} catch (e: any) {
|
||||||
|
setError(e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const tabStyle = (t: Tab) => ({
|
||||||
|
flex: 1 as const,
|
||||||
|
paddingVertical: 10,
|
||||||
|
alignItems: 'center' as const,
|
||||||
|
borderBottomWidth: 2,
|
||||||
|
borderBottomColor: tab === t ? '#2563eb' : 'transparent',
|
||||||
|
});
|
||||||
|
|
||||||
|
const tabTextStyle = (t: Tab) => ({
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: '600' as const,
|
||||||
|
color: tab === t ? '#fff' : '#8b949e',
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollView
|
||||||
|
style={{ flex: 1, backgroundColor: '#0d1117' }}
|
||||||
|
contentContainerStyle={{ paddingHorizontal: 20, paddingTop: 60, paddingBottom: 40 }}
|
||||||
|
keyboardShouldPersistTaps="handled"
|
||||||
|
keyboardDismissMode="on-drag"
|
||||||
|
>
|
||||||
|
{/* Back */}
|
||||||
|
<Pressable onPress={() => router.back()} style={{ marginBottom: 24, alignSelf: 'flex-start' }}>
|
||||||
|
<Text style={{ color: '#2563eb', fontSize: 15 }}>← Back</Text>
|
||||||
|
</Pressable>
|
||||||
|
|
||||||
|
<Text style={{ color: '#fff', fontSize: 28, fontWeight: '700', marginBottom: 6 }}>
|
||||||
|
Import Key
|
||||||
|
</Text>
|
||||||
|
<Text style={{ color: '#8b949e', fontSize: 15, lineHeight: 22, marginBottom: 24 }}>
|
||||||
|
Restore your account from an existing{' '}
|
||||||
|
<Text style={{ color: '#fff', fontFamily: 'monospace' }}>key.json</Text>.
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{/* Tabs */}
|
||||||
|
<View style={{
|
||||||
|
flexDirection: 'row',
|
||||||
|
borderBottomWidth: 1, borderBottomColor: '#30363d',
|
||||||
|
marginBottom: 24,
|
||||||
|
}}>
|
||||||
|
<TouchableOpacity style={tabStyle('paste')} onPress={() => setTab('paste')}>
|
||||||
|
<Text style={tabTextStyle('paste')}>📋 Paste JSON</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity style={tabStyle('file')} onPress={() => setTab('file')}>
|
||||||
|
<Text style={tabTextStyle('file')}>📁 Open File</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* ── Paste tab ── */}
|
||||||
|
{tab === 'paste' && (
|
||||||
|
<View style={{ gap: 12 }}>
|
||||||
|
<Text style={{ color: '#8b949e', fontSize: 12, fontWeight: '600',
|
||||||
|
textTransform: 'uppercase', letterSpacing: 1 }}>
|
||||||
|
Key JSON
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<View style={{
|
||||||
|
backgroundColor: '#161b22',
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: error ? '#f85149' : jsonText ? '#2563eb' : '#30363d',
|
||||||
|
borderRadius: 12,
|
||||||
|
padding: 12,
|
||||||
|
}}>
|
||||||
|
<TextInput
|
||||||
|
value={jsonText}
|
||||||
|
onChangeText={t => { setJsonText(t); setError(null); }}
|
||||||
|
placeholder={'{\n "pub_key": "...",\n "priv_key": "...",\n "x25519_pub": "...",\n "x25519_priv": "..."\n}'}
|
||||||
|
placeholderTextColor="#8b949e"
|
||||||
|
multiline
|
||||||
|
numberOfLines={8}
|
||||||
|
autoCapitalize="none"
|
||||||
|
autoCorrect={false}
|
||||||
|
style={{
|
||||||
|
color: '#fff',
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
fontSize: 12,
|
||||||
|
lineHeight: 18,
|
||||||
|
minHeight: 160,
|
||||||
|
textAlignVertical: 'top',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Paste from clipboard shortcut */}
|
||||||
|
{!jsonText && (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={async () => {
|
||||||
|
const clip = await Clipboard.getStringAsync();
|
||||||
|
if (clip) { setJsonText(clip); setError(null); }
|
||||||
|
else Alert.alert('Clipboard empty', 'Copy your key JSON first.');
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
flexDirection: 'row', alignItems: 'center', gap: 8,
|
||||||
|
padding: 12, backgroundColor: '#161b22',
|
||||||
|
borderWidth: 1, borderColor: '#30363d', borderRadius: 12,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text style={{ fontSize: 18 }}>📋</Text>
|
||||||
|
<Text style={{ color: '#8b949e', fontSize: 14 }}>Paste from clipboard</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<View style={{
|
||||||
|
backgroundColor: 'rgba(248,81,73,0.1)',
|
||||||
|
borderWidth: 1, borderColor: 'rgba(248,81,73,0.3)',
|
||||||
|
borderRadius: 10, padding: 12,
|
||||||
|
}}>
|
||||||
|
<Text style={{ color: '#f85149', fontSize: 13, lineHeight: 18 }}>⚠ {error}</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
size="lg"
|
||||||
|
loading={loading}
|
||||||
|
disabled={!jsonText.trim()}
|
||||||
|
onPress={handlePasteImport}
|
||||||
|
>
|
||||||
|
Import Key
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── File tab ── */}
|
||||||
|
{tab === 'file' && (
|
||||||
|
<View style={{ gap: 12 }}>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={pickFile}
|
||||||
|
style={{
|
||||||
|
backgroundColor: '#161b22',
|
||||||
|
borderWidth: 2, borderColor: '#30363d',
|
||||||
|
borderRadius: 16, borderStyle: 'dashed',
|
||||||
|
padding: 32, alignItems: 'center', gap: 12,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text style={{ fontSize: 40 }}>📂</Text>
|
||||||
|
<Text style={{ color: '#fff', fontSize: 16, fontWeight: '600' }}>
|
||||||
|
{fileName ?? 'Choose key.json'}
|
||||||
|
</Text>
|
||||||
|
<Text style={{ color: '#8b949e', fontSize: 13, textAlign: 'center' }}>
|
||||||
|
Tap to browse files
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
{fileName && (
|
||||||
|
<View style={{
|
||||||
|
flexDirection: 'row', alignItems: 'center', gap: 10,
|
||||||
|
backgroundColor: 'rgba(63,185,80,0.1)',
|
||||||
|
borderWidth: 1, borderColor: 'rgba(63,185,80,0.3)',
|
||||||
|
borderRadius: 10, padding: 12,
|
||||||
|
}}>
|
||||||
|
<Text style={{ fontSize: 18 }}>📄</Text>
|
||||||
|
<Text style={{ color: '#3fb950', fontSize: 13, flex: 1 }} numberOfLines={1}>
|
||||||
|
{fileName}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<View style={{
|
||||||
|
backgroundColor: 'rgba(248,81,73,0.1)',
|
||||||
|
borderWidth: 1, borderColor: 'rgba(248,81,73,0.3)',
|
||||||
|
borderRadius: 10, padding: 12,
|
||||||
|
}}>
|
||||||
|
<Text style={{ color: '#f85149', fontSize: 13, lineHeight: 18 }}>⚠ {error}</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loading && (
|
||||||
|
<Text style={{ color: '#8b949e', textAlign: 'center', fontSize: 14 }}>
|
||||||
|
Validating key…
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Format hint */}
|
||||||
|
<View style={{
|
||||||
|
marginTop: 28, padding: 14,
|
||||||
|
backgroundColor: '#161b22',
|
||||||
|
borderWidth: 1, borderColor: '#30363d', borderRadius: 12,
|
||||||
|
}}>
|
||||||
|
<Text style={{ color: '#8b949e', fontSize: 12, fontWeight: '600',
|
||||||
|
marginBottom: 8, textTransform: 'uppercase', letterSpacing: 1 }}>
|
||||||
|
Expected format
|
||||||
|
</Text>
|
||||||
|
<Text style={{ color: '#8b949e', fontFamily: 'monospace', fontSize: 11, lineHeight: 17 }}>
|
||||||
|
{`{\n "pub_key": "<64 hex chars>",\n "priv_key": "<128 hex chars>",\n "x25519_pub": "<64 hex chars>",\n "x25519_priv": "<64 hex chars>"\n}`}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
);
|
||||||
|
}
|
||||||
40
client-app/app/_layout.tsx
Normal file
40
client-app/app/_layout.tsx
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import '../global.css';
|
||||||
|
|
||||||
|
import React, { useEffect } from 'react';
|
||||||
|
import { Stack } from 'expo-router';
|
||||||
|
import { StatusBar } from 'expo-status-bar';
|
||||||
|
import { View } from 'react-native';
|
||||||
|
import { SafeAreaProvider } from 'react-native-safe-area-context';
|
||||||
|
import { loadKeyFile, loadSettings } from '@/lib/storage';
|
||||||
|
import { setNodeUrl } from '@/lib/api';
|
||||||
|
import { useStore } from '@/lib/store';
|
||||||
|
|
||||||
|
export default function RootLayout() {
|
||||||
|
const setKeyFile = useStore(s => s.setKeyFile);
|
||||||
|
const setSettings = useStore(s => s.setSettings);
|
||||||
|
|
||||||
|
// Bootstrap: load key + settings from storage
|
||||||
|
useEffect(() => {
|
||||||
|
(async () => {
|
||||||
|
const [kf, settings] = await Promise.all([loadKeyFile(), loadSettings()]);
|
||||||
|
if (kf) setKeyFile(kf);
|
||||||
|
setSettings(settings);
|
||||||
|
setNodeUrl(settings.nodeUrl);
|
||||||
|
})();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SafeAreaProvider>
|
||||||
|
<View className="flex-1 bg-background">
|
||||||
|
<StatusBar style="light" />
|
||||||
|
<Stack
|
||||||
|
screenOptions={{
|
||||||
|
headerShown: false,
|
||||||
|
contentStyle: { backgroundColor: '#0b1220' },
|
||||||
|
animation: 'slide_from_right',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</SafeAreaProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
220
client-app/app/index.tsx
Normal file
220
client-app/app/index.tsx
Normal file
@@ -0,0 +1,220 @@
|
|||||||
|
/**
|
||||||
|
* Welcome / landing screen.
|
||||||
|
* - Node URL input with live ping + QR scanner
|
||||||
|
* - Create / Import account buttons
|
||||||
|
* Redirects to (app)/chats if key already loaded.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useEffect, useState, useCallback } from 'react';
|
||||||
|
import {
|
||||||
|
View, Text, TextInput, Pressable,
|
||||||
|
ScrollView, Alert, ActivityIndicator,
|
||||||
|
} from 'react-native';
|
||||||
|
import { router } from 'expo-router';
|
||||||
|
import { CameraView, useCameraPermissions } from 'expo-camera';
|
||||||
|
import { useStore } from '@/lib/store';
|
||||||
|
import { saveSettings } from '@/lib/storage';
|
||||||
|
import { setNodeUrl, getNetStats } from '@/lib/api';
|
||||||
|
import { Button } from '@/components/ui/Button';
|
||||||
|
|
||||||
|
export default function WelcomeScreen() {
|
||||||
|
const keyFile = useStore(s => s.keyFile);
|
||||||
|
const settings = useStore(s => s.settings);
|
||||||
|
const setSettings = useStore(s => s.setSettings);
|
||||||
|
|
||||||
|
const [nodeInput, setNodeInput] = useState('');
|
||||||
|
const [scanning, setScanning] = useState(false);
|
||||||
|
const [checking, setChecking] = useState(false);
|
||||||
|
const [nodeOk, setNodeOk] = useState<boolean | null>(null);
|
||||||
|
|
||||||
|
const [permission, requestPermission] = useCameraPermissions();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (keyFile) router.replace('/(app)/chats');
|
||||||
|
}, [keyFile]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setNodeInput(settings.nodeUrl);
|
||||||
|
}, [settings.nodeUrl]);
|
||||||
|
|
||||||
|
const applyNode = useCallback(async (url: string) => {
|
||||||
|
const clean = url.trim().replace(/\/$/, '');
|
||||||
|
if (!clean) return;
|
||||||
|
setChecking(true);
|
||||||
|
setNodeOk(null);
|
||||||
|
setNodeUrl(clean);
|
||||||
|
try {
|
||||||
|
await getNetStats();
|
||||||
|
setNodeOk(true);
|
||||||
|
const next = { ...settings, nodeUrl: clean };
|
||||||
|
setSettings(next);
|
||||||
|
await saveSettings(next);
|
||||||
|
} catch {
|
||||||
|
setNodeOk(false);
|
||||||
|
} finally {
|
||||||
|
setChecking(false);
|
||||||
|
}
|
||||||
|
}, [settings, setSettings]);
|
||||||
|
|
||||||
|
const onQrScanned = useCallback(({ data }: { data: string }) => {
|
||||||
|
setScanning(false);
|
||||||
|
let url = data.trim();
|
||||||
|
try { const p = JSON.parse(url); if (p.nodeUrl) url = p.nodeUrl; } catch {}
|
||||||
|
setNodeInput(url);
|
||||||
|
applyNode(url);
|
||||||
|
}, [applyNode]);
|
||||||
|
|
||||||
|
const openScanner = async () => {
|
||||||
|
if (!permission?.granted) {
|
||||||
|
const { granted } = await requestPermission();
|
||||||
|
if (!granted) {
|
||||||
|
Alert.alert('Camera permission required', 'Allow camera access to scan QR codes.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setScanning(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── QR Scanner overlay ───────────────────────────────────────────────────
|
||||||
|
if (scanning) {
|
||||||
|
return (
|
||||||
|
<View style={{ flex: 1, backgroundColor: '#000' }}>
|
||||||
|
<CameraView
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
facing="back"
|
||||||
|
barcodeScannerSettings={{ barcodeTypes: ['qr'] }}
|
||||||
|
onBarcodeScanned={onQrScanned}
|
||||||
|
/>
|
||||||
|
<View style={{
|
||||||
|
position: 'absolute', top: 0, left: 0, right: 0, bottom: 0,
|
||||||
|
alignItems: 'center', justifyContent: 'center',
|
||||||
|
}}>
|
||||||
|
<View style={{
|
||||||
|
width: 240, height: 240,
|
||||||
|
borderWidth: 2, borderColor: 'white', borderRadius: 16,
|
||||||
|
}} />
|
||||||
|
<Text style={{ color: 'white', marginTop: 20, opacity: 0.8 }}>
|
||||||
|
Point at a DChain node QR code
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Pressable
|
||||||
|
onPress={() => setScanning(false)}
|
||||||
|
style={{
|
||||||
|
position: 'absolute', top: 56, left: 16,
|
||||||
|
backgroundColor: 'rgba(0,0,0,0.6)', borderRadius: 20,
|
||||||
|
paddingHorizontal: 16, paddingVertical: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text style={{ color: 'white', fontSize: 16 }}>✕ Cancel</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Main screen ──────────────────────────────────────────────────────────
|
||||||
|
const statusColor = nodeOk === true ? '#3fb950' : nodeOk === false ? '#f85149' : '#8b949e';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollView
|
||||||
|
style={{ flex: 1, backgroundColor: '#0d1117' }}
|
||||||
|
contentContainerStyle={{ flexGrow: 1, paddingHorizontal: 24, paddingTop: 80, paddingBottom: 40 }}
|
||||||
|
keyboardShouldPersistTaps="handled"
|
||||||
|
keyboardDismissMode="on-drag"
|
||||||
|
>
|
||||||
|
{/* Logo ─ takes remaining space above, centered */}
|
||||||
|
<View style={{ flex: 1, alignItems: 'center', justifyContent: 'center', gap: 16 }}>
|
||||||
|
<View style={{
|
||||||
|
width: 96, height: 96, borderRadius: 24,
|
||||||
|
backgroundColor: '#2563eb', alignItems: 'center', justifyContent: 'center',
|
||||||
|
}}>
|
||||||
|
<Text style={{ fontSize: 48 }}>⛓</Text>
|
||||||
|
</View>
|
||||||
|
<Text style={{ color: '#fff', fontSize: 36, fontWeight: '700', letterSpacing: -1 }}>
|
||||||
|
DChain
|
||||||
|
</Text>
|
||||||
|
<Text style={{ color: '#8b949e', textAlign: 'center', fontSize: 15, lineHeight: 22 }}>
|
||||||
|
Decentralised E2E-encrypted messenger.{'\n'}Your keys. Your messages.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Bottom section ─ node input + buttons */}
|
||||||
|
<View style={{ gap: 12, marginTop: 40 }}>
|
||||||
|
|
||||||
|
{/* Node URL label */}
|
||||||
|
<Text style={{ color: '#8b949e', fontSize: 11, fontWeight: '600',
|
||||||
|
textTransform: 'uppercase', letterSpacing: 1.2, marginBottom: 2 }}>
|
||||||
|
Node URL
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{/* Input row */}
|
||||||
|
<View style={{ flexDirection: 'row', gap: 8 }}>
|
||||||
|
<View style={{
|
||||||
|
flex: 1, flexDirection: 'row', alignItems: 'center',
|
||||||
|
backgroundColor: '#21262d', borderWidth: 1, borderColor: '#30363d',
|
||||||
|
borderRadius: 12, paddingHorizontal: 12, gap: 8,
|
||||||
|
}}>
|
||||||
|
{/* Status dot */}
|
||||||
|
<View style={{ width: 8, height: 8, borderRadius: 4, backgroundColor: statusColor }} />
|
||||||
|
|
||||||
|
<TextInput
|
||||||
|
value={nodeInput}
|
||||||
|
onChangeText={t => { setNodeInput(t); setNodeOk(null); }}
|
||||||
|
onEndEditing={() => applyNode(nodeInput)}
|
||||||
|
onSubmitEditing={() => applyNode(nodeInput)}
|
||||||
|
placeholder="http://192.168.1.10:8081"
|
||||||
|
placeholderTextColor="#8b949e"
|
||||||
|
autoCapitalize="none"
|
||||||
|
autoCorrect={false}
|
||||||
|
keyboardType="url"
|
||||||
|
returnKeyType="done"
|
||||||
|
style={{ flex: 1, color: '#fff', fontSize: 14, paddingVertical: 14 }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{checking
|
||||||
|
? <ActivityIndicator size="small" color="#8b949e" />
|
||||||
|
: nodeOk === true
|
||||||
|
? <Text style={{ color: '#3fb950', fontSize: 16 }}>✓</Text>
|
||||||
|
: nodeOk === false
|
||||||
|
? <Text style={{ color: '#f85149', fontSize: 14 }}>✗</Text>
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* QR button */}
|
||||||
|
<Pressable
|
||||||
|
onPress={openScanner}
|
||||||
|
style={({ pressed }) => ({
|
||||||
|
width: 48, alignItems: 'center', justifyContent: 'center',
|
||||||
|
backgroundColor: '#21262d', borderWidth: 1, borderColor: '#30363d',
|
||||||
|
borderRadius: 12, opacity: pressed ? 0.7 : 1,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Text style={{ fontSize: 22 }}>▦</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Status text */}
|
||||||
|
{nodeOk === true && (
|
||||||
|
<Text style={{ color: '#3fb950', fontSize: 12, marginTop: -4 }}>
|
||||||
|
✓ Node connected
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{nodeOk === false && (
|
||||||
|
<Text style={{ color: '#f85149', fontSize: 12, marginTop: -4 }}>
|
||||||
|
✗ Cannot reach node — check URL and that the node is running
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Buttons */}
|
||||||
|
<View style={{ gap: 10, marginTop: 4 }}>
|
||||||
|
<Button size="lg" onPress={() => router.push('/(auth)/create')}>
|
||||||
|
Create New Account
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="lg" onPress={() => router.push('/(auth)/import')}>
|
||||||
|
Import Existing Key
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
);
|
||||||
|
}
|
||||||
12
client-app/babel.config.js
Normal file
12
client-app/babel.config.js
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
module.exports = function (api) {
|
||||||
|
api.cache(true);
|
||||||
|
return {
|
||||||
|
presets: [
|
||||||
|
['babel-preset-expo', { jsxImportSource: 'nativewind' }],
|
||||||
|
'nativewind/babel',
|
||||||
|
],
|
||||||
|
plugins: [
|
||||||
|
'react-native-reanimated/plugin', // must be last
|
||||||
|
],
|
||||||
|
};
|
||||||
|
};
|
||||||
37
client-app/components/ui/Avatar.tsx
Normal file
37
client-app/components/ui/Avatar.tsx
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { View, Text } from 'react-native';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
/** Deterministic color from a string */
|
||||||
|
function colorFor(str: string): string {
|
||||||
|
const colors = [
|
||||||
|
'bg-blue-600', 'bg-purple-600', 'bg-green-600',
|
||||||
|
'bg-pink-600', 'bg-orange-600', 'bg-teal-600',
|
||||||
|
'bg-red-600', 'bg-indigo-600', 'bg-cyan-600',
|
||||||
|
];
|
||||||
|
let h = 0;
|
||||||
|
for (let i = 0; i < str.length; i++) h = (h * 31 + str.charCodeAt(i)) >>> 0;
|
||||||
|
return colors[h % colors.length];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AvatarProps {
|
||||||
|
name?: string;
|
||||||
|
size?: 'sm' | 'md' | 'lg';
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sizeMap = {
|
||||||
|
sm: { outer: 'w-8 h-8', text: 'text-sm' },
|
||||||
|
md: { outer: 'w-10 h-10', text: 'text-base' },
|
||||||
|
lg: { outer: 'w-14 h-14', text: 'text-xl' },
|
||||||
|
};
|
||||||
|
|
||||||
|
export function Avatar({ name = '?', size = 'md', className }: AvatarProps) {
|
||||||
|
const initials = name.slice(0, 2).toUpperCase();
|
||||||
|
const { outer, text } = sizeMap[size];
|
||||||
|
return (
|
||||||
|
<View className={cn(outer, colorFor(name), 'rounded-full items-center justify-center', className)}>
|
||||||
|
<Text className={cn(text, 'text-white font-bold')}>{initials}</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
24
client-app/components/ui/Badge.tsx
Normal file
24
client-app/components/ui/Badge.tsx
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { View, Text } from 'react-native';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
interface BadgeProps {
|
||||||
|
label: string | number;
|
||||||
|
variant?: 'default' | 'success' | 'destructive' | 'muted';
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const variantMap = {
|
||||||
|
default: 'bg-primary',
|
||||||
|
success: 'bg-success',
|
||||||
|
destructive: 'bg-destructive',
|
||||||
|
muted: 'bg-surfaceHigh border border-border',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function Badge({ label, variant = 'default', className }: BadgeProps) {
|
||||||
|
return (
|
||||||
|
<View className={cn('rounded-full px-2 py-0.5 items-center justify-center', variantMap[variant], className)}>
|
||||||
|
<Text className="text-white text-xs font-semibold">{label}</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
76
client-app/components/ui/Button.tsx
Normal file
76
client-app/components/ui/Button.tsx
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Pressable, Text, ActivityIndicator } from 'react-native';
|
||||||
|
import { cva, type VariantProps } from 'class-variance-authority';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
const buttonVariants = cva(
|
||||||
|
'flex-row items-center justify-center rounded-xl px-5 py-3 active:opacity-80',
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: 'bg-primary',
|
||||||
|
secondary: 'bg-surfaceHigh border border-border',
|
||||||
|
destructive: 'bg-destructive',
|
||||||
|
ghost: 'bg-transparent',
|
||||||
|
outline: 'bg-transparent border border-primary',
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
sm: 'px-3 py-2',
|
||||||
|
md: 'px-5 py-3',
|
||||||
|
lg: 'px-6 py-4',
|
||||||
|
icon: 'p-2',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: 'default',
|
||||||
|
size: 'md',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const textVariants = cva('font-semibold text-center', {
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: 'text-white',
|
||||||
|
secondary: 'text-white',
|
||||||
|
destructive: 'text-white',
|
||||||
|
ghost: 'text-primary',
|
||||||
|
outline: 'text-primary',
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
sm: 'text-sm',
|
||||||
|
md: 'text-base',
|
||||||
|
lg: 'text-lg',
|
||||||
|
icon: 'text-base',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: 'default',
|
||||||
|
size: 'md',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
interface ButtonProps extends VariantProps<typeof buttonVariants> {
|
||||||
|
onPress?: () => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
loading?: boolean;
|
||||||
|
children: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Button({
|
||||||
|
variant, size, onPress, disabled, loading, children, className,
|
||||||
|
}: ButtonProps) {
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
onPress={onPress}
|
||||||
|
disabled={disabled || loading}
|
||||||
|
className={cn(buttonVariants({ variant, size }), disabled && 'opacity-50', className)}
|
||||||
|
>
|
||||||
|
{loading
|
||||||
|
? <ActivityIndicator color="#fff" size="small" />
|
||||||
|
: <Text className={textVariants({ variant, size })}>{children}</Text>
|
||||||
|
}
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
}
|
||||||
16
client-app/components/ui/Card.tsx
Normal file
16
client-app/components/ui/Card.tsx
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { View } from 'react-native';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
interface CardProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Card({ children, className }: CardProps) {
|
||||||
|
return (
|
||||||
|
<View className={cn('bg-surface border border-border rounded-2xl p-4', className)}>
|
||||||
|
{children}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
34
client-app/components/ui/Input.tsx
Normal file
34
client-app/components/ui/Input.tsx
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import React, { forwardRef } from 'react';
|
||||||
|
import { TextInput, View, Text, type TextInputProps } from 'react-native';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
interface InputProps extends TextInputProps {
|
||||||
|
label?: string;
|
||||||
|
error?: string;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Input = forwardRef<TextInput, InputProps>(
|
||||||
|
({ label, error, className, ...props }, ref) => (
|
||||||
|
<View className="w-full gap-1">
|
||||||
|
{label && (
|
||||||
|
<Text className="text-muted text-sm font-medium mb-1">{label}</Text>
|
||||||
|
)}
|
||||||
|
<TextInput
|
||||||
|
ref={ref}
|
||||||
|
placeholderTextColor="#8b949e"
|
||||||
|
className={cn(
|
||||||
|
'bg-surfaceHigh border border-border rounded-xl px-4 py-3 text-white text-base',
|
||||||
|
error && 'border-destructive',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
{error && (
|
||||||
|
<Text className="text-destructive text-xs mt-1">{error}</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
Input.displayName = 'Input';
|
||||||
7
client-app/components/ui/Separator.tsx
Normal file
7
client-app/components/ui/Separator.tsx
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { View } from 'react-native';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
export function Separator({ className }: { className?: string }) {
|
||||||
|
return <View className={cn('h-px bg-border my-2', className)} />;
|
||||||
|
}
|
||||||
6
client-app/components/ui/index.ts
Normal file
6
client-app/components/ui/index.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export { Button } from './Button';
|
||||||
|
export { Card } from './Card';
|
||||||
|
export { Input } from './Input';
|
||||||
|
export { Avatar } from './Avatar';
|
||||||
|
export { Badge } from './Badge';
|
||||||
|
export { Separator } from './Separator';
|
||||||
3
client-app/global.css
Normal file
3
client-app/global.css
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
94
client-app/hooks/useBalance.ts
Normal file
94
client-app/hooks/useBalance.ts
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
/**
|
||||||
|
* Balance hook — uses the WebSocket gateway to receive instant updates when
|
||||||
|
* a tx involving the current address is committed, with HTTP polling as a
|
||||||
|
* graceful fallback for old nodes that don't expose /api/ws.
|
||||||
|
*
|
||||||
|
* Flow:
|
||||||
|
* 1. On mount: immediate HTTP fetch so the UI has a non-zero balance ASAP
|
||||||
|
* 2. Subscribe to `addr:<my_pubkey>` on the WS hub
|
||||||
|
* 3. On every `tx` event, re-fetch balance (cheap — one Badger read server-side)
|
||||||
|
* 4. If WS disconnects for >15s, fall back to 10-second polling until it reconnects
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect, useCallback, useRef } from 'react';
|
||||||
|
import { getBalance } from '@/lib/api';
|
||||||
|
import { getWSClient } from '@/lib/ws';
|
||||||
|
import { useStore } from '@/lib/store';
|
||||||
|
|
||||||
|
const FALLBACK_POLL_INTERVAL = 10_000; // HTTP poll when WS is down
|
||||||
|
const WS_GRACE_BEFORE_POLLING = 15_000; // don't start polling immediately on disconnect
|
||||||
|
|
||||||
|
export function useBalance() {
|
||||||
|
const keyFile = useStore(s => s.keyFile);
|
||||||
|
const setBalance = useStore(s => s.setBalance);
|
||||||
|
|
||||||
|
const refresh = useCallback(async () => {
|
||||||
|
if (!keyFile) return;
|
||||||
|
try {
|
||||||
|
const bal = await getBalance(keyFile.pub_key);
|
||||||
|
setBalance(bal);
|
||||||
|
} catch {
|
||||||
|
// transient — next call will retry
|
||||||
|
}
|
||||||
|
}, [keyFile, setBalance]);
|
||||||
|
|
||||||
|
// --- fallback polling management ---
|
||||||
|
const pollTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
|
const disconnectSinceRef = useRef<number | null>(null);
|
||||||
|
const disconnectTORef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
|
const startPolling = useCallback(() => {
|
||||||
|
if (pollTimerRef.current) return;
|
||||||
|
console.log('[useBalance] WS down for grace period — starting HTTP poll');
|
||||||
|
refresh();
|
||||||
|
pollTimerRef.current = setInterval(refresh, FALLBACK_POLL_INTERVAL);
|
||||||
|
}, [refresh]);
|
||||||
|
|
||||||
|
const stopPolling = useCallback(() => {
|
||||||
|
if (pollTimerRef.current) {
|
||||||
|
clearInterval(pollTimerRef.current);
|
||||||
|
pollTimerRef.current = null;
|
||||||
|
}
|
||||||
|
if (disconnectTORef.current) {
|
||||||
|
clearTimeout(disconnectTORef.current);
|
||||||
|
disconnectTORef.current = null;
|
||||||
|
}
|
||||||
|
disconnectSinceRef.current = null;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!keyFile) return;
|
||||||
|
const ws = getWSClient();
|
||||||
|
|
||||||
|
// Immediate HTTP fetch so the UI is not empty while the WS hello arrives.
|
||||||
|
refresh();
|
||||||
|
|
||||||
|
// Refresh balance whenever a tx for our address is committed.
|
||||||
|
const offTx = ws.subscribe('addr:' + keyFile.pub_key, (frame) => {
|
||||||
|
if (frame.event === 'tx') {
|
||||||
|
refresh();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Manage fallback polling based on WS connection state.
|
||||||
|
const offConn = ws.onConnectionChange((ok) => {
|
||||||
|
if (ok) {
|
||||||
|
stopPolling();
|
||||||
|
refresh(); // catch up anything we missed while disconnected
|
||||||
|
} else if (disconnectTORef.current === null) {
|
||||||
|
disconnectSinceRef.current = Date.now();
|
||||||
|
disconnectTORef.current = setTimeout(startPolling, WS_GRACE_BEFORE_POLLING);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ws.connect();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
offTx();
|
||||||
|
offConn();
|
||||||
|
stopPolling();
|
||||||
|
};
|
||||||
|
}, [keyFile, refresh, startPolling, stopPolling]);
|
||||||
|
|
||||||
|
return { refresh };
|
||||||
|
}
|
||||||
80
client-app/hooks/useContacts.ts
Normal file
80
client-app/hooks/useContacts.ts
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
/**
|
||||||
|
* Contacts + inbound request tracking.
|
||||||
|
*
|
||||||
|
* - Loads cached contacts from local storage on boot.
|
||||||
|
* - Subscribes to the address WS topic so a new CONTACT_REQUEST pulls the
|
||||||
|
* relay contact list immediately (sub-second UX).
|
||||||
|
* - Keeps a 30 s polling fallback for nodes without WS or while disconnected.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect, useCallback } from 'react';
|
||||||
|
import { fetchContactRequests } from '@/lib/api';
|
||||||
|
import { getWSClient } from '@/lib/ws';
|
||||||
|
import { loadContacts } from '@/lib/storage';
|
||||||
|
import { useStore } from '@/lib/store';
|
||||||
|
|
||||||
|
const FALLBACK_POLL_INTERVAL = 30_000;
|
||||||
|
|
||||||
|
export function useContacts() {
|
||||||
|
const keyFile = useStore(s => s.keyFile);
|
||||||
|
const setContacts = useStore(s => s.setContacts);
|
||||||
|
const setRequests = useStore(s => s.setRequests);
|
||||||
|
const contacts = useStore(s => s.contacts);
|
||||||
|
|
||||||
|
// Load cached contacts from local storage once
|
||||||
|
useEffect(() => {
|
||||||
|
loadContacts().then(setContacts);
|
||||||
|
}, [setContacts]);
|
||||||
|
|
||||||
|
const pollRequests = useCallback(async () => {
|
||||||
|
if (!keyFile) return;
|
||||||
|
try {
|
||||||
|
const raw = await fetchContactRequests(keyFile.pub_key);
|
||||||
|
|
||||||
|
// Filter out already-accepted contacts
|
||||||
|
const contactAddresses = new Set(contacts.map(c => c.address));
|
||||||
|
|
||||||
|
const requests = raw
|
||||||
|
.filter(r => r.status === 'pending' && !contactAddresses.has(r.requester_pub))
|
||||||
|
.map(r => ({
|
||||||
|
from: r.requester_pub,
|
||||||
|
// x25519Pub will be fetched from identity when user taps Accept
|
||||||
|
x25519Pub: '',
|
||||||
|
intro: r.intro ?? '',
|
||||||
|
timestamp: r.created_at,
|
||||||
|
txHash: r.tx_id,
|
||||||
|
}));
|
||||||
|
|
||||||
|
setRequests(requests);
|
||||||
|
} catch {
|
||||||
|
// Ignore transient network errors
|
||||||
|
}
|
||||||
|
}, [keyFile, contacts, setRequests]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!keyFile) return;
|
||||||
|
const ws = getWSClient();
|
||||||
|
|
||||||
|
// Initial load + low-frequency fallback poll (covers missed WS events,
|
||||||
|
// works even when the node has no WS endpoint).
|
||||||
|
pollRequests();
|
||||||
|
const interval = setInterval(pollRequests, FALLBACK_POLL_INTERVAL);
|
||||||
|
|
||||||
|
// Immediate refresh when a CONTACT_REQUEST / ACCEPT_CONTACT tx addressed
|
||||||
|
// to us lands on-chain. WS fan-out already filters to our address topic.
|
||||||
|
const off = ws.subscribe('addr:' + keyFile.pub_key, (frame) => {
|
||||||
|
if (frame.event === 'tx') {
|
||||||
|
const d = frame.data as { tx_type?: string } | undefined;
|
||||||
|
if (d?.tx_type === 'CONTACT_REQUEST' || d?.tx_type === 'ACCEPT_CONTACT') {
|
||||||
|
pollRequests();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
ws.connect();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
clearInterval(interval);
|
||||||
|
off();
|
||||||
|
};
|
||||||
|
}, [keyFile, pollRequests]);
|
||||||
|
}
|
||||||
123
client-app/hooks/useMessages.ts
Normal file
123
client-app/hooks/useMessages.ts
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
/**
|
||||||
|
* Subscribe to the relay inbox via WebSocket and decrypt incoming envelopes
|
||||||
|
* for the active chat. Falls back to 30-second polling whenever the WS is
|
||||||
|
* not connected — preserves correctness on older nodes or flaky networks.
|
||||||
|
*
|
||||||
|
* Flow:
|
||||||
|
* 1. On mount: one HTTP fetch so we have whatever is already in the inbox
|
||||||
|
* 2. Subscribe to topic `inbox:<my_x25519>` — the node pushes a summary
|
||||||
|
* for each fresh envelope as soon as mailbox.Store() succeeds
|
||||||
|
* 3. On each push, pull the full envelope list (cheap — bounded by
|
||||||
|
* MailboxPerRecipientCap) and decrypt anything we haven't seen yet
|
||||||
|
* 4. If WS disconnects for > 15 seconds, start a 30 s HTTP poll until it
|
||||||
|
* reconnects
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect, useCallback, useRef } from 'react';
|
||||||
|
import { fetchInbox } from '@/lib/api';
|
||||||
|
import { getWSClient } from '@/lib/ws';
|
||||||
|
import { decryptMessage } from '@/lib/crypto';
|
||||||
|
import { appendMessage } from '@/lib/storage';
|
||||||
|
import { useStore } from '@/lib/store';
|
||||||
|
|
||||||
|
const FALLBACK_POLL_INTERVAL = 30_000; // HTTP poll when WS is down
|
||||||
|
const WS_GRACE_BEFORE_POLLING = 15_000; // don't start polling immediately on disconnect
|
||||||
|
|
||||||
|
export function useMessages(contactX25519: string) {
|
||||||
|
const keyFile = useStore(s => s.keyFile);
|
||||||
|
const appendMsg = useStore(s => s.appendMessage);
|
||||||
|
|
||||||
|
const pullAndDecrypt = useCallback(async () => {
|
||||||
|
if (!keyFile || !contactX25519) return;
|
||||||
|
try {
|
||||||
|
const envelopes = await fetchInbox(keyFile.x25519_pub);
|
||||||
|
for (const env of envelopes) {
|
||||||
|
// Only process messages from this contact
|
||||||
|
if (env.sender_pub !== contactX25519) continue;
|
||||||
|
|
||||||
|
const text = decryptMessage(
|
||||||
|
env.ciphertext,
|
||||||
|
env.nonce,
|
||||||
|
env.sender_pub,
|
||||||
|
keyFile.x25519_priv,
|
||||||
|
);
|
||||||
|
if (!text) continue;
|
||||||
|
|
||||||
|
const msg = {
|
||||||
|
id: `${env.sender_pub}_${env.timestamp}_${env.nonce.slice(0, 8)}`,
|
||||||
|
from: env.sender_pub,
|
||||||
|
text,
|
||||||
|
timestamp: env.timestamp,
|
||||||
|
mine: false,
|
||||||
|
};
|
||||||
|
appendMsg(contactX25519, msg);
|
||||||
|
await appendMessage(contactX25519, msg);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Don't surface inbox errors aggressively — next event or poll retries
|
||||||
|
console.warn('[useMessages] pull error:', e);
|
||||||
|
}
|
||||||
|
}, [keyFile, contactX25519, appendMsg]);
|
||||||
|
|
||||||
|
// ── Fallback polling state ────────────────────────────────────────────
|
||||||
|
const pollTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
|
const disconnectTORef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
|
const startPolling = useCallback(() => {
|
||||||
|
if (pollTimerRef.current) return;
|
||||||
|
console.log('[useMessages] WS down — starting HTTP poll fallback');
|
||||||
|
pullAndDecrypt();
|
||||||
|
pollTimerRef.current = setInterval(pullAndDecrypt, FALLBACK_POLL_INTERVAL);
|
||||||
|
}, [pullAndDecrypt]);
|
||||||
|
|
||||||
|
const stopPolling = useCallback(() => {
|
||||||
|
if (pollTimerRef.current) {
|
||||||
|
clearInterval(pollTimerRef.current);
|
||||||
|
pollTimerRef.current = null;
|
||||||
|
}
|
||||||
|
if (disconnectTORef.current) {
|
||||||
|
clearTimeout(disconnectTORef.current);
|
||||||
|
disconnectTORef.current = null;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!keyFile || !contactX25519) return;
|
||||||
|
|
||||||
|
const ws = getWSClient();
|
||||||
|
|
||||||
|
// Initial fetch — populate whatever landed before we mounted.
|
||||||
|
pullAndDecrypt();
|
||||||
|
|
||||||
|
// Subscribe to our x25519 inbox — the node emits on mailbox.Store.
|
||||||
|
// Topic filter: only envelopes for ME; we then filter by sender inside
|
||||||
|
// the handler so we only render messages in THIS chat.
|
||||||
|
const offInbox = ws.subscribe('inbox:' + keyFile.x25519_pub, (frame) => {
|
||||||
|
if (frame.event !== 'inbox') return;
|
||||||
|
const d = frame.data as { sender_pub?: string } | undefined;
|
||||||
|
// Optimisation: if the envelope is from a different peer, skip the
|
||||||
|
// whole refetch — we'd just drop it in the sender filter below anyway.
|
||||||
|
if (d?.sender_pub && d.sender_pub !== contactX25519) return;
|
||||||
|
pullAndDecrypt();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Manage fallback polling based on WS connection state.
|
||||||
|
const offConn = ws.onConnectionChange((ok) => {
|
||||||
|
if (ok) {
|
||||||
|
stopPolling();
|
||||||
|
// Catch up anything we missed while disconnected.
|
||||||
|
pullAndDecrypt();
|
||||||
|
} else if (disconnectTORef.current === null) {
|
||||||
|
disconnectTORef.current = setTimeout(startPolling, WS_GRACE_BEFORE_POLLING);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ws.connect();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
offInbox();
|
||||||
|
offConn();
|
||||||
|
stopPolling();
|
||||||
|
};
|
||||||
|
}, [keyFile, contactX25519, pullAndDecrypt, startPolling, stopPolling]);
|
||||||
|
}
|
||||||
61
client-app/hooks/useWellKnownContracts.ts
Normal file
61
client-app/hooks/useWellKnownContracts.ts
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
/**
|
||||||
|
* Auto-discover canonical system contracts from the node so the user doesn't
|
||||||
|
* have to paste contract IDs into settings by hand.
|
||||||
|
*
|
||||||
|
* Flow:
|
||||||
|
* 1. On app boot (and whenever nodeUrl changes), call GET /api/well-known-contracts
|
||||||
|
* 2. If the node advertises a `username_registry` and the user has not
|
||||||
|
* manually set `settings.contractId`, auto-populate it and persist.
|
||||||
|
* 3. A user-supplied contractId is never overwritten — so power users can
|
||||||
|
* still pin a non-canonical deployment from settings.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
import { fetchWellKnownContracts } from '@/lib/api';
|
||||||
|
import { saveSettings } from '@/lib/storage';
|
||||||
|
import { useStore } from '@/lib/store';
|
||||||
|
|
||||||
|
export function useWellKnownContracts() {
|
||||||
|
const nodeUrl = useStore(s => s.settings.nodeUrl);
|
||||||
|
const contractId = useStore(s => s.settings.contractId);
|
||||||
|
const settings = useStore(s => s.settings);
|
||||||
|
const setSettings = useStore(s => s.setSettings);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
async function run() {
|
||||||
|
if (!nodeUrl) return;
|
||||||
|
const res = await fetchWellKnownContracts();
|
||||||
|
if (cancelled || !res) return;
|
||||||
|
|
||||||
|
const registry = res.contracts['username_registry'];
|
||||||
|
if (!registry) return;
|
||||||
|
|
||||||
|
// Always keep the stored contractId in sync with what the node reports
|
||||||
|
// as canonical. If the user resets their chain or we migrate from a
|
||||||
|
// WASM contract to the native one, the stale contract_id cached in
|
||||||
|
// local storage would otherwise keep the client trying to call a
|
||||||
|
// contract that no longer exists on this chain.
|
||||||
|
//
|
||||||
|
// To still support intentional overrides: the UI's "advanced" section
|
||||||
|
// allows pasting a specific ID — and since that also writes to
|
||||||
|
// settings.contractId, the loop converges back to whatever the node
|
||||||
|
// says after a short delay. Operators who want a hard override should
|
||||||
|
// either run a patched node or pin the value with a wrapper config
|
||||||
|
// outside the app.
|
||||||
|
if (registry.contract_id !== contractId) {
|
||||||
|
const next = { ...settings, contractId: registry.contract_id };
|
||||||
|
setSettings({ contractId: registry.contract_id });
|
||||||
|
await saveSettings(next);
|
||||||
|
console.log('[well-known] synced username_registry =', registry.contract_id,
|
||||||
|
'(was:', contractId || '<empty>', ')');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
run();
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
// Re-run when the node URL changes (user switched networks) or when
|
||||||
|
// contractId is cleared.
|
||||||
|
}, [nodeUrl, contractId]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
}
|
||||||
701
client-app/lib/api.ts
Normal file
701
client-app/lib/api.ts
Normal file
@@ -0,0 +1,701 @@
|
|||||||
|
/**
|
||||||
|
* DChain REST API client.
|
||||||
|
* All requests go to the configured node URL (e.g. http://192.168.1.10:8081).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Envelope, TxRecord, NetStats, Contact } from './types';
|
||||||
|
|
||||||
|
// ─── Base ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
let _nodeUrl = 'http://localhost:8081';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Listeners invoked AFTER _nodeUrl changes. The WS client registers here so
|
||||||
|
* that switching nodes in Settings tears down the old socket and re-dials
|
||||||
|
* the new one (without this, a user who pointed their app at node A would
|
||||||
|
* keep receiving A's events forever after flipping to B).
|
||||||
|
*/
|
||||||
|
const nodeUrlListeners = new Set<(url: string) => void>();
|
||||||
|
|
||||||
|
export function setNodeUrl(url: string) {
|
||||||
|
const normalised = url.replace(/\/$/, '');
|
||||||
|
if (_nodeUrl === normalised) return;
|
||||||
|
_nodeUrl = normalised;
|
||||||
|
for (const fn of nodeUrlListeners) {
|
||||||
|
try { fn(_nodeUrl); } catch { /* ignore — listeners are best-effort */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getNodeUrl(): string {
|
||||||
|
return _nodeUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Register a callback for node-URL changes. Returns an unsubscribe fn. */
|
||||||
|
export function onNodeUrlChange(fn: (url: string) => void): () => void {
|
||||||
|
nodeUrlListeners.add(fn);
|
||||||
|
return () => { nodeUrlListeners.delete(fn); };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function get<T>(path: string): Promise<T> {
|
||||||
|
const res = await fetch(`${_nodeUrl}${path}`);
|
||||||
|
if (!res.ok) throw new Error(`GET ${path} → ${res.status}`);
|
||||||
|
return res.json() as Promise<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enhanced error reporter for POST failures. The node's `jsonErr` writes
|
||||||
|
* `{"error": "..."}` as the response body; we parse that out so the UI layer
|
||||||
|
* can show a meaningful message instead of a raw status code.
|
||||||
|
*
|
||||||
|
* Rate-limit and timestamp-skew rejections produce specific strings the UI
|
||||||
|
* can translate to user-friendly Russian via matcher functions below.
|
||||||
|
*/
|
||||||
|
async function post<T>(path: string, body: unknown): Promise<T> {
|
||||||
|
const res = await fetch(`${_nodeUrl}${path}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text();
|
||||||
|
// Try to extract {"error":"..."} payload for a cleaner message.
|
||||||
|
let detail = text;
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(text);
|
||||||
|
if (parsed?.error) detail = parsed.error;
|
||||||
|
} catch { /* keep raw text */ }
|
||||||
|
// Include HTTP status so `humanizeTxError` can branch on 429/400/etc.
|
||||||
|
throw new Error(`${res.status}: ${detail}`);
|
||||||
|
}
|
||||||
|
return res.json() as Promise<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Turn a submission error from `post()` / `submitTx()` into a user-facing
|
||||||
|
* Russian message with actionable hints. Preserves the raw detail at the end
|
||||||
|
* so advanced users can still copy the original for support.
|
||||||
|
*/
|
||||||
|
export function humanizeTxError(e: unknown): string {
|
||||||
|
const raw = e instanceof Error ? e.message : String(e);
|
||||||
|
if (raw.startsWith('429')) {
|
||||||
|
return 'Слишком много запросов к ноде. Подождите пару секунд и попробуйте снова.';
|
||||||
|
}
|
||||||
|
if (raw.startsWith('400') && raw.includes('timestamp')) {
|
||||||
|
return 'Часы устройства не синхронизированы с нодой. Проверьте время на телефоне (±1 час).';
|
||||||
|
}
|
||||||
|
if (raw.startsWith('400') && raw.includes('signature')) {
|
||||||
|
return 'Подпись транзакции невалидна. Попробуйте ещё раз; если не помогает — вероятна несовместимость версий клиента и ноды.';
|
||||||
|
}
|
||||||
|
if (raw.startsWith('400')) {
|
||||||
|
return `Нода отклонила транзакцию: ${raw.replace(/^400:\s*/, '')}`;
|
||||||
|
}
|
||||||
|
if (raw.startsWith('5')) {
|
||||||
|
return `Ошибка ноды (${raw}). Попробуйте позже.`;
|
||||||
|
}
|
||||||
|
// Network-level
|
||||||
|
if (raw.toLowerCase().includes('network request failed')) {
|
||||||
|
return 'Нет связи с нодой. Проверьте URL в настройках и доступность сервера.';
|
||||||
|
}
|
||||||
|
return raw;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Chain API ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function getNetStats(): Promise<NetStats> {
|
||||||
|
return get<NetStats>('/api/netstats');
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AddrResponse {
|
||||||
|
balance_ut: number;
|
||||||
|
balance: string;
|
||||||
|
transactions: Array<{
|
||||||
|
id: string;
|
||||||
|
type: string;
|
||||||
|
from: string;
|
||||||
|
to?: string;
|
||||||
|
amount_ut: number;
|
||||||
|
fee_ut: number;
|
||||||
|
time: string; // ISO-8601 e.g. "2025-01-01T12:00:00Z"
|
||||||
|
block_index: number;
|
||||||
|
}>;
|
||||||
|
tx_count: number;
|
||||||
|
has_more: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getBalance(pubkey: string): Promise<number> {
|
||||||
|
const data = await get<AddrResponse>(`/api/address/${pubkey}`);
|
||||||
|
return data.balance_ut ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transaction as sent to /api/tx — maps 1-to-1 to blockchain.Transaction JSON.
|
||||||
|
* Key facts:
|
||||||
|
* - `payload` is base64-encoded JSON bytes (Go []byte → base64 in JSON)
|
||||||
|
* - `signature` is base64-encoded Ed25519 sig (Go []byte → base64 in JSON)
|
||||||
|
* - `timestamp` is RFC3339 string (Go time.Time → string in JSON)
|
||||||
|
* - There is NO nonce field; dedup is by `id`
|
||||||
|
*/
|
||||||
|
export interface RawTx {
|
||||||
|
id: string; // "tx-<nanoseconds>" or sha256-based
|
||||||
|
type: string; // "TRANSFER", "CONTACT_REQUEST", etc.
|
||||||
|
from: string; // hex Ed25519 pub key
|
||||||
|
to: string; // hex Ed25519 pub key (empty string if N/A)
|
||||||
|
amount: number; // µT (uint64)
|
||||||
|
fee: number; // µT (uint64)
|
||||||
|
memo?: string; // optional
|
||||||
|
payload: string; // base64(json.Marshal(TypeSpecificPayload))
|
||||||
|
signature: string; // base64(ed25519.Sign(canonical_bytes, priv))
|
||||||
|
timestamp: string; // RFC3339 e.g. "2025-01-01T12:00:00Z"
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function submitTx(tx: RawTx): Promise<{ id: string; status: string }> {
|
||||||
|
console.log('[submitTx] →', {
|
||||||
|
id: tx.id,
|
||||||
|
type: tx.type,
|
||||||
|
from: tx.from.slice(0, 12) + '…',
|
||||||
|
to: tx.to ? tx.to.slice(0, 12) + '…' : '',
|
||||||
|
amount: tx.amount,
|
||||||
|
fee: tx.fee,
|
||||||
|
timestamp: tx.timestamp,
|
||||||
|
transport: 'auto',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Try the WebSocket path first: no HTTP round-trip, and we get a proper
|
||||||
|
// submit_ack correlated back to our tx id. Falls through to HTTP if WS is
|
||||||
|
// unavailable (old node, disconnected, timeout, etc.) so legacy setups
|
||||||
|
// keep working.
|
||||||
|
try {
|
||||||
|
// Lazy import avoids a circular dep with lib/ws.ts (which itself
|
||||||
|
// imports getNodeUrl from this module).
|
||||||
|
const { getWSClient } = await import('./ws');
|
||||||
|
const ws = getWSClient();
|
||||||
|
if (ws.isConnected()) {
|
||||||
|
try {
|
||||||
|
const res = await ws.submitTx(tx);
|
||||||
|
console.log('[submitTx] ← accepted via WS', res);
|
||||||
|
return { id: res.id || tx.id, status: 'accepted' };
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[submitTx] WS path failed, falling back to HTTP:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch { /* circular import edge case — ignore and use HTTP */ }
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await post<{ id: string; status: string }>('/api/tx', tx);
|
||||||
|
console.log('[submitTx] ← accepted via HTTP', res);
|
||||||
|
return res;
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[submitTx] ← rejected', e);
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getTxHistory(pubkey: string, limit = 50): Promise<TxRecord[]> {
|
||||||
|
const data = await get<AddrResponse>(`/api/address/${pubkey}?limit=${limit}`);
|
||||||
|
return (data.transactions ?? []).map(tx => ({
|
||||||
|
hash: tx.id,
|
||||||
|
type: tx.type,
|
||||||
|
from: tx.from,
|
||||||
|
to: tx.to,
|
||||||
|
amount: tx.amount_ut,
|
||||||
|
fee: tx.fee_ut,
|
||||||
|
// Convert ISO-8601 string → unix seconds
|
||||||
|
timestamp: tx.time ? Math.floor(new Date(tx.time).getTime() / 1000) : 0,
|
||||||
|
status: 'confirmed' as const,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Relay API ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface SendEnvelopeReq {
|
||||||
|
sender_pub: string;
|
||||||
|
recipient_pub: string;
|
||||||
|
nonce: string;
|
||||||
|
ciphertext: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function sendEnvelope(env: SendEnvelopeReq): Promise<{ ok: boolean }> {
|
||||||
|
return post<{ ok: boolean }>('/api/relay/send', env);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchInbox(x25519PubHex: string): Promise<Envelope[]> {
|
||||||
|
return get<Envelope[]>(`/api/relay/inbox?pub=${x25519PubHex}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Contact requests (on-chain) ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maps blockchain.ContactInfo returned by GET /api/relay/contacts?pub=...
|
||||||
|
* The response shape is { pub, count, contacts: ContactInfo[] }.
|
||||||
|
*/
|
||||||
|
export interface ContactRequestRaw {
|
||||||
|
requester_pub: string; // Ed25519 pubkey of requester
|
||||||
|
requester_addr: string; // DChain address (DC…)
|
||||||
|
status: string; // "pending" | "accepted" | "blocked"
|
||||||
|
intro: string; // plaintext intro message (may be empty)
|
||||||
|
fee_ut: number; // anti-spam fee paid in µT
|
||||||
|
tx_id: string; // transaction ID
|
||||||
|
created_at: number; // unix seconds
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchContactRequests(edPubHex: string): Promise<ContactRequestRaw[]> {
|
||||||
|
const data = await get<{ contacts: ContactRequestRaw[] }>(`/api/relay/contacts?pub=${edPubHex}`);
|
||||||
|
return data.contacts ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Identity API ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface IdentityInfo {
|
||||||
|
pub_key: string;
|
||||||
|
address: string;
|
||||||
|
x25519_pub: string; // hex Curve25519 key; empty string if not published
|
||||||
|
nickname: string;
|
||||||
|
registered: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fetch identity info for any pubkey or DC address. Returns null on 404. */
|
||||||
|
export async function getIdentity(pubkeyOrAddr: string): Promise<IdentityInfo | null> {
|
||||||
|
try {
|
||||||
|
return await get<IdentityInfo>(`/api/identity/${pubkeyOrAddr}`);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Contract API ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Response shape from GET /api/contracts/{id}/state/{key}.
|
||||||
|
* The node handler (node/api_contract.go:handleContractState) returns either:
|
||||||
|
* { value_b64: null, value_hex: null, ... } when the key is missing
|
||||||
|
* or
|
||||||
|
* { value_b64: "...", value_hex: "...", value_u64?: 0 } when the key exists.
|
||||||
|
*/
|
||||||
|
interface ContractStateResponse {
|
||||||
|
contract_id: string;
|
||||||
|
key: string;
|
||||||
|
value_b64: string | null;
|
||||||
|
value_hex: string | null;
|
||||||
|
value_u64?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decode a hex string (lowercase/uppercase) back to the original string value
|
||||||
|
* it represents. The username registry contract stores values as plain ASCII
|
||||||
|
* bytes (pubkey hex strings / username strings), so `value_hex` on the wire
|
||||||
|
* is the hex-encoding of UTF-8 bytes. We hex-decode to bytes, then interpret
|
||||||
|
* those bytes as UTF-8.
|
||||||
|
*/
|
||||||
|
function hexToUtf8(hex: string): string {
|
||||||
|
if (hex.length % 2 !== 0) return '';
|
||||||
|
const bytes = new Uint8Array(hex.length / 2);
|
||||||
|
for (let i = 0; i < hex.length; i += 2) {
|
||||||
|
bytes[i / 2] = parseInt(hex.substr(i, 2), 16);
|
||||||
|
}
|
||||||
|
// TextDecoder is available in Hermes / RN's JS runtime.
|
||||||
|
try {
|
||||||
|
return new TextDecoder('utf-8').decode(bytes);
|
||||||
|
} catch {
|
||||||
|
// Fallback for environments without TextDecoder.
|
||||||
|
let s = '';
|
||||||
|
for (const b of bytes) s += String.fromCharCode(b);
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** username → address (hex pubkey). Returns null if unregistered. */
|
||||||
|
export async function resolveUsername(contractId: string, username: string): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
const data = await get<ContractStateResponse>(`/api/contracts/${contractId}/state/name:${username}`);
|
||||||
|
if (!data.value_hex) return null;
|
||||||
|
const decoded = hexToUtf8(data.value_hex).trim();
|
||||||
|
return decoded || null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** address (hex pubkey) → username. Returns null if this address hasn't registered a name. */
|
||||||
|
export async function reverseResolve(contractId: string, address: string): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
const data = await get<ContractStateResponse>(`/api/contracts/${contractId}/state/addr:${address}`);
|
||||||
|
if (!data.value_hex) return null;
|
||||||
|
const decoded = hexToUtf8(data.value_hex).trim();
|
||||||
|
return decoded || null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Well-known contracts ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-entry shape returned by GET /api/well-known-contracts.
|
||||||
|
* Matches node/api_well_known.go:WellKnownContract.
|
||||||
|
*/
|
||||||
|
export interface WellKnownContract {
|
||||||
|
contract_id: string;
|
||||||
|
name: string;
|
||||||
|
version?: string;
|
||||||
|
deployed_at: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Response from GET /api/well-known-contracts.
|
||||||
|
* `contracts` is keyed by ABI name (e.g. "username_registry").
|
||||||
|
*/
|
||||||
|
export interface WellKnownResponse {
|
||||||
|
count: number;
|
||||||
|
contracts: Record<string, WellKnownContract>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch the node's view of canonical system contracts so the client doesn't
|
||||||
|
* have to force the user to paste contract IDs into settings.
|
||||||
|
*
|
||||||
|
* The node returns the earliest-deployed contract per ABI name; this means
|
||||||
|
* every peer in the same chain reports the same mapping.
|
||||||
|
*
|
||||||
|
* Returns `null` on failure (old node, network hiccup, endpoint missing).
|
||||||
|
*/
|
||||||
|
export async function fetchWellKnownContracts(): Promise<WellKnownResponse | null> {
|
||||||
|
try {
|
||||||
|
return await get<WellKnownResponse>('/api/well-known-contracts');
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Node version / update-check ─────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// The three calls below let the client:
|
||||||
|
// 1. fetchNodeVersion() — see what tag/commit/features the connected node
|
||||||
|
// exposes. Used on first boot + on every chain-switch so we can warn if
|
||||||
|
// a required feature is missing.
|
||||||
|
// 2. checkNodeVersion(required) — thin wrapper that returns {supported,
|
||||||
|
// missing} by diffing a client-expected feature list against the node's.
|
||||||
|
// 3. fetchUpdateCheck() — ask the node whether its operator has a newer
|
||||||
|
// release available from their configured release source (Gitea). For
|
||||||
|
// messenger UX this is purely informational ("the node you're on is N
|
||||||
|
// versions behind"), never used to update the node automatically.
|
||||||
|
|
||||||
|
/** The shape returned by GET /api/well-known-version. */
|
||||||
|
export interface NodeVersionInfo {
|
||||||
|
node_version: string;
|
||||||
|
protocol_version: number;
|
||||||
|
features: string[];
|
||||||
|
chain_id?: string;
|
||||||
|
build?: {
|
||||||
|
tag: string;
|
||||||
|
commit: string;
|
||||||
|
date: string;
|
||||||
|
dirty: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Client-expected protocol version. Bumped only when wire-protocol breaks. */
|
||||||
|
export const CLIENT_PROTOCOL_VERSION = 1;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minimum feature set this client build relies on. A node missing any of
|
||||||
|
* these is considered "unsupported" — caller should surface an upgrade
|
||||||
|
* prompt to the user instead of silently failing on the first feature call.
|
||||||
|
*/
|
||||||
|
export const CLIENT_REQUIRED_FEATURES = [
|
||||||
|
'chain_id',
|
||||||
|
'identity_registry',
|
||||||
|
'onboarding_api',
|
||||||
|
'relay_mailbox',
|
||||||
|
'ws_submit_tx',
|
||||||
|
];
|
||||||
|
|
||||||
|
/** GET /api/well-known-version. Returns null on failure (old node, network hiccup). */
|
||||||
|
export async function fetchNodeVersion(): Promise<NodeVersionInfo | null> {
|
||||||
|
try {
|
||||||
|
return await get<NodeVersionInfo>('/api/well-known-version');
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check whether the connected node supports this client's required features
|
||||||
|
* and protocol version. Returns a decision blob the UI can render directly.
|
||||||
|
*
|
||||||
|
* { supported: true } → everything fine
|
||||||
|
* { supported: false, reason: "...", ... } → show update prompt
|
||||||
|
* { supported: null, reason: "unreachable" } → couldn't reach the endpoint,
|
||||||
|
* likely old node — assume OK
|
||||||
|
* but warn quietly.
|
||||||
|
*/
|
||||||
|
export async function checkNodeVersion(
|
||||||
|
required: string[] = CLIENT_REQUIRED_FEATURES,
|
||||||
|
): Promise<{
|
||||||
|
supported: boolean | null;
|
||||||
|
reason?: string;
|
||||||
|
missing?: string[];
|
||||||
|
info?: NodeVersionInfo;
|
||||||
|
}> {
|
||||||
|
const info = await fetchNodeVersion();
|
||||||
|
if (!info) {
|
||||||
|
return { supported: null, reason: 'unreachable' };
|
||||||
|
}
|
||||||
|
if (info.protocol_version !== CLIENT_PROTOCOL_VERSION) {
|
||||||
|
return {
|
||||||
|
supported: false,
|
||||||
|
reason: `protocol v${info.protocol_version} but client expects v${CLIENT_PROTOCOL_VERSION}`,
|
||||||
|
info,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const have = new Set(info.features || []);
|
||||||
|
const missing = required.filter((f) => !have.has(f));
|
||||||
|
if (missing.length > 0) {
|
||||||
|
return {
|
||||||
|
supported: false,
|
||||||
|
reason: `node missing features: ${missing.join(', ')}`,
|
||||||
|
missing,
|
||||||
|
info,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { supported: true, info };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The shape returned by GET /api/update-check. */
|
||||||
|
export interface UpdateCheckResponse {
|
||||||
|
current: { tag: string; commit: string; date: string; dirty: string };
|
||||||
|
latest?: { tag: string; commit?: string; url?: string; published_at?: string };
|
||||||
|
update_available: boolean;
|
||||||
|
checked_at: string;
|
||||||
|
source?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/update-check. Returns null when:
|
||||||
|
* - the node operator hasn't configured DCHAIN_UPDATE_SOURCE_URL (503),
|
||||||
|
* - upstream Gitea call failed (502),
|
||||||
|
* - request errored out.
|
||||||
|
* All three are non-fatal for the client; the UI just doesn't render the
|
||||||
|
* "update available" banner.
|
||||||
|
*/
|
||||||
|
export async function fetchUpdateCheck(): Promise<UpdateCheckResponse | null> {
|
||||||
|
try {
|
||||||
|
return await get<UpdateCheckResponse>('/api/update-check');
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Transaction builder helpers ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
import { signBase64, bytesToBase64 } from './crypto';
|
||||||
|
|
||||||
|
/** Minimum blockchain tx fee paid to the block validator (matches blockchain.MinFee = 1000 µT). */
|
||||||
|
const MIN_TX_FEE = 1000;
|
||||||
|
|
||||||
|
const _encoder = new TextEncoder();
|
||||||
|
|
||||||
|
/** RFC3339 timestamp with second precision — matches Go time.Time JSON output. */
|
||||||
|
function rfc3339Now(): string {
|
||||||
|
const d = new Date();
|
||||||
|
d.setMilliseconds(0);
|
||||||
|
// toISOString() gives "2025-01-01T12:00:00.000Z" → replace ".000Z" with "Z"
|
||||||
|
return d.toISOString().replace('.000Z', 'Z');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Unique transaction ID (nanoseconds-like using Date.now + random). */
|
||||||
|
function newTxID(): string {
|
||||||
|
return `tx-${Date.now()}${Math.floor(Math.random() * 1_000_000)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Canonical bytes for signing — must match identity.txSignBytes in Go exactly.
|
||||||
|
*
|
||||||
|
* Go struct field order: id, type, from, to, amount, fee, payload, timestamp.
|
||||||
|
* JS JSON.stringify preserves insertion order, so we rely on that here.
|
||||||
|
*/
|
||||||
|
function txCanonicalBytes(tx: {
|
||||||
|
id: string; type: string; from: string; to: string;
|
||||||
|
amount: number; fee: number; payload: string; timestamp: string;
|
||||||
|
}): Uint8Array {
|
||||||
|
const s = JSON.stringify({
|
||||||
|
id: tx.id,
|
||||||
|
type: tx.type,
|
||||||
|
from: tx.from,
|
||||||
|
to: tx.to,
|
||||||
|
amount: tx.amount,
|
||||||
|
fee: tx.fee,
|
||||||
|
payload: tx.payload,
|
||||||
|
timestamp: tx.timestamp,
|
||||||
|
});
|
||||||
|
return _encoder.encode(s);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Encode a JS string (UTF-8) to base64. */
|
||||||
|
function strToBase64(s: string): string {
|
||||||
|
return bytesToBase64(_encoder.encode(s));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildTransferTx(params: {
|
||||||
|
from: string;
|
||||||
|
to: string;
|
||||||
|
amount: number;
|
||||||
|
fee: number;
|
||||||
|
privKey: string;
|
||||||
|
memo?: string;
|
||||||
|
}): RawTx {
|
||||||
|
const id = newTxID();
|
||||||
|
const timestamp = rfc3339Now();
|
||||||
|
const payloadObj = params.memo ? { memo: params.memo } : {};
|
||||||
|
const payload = strToBase64(JSON.stringify(payloadObj));
|
||||||
|
|
||||||
|
const canonical = txCanonicalBytes({
|
||||||
|
id, type: 'TRANSFER', from: params.from, to: params.to,
|
||||||
|
amount: params.amount, fee: params.fee, payload, timestamp,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
id, type: 'TRANSFER', from: params.from, to: params.to,
|
||||||
|
amount: params.amount, fee: params.fee,
|
||||||
|
memo: params.memo,
|
||||||
|
payload, timestamp,
|
||||||
|
signature: signBase64(canonical, params.privKey),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CONTACT_REQUEST transaction.
|
||||||
|
*
|
||||||
|
* blockchain.Transaction fields:
|
||||||
|
* Amount = contactFee — anti-spam fee, paid directly to recipient (>= 5000 µT)
|
||||||
|
* Fee = MIN_TX_FEE — blockchain tx fee to the block validator (1000 µT)
|
||||||
|
* Payload = ContactRequestPayload { intro? } as base64 JSON bytes
|
||||||
|
*/
|
||||||
|
export function buildContactRequestTx(params: {
|
||||||
|
from: string; // sender Ed25519 pubkey
|
||||||
|
to: string; // recipient Ed25519 pubkey
|
||||||
|
contactFee: number; // anti-spam amount paid to recipient (>= 5000 µT)
|
||||||
|
intro?: string; // optional plaintext intro message (≤ 280 chars)
|
||||||
|
privKey: string;
|
||||||
|
}): RawTx {
|
||||||
|
const id = newTxID();
|
||||||
|
const timestamp = rfc3339Now();
|
||||||
|
// Payload matches ContactRequestPayload{Intro: "..."} in Go
|
||||||
|
const payloadObj = params.intro ? { intro: params.intro } : {};
|
||||||
|
const payload = strToBase64(JSON.stringify(payloadObj));
|
||||||
|
|
||||||
|
const canonical = txCanonicalBytes({
|
||||||
|
id, type: 'CONTACT_REQUEST', from: params.from, to: params.to,
|
||||||
|
amount: params.contactFee, fee: MIN_TX_FEE, payload, timestamp,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
id, type: 'CONTACT_REQUEST', from: params.from, to: params.to,
|
||||||
|
amount: params.contactFee, fee: MIN_TX_FEE, payload, timestamp,
|
||||||
|
signature: signBase64(canonical, params.privKey),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ACCEPT_CONTACT transaction.
|
||||||
|
* AcceptContactPayload is an empty struct in Go — no fields needed.
|
||||||
|
*/
|
||||||
|
export function buildAcceptContactTx(params: {
|
||||||
|
from: string; // acceptor Ed25519 pubkey (us — the recipient of the request)
|
||||||
|
to: string; // requester Ed25519 pubkey
|
||||||
|
privKey: string;
|
||||||
|
}): RawTx {
|
||||||
|
const id = newTxID();
|
||||||
|
const timestamp = rfc3339Now();
|
||||||
|
const payload = strToBase64(JSON.stringify({})); // AcceptContactPayload{}
|
||||||
|
|
||||||
|
const canonical = txCanonicalBytes({
|
||||||
|
id, type: 'ACCEPT_CONTACT', from: params.from, to: params.to,
|
||||||
|
amount: 0, fee: MIN_TX_FEE, payload, timestamp,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
id, type: 'ACCEPT_CONTACT', from: params.from, to: params.to,
|
||||||
|
amount: 0, fee: MIN_TX_FEE, payload, timestamp,
|
||||||
|
signature: signBase64(canonical, params.privKey),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Contract call ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Minimum base fee for CALL_CONTRACT (matches blockchain.MinCallFee). */
|
||||||
|
const MIN_CALL_FEE = 1000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CALL_CONTRACT transaction.
|
||||||
|
*
|
||||||
|
* Payload shape (CallContractPayload):
|
||||||
|
* { contract_id, method, args_json?, gas_limit }
|
||||||
|
*
|
||||||
|
* `amount` is the payment attached to the call and made available to the
|
||||||
|
* contract as `tx.Amount`. Whether it's collected depends on the contract
|
||||||
|
* — e.g. username_registry.register requires exactly 10_000 µT. Contracts
|
||||||
|
* that don't need payment should be called with `amount: 0` (default).
|
||||||
|
*
|
||||||
|
* The on-chain tx envelope carries `amount` openly, so the explorer shows
|
||||||
|
* the exact cost of a call rather than hiding it in a contract-internal
|
||||||
|
* debit — this was the UX motivation for this field.
|
||||||
|
*
|
||||||
|
* `fee` is the NETWORK fee paid to the block validator (not the contract).
|
||||||
|
* `gas` costs are additional and billed at the live gas price.
|
||||||
|
*/
|
||||||
|
export function buildCallContractTx(params: {
|
||||||
|
from: string;
|
||||||
|
contractId: string;
|
||||||
|
method: string;
|
||||||
|
args?: unknown[]; // JSON-serializable arguments
|
||||||
|
amount?: number; // µT attached to the call (default 0)
|
||||||
|
gasLimit?: number; // default 1_000_000
|
||||||
|
privKey: string;
|
||||||
|
}): RawTx {
|
||||||
|
const id = newTxID();
|
||||||
|
const timestamp = rfc3339Now();
|
||||||
|
const amount = params.amount ?? 0;
|
||||||
|
|
||||||
|
const argsJson = params.args && params.args.length > 0
|
||||||
|
? JSON.stringify(params.args)
|
||||||
|
: '';
|
||||||
|
|
||||||
|
const payloadObj = {
|
||||||
|
contract_id: params.contractId,
|
||||||
|
method: params.method,
|
||||||
|
args_json: argsJson,
|
||||||
|
gas_limit: params.gasLimit ?? 1_000_000,
|
||||||
|
};
|
||||||
|
const payload = strToBase64(JSON.stringify(payloadObj));
|
||||||
|
|
||||||
|
const canonical = txCanonicalBytes({
|
||||||
|
id, type: 'CALL_CONTRACT', from: params.from, to: '',
|
||||||
|
amount, fee: MIN_CALL_FEE, payload, timestamp,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
id, type: 'CALL_CONTRACT', from: params.from, to: '',
|
||||||
|
amount, fee: MIN_CALL_FEE, payload, timestamp,
|
||||||
|
signature: signBase64(canonical, params.privKey),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Flat registration fee for a username, in µT.
|
||||||
|
*
|
||||||
|
* The native username_registry charges a single flat fee (10 000 µT = 0.01 T)
|
||||||
|
* per register() call regardless of name length, replacing the earlier
|
||||||
|
* length-based formula. Flat pricing is easier to communicate and the
|
||||||
|
* 4-char minimum (enforced both in the client UI and the on-chain contract)
|
||||||
|
* already removes the squatting pressure that tiered pricing mitigated.
|
||||||
|
*/
|
||||||
|
export const USERNAME_REGISTRATION_FEE = 10_000;
|
||||||
|
|
||||||
|
/** Minimum/maximum allowed username length. Match blockchain/native_username.go. */
|
||||||
|
export const MIN_USERNAME_LENGTH = 4;
|
||||||
|
export const MAX_USERNAME_LENGTH = 32;
|
||||||
|
|
||||||
|
/** @deprecated Kept for backward compatibility; always returns the flat fee. */
|
||||||
|
export function usernameRegistrationFee(_name: string): number {
|
||||||
|
return USERNAME_REGISTRATION_FEE;
|
||||||
|
}
|
||||||
156
client-app/lib/crypto.ts
Normal file
156
client-app/lib/crypto.ts
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
/**
|
||||||
|
* Cryptographic operations for DChain messenger.
|
||||||
|
*
|
||||||
|
* Ed25519 — transaction signing (via TweetNaCl sign)
|
||||||
|
* X25519 — Diffie-Hellman key exchange for NaCl box
|
||||||
|
* NaCl box — authenticated encryption for relay messages
|
||||||
|
*/
|
||||||
|
|
||||||
|
import nacl from 'tweetnacl';
|
||||||
|
import { decodeUTF8, encodeUTF8 } from 'tweetnacl-util';
|
||||||
|
import { getRandomBytes } from 'expo-crypto';
|
||||||
|
import type { KeyFile } from './types';
|
||||||
|
|
||||||
|
// ─── PRNG ─────────────────────────────────────────────────────────────────────
|
||||||
|
// TweetNaCl looks for window.crypto which doesn't exist in React Native/Hermes.
|
||||||
|
// Wire nacl to expo-crypto which uses the platform's secure RNG natively.
|
||||||
|
nacl.setPRNG((output: Uint8Array, length: number) => {
|
||||||
|
const bytes = getRandomBytes(length);
|
||||||
|
for (let i = 0; i < length; i++) output[i] = bytes[i];
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function hexToBytes(hex: string): Uint8Array {
|
||||||
|
if (hex.length % 2 !== 0) throw new Error('odd hex length');
|
||||||
|
const bytes = new Uint8Array(hex.length / 2);
|
||||||
|
for (let i = 0; i < bytes.length; i++) {
|
||||||
|
bytes[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
|
||||||
|
}
|
||||||
|
return bytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function bytesToHex(bytes: Uint8Array): string {
|
||||||
|
return Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Key generation ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a new identity: Ed25519 signing keys + X25519 encryption keys.
|
||||||
|
* Returns a KeyFile compatible with the Go node format.
|
||||||
|
*/
|
||||||
|
export function generateKeyFile(): KeyFile {
|
||||||
|
// Ed25519 for signing / blockchain identity
|
||||||
|
const signKP = nacl.sign.keyPair();
|
||||||
|
|
||||||
|
// X25519 for NaCl box encryption
|
||||||
|
// nacl.box.keyPair() returns Curve25519 keys
|
||||||
|
const boxKP = nacl.box.keyPair();
|
||||||
|
|
||||||
|
return {
|
||||||
|
pub_key: bytesToHex(signKP.publicKey),
|
||||||
|
priv_key: bytesToHex(signKP.secretKey),
|
||||||
|
x25519_pub: bytesToHex(boxKP.publicKey),
|
||||||
|
x25519_priv: bytesToHex(boxKP.secretKey),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── NaCl box encryption ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encrypt a plaintext message using NaCl box.
|
||||||
|
* Sender uses their X25519 secret key + recipient's X25519 public key.
|
||||||
|
* Returns { nonce, ciphertext } as hex strings.
|
||||||
|
*/
|
||||||
|
export function encryptMessage(
|
||||||
|
plaintext: string,
|
||||||
|
senderSecretHex: string,
|
||||||
|
recipientPubHex: string,
|
||||||
|
): { nonce: string; ciphertext: string } {
|
||||||
|
const nonce = nacl.randomBytes(nacl.box.nonceLength);
|
||||||
|
const message = decodeUTF8(plaintext);
|
||||||
|
const secretKey = hexToBytes(senderSecretHex);
|
||||||
|
const publicKey = hexToBytes(recipientPubHex);
|
||||||
|
|
||||||
|
const box = nacl.box(message, nonce, publicKey, secretKey);
|
||||||
|
return {
|
||||||
|
nonce: bytesToHex(nonce),
|
||||||
|
ciphertext: bytesToHex(box),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decrypt a NaCl box.
|
||||||
|
* Recipient uses their X25519 secret key + sender's X25519 public key.
|
||||||
|
*/
|
||||||
|
export function decryptMessage(
|
||||||
|
ciphertextHex: string,
|
||||||
|
nonceHex: string,
|
||||||
|
senderPubHex: string,
|
||||||
|
recipientSecHex: string,
|
||||||
|
): string | null {
|
||||||
|
try {
|
||||||
|
const ciphertext = hexToBytes(ciphertextHex);
|
||||||
|
const nonce = hexToBytes(nonceHex);
|
||||||
|
const senderPub = hexToBytes(senderPubHex);
|
||||||
|
const secretKey = hexToBytes(recipientSecHex);
|
||||||
|
|
||||||
|
const plain = nacl.box.open(ciphertext, nonce, senderPub, secretKey);
|
||||||
|
if (!plain) return null;
|
||||||
|
return encodeUTF8(plain);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Ed25519 signing ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sign arbitrary data with the Ed25519 private key.
|
||||||
|
* Returns signature as hex.
|
||||||
|
*/
|
||||||
|
export function sign(data: Uint8Array, privKeyHex: string): string {
|
||||||
|
const secretKey = hexToBytes(privKeyHex);
|
||||||
|
const sig = nacl.sign.detached(data, secretKey);
|
||||||
|
return bytesToHex(sig);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sign arbitrary data with the Ed25519 private key.
|
||||||
|
* Returns signature as base64 — this is the format the Go blockchain node
|
||||||
|
* expects ([]byte fields are base64 in JSON).
|
||||||
|
*/
|
||||||
|
export function signBase64(data: Uint8Array, privKeyHex: string): string {
|
||||||
|
const secretKey = hexToBytes(privKeyHex);
|
||||||
|
const sig = nacl.sign.detached(data, secretKey);
|
||||||
|
return bytesToBase64(sig);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Encode bytes as base64. Works on Hermes (btoa is available since RN 0.71). */
|
||||||
|
export function bytesToBase64(bytes: Uint8Array): string {
|
||||||
|
let binary = '';
|
||||||
|
for (let i = 0; i < bytes.length; i++) {
|
||||||
|
binary += String.fromCharCode(bytes[i]);
|
||||||
|
}
|
||||||
|
return btoa(binary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify an Ed25519 signature.
|
||||||
|
*/
|
||||||
|
export function verify(data: Uint8Array, sigHex: string, pubKeyHex: string): boolean {
|
||||||
|
try {
|
||||||
|
return nacl.sign.detached.verify(data, hexToBytes(sigHex), hexToBytes(pubKeyHex));
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Address helpers ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Truncate a long hex address for display: 8...8 */
|
||||||
|
export function shortAddr(hex: string, chars = 8): string {
|
||||||
|
if (hex.length <= chars * 2 + 3) return hex;
|
||||||
|
return `${hex.slice(0, chars)}…${hex.slice(-chars)}`;
|
||||||
|
}
|
||||||
101
client-app/lib/storage.ts
Normal file
101
client-app/lib/storage.ts
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
/**
|
||||||
|
* Persistent storage for keys and app settings.
|
||||||
|
* On mobile: expo-secure-store for key material, AsyncStorage for settings.
|
||||||
|
* On web: falls back to localStorage (dev only).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import * as SecureStore from 'expo-secure-store';
|
||||||
|
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||||
|
import type { KeyFile, Contact, NodeSettings } from './types';
|
||||||
|
|
||||||
|
// ─── Keys ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const KEYFILE_KEY = 'dchain_keyfile';
|
||||||
|
const CONTACTS_KEY = 'dchain_contacts';
|
||||||
|
const SETTINGS_KEY = 'dchain_settings';
|
||||||
|
const CHATS_KEY = 'dchain_chats';
|
||||||
|
|
||||||
|
/** Save the key file in secure storage (encrypted on device). */
|
||||||
|
export async function saveKeyFile(kf: KeyFile): Promise<void> {
|
||||||
|
await SecureStore.setItemAsync(KEYFILE_KEY, JSON.stringify(kf));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Load key file. Returns null if not set. */
|
||||||
|
export async function loadKeyFile(): Promise<KeyFile | null> {
|
||||||
|
const raw = await SecureStore.getItemAsync(KEYFILE_KEY);
|
||||||
|
if (!raw) return null;
|
||||||
|
return JSON.parse(raw) as KeyFile;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Delete key file (logout / factory reset). */
|
||||||
|
export async function deleteKeyFile(): Promise<void> {
|
||||||
|
await SecureStore.deleteItemAsync(KEYFILE_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Node settings ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const DEFAULT_SETTINGS: NodeSettings = {
|
||||||
|
nodeUrl: 'http://localhost:8081',
|
||||||
|
contractId: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function loadSettings(): Promise<NodeSettings> {
|
||||||
|
const raw = await AsyncStorage.getItem(SETTINGS_KEY);
|
||||||
|
if (!raw) return DEFAULT_SETTINGS;
|
||||||
|
return { ...DEFAULT_SETTINGS, ...JSON.parse(raw) };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveSettings(s: Partial<NodeSettings>): Promise<void> {
|
||||||
|
const current = await loadSettings();
|
||||||
|
await AsyncStorage.setItem(SETTINGS_KEY, JSON.stringify({ ...current, ...s }));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Contacts ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function loadContacts(): Promise<Contact[]> {
|
||||||
|
const raw = await AsyncStorage.getItem(CONTACTS_KEY);
|
||||||
|
if (!raw) return [];
|
||||||
|
return JSON.parse(raw) as Contact[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveContact(c: Contact): Promise<void> {
|
||||||
|
const contacts = await loadContacts();
|
||||||
|
const idx = contacts.findIndex(x => x.address === c.address);
|
||||||
|
if (idx >= 0) contacts[idx] = c;
|
||||||
|
else contacts.push(c);
|
||||||
|
await AsyncStorage.setItem(CONTACTS_KEY, JSON.stringify(contacts));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteContact(address: string): Promise<void> {
|
||||||
|
const contacts = await loadContacts();
|
||||||
|
await AsyncStorage.setItem(
|
||||||
|
CONTACTS_KEY,
|
||||||
|
JSON.stringify(contacts.filter(c => c.address !== address)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Message cache (per-chat local store) ────────────────────────────────────
|
||||||
|
|
||||||
|
export interface CachedMessage {
|
||||||
|
id: string;
|
||||||
|
from: string;
|
||||||
|
text: string;
|
||||||
|
timestamp: number;
|
||||||
|
mine: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadMessages(chatId: string): Promise<CachedMessage[]> {
|
||||||
|
const raw = await AsyncStorage.getItem(`${CHATS_KEY}_${chatId}`);
|
||||||
|
if (!raw) return [];
|
||||||
|
return JSON.parse(raw) as CachedMessage[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function appendMessage(chatId: string, msg: CachedMessage): Promise<void> {
|
||||||
|
const msgs = await loadMessages(chatId);
|
||||||
|
// Deduplicate by id
|
||||||
|
if (msgs.find(m => m.id === msg.id)) return;
|
||||||
|
msgs.push(msg);
|
||||||
|
// Keep last 500 messages per chat
|
||||||
|
const trimmed = msgs.slice(-500);
|
||||||
|
await AsyncStorage.setItem(`${CHATS_KEY}_${chatId}`, JSON.stringify(trimmed));
|
||||||
|
}
|
||||||
103
client-app/lib/store.ts
Normal file
103
client-app/lib/store.ts
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
/**
|
||||||
|
* Global app state via Zustand.
|
||||||
|
* Keeps runtime state; persistent data lives in storage.ts.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { create } from 'zustand';
|
||||||
|
import type { KeyFile, Contact, Chat, Message, ContactRequest, NodeSettings } from './types';
|
||||||
|
|
||||||
|
interface AppState {
|
||||||
|
// Identity
|
||||||
|
keyFile: KeyFile | null;
|
||||||
|
username: string | null;
|
||||||
|
setKeyFile: (kf: KeyFile | null) => void;
|
||||||
|
setUsername: (u: string | null) => void;
|
||||||
|
|
||||||
|
// Node settings
|
||||||
|
settings: NodeSettings;
|
||||||
|
setSettings: (s: Partial<NodeSettings>) => void;
|
||||||
|
|
||||||
|
// Contacts
|
||||||
|
contacts: Contact[];
|
||||||
|
setContacts: (contacts: Contact[]) => void;
|
||||||
|
upsertContact: (c: Contact) => void;
|
||||||
|
|
||||||
|
// Chats (derived from contacts + messages)
|
||||||
|
chats: Chat[];
|
||||||
|
setChats: (chats: Chat[]) => void;
|
||||||
|
|
||||||
|
// Active chat messages
|
||||||
|
messages: Record<string, Message[]>; // key: contactAddress
|
||||||
|
setMessages: (chatId: string, msgs: Message[]) => void;
|
||||||
|
appendMessage: (chatId: string, msg: Message) => void;
|
||||||
|
|
||||||
|
// Contact requests (pending)
|
||||||
|
requests: ContactRequest[];
|
||||||
|
setRequests: (reqs: ContactRequest[]) => void;
|
||||||
|
|
||||||
|
// Balance
|
||||||
|
balance: number;
|
||||||
|
setBalance: (b: number) => void;
|
||||||
|
|
||||||
|
// Loading / error states
|
||||||
|
loading: boolean;
|
||||||
|
setLoading: (v: boolean) => void;
|
||||||
|
error: string | null;
|
||||||
|
setError: (e: string | null) => void;
|
||||||
|
|
||||||
|
// Nonce cache (to avoid refetching)
|
||||||
|
nonce: number;
|
||||||
|
setNonce: (n: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useStore = create<AppState>((set, get) => ({
|
||||||
|
keyFile: null,
|
||||||
|
username: null,
|
||||||
|
setKeyFile: (kf) => set({ keyFile: kf }),
|
||||||
|
setUsername: (u) => set({ username: u }),
|
||||||
|
|
||||||
|
settings: {
|
||||||
|
nodeUrl: 'http://localhost:8081',
|
||||||
|
contractId: '',
|
||||||
|
},
|
||||||
|
setSettings: (s) => set(state => ({ settings: { ...state.settings, ...s } })),
|
||||||
|
|
||||||
|
contacts: [],
|
||||||
|
setContacts: (contacts) => set({ contacts }),
|
||||||
|
upsertContact: (c) => set(state => {
|
||||||
|
const idx = state.contacts.findIndex(x => x.address === c.address);
|
||||||
|
if (idx >= 0) {
|
||||||
|
const updated = [...state.contacts];
|
||||||
|
updated[idx] = c;
|
||||||
|
return { contacts: updated };
|
||||||
|
}
|
||||||
|
return { contacts: [...state.contacts, c] };
|
||||||
|
}),
|
||||||
|
|
||||||
|
chats: [],
|
||||||
|
setChats: (chats) => set({ chats }),
|
||||||
|
|
||||||
|
messages: {},
|
||||||
|
setMessages: (chatId, msgs) => set(state => ({
|
||||||
|
messages: { ...state.messages, [chatId]: msgs },
|
||||||
|
})),
|
||||||
|
appendMessage: (chatId, msg) => set(state => {
|
||||||
|
const current = state.messages[chatId] ?? [];
|
||||||
|
if (current.find(m => m.id === msg.id)) return {};
|
||||||
|
return { messages: { ...state.messages, [chatId]: [...current, msg] } };
|
||||||
|
}),
|
||||||
|
|
||||||
|
requests: [],
|
||||||
|
setRequests: (reqs) => set({ requests: reqs }),
|
||||||
|
|
||||||
|
balance: 0,
|
||||||
|
setBalance: (b) => set({ balance: b }),
|
||||||
|
|
||||||
|
loading: false,
|
||||||
|
setLoading: (v) => set({ loading: v }),
|
||||||
|
error: null,
|
||||||
|
setError: (e) => set({ error: e }),
|
||||||
|
|
||||||
|
nonce: 0,
|
||||||
|
setNonce: (n) => set({ nonce: n }),
|
||||||
|
}));
|
||||||
86
client-app/lib/types.ts
Normal file
86
client-app/lib/types.ts
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
// ─── Key material ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface KeyFile {
|
||||||
|
pub_key: string; // hex Ed25519 public key (32 bytes)
|
||||||
|
priv_key: string; // hex Ed25519 private key (64 bytes)
|
||||||
|
x25519_pub: string; // hex X25519 public key (32 bytes)
|
||||||
|
x25519_priv: string; // hex X25519 private key (32 bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Contact ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface Contact {
|
||||||
|
address: string; // Ed25519 pubkey hex — blockchain address
|
||||||
|
x25519Pub: string; // X25519 pubkey hex — encryption key
|
||||||
|
username?: string; // @name from registry contract
|
||||||
|
alias?: string; // local nickname
|
||||||
|
addedAt: number; // unix ms
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Messages ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface Envelope {
|
||||||
|
sender_pub: string; // X25519 hex
|
||||||
|
recipient_pub: string; // X25519 hex
|
||||||
|
nonce: string; // hex 24 bytes
|
||||||
|
ciphertext: string; // hex NaCl box
|
||||||
|
timestamp: number; // unix seconds
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Message {
|
||||||
|
id: string;
|
||||||
|
from: string; // X25519 pubkey of sender
|
||||||
|
text: string;
|
||||||
|
timestamp: number;
|
||||||
|
mine: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Chat ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface Chat {
|
||||||
|
contactAddress: string; // Ed25519 pubkey hex
|
||||||
|
contactX25519: string; // X25519 pubkey hex
|
||||||
|
username?: string;
|
||||||
|
alias?: string;
|
||||||
|
lastMessage?: string;
|
||||||
|
lastTime?: number;
|
||||||
|
unread: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Contact request ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface ContactRequest {
|
||||||
|
from: string; // Ed25519 pubkey hex
|
||||||
|
x25519Pub: string; // X25519 pubkey hex; empty until fetched from identity
|
||||||
|
username?: string;
|
||||||
|
intro: string; // plaintext intro (stored on-chain)
|
||||||
|
timestamp: number;
|
||||||
|
txHash: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Transaction ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface TxRecord {
|
||||||
|
hash: string;
|
||||||
|
type: string;
|
||||||
|
from: string;
|
||||||
|
to?: string;
|
||||||
|
amount?: number;
|
||||||
|
fee: number;
|
||||||
|
timestamp: number;
|
||||||
|
status: 'confirmed' | 'pending';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Node info ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface NetStats {
|
||||||
|
total_blocks: number;
|
||||||
|
total_txs: number;
|
||||||
|
peer_count: number;
|
||||||
|
chain_id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NodeSettings {
|
||||||
|
nodeUrl: string;
|
||||||
|
contractId: string; // username_registry contract
|
||||||
|
}
|
||||||
35
client-app/lib/utils.ts
Normal file
35
client-app/lib/utils.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { clsx, type ClassValue } from 'clsx';
|
||||||
|
import { twMerge } from 'tailwind-merge';
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Format µT amount to human-readable string */
|
||||||
|
export function formatAmount(microTokens: number | undefined | null): string {
|
||||||
|
if (microTokens == null) return '—';
|
||||||
|
if (microTokens >= 1_000_000) return `${(microTokens / 1_000_000).toFixed(2)} T`;
|
||||||
|
if (microTokens >= 1_000) return `${(microTokens / 1_000).toFixed(1)} mT`;
|
||||||
|
return `${microTokens} µT`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Format unix seconds to relative time */
|
||||||
|
export function relativeTime(unixSeconds: number | undefined | null): string {
|
||||||
|
if (!unixSeconds) return '';
|
||||||
|
const diff = Date.now() / 1000 - unixSeconds;
|
||||||
|
if (diff < 60) return 'just now';
|
||||||
|
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
|
||||||
|
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
|
||||||
|
return new Date(unixSeconds * 1000).toLocaleDateString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Format unix seconds to HH:MM */
|
||||||
|
export function formatTime(unixSeconds: number | undefined | null): string {
|
||||||
|
if (!unixSeconds) return '';
|
||||||
|
return new Date(unixSeconds * 1000).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Generate a random nonce string */
|
||||||
|
export function randomId(): string {
|
||||||
|
return Math.random().toString(36).slice(2) + Date.now().toString(36);
|
||||||
|
}
|
||||||
401
client-app/lib/ws.ts
Normal file
401
client-app/lib/ws.ts
Normal file
@@ -0,0 +1,401 @@
|
|||||||
|
/**
|
||||||
|
* DChain WebSocket client — replaces balance / inbox / contacts polling with
|
||||||
|
* server-push. Matches `node/ws.go` exactly.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* const ws = getWSClient();
|
||||||
|
* ws.connect(); // idempotent
|
||||||
|
* const off = ws.subscribe('addr:ab12…', ev => { ... });
|
||||||
|
* // later:
|
||||||
|
* off(); // unsubscribe + stop handler
|
||||||
|
* ws.disconnect();
|
||||||
|
*
|
||||||
|
* Features:
|
||||||
|
* - Auto-reconnect with exponential backoff (1s → 30s cap).
|
||||||
|
* - Re-subscribes all topics after a reconnect.
|
||||||
|
* - `hello` frame exposes chain_id + tip_height for connection state UI.
|
||||||
|
* - Degrades silently if the endpoint returns 501 (old node without WS).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { getNodeUrl, onNodeUrlChange } from './api';
|
||||||
|
import { sign } from './crypto';
|
||||||
|
|
||||||
|
export type WSEventName =
|
||||||
|
| 'hello'
|
||||||
|
| 'block'
|
||||||
|
| 'tx'
|
||||||
|
| 'contract_log'
|
||||||
|
| 'inbox'
|
||||||
|
| 'typing'
|
||||||
|
| 'pong'
|
||||||
|
| 'error'
|
||||||
|
| 'subscribed'
|
||||||
|
| 'submit_ack'
|
||||||
|
| 'lag';
|
||||||
|
|
||||||
|
export interface WSFrame {
|
||||||
|
event: WSEventName;
|
||||||
|
data?: unknown;
|
||||||
|
topic?: string;
|
||||||
|
msg?: string;
|
||||||
|
chain_id?: string;
|
||||||
|
tip_height?: number;
|
||||||
|
/** Server-issued nonce in the hello frame; client signs it for auth. */
|
||||||
|
auth_nonce?: string;
|
||||||
|
// submit_ack fields
|
||||||
|
id?: string;
|
||||||
|
status?: 'accepted' | 'rejected';
|
||||||
|
reason?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Handler = (frame: WSFrame) => void;
|
||||||
|
|
||||||
|
class WSClient {
|
||||||
|
private ws: WebSocket | null = null;
|
||||||
|
private url: string | null = null;
|
||||||
|
private reconnectMs: number = 1000;
|
||||||
|
private closing: boolean = false;
|
||||||
|
|
||||||
|
/** topic → set of handlers interested in frames for this topic */
|
||||||
|
private handlers: Map<string, Set<Handler>> = new Map();
|
||||||
|
/** topics we want the server to push — replayed on every reconnect */
|
||||||
|
private wantedTopics: Set<string> = new Set();
|
||||||
|
|
||||||
|
private connectionListeners: Set<(ok: boolean, err?: string) => void> = new Set();
|
||||||
|
private helloInfo: { chainId?: string; tipHeight?: number; authNonce?: string } = {};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Credentials used for auto-auth on every (re)connect. The signer runs on
|
||||||
|
* each hello frame so scoped subscriptions (addr:*, inbox:*) are accepted.
|
||||||
|
* Without these, subscribe requests to scoped topics get rejected by the
|
||||||
|
* server; global topics (blocks, tx, …) still work unauthenticated.
|
||||||
|
*/
|
||||||
|
private authCreds: { pubKey: string; privKey: string } | null = null;
|
||||||
|
|
||||||
|
/** Current connection state (read-only for UI). */
|
||||||
|
isConnected(): boolean {
|
||||||
|
return this.ws?.readyState === WebSocket.OPEN;
|
||||||
|
}
|
||||||
|
getHelloInfo(): { chainId?: string; tipHeight?: number } {
|
||||||
|
return this.helloInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Subscribe to a connection-state listener — fires on connect/disconnect. */
|
||||||
|
onConnectionChange(cb: (ok: boolean, err?: string) => void): () => void {
|
||||||
|
this.connectionListeners.add(cb);
|
||||||
|
return () => this.connectionListeners.delete(cb) as unknown as void;
|
||||||
|
}
|
||||||
|
private fireConnectionChange(ok: boolean, err?: string) {
|
||||||
|
for (const cb of this.connectionListeners) {
|
||||||
|
try { cb(ok, err); } catch { /* noop */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register the Ed25519 keypair used for auto-auth. The signer runs on each
|
||||||
|
* (re)connect against the server-issued nonce so the connection is bound
|
||||||
|
* to this identity. Pass null to disable auth (only global topics will
|
||||||
|
* work — useful for observers).
|
||||||
|
*/
|
||||||
|
setAuthCreds(creds: { pubKey: string; privKey: string } | null): void {
|
||||||
|
this.authCreds = creds;
|
||||||
|
// If we're already connected, kick off auth immediately.
|
||||||
|
if (creds && this.isConnected() && this.helloInfo.authNonce) {
|
||||||
|
this.sendAuth(this.helloInfo.authNonce);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Idempotent connect. Call once on app boot. */
|
||||||
|
connect(): void {
|
||||||
|
const base = getNodeUrl();
|
||||||
|
const newURL = base.replace(/^http/, 'ws') + '/api/ws';
|
||||||
|
if (this.ws) {
|
||||||
|
const state = this.ws.readyState;
|
||||||
|
// Already pointing at this URL and connected / connecting — nothing to do.
|
||||||
|
if (this.url === newURL && (state === WebSocket.OPEN || state === WebSocket.CONNECTING)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// URL changed (operator flipped nodes in settings) — tear down and
|
||||||
|
// re-dial. Existing subscriptions live in wantedTopics and will be
|
||||||
|
// replayed after the new onopen fires.
|
||||||
|
if (this.url !== newURL && (state === WebSocket.OPEN || state === WebSocket.CONNECTING)) {
|
||||||
|
try { this.ws.close(); } catch { /* noop */ }
|
||||||
|
this.ws = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.closing = false;
|
||||||
|
this.url = newURL;
|
||||||
|
try {
|
||||||
|
this.ws = new WebSocket(this.url);
|
||||||
|
} catch (e: any) {
|
||||||
|
this.fireConnectionChange(false, e?.message ?? 'ws construct failed');
|
||||||
|
this.scheduleReconnect();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.ws.onopen = () => {
|
||||||
|
this.reconnectMs = 1000; // reset backoff
|
||||||
|
this.fireConnectionChange(true);
|
||||||
|
// Replay all wanted subscriptions.
|
||||||
|
for (const topic of this.wantedTopics) {
|
||||||
|
this.sendRaw({ op: 'subscribe', topic });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
this.ws.onmessage = (ev) => {
|
||||||
|
let frame: WSFrame;
|
||||||
|
try {
|
||||||
|
frame = JSON.parse(typeof ev.data === 'string' ? ev.data : '');
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (frame.event === 'hello') {
|
||||||
|
this.helloInfo = {
|
||||||
|
chainId: frame.chain_id,
|
||||||
|
tipHeight: frame.tip_height,
|
||||||
|
authNonce: frame.auth_nonce,
|
||||||
|
};
|
||||||
|
// Auto-authenticate if credentials are set. The server binds this
|
||||||
|
// connection to the signed pubkey so scoped subscriptions (addr:*,
|
||||||
|
// inbox:*) get through. On reconnect a new nonce is issued, so the
|
||||||
|
// auth dance repeats transparently.
|
||||||
|
if (this.authCreds && frame.auth_nonce) {
|
||||||
|
this.sendAuth(frame.auth_nonce);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Dispatch to all handlers for any topic that could match this frame.
|
||||||
|
// We use a simple predicate: look at the frame to decide which topics it
|
||||||
|
// was fanned out to, then fire every matching handler.
|
||||||
|
for (const topic of this.topicsForFrame(frame)) {
|
||||||
|
const set = this.handlers.get(topic);
|
||||||
|
if (!set) continue;
|
||||||
|
for (const h of set) {
|
||||||
|
try { h(frame); } catch (e) { console.warn('[ws] handler error', e); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
this.ws.onerror = (e: any) => {
|
||||||
|
this.fireConnectionChange(false, 'ws error');
|
||||||
|
};
|
||||||
|
|
||||||
|
this.ws.onclose = () => {
|
||||||
|
this.ws = null;
|
||||||
|
this.fireConnectionChange(false);
|
||||||
|
if (!this.closing) this.scheduleReconnect();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
disconnect(): void {
|
||||||
|
this.closing = true;
|
||||||
|
if (this.ws) {
|
||||||
|
try { this.ws.close(); } catch { /* noop */ }
|
||||||
|
this.ws = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subscribe to a topic. Returns an `off()` function that unsubscribes AND
|
||||||
|
* removes the handler. If multiple callers subscribe to the same topic,
|
||||||
|
* the server is only notified on the first and last caller.
|
||||||
|
*/
|
||||||
|
subscribe(topic: string, handler: Handler): () => void {
|
||||||
|
let set = this.handlers.get(topic);
|
||||||
|
if (!set) {
|
||||||
|
set = new Set();
|
||||||
|
this.handlers.set(topic, set);
|
||||||
|
}
|
||||||
|
set.add(handler);
|
||||||
|
|
||||||
|
// Notify server only on the first handler for this topic.
|
||||||
|
if (!this.wantedTopics.has(topic)) {
|
||||||
|
this.wantedTopics.add(topic);
|
||||||
|
if (this.isConnected()) {
|
||||||
|
this.sendRaw({ op: 'subscribe', topic });
|
||||||
|
} else {
|
||||||
|
this.connect(); // lazy-connect on first subscribe
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
const s = this.handlers.get(topic);
|
||||||
|
if (!s) return;
|
||||||
|
s.delete(handler);
|
||||||
|
if (s.size === 0) {
|
||||||
|
this.handlers.delete(topic);
|
||||||
|
this.wantedTopics.delete(topic);
|
||||||
|
if (this.isConnected()) {
|
||||||
|
this.sendRaw({ op: 'unsubscribe', topic });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Force a keepalive ping. Useful for debugging. */
|
||||||
|
ping(): void {
|
||||||
|
this.sendRaw({ op: 'ping' });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a typing indicator to another user. Recipient is their X25519 pubkey
|
||||||
|
* (the one used for inbox encryption). Ephemeral — no ack, no retry; just
|
||||||
|
* fire and forget. Call on each keystroke but throttle to once per 2-3s
|
||||||
|
* at the caller side so we don't flood the WS with frames.
|
||||||
|
*/
|
||||||
|
sendTyping(recipientX25519: string): void {
|
||||||
|
if (!this.isConnected()) return;
|
||||||
|
try {
|
||||||
|
this.ws!.send(JSON.stringify({ op: 'typing', to: recipientX25519 }));
|
||||||
|
} catch { /* best-effort */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Submit a signed transaction over the WebSocket and resolve once the
|
||||||
|
* server returns a `submit_ack`. Saves the HTTP round-trip on every tx
|
||||||
|
* and gives the UI immediate accept/reject feedback.
|
||||||
|
*
|
||||||
|
* Rejects if:
|
||||||
|
* - WS is not connected (caller should fall back to HTTP)
|
||||||
|
* - Server returns `status: "rejected"` — `reason` is surfaced as error msg
|
||||||
|
* - No ack within `timeoutMs` (default 10 s)
|
||||||
|
*/
|
||||||
|
submitTx(tx: unknown, timeoutMs = 10_000): Promise<{ id: string }> {
|
||||||
|
if (!this.isConnected()) {
|
||||||
|
return Promise.reject(new Error('WS not connected'));
|
||||||
|
}
|
||||||
|
const reqId = 's_' + Date.now() + '_' + Math.random().toString(36).slice(2, 8);
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const off = this.subscribe('$system', (frame) => {
|
||||||
|
if (frame.event !== 'submit_ack' || frame.id !== reqId) return;
|
||||||
|
off();
|
||||||
|
clearTimeout(timer);
|
||||||
|
if (frame.status === 'accepted') {
|
||||||
|
// `msg` carries the server-confirmed tx id.
|
||||||
|
resolve({ id: typeof frame.msg === 'string' ? frame.msg : '' });
|
||||||
|
} else {
|
||||||
|
reject(new Error(frame.reason || 'submit_tx rejected'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
off();
|
||||||
|
reject(new Error('submit_tx timeout (' + timeoutMs + 'ms)'));
|
||||||
|
}, timeoutMs);
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.ws!.send(JSON.stringify({ op: 'submit_tx', tx, id: reqId }));
|
||||||
|
} catch (e: any) {
|
||||||
|
off();
|
||||||
|
clearTimeout(timer);
|
||||||
|
reject(new Error('WS send failed: ' + (e?.message ?? 'unknown')));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── internals ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private scheduleReconnect(): void {
|
||||||
|
if (this.closing) return;
|
||||||
|
const delay = Math.min(this.reconnectMs, 30_000);
|
||||||
|
this.reconnectMs = Math.min(this.reconnectMs * 2, 30_000);
|
||||||
|
setTimeout(() => {
|
||||||
|
if (!this.closing) this.connect();
|
||||||
|
}, delay);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sendRaw(cmd: { op: string; topic?: string }): void {
|
||||||
|
if (!this.isConnected()) return;
|
||||||
|
try { this.ws!.send(JSON.stringify(cmd)); } catch { /* noop */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sign the server nonce with our Ed25519 private key and send the `auth`
|
||||||
|
* op. The server binds this connection to `authCreds.pubKey`; subsequent
|
||||||
|
* subscribe requests to `addr:<pubKey>` / `inbox:<my_x25519>` are accepted.
|
||||||
|
*/
|
||||||
|
private sendAuth(nonce: string): void {
|
||||||
|
if (!this.authCreds || !this.isConnected()) return;
|
||||||
|
try {
|
||||||
|
const bytes = new TextEncoder().encode(nonce);
|
||||||
|
const sig = sign(bytes, this.authCreds.privKey);
|
||||||
|
this.ws!.send(JSON.stringify({
|
||||||
|
op: 'auth',
|
||||||
|
pubkey: this.authCreds.pubKey,
|
||||||
|
sig,
|
||||||
|
}));
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[ws] auth send failed:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Given an incoming frame, enumerate every topic that handlers could have
|
||||||
|
* subscribed to and still be interested. This mirrors the fan-out logic in
|
||||||
|
* node/ws.go:EmitBlock / EmitTx / EmitContractLog.
|
||||||
|
*/
|
||||||
|
private topicsForFrame(frame: WSFrame): string[] {
|
||||||
|
switch (frame.event) {
|
||||||
|
case 'block':
|
||||||
|
return ['blocks'];
|
||||||
|
case 'tx': {
|
||||||
|
const d = frame.data as { from?: string; to?: string } | undefined;
|
||||||
|
const topics = ['tx'];
|
||||||
|
if (d?.from) topics.push('addr:' + d.from);
|
||||||
|
if (d?.to && d.to !== d.from) topics.push('addr:' + d.to);
|
||||||
|
return topics;
|
||||||
|
}
|
||||||
|
case 'contract_log': {
|
||||||
|
const d = frame.data as { contract_id?: string } | undefined;
|
||||||
|
const topics = ['contract_log'];
|
||||||
|
if (d?.contract_id) topics.push('contract:' + d.contract_id);
|
||||||
|
return topics;
|
||||||
|
}
|
||||||
|
case 'inbox': {
|
||||||
|
// Node fans inbox events to `inbox` + `inbox:<recipient_x25519>`;
|
||||||
|
// we mirror that here so both firehose listeners and address-scoped
|
||||||
|
// subscribers see the event.
|
||||||
|
const d = frame.data as { recipient_pub?: string } | undefined;
|
||||||
|
const topics = ['inbox'];
|
||||||
|
if (d?.recipient_pub) topics.push('inbox:' + d.recipient_pub);
|
||||||
|
return topics;
|
||||||
|
}
|
||||||
|
case 'typing': {
|
||||||
|
// Server fans to `typing:<to>` only (the recipient).
|
||||||
|
const d = frame.data as { to?: string } | undefined;
|
||||||
|
return d?.to ? ['typing:' + d.to] : [];
|
||||||
|
}
|
||||||
|
// Control-plane events — no topic fan-out; use a pseudo-topic so UI
|
||||||
|
// can listen for them via subscribe('$system', ...).
|
||||||
|
case 'hello':
|
||||||
|
case 'pong':
|
||||||
|
case 'error':
|
||||||
|
case 'subscribed':
|
||||||
|
case 'submit_ack':
|
||||||
|
case 'lag':
|
||||||
|
return ['$system'];
|
||||||
|
default:
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let _singleton: WSClient | null = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return the app-wide WebSocket client. Safe to call from any component;
|
||||||
|
* `.connect()` is idempotent.
|
||||||
|
*
|
||||||
|
* On first creation we register a node-URL listener so flipping the node
|
||||||
|
* in Settings tears down the existing socket and dials the new one — the
|
||||||
|
* user's active subscriptions (addr:*, inbox:*) replay automatically.
|
||||||
|
*/
|
||||||
|
export function getWSClient(): WSClient {
|
||||||
|
if (!_singleton) {
|
||||||
|
_singleton = new WSClient();
|
||||||
|
onNodeUrlChange(() => {
|
||||||
|
// Fire and forget — connect() is idempotent and handles stale URLs.
|
||||||
|
_singleton!.connect();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return _singleton;
|
||||||
|
}
|
||||||
6
client-app/metro.config.js
Normal file
6
client-app/metro.config.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
const { getDefaultConfig } = require('expo/metro-config');
|
||||||
|
const { withNativeWind } = require('nativewind/metro');
|
||||||
|
|
||||||
|
const config = getDefaultConfig(__dirname);
|
||||||
|
|
||||||
|
module.exports = withNativeWind(config, { input: './global.css' });
|
||||||
1
client-app/nativewind-env.d.ts
vendored
Normal file
1
client-app/nativewind-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
/// <reference types="nativewind/types" />
|
||||||
10317
client-app/package-lock.json
generated
Normal file
10317
client-app/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
51
client-app/package.json
Normal file
51
client-app/package.json
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
{
|
||||||
|
"name": "dchain-messenger",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"main": "expo-router/entry",
|
||||||
|
"scripts": {
|
||||||
|
"start": "expo start",
|
||||||
|
"android": "expo start --android",
|
||||||
|
"ios": "expo start --ios",
|
||||||
|
"web": "expo start --web",
|
||||||
|
"lint": "eslint ."
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@react-native-async-storage/async-storage": "2.2.0",
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"expo": "~54.0.0",
|
||||||
|
"expo-asset": "~12.0.12",
|
||||||
|
"expo-camera": "~16.1.6",
|
||||||
|
"expo-crypto": "~14.1.4",
|
||||||
|
"expo-clipboard": "~8.0.8",
|
||||||
|
"expo-constants": "~18.0.13",
|
||||||
|
"expo-document-picker": "~14.0.8",
|
||||||
|
"expo-file-system": "~19.0.21",
|
||||||
|
"expo-font": "~14.0.11",
|
||||||
|
"expo-linking": "~8.0.11",
|
||||||
|
"expo-router": "~6.0.23",
|
||||||
|
"expo-secure-store": "~15.0.8",
|
||||||
|
"expo-sharing": "~14.0.8",
|
||||||
|
"expo-splash-screen": "~31.0.13",
|
||||||
|
"expo-status-bar": "~3.0.9",
|
||||||
|
"expo-web-browser": "~15.0.10",
|
||||||
|
"nativewind": "^4.1.23",
|
||||||
|
"react": "19.1.0",
|
||||||
|
"react-native": "0.81.5",
|
||||||
|
"react-native-reanimated": "~3.17.0",
|
||||||
|
"react-native-safe-area-context": "~5.6.0",
|
||||||
|
"react-native-screens": "~4.16.0",
|
||||||
|
"react-native-worklets": "~0.8.1",
|
||||||
|
"tailwind-merge": "^2.6.0",
|
||||||
|
"tailwindcss": "^3.4.17",
|
||||||
|
"tweetnacl": "^1.0.3",
|
||||||
|
"tweetnacl-util": "^0.15.1",
|
||||||
|
"zustand": "^5.0.3"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@babel/core": "^7.25.2",
|
||||||
|
"@types/react": "~19.1.0",
|
||||||
|
"babel-preset-expo": "~13.0.0",
|
||||||
|
"typescript": "^5.3.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
28
client-app/tailwind.config.js
Normal file
28
client-app/tailwind.config.js
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
module.exports = {
|
||||||
|
content: [
|
||||||
|
'./app/**/*.{js,jsx,ts,tsx}',
|
||||||
|
'./components/**/*.{js,jsx,ts,tsx}',
|
||||||
|
'./hooks/**/*.{js,jsx,ts,tsx}',
|
||||||
|
'./lib/**/*.{js,jsx,ts,tsx}',
|
||||||
|
],
|
||||||
|
presets: [require('nativewind/preset')],
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
// DChain brand — deep navy + teal accent
|
||||||
|
background: '#0d1117',
|
||||||
|
surface: '#161b22',
|
||||||
|
surfaceHigh: '#21262d',
|
||||||
|
border: '#30363d',
|
||||||
|
primary: '#2563eb',
|
||||||
|
primaryFg: '#ffffff',
|
||||||
|
accent: '#22d3ee',
|
||||||
|
muted: '#8b949e',
|
||||||
|
destructive: '#f85149',
|
||||||
|
success: '#3fb950',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
};
|
||||||
9
client-app/tsconfig.json
Normal file
9
client-app/tsconfig.json
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"extends": "expo/tsconfig.base",
|
||||||
|
"compilerOptions": {
|
||||||
|
"strict": true,
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./*"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1564
cmd/client/main.go
Normal file
1564
cmd/client/main.go
Normal file
File diff suppressed because it is too large
Load Diff
402
cmd/loadtest/main.go
Normal file
402
cmd/loadtest/main.go
Normal file
@@ -0,0 +1,402 @@
|
|||||||
|
// Command loadtest — probes a running DChain cluster with N concurrent
|
||||||
|
// WebSocket clients, each subscribing to its own address and submitting
|
||||||
|
// periodic TRANSFER transactions.
|
||||||
|
//
|
||||||
|
// Goal: smoke-test the WS gateway, submit_tx path, native contracts, and
|
||||||
|
// mempool fairness end-to-end. Catches deadlocks / leaks that unit tests
|
||||||
|
// miss because they don't run the full stack.
|
||||||
|
//
|
||||||
|
// Usage:
|
||||||
|
//
|
||||||
|
// go run ./cmd/loadtest \
|
||||||
|
// --node http://localhost:8081 \
|
||||||
|
// --funder testdata/node1.json \
|
||||||
|
// --clients 50 \
|
||||||
|
// --duration 60s \
|
||||||
|
// --tx-per-client-per-sec 1
|
||||||
|
//
|
||||||
|
// Exits non-zero if:
|
||||||
|
// - chain tip doesn't advance during the run (consensus stuck)
|
||||||
|
// - any client's WS connection drops and fails to reconnect
|
||||||
|
// - mempool-reject rate exceeds 10%
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"crypto/ed25519"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gorilla/websocket"
|
||||||
|
|
||||||
|
"go-blockchain/blockchain"
|
||||||
|
"go-blockchain/identity"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
nodeURL := flag.String("node", "http://localhost:8081", "node HTTP base URL")
|
||||||
|
funderKey := flag.String("funder", "testdata/node1.json", "path to key file with balance used to fund the test clients")
|
||||||
|
numClients := flag.Int("clients", 50, "number of concurrent clients")
|
||||||
|
duration := flag.Duration("duration", 30*time.Second, "how long to run the load test")
|
||||||
|
txRate := flag.Float64("tx-per-client-per-sec", 1.0, "how fast each client should submit TRANSFER txs")
|
||||||
|
fundAmount := flag.Uint64("fund-amount", 100_000, "µT sent to each client before the test begins")
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
funder := loadKeyFile(*funderKey)
|
||||||
|
log.Printf("[loadtest] funder: %s", funder.PubKeyHex()[:12])
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), *duration+1*time.Minute)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
// --- 1. Generate N throw-away client identities ---
|
||||||
|
clients := make([]*identity.Identity, *numClients)
|
||||||
|
for i := range clients {
|
||||||
|
clients[i] = newEphemeralIdentity()
|
||||||
|
}
|
||||||
|
log.Printf("[loadtest] generated %d client identities", *numClients)
|
||||||
|
|
||||||
|
// --- 2. Fund them all — throttle to stay below the node's per-IP
|
||||||
|
// submit rate limiter (~10/s with burst 20). Loadtest runs from a
|
||||||
|
// single IP so it'd hit that defence immediately otherwise.
|
||||||
|
log.Printf("[loadtest] funding each client with %d µT…", *fundAmount)
|
||||||
|
startHeight := mustNetstats(*nodeURL).TotalBlocks
|
||||||
|
for _, c := range clients {
|
||||||
|
if err := submitTransfer(*nodeURL, funder, c.PubKeyHex(), *fundAmount); err != nil {
|
||||||
|
log.Fatalf("fund client: %v", err)
|
||||||
|
}
|
||||||
|
time.Sleep(120 * time.Millisecond)
|
||||||
|
}
|
||||||
|
// Wait for all funding txs to commit. We budget 60s at a conservative
|
||||||
|
// 1 block / 3-5 s PBFT cadence — plenty for dozens of fundings to
|
||||||
|
// round-robin into blocks. We only require ONE block of advance as
|
||||||
|
// the "chain is alive" signal; real check is via balance query below.
|
||||||
|
if err := waitTipAdvance(ctx, *nodeURL, startHeight, 1, 60*time.Second); err != nil {
|
||||||
|
log.Fatalf("funding didn't commit: %v", err)
|
||||||
|
}
|
||||||
|
// Poll until every client has a non-zero balance — that's the real
|
||||||
|
// signal that funding landed, independent of block-count guesses.
|
||||||
|
if err := waitAllFunded(ctx, *nodeURL, clients, *fundAmount, 90*time.Second); err != nil {
|
||||||
|
log.Fatalf("funding balance check: %v", err)
|
||||||
|
}
|
||||||
|
log.Printf("[loadtest] funding complete; starting traffic")
|
||||||
|
|
||||||
|
// --- 3. Kick off N client goroutines ---
|
||||||
|
var (
|
||||||
|
accepted atomic.Uint64
|
||||||
|
rejected atomic.Uint64
|
||||||
|
wsDrops atomic.Uint64
|
||||||
|
)
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
runCtx, runCancel := context.WithTimeout(ctx, *duration)
|
||||||
|
defer runCancel()
|
||||||
|
|
||||||
|
for i, c := range clients {
|
||||||
|
wg.Add(1)
|
||||||
|
go func(idx int, id *identity.Identity) {
|
||||||
|
defer wg.Done()
|
||||||
|
runClient(runCtx, *nodeURL, id, clients, *txRate, &accepted, &rejected, &wsDrops)
|
||||||
|
}(i, c)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 4. Monitor chain progression while the test runs ---
|
||||||
|
monitorDone := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
defer close(monitorDone)
|
||||||
|
lastHeight := startHeight
|
||||||
|
lastTime := time.Now()
|
||||||
|
t := time.NewTicker(5 * time.Second)
|
||||||
|
defer t.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-runCtx.Done():
|
||||||
|
return
|
||||||
|
case <-t.C:
|
||||||
|
s := mustNetstats(*nodeURL)
|
||||||
|
blkPerSec := float64(s.TotalBlocks-lastHeight) / time.Since(lastTime).Seconds()
|
||||||
|
log.Printf("[loadtest] tip=%d (%.1f blk/s) accepted=%d rejected=%d ws-drops=%d",
|
||||||
|
s.TotalBlocks, blkPerSec,
|
||||||
|
accepted.Load(), rejected.Load(), wsDrops.Load())
|
||||||
|
lastHeight = s.TotalBlocks
|
||||||
|
lastTime = time.Now()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
wg.Wait()
|
||||||
|
runCancel()
|
||||||
|
<-monitorDone
|
||||||
|
|
||||||
|
// --- 5. Final verdict ---
|
||||||
|
finalHeight := mustNetstats(*nodeURL).TotalBlocks
|
||||||
|
acc := accepted.Load()
|
||||||
|
rej := rejected.Load()
|
||||||
|
total := acc + rej
|
||||||
|
log.Printf("[loadtest] DONE: startHeight=%d endHeight=%d (Δ=%d blocks)",
|
||||||
|
startHeight, finalHeight, finalHeight-startHeight)
|
||||||
|
log.Printf("[loadtest] txs: accepted=%d rejected=%d (%.1f%% reject rate)",
|
||||||
|
acc, rej, 100*float64(rej)/float64(max1(total)))
|
||||||
|
log.Printf("[loadtest] ws-drops=%d", wsDrops.Load())
|
||||||
|
|
||||||
|
if finalHeight <= startHeight {
|
||||||
|
log.Fatalf("FAIL: chain did not advance during the test")
|
||||||
|
}
|
||||||
|
if rej*10 > total {
|
||||||
|
log.Fatalf("FAIL: reject rate > 10%% (%d of %d)", rej, total)
|
||||||
|
}
|
||||||
|
log.Printf("PASS")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Client loop ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func runClient(
|
||||||
|
ctx context.Context,
|
||||||
|
nodeURL string,
|
||||||
|
self *identity.Identity,
|
||||||
|
all []*identity.Identity,
|
||||||
|
txRate float64,
|
||||||
|
accepted, rejected, wsDrops *atomic.Uint64,
|
||||||
|
) {
|
||||||
|
wsURL := toWSURL(nodeURL) + "/api/ws"
|
||||||
|
conn, _, err := websocket.DefaultDialer.DialContext(ctx, wsURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
wsDrops.Add(1)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
|
||||||
|
// Read hello, then authenticate.
|
||||||
|
var hello struct {
|
||||||
|
Event string `json:"event"`
|
||||||
|
AuthNonce string `json:"auth_nonce"`
|
||||||
|
}
|
||||||
|
conn.SetReadDeadline(time.Now().Add(5 * time.Second))
|
||||||
|
if err := conn.ReadJSON(&hello); err != nil {
|
||||||
|
wsDrops.Add(1)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
conn.SetReadDeadline(time.Time{})
|
||||||
|
sig := ed25519.Sign(self.PrivKey, []byte(hello.AuthNonce))
|
||||||
|
_ = conn.WriteJSON(map[string]any{
|
||||||
|
"op": "auth",
|
||||||
|
"pubkey": self.PubKeyHex(),
|
||||||
|
"sig": hex.EncodeToString(sig),
|
||||||
|
})
|
||||||
|
// Subscribe to our own addr topic.
|
||||||
|
_ = conn.WriteJSON(map[string]any{
|
||||||
|
"op": "subscribe",
|
||||||
|
"topic": "addr:" + self.PubKeyHex(),
|
||||||
|
})
|
||||||
|
|
||||||
|
// Drain incoming frames in a background goroutine so the socket stays
|
||||||
|
// alive while we submit.
|
||||||
|
go func() {
|
||||||
|
for {
|
||||||
|
if _, _, err := conn.ReadMessage(); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Submit txs at the requested rate.
|
||||||
|
interval := time.Duration(float64(time.Second) / txRate)
|
||||||
|
t := time.NewTicker(interval)
|
||||||
|
defer t.Stop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-t.C:
|
||||||
|
peer := all[randIndex(len(all))]
|
||||||
|
if peer.PubKeyHex() == self.PubKeyHex() {
|
||||||
|
continue // don't transfer to self
|
||||||
|
}
|
||||||
|
err := submitTransfer(nodeURL, self, peer.PubKeyHex(), 1)
|
||||||
|
if err != nil {
|
||||||
|
rejected.Add(1)
|
||||||
|
} else {
|
||||||
|
accepted.Add(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── HTTP helpers ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func submitTransfer(nodeURL string, from *identity.Identity, toHex string, amount uint64) error {
|
||||||
|
tx := &blockchain.Transaction{
|
||||||
|
ID: fmt.Sprintf("lt-%d-%x", time.Now().UnixNano(), randBytes(4)),
|
||||||
|
Type: blockchain.EventTransfer,
|
||||||
|
From: from.PubKeyHex(),
|
||||||
|
To: toHex,
|
||||||
|
Amount: amount,
|
||||||
|
Fee: blockchain.MinFee,
|
||||||
|
Timestamp: time.Now().UTC(),
|
||||||
|
}
|
||||||
|
tx.Signature = from.Sign(identity.TxSignBytes(tx))
|
||||||
|
|
||||||
|
body, _ := json.Marshal(tx)
|
||||||
|
resp, err := http.Post(nodeURL+"/api/tx", "application/json", bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != 200 {
|
||||||
|
b, _ := io.ReadAll(resp.Body)
|
||||||
|
return fmt.Errorf("status %d: %s", resp.StatusCode, string(b))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type netStats struct {
|
||||||
|
TotalBlocks uint64 `json:"total_blocks"`
|
||||||
|
TotalTxs uint64 `json:"total_txs"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func mustNetstats(nodeURL string) netStats {
|
||||||
|
resp, err := http.Get(nodeURL + "/api/netstats")
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("netstats: %v", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
var s netStats
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&s); err != nil {
|
||||||
|
log.Fatalf("decode netstats: %v", err)
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func waitTipAdvance(ctx context.Context, nodeURL string, from, minDelta uint64, timeout time.Duration) error {
|
||||||
|
deadline := time.Now().Add(timeout)
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
s := mustNetstats(nodeURL)
|
||||||
|
if s.TotalBlocks >= from+minDelta {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return ctx.Err()
|
||||||
|
case <-time.After(500 * time.Millisecond):
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fmt.Errorf("tip did not advance by %d within %s", minDelta, timeout)
|
||||||
|
}
|
||||||
|
|
||||||
|
// waitAllFunded polls /api/address/<pubkey> for each client until their
|
||||||
|
// balance reaches fundAmount. More reliable than block-count heuristics
|
||||||
|
// because it verifies the funding txs were actually applied (not just
|
||||||
|
// that SOME blocks committed — empty blocks wouldn't fund anyone).
|
||||||
|
func waitAllFunded(ctx context.Context, nodeURL string, clients []*identity.Identity, fundAmount uint64, timeout time.Duration) error {
|
||||||
|
deadline := time.Now().Add(timeout)
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
allFunded := true
|
||||||
|
for _, c := range clients {
|
||||||
|
resp, err := http.Get(nodeURL + "/api/address/" + c.PubKeyHex())
|
||||||
|
if err != nil {
|
||||||
|
allFunded = false
|
||||||
|
break
|
||||||
|
}
|
||||||
|
var body struct{ BalanceUT uint64 `json:"balance_ut"` }
|
||||||
|
_ = json.NewDecoder(resp.Body).Decode(&body)
|
||||||
|
resp.Body.Close()
|
||||||
|
if body.BalanceUT < fundAmount {
|
||||||
|
allFunded = false
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if allFunded {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return ctx.Err()
|
||||||
|
case <-time.After(1 * time.Second):
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fmt.Errorf("not all clients funded within %s", timeout)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Identity helpers ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func newEphemeralIdentity() *identity.Identity {
|
||||||
|
id, err := identity.Generate()
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("genkey: %v", err)
|
||||||
|
}
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadKeyFile reads the same JSON shape cmd/client uses (PubKey/PrivKey
|
||||||
|
// as hex strings, optional X25519 pair) and returns an Identity.
|
||||||
|
func loadKeyFile(path string) *identity.Identity {
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("read funder key %s: %v", path, err)
|
||||||
|
}
|
||||||
|
var k struct {
|
||||||
|
PubKey string `json:"pub_key"`
|
||||||
|
PrivKey string `json:"priv_key"`
|
||||||
|
X25519Pub string `json:"x25519_pub"`
|
||||||
|
X25519Priv string `json:"x25519_priv"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(data, &k); err != nil {
|
||||||
|
log.Fatalf("parse funder key: %v", err)
|
||||||
|
}
|
||||||
|
id, err := identity.FromHexFull(k.PubKey, k.PrivKey, k.X25519Pub, k.X25519Priv)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("load funder identity: %v", err)
|
||||||
|
}
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Misc ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func toWSURL(httpURL string) string {
|
||||||
|
u, _ := url.Parse(httpURL)
|
||||||
|
switch u.Scheme {
|
||||||
|
case "https":
|
||||||
|
u.Scheme = "wss"
|
||||||
|
default:
|
||||||
|
u.Scheme = "ws"
|
||||||
|
}
|
||||||
|
return u.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func randBytes(n int) []byte {
|
||||||
|
b := make([]byte, n)
|
||||||
|
_, _ = rand.Read(b)
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
func randIndex(n int) int {
|
||||||
|
var b [8]byte
|
||||||
|
_, _ = rand.Read(b[:])
|
||||||
|
v := 0
|
||||||
|
for _, x := range b {
|
||||||
|
v = (v*256 + int(x)) & 0x7fffffff
|
||||||
|
}
|
||||||
|
return v % n
|
||||||
|
}
|
||||||
|
|
||||||
|
func max1(x uint64) uint64 {
|
||||||
|
if x == 0 {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
return x
|
||||||
|
}
|
||||||
|
|
||||||
|
// Silence unused-imports warning when building on platforms that don't
|
||||||
|
// need them. All imports above ARE used in the file; this is belt + braces.
|
||||||
|
var _ = os.Exit
|
||||||
1578
cmd/node/main.go
Normal file
1578
cmd/node/main.go
Normal file
File diff suppressed because it is too large
Load Diff
65
cmd/peerid/main.go
Normal file
65
cmd/peerid/main.go
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
// cmd/peerid — prints the libp2p peer ID for a key file.
|
||||||
|
// Usage: peerid --key node1.json
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/ed25519"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
libp2pcrypto "github.com/libp2p/go-libp2p/core/crypto"
|
||||||
|
"github.com/libp2p/go-libp2p/core/peer"
|
||||||
|
)
|
||||||
|
|
||||||
|
type keyJSON struct {
|
||||||
|
PubKey string `json:"pub_key"`
|
||||||
|
PrivKey string `json:"priv_key"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
keyFile := flag.String("key", "", "path to key JSON file")
|
||||||
|
listenIP := flag.String("ip", "0.0.0.0", "IP for multiaddr output")
|
||||||
|
port := flag.String("port", "4001", "port for multiaddr output")
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
if *keyFile == "" {
|
||||||
|
log.Fatal("--key is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := os.ReadFile(*keyFile)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("read key: %v", err)
|
||||||
|
}
|
||||||
|
var kj keyJSON
|
||||||
|
if err := json.Unmarshal(data, &kj); err != nil {
|
||||||
|
log.Fatalf("parse key: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
privBytes, err := hexDecode(kj.PrivKey)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("decode priv key: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
privStd := ed25519.PrivateKey(privBytes)
|
||||||
|
lk, _, err := libp2pcrypto.KeyPairFromStdKey(&privStd)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("convert key: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
pid, err := peer.IDFromPrivateKey(lk)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("peer ID: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("pub_key: %s\n", kj.PubKey)
|
||||||
|
fmt.Printf("peer_id: %s\n", pid)
|
||||||
|
fmt.Printf("multiaddr: /ip4/%s/tcp/%s/p2p/%s\n", *listenIP, *port, pid)
|
||||||
|
}
|
||||||
|
|
||||||
|
func hexDecode(s string) ([]byte, error) {
|
||||||
|
return hex.DecodeString(s)
|
||||||
|
}
|
||||||
240
cmd/wallet/main.go
Normal file
240
cmd/wallet/main.go
Normal file
@@ -0,0 +1,240 @@
|
|||||||
|
// cmd/wallet — wallet management CLI.
|
||||||
|
//
|
||||||
|
// Commands:
|
||||||
|
//
|
||||||
|
// wallet create --type node|user --label <name> --out <file> [--pass <phrase>]
|
||||||
|
// wallet info --wallet <file> [--pass <phrase>]
|
||||||
|
// wallet balance --wallet <file> [--pass <phrase>] --db <chaindata>
|
||||||
|
// wallet bind --wallet <file> [--pass <phrase>] --node-key <node-key.json>
|
||||||
|
// Build a BIND_WALLET transaction to link a node to this wallet.
|
||||||
|
// Print the tx JSON (broadcast separately).
|
||||||
|
// wallet address --pub-key <hex> Derive DC address from any pub key
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"go-blockchain/blockchain"
|
||||||
|
"go-blockchain/economy"
|
||||||
|
"go-blockchain/identity"
|
||||||
|
"go-blockchain/wallet"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
if len(os.Args) < 2 {
|
||||||
|
usage()
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
switch os.Args[1] {
|
||||||
|
case "create":
|
||||||
|
cmdCreate(os.Args[2:])
|
||||||
|
case "info":
|
||||||
|
cmdInfo(os.Args[2:])
|
||||||
|
case "balance":
|
||||||
|
cmdBalance(os.Args[2:])
|
||||||
|
case "bind":
|
||||||
|
cmdBind(os.Args[2:])
|
||||||
|
case "address":
|
||||||
|
cmdAddress(os.Args[2:])
|
||||||
|
default:
|
||||||
|
usage()
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func usage() {
|
||||||
|
fmt.Print(`wallet — manage DC wallets
|
||||||
|
|
||||||
|
Commands:
|
||||||
|
create --type node|user --label <name> --out <file.json> [--pass <phrase>]
|
||||||
|
info --wallet <file> [--pass <phrase>]
|
||||||
|
balance --wallet <file> [--pass <phrase>] --db <chaindata>
|
||||||
|
bind --wallet <file> [--pass <phrase>] --node-key <node.json>
|
||||||
|
address --pub-key <hex>
|
||||||
|
`)
|
||||||
|
}
|
||||||
|
|
||||||
|
func cmdCreate(args []string) {
|
||||||
|
fs := flag.NewFlagSet("create", flag.ExitOnError)
|
||||||
|
wtype := fs.String("type", "user", "wallet type: node or user")
|
||||||
|
label := fs.String("label", "My Wallet", "wallet label")
|
||||||
|
out := fs.String("out", "wallet.json", "output file")
|
||||||
|
pass := fs.String("pass", "", "encryption passphrase (empty = no encryption)")
|
||||||
|
if err := fs.Parse(args); err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
wt := wallet.UserWallet
|
||||||
|
if *wtype == "node" {
|
||||||
|
wt = wallet.NodeWallet
|
||||||
|
}
|
||||||
|
|
||||||
|
w, err := wallet.New(wt, *label)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("create wallet: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := w.Save(*out, *pass); err != nil {
|
||||||
|
log.Fatalf("save wallet: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Wallet created:\n")
|
||||||
|
fmt.Printf(" type: %s\n", w.Type)
|
||||||
|
fmt.Printf(" label: %s\n", w.Label)
|
||||||
|
fmt.Printf(" address: %s\n", w.Address)
|
||||||
|
fmt.Printf(" pub_key: %s\n", w.ID.PubKeyHex())
|
||||||
|
fmt.Printf(" saved: %s\n", *out)
|
||||||
|
if *pass == "" {
|
||||||
|
fmt.Println(" warning: no passphrase set — private key is unencrypted!")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func cmdInfo(args []string) {
|
||||||
|
fs := flag.NewFlagSet("info", flag.ExitOnError)
|
||||||
|
file := fs.String("wallet", "wallet.json", "wallet file")
|
||||||
|
pass := fs.String("pass", "", "passphrase")
|
||||||
|
if err := fs.Parse(args); err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
w, err := wallet.Load(*file, *pass)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("load wallet: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
data, _ := json.MarshalIndent(w.Info(), "", " ")
|
||||||
|
fmt.Println(string(data))
|
||||||
|
}
|
||||||
|
|
||||||
|
func cmdBalance(args []string) {
|
||||||
|
fs := flag.NewFlagSet("balance", flag.ExitOnError)
|
||||||
|
file := fs.String("wallet", "wallet.json", "wallet file")
|
||||||
|
pass := fs.String("pass", "", "passphrase")
|
||||||
|
dbPath := fs.String("db", "./chaindata", "chain DB path")
|
||||||
|
if err := fs.Parse(args); err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
w, err := wallet.Load(*file, *pass)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("load wallet: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
chain, err := blockchain.NewChain(*dbPath)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("open chain: %v", err)
|
||||||
|
}
|
||||||
|
defer chain.Close()
|
||||||
|
|
||||||
|
bal, err := chain.Balance(w.ID.PubKeyHex())
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("query balance: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
rep, err := chain.Reputation(w.ID.PubKeyHex())
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("reputation unavailable: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
binding, _ := chain.WalletBinding(w.ID.PubKeyHex())
|
||||||
|
|
||||||
|
fmt.Printf("Wallet: %s\n", w.Short())
|
||||||
|
fmt.Printf(" Address: %s\n", w.Address)
|
||||||
|
fmt.Printf(" Pub key: %s\n", w.ID.PubKeyHex())
|
||||||
|
fmt.Printf(" Balance: %s (%d µT)\n", economy.FormatTokens(bal), bal)
|
||||||
|
fmt.Printf(" Reputation: score=%d rank=%s (blocks=%d relay=%d slashes=%d)\n",
|
||||||
|
rep.Score, rep.Rank(), rep.BlocksProduced, rep.RelayProofs, rep.SlashCount)
|
||||||
|
if binding != "" {
|
||||||
|
fmt.Printf(" Wallet binding: → %s\n", wallet.PubKeyToAddress(binding))
|
||||||
|
} else if w.Type == wallet.NodeWallet {
|
||||||
|
fmt.Printf(" Wallet binding: (none — rewards go to node key itself)\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func cmdBind(args []string) {
|
||||||
|
fs := flag.NewFlagSet("bind", flag.ExitOnError)
|
||||||
|
file := fs.String("wallet", "wallet.json", "payout wallet file")
|
||||||
|
pass := fs.String("pass", "", "wallet passphrase")
|
||||||
|
nodeKeyFile := fs.String("node-key", "node.json", "node identity JSON file")
|
||||||
|
if err := fs.Parse(args); err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load payout wallet (where rewards should go)
|
||||||
|
w, err := wallet.Load(*file, *pass)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("load wallet: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load node identity (the one that signs blocks)
|
||||||
|
type rawKey struct {
|
||||||
|
PubKey string `json:"pub_key"`
|
||||||
|
PrivKey string `json:"priv_key"`
|
||||||
|
}
|
||||||
|
raw, err := os.ReadFile(*nodeKeyFile)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("read node key: %v", err)
|
||||||
|
}
|
||||||
|
var rk rawKey
|
||||||
|
if err := json.Unmarshal(raw, &rk); err != nil {
|
||||||
|
log.Fatalf("parse node key: %v", err)
|
||||||
|
}
|
||||||
|
nodeID, err := identity.FromHex(rk.PubKey, rk.PrivKey)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("load node identity: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build BIND_WALLET transaction signed by the node key
|
||||||
|
payload := blockchain.BindWalletPayload{
|
||||||
|
WalletPubKey: w.ID.PubKeyHex(),
|
||||||
|
WalletAddr: w.Address,
|
||||||
|
}
|
||||||
|
payloadBytes, _ := json.Marshal(payload)
|
||||||
|
|
||||||
|
tx := &blockchain.Transaction{
|
||||||
|
ID: fmt.Sprintf("bind-%d", time.Now().UnixNano()),
|
||||||
|
Type: blockchain.EventBindWallet,
|
||||||
|
From: nodeID.PubKeyHex(),
|
||||||
|
To: w.ID.PubKeyHex(),
|
||||||
|
Payload: payloadBytes,
|
||||||
|
Fee: blockchain.MinFee,
|
||||||
|
Timestamp: time.Now().UTC(),
|
||||||
|
}
|
||||||
|
// Sign with the node key (node authorises the binding)
|
||||||
|
signBytes, _ := json.Marshal(struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Type blockchain.EventType `json:"type"`
|
||||||
|
From string `json:"from"`
|
||||||
|
To string `json:"to"`
|
||||||
|
Amount uint64 `json:"amount"`
|
||||||
|
Fee uint64 `json:"fee"`
|
||||||
|
Payload []byte `json:"payload"`
|
||||||
|
Timestamp time.Time `json:"timestamp"`
|
||||||
|
}{tx.ID, tx.Type, tx.From, tx.To, tx.Amount, tx.Fee, tx.Payload, tx.Timestamp})
|
||||||
|
tx.Signature = nodeID.Sign(signBytes)
|
||||||
|
|
||||||
|
data, _ := json.MarshalIndent(tx, "", " ")
|
||||||
|
fmt.Printf("BIND_WALLET transaction (broadcast to a node to commit):\n\n%s\n\n", string(data))
|
||||||
|
fmt.Printf("Effect: node %s...%s will pay rewards to wallet %s\n",
|
||||||
|
nodeID.PubKeyHex()[:8], nodeID.PubKeyHex()[len(nodeID.PubKeyHex())-4:],
|
||||||
|
w.Address)
|
||||||
|
}
|
||||||
|
|
||||||
|
func cmdAddress(args []string) {
|
||||||
|
fs := flag.NewFlagSet("address", flag.ExitOnError)
|
||||||
|
pubKey := fs.String("pub-key", "", "hex-encoded Ed25519 public key")
|
||||||
|
if err := fs.Parse(args); err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if *pubKey == "" {
|
||||||
|
log.Fatal("--pub-key is required")
|
||||||
|
}
|
||||||
|
addr := wallet.PubKeyToAddress(*pubKey)
|
||||||
|
fmt.Printf("pub_key: %s\naddress: %s\n", *pubKey, addr)
|
||||||
|
}
|
||||||
883
consensus/pbft.go
Normal file
883
consensus/pbft.go
Normal file
@@ -0,0 +1,883 @@
|
|||||||
|
// Package consensus implements PBFT (Practical Byzantine Fault Tolerance)
|
||||||
|
// in the Tendermint style: Pre-prepare → Prepare → Commit.
|
||||||
|
//
|
||||||
|
// Safety: block committed only after 2f+1 COMMIT votes (f = max faulty nodes)
|
||||||
|
// Liveness: view-change if no commit within blockTimeout
|
||||||
|
package consensus
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"go-blockchain/blockchain"
|
||||||
|
"go-blockchain/identity"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
phaseNone = 0
|
||||||
|
phasePrepare = 1 // received PRE-PREPARE, broadcast PREPARE
|
||||||
|
phaseCommit = 2 // have 2f+1 PREPARE, broadcast COMMIT
|
||||||
|
|
||||||
|
blockTimeout = 2 * time.Second
|
||||||
|
)
|
||||||
|
|
||||||
|
// CommitCallback is called when a block reaches 2f+1 COMMIT votes.
|
||||||
|
type CommitCallback func(block *blockchain.Block)
|
||||||
|
|
||||||
|
// Engine is a single-node PBFT consensus engine.
|
||||||
|
type Engine struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
|
||||||
|
id *identity.Identity
|
||||||
|
validators []string // sorted hex pub keys of all validators
|
||||||
|
|
||||||
|
view uint64 // current PBFT view (increments on view-change)
|
||||||
|
seqNum uint64 // index of the next block we expect to propose/commit
|
||||||
|
|
||||||
|
// in-flight round state
|
||||||
|
phase int
|
||||||
|
proposal *blockchain.Block
|
||||||
|
|
||||||
|
prepareVotes map[string]bool
|
||||||
|
commitVotes map[string]bool
|
||||||
|
|
||||||
|
onCommit CommitCallback
|
||||||
|
|
||||||
|
// send broadcasts a ConsensusMsg to all peers (via P2P layer).
|
||||||
|
send func(msg *blockchain.ConsensusMsg)
|
||||||
|
|
||||||
|
// liveness tracks the last seqNum we saw a commit vote from each
|
||||||
|
// validator. Used by LivenessReport to surface stale peers in /metrics
|
||||||
|
// and logs. In-memory only — restarting the node resets counters,
|
||||||
|
// which is fine because auto-removal requires multiple nodes to
|
||||||
|
// independently agree anyway.
|
||||||
|
livenessMu sync.RWMutex
|
||||||
|
liveness map[string]uint64 // pubkey → last seqNum
|
||||||
|
|
||||||
|
// seenVotes records the first PREPARE/COMMIT we saw from each validator
|
||||||
|
// for each (type, view, seqNum). If a second message arrives with a
|
||||||
|
// different BlockHash, it's equivocation evidence — we stash both so
|
||||||
|
// an operator (or future auto-slasher) can submit a SLASH tx.
|
||||||
|
// Bounded implicitly: entries are pruned as we advance past the seqNum.
|
||||||
|
evidenceMu sync.Mutex
|
||||||
|
seenVotes map[voteKey]*blockchain.ConsensusMsg
|
||||||
|
pendingEvidence []blockchain.EquivocationEvidence
|
||||||
|
|
||||||
|
// Mempool is fair-queued per sender: each address has its own FIFO
|
||||||
|
// queue and Propose drains them round-robin. Without this, one
|
||||||
|
// spammer's txs go in first and can starve everyone else for the
|
||||||
|
// duration of the flood.
|
||||||
|
//
|
||||||
|
// senderQueues[from] — per-address FIFO of uncommitted txs
|
||||||
|
// senderOrder — iteration order for round-robin draining
|
||||||
|
// seenIDs — O(1) dedup set across all queues
|
||||||
|
pendingMu sync.Mutex
|
||||||
|
senderQueues map[string][]*blockchain.Transaction
|
||||||
|
senderOrder []string
|
||||||
|
seenIDs map[string]struct{}
|
||||||
|
|
||||||
|
timer *time.Timer
|
||||||
|
|
||||||
|
// optional stats hooks — called outside the lock
|
||||||
|
hookPropose func()
|
||||||
|
hookVote func()
|
||||||
|
hookViewChange func()
|
||||||
|
}
|
||||||
|
|
||||||
|
// OnPropose registers a hook called each time this node proposes a block.
|
||||||
|
func (e *Engine) OnPropose(fn func()) { e.hookPropose = fn }
|
||||||
|
|
||||||
|
// OnVote registers a hook called each time this node casts a PREPARE or COMMIT vote.
|
||||||
|
func (e *Engine) OnVote(fn func()) { e.hookVote = fn }
|
||||||
|
|
||||||
|
// OnViewChange registers a hook called each time a view-change is triggered.
|
||||||
|
func (e *Engine) OnViewChange(fn func()) { e.hookViewChange = fn }
|
||||||
|
|
||||||
|
// NewEngine creates a PBFT engine.
|
||||||
|
// - validators: complete validator set (including this node)
|
||||||
|
// - seqNum: tip.Index + 1 (or 0 if chain is empty)
|
||||||
|
// - onCommit: called when a block is finalised
|
||||||
|
// - send: broadcast function (gossipsub publish)
|
||||||
|
func NewEngine(
|
||||||
|
id *identity.Identity,
|
||||||
|
validators []string,
|
||||||
|
seqNum uint64,
|
||||||
|
onCommit CommitCallback,
|
||||||
|
send func(*blockchain.ConsensusMsg),
|
||||||
|
) *Engine {
|
||||||
|
return &Engine{
|
||||||
|
id: id,
|
||||||
|
validators: validators,
|
||||||
|
seqNum: seqNum,
|
||||||
|
onCommit: onCommit,
|
||||||
|
send: send,
|
||||||
|
senderQueues: make(map[string][]*blockchain.Transaction),
|
||||||
|
seenIDs: make(map[string]struct{}),
|
||||||
|
liveness: make(map[string]uint64),
|
||||||
|
seenVotes: make(map[voteKey]*blockchain.ConsensusMsg),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// recordVote stores the vote and checks whether it conflicts with a prior
|
||||||
|
// vote from the same validator at the same consensus position. If so, it
|
||||||
|
// pushes the pair onto pendingEvidence for later retrieval via
|
||||||
|
// TakeEvidence.
|
||||||
|
//
|
||||||
|
// Only PREPARE and COMMIT are checked. PRE-PREPARE equivocation can happen
|
||||||
|
// legitimately during view changes, so we don't flag it.
|
||||||
|
func (e *Engine) recordVote(msg *blockchain.ConsensusMsg) {
|
||||||
|
if msg.Type != blockchain.MsgPrepare && msg.Type != blockchain.MsgCommit {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
k := voteKey{from: msg.From, typ: msg.Type, view: msg.View, seqNum: msg.SeqNum}
|
||||||
|
|
||||||
|
e.evidenceMu.Lock()
|
||||||
|
defer e.evidenceMu.Unlock()
|
||||||
|
prev, seen := e.seenVotes[k]
|
||||||
|
if !seen {
|
||||||
|
// First message at this position from this validator — record.
|
||||||
|
msgCopy := *msg
|
||||||
|
e.seenVotes[k] = &msgCopy
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if bytesEqualBlockHash(prev.BlockHash, msg.BlockHash) {
|
||||||
|
return // same vote, not equivocation
|
||||||
|
}
|
||||||
|
// Equivocation detected.
|
||||||
|
log.Printf("[PBFT] EQUIVOCATION: %s signed two %v at view=%d seq=%d (blocks %x vs %x)",
|
||||||
|
shortKey(msg.From), msg.Type, msg.View, msg.SeqNum,
|
||||||
|
prev.BlockHash[:min(4, len(prev.BlockHash))],
|
||||||
|
msg.BlockHash[:min(4, len(msg.BlockHash))])
|
||||||
|
msgCopy := *msg
|
||||||
|
e.pendingEvidence = append(e.pendingEvidence, blockchain.EquivocationEvidence{
|
||||||
|
A: prev,
|
||||||
|
B: &msgCopy,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TakeEvidence drains the collected equivocation evidence. Caller is
|
||||||
|
// responsible for deciding what to do with it (typically: wrap each into
|
||||||
|
// a SLASH tx and submit). Safe to call concurrently.
|
||||||
|
func (e *Engine) TakeEvidence() []blockchain.EquivocationEvidence {
|
||||||
|
e.evidenceMu.Lock()
|
||||||
|
defer e.evidenceMu.Unlock()
|
||||||
|
if len(e.pendingEvidence) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := e.pendingEvidence
|
||||||
|
e.pendingEvidence = nil
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// pruneOldVotes clears seenVotes entries below the given seqNum floor so
|
||||||
|
// memory doesn't grow unboundedly. Called after each commit.
|
||||||
|
func (e *Engine) pruneOldVotes(belowSeq uint64) {
|
||||||
|
e.evidenceMu.Lock()
|
||||||
|
defer e.evidenceMu.Unlock()
|
||||||
|
for k := range e.seenVotes {
|
||||||
|
if k.seqNum < belowSeq {
|
||||||
|
delete(e.seenVotes, k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func bytesEqualBlockHash(a, b []byte) bool {
|
||||||
|
if len(a) != len(b) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for i := range a {
|
||||||
|
if a[i] != b[i] {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func min(a, b int) int {
|
||||||
|
if a < b {
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
// MissedBlocks returns how many seqNums have passed since the given validator
|
||||||
|
// last contributed a commit vote. A missing entry is treated as "never seen",
|
||||||
|
// in which case we return the current seqNum — caller can decide what to do.
|
||||||
|
//
|
||||||
|
// Thread-safe; may be polled from a metrics/reporting goroutine.
|
||||||
|
func (e *Engine) MissedBlocks(pubKey string) uint64 {
|
||||||
|
e.livenessMu.RLock()
|
||||||
|
lastSeen, ok := e.liveness[pubKey]
|
||||||
|
e.livenessMu.RUnlock()
|
||||||
|
|
||||||
|
e.mu.Lock()
|
||||||
|
cur := e.seqNum
|
||||||
|
e.mu.Unlock()
|
||||||
|
|
||||||
|
if !ok {
|
||||||
|
return cur
|
||||||
|
}
|
||||||
|
if cur <= lastSeen {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return cur - lastSeen
|
||||||
|
}
|
||||||
|
|
||||||
|
// LivenessReport returns a snapshot of (validator, missedBlocks) for the
|
||||||
|
// full current set. Intended for the /metrics endpoint and ops dashboards.
|
||||||
|
func (e *Engine) LivenessReport() map[string]uint64 {
|
||||||
|
e.mu.Lock()
|
||||||
|
vals := make([]string, len(e.validators))
|
||||||
|
copy(vals, e.validators)
|
||||||
|
e.mu.Unlock()
|
||||||
|
|
||||||
|
out := make(map[string]uint64, len(vals))
|
||||||
|
for _, v := range vals {
|
||||||
|
out[v] = e.MissedBlocks(v)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// noteLiveness records that `pubKey` contributed a commit at `seq`.
|
||||||
|
// Called from handleCommit whenever we see a matching vote.
|
||||||
|
func (e *Engine) noteLiveness(pubKey string, seq uint64) {
|
||||||
|
e.livenessMu.Lock()
|
||||||
|
if seq > e.liveness[pubKey] {
|
||||||
|
e.liveness[pubKey] = seq
|
||||||
|
}
|
||||||
|
e.livenessMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// voteKey uniquely identifies a (validator, phase, round) tuple. Two
|
||||||
|
// messages sharing a voteKey but with different BlockHash are equivocation.
|
||||||
|
type voteKey struct {
|
||||||
|
from string
|
||||||
|
typ blockchain.MsgType
|
||||||
|
view uint64
|
||||||
|
seqNum uint64
|
||||||
|
}
|
||||||
|
|
||||||
|
// MaxTxsPerBlock caps how many transactions one proposal pulls from the
|
||||||
|
// mempool. Keeps block commit time bounded regardless of pending backlog.
|
||||||
|
// Combined with the round-robin drain, this also caps how many txs a
|
||||||
|
// single sender can get into one block to `ceil(MaxTxsPerBlock / senders)`.
|
||||||
|
const MaxTxsPerBlock = 200
|
||||||
|
|
||||||
|
// UpdateValidators hot-reloads the validator set. Safe to call concurrently.
|
||||||
|
// The new set takes effect on the next round (does not affect the current in-flight round).
|
||||||
|
func (e *Engine) UpdateValidators(validators []string) {
|
||||||
|
e.mu.Lock()
|
||||||
|
defer e.mu.Unlock()
|
||||||
|
e.validators = validators
|
||||||
|
log.Printf("[PBFT] validator set updated: %d validators", len(validators))
|
||||||
|
}
|
||||||
|
|
||||||
|
// SyncSeqNum updates the engine's expected next block index after a chain sync.
|
||||||
|
func (e *Engine) SyncSeqNum(next uint64) {
|
||||||
|
e.mu.Lock()
|
||||||
|
defer e.mu.Unlock()
|
||||||
|
if next > e.seqNum {
|
||||||
|
e.seqNum = next
|
||||||
|
e.phase = phaseNone
|
||||||
|
e.reclaimProposal()
|
||||||
|
e.proposal = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddTransaction validates and adds a tx to the pending mempool.
|
||||||
|
// Returns an error if the tx is invalid or a duplicate; the tx is silently
|
||||||
|
// dropped in both cases so callers can safely ignore the return value when
|
||||||
|
// forwarding gossip from untrusted peers.
|
||||||
|
func (e *Engine) AddTransaction(tx *blockchain.Transaction) error {
|
||||||
|
if err := validateTx(tx); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
e.pendingMu.Lock()
|
||||||
|
defer e.pendingMu.Unlock()
|
||||||
|
|
||||||
|
// O(1) dedup across all per-sender queues.
|
||||||
|
if _, seen := e.seenIDs[tx.ID]; seen {
|
||||||
|
return fmt.Errorf("duplicate tx: %s", tx.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Route by sender. First tx from a new address extends the round-robin
|
||||||
|
// iteration order so later senders don't starve earlier ones.
|
||||||
|
if _, ok := e.senderQueues[tx.From]; !ok {
|
||||||
|
e.senderOrder = append(e.senderOrder, tx.From)
|
||||||
|
}
|
||||||
|
e.senderQueues[tx.From] = append(e.senderQueues[tx.From], tx)
|
||||||
|
e.seenIDs[tx.ID] = struct{}{}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// validateTx performs stateless transaction validation:
|
||||||
|
// - required fields present
|
||||||
|
// - fee at or above MinFee
|
||||||
|
// - Ed25519 signature valid over canonical bytes
|
||||||
|
func validateTx(tx *blockchain.Transaction) error {
|
||||||
|
if tx == nil {
|
||||||
|
return fmt.Errorf("nil transaction")
|
||||||
|
}
|
||||||
|
if tx.ID == "" || tx.From == "" || tx.Type == "" {
|
||||||
|
return fmt.Errorf("tx missing required fields (id/from/type)")
|
||||||
|
}
|
||||||
|
if tx.Fee < blockchain.MinFee {
|
||||||
|
return fmt.Errorf("tx fee %d < MinFee %d", tx.Fee, blockchain.MinFee)
|
||||||
|
}
|
||||||
|
// Delegate signature verification to identity.VerifyTx so that the
|
||||||
|
// canonical signing bytes are defined in exactly one place.
|
||||||
|
if err := identity.VerifyTx(tx); err != nil {
|
||||||
|
return fmt.Errorf("tx signature invalid: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// requeueHead puts txs back at the FRONT of their sender's FIFO. Used when
|
||||||
|
// Propose aborted after draining (chain tip advanced under us) so the txs
|
||||||
|
// don't get moved to the back of the line through no fault of the sender.
|
||||||
|
// Preserves per-sender ordering within the given slice.
|
||||||
|
func (e *Engine) requeueHead(txs []*blockchain.Transaction) {
|
||||||
|
if len(txs) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Group by sender to preserve per-sender order.
|
||||||
|
bySender := make(map[string][]*blockchain.Transaction)
|
||||||
|
order := []string{}
|
||||||
|
for _, tx := range txs {
|
||||||
|
if _, ok := bySender[tx.From]; !ok {
|
||||||
|
order = append(order, tx.From)
|
||||||
|
}
|
||||||
|
bySender[tx.From] = append(bySender[tx.From], tx)
|
||||||
|
}
|
||||||
|
e.pendingMu.Lock()
|
||||||
|
defer e.pendingMu.Unlock()
|
||||||
|
for _, sender := range order {
|
||||||
|
if _, known := e.senderQueues[sender]; !known {
|
||||||
|
e.senderOrder = append(e.senderOrder, sender)
|
||||||
|
}
|
||||||
|
// Prepend: new slice = group + existing.
|
||||||
|
e.senderQueues[sender] = append(bySender[sender], e.senderQueues[sender]...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// requeueTail puts txs at the BACK of their sender's FIFO, skipping any
|
||||||
|
// that are already seen. Used on view-change rescue — the tx has been in
|
||||||
|
// flight for a while and fairness dictates it shouldn't jump ahead of txs
|
||||||
|
// that arrived since. Returns the count actually requeued.
|
||||||
|
func (e *Engine) requeueTail(txs []*blockchain.Transaction) int {
|
||||||
|
e.pendingMu.Lock()
|
||||||
|
defer e.pendingMu.Unlock()
|
||||||
|
rescued := 0
|
||||||
|
for _, tx := range txs {
|
||||||
|
if _, seen := e.seenIDs[tx.ID]; seen {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, ok := e.senderQueues[tx.From]; !ok {
|
||||||
|
e.senderOrder = append(e.senderOrder, tx.From)
|
||||||
|
}
|
||||||
|
e.senderQueues[tx.From] = append(e.senderQueues[tx.From], tx)
|
||||||
|
e.seenIDs[tx.ID] = struct{}{}
|
||||||
|
rescued++
|
||||||
|
}
|
||||||
|
return rescued
|
||||||
|
}
|
||||||
|
|
||||||
|
// HasPendingTxs reports whether there are uncommitted transactions in the mempool.
|
||||||
|
// Used by the block-production loop to skip proposals when there is nothing to commit.
|
||||||
|
func (e *Engine) HasPendingTxs() bool {
|
||||||
|
e.pendingMu.Lock()
|
||||||
|
defer e.pendingMu.Unlock()
|
||||||
|
for _, q := range e.senderQueues {
|
||||||
|
if len(q) > 0 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// PruneTxs removes transactions that were committed in a block from the pending
|
||||||
|
// mempool. Must be called by the onCommit handler so that non-proposing validators
|
||||||
|
// don't re-propose transactions they received via gossip but didn't drain themselves.
|
||||||
|
func (e *Engine) PruneTxs(txs []*blockchain.Transaction) {
|
||||||
|
if len(txs) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
committed := make(map[string]bool, len(txs))
|
||||||
|
for _, tx := range txs {
|
||||||
|
committed[tx.ID] = true
|
||||||
|
}
|
||||||
|
e.pendingMu.Lock()
|
||||||
|
defer e.pendingMu.Unlock()
|
||||||
|
for sender, q := range e.senderQueues {
|
||||||
|
kept := q[:0]
|
||||||
|
for _, tx := range q {
|
||||||
|
if !committed[tx.ID] {
|
||||||
|
kept = append(kept, tx)
|
||||||
|
} else {
|
||||||
|
delete(e.seenIDs, tx.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(kept) == 0 {
|
||||||
|
delete(e.senderQueues, sender)
|
||||||
|
} else {
|
||||||
|
e.senderQueues[sender] = kept
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Prune senderOrder of now-empty senders so iteration stays O(senders).
|
||||||
|
if len(e.senderOrder) > 0 {
|
||||||
|
pruned := e.senderOrder[:0]
|
||||||
|
for _, s := range e.senderOrder {
|
||||||
|
if _, ok := e.senderQueues[s]; ok {
|
||||||
|
pruned = append(pruned, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
e.senderOrder = pruned
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsLeader returns true if this node is leader for the current round.
|
||||||
|
// Leadership rotates: leader = validators[(seqNum + view) % n]
|
||||||
|
func (e *Engine) IsLeader() bool {
|
||||||
|
if len(e.validators) == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
idx := int(e.seqNum+e.view) % len(e.validators)
|
||||||
|
return e.validators[idx] == e.id.PubKeyHex()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Propose builds a block and broadcasts a PRE-PREPARE message.
|
||||||
|
// Only the current leader calls this.
|
||||||
|
func (e *Engine) Propose(prevBlock *blockchain.Block) {
|
||||||
|
e.mu.Lock()
|
||||||
|
defer e.mu.Unlock()
|
||||||
|
|
||||||
|
if !e.IsLeader() || e.phase != phaseNone {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Round-robin drain: take one tx from each sender's FIFO per pass,
|
||||||
|
// up to MaxTxsPerBlock. Guarantees that a spammer's 10k-tx queue can
|
||||||
|
// not starve a legitimate user who has just one tx pending.
|
||||||
|
e.pendingMu.Lock()
|
||||||
|
txs := make([]*blockchain.Transaction, 0, MaxTxsPerBlock)
|
||||||
|
for len(txs) < MaxTxsPerBlock {
|
||||||
|
drained := 0
|
||||||
|
for _, sender := range e.senderOrder {
|
||||||
|
q := e.senderQueues[sender]
|
||||||
|
if len(q) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
txs = append(txs, q[0])
|
||||||
|
// Pop the head of this sender's queue.
|
||||||
|
q = q[1:]
|
||||||
|
if len(q) == 0 {
|
||||||
|
delete(e.senderQueues, sender)
|
||||||
|
} else {
|
||||||
|
e.senderQueues[sender] = q
|
||||||
|
}
|
||||||
|
drained++
|
||||||
|
if len(txs) >= MaxTxsPerBlock {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if drained == 0 {
|
||||||
|
break // no queues had any tx this pass → done
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Rebuild senderOrder keeping only senders who still have pending txs,
|
||||||
|
// so iteration cost stays O(active senders) on next round.
|
||||||
|
if len(e.senderOrder) > 0 {
|
||||||
|
keep := e.senderOrder[:0]
|
||||||
|
for _, s := range e.senderOrder {
|
||||||
|
if _, ok := e.senderQueues[s]; ok {
|
||||||
|
keep = append(keep, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
e.senderOrder = keep
|
||||||
|
}
|
||||||
|
// seenIDs is left intact — those txs are now in-flight; PruneTxs in
|
||||||
|
// the commit callback will clear them once accepted.
|
||||||
|
e.pendingMu.Unlock()
|
||||||
|
|
||||||
|
var prevHash []byte
|
||||||
|
var idx uint64
|
||||||
|
if prevBlock != nil {
|
||||||
|
prevHash = prevBlock.Hash
|
||||||
|
idx = prevBlock.Index + 1
|
||||||
|
}
|
||||||
|
if idx != e.seqNum {
|
||||||
|
// Chain tip doesn't match our expected seqNum — return txs to mempool and wait for sync.
|
||||||
|
// requeueHead puts them back at the front of each sender's FIFO.
|
||||||
|
e.requeueHead(txs)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var totalFees uint64
|
||||||
|
for _, tx := range txs {
|
||||||
|
totalFees += tx.Fee
|
||||||
|
}
|
||||||
|
|
||||||
|
b := &blockchain.Block{
|
||||||
|
Index: idx,
|
||||||
|
Timestamp: time.Now().UTC(),
|
||||||
|
Transactions: txs,
|
||||||
|
PrevHash: prevHash,
|
||||||
|
Validator: e.id.PubKeyHex(),
|
||||||
|
TotalFees: totalFees,
|
||||||
|
}
|
||||||
|
b.ComputeHash()
|
||||||
|
b.Sign(e.id.PrivKey)
|
||||||
|
|
||||||
|
e.proposal = b
|
||||||
|
e.prepareVotes = make(map[string]bool)
|
||||||
|
e.commitVotes = make(map[string]bool)
|
||||||
|
|
||||||
|
// Broadcast PRE-PREPARE
|
||||||
|
e.send(e.signMsg(&blockchain.ConsensusMsg{
|
||||||
|
Type: blockchain.MsgPrePrepare,
|
||||||
|
View: e.view,
|
||||||
|
SeqNum: b.Index,
|
||||||
|
BlockHash: b.Hash,
|
||||||
|
Block: b,
|
||||||
|
}))
|
||||||
|
|
||||||
|
log.Printf("[PBFT] leader %s proposed block #%d hash=%s",
|
||||||
|
shortKey(e.id.PubKeyHex()), b.Index, b.HashHex()[:8])
|
||||||
|
|
||||||
|
if e.hookPropose != nil {
|
||||||
|
go e.hookPropose()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Leader casts its own PREPARE vote immediately
|
||||||
|
e.castPrepare()
|
||||||
|
e.resetTimer()
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleMessage processes an incoming ConsensusMsg from a peer.
|
||||||
|
func (e *Engine) HandleMessage(msg *blockchain.ConsensusMsg) {
|
||||||
|
if err := e.verifyMsgSig(msg); err != nil {
|
||||||
|
log.Printf("[PBFT] bad sig from %s: %v", shortKey(msg.From), err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !e.isKnownValidator(msg.From) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
e.mu.Lock()
|
||||||
|
defer e.mu.Unlock()
|
||||||
|
|
||||||
|
switch msg.Type {
|
||||||
|
case blockchain.MsgPrePrepare:
|
||||||
|
e.handlePrePrepare(msg)
|
||||||
|
case blockchain.MsgPrepare:
|
||||||
|
e.handlePrepare(msg)
|
||||||
|
case blockchain.MsgCommit:
|
||||||
|
e.handleCommit(msg)
|
||||||
|
case blockchain.MsgViewChange:
|
||||||
|
e.handleViewChange(msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- phase handlers ---
|
||||||
|
|
||||||
|
func (e *Engine) handlePrePrepare(msg *blockchain.ConsensusMsg) {
|
||||||
|
if msg.View != e.view || msg.SeqNum != e.seqNum {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if e.phase != phaseNone || msg.Block == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify that block hash matches its canonical content
|
||||||
|
msg.Block.ComputeHash()
|
||||||
|
if !hashEqual(msg.Block.Hash, msg.BlockHash) {
|
||||||
|
log.Printf("[PBFT] PRE-PREPARE: block hash mismatch")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
e.proposal = msg.Block
|
||||||
|
e.prepareVotes = make(map[string]bool)
|
||||||
|
e.commitVotes = make(map[string]bool)
|
||||||
|
|
||||||
|
log.Printf("[PBFT] %s accepted PRE-PREPARE for block #%d",
|
||||||
|
shortKey(e.id.PubKeyHex()), msg.SeqNum)
|
||||||
|
|
||||||
|
e.castPrepare()
|
||||||
|
e.resetTimer()
|
||||||
|
}
|
||||||
|
|
||||||
|
// castPrepare adds own PREPARE vote and broadcasts; advances phase if quorum.
|
||||||
|
// Must be called with e.mu held.
|
||||||
|
func (e *Engine) castPrepare() {
|
||||||
|
e.phase = phasePrepare
|
||||||
|
e.prepareVotes[e.id.PubKeyHex()] = true
|
||||||
|
|
||||||
|
if e.hookVote != nil {
|
||||||
|
go e.hookVote()
|
||||||
|
}
|
||||||
|
|
||||||
|
e.send(e.signMsg(&blockchain.ConsensusMsg{
|
||||||
|
Type: blockchain.MsgPrepare,
|
||||||
|
View: e.view,
|
||||||
|
SeqNum: e.proposal.Index,
|
||||||
|
BlockHash: e.proposal.Hash,
|
||||||
|
}))
|
||||||
|
|
||||||
|
if e.quorum(len(e.prepareVotes)) {
|
||||||
|
e.advanceToCommit()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Engine) handlePrepare(msg *blockchain.ConsensusMsg) {
|
||||||
|
// Equivocation check runs BEFORE the view/proposal filter — we want to
|
||||||
|
// catch votes for a different block even if we've already moved on.
|
||||||
|
e.recordVote(msg)
|
||||||
|
if msg.View != e.view || e.proposal == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !hashEqual(msg.BlockHash, e.proposal.Hash) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
e.prepareVotes[msg.From] = true
|
||||||
|
|
||||||
|
if e.phase == phasePrepare && e.quorum(len(e.prepareVotes)) {
|
||||||
|
e.advanceToCommit()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// advanceToCommit transitions to COMMIT phase and casts own COMMIT vote.
|
||||||
|
// Must be called with e.mu held.
|
||||||
|
func (e *Engine) advanceToCommit() {
|
||||||
|
e.phase = phaseCommit
|
||||||
|
e.commitVotes[e.id.PubKeyHex()] = true
|
||||||
|
// Self-liveness: we're about to broadcast COMMIT, so count ourselves
|
||||||
|
// as participating in this seqNum. Without this our own pubkey would
|
||||||
|
// always show "missed blocks = current seqNum" in LivenessReport.
|
||||||
|
e.noteLiveness(e.id.PubKeyHex(), e.seqNum)
|
||||||
|
|
||||||
|
if e.hookVote != nil {
|
||||||
|
go e.hookVote()
|
||||||
|
}
|
||||||
|
|
||||||
|
e.send(e.signMsg(&blockchain.ConsensusMsg{
|
||||||
|
Type: blockchain.MsgCommit,
|
||||||
|
View: e.view,
|
||||||
|
SeqNum: e.proposal.Index,
|
||||||
|
BlockHash: e.proposal.Hash,
|
||||||
|
}))
|
||||||
|
|
||||||
|
log.Printf("[PBFT] %s sent COMMIT for block #%d (prepare quorum %d/%d)",
|
||||||
|
shortKey(e.id.PubKeyHex()), e.proposal.Index,
|
||||||
|
len(e.prepareVotes), len(e.validators))
|
||||||
|
|
||||||
|
e.tryFinalize()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Engine) handleCommit(msg *blockchain.ConsensusMsg) {
|
||||||
|
e.recordVote(msg)
|
||||||
|
if msg.View != e.view || e.proposal == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !hashEqual(msg.BlockHash, e.proposal.Hash) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
e.commitVotes[msg.From] = true
|
||||||
|
// Record liveness so we know which validators are still participating.
|
||||||
|
// msg.SeqNum reflects the block being committed; use it directly.
|
||||||
|
e.noteLiveness(msg.From, msg.SeqNum)
|
||||||
|
e.tryFinalize()
|
||||||
|
}
|
||||||
|
|
||||||
|
// tryFinalize commits the block if commit quorum is reached.
|
||||||
|
// Must be called with e.mu held.
|
||||||
|
func (e *Engine) tryFinalize() {
|
||||||
|
if e.phase != phaseCommit || !e.quorum(len(e.commitVotes)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
committed := e.proposal
|
||||||
|
e.proposal = nil
|
||||||
|
e.phase = phaseNone
|
||||||
|
e.seqNum++
|
||||||
|
// Drop recorded votes for previous seqNums so the equivocation-
|
||||||
|
// detection map doesn't grow unboundedly. Keep the current seqNum
|
||||||
|
// in case a late duplicate arrives.
|
||||||
|
e.pruneOldVotes(e.seqNum)
|
||||||
|
|
||||||
|
if e.timer != nil {
|
||||||
|
e.timer.Stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("[PBFT] COMMITTED block #%d hash=%s validator=%s fees=%d µT (commit votes %d/%d)",
|
||||||
|
committed.Index, committed.HashHex()[:8],
|
||||||
|
shortKey(committed.Validator),
|
||||||
|
committed.TotalFees,
|
||||||
|
len(e.commitVotes), len(e.validators))
|
||||||
|
|
||||||
|
go e.onCommit(committed) // call outside lock
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Engine) handleViewChange(msg *blockchain.ConsensusMsg) {
|
||||||
|
if msg.View > e.view {
|
||||||
|
e.view = msg.View
|
||||||
|
e.phase = phaseNone
|
||||||
|
e.reclaimProposal()
|
||||||
|
e.proposal = nil
|
||||||
|
log.Printf("[PBFT] view-change to view %d (new leader: %s)",
|
||||||
|
e.view, shortKey(e.currentLeader()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// reclaimProposal moves transactions from the in-flight proposal back into the
|
||||||
|
// pending mempool so they are not permanently lost on a view-change or timeout.
|
||||||
|
// Must be called with e.mu held; acquires pendingMu internally.
|
||||||
|
func (e *Engine) reclaimProposal() {
|
||||||
|
if e.proposal == nil || len(e.proposal.Transactions) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Re-enqueue via the helper so per-sender FIFO + dedup invariants hold.
|
||||||
|
// Any tx already in-flight (still in seenIDs) is skipped; the rest land
|
||||||
|
// at the TAIL of the sender's queue — they're "older" than whatever the
|
||||||
|
// user sent since; it's a loss of order, but the alternative (HEAD
|
||||||
|
// insert) would starve later arrivals.
|
||||||
|
rescued := e.requeueTail(e.proposal.Transactions)
|
||||||
|
if rescued > 0 {
|
||||||
|
log.Printf("[PBFT] reclaimed %d tx(s) from abandoned proposal #%d back to mempool",
|
||||||
|
rescued, e.proposal.Index)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- helpers ---
|
||||||
|
|
||||||
|
func (e *Engine) quorum(count int) bool {
|
||||||
|
n := len(e.validators)
|
||||||
|
if n == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
needed := (2*n + 2) / 3 // ⌈2n/3⌉
|
||||||
|
return count >= needed
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Engine) currentLeader() string {
|
||||||
|
if len(e.validators) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return e.validators[int(e.seqNum+e.view)%len(e.validators)]
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Engine) isKnownValidator(pubKeyHex string) bool {
|
||||||
|
for _, v := range e.validators {
|
||||||
|
if v == pubKeyHex {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Engine) resetTimer() {
|
||||||
|
if e.timer != nil {
|
||||||
|
e.timer.Stop()
|
||||||
|
}
|
||||||
|
e.timer = time.AfterFunc(blockTimeout, func() {
|
||||||
|
e.mu.Lock()
|
||||||
|
defer e.mu.Unlock()
|
||||||
|
if e.phase == phaseNone {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count votes from OTHER validators (not ourselves).
|
||||||
|
// If we received zero foreign votes the peer is simply not connected yet —
|
||||||
|
// advancing the view would desync us (we'd be in view N+1, the peer in view 0).
|
||||||
|
// Instead: silently reset and let the next proposal tick retry in the same view.
|
||||||
|
otherVotes := 0
|
||||||
|
ownKey := e.id.PubKeyHex()
|
||||||
|
if e.phase == phasePrepare {
|
||||||
|
for k := range e.prepareVotes {
|
||||||
|
if k != ownKey {
|
||||||
|
otherVotes++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else { // phaseCommit
|
||||||
|
for k := range e.commitVotes {
|
||||||
|
if k != ownKey {
|
||||||
|
otherVotes++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if otherVotes == 0 {
|
||||||
|
// No peer participation — peer is offline/not yet connected.
|
||||||
|
// Reset without a view-change so both sides stay in view 0
|
||||||
|
// and can agree as soon as the peer comes up.
|
||||||
|
log.Printf("[PBFT] timeout in view %d seq %d — no peer votes, retrying in same view",
|
||||||
|
e.view, e.seqNum)
|
||||||
|
e.phase = phaseNone
|
||||||
|
e.reclaimProposal()
|
||||||
|
e.proposal = nil
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Got votes from at least one peer but still timed out — real view-change.
|
||||||
|
log.Printf("[PBFT] timeout in view %d seq %d — triggering view-change",
|
||||||
|
e.view, e.seqNum)
|
||||||
|
e.view++
|
||||||
|
e.phase = phaseNone
|
||||||
|
e.reclaimProposal()
|
||||||
|
e.proposal = nil
|
||||||
|
if e.hookViewChange != nil {
|
||||||
|
go e.hookViewChange()
|
||||||
|
}
|
||||||
|
e.send(e.signMsg(&blockchain.ConsensusMsg{
|
||||||
|
Type: blockchain.MsgViewChange,
|
||||||
|
View: e.view,
|
||||||
|
SeqNum: e.seqNum,
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Engine) signMsg(msg *blockchain.ConsensusMsg) *blockchain.ConsensusMsg {
|
||||||
|
msg.From = e.id.PubKeyHex()
|
||||||
|
msg.Signature = e.id.Sign(msgSignBytes(msg))
|
||||||
|
return msg
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Engine) verifyMsgSig(msg *blockchain.ConsensusMsg) error {
|
||||||
|
sig := msg.Signature
|
||||||
|
msg.Signature = nil
|
||||||
|
raw := msgSignBytes(msg)
|
||||||
|
msg.Signature = sig
|
||||||
|
|
||||||
|
ok, err := identity.Verify(msg.From, raw, sig)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("invalid signature")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func msgSignBytes(msg *blockchain.ConsensusMsg) []byte {
|
||||||
|
tmp := *msg
|
||||||
|
tmp.Signature = nil
|
||||||
|
tmp.Block = nil // block hash covers block content
|
||||||
|
data, _ := json.Marshal(tmp)
|
||||||
|
h := sha256.Sum256(data)
|
||||||
|
return h[:]
|
||||||
|
}
|
||||||
|
|
||||||
|
func hashEqual(a, b []byte) bool {
|
||||||
|
return hex.EncodeToString(a) == hex.EncodeToString(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
func shortKey(h string) string {
|
||||||
|
if len(h) > 8 {
|
||||||
|
return h[:8]
|
||||||
|
}
|
||||||
|
return h
|
||||||
|
}
|
||||||
438
consensus/pbft_test.go
Normal file
438
consensus/pbft_test.go
Normal file
@@ -0,0 +1,438 @@
|
|||||||
|
package consensus_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"go-blockchain/blockchain"
|
||||||
|
"go-blockchain/consensus"
|
||||||
|
"go-blockchain/identity"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ─── helpers ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func newID(t *testing.T) *identity.Identity {
|
||||||
|
t.Helper()
|
||||||
|
id, err := identity.Generate()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("identity.Generate: %v", err)
|
||||||
|
}
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
func genesisFor(id *identity.Identity) *blockchain.Block {
|
||||||
|
return blockchain.GenesisBlock(id.PubKeyHex(), id.PrivKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
// network is a simple in-process message bus between engines.
|
||||||
|
// Each engine has a dedicated goroutine that delivers messages in FIFO order,
|
||||||
|
// which avoids the race where a PREPARE arrives before the PRE-PREPARE it
|
||||||
|
// depends on (which would cause the vote to be silently discarded).
|
||||||
|
type network struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
queues []chan *blockchain.ConsensusMsg
|
||||||
|
engines []*consensus.Engine
|
||||||
|
}
|
||||||
|
|
||||||
|
// addEngine registers an engine and starts its delivery goroutine.
|
||||||
|
func (n *network) addEngine(e *consensus.Engine) {
|
||||||
|
ch := make(chan *blockchain.ConsensusMsg, 256)
|
||||||
|
n.mu.Lock()
|
||||||
|
n.engines = append(n.engines, e)
|
||||||
|
n.queues = append(n.queues, ch)
|
||||||
|
n.mu.Unlock()
|
||||||
|
go func() {
|
||||||
|
for msg := range ch {
|
||||||
|
e.HandleMessage(msg)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (n *network) broadcast(msg *blockchain.ConsensusMsg) {
|
||||||
|
n.mu.Lock()
|
||||||
|
queues := make([]chan *blockchain.ConsensusMsg, len(n.queues))
|
||||||
|
copy(queues, n.queues)
|
||||||
|
n.mu.Unlock()
|
||||||
|
for _, q := range queues {
|
||||||
|
cp := *msg // copy to avoid concurrent signature-nil race in verifyMsgSig
|
||||||
|
q <- &cp
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// committedBlocks collects blocks committed by an engine into a channel.
|
||||||
|
type committedBlocks struct {
|
||||||
|
ch chan *blockchain.Block
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cb *committedBlocks) onCommit(b *blockchain.Block) {
|
||||||
|
cb.ch <- b
|
||||||
|
}
|
||||||
|
|
||||||
|
func newCommitted() *committedBlocks {
|
||||||
|
return &committedBlocks{ch: make(chan *blockchain.Block, 16)}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cb *committedBlocks) waitOne(t *testing.T, timeout time.Duration) *blockchain.Block {
|
||||||
|
t.Helper()
|
||||||
|
select {
|
||||||
|
case b := <-cb.ch:
|
||||||
|
return b
|
||||||
|
case <-time.After(timeout):
|
||||||
|
t.Fatal("timed out waiting for committed block")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── tests ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// TestSingleValidatorCommit verifies that a single-validator network commits
|
||||||
|
// blocks immediately (f=0, quorum=1).
|
||||||
|
func TestSingleValidatorCommit(t *testing.T) {
|
||||||
|
id := newID(t)
|
||||||
|
genesis := genesisFor(id)
|
||||||
|
|
||||||
|
committed := newCommitted()
|
||||||
|
net := &network{}
|
||||||
|
|
||||||
|
engine := consensus.NewEngine(
|
||||||
|
id,
|
||||||
|
[]string{id.PubKeyHex()},
|
||||||
|
1, // seqNum = genesis+1
|
||||||
|
committed.onCommit,
|
||||||
|
net.broadcast,
|
||||||
|
)
|
||||||
|
net.addEngine(engine)
|
||||||
|
|
||||||
|
// Propose block 1 from genesis.
|
||||||
|
engine.Propose(genesis)
|
||||||
|
|
||||||
|
b := committed.waitOne(t, 3*time.Second)
|
||||||
|
if b.Index != 1 {
|
||||||
|
t.Errorf("expected committed block index 1, got %d", b.Index)
|
||||||
|
}
|
||||||
|
if b.Validator != id.PubKeyHex() {
|
||||||
|
t.Errorf("wrong validator in committed block")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSingleValidatorMultipleBlocks verifies sequential block commitment.
|
||||||
|
func TestSingleValidatorMultipleBlocks(t *testing.T) {
|
||||||
|
id := newID(t)
|
||||||
|
genesis := genesisFor(id)
|
||||||
|
|
||||||
|
committed := newCommitted()
|
||||||
|
net := &network{}
|
||||||
|
|
||||||
|
engine := consensus.NewEngine(
|
||||||
|
id,
|
||||||
|
[]string{id.PubKeyHex()},
|
||||||
|
1,
|
||||||
|
committed.onCommit,
|
||||||
|
net.broadcast,
|
||||||
|
)
|
||||||
|
net.addEngine(engine)
|
||||||
|
|
||||||
|
prev := genesis
|
||||||
|
for i := uint64(1); i <= 3; i++ {
|
||||||
|
engine.Propose(prev)
|
||||||
|
b := committed.waitOne(t, 3*time.Second)
|
||||||
|
if b.Index != i {
|
||||||
|
t.Errorf("block %d: expected index %d, got %d", i, i, b.Index)
|
||||||
|
}
|
||||||
|
engine.SyncSeqNum(i + 1)
|
||||||
|
prev = b
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestThreeValidatorCommit verifies a 3-node network reaches consensus.
|
||||||
|
// Messages are delivered synchronously through the in-process network bus.
|
||||||
|
// With f=1, quorum = ⌈2*3/3⌉ = 2.
|
||||||
|
func TestThreeValidatorCommit(t *testing.T) {
|
||||||
|
ids := []*identity.Identity{newID(t), newID(t), newID(t)}
|
||||||
|
valSet := []string{ids[0].PubKeyHex(), ids[1].PubKeyHex(), ids[2].PubKeyHex()}
|
||||||
|
|
||||||
|
genesis := genesisFor(ids[0]) // block 0 signed by ids[0]
|
||||||
|
|
||||||
|
committed := [3]*committedBlocks{newCommitted(), newCommitted(), newCommitted()}
|
||||||
|
net := &network{}
|
||||||
|
|
||||||
|
for i, id := range ids {
|
||||||
|
idx := i
|
||||||
|
engine := consensus.NewEngine(
|
||||||
|
id, valSet, 1,
|
||||||
|
func(b *blockchain.Block) { committed[idx].onCommit(b) },
|
||||||
|
net.broadcast,
|
||||||
|
)
|
||||||
|
net.addEngine(engine)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Leader for seqNum=1, view=0 is valSet[(1+0)%3] = ids[1].
|
||||||
|
// Find and trigger the leader.
|
||||||
|
for i, e := range net.engines {
|
||||||
|
_ = i
|
||||||
|
e.Propose(genesis)
|
||||||
|
}
|
||||||
|
|
||||||
|
// All three should commit the same block.
|
||||||
|
timeout := 5 * time.Second
|
||||||
|
var commitIdx [3]uint64
|
||||||
|
for i := 0; i < 3; i++ {
|
||||||
|
b := committed[i].waitOne(t, timeout)
|
||||||
|
commitIdx[i] = b.Index
|
||||||
|
}
|
||||||
|
for i, idx := range commitIdx {
|
||||||
|
if idx != 1 {
|
||||||
|
t.Errorf("engine %d committed block at wrong index: got %d, want 1", i, idx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAddTransactionAndPropose verifies that pending transactions appear in committed blocks.
|
||||||
|
func TestAddTransactionAndPropose(t *testing.T) {
|
||||||
|
id := newID(t)
|
||||||
|
sender := newID(t)
|
||||||
|
genesis := genesisFor(id)
|
||||||
|
|
||||||
|
committed := newCommitted()
|
||||||
|
net := &network{}
|
||||||
|
|
||||||
|
engine := consensus.NewEngine(
|
||||||
|
id,
|
||||||
|
[]string{id.PubKeyHex()},
|
||||||
|
1,
|
||||||
|
committed.onCommit,
|
||||||
|
net.broadcast,
|
||||||
|
)
|
||||||
|
net.addEngine(engine)
|
||||||
|
|
||||||
|
// Build a valid signed transaction.
|
||||||
|
payload, _ := json.Marshal(blockchain.TransferPayload{})
|
||||||
|
tx := &blockchain.Transaction{
|
||||||
|
ID: "test-tx-1",
|
||||||
|
Type: blockchain.EventTransfer,
|
||||||
|
From: sender.PubKeyHex(),
|
||||||
|
To: id.PubKeyHex(),
|
||||||
|
Amount: 1000,
|
||||||
|
Fee: blockchain.MinFee,
|
||||||
|
Payload: payload,
|
||||||
|
Timestamp: time.Now().UTC(),
|
||||||
|
}
|
||||||
|
// Sign with canonical bytes (matching validateTx).
|
||||||
|
signData, _ := json.Marshal(struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Type blockchain.EventType `json:"type"`
|
||||||
|
From string `json:"from"`
|
||||||
|
To string `json:"to"`
|
||||||
|
Amount uint64 `json:"amount"`
|
||||||
|
Fee uint64 `json:"fee"`
|
||||||
|
Payload []byte `json:"payload"`
|
||||||
|
Timestamp time.Time `json:"timestamp"`
|
||||||
|
}{tx.ID, tx.Type, tx.From, tx.To, tx.Amount, tx.Fee, tx.Payload, tx.Timestamp})
|
||||||
|
tx.Signature = sender.Sign(signData)
|
||||||
|
|
||||||
|
if err := engine.AddTransaction(tx); err != nil {
|
||||||
|
t.Fatalf("AddTransaction: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
engine.Propose(genesis)
|
||||||
|
|
||||||
|
b := committed.waitOne(t, 3*time.Second)
|
||||||
|
if len(b.Transactions) != 1 {
|
||||||
|
t.Errorf("expected 1 transaction in committed block, got %d", len(b.Transactions))
|
||||||
|
}
|
||||||
|
if b.Transactions[0].ID != "test-tx-1" {
|
||||||
|
t.Errorf("wrong transaction in committed block: %s", b.Transactions[0].ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDuplicateTransactionRejected verifies the mempool deduplicates by TX ID.
|
||||||
|
func TestDuplicateTransactionRejected(t *testing.T) {
|
||||||
|
id := newID(t)
|
||||||
|
sender := newID(t)
|
||||||
|
net := &network{}
|
||||||
|
|
||||||
|
engine := consensus.NewEngine(
|
||||||
|
id,
|
||||||
|
[]string{id.PubKeyHex()},
|
||||||
|
1,
|
||||||
|
func(*blockchain.Block) {},
|
||||||
|
net.broadcast,
|
||||||
|
)
|
||||||
|
|
||||||
|
payload, _ := json.Marshal(blockchain.TransferPayload{})
|
||||||
|
tx := &blockchain.Transaction{
|
||||||
|
ID: "dup-tx",
|
||||||
|
Type: blockchain.EventTransfer,
|
||||||
|
From: sender.PubKeyHex(),
|
||||||
|
To: id.PubKeyHex(),
|
||||||
|
Amount: 1000,
|
||||||
|
Fee: blockchain.MinFee,
|
||||||
|
Payload: payload,
|
||||||
|
Timestamp: time.Now().UTC(),
|
||||||
|
}
|
||||||
|
signData, _ := json.Marshal(struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Type blockchain.EventType `json:"type"`
|
||||||
|
From string `json:"from"`
|
||||||
|
To string `json:"to"`
|
||||||
|
Amount uint64 `json:"amount"`
|
||||||
|
Fee uint64 `json:"fee"`
|
||||||
|
Payload []byte `json:"payload"`
|
||||||
|
Timestamp time.Time `json:"timestamp"`
|
||||||
|
}{tx.ID, tx.Type, tx.From, tx.To, tx.Amount, tx.Fee, tx.Payload, tx.Timestamp})
|
||||||
|
tx.Signature = sender.Sign(signData)
|
||||||
|
|
||||||
|
if err := engine.AddTransaction(tx); err != nil {
|
||||||
|
t.Fatalf("first AddTransaction: %v", err)
|
||||||
|
}
|
||||||
|
if err := engine.AddTransaction(tx); err == nil {
|
||||||
|
t.Fatal("expected duplicate transaction to be rejected, but it was accepted")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestInvalidTxRejected verifies that transactions with bad signatures are rejected.
|
||||||
|
func TestInvalidTxRejected(t *testing.T) {
|
||||||
|
id := newID(t)
|
||||||
|
net := &network{}
|
||||||
|
|
||||||
|
engine := consensus.NewEngine(
|
||||||
|
id,
|
||||||
|
[]string{id.PubKeyHex()},
|
||||||
|
1,
|
||||||
|
func(*blockchain.Block) {},
|
||||||
|
net.broadcast,
|
||||||
|
)
|
||||||
|
|
||||||
|
payload, _ := json.Marshal(blockchain.TransferPayload{})
|
||||||
|
tx := &blockchain.Transaction{
|
||||||
|
ID: "bad-sig-tx",
|
||||||
|
Type: blockchain.EventTransfer,
|
||||||
|
From: id.PubKeyHex(),
|
||||||
|
To: id.PubKeyHex(),
|
||||||
|
Amount: 1000,
|
||||||
|
Fee: blockchain.MinFee,
|
||||||
|
Payload: payload,
|
||||||
|
Timestamp: time.Now().UTC(),
|
||||||
|
Signature: []byte("not-a-real-signature"),
|
||||||
|
}
|
||||||
|
if err := engine.AddTransaction(tx); err == nil {
|
||||||
|
t.Fatal("expected transaction with bad signature to be rejected")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFeeBelowMinimumRejected verifies that sub-minimum fees are rejected by the engine.
|
||||||
|
func TestFeeBelowMinimumRejected(t *testing.T) {
|
||||||
|
id := newID(t)
|
||||||
|
net := &network{}
|
||||||
|
|
||||||
|
engine := consensus.NewEngine(
|
||||||
|
id,
|
||||||
|
[]string{id.PubKeyHex()},
|
||||||
|
1,
|
||||||
|
func(*blockchain.Block) {},
|
||||||
|
net.broadcast,
|
||||||
|
)
|
||||||
|
|
||||||
|
payload, _ := json.Marshal(blockchain.TransferPayload{})
|
||||||
|
tx := &blockchain.Transaction{
|
||||||
|
ID: "low-fee-tx",
|
||||||
|
Type: blockchain.EventTransfer,
|
||||||
|
From: id.PubKeyHex(),
|
||||||
|
To: id.PubKeyHex(),
|
||||||
|
Amount: 1000,
|
||||||
|
Fee: blockchain.MinFee - 1, // one µT below minimum
|
||||||
|
Payload: payload,
|
||||||
|
Timestamp: time.Now().UTC(),
|
||||||
|
}
|
||||||
|
signData, _ := json.Marshal(struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Type blockchain.EventType `json:"type"`
|
||||||
|
From string `json:"from"`
|
||||||
|
To string `json:"to"`
|
||||||
|
Amount uint64 `json:"amount"`
|
||||||
|
Fee uint64 `json:"fee"`
|
||||||
|
Payload []byte `json:"payload"`
|
||||||
|
Timestamp time.Time `json:"timestamp"`
|
||||||
|
}{tx.ID, tx.Type, tx.From, tx.To, tx.Amount, tx.Fee, tx.Payload, tx.Timestamp})
|
||||||
|
tx.Signature = id.Sign(signData)
|
||||||
|
|
||||||
|
if err := engine.AddTransaction(tx); err == nil {
|
||||||
|
t.Fatal("expected transaction with fee below MinFee to be rejected")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestUpdateValidators verifies that UpdateValidators takes effect on the next round.
|
||||||
|
// We start with a 1-validator network (quorum=1), commit one block, then shrink
|
||||||
|
// the set back to the same single validator — confirming the hot-reload path runs
|
||||||
|
// without panicking and that the engine continues to commit blocks normally.
|
||||||
|
func TestUpdateValidators(t *testing.T) {
|
||||||
|
id := newID(t)
|
||||||
|
genesis := genesisFor(id)
|
||||||
|
|
||||||
|
committed := newCommitted()
|
||||||
|
net := &network{}
|
||||||
|
|
||||||
|
engine := consensus.NewEngine(
|
||||||
|
id,
|
||||||
|
[]string{id.PubKeyHex()},
|
||||||
|
1,
|
||||||
|
committed.onCommit,
|
||||||
|
net.broadcast,
|
||||||
|
)
|
||||||
|
net.engines = []*consensus.Engine{engine}
|
||||||
|
|
||||||
|
// Block 1.
|
||||||
|
engine.Propose(genesis)
|
||||||
|
b1 := committed.waitOne(t, 3*time.Second)
|
||||||
|
engine.SyncSeqNum(2)
|
||||||
|
|
||||||
|
// Hot-reload: same single validator — ensures the method is exercised.
|
||||||
|
engine.UpdateValidators([]string{id.PubKeyHex()})
|
||||||
|
|
||||||
|
// Block 2 should still commit with the reloaded set.
|
||||||
|
engine.Propose(b1)
|
||||||
|
b2 := committed.waitOne(t, 3*time.Second)
|
||||||
|
if b2.Index != 2 {
|
||||||
|
t.Errorf("expected block index 2 after validator update, got %d", b2.Index)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSyncSeqNum verifies that SyncSeqNum advances the engine's expected block index.
|
||||||
|
func TestSyncSeqNum(t *testing.T) {
|
||||||
|
id := newID(t)
|
||||||
|
net := &network{}
|
||||||
|
|
||||||
|
committed := newCommitted()
|
||||||
|
engine := consensus.NewEngine(
|
||||||
|
id,
|
||||||
|
[]string{id.PubKeyHex()},
|
||||||
|
1,
|
||||||
|
committed.onCommit,
|
||||||
|
net.broadcast,
|
||||||
|
)
|
||||||
|
net.engines = []*consensus.Engine{engine}
|
||||||
|
|
||||||
|
// Simulate receiving a chain sync that jumps to block 5.
|
||||||
|
engine.SyncSeqNum(5)
|
||||||
|
|
||||||
|
genesis := genesisFor(id)
|
||||||
|
// Build a fake block at index 5 to propose from.
|
||||||
|
b5 := &blockchain.Block{
|
||||||
|
Index: 4,
|
||||||
|
Timestamp: time.Now().UTC(),
|
||||||
|
Transactions: []*blockchain.Transaction{},
|
||||||
|
PrevHash: genesis.Hash,
|
||||||
|
Validator: id.PubKeyHex(),
|
||||||
|
TotalFees: 0,
|
||||||
|
}
|
||||||
|
b5.ComputeHash()
|
||||||
|
b5.Sign(id.PrivKey)
|
||||||
|
|
||||||
|
engine.Propose(b5)
|
||||||
|
b := committed.waitOne(t, 3*time.Second)
|
||||||
|
if b.Index != 5 {
|
||||||
|
t.Errorf("expected committed block index 5, got %d", b.Index)
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
contracts/auction/auction.wasm
Normal file
BIN
contracts/auction/auction.wasm
Normal file
Binary file not shown.
45
contracts/auction/auction_abi.json
Normal file
45
contracts/auction/auction_abi.json
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
{
|
||||||
|
"contract": "auction",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "English auction with on-chain token escrow. Bids are locked in the contract treasury. When an auction settles, the seller receives the top bid; outbid funds are automatically refunded.",
|
||||||
|
"methods": [
|
||||||
|
{
|
||||||
|
"name": "create",
|
||||||
|
"description": "Create a new auction. Logs 'created: <auction_id>'. auction_id = block_height:seq (seq auto-incremented).",
|
||||||
|
"args": [
|
||||||
|
{"name": "title", "type": "string", "description": "Human-readable item title (max 128 chars)"},
|
||||||
|
{"name": "min_bid", "type": "uint64", "description": "Minimum opening bid in µT"},
|
||||||
|
{"name": "duration", "type": "uint64", "description": "Duration in blocks"}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "bid",
|
||||||
|
"description": "Place a bid on an open auction. Caller transfers bid amount to the contract treasury; previous top bidder is refunded. Logs 'bid: <auction_id>'.",
|
||||||
|
"args": [
|
||||||
|
{"name": "auction_id", "type": "string", "description": "Auction ID returned by create"},
|
||||||
|
{"name": "amount", "type": "uint64", "description": "Bid amount in µT (must exceed current top bid)"}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "settle",
|
||||||
|
"description": "Settle an ended auction. Transfers winning bid to seller. Anyone may call after the end block. Logs 'settled: <auction_id>'.",
|
||||||
|
"args": [
|
||||||
|
{"name": "auction_id", "type": "string", "description": "Auction ID to settle"}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "cancel",
|
||||||
|
"description": "Cancel an auction before any bids. Only the seller may cancel. Logs 'cancelled: <auction_id>'.",
|
||||||
|
"args": [
|
||||||
|
{"name": "auction_id", "type": "string", "description": "Auction ID to cancel"}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "info",
|
||||||
|
"description": "Query auction info. Logs 'seller: ...', 'title: ...', 'top_bid: ...', 'end_block: ...', 'status: open/settled/cancelled'.",
|
||||||
|
"args": [
|
||||||
|
{"name": "auction_id", "type": "string", "description": "Auction ID"}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
784
contracts/auction/gen/main.go
Normal file
784
contracts/auction/gen/main.go
Normal file
@@ -0,0 +1,784 @@
|
|||||||
|
// gen generates contracts/auction/auction.wasm
|
||||||
|
// Run from repo root: go run ./contracts/auction/gen/
|
||||||
|
//
|
||||||
|
// Methods: create, bid, settle, cancel, info
|
||||||
|
//
|
||||||
|
// State keys (per auction_id, single-char suffix):
|
||||||
|
// "a:<id>:s" → seller address
|
||||||
|
// "a:<id>:t" → title
|
||||||
|
// "a:<id>:m" → min_bid (8-byte big-endian u64 via put_u64/get_u64)
|
||||||
|
// "a:<id>:e" → end_block (8-byte big-endian u64)
|
||||||
|
// "a:<id>:b" → top_bidder address (empty string = no bid)
|
||||||
|
// "a:<id>:v" → top_bid amount (8-byte big-endian u64)
|
||||||
|
// "a:<id>:x" → status byte ('o'=open, 's'=settled, 'c'=cancelled)
|
||||||
|
//
|
||||||
|
// The contract treasury holds in-flight bid escrow.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── LEB128 & builders ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func u(v uint64) []byte {
|
||||||
|
var b []byte
|
||||||
|
for {
|
||||||
|
bt := byte(v & 0x7f)
|
||||||
|
v >>= 7
|
||||||
|
if v != 0 {
|
||||||
|
bt |= 0x80
|
||||||
|
}
|
||||||
|
b = append(b, bt)
|
||||||
|
if v == 0 {
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func s(v int64) []byte {
|
||||||
|
var b []byte
|
||||||
|
for {
|
||||||
|
bt := byte(v & 0x7f)
|
||||||
|
v >>= 7
|
||||||
|
sign := (bt & 0x40) != 0
|
||||||
|
if (v == 0 && !sign) || (v == -1 && sign) {
|
||||||
|
return append(b, bt)
|
||||||
|
}
|
||||||
|
b = append(b, bt|0x80)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func cat(slices ...[]byte) []byte {
|
||||||
|
var out []byte
|
||||||
|
for _, sl := range slices {
|
||||||
|
out = append(out, sl...)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func wstr(str string) []byte { return cat(u(uint64(len(str))), []byte(str)) }
|
||||||
|
|
||||||
|
func section(id byte, content []byte) []byte {
|
||||||
|
return cat([]byte{id}, u(uint64(len(content))), content)
|
||||||
|
}
|
||||||
|
|
||||||
|
func vec(items ...[]byte) []byte {
|
||||||
|
out := u(uint64(len(items)))
|
||||||
|
for _, it := range items {
|
||||||
|
out = append(out, it...)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func functype(params, results []byte) []byte {
|
||||||
|
return cat([]byte{0x60}, u(uint64(len(params))), params, u(uint64(len(results))), results)
|
||||||
|
}
|
||||||
|
|
||||||
|
func importFunc(mod, name string, typeIdx uint32) []byte {
|
||||||
|
return cat(wstr(mod), wstr(name), []byte{0x00}, u(uint64(typeIdx)))
|
||||||
|
}
|
||||||
|
|
||||||
|
func exportEntry(name string, kind byte, idx uint32) []byte {
|
||||||
|
return cat(wstr(name), []byte{kind}, u(uint64(idx)))
|
||||||
|
}
|
||||||
|
|
||||||
|
func dataSegment(offset int32, data []byte) []byte {
|
||||||
|
return cat(
|
||||||
|
[]byte{0x00},
|
||||||
|
[]byte{0x41}, s(int64(offset)), []byte{0x0B},
|
||||||
|
u(uint64(len(data))), data,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func funcBody(localDecls []byte, instrs ...[]byte) []byte {
|
||||||
|
inner := cat(localDecls)
|
||||||
|
for _, ins := range instrs {
|
||||||
|
inner = append(inner, ins...)
|
||||||
|
}
|
||||||
|
inner = append(inner, 0x0B)
|
||||||
|
return cat(u(uint64(len(inner))), inner)
|
||||||
|
}
|
||||||
|
|
||||||
|
var noLocals = u(0)
|
||||||
|
|
||||||
|
func localDecl(n uint32, typ byte) []byte { return cat(u(uint64(n)), []byte{typ}) }
|
||||||
|
func withLocals(decls ...[]byte) []byte {
|
||||||
|
return cat(u(uint64(len(decls))), cat(decls...))
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
tI32 byte = 0x7F
|
||||||
|
tI64 byte = 0x7E
|
||||||
|
)
|
||||||
|
|
||||||
|
func call(fn uint32) []byte { return cat([]byte{0x10}, u(uint64(fn))) }
|
||||||
|
func lget(i uint32) []byte { return cat([]byte{0x20}, u(uint64(i))) }
|
||||||
|
func lset(i uint32) []byte { return cat([]byte{0x21}, u(uint64(i))) }
|
||||||
|
func ic32(v int32) []byte { return cat([]byte{0x41}, s(int64(v))) }
|
||||||
|
func ic64(v int64) []byte { return cat([]byte{0x42}, s(v)) }
|
||||||
|
func block_() []byte { return []byte{0x02, 0x40} }
|
||||||
|
func loop_() []byte { return []byte{0x03, 0x40} }
|
||||||
|
func if_() []byte { return []byte{0x04, 0x40} }
|
||||||
|
func else_() []byte { return []byte{0x05} }
|
||||||
|
func end_() []byte { return []byte{0x0B} }
|
||||||
|
func br_(lbl uint32) []byte { return cat([]byte{0x0C}, u(uint64(lbl))) }
|
||||||
|
func brIf_(lbl uint32) []byte { return cat([]byte{0x0D}, u(uint64(lbl))) }
|
||||||
|
func return_() []byte { return []byte{0x0F} }
|
||||||
|
func drop() []byte { return []byte{0x1A} }
|
||||||
|
func i32Eqz() []byte { return []byte{0x45} }
|
||||||
|
func i32Ne() []byte { return []byte{0x47} }
|
||||||
|
func i32GtU() []byte { return []byte{0x4B} }
|
||||||
|
func i32GeU() []byte { return []byte{0x4F} }
|
||||||
|
func i32Add() []byte { return []byte{0x6A} }
|
||||||
|
func i64Eqz() []byte { return []byte{0x50} }
|
||||||
|
func i64Eq() []byte { return []byte{0x51} }
|
||||||
|
func i64GtU() []byte { return []byte{0x56} }
|
||||||
|
func i64LeU() []byte { return []byte{0x57} }
|
||||||
|
func i64Add() []byte { return []byte{0x7C} }
|
||||||
|
func i32Load8U() []byte { return []byte{0x2D, 0x00, 0x00} }
|
||||||
|
func i32Store8() []byte { return []byte{0x3A, 0x00, 0x00} }
|
||||||
|
|
||||||
|
// ── Memory layout ─────────────────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// 0x000 64 arg[0]: auction_id
|
||||||
|
// 0x040 128 arg[1]: title
|
||||||
|
// 0x140 128 caller buffer
|
||||||
|
// 0x1C0 64 treasury buffer
|
||||||
|
// 0x200 128 state-read: seller or top_bidder
|
||||||
|
// 0x280 128 secondary state-read: top_bidder (during bid)
|
||||||
|
// 0x300 2 state-read: status byte
|
||||||
|
// 0x310 256 scratch (buildField writes here)
|
||||||
|
//
|
||||||
|
// Constant strings at 0x500+:
|
||||||
|
// 0x500 10 "created: "
|
||||||
|
// 0x50A 5 "bid: "
|
||||||
|
// 0x510 10 "settled: "
|
||||||
|
// 0x51A 11 "cancelled: "
|
||||||
|
// 0x526 14 "unauthorized: "
|
||||||
|
// 0x535 9 "not found: "
|
||||||
|
// 0x540 12 "not open: " (auction not in open state)
|
||||||
|
// 0x54C 14 "bidding open: " (cannot cancel with bids)
|
||||||
|
// 0x55B 12 "still open: " (auction has not ended yet)
|
||||||
|
// 0x568 11 "low bid: " (bid too low)
|
||||||
|
// 0x574 8 "seller: "
|
||||||
|
// 0x57D 7 "title: "
|
||||||
|
// 0x585 10 "top bid: "
|
||||||
|
// 0x590 11 "end block: "
|
||||||
|
// 0x59C 8 "status: "
|
||||||
|
// 0x5A5 5 "open"
|
||||||
|
// 0x5AA 8 "settled"
|
||||||
|
// 0x5B3 10 "cancelled"
|
||||||
|
|
||||||
|
const (
|
||||||
|
offArg0 int32 = 0x000
|
||||||
|
offArg1 int32 = 0x040
|
||||||
|
offCaller int32 = 0x140
|
||||||
|
offTreasury int32 = 0x1C0
|
||||||
|
offRead1 int32 = 0x200 // seller / first state read
|
||||||
|
offRead2 int32 = 0x280 // top_bidder / second state read
|
||||||
|
offReadStat int32 = 0x300 // status byte
|
||||||
|
offScratch int32 = 0x310 // key scratch
|
||||||
|
|
||||||
|
offCreatedPfx int32 = 0x500
|
||||||
|
offBidPfx int32 = 0x50A
|
||||||
|
offSettledPfx int32 = 0x510
|
||||||
|
offCancelledPfx int32 = 0x51A
|
||||||
|
offUnauthPfx int32 = 0x526
|
||||||
|
offNotFoundPfx int32 = 0x535
|
||||||
|
offNotOpenPfx int32 = 0x540
|
||||||
|
offHasBidsPfx int32 = 0x54C
|
||||||
|
offStillOpenPfx int32 = 0x55B
|
||||||
|
offLowBidPfx int32 = 0x568
|
||||||
|
offSellerPfx int32 = 0x574
|
||||||
|
offTitlePfx int32 = 0x57D
|
||||||
|
offTopBidPfx int32 = 0x585
|
||||||
|
offEndBlockPfx int32 = 0x590
|
||||||
|
offStatusPfx int32 = 0x59C
|
||||||
|
offStrOpen int32 = 0x5A5
|
||||||
|
offStrSettled int32 = 0x5AA
|
||||||
|
offStrCancelled int32 = 0x5B3
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── Import / function indices ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const (
|
||||||
|
fnGetArgStr = 0
|
||||||
|
fnGetArgU64 = 1
|
||||||
|
fnGetCaller = 2
|
||||||
|
fnGetState = 3
|
||||||
|
fnSetState = 4
|
||||||
|
fnLog = 5
|
||||||
|
fnTransfer = 6
|
||||||
|
fnGetBalance = 7
|
||||||
|
fnGetContractTreasury = 8
|
||||||
|
fnGetBlockHeight = 9
|
||||||
|
fnPutU64 = 10
|
||||||
|
fnGetU64 = 11
|
||||||
|
|
||||||
|
fnBytesEqual = 12
|
||||||
|
fnMemcpy = 13
|
||||||
|
fnLogPrefix = 14
|
||||||
|
fnBuildField = 15 // buildField(idOff, idLen, fieldChar i32) → keyLen i32
|
||||||
|
fnCreate = 16
|
||||||
|
fnBid = 17
|
||||||
|
fnSettle = 18
|
||||||
|
fnCancel = 19
|
||||||
|
fnInfo = 20
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── Helper bodies ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func bytesEqualBody() []byte {
|
||||||
|
return funcBody(
|
||||||
|
withLocals(localDecl(2, tI32)),
|
||||||
|
ic32(1), lset(4), ic32(0), lset(3),
|
||||||
|
block_(), loop_(),
|
||||||
|
lget(3), lget(2), i32GeU(), brIf_(1),
|
||||||
|
lget(0), lget(3), i32Add(), i32Load8U(),
|
||||||
|
lget(1), lget(3), i32Add(), i32Load8U(),
|
||||||
|
i32Ne(), if_(), ic32(0), lset(4), br_(2), end_(),
|
||||||
|
lget(3), ic32(1), i32Add(), lset(3),
|
||||||
|
br_(0), end_(), end_(),
|
||||||
|
lget(4),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func memcpyBody() []byte {
|
||||||
|
return funcBody(
|
||||||
|
withLocals(localDecl(1, tI32)),
|
||||||
|
ic32(0), lset(3),
|
||||||
|
block_(), loop_(),
|
||||||
|
lget(3), lget(2), i32GeU(), brIf_(1),
|
||||||
|
lget(0), lget(3), i32Add(),
|
||||||
|
lget(1), lget(3), i32Add(), i32Load8U(),
|
||||||
|
i32Store8(),
|
||||||
|
lget(3), ic32(1), i32Add(), lset(3),
|
||||||
|
br_(0), end_(), end_(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func logPrefixBody() []byte {
|
||||||
|
return funcBody(
|
||||||
|
noLocals,
|
||||||
|
ic32(offScratch), lget(0), lget(1), call(fnMemcpy),
|
||||||
|
ic32(offScratch), lget(1), i32Add(), lget(2), lget(3), call(fnMemcpy),
|
||||||
|
ic32(offScratch), lget(1), lget(3), i32Add(), call(fnLog),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildField(idOff, idLen, fieldChar i32) → keyLen i32
|
||||||
|
// Writes "a:<id>:<fieldChar>" into offScratch. Returns idLen+4.
|
||||||
|
func buildFieldBody() []byte {
|
||||||
|
return funcBody(
|
||||||
|
noLocals,
|
||||||
|
// scratch[0] = 'a'
|
||||||
|
ic32(offScratch), ic32('a'), i32Store8(),
|
||||||
|
// scratch[1] = ':'
|
||||||
|
ic32(offScratch+1), ic32(':'), i32Store8(),
|
||||||
|
// memcpy(scratch+2, idOff, idLen)
|
||||||
|
ic32(offScratch+2), lget(0), lget(1), call(fnMemcpy),
|
||||||
|
// scratch[2+idLen] = ':'
|
||||||
|
ic32(offScratch+2), lget(1), i32Add(), ic32(':'), i32Store8(),
|
||||||
|
// scratch[3+idLen] = fieldChar
|
||||||
|
ic32(offScratch+3), lget(1), i32Add(), lget(2), i32Store8(),
|
||||||
|
// return idLen + 4
|
||||||
|
lget(1), ic32(4), i32Add(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Contract methods ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// create(id string, title string, min_bid u64, duration u64)
|
||||||
|
// Locals: idLen(0), titleLen(1), treasuryLen(2), keyLen(3)
|
||||||
|
// i64 locals: minBid(4), duration(5)
|
||||||
|
func createBody() []byte {
|
||||||
|
return funcBody(
|
||||||
|
withLocals(localDecl(4, tI32), localDecl(2, tI64)),
|
||||||
|
ic32(0), ic32(offArg0), ic32(64), call(fnGetArgStr), lset(0),
|
||||||
|
lget(0), i32Eqz(), if_(), return_(), end_(),
|
||||||
|
ic32(1), ic32(offArg1), ic32(128), call(fnGetArgStr), lset(1),
|
||||||
|
lget(1), i32Eqz(), if_(), return_(), end_(),
|
||||||
|
|
||||||
|
// minBid = get_arg_u64(2)
|
||||||
|
ic32(2), call(fnGetArgU64), lset(4),
|
||||||
|
// duration = get_arg_u64(3)
|
||||||
|
ic32(3), call(fnGetArgU64), lset(5),
|
||||||
|
|
||||||
|
// Check not already registered: get_state("a:<id>:x", ...)
|
||||||
|
ic32(offArg0), lget(0), ic32('x'), call(fnBuildField), lset(3),
|
||||||
|
ic32(offScratch), lget(3), ic32(offReadStat), ic32(2), call(fnGetState),
|
||||||
|
ic32(0), i32GtU(), if_(), // status exists → already created
|
||||||
|
ic32(offNotFoundPfx), ic32(11), ic32(offArg0), lget(0), call(fnLogPrefix),
|
||||||
|
return_(),
|
||||||
|
end_(),
|
||||||
|
|
||||||
|
// Get treasury address
|
||||||
|
ic32(offTreasury), ic32(64), call(fnGetContractTreasury), lset(2),
|
||||||
|
|
||||||
|
// Get caller (seller)
|
||||||
|
ic32(offCaller), ic32(128), call(fnGetCaller), lset(2), // reuse local 2 for callerLen
|
||||||
|
|
||||||
|
// Write seller: a:<id>:s → caller
|
||||||
|
ic32(offArg0), lget(0), ic32('s'), call(fnBuildField), lset(3),
|
||||||
|
ic32(offScratch), lget(3), ic32(offCaller), lget(2), call(fnSetState),
|
||||||
|
|
||||||
|
// Write title: a:<id>:t → title
|
||||||
|
ic32(offArg0), lget(0), ic32('t'), call(fnBuildField), lset(3),
|
||||||
|
ic32(offScratch), lget(3), ic32(offArg1), lget(1), call(fnSetState),
|
||||||
|
|
||||||
|
// Write min_bid: a:<id>:m → put_u64
|
||||||
|
ic32(offArg0), lget(0), ic32('m'), call(fnBuildField), lset(3),
|
||||||
|
ic32(offScratch), lget(3), lget(4), call(fnPutU64),
|
||||||
|
|
||||||
|
// Write end_block: a:<id>:e → get_block_height() + duration
|
||||||
|
call(fnGetBlockHeight), lget(5), i64Add(),
|
||||||
|
ic32(offArg0), lget(0), ic32('e'), call(fnBuildField), lset(3),
|
||||||
|
ic32(offScratch), lget(3),
|
||||||
|
// stack: keyPtr, keyLen, [endBlock i64 still on stack? No — need to rearrange]
|
||||||
|
// Actually put_u64 takes (keyPtr, keyLen, val i64)
|
||||||
|
// We already have endBlock on stack but called buildField after. Let me restructure.
|
||||||
|
drop(), // drop keyLen from buildField - oops this won't work as coded above
|
||||||
|
// Actually above I wrote lset(3) to save keyLen, so stack should be clean.
|
||||||
|
// The i64 (endBlock) is on the operand stack before the buildField call... this is wrong.
|
||||||
|
// Let me restructure: compute endBlock first into an i64 local.
|
||||||
|
// I need another i64 local. Let me add local 6 (i64).
|
||||||
|
// This requires restructuring... see revised body below
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Revised create body with proper local management
|
||||||
|
// Locals: idLen(0), titleLen(1), callerLen(2), keyLen(3) [i32]
|
||||||
|
// minBid(4), duration(5), endBlock(6) [i64]
|
||||||
|
func createBodyV2() []byte {
|
||||||
|
return funcBody(
|
||||||
|
withLocals(localDecl(4, tI32), localDecl(3, tI64)),
|
||||||
|
|
||||||
|
// Read args
|
||||||
|
ic32(0), ic32(offArg0), ic32(64), call(fnGetArgStr), lset(0),
|
||||||
|
lget(0), i32Eqz(), if_(), return_(), end_(),
|
||||||
|
ic32(1), ic32(offArg1), ic32(128), call(fnGetArgStr), lset(1),
|
||||||
|
lget(1), i32Eqz(), if_(), return_(), end_(),
|
||||||
|
ic32(2), call(fnGetArgU64), lset(4), // minBid
|
||||||
|
ic32(3), call(fnGetArgU64), lset(5), // duration
|
||||||
|
|
||||||
|
// Check id not already used: read status key
|
||||||
|
ic32(offArg0), lget(0), ic32('x'), call(fnBuildField), lset(3),
|
||||||
|
ic32(offScratch), lget(3), ic32(offReadStat), ic32(2), call(fnGetState),
|
||||||
|
ic32(0), i32GtU(), if_(),
|
||||||
|
// ID already exists — log conflict
|
||||||
|
ic32(offNotFoundPfx), ic32(11), ic32(offArg0), lget(0), call(fnLogPrefix),
|
||||||
|
return_(),
|
||||||
|
end_(),
|
||||||
|
|
||||||
|
// Get caller (seller)
|
||||||
|
ic32(offCaller), ic32(128), call(fnGetCaller), lset(2),
|
||||||
|
|
||||||
|
// Compute endBlock = get_block_height() + duration
|
||||||
|
call(fnGetBlockHeight), lget(5), i64Add(), lset(6),
|
||||||
|
|
||||||
|
// Write seller: a:<id>:s → caller
|
||||||
|
ic32(offArg0), lget(0), ic32('s'), call(fnBuildField), lset(3),
|
||||||
|
ic32(offScratch), lget(3), ic32(offCaller), lget(2), call(fnSetState),
|
||||||
|
|
||||||
|
// Write title: a:<id>:t → arg1
|
||||||
|
ic32(offArg0), lget(0), ic32('t'), call(fnBuildField), lset(3),
|
||||||
|
ic32(offScratch), lget(3), ic32(offArg1), lget(1), call(fnSetState),
|
||||||
|
|
||||||
|
// Write min_bid: a:<id>:m
|
||||||
|
ic32(offArg0), lget(0), ic32('m'), call(fnBuildField), lset(3),
|
||||||
|
ic32(offScratch), lget(3), lget(4), call(fnPutU64),
|
||||||
|
|
||||||
|
// Write end_block: a:<id>:e
|
||||||
|
ic32(offArg0), lget(0), ic32('e'), call(fnBuildField), lset(3),
|
||||||
|
ic32(offScratch), lget(3), lget(6), call(fnPutU64),
|
||||||
|
|
||||||
|
// Write top_bid = 0: a:<id>:v
|
||||||
|
ic32(offArg0), lget(0), ic32('v'), call(fnBuildField), lset(3),
|
||||||
|
ic32(offScratch), lget(3), ic64(0), call(fnPutU64),
|
||||||
|
|
||||||
|
// Write status = 'o': a:<id>:x
|
||||||
|
ic32(offArg0), lget(0), ic32('x'), call(fnBuildField), lset(3),
|
||||||
|
ic32(offReadStat), ic32('o'), i32Store8(), // write 'o' to offReadStat
|
||||||
|
ic32(offScratch), lget(3), ic32(offReadStat), ic32(1), call(fnSetState),
|
||||||
|
|
||||||
|
ic32(offCreatedPfx), ic32(9), ic32(offArg0), lget(0), call(fnLogPrefix),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// bid(id string, amount u64)
|
||||||
|
// Locals: idLen(0), callerLen(1), sellerLen(2), topBidderLen(3), keyLen(4) [i32]
|
||||||
|
// amount(5), topBid(6), endBlock(7) [i64]
|
||||||
|
func bidBody() []byte {
|
||||||
|
return funcBody(
|
||||||
|
withLocals(localDecl(5, tI32), localDecl(3, tI64)),
|
||||||
|
|
||||||
|
ic32(0), ic32(offArg0), ic32(64), call(fnGetArgStr), lset(0),
|
||||||
|
lget(0), i32Eqz(), if_(), return_(), end_(),
|
||||||
|
ic32(1), call(fnGetArgU64), lset(5), // amount
|
||||||
|
|
||||||
|
// Check status == 'o'
|
||||||
|
ic32(offArg0), lget(0), ic32('x'), call(fnBuildField), lset(4),
|
||||||
|
ic32(offScratch), lget(4), ic32(offReadStat), ic32(2), call(fnGetState),
|
||||||
|
ic32(0), i32Ne(), if_(), // status byte exists
|
||||||
|
ic32(offReadStat), i32Load8U(), ic32('o'), i32Ne(), if_(),
|
||||||
|
ic32(offNotOpenPfx), ic32(10), ic32(offArg0), lget(0), call(fnLogPrefix),
|
||||||
|
return_(),
|
||||||
|
end_(),
|
||||||
|
else_(),
|
||||||
|
// no status byte = not found
|
||||||
|
ic32(offNotFoundPfx), ic32(11), ic32(offArg0), lget(0), call(fnLogPrefix),
|
||||||
|
return_(),
|
||||||
|
end_(),
|
||||||
|
|
||||||
|
// Check end_block > current block
|
||||||
|
ic32(offArg0), lget(0), ic32('e'), call(fnBuildField), lset(4),
|
||||||
|
ic32(offScratch), lget(4), call(fnGetU64), lset(7),
|
||||||
|
call(fnGetBlockHeight), lget(7), i64LeU(), if_(), // blockHeight >= endBlock → ended
|
||||||
|
ic32(offNotOpenPfx), ic32(10), ic32(offArg0), lget(0), call(fnLogPrefix),
|
||||||
|
return_(),
|
||||||
|
end_(),
|
||||||
|
|
||||||
|
// Get current top_bid
|
||||||
|
ic32(offArg0), lget(0), ic32('v'), call(fnBuildField), lset(4),
|
||||||
|
ic32(offScratch), lget(4), call(fnGetU64), lset(6),
|
||||||
|
|
||||||
|
// amount must be > max(topBid, minBid-1) → amount > topBid AND amount >= minBid
|
||||||
|
// Check amount > topBid
|
||||||
|
lget(5), lget(6), i64GtU(), i32Eqz(), if_(),
|
||||||
|
ic32(offLowBidPfx), ic32(9), ic32(offArg0), lget(0), call(fnLogPrefix),
|
||||||
|
return_(),
|
||||||
|
end_(),
|
||||||
|
|
||||||
|
// Get caller
|
||||||
|
ic32(offCaller), ic32(128), call(fnGetCaller), lset(1),
|
||||||
|
|
||||||
|
// Get treasury
|
||||||
|
ic32(offTreasury), ic32(64), call(fnGetContractTreasury), lset(2), // reuse local 2
|
||||||
|
|
||||||
|
// Transfer amount from caller to treasury
|
||||||
|
ic32(offCaller), lget(1), ic32(offTreasury), lget(2), lget(5), call(fnTransfer), drop(),
|
||||||
|
|
||||||
|
// Refund previous top bidder if topBid > 0
|
||||||
|
lget(6), i64Eqz(), i32Eqz(), if_(), // topBid > 0
|
||||||
|
// Read top_bidder address into offRead2
|
||||||
|
ic32(offArg0), lget(0), ic32('b'), call(fnBuildField), lset(4),
|
||||||
|
ic32(offScratch), lget(4), ic32(offRead2), ic32(128), call(fnGetState), lset(3),
|
||||||
|
lget(3), ic32(0), i32GtU(), if_(), // topBidder address exists
|
||||||
|
// transfer(treasury, topBidder, topBid)
|
||||||
|
ic32(offTreasury), lget(2), ic32(offRead2), lget(3), lget(6), call(fnTransfer), drop(),
|
||||||
|
end_(),
|
||||||
|
end_(),
|
||||||
|
|
||||||
|
// Update top_bidder: a:<id>:b → caller
|
||||||
|
ic32(offArg0), lget(0), ic32('b'), call(fnBuildField), lset(4),
|
||||||
|
ic32(offScratch), lget(4), ic32(offCaller), lget(1), call(fnSetState),
|
||||||
|
|
||||||
|
// Update top_bid: a:<id>:v → amount
|
||||||
|
ic32(offArg0), lget(0), ic32('v'), call(fnBuildField), lset(4),
|
||||||
|
ic32(offScratch), lget(4), lget(5), call(fnPutU64),
|
||||||
|
|
||||||
|
ic32(offBidPfx), ic32(5), ic32(offArg0), lget(0), call(fnLogPrefix),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// settle(id string)
|
||||||
|
// Locals: idLen(0), sellerLen(1), topBidderLen(2), treasuryLen(3), keyLen(4) [i32]
|
||||||
|
// topBid(5), endBlock(6) [i64]
|
||||||
|
func settleBody() []byte {
|
||||||
|
return funcBody(
|
||||||
|
withLocals(localDecl(5, tI32), localDecl(2, tI64)),
|
||||||
|
|
||||||
|
ic32(0), ic32(offArg0), ic32(64), call(fnGetArgStr), lset(0),
|
||||||
|
lget(0), i32Eqz(), if_(), return_(), end_(),
|
||||||
|
|
||||||
|
// Check status == 'o'
|
||||||
|
ic32(offArg0), lget(0), ic32('x'), call(fnBuildField), lset(4),
|
||||||
|
ic32(offScratch), lget(4), ic32(offReadStat), ic32(2), call(fnGetState),
|
||||||
|
ic32(0), i32Ne(), if_(),
|
||||||
|
ic32(offReadStat), i32Load8U(), ic32('o'), i32Ne(), if_(),
|
||||||
|
ic32(offNotOpenPfx), ic32(10), ic32(offArg0), lget(0), call(fnLogPrefix),
|
||||||
|
return_(),
|
||||||
|
end_(),
|
||||||
|
else_(),
|
||||||
|
ic32(offNotFoundPfx), ic32(11), ic32(offArg0), lget(0), call(fnLogPrefix),
|
||||||
|
return_(),
|
||||||
|
end_(),
|
||||||
|
|
||||||
|
// Check auction has ended: current_block >= end_block
|
||||||
|
ic32(offArg0), lget(0), ic32('e'), call(fnBuildField), lset(4),
|
||||||
|
ic32(offScratch), lget(4), call(fnGetU64), lset(6),
|
||||||
|
call(fnGetBlockHeight), lget(6), i64LeU(), if_(), // height < endBlock → still open
|
||||||
|
ic32(offStillOpenPfx), ic32(12), ic32(offArg0), lget(0), call(fnLogPrefix),
|
||||||
|
return_(),
|
||||||
|
end_(),
|
||||||
|
|
||||||
|
// Get top_bid
|
||||||
|
ic32(offArg0), lget(0), ic32('v'), call(fnBuildField), lset(4),
|
||||||
|
ic32(offScratch), lget(4), call(fnGetU64), lset(5),
|
||||||
|
|
||||||
|
// If top_bid > 0: transfer topBid from treasury to seller
|
||||||
|
lget(5), i64Eqz(), i32Eqz(), if_(),
|
||||||
|
// Read seller into offRead1
|
||||||
|
ic32(offArg0), lget(0), ic32('s'), call(fnBuildField), lset(4),
|
||||||
|
ic32(offScratch), lget(4), ic32(offRead1), ic32(128), call(fnGetState), lset(1),
|
||||||
|
// Get treasury
|
||||||
|
ic32(offTreasury), ic32(64), call(fnGetContractTreasury), lset(3),
|
||||||
|
ic32(offTreasury), lget(3), ic32(offRead1), lget(1), lget(5), call(fnTransfer), drop(),
|
||||||
|
end_(),
|
||||||
|
|
||||||
|
// Mark status = 's'
|
||||||
|
ic32(offReadStat), ic32('s'), i32Store8(),
|
||||||
|
ic32(offArg0), lget(0), ic32('x'), call(fnBuildField), lset(4),
|
||||||
|
ic32(offScratch), lget(4), ic32(offReadStat), ic32(1), call(fnSetState),
|
||||||
|
|
||||||
|
ic32(offSettledPfx), ic32(9), ic32(offArg0), lget(0), call(fnLogPrefix),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// cancel(id string) — seller cancels (no bids yet)
|
||||||
|
// Locals: idLen(0), callerLen(1), sellerLen(2), keyLen(3) [i32]
|
||||||
|
// topBid(4) [i64]
|
||||||
|
func cancelBody() []byte {
|
||||||
|
return funcBody(
|
||||||
|
withLocals(localDecl(4, tI32), localDecl(1, tI64)),
|
||||||
|
|
||||||
|
ic32(0), ic32(offArg0), ic32(64), call(fnGetArgStr), lset(0),
|
||||||
|
lget(0), i32Eqz(), if_(), return_(), end_(),
|
||||||
|
|
||||||
|
// Check status == 'o'
|
||||||
|
ic32(offArg0), lget(0), ic32('x'), call(fnBuildField), lset(3),
|
||||||
|
ic32(offScratch), lget(3), ic32(offReadStat), ic32(2), call(fnGetState),
|
||||||
|
ic32(0), i32Ne(), if_(),
|
||||||
|
ic32(offReadStat), i32Load8U(), ic32('o'), i32Ne(), if_(),
|
||||||
|
ic32(offNotOpenPfx), ic32(10), ic32(offArg0), lget(0), call(fnLogPrefix),
|
||||||
|
return_(),
|
||||||
|
end_(),
|
||||||
|
else_(),
|
||||||
|
ic32(offNotFoundPfx), ic32(11), ic32(offArg0), lget(0), call(fnLogPrefix),
|
||||||
|
return_(),
|
||||||
|
end_(),
|
||||||
|
|
||||||
|
// Check no bids: topBid == 0
|
||||||
|
ic32(offArg0), lget(0), ic32('v'), call(fnBuildField), lset(3),
|
||||||
|
ic32(offScratch), lget(3), call(fnGetU64), lset(4),
|
||||||
|
lget(4), i64Eqz(), i32Eqz(), if_(), // topBid > 0 → has bids
|
||||||
|
ic32(offHasBidsPfx), ic32(14), ic32(offArg0), lget(0), call(fnLogPrefix),
|
||||||
|
return_(),
|
||||||
|
end_(),
|
||||||
|
|
||||||
|
// Verify caller is seller
|
||||||
|
ic32(offArg0), lget(0), ic32('s'), call(fnBuildField), lset(3),
|
||||||
|
ic32(offScratch), lget(3), ic32(offRead1), ic32(128), call(fnGetState), lset(2),
|
||||||
|
ic32(offCaller), ic32(128), call(fnGetCaller), lset(1),
|
||||||
|
// isOwner: callerLen == sellerLen && bytes_equal
|
||||||
|
lget(1), lget(2), i32Ne(), if_(),
|
||||||
|
ic32(offUnauthPfx), ic32(14), ic32(offArg0), lget(0), call(fnLogPrefix),
|
||||||
|
return_(),
|
||||||
|
end_(),
|
||||||
|
ic32(offCaller), ic32(offRead1), lget(1), call(fnBytesEqual),
|
||||||
|
i32Eqz(), if_(),
|
||||||
|
ic32(offUnauthPfx), ic32(14), ic32(offArg0), lget(0), call(fnLogPrefix),
|
||||||
|
return_(),
|
||||||
|
end_(),
|
||||||
|
|
||||||
|
// Mark status = 'c'
|
||||||
|
ic32(offReadStat), ic32('c'), i32Store8(),
|
||||||
|
ic32(offArg0), lget(0), ic32('x'), call(fnBuildField), lset(3),
|
||||||
|
ic32(offScratch), lget(3), ic32(offReadStat), ic32(1), call(fnSetState),
|
||||||
|
|
||||||
|
ic32(offCancelledPfx), ic32(11), ic32(offArg0), lget(0), call(fnLogPrefix),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// info(id string) — log auction details
|
||||||
|
// Locals: idLen(0), strLen(1), keyLen(2) [i32]
|
||||||
|
// topBid(3) [i64]
|
||||||
|
func infoBody() []byte {
|
||||||
|
return funcBody(
|
||||||
|
withLocals(localDecl(3, tI32), localDecl(1, tI64)),
|
||||||
|
|
||||||
|
ic32(0), ic32(offArg0), ic32(64), call(fnGetArgStr), lset(0),
|
||||||
|
lget(0), i32Eqz(), if_(), return_(), end_(),
|
||||||
|
|
||||||
|
// Check exists
|
||||||
|
ic32(offArg0), lget(0), ic32('x'), call(fnBuildField), lset(2),
|
||||||
|
ic32(offScratch), lget(2), ic32(offReadStat), ic32(2), call(fnGetState),
|
||||||
|
ic32(0), i32Ne(), i32Eqz(), if_(),
|
||||||
|
ic32(offNotFoundPfx), ic32(11), ic32(offArg0), lget(0), call(fnLogPrefix),
|
||||||
|
return_(),
|
||||||
|
end_(),
|
||||||
|
|
||||||
|
// Log seller: a:<id>:s
|
||||||
|
ic32(offArg0), lget(0), ic32('s'), call(fnBuildField), lset(2),
|
||||||
|
ic32(offScratch), lget(2), ic32(offRead1), ic32(128), call(fnGetState), lset(1),
|
||||||
|
ic32(offSellerPfx), ic32(8), ic32(offRead1), lget(1), call(fnLogPrefix),
|
||||||
|
|
||||||
|
// Log title: a:<id>:t
|
||||||
|
ic32(offArg0), lget(0), ic32('t'), call(fnBuildField), lset(2),
|
||||||
|
ic32(offScratch), lget(2), ic32(offRead1), ic32(128), call(fnGetState), lset(1),
|
||||||
|
ic32(offTitlePfx), ic32(7), ic32(offRead1), lget(1), call(fnLogPrefix),
|
||||||
|
|
||||||
|
// Log status
|
||||||
|
ic32(offReadStat), i32Load8U(), ic32('s'), i32Ne(), if_(),
|
||||||
|
ic32(offReadStat), i32Load8U(), ic32('c'), i32Ne(), if_(),
|
||||||
|
// status == 'o'
|
||||||
|
ic32(offStatusPfx), ic32(8), ic32(offStrOpen), ic32(4), call(fnLogPrefix),
|
||||||
|
else_(),
|
||||||
|
ic32(offStatusPfx), ic32(8), ic32(offStrCancelled), ic32(9), call(fnLogPrefix),
|
||||||
|
end_(),
|
||||||
|
else_(),
|
||||||
|
ic32(offStatusPfx), ic32(8), ic32(offStrSettled), ic32(7), call(fnLogPrefix),
|
||||||
|
end_(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── main ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
// Types:
|
||||||
|
// 0: (i32,i32,i32)→(i32) get_arg_str, bytes_equal
|
||||||
|
// 1: (i32)→(i64) get_arg_u64, get_block_height (no, diff sig)
|
||||||
|
// 2: (i32,i32,i32,i32)→(i32) get_state
|
||||||
|
// 3: (i32,i32,i32,i32)→() set_state, log_prefix
|
||||||
|
// 4: (i32,i32)→() log
|
||||||
|
// 5: (i32,i32,i32,i32,i64)→(i32) transfer
|
||||||
|
// 6: (i32,i32)→(i64) get_balance, get_u64
|
||||||
|
// 7: (i32,i32)→(i32) get_caller, get_contract_treasury
|
||||||
|
// 8: ()→(i64) get_block_height
|
||||||
|
// 9: (i32,i32,i64)→() put_u64
|
||||||
|
// 10: ()→() exported methods
|
||||||
|
// 11: (i32,i32,i32)→() memcpy
|
||||||
|
// 12: (i32,i32,i32)→(i32) buildField
|
||||||
|
typeSection := section(0x01, vec(
|
||||||
|
functype([]byte{tI32, tI32, tI32}, []byte{tI32}), // 0
|
||||||
|
functype([]byte{tI32}, []byte{tI64}), // 1
|
||||||
|
functype([]byte{tI32, tI32, tI32, tI32}, []byte{tI32}), // 2
|
||||||
|
functype([]byte{tI32, tI32, tI32, tI32}, []byte{}), // 3
|
||||||
|
functype([]byte{tI32, tI32}, []byte{}), // 4
|
||||||
|
functype([]byte{tI32, tI32, tI32, tI32, tI64}, []byte{tI32}), // 5
|
||||||
|
functype([]byte{tI32, tI32}, []byte{tI64}), // 6
|
||||||
|
functype([]byte{tI32, tI32}, []byte{tI32}), // 7
|
||||||
|
functype([]byte{}, []byte{tI64}), // 8
|
||||||
|
functype([]byte{tI32, tI32, tI64}, []byte{}), // 9
|
||||||
|
functype([]byte{}, []byte{}), // 10
|
||||||
|
functype([]byte{tI32, tI32, tI32}, []byte{}), // 11
|
||||||
|
))
|
||||||
|
|
||||||
|
importSection := section(0x02, vec(
|
||||||
|
importFunc("env", "get_arg_str", 0), // 0
|
||||||
|
importFunc("env", "get_arg_u64", 1), // 1
|
||||||
|
importFunc("env", "get_caller", 7), // 2
|
||||||
|
importFunc("env", "get_state", 2), // 3
|
||||||
|
importFunc("env", "set_state", 3), // 4
|
||||||
|
importFunc("env", "log", 4), // 5
|
||||||
|
importFunc("env", "transfer", 5), // 6
|
||||||
|
importFunc("env", "get_balance", 6), // 7
|
||||||
|
importFunc("env", "get_contract_treasury", 7), // 8
|
||||||
|
importFunc("env", "get_block_height", 8), // 9
|
||||||
|
importFunc("env", "put_u64", 9), // 10
|
||||||
|
importFunc("env", "get_u64", 6), // 11
|
||||||
|
))
|
||||||
|
|
||||||
|
// 9 local functions
|
||||||
|
functionSection := section(0x03, vec(
|
||||||
|
u(0), // bytes_equal type 0
|
||||||
|
u(11), // memcpy type 11
|
||||||
|
u(3), // log_prefix type 3
|
||||||
|
u(0), // buildField type 0 (i32,i32,i32)→(i32)
|
||||||
|
u(10), // create type 10
|
||||||
|
u(10), // bid type 10
|
||||||
|
u(10), // settle type 10
|
||||||
|
u(10), // cancel type 10
|
||||||
|
u(10), // info type 10
|
||||||
|
))
|
||||||
|
|
||||||
|
memorySection := section(0x05, vec(cat([]byte{0x00}, u(2)))) // 2 pages
|
||||||
|
|
||||||
|
exportSection := section(0x07, vec(
|
||||||
|
exportEntry("memory", 0x02, 0),
|
||||||
|
exportEntry("create", 0x00, fnCreate),
|
||||||
|
exportEntry("bid", 0x00, fnBid),
|
||||||
|
exportEntry("settle", 0x00, fnSettle),
|
||||||
|
exportEntry("cancel", 0x00, fnCancel),
|
||||||
|
exportEntry("info", 0x00, fnInfo),
|
||||||
|
))
|
||||||
|
|
||||||
|
dataSection := section(0x0B, cat(
|
||||||
|
u(17),
|
||||||
|
dataSegment(offCreatedPfx, []byte("created: ")),
|
||||||
|
dataSegment(offBidPfx, []byte("bid: ")),
|
||||||
|
dataSegment(offSettledPfx, []byte("settled: ")),
|
||||||
|
dataSegment(offCancelledPfx, []byte("cancelled: ")),
|
||||||
|
dataSegment(offUnauthPfx, []byte("unauthorized: ")),
|
||||||
|
dataSegment(offNotFoundPfx, []byte("not found: ")),
|
||||||
|
dataSegment(offNotOpenPfx, []byte("not open: ")),
|
||||||
|
dataSegment(offHasBidsPfx, []byte("has bids: ")),
|
||||||
|
dataSegment(offStillOpenPfx, []byte("still open: ")),
|
||||||
|
dataSegment(offLowBidPfx, []byte("low bid: ")),
|
||||||
|
dataSegment(offSellerPfx, []byte("seller: ")),
|
||||||
|
dataSegment(offTitlePfx, []byte("title: ")),
|
||||||
|
dataSegment(offTopBidPfx, []byte("top bid: ")),
|
||||||
|
dataSegment(offEndBlockPfx, []byte("end block: ")),
|
||||||
|
dataSegment(offStatusPfx, []byte("status: ")),
|
||||||
|
dataSegment(offStrOpen, []byte("open")),
|
||||||
|
dataSegment(offStrSettled, []byte("settled")),
|
||||||
|
// Note: offStrCancelled is at 0x5B3 but we declared 17 segments
|
||||||
|
// Add cancelled string too
|
||||||
|
))
|
||||||
|
// Fix: 18 data segments including "cancelled"
|
||||||
|
dataSection = section(0x0B, cat(
|
||||||
|
u(18),
|
||||||
|
dataSegment(offCreatedPfx, []byte("created: ")),
|
||||||
|
dataSegment(offBidPfx, []byte("bid: ")),
|
||||||
|
dataSegment(offSettledPfx, []byte("settled: ")),
|
||||||
|
dataSegment(offCancelledPfx, []byte("cancelled: ")),
|
||||||
|
dataSegment(offUnauthPfx, []byte("unauthorized: ")),
|
||||||
|
dataSegment(offNotFoundPfx, []byte("not found: ")),
|
||||||
|
dataSegment(offNotOpenPfx, []byte("not open: ")),
|
||||||
|
dataSegment(offHasBidsPfx, []byte("has bids: ")),
|
||||||
|
dataSegment(offStillOpenPfx, []byte("still open: ")),
|
||||||
|
dataSegment(offLowBidPfx, []byte("low bid: ")),
|
||||||
|
dataSegment(offSellerPfx, []byte("seller: ")),
|
||||||
|
dataSegment(offTitlePfx, []byte("title: ")),
|
||||||
|
dataSegment(offTopBidPfx, []byte("top bid: ")),
|
||||||
|
dataSegment(offEndBlockPfx, []byte("end block: ")),
|
||||||
|
dataSegment(offStatusPfx, []byte("status: ")),
|
||||||
|
dataSegment(offStrOpen, []byte("open")),
|
||||||
|
dataSegment(offStrSettled, []byte("settled")),
|
||||||
|
dataSegment(offStrCancelled, []byte("cancelled")),
|
||||||
|
))
|
||||||
|
|
||||||
|
codeSection := section(0x0A, cat(
|
||||||
|
u(9),
|
||||||
|
bytesEqualBody(),
|
||||||
|
memcpyBody(),
|
||||||
|
logPrefixBody(),
|
||||||
|
buildFieldBody(),
|
||||||
|
createBodyV2(),
|
||||||
|
bidBody(),
|
||||||
|
settleBody(),
|
||||||
|
cancelBody(),
|
||||||
|
infoBody(),
|
||||||
|
))
|
||||||
|
|
||||||
|
module := cat(
|
||||||
|
[]byte{0x00, 0x61, 0x73, 0x6d},
|
||||||
|
[]byte{0x01, 0x00, 0x00, 0x00},
|
||||||
|
typeSection,
|
||||||
|
importSection,
|
||||||
|
functionSection,
|
||||||
|
memorySection,
|
||||||
|
exportSection,
|
||||||
|
dataSection,
|
||||||
|
codeSection,
|
||||||
|
)
|
||||||
|
|
||||||
|
out := "contracts/auction/auction.wasm"
|
||||||
|
if err := os.WriteFile(out, module, 0644); err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, "write:", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
fmt.Printf("Written %s (%d bytes)\n", out, len(module))
|
||||||
|
}
|
||||||
BIN
contracts/counter/counter.wasm
Normal file
BIN
contracts/counter/counter.wasm
Normal file
Binary file not shown.
102
contracts/counter/counter.wat
Normal file
102
contracts/counter/counter.wat
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
(module
|
||||||
|
;; Counter smart contract — methods: increment, get, reset
|
||||||
|
;; State key "counter" stores uint64 as 8-byte big-endian.
|
||||||
|
;; State key "owner" stores the deployer pub key set on first reset() call.
|
||||||
|
|
||||||
|
;; ── imports ──────────────────────────────────────────────────────────────
|
||||||
|
(import "env" "put_u64"
|
||||||
|
(func $put_u64 (param i32 i32 i64)))
|
||||||
|
(import "env" "get_u64"
|
||||||
|
(func $get_u64 (param i32 i32) (result i64)))
|
||||||
|
(import "env" "get_caller"
|
||||||
|
(func $get_caller (param i32 i32) (result i32)))
|
||||||
|
(import "env" "get_state"
|
||||||
|
(func $get_state (param i32 i32 i32 i32) (result i32)))
|
||||||
|
(import "env" "set_state"
|
||||||
|
(func $set_state (param i32 i32 i32 i32)))
|
||||||
|
(import "env" "log"
|
||||||
|
(func $log (param i32 i32)))
|
||||||
|
|
||||||
|
;; ── memory ───────────────────────────────────────────────────────────────
|
||||||
|
;; Memory layout:
|
||||||
|
;; offset 0 : "counter" (7 bytes) — state key for the counter value
|
||||||
|
;; offset 16 : "owner" (5 bytes) — state key for the owner pub key
|
||||||
|
;; offset 32 : caller buffer (128 bytes)
|
||||||
|
;; offset 160 : owner buffer (128 bytes)
|
||||||
|
;; offset 288 : log messages
|
||||||
|
(memory (export "memory") 1)
|
||||||
|
|
||||||
|
(data (i32.const 0) "counter")
|
||||||
|
(data (i32.const 16) "owner")
|
||||||
|
(data (i32.const 288) "incremented")
|
||||||
|
(data (i32.const 300) "get called")
|
||||||
|
(data (i32.const 310) "reset")
|
||||||
|
(data (i32.const 316) "unauthorized")
|
||||||
|
(data (i32.const 329) "reset ok")
|
||||||
|
|
||||||
|
;; ── increment() ──────────────────────────────────────────────────────────
|
||||||
|
(func (export "increment")
|
||||||
|
(local $val i64)
|
||||||
|
(local.set $val (call $get_u64 (i32.const 0) (i32.const 7)))
|
||||||
|
(local.set $val (i64.add (local.get $val) (i64.const 1)))
|
||||||
|
(call $put_u64 (i32.const 0) (i32.const 7) (local.get $val))
|
||||||
|
(call $log (i32.const 288) (i32.const 11))
|
||||||
|
)
|
||||||
|
|
||||||
|
;; ── get() ─────────────────────────────────────────────────────────────────
|
||||||
|
(func (export "get")
|
||||||
|
(call $log (i32.const 300) (i32.const 10))
|
||||||
|
)
|
||||||
|
|
||||||
|
;; ── reset() ───────────────────────────────────────────────────────────────
|
||||||
|
;; Resets counter to 0. Only callable by the deployer (first caller sets ownership).
|
||||||
|
(func (export "reset")
|
||||||
|
(local $callerLen i32)
|
||||||
|
(local $ownerLen i32)
|
||||||
|
(local $i i32)
|
||||||
|
(local $same i32)
|
||||||
|
|
||||||
|
(local.set $callerLen (call $get_caller (i32.const 32) (i32.const 128)))
|
||||||
|
(local.set $ownerLen
|
||||||
|
(call $get_state (i32.const 16) (i32.const 5) (i32.const 160) (i32.const 128)))
|
||||||
|
|
||||||
|
;; No owner yet — first caller becomes owner and resets to 0
|
||||||
|
(if (i32.eqz (local.get $ownerLen))
|
||||||
|
(then
|
||||||
|
(call $set_state (i32.const 16) (i32.const 5) (i32.const 32) (local.get $callerLen))
|
||||||
|
(call $put_u64 (i32.const 0) (i32.const 7) (i64.const 0))
|
||||||
|
(call $log (i32.const 329) (i32.const 8))
|
||||||
|
return
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
;; Length mismatch → unauthorized
|
||||||
|
(if (i32.ne (local.get $callerLen) (local.get $ownerLen))
|
||||||
|
(then (call $log (i32.const 316) (i32.const 12)) return)
|
||||||
|
)
|
||||||
|
|
||||||
|
;; Byte-by-byte comparison
|
||||||
|
(local.set $same (i32.const 1))
|
||||||
|
(local.set $i (i32.const 0))
|
||||||
|
(block $break
|
||||||
|
(loop $loop
|
||||||
|
(br_if $break (i32.ge_u (local.get $i) (local.get $callerLen)))
|
||||||
|
(if (i32.ne
|
||||||
|
(i32.load8_u (i32.add (i32.const 32) (local.get $i)))
|
||||||
|
(i32.load8_u (i32.add (i32.const 160) (local.get $i))))
|
||||||
|
(then (local.set $same (i32.const 0)) (br $break))
|
||||||
|
)
|
||||||
|
(local.set $i (i32.add (local.get $i) (i32.const 1)))
|
||||||
|
(br $loop)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
(if (i32.eqz (local.get $same))
|
||||||
|
(then (call $log (i32.const 316) (i32.const 12)) return)
|
||||||
|
)
|
||||||
|
|
||||||
|
;; Authorized — reset
|
||||||
|
(call $put_u64 (i32.const 0) (i32.const 7) (i64.const 0))
|
||||||
|
(call $log (i32.const 329) (i32.const 8))
|
||||||
|
)
|
||||||
|
)
|
||||||
1
contracts/counter/counter_abi.json
Normal file
1
contracts/counter/counter_abi.json
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"methods":[{"name":"increment","args":[]},{"name":"get","args":[]},{"name":"reset","args":[]}]}
|
||||||
331
contracts/counter/gen/main.go
Normal file
331
contracts/counter/gen/main.go
Normal file
@@ -0,0 +1,331 @@
|
|||||||
|
// gen generates contracts/counter/counter.wasm — binary WASM for the counter contract.
|
||||||
|
// Run from the repo root: go run ./contracts/counter/gen/
|
||||||
|
//
|
||||||
|
// Contract methods exported: increment, get, reset
|
||||||
|
// Host imports from "env": put_u64, get_u64, log, get_caller, get_state, set_state
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── LEB128 ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func u(v uint64) []byte {
|
||||||
|
var b []byte
|
||||||
|
for {
|
||||||
|
bt := byte(v & 0x7f)
|
||||||
|
v >>= 7
|
||||||
|
if v != 0 {
|
||||||
|
bt |= 0x80
|
||||||
|
}
|
||||||
|
b = append(b, bt)
|
||||||
|
if v == 0 {
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func s(v int64) []byte {
|
||||||
|
var b []byte
|
||||||
|
for {
|
||||||
|
bt := byte(v & 0x7f)
|
||||||
|
v >>= 7
|
||||||
|
sign := (bt & 0x40) != 0
|
||||||
|
if (v == 0 && !sign) || (v == -1 && sign) {
|
||||||
|
return append(b, bt)
|
||||||
|
}
|
||||||
|
b = append(b, bt|0x80)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Builders ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func cat(slices ...[]byte) []byte {
|
||||||
|
var out []byte
|
||||||
|
for _, s := range slices {
|
||||||
|
out = append(out, s...)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func wstr(str string) []byte { return cat(u(uint64(len(str))), []byte(str)) }
|
||||||
|
|
||||||
|
func section(id byte, content []byte) []byte {
|
||||||
|
return cat([]byte{id}, u(uint64(len(content))), content)
|
||||||
|
}
|
||||||
|
|
||||||
|
// vec encodes a vector: count followed by concatenated items.
|
||||||
|
func vec(items ...[]byte) []byte {
|
||||||
|
out := u(uint64(len(items)))
|
||||||
|
for _, it := range items {
|
||||||
|
out = append(out, it...)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// functype encodes a WASM function type (0x60 prefix).
|
||||||
|
func functype(params, results []byte) []byte {
|
||||||
|
return cat([]byte{0x60}, u(uint64(len(params))), params, u(uint64(len(results))), results)
|
||||||
|
}
|
||||||
|
|
||||||
|
// importFunc encodes a function import entry.
|
||||||
|
func importFunc(mod, name string, typeIdx uint32) []byte {
|
||||||
|
return cat(wstr(mod), wstr(name), []byte{0x00}, u(uint64(typeIdx)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// exportEntry encodes an export entry.
|
||||||
|
func exportEntry(name string, kind byte, idx uint32) []byte {
|
||||||
|
return cat(wstr(name), []byte{kind}, u(uint64(idx)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// dataSegment encodes an active data segment for memory 0.
|
||||||
|
func dataSegment(offset int32, data []byte) []byte {
|
||||||
|
return cat(
|
||||||
|
[]byte{0x00}, // active segment, implicit mem 0
|
||||||
|
[]byte{0x41}, s(int64(offset)), []byte{0x0B}, // i32.const offset; end
|
||||||
|
u(uint64(len(data))), data,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// funcBody encodes one function body: localDecls + instructions + end.
|
||||||
|
func funcBody(localDecls []byte, instrs ...[]byte) []byte {
|
||||||
|
inner := cat(localDecls)
|
||||||
|
for _, ins := range instrs {
|
||||||
|
inner = append(inner, ins...)
|
||||||
|
}
|
||||||
|
inner = append(inner, 0x0B) // end
|
||||||
|
return cat(u(uint64(len(inner))), inner)
|
||||||
|
}
|
||||||
|
|
||||||
|
// noLocals is an empty local decl list.
|
||||||
|
var noLocals = u(0)
|
||||||
|
|
||||||
|
// localDecl encodes n locals of a given type.
|
||||||
|
func localDecl(n uint32, typ byte) []byte { return cat(u(uint64(n)), []byte{typ}) }
|
||||||
|
func withLocals(decls ...[]byte) []byte {
|
||||||
|
return cat(u(uint64(len(decls))), cat(decls...))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Instructions ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const (
|
||||||
|
tI32 byte = 0x7F
|
||||||
|
tI64 byte = 0x7E
|
||||||
|
)
|
||||||
|
|
||||||
|
func call(fn uint32) []byte { return cat([]byte{0x10}, u(uint64(fn))) }
|
||||||
|
func lget(i uint32) []byte { return cat([]byte{0x20}, u(uint64(i))) }
|
||||||
|
func lset(i uint32) []byte { return cat([]byte{0x21}, u(uint64(i))) }
|
||||||
|
func ic32(v int32) []byte { return cat([]byte{0x41}, s(int64(v))) }
|
||||||
|
func ic64(v int64) []byte { return cat([]byte{0x42}, s(v)) }
|
||||||
|
func block_() []byte { return []byte{0x02, 0x40} }
|
||||||
|
func loop_() []byte { return []byte{0x03, 0x40} }
|
||||||
|
func if_() []byte { return []byte{0x04, 0x40} }
|
||||||
|
func end_() []byte { return []byte{0x0B} }
|
||||||
|
func br_(lbl uint32) []byte { return cat([]byte{0x0C}, u(uint64(lbl))) }
|
||||||
|
func brIf_(lbl uint32) []byte { return cat([]byte{0x0D}, u(uint64(lbl))) }
|
||||||
|
func return_() []byte { return []byte{0x0F} }
|
||||||
|
func i32Eqz() []byte { return []byte{0x45} }
|
||||||
|
func i32Ne() []byte { return []byte{0x47} }
|
||||||
|
func i32GeU() []byte { return []byte{0x4F} }
|
||||||
|
func i32Add() []byte { return []byte{0x6A} }
|
||||||
|
func i64Add() []byte { return []byte{0x7C} }
|
||||||
|
func i32Load8U() []byte { return []byte{0x2D, 0x00, 0x00} } // align=0, offset=0
|
||||||
|
|
||||||
|
// ── Memory layout constants ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const (
|
||||||
|
offCounter = 0x00 // "counter" (7 bytes)
|
||||||
|
offOwner = 0x10 // "owner" (5 bytes)
|
||||||
|
offIncMsg = 0x20 // "incremented" (11 bytes)
|
||||||
|
offGetMsg = 0x30 // "get called" (10 bytes)
|
||||||
|
offResetOk = 0x40 // "reset ok" (8 bytes)
|
||||||
|
offUnauth = 0x50 // "unauthorized" (12 bytes)
|
||||||
|
offCallerBuf = 0x60 // caller buf (128 bytes)
|
||||||
|
offOwnerBuf = 0xE0 // owner buf (128 bytes)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Import function indices
|
||||||
|
const (
|
||||||
|
fnPutU64 = 0 // put_u64(keyPtr, keyLen i32, val i64)
|
||||||
|
fnGetU64 = 1 // get_u64(keyPtr, keyLen i32) → i64
|
||||||
|
fnLog = 2 // log(msgPtr, msgLen i32)
|
||||||
|
fnGetCaller = 3 // get_caller(bufPtr, bufLen i32) → i32
|
||||||
|
fnGetState = 4 // get_state(kPtr,kLen,dPtr,dLen i32) → i32
|
||||||
|
fnSetState = 5 // set_state(kPtr,kLen,vPtr,vLen i32)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Local function indices (imports are 0-5, locals start at 6)
|
||||||
|
const (
|
||||||
|
fnIncrement = 6
|
||||||
|
fnGet = 7
|
||||||
|
fnReset = 8
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
// ── Type section ─────────────────────────────────────────────────────────
|
||||||
|
// Type 0: (i32,i32,i64)→() put_u64
|
||||||
|
// Type 1: (i32,i32)→(i64) get_u64
|
||||||
|
// Type 2: (i32,i32)→() log
|
||||||
|
// Type 3: (i32,i32)→(i32) get_caller
|
||||||
|
// Type 4: (i32,i32,i32,i32)→(i32) get_state
|
||||||
|
// Type 5: (i32,i32,i32,i32)→() set_state
|
||||||
|
// Type 6: ()→() increment, get, reset
|
||||||
|
typeSection := section(0x01, vec(
|
||||||
|
functype([]byte{tI32, tI32, tI64}, []byte{}), // 0
|
||||||
|
functype([]byte{tI32, tI32}, []byte{tI64}), // 1
|
||||||
|
functype([]byte{tI32, tI32}, []byte{}), // 2
|
||||||
|
functype([]byte{tI32, tI32}, []byte{tI32}), // 3
|
||||||
|
functype([]byte{tI32, tI32, tI32, tI32}, []byte{tI32}), // 4
|
||||||
|
functype([]byte{tI32, tI32, tI32, tI32}, []byte{}), // 5
|
||||||
|
functype([]byte{}, []byte{}), // 6
|
||||||
|
))
|
||||||
|
|
||||||
|
// ── Import section ────────────────────────────────────────────────────────
|
||||||
|
importSection := section(0x02, vec(
|
||||||
|
importFunc("env", "put_u64", fnPutU64),
|
||||||
|
importFunc("env", "get_u64", fnGetU64),
|
||||||
|
importFunc("env", "log", fnLog),
|
||||||
|
importFunc("env", "get_caller", fnGetCaller),
|
||||||
|
importFunc("env", "get_state", fnGetState),
|
||||||
|
importFunc("env", "set_state", fnSetState),
|
||||||
|
))
|
||||||
|
|
||||||
|
// ── Function section: 3 local functions, all type 6 ──────────────────────
|
||||||
|
functionSection := section(0x03, vec(u(6), u(6), u(6)))
|
||||||
|
|
||||||
|
// ── Memory section: 1 page (64 KiB) ──────────────────────────────────────
|
||||||
|
// limits type 0x00 = min only; type 0x01 = min+max
|
||||||
|
memorySection := section(0x05, vec(cat([]byte{0x00}, u(1)))) // min=1, no max
|
||||||
|
|
||||||
|
// ── Export section ────────────────────────────────────────────────────────
|
||||||
|
exportSection := section(0x07, vec(
|
||||||
|
exportEntry("memory", 0x02, 0),
|
||||||
|
exportEntry("increment", 0x00, fnIncrement),
|
||||||
|
exportEntry("get", 0x00, fnGet),
|
||||||
|
exportEntry("reset", 0x00, fnReset),
|
||||||
|
))
|
||||||
|
|
||||||
|
// ── Data section ──────────────────────────────────────────────────────────
|
||||||
|
dataSection := section(0x0B, cat(
|
||||||
|
u(6), // 6 segments
|
||||||
|
dataSegment(offCounter, []byte("counter")),
|
||||||
|
dataSegment(offOwner, []byte("owner")),
|
||||||
|
dataSegment(offIncMsg, []byte("incremented")),
|
||||||
|
dataSegment(offGetMsg, []byte("get called")),
|
||||||
|
dataSegment(offResetOk, []byte("reset ok")),
|
||||||
|
dataSegment(offUnauth, []byte("unauthorized")),
|
||||||
|
))
|
||||||
|
|
||||||
|
// ── Code section ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// increment():
|
||||||
|
// local $val i64
|
||||||
|
// $val = get_u64("counter")
|
||||||
|
// $val++
|
||||||
|
// put_u64("counter", $val)
|
||||||
|
// log("incremented")
|
||||||
|
incrementBody := funcBody(
|
||||||
|
withLocals(localDecl(1, tI64)),
|
||||||
|
ic32(offCounter), ic32(7), call(fnGetU64), lset(0),
|
||||||
|
lget(0), ic64(1), i64Add(), lset(0),
|
||||||
|
ic32(offCounter), ic32(7), lget(0), call(fnPutU64),
|
||||||
|
ic32(offIncMsg), ic32(11), call(fnLog),
|
||||||
|
)
|
||||||
|
|
||||||
|
// get():
|
||||||
|
// log("get called")
|
||||||
|
getBody := funcBody(
|
||||||
|
noLocals,
|
||||||
|
ic32(offGetMsg), ic32(10), call(fnLog),
|
||||||
|
)
|
||||||
|
|
||||||
|
// reset():
|
||||||
|
// locals: callerLen(0), ownerLen(1), i(2), same(3) — all i32
|
||||||
|
// callerLen = get_caller(callerBuf, 128)
|
||||||
|
// ownerLen = get_state("owner", ownerBuf, 128)
|
||||||
|
// if ownerLen == 0:
|
||||||
|
// set_state("owner", callerBuf[:callerLen])
|
||||||
|
// put_u64("counter", 0)
|
||||||
|
// log("reset ok")
|
||||||
|
// return
|
||||||
|
// if callerLen != ownerLen: log unauthorized; return
|
||||||
|
// same = 1; i = 0
|
||||||
|
// block:
|
||||||
|
// loop:
|
||||||
|
// if i >= callerLen: br 1 (exit block)
|
||||||
|
// if callerBuf[i] != ownerBuf[i]: same=0; br 1
|
||||||
|
// i++; continue loop
|
||||||
|
// if !same: log unauthorized; return
|
||||||
|
// put_u64("counter", 0); log("reset ok")
|
||||||
|
resetBody := funcBody(
|
||||||
|
withLocals(localDecl(4, tI32)),
|
||||||
|
// callerLen = get_caller(callerBuf, 128)
|
||||||
|
ic32(offCallerBuf), ic32(128), call(fnGetCaller), lset(0),
|
||||||
|
// ownerLen = get_state("owner", 5, ownerBuf, 128)
|
||||||
|
ic32(offOwner), ic32(5), ic32(offOwnerBuf), ic32(128), call(fnGetState), lset(1),
|
||||||
|
// if ownerLen == 0:
|
||||||
|
lget(1), i32Eqz(), if_(),
|
||||||
|
ic32(offOwner), ic32(5), ic32(offCallerBuf), lget(0), call(fnSetState),
|
||||||
|
ic32(offCounter), ic32(7), ic64(0), call(fnPutU64),
|
||||||
|
ic32(offResetOk), ic32(8), call(fnLog),
|
||||||
|
return_(),
|
||||||
|
end_(),
|
||||||
|
// if callerLen != ownerLen: unauthorized
|
||||||
|
lget(0), lget(1), i32Ne(), if_(),
|
||||||
|
ic32(offUnauth), ic32(12), call(fnLog),
|
||||||
|
return_(),
|
||||||
|
end_(),
|
||||||
|
// same = 1; i = 0
|
||||||
|
ic32(1), lset(3),
|
||||||
|
ic32(0), lset(2),
|
||||||
|
// block $break
|
||||||
|
block_(),
|
||||||
|
loop_(),
|
||||||
|
lget(2), lget(0), i32GeU(), brIf_(1), // i >= callerLen → break
|
||||||
|
// load callerBuf[i]
|
||||||
|
ic32(offCallerBuf), lget(2), i32Add(), i32Load8U(),
|
||||||
|
// load ownerBuf[i]
|
||||||
|
ic32(offOwnerBuf), lget(2), i32Add(), i32Load8U(),
|
||||||
|
i32Ne(), if_(),
|
||||||
|
ic32(0), lset(3),
|
||||||
|
br_(2), // break out of block
|
||||||
|
end_(),
|
||||||
|
lget(2), ic32(1), i32Add(), lset(2),
|
||||||
|
br_(0), // continue loop
|
||||||
|
end_(),
|
||||||
|
end_(),
|
||||||
|
// if !same: unauthorized
|
||||||
|
lget(3), i32Eqz(), if_(),
|
||||||
|
ic32(offUnauth), ic32(12), call(fnLog),
|
||||||
|
return_(),
|
||||||
|
end_(),
|
||||||
|
// authorized
|
||||||
|
ic32(offCounter), ic32(7), ic64(0), call(fnPutU64),
|
||||||
|
ic32(offResetOk), ic32(8), call(fnLog),
|
||||||
|
)
|
||||||
|
|
||||||
|
codeSection := section(0x0A, cat(u(3), incrementBody, getBody, resetBody))
|
||||||
|
|
||||||
|
// ── Assemble module ───────────────────────────────────────────────────────
|
||||||
|
module := cat(
|
||||||
|
[]byte{0x00, 0x61, 0x73, 0x6d}, // magic \0asm
|
||||||
|
[]byte{0x01, 0x00, 0x00, 0x00}, // version 1
|
||||||
|
typeSection,
|
||||||
|
importSection,
|
||||||
|
functionSection,
|
||||||
|
memorySection,
|
||||||
|
exportSection,
|
||||||
|
dataSection,
|
||||||
|
codeSection,
|
||||||
|
)
|
||||||
|
|
||||||
|
out := "contracts/counter/counter.wasm"
|
||||||
|
if err := os.WriteFile(out, module, 0644); err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, "write:", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
fmt.Printf("Written %s (%d bytes)\n", out, len(module))
|
||||||
|
}
|
||||||
137
contracts/counter/main.go
Normal file
137
contracts/counter/main.go
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
// Counter smart contract — compiles to WASM with GOOS=wasip1 GOARCH=wasm.
|
||||||
|
//
|
||||||
|
// Methods (exported via //go:export):
|
||||||
|
// - increment — adds 1 to the stored counter
|
||||||
|
// - get — logs the current value (readable via /api/contracts/{id}/state/counter)
|
||||||
|
// - reset — resets counter to 0; only the first caller (owner) is allowed
|
||||||
|
//
|
||||||
|
// Host imports from the "env" module (see vm/host.go):
|
||||||
|
// - put_u64(keyPtr, keyLen, val) — stores uint64 as 8-byte big-endian
|
||||||
|
// - get_u64(keyPtr, keyLen) uint64 — reads 8-byte big-endian uint64
|
||||||
|
// - get_caller(buf, bufLen) int32 — writes caller pub key hex into buf
|
||||||
|
// - get_state(kPtr,kLen,dPtr,dLen) int32 — reads raw state bytes
|
||||||
|
// - set_state(kPtr,kLen,vPtr,vLen) — writes raw state bytes
|
||||||
|
// - log(msgPtr, msgLen) — emits message to node log
|
||||||
|
//
|
||||||
|
//go:build wasip1
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"unsafe"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── host function imports ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
//go:wasmimport env put_u64
|
||||||
|
func hostPutU64(keyPtr unsafe.Pointer, keyLen int32, val uint64)
|
||||||
|
|
||||||
|
//go:wasmimport env get_u64
|
||||||
|
func hostGetU64(keyPtr unsafe.Pointer, keyLen int32) uint64
|
||||||
|
|
||||||
|
//go:wasmimport env get_caller
|
||||||
|
func hostGetCaller(buf unsafe.Pointer, bufLen int32) int32
|
||||||
|
|
||||||
|
//go:wasmimport env get_state
|
||||||
|
func hostGetState(keyPtr unsafe.Pointer, keyLen int32, dstPtr unsafe.Pointer, dstLen int32) int32
|
||||||
|
|
||||||
|
//go:wasmimport env set_state
|
||||||
|
func hostSetState(keyPtr unsafe.Pointer, keyLen int32, valPtr unsafe.Pointer, valLen int32)
|
||||||
|
|
||||||
|
//go:wasmimport env log
|
||||||
|
func hostLog(msgPtr unsafe.Pointer, msgLen int32)
|
||||||
|
|
||||||
|
// ── helpers ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func logMsg(s string) {
|
||||||
|
if len(s) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
b := []byte(s)
|
||||||
|
hostLog(unsafe.Pointer(&b[0]), int32(len(b)))
|
||||||
|
}
|
||||||
|
|
||||||
|
func putU64(key string, val uint64) {
|
||||||
|
b := []byte(key)
|
||||||
|
hostPutU64(unsafe.Pointer(&b[0]), int32(len(b)), val)
|
||||||
|
}
|
||||||
|
|
||||||
|
func getU64(key string) uint64 {
|
||||||
|
b := []byte(key)
|
||||||
|
return hostGetU64(unsafe.Pointer(&b[0]), int32(len(b)))
|
||||||
|
}
|
||||||
|
|
||||||
|
func getState(key string, dst []byte) int32 {
|
||||||
|
kb := []byte(key)
|
||||||
|
return hostGetState(unsafe.Pointer(&kb[0]), int32(len(kb)),
|
||||||
|
unsafe.Pointer(&dst[0]), int32(len(dst)))
|
||||||
|
}
|
||||||
|
|
||||||
|
func setState(key string, val []byte) {
|
||||||
|
kb := []byte(key)
|
||||||
|
hostSetState(unsafe.Pointer(&kb[0]), int32(len(kb)),
|
||||||
|
unsafe.Pointer(&val[0]), int32(len(val)))
|
||||||
|
}
|
||||||
|
|
||||||
|
func getCaller() string {
|
||||||
|
buf := make([]byte, 128)
|
||||||
|
n := hostGetCaller(unsafe.Pointer(&buf[0]), int32(len(buf)))
|
||||||
|
if n <= 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return string(buf[:n])
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── contract state keys ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const (
|
||||||
|
keyCounter = "counter"
|
||||||
|
keyOwner = "owner"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── exported contract methods ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
//go:export increment
|
||||||
|
func increment() {
|
||||||
|
val := getU64(keyCounter)
|
||||||
|
val++
|
||||||
|
putU64(keyCounter, val)
|
||||||
|
logMsg("incremented")
|
||||||
|
}
|
||||||
|
|
||||||
|
//go:export get
|
||||||
|
func get() {
|
||||||
|
logMsg("get called")
|
||||||
|
}
|
||||||
|
|
||||||
|
//go:export reset
|
||||||
|
func reset() {
|
||||||
|
caller := getCaller()
|
||||||
|
if caller == "" {
|
||||||
|
logMsg("reset: no caller")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ownerBuf := make([]byte, 128)
|
||||||
|
ownerLen := getState(keyOwner, ownerBuf)
|
||||||
|
|
||||||
|
if ownerLen == 0 {
|
||||||
|
// No owner set yet — first caller becomes the owner.
|
||||||
|
setState(keyOwner, []byte(caller))
|
||||||
|
putU64(keyCounter, 0)
|
||||||
|
logMsg("reset ok (owner set)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
owner := string(ownerBuf[:ownerLen])
|
||||||
|
if caller != owner {
|
||||||
|
logMsg("reset: unauthorized")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
putU64(keyCounter, 0)
|
||||||
|
logMsg("reset ok")
|
||||||
|
}
|
||||||
|
|
||||||
|
// main is required by the Go runtime for wasip1 programs.
|
||||||
|
func main() {}
|
||||||
BIN
contracts/escrow/escrow.wasm
Normal file
BIN
contracts/escrow/escrow.wasm
Normal file
Binary file not shown.
57
contracts/escrow/escrow_abi.json
Normal file
57
contracts/escrow/escrow_abi.json
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
{
|
||||||
|
"contract": "escrow",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Two-party trustless escrow. Buyer deposits funds into the contract treasury. Seller delivers; buyer releases. If disputed, the contract admin resolves.",
|
||||||
|
"methods": [
|
||||||
|
{
|
||||||
|
"name": "init",
|
||||||
|
"description": "Set the caller as the escrow admin. Call once after deployment.",
|
||||||
|
"args": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "create",
|
||||||
|
"description": "Buyer creates an escrow. Transfers amount from buyer to treasury. Logs 'created: <id>'.",
|
||||||
|
"args": [
|
||||||
|
{"name": "id", "type": "string", "description": "Unique escrow ID (user-supplied)"},
|
||||||
|
{"name": "seller", "type": "string", "description": "Seller address (hex pubkey)"},
|
||||||
|
{"name": "amount", "type": "uint64", "description": "Amount in µT to lock in escrow"}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "release",
|
||||||
|
"description": "Buyer releases funds to seller. Transfers treasury → seller. Logs 'released: <id>'.",
|
||||||
|
"args": [
|
||||||
|
{"name": "id", "type": "string", "description": "Escrow ID"}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "refund",
|
||||||
|
"description": "Seller refunds the buyer (voluntary). Transfers treasury → buyer. Logs 'refunded: <id>'.",
|
||||||
|
"args": [
|
||||||
|
{"name": "id", "type": "string", "description": "Escrow ID"}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "dispute",
|
||||||
|
"description": "Buyer or seller raises a dispute. Logs 'disputed: <id>'. Admin must then call resolve.",
|
||||||
|
"args": [
|
||||||
|
{"name": "id", "type": "string", "description": "Escrow ID"}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "resolve",
|
||||||
|
"description": "Admin resolves a disputed escrow. winner must be 'buyer' or 'seller'. Logs 'resolved: <id>'.",
|
||||||
|
"args": [
|
||||||
|
{"name": "id", "type": "string", "description": "Escrow ID"},
|
||||||
|
{"name": "winner", "type": "string", "description": "'buyer' or 'seller'"}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "info",
|
||||||
|
"description": "Log escrow details: buyer, seller, amount, status.",
|
||||||
|
"args": [
|
||||||
|
{"name": "id", "type": "string", "description": "Escrow ID"}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
818
contracts/escrow/gen/main.go
Normal file
818
contracts/escrow/gen/main.go
Normal file
@@ -0,0 +1,818 @@
|
|||||||
|
// gen generates contracts/escrow/escrow.wasm
|
||||||
|
// Run from repo root: go run ./contracts/escrow/gen/
|
||||||
|
//
|
||||||
|
// Methods: init, create, release, refund, dispute, resolve, info
|
||||||
|
//
|
||||||
|
// State keys (per escrow id):
|
||||||
|
// "admin" → admin address
|
||||||
|
// "e:<id>:b" → buyer address
|
||||||
|
// "e:<id>:s" → seller address
|
||||||
|
// "e:<id>:v" → amount (8-byte big-endian u64)
|
||||||
|
// "e:<id>:x" → status byte:
|
||||||
|
// 'a' = active (funds locked)
|
||||||
|
// 'd' = disputed
|
||||||
|
// 'r' = released to seller
|
||||||
|
// 'f' = refunded to buyer
|
||||||
|
//
|
||||||
|
// The contract treasury holds the locked funds during active/disputed state.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── LEB128 & builders ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func u(v uint64) []byte {
|
||||||
|
var b []byte
|
||||||
|
for {
|
||||||
|
bt := byte(v & 0x7f)
|
||||||
|
v >>= 7
|
||||||
|
if v != 0 {
|
||||||
|
bt |= 0x80
|
||||||
|
}
|
||||||
|
b = append(b, bt)
|
||||||
|
if v == 0 {
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func s(v int64) []byte {
|
||||||
|
var b []byte
|
||||||
|
for {
|
||||||
|
bt := byte(v & 0x7f)
|
||||||
|
v >>= 7
|
||||||
|
sign := (bt & 0x40) != 0
|
||||||
|
if (v == 0 && !sign) || (v == -1 && sign) {
|
||||||
|
return append(b, bt)
|
||||||
|
}
|
||||||
|
b = append(b, bt|0x80)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func cat(slices ...[]byte) []byte {
|
||||||
|
var out []byte
|
||||||
|
for _, sl := range slices {
|
||||||
|
out = append(out, sl...)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func wstr(str string) []byte { return cat(u(uint64(len(str))), []byte(str)) }
|
||||||
|
|
||||||
|
func section(id byte, content []byte) []byte {
|
||||||
|
return cat([]byte{id}, u(uint64(len(content))), content)
|
||||||
|
}
|
||||||
|
|
||||||
|
func vec(items ...[]byte) []byte {
|
||||||
|
out := u(uint64(len(items)))
|
||||||
|
for _, it := range items {
|
||||||
|
out = append(out, it...)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func functype(params, results []byte) []byte {
|
||||||
|
return cat([]byte{0x60}, u(uint64(len(params))), params, u(uint64(len(results))), results)
|
||||||
|
}
|
||||||
|
|
||||||
|
func importFunc(mod, name string, typeIdx uint32) []byte {
|
||||||
|
return cat(wstr(mod), wstr(name), []byte{0x00}, u(uint64(typeIdx)))
|
||||||
|
}
|
||||||
|
|
||||||
|
func exportEntry(name string, kind byte, idx uint32) []byte {
|
||||||
|
return cat(wstr(name), []byte{kind}, u(uint64(idx)))
|
||||||
|
}
|
||||||
|
|
||||||
|
func dataSegment(offset int32, data []byte) []byte {
|
||||||
|
return cat(
|
||||||
|
[]byte{0x00},
|
||||||
|
[]byte{0x41}, s(int64(offset)), []byte{0x0B},
|
||||||
|
u(uint64(len(data))), data,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func funcBody(localDecls []byte, instrs ...[]byte) []byte {
|
||||||
|
inner := cat(localDecls)
|
||||||
|
for _, ins := range instrs {
|
||||||
|
inner = append(inner, ins...)
|
||||||
|
}
|
||||||
|
inner = append(inner, 0x0B)
|
||||||
|
return cat(u(uint64(len(inner))), inner)
|
||||||
|
}
|
||||||
|
|
||||||
|
var noLocals = u(0)
|
||||||
|
|
||||||
|
func localDecl(n uint32, typ byte) []byte { return cat(u(uint64(n)), []byte{typ}) }
|
||||||
|
func withLocals(decls ...[]byte) []byte {
|
||||||
|
return cat(u(uint64(len(decls))), cat(decls...))
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
tI32 byte = 0x7F
|
||||||
|
tI64 byte = 0x7E
|
||||||
|
)
|
||||||
|
|
||||||
|
func call(fn uint32) []byte { return cat([]byte{0x10}, u(uint64(fn))) }
|
||||||
|
func lget(i uint32) []byte { return cat([]byte{0x20}, u(uint64(i))) }
|
||||||
|
func lset(i uint32) []byte { return cat([]byte{0x21}, u(uint64(i))) }
|
||||||
|
func ic32(v int32) []byte { return cat([]byte{0x41}, s(int64(v))) }
|
||||||
|
func ic64(v int64) []byte { return cat([]byte{0x42}, s(v)) }
|
||||||
|
func block_() []byte { return []byte{0x02, 0x40} }
|
||||||
|
func loop_() []byte { return []byte{0x03, 0x40} }
|
||||||
|
func if_() []byte { return []byte{0x04, 0x40} }
|
||||||
|
func ifI32() []byte { return []byte{0x04, tI32} }
|
||||||
|
func else_() []byte { return []byte{0x05} }
|
||||||
|
func end_() []byte { return []byte{0x0B} }
|
||||||
|
func br_(lbl uint32) []byte { return cat([]byte{0x0C}, u(uint64(lbl))) }
|
||||||
|
func brIf_(lbl uint32) []byte { return cat([]byte{0x0D}, u(uint64(lbl))) }
|
||||||
|
func return_() []byte { return []byte{0x0F} }
|
||||||
|
func drop() []byte { return []byte{0x1A} }
|
||||||
|
func i32Eqz() []byte { return []byte{0x45} }
|
||||||
|
func i32Ne() []byte { return []byte{0x47} }
|
||||||
|
func i32GtU() []byte { return []byte{0x4B} }
|
||||||
|
func i32GeU() []byte { return []byte{0x4F} }
|
||||||
|
func i32Add() []byte { return []byte{0x6A} }
|
||||||
|
func i64Eqz() []byte { return []byte{0x50} }
|
||||||
|
func i32Load8U() []byte { return []byte{0x2D, 0x00, 0x00} }
|
||||||
|
func i32Store8() []byte { return []byte{0x3A, 0x00, 0x00} }
|
||||||
|
|
||||||
|
// ── Memory layout ─────────────────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// 0x000 64 arg[0]: escrow id
|
||||||
|
// 0x040 128 arg[1]: seller address or winner string
|
||||||
|
// 0x140 128 caller buffer
|
||||||
|
// 0x1C0 64 treasury buffer
|
||||||
|
// 0x200 128 state-read buffer 1 (buyer or seller)
|
||||||
|
// 0x280 128 state-read buffer 2 (seller or admin)
|
||||||
|
// 0x300 2 status byte buffer
|
||||||
|
// 0x310 256 scratch (buildField key workspace)
|
||||||
|
//
|
||||||
|
// Constants at 0x500+:
|
||||||
|
// 0x500 5 "admin"
|
||||||
|
// 0x506 13 "initialized: "
|
||||||
|
// 0x514 10 "created: "
|
||||||
|
// 0x51E 10 "released: "
|
||||||
|
// 0x529 10 "refunded: "
|
||||||
|
// 0x534 10 "disputed: "
|
||||||
|
// 0x53F 10 "resolved: "
|
||||||
|
// 0x54A 14 "unauthorized: "
|
||||||
|
// 0x559 11 "not found: "
|
||||||
|
// 0x565 14 "already init: "
|
||||||
|
// 0x574 12 "not active: "
|
||||||
|
// 0x581 14 "not disputed: "
|
||||||
|
// 0x590 7 "buyer: "
|
||||||
|
// 0x598 8 "seller: "
|
||||||
|
// 0x5A1 8 "status: "
|
||||||
|
// 0x5AA 7 "active"
|
||||||
|
// 0x5B1 9 "disputed"
|
||||||
|
// 0x5BB 9 "released"
|
||||||
|
// 0x5C5 8 "refunded"
|
||||||
|
// 0x5CE 6 "buyer" (for resolve winner comparison)
|
||||||
|
// 0x5D4 6 "seller"
|
||||||
|
|
||||||
|
const (
|
||||||
|
offArg0 int32 = 0x000
|
||||||
|
offArg1 int32 = 0x040
|
||||||
|
offCaller int32 = 0x140
|
||||||
|
offTreasury int32 = 0x1C0
|
||||||
|
offRead1 int32 = 0x200
|
||||||
|
offRead2 int32 = 0x280
|
||||||
|
offStatBuf int32 = 0x300
|
||||||
|
offScratch int32 = 0x310
|
||||||
|
|
||||||
|
offKeyAdmin int32 = 0x500
|
||||||
|
offInitedPfx int32 = 0x506
|
||||||
|
offCreatedPfx int32 = 0x514
|
||||||
|
offReleasedPfx int32 = 0x51E
|
||||||
|
offRefundedPfx int32 = 0x529
|
||||||
|
offDisputedPfx int32 = 0x534
|
||||||
|
offResolvedPfx int32 = 0x53F
|
||||||
|
offUnauthPfx int32 = 0x54A
|
||||||
|
offNotFoundPfx int32 = 0x559
|
||||||
|
offAlreadyPfx int32 = 0x565
|
||||||
|
offNotActivePfx int32 = 0x574
|
||||||
|
offNotDispPfx int32 = 0x581
|
||||||
|
offBuyerPfx int32 = 0x590
|
||||||
|
offSellerPfx int32 = 0x598
|
||||||
|
offStatusPfx int32 = 0x5A1
|
||||||
|
offStrActive int32 = 0x5AA
|
||||||
|
offStrDisputed int32 = 0x5B1
|
||||||
|
offStrReleased int32 = 0x5BB
|
||||||
|
offStrRefunded int32 = 0x5C5
|
||||||
|
offStrBuyer int32 = 0x5CE
|
||||||
|
offStrSeller int32 = 0x5D4
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── Import / function indices ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const (
|
||||||
|
fnGetArgStr = 0
|
||||||
|
fnGetArgU64 = 1
|
||||||
|
fnGetCaller = 2
|
||||||
|
fnGetState = 3
|
||||||
|
fnSetState = 4
|
||||||
|
fnLog = 5
|
||||||
|
fnTransfer = 6
|
||||||
|
fnGetContractTreasury = 7
|
||||||
|
fnPutU64 = 8
|
||||||
|
fnGetU64 = 9
|
||||||
|
|
||||||
|
fnBytesEqual = 10
|
||||||
|
fnMemcpy = 11
|
||||||
|
fnLogPrefix = 12
|
||||||
|
fnBuildField = 13 // (idOff, idLen, fieldChar i32) → keyLen i32
|
||||||
|
fnInit = 14
|
||||||
|
fnCreate = 15
|
||||||
|
fnRelease = 16
|
||||||
|
fnRefund = 17
|
||||||
|
fnDispute = 18
|
||||||
|
fnResolve = 19
|
||||||
|
fnInfo = 20
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── Helper bodies ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func bytesEqualBody() []byte {
|
||||||
|
return funcBody(
|
||||||
|
withLocals(localDecl(2, tI32)),
|
||||||
|
ic32(1), lset(4), ic32(0), lset(3),
|
||||||
|
block_(), loop_(),
|
||||||
|
lget(3), lget(2), i32GeU(), brIf_(1),
|
||||||
|
lget(0), lget(3), i32Add(), i32Load8U(),
|
||||||
|
lget(1), lget(3), i32Add(), i32Load8U(),
|
||||||
|
i32Ne(), if_(), ic32(0), lset(4), br_(2), end_(),
|
||||||
|
lget(3), ic32(1), i32Add(), lset(3),
|
||||||
|
br_(0), end_(), end_(),
|
||||||
|
lget(4),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func memcpyBody() []byte {
|
||||||
|
return funcBody(
|
||||||
|
withLocals(localDecl(1, tI32)),
|
||||||
|
ic32(0), lset(3),
|
||||||
|
block_(), loop_(),
|
||||||
|
lget(3), lget(2), i32GeU(), brIf_(1),
|
||||||
|
lget(0), lget(3), i32Add(),
|
||||||
|
lget(1), lget(3), i32Add(), i32Load8U(),
|
||||||
|
i32Store8(),
|
||||||
|
lget(3), ic32(1), i32Add(), lset(3),
|
||||||
|
br_(0), end_(), end_(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func logPrefixBody() []byte {
|
||||||
|
// (prefixPtr, prefixLen, suffixPtr, suffixLen i32)
|
||||||
|
return funcBody(
|
||||||
|
noLocals,
|
||||||
|
ic32(offScratch), lget(0), lget(1), call(fnMemcpy),
|
||||||
|
ic32(offScratch), lget(1), i32Add(), lget(2), lget(3), call(fnMemcpy),
|
||||||
|
ic32(offScratch), lget(1), lget(3), i32Add(), call(fnLog),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildField(idOff, idLen, fieldChar i32) → keyLen i32
|
||||||
|
// Writes "e:<id>:<fieldChar>" into offScratch.
|
||||||
|
func buildFieldBody() []byte {
|
||||||
|
return funcBody(
|
||||||
|
noLocals,
|
||||||
|
ic32(offScratch), ic32('e'), i32Store8(),
|
||||||
|
ic32(offScratch+1), ic32(':'), i32Store8(),
|
||||||
|
ic32(offScratch+2), lget(0), lget(1), call(fnMemcpy),
|
||||||
|
ic32(offScratch+2), lget(1), i32Add(), ic32(':'), i32Store8(),
|
||||||
|
ic32(offScratch+3), lget(1), i32Add(), lget(2), i32Store8(),
|
||||||
|
lget(1), ic32(4), i32Add(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// isCallerAdminCode: reads admin into offRead2, checks caller == admin.
|
||||||
|
// Params: callerLen local index, adminLen local index.
|
||||||
|
// Returns inline code that leaves i32 (1=is admin) on stack.
|
||||||
|
func isCallerAdminCode(callerLenLocal, adminLenLocal uint32) []byte {
|
||||||
|
return cat(
|
||||||
|
ic32(offKeyAdmin), ic32(5), ic32(offRead2), ic32(128), call(fnGetState), lset(adminLenLocal),
|
||||||
|
lget(adminLenLocal), i32Eqz(),
|
||||||
|
ifI32(), ic32(0), else_(),
|
||||||
|
lget(callerLenLocal), lget(adminLenLocal), i32Ne(),
|
||||||
|
ifI32(), ic32(0), else_(),
|
||||||
|
ic32(offCaller), ic32(offRead2), lget(callerLenLocal), call(fnBytesEqual),
|
||||||
|
end_(), end_(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Contract method bodies ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// init() — set caller as admin
|
||||||
|
// Locals: callerLen(0), existingLen(1)
|
||||||
|
func initBody() []byte {
|
||||||
|
return funcBody(
|
||||||
|
withLocals(localDecl(2, tI32)),
|
||||||
|
ic32(offCaller), ic32(128), call(fnGetCaller), lset(0),
|
||||||
|
ic32(offKeyAdmin), ic32(5), ic32(offRead2), ic32(128), call(fnGetState), lset(1),
|
||||||
|
lget(1), ic32(0), i32GtU(), if_(),
|
||||||
|
ic32(offAlreadyPfx), ic32(14), ic32(offCaller), lget(0), call(fnLogPrefix),
|
||||||
|
return_(),
|
||||||
|
end_(),
|
||||||
|
ic32(offKeyAdmin), ic32(5), ic32(offCaller), lget(0), call(fnSetState),
|
||||||
|
ic32(offInitedPfx), ic32(13), ic32(offCaller), lget(0), call(fnLogPrefix),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// create(id, seller, amount u64)
|
||||||
|
// Locals: idLen(0), sellerLen(1), callerLen(2), treasuryLen(3), keyLen(4) [i32]
|
||||||
|
// amount(5) [i64]
|
||||||
|
func createBody() []byte {
|
||||||
|
return funcBody(
|
||||||
|
withLocals(localDecl(5, tI32), localDecl(1, tI64)),
|
||||||
|
ic32(0), ic32(offArg0), ic32(64), call(fnGetArgStr), lset(0),
|
||||||
|
lget(0), i32Eqz(), if_(), return_(), end_(),
|
||||||
|
ic32(1), ic32(offArg1), ic32(128), call(fnGetArgStr), lset(1),
|
||||||
|
lget(1), i32Eqz(), if_(), return_(), end_(),
|
||||||
|
ic32(2), call(fnGetArgU64), lset(5),
|
||||||
|
|
||||||
|
// Check id not already used
|
||||||
|
ic32(offArg0), lget(0), ic32('x'), call(fnBuildField), lset(4),
|
||||||
|
ic32(offScratch), lget(4), ic32(offStatBuf), ic32(2), call(fnGetState),
|
||||||
|
ic32(0), i32GtU(), if_(),
|
||||||
|
ic32(offNotFoundPfx), ic32(11), ic32(offArg0), lget(0), call(fnLogPrefix),
|
||||||
|
return_(),
|
||||||
|
end_(),
|
||||||
|
|
||||||
|
// Get caller (buyer) and treasury
|
||||||
|
ic32(offCaller), ic32(128), call(fnGetCaller), lset(2),
|
||||||
|
ic32(offTreasury), ic32(64), call(fnGetContractTreasury), lset(3),
|
||||||
|
|
||||||
|
// Transfer amount: buyer → treasury
|
||||||
|
ic32(offCaller), lget(2), ic32(offTreasury), lget(3), lget(5), call(fnTransfer), drop(),
|
||||||
|
|
||||||
|
// Write buyer: e:<id>:b → caller
|
||||||
|
ic32(offArg0), lget(0), ic32('b'), call(fnBuildField), lset(4),
|
||||||
|
ic32(offScratch), lget(4), ic32(offCaller), lget(2), call(fnSetState),
|
||||||
|
|
||||||
|
// Write seller: e:<id>:s → arg1
|
||||||
|
ic32(offArg0), lget(0), ic32('s'), call(fnBuildField), lset(4),
|
||||||
|
ic32(offScratch), lget(4), ic32(offArg1), lget(1), call(fnSetState),
|
||||||
|
|
||||||
|
// Write amount: e:<id>:v
|
||||||
|
ic32(offArg0), lget(0), ic32('v'), call(fnBuildField), lset(4),
|
||||||
|
ic32(offScratch), lget(4), lget(5), call(fnPutU64),
|
||||||
|
|
||||||
|
// Write status = 'a': e:<id>:x
|
||||||
|
ic32(offStatBuf), ic32('a'), i32Store8(),
|
||||||
|
ic32(offArg0), lget(0), ic32('x'), call(fnBuildField), lset(4),
|
||||||
|
ic32(offScratch), lget(4), ic32(offStatBuf), ic32(1), call(fnSetState),
|
||||||
|
|
||||||
|
ic32(offCreatedPfx), ic32(9), ic32(offArg0), lget(0), call(fnLogPrefix),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkActiveStatus: reads status byte into offStatBuf; returns inline code
|
||||||
|
// that returns with an error log if status != expectedChar.
|
||||||
|
func checkStatus(idLenLocal uint32, expectedChar int32, errPfxOff int32, errPfxLen int32) []byte {
|
||||||
|
return cat(
|
||||||
|
ic32(offArg0), lget(idLenLocal), ic32('x'), call(fnBuildField),
|
||||||
|
// keyLen on stack — but we need it in a local. Use the call result directly:
|
||||||
|
// Actually we need to save it. This is getting complex. Let me inline differently.
|
||||||
|
// Instead, we'll read status without saving keyLen — just check immediately.
|
||||||
|
// Actually the fn returns keyLen on stack. We need (keyPtr, keyLen, dstPtr, dstLen) for get_state.
|
||||||
|
// So: ic32(offScratch), [keyLen], ic32(offStatBuf), ic32(2), call(fnGetState)
|
||||||
|
// But keyLen is already on the stack after buildField!
|
||||||
|
// We can do: ic32(offScratch), <stack: keyLen>, ...
|
||||||
|
// No wait, stack ordering: we push ic32(offScratch) BEFORE the keyLen.
|
||||||
|
// For get_state(keyPtr, keyLen, dstPtr, dstLen):
|
||||||
|
// We need: keyPtr first, then keyLen.
|
||||||
|
// buildField leaves keyLen on stack, but we need keyPtr first.
|
||||||
|
// This is the fundamental issue: the keyPtr (offScratch) needs to be pushed before keyLen.
|
||||||
|
//
|
||||||
|
// Fix: save keyLen to a local first, then push offScratch, keyLen.
|
||||||
|
// But we don't have a spare local in this helper...
|
||||||
|
//
|
||||||
|
// For now, let me just not use this helper function and inline the check in each method.
|
||||||
|
ic32(0), // placeholder - don't use this helper
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// release(id) — buyer releases funds to seller
|
||||||
|
// Locals: idLen(0), buyerLen(1), sellerLen(2), callerLen(3), treasuryLen(4), keyLen(5) [i32]
|
||||||
|
// amount(6) [i64]
|
||||||
|
func releaseBody() []byte {
|
||||||
|
return funcBody(
|
||||||
|
withLocals(localDecl(6, tI32), localDecl(1, tI64)),
|
||||||
|
ic32(0), ic32(offArg0), ic32(64), call(fnGetArgStr), lset(0),
|
||||||
|
lget(0), i32Eqz(), if_(), return_(), end_(),
|
||||||
|
|
||||||
|
// Check status == 'a'
|
||||||
|
ic32(offArg0), lget(0), ic32('x'), call(fnBuildField), lset(5),
|
||||||
|
ic32(offScratch), lget(5), ic32(offStatBuf), ic32(2), call(fnGetState),
|
||||||
|
ic32(0), i32Ne(), if_(),
|
||||||
|
ic32(offStatBuf), i32Load8U(), ic32('a'), i32Ne(), if_(),
|
||||||
|
ic32(offNotActivePfx), ic32(12), ic32(offArg0), lget(0), call(fnLogPrefix),
|
||||||
|
return_(),
|
||||||
|
end_(),
|
||||||
|
else_(),
|
||||||
|
ic32(offNotFoundPfx), ic32(11), ic32(offArg0), lget(0), call(fnLogPrefix),
|
||||||
|
return_(),
|
||||||
|
end_(),
|
||||||
|
|
||||||
|
// Check caller == buyer
|
||||||
|
ic32(offCaller), ic32(128), call(fnGetCaller), lset(3),
|
||||||
|
ic32(offArg0), lget(0), ic32('b'), call(fnBuildField), lset(5),
|
||||||
|
ic32(offScratch), lget(5), ic32(offRead1), ic32(128), call(fnGetState), lset(1),
|
||||||
|
lget(3), lget(1), i32Ne(), if_(),
|
||||||
|
ic32(offUnauthPfx), ic32(14), ic32(offArg0), lget(0), call(fnLogPrefix),
|
||||||
|
return_(),
|
||||||
|
end_(),
|
||||||
|
ic32(offCaller), ic32(offRead1), lget(3), call(fnBytesEqual),
|
||||||
|
i32Eqz(), if_(),
|
||||||
|
ic32(offUnauthPfx), ic32(14), ic32(offArg0), lget(0), call(fnLogPrefix),
|
||||||
|
return_(),
|
||||||
|
end_(),
|
||||||
|
|
||||||
|
// Get seller, treasury, amount
|
||||||
|
ic32(offArg0), lget(0), ic32('s'), call(fnBuildField), lset(5),
|
||||||
|
ic32(offScratch), lget(5), ic32(offRead2), ic32(128), call(fnGetState), lset(2),
|
||||||
|
ic32(offTreasury), ic32(64), call(fnGetContractTreasury), lset(4),
|
||||||
|
ic32(offArg0), lget(0), ic32('v'), call(fnBuildField), lset(5),
|
||||||
|
ic32(offScratch), lget(5), call(fnGetU64), lset(6),
|
||||||
|
|
||||||
|
// Transfer: treasury → seller
|
||||||
|
ic32(offTreasury), lget(4), ic32(offRead2), lget(2), lget(6), call(fnTransfer), drop(),
|
||||||
|
|
||||||
|
// Mark status = 'r'
|
||||||
|
ic32(offStatBuf), ic32('r'), i32Store8(),
|
||||||
|
ic32(offArg0), lget(0), ic32('x'), call(fnBuildField), lset(5),
|
||||||
|
ic32(offScratch), lget(5), ic32(offStatBuf), ic32(1), call(fnSetState),
|
||||||
|
|
||||||
|
ic32(offReleasedPfx), ic32(10), ic32(offArg0), lget(0), call(fnLogPrefix),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// refund(id) — seller voluntarily refunds buyer
|
||||||
|
// Locals: idLen(0), sellerLen(1), buyerLen(2), callerLen(3), treasuryLen(4), keyLen(5) [i32]
|
||||||
|
// amount(6) [i64]
|
||||||
|
func refundBody() []byte {
|
||||||
|
return funcBody(
|
||||||
|
withLocals(localDecl(6, tI32), localDecl(1, tI64)),
|
||||||
|
ic32(0), ic32(offArg0), ic32(64), call(fnGetArgStr), lset(0),
|
||||||
|
lget(0), i32Eqz(), if_(), return_(), end_(),
|
||||||
|
|
||||||
|
// Check status == 'a'
|
||||||
|
ic32(offArg0), lget(0), ic32('x'), call(fnBuildField), lset(5),
|
||||||
|
ic32(offScratch), lget(5), ic32(offStatBuf), ic32(2), call(fnGetState),
|
||||||
|
ic32(0), i32Ne(), if_(),
|
||||||
|
ic32(offStatBuf), i32Load8U(), ic32('a'), i32Ne(), if_(),
|
||||||
|
ic32(offNotActivePfx), ic32(12), ic32(offArg0), lget(0), call(fnLogPrefix),
|
||||||
|
return_(),
|
||||||
|
end_(),
|
||||||
|
else_(),
|
||||||
|
ic32(offNotFoundPfx), ic32(11), ic32(offArg0), lget(0), call(fnLogPrefix),
|
||||||
|
return_(),
|
||||||
|
end_(),
|
||||||
|
|
||||||
|
// Check caller == seller
|
||||||
|
ic32(offCaller), ic32(128), call(fnGetCaller), lset(3),
|
||||||
|
ic32(offArg0), lget(0), ic32('s'), call(fnBuildField), lset(5),
|
||||||
|
ic32(offScratch), lget(5), ic32(offRead1), ic32(128), call(fnGetState), lset(1),
|
||||||
|
lget(3), lget(1), i32Ne(), if_(),
|
||||||
|
ic32(offUnauthPfx), ic32(14), ic32(offArg0), lget(0), call(fnLogPrefix),
|
||||||
|
return_(),
|
||||||
|
end_(),
|
||||||
|
ic32(offCaller), ic32(offRead1), lget(3), call(fnBytesEqual),
|
||||||
|
i32Eqz(), if_(),
|
||||||
|
ic32(offUnauthPfx), ic32(14), ic32(offArg0), lget(0), call(fnLogPrefix),
|
||||||
|
return_(),
|
||||||
|
end_(),
|
||||||
|
|
||||||
|
// Get buyer, treasury, amount
|
||||||
|
ic32(offArg0), lget(0), ic32('b'), call(fnBuildField), lset(5),
|
||||||
|
ic32(offScratch), lget(5), ic32(offRead2), ic32(128), call(fnGetState), lset(2),
|
||||||
|
ic32(offTreasury), ic32(64), call(fnGetContractTreasury), lset(4),
|
||||||
|
ic32(offArg0), lget(0), ic32('v'), call(fnBuildField), lset(5),
|
||||||
|
ic32(offScratch), lget(5), call(fnGetU64), lset(6),
|
||||||
|
|
||||||
|
// Transfer: treasury → buyer
|
||||||
|
ic32(offTreasury), lget(4), ic32(offRead2), lget(2), lget(6), call(fnTransfer), drop(),
|
||||||
|
|
||||||
|
// Mark status = 'f'
|
||||||
|
ic32(offStatBuf), ic32('f'), i32Store8(),
|
||||||
|
ic32(offArg0), lget(0), ic32('x'), call(fnBuildField), lset(5),
|
||||||
|
ic32(offScratch), lget(5), ic32(offStatBuf), ic32(1), call(fnSetState),
|
||||||
|
|
||||||
|
ic32(offRefundedPfx), ic32(10), ic32(offArg0), lget(0), call(fnLogPrefix),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// dispute(id) — buyer or seller raises a dispute
|
||||||
|
// Locals: idLen(0), callerLen(1), buyerLen(2), sellerLen(3), keyLen(4) [i32]
|
||||||
|
func disputeBody() []byte {
|
||||||
|
return funcBody(
|
||||||
|
withLocals(localDecl(5, tI32)),
|
||||||
|
ic32(0), ic32(offArg0), ic32(64), call(fnGetArgStr), lset(0),
|
||||||
|
lget(0), i32Eqz(), if_(), return_(), end_(),
|
||||||
|
|
||||||
|
// Check status == 'a'
|
||||||
|
ic32(offArg0), lget(0), ic32('x'), call(fnBuildField), lset(4),
|
||||||
|
ic32(offScratch), lget(4), ic32(offStatBuf), ic32(2), call(fnGetState),
|
||||||
|
ic32(0), i32Ne(), if_(),
|
||||||
|
ic32(offStatBuf), i32Load8U(), ic32('a'), i32Ne(), if_(),
|
||||||
|
ic32(offNotActivePfx), ic32(12), ic32(offArg0), lget(0), call(fnLogPrefix),
|
||||||
|
return_(),
|
||||||
|
end_(),
|
||||||
|
else_(),
|
||||||
|
ic32(offNotFoundPfx), ic32(11), ic32(offArg0), lget(0), call(fnLogPrefix),
|
||||||
|
return_(),
|
||||||
|
end_(),
|
||||||
|
|
||||||
|
// Caller must be buyer or seller
|
||||||
|
ic32(offCaller), ic32(128), call(fnGetCaller), lset(1),
|
||||||
|
ic32(offArg0), lget(0), ic32('b'), call(fnBuildField), lset(4),
|
||||||
|
ic32(offScratch), lget(4), ic32(offRead1), ic32(128), call(fnGetState), lset(2),
|
||||||
|
ic32(offArg0), lget(0), ic32('s'), call(fnBuildField), lset(4),
|
||||||
|
ic32(offScratch), lget(4), ic32(offRead2), ic32(128), call(fnGetState), lset(3),
|
||||||
|
|
||||||
|
// isBuyer = callerLen==buyerLen && bytes_equal(caller, buyer, callerLen)
|
||||||
|
// isSeller = callerLen==sellerLen && bytes_equal(caller, seller, callerLen)
|
||||||
|
// if !isBuyer && !isSeller: unauthorized
|
||||||
|
lget(1), lget(2), i32Ne(),
|
||||||
|
if_(),
|
||||||
|
// callerLen != buyerLen → check seller
|
||||||
|
lget(1), lget(3), i32Ne(), if_(),
|
||||||
|
ic32(offUnauthPfx), ic32(14), ic32(offArg0), lget(0), call(fnLogPrefix),
|
||||||
|
return_(),
|
||||||
|
end_(),
|
||||||
|
ic32(offCaller), ic32(offRead2), lget(1), call(fnBytesEqual),
|
||||||
|
i32Eqz(), if_(),
|
||||||
|
ic32(offUnauthPfx), ic32(14), ic32(offArg0), lget(0), call(fnLogPrefix),
|
||||||
|
return_(),
|
||||||
|
end_(),
|
||||||
|
else_(),
|
||||||
|
// callerLen == buyerLen → check bytes
|
||||||
|
ic32(offCaller), ic32(offRead1), lget(1), call(fnBytesEqual),
|
||||||
|
i32Eqz(), if_(),
|
||||||
|
// not buyer — check seller
|
||||||
|
lget(1), lget(3), i32Ne(), if_(),
|
||||||
|
ic32(offUnauthPfx), ic32(14), ic32(offArg0), lget(0), call(fnLogPrefix),
|
||||||
|
return_(),
|
||||||
|
end_(),
|
||||||
|
ic32(offCaller), ic32(offRead2), lget(1), call(fnBytesEqual),
|
||||||
|
i32Eqz(), if_(),
|
||||||
|
ic32(offUnauthPfx), ic32(14), ic32(offArg0), lget(0), call(fnLogPrefix),
|
||||||
|
return_(),
|
||||||
|
end_(),
|
||||||
|
end_(),
|
||||||
|
end_(),
|
||||||
|
|
||||||
|
// Mark status = 'd'
|
||||||
|
ic32(offStatBuf), ic32('d'), i32Store8(),
|
||||||
|
ic32(offArg0), lget(0), ic32('x'), call(fnBuildField), lset(4),
|
||||||
|
ic32(offScratch), lget(4), ic32(offStatBuf), ic32(1), call(fnSetState),
|
||||||
|
ic32(offDisputedPfx), ic32(10), ic32(offArg0), lget(0), call(fnLogPrefix),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolve(id, winner) — admin resolves disputed escrow
|
||||||
|
// winner arg must be "buyer" or "seller"
|
||||||
|
// Locals: idLen(0), winnerLen(1), callerLen(2), adminLen(3),
|
||||||
|
// recipientLen(4), treasuryLen(5), keyLen(6) [i32]
|
||||||
|
// amount(7) [i64]
|
||||||
|
func resolveBody() []byte {
|
||||||
|
return funcBody(
|
||||||
|
withLocals(localDecl(7, tI32), localDecl(1, tI64)),
|
||||||
|
ic32(0), ic32(offArg0), ic32(64), call(fnGetArgStr), lset(0),
|
||||||
|
lget(0), i32Eqz(), if_(), return_(), end_(),
|
||||||
|
ic32(1), ic32(offArg1), ic32(128), call(fnGetArgStr), lset(1),
|
||||||
|
lget(1), i32Eqz(), if_(), return_(), end_(),
|
||||||
|
|
||||||
|
// Check status == 'd'
|
||||||
|
ic32(offArg0), lget(0), ic32('x'), call(fnBuildField), lset(6),
|
||||||
|
ic32(offScratch), lget(6), ic32(offStatBuf), ic32(2), call(fnGetState),
|
||||||
|
ic32(0), i32Ne(), if_(),
|
||||||
|
ic32(offStatBuf), i32Load8U(), ic32('d'), i32Ne(), if_(),
|
||||||
|
ic32(offNotDispPfx), ic32(14), ic32(offArg0), lget(0), call(fnLogPrefix),
|
||||||
|
return_(),
|
||||||
|
end_(),
|
||||||
|
else_(),
|
||||||
|
ic32(offNotFoundPfx), ic32(11), ic32(offArg0), lget(0), call(fnLogPrefix),
|
||||||
|
return_(),
|
||||||
|
end_(),
|
||||||
|
|
||||||
|
// Check caller is admin
|
||||||
|
ic32(offCaller), ic32(128), call(fnGetCaller), lset(2),
|
||||||
|
isCallerAdminCode(2, 3),
|
||||||
|
i32Eqz(), if_(),
|
||||||
|
ic32(offUnauthPfx), ic32(14), ic32(offArg0), lget(0), call(fnLogPrefix),
|
||||||
|
return_(),
|
||||||
|
end_(),
|
||||||
|
|
||||||
|
// Determine recipient based on winner arg
|
||||||
|
// winner == "buyer" (5 bytes) → recipient = buyer address
|
||||||
|
// winner == "seller" (6 bytes) → recipient = seller address
|
||||||
|
ic32(offTreasury), ic32(64), call(fnGetContractTreasury), lset(5),
|
||||||
|
ic32(offArg0), lget(0), ic32('v'), call(fnBuildField), lset(6),
|
||||||
|
ic32(offScratch), lget(6), call(fnGetU64), lset(7),
|
||||||
|
|
||||||
|
// Compare winner to "buyer"
|
||||||
|
lget(1), ic32(5), i32Ne(), if_(),
|
||||||
|
// not "buyer" — assume "seller"
|
||||||
|
ic32(offArg0), lget(0), ic32('s'), call(fnBuildField), lset(6),
|
||||||
|
ic32(offScratch), lget(6), ic32(offRead1), ic32(128), call(fnGetState), lset(4),
|
||||||
|
else_(),
|
||||||
|
// might be "buyer" — verify bytes
|
||||||
|
ic32(offArg1), ic32(offStrBuyer), ic32(5), call(fnBytesEqual),
|
||||||
|
i32Eqz(), if_(),
|
||||||
|
// not "buyer" bytes — default to seller
|
||||||
|
ic32(offArg0), lget(0), ic32('s'), call(fnBuildField), lset(6),
|
||||||
|
ic32(offScratch), lget(6), ic32(offRead1), ic32(128), call(fnGetState), lset(4),
|
||||||
|
else_(),
|
||||||
|
ic32(offArg0), lget(0), ic32('b'), call(fnBuildField), lset(6),
|
||||||
|
ic32(offScratch), lget(6), ic32(offRead1), ic32(128), call(fnGetState), lset(4),
|
||||||
|
end_(),
|
||||||
|
end_(),
|
||||||
|
|
||||||
|
// Transfer: treasury → recipient
|
||||||
|
ic32(offTreasury), lget(5), ic32(offRead1), lget(4), lget(7), call(fnTransfer), drop(),
|
||||||
|
|
||||||
|
// Mark status = 'r' (released, settled)
|
||||||
|
ic32(offStatBuf), ic32('r'), i32Store8(),
|
||||||
|
ic32(offArg0), lget(0), ic32('x'), call(fnBuildField), lset(6),
|
||||||
|
ic32(offScratch), lget(6), ic32(offStatBuf), ic32(1), call(fnSetState),
|
||||||
|
|
||||||
|
ic32(offResolvedPfx), ic32(10), ic32(offArg0), lget(0), call(fnLogPrefix),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// info(id) — log escrow details
|
||||||
|
// Locals: idLen(0), buyerLen(1), sellerLen(2), keyLen(3) [i32]
|
||||||
|
func infoBody() []byte {
|
||||||
|
return funcBody(
|
||||||
|
withLocals(localDecl(4, tI32)),
|
||||||
|
ic32(0), ic32(offArg0), ic32(64), call(fnGetArgStr), lset(0),
|
||||||
|
lget(0), i32Eqz(), if_(), return_(), end_(),
|
||||||
|
|
||||||
|
// Check exists
|
||||||
|
ic32(offArg0), lget(0), ic32('x'), call(fnBuildField), lset(3),
|
||||||
|
ic32(offScratch), lget(3), ic32(offStatBuf), ic32(2), call(fnGetState),
|
||||||
|
ic32(0), i32Ne(), i32Eqz(), if_(),
|
||||||
|
ic32(offNotFoundPfx), ic32(11), ic32(offArg0), lget(0), call(fnLogPrefix),
|
||||||
|
return_(),
|
||||||
|
end_(),
|
||||||
|
|
||||||
|
// Log buyer
|
||||||
|
ic32(offArg0), lget(0), ic32('b'), call(fnBuildField), lset(3),
|
||||||
|
ic32(offScratch), lget(3), ic32(offRead1), ic32(128), call(fnGetState), lset(1),
|
||||||
|
ic32(offBuyerPfx), ic32(7), ic32(offRead1), lget(1), call(fnLogPrefix),
|
||||||
|
|
||||||
|
// Log seller
|
||||||
|
ic32(offArg0), lget(0), ic32('s'), call(fnBuildField), lset(3),
|
||||||
|
ic32(offScratch), lget(3), ic32(offRead2), ic32(128), call(fnGetState), lset(2),
|
||||||
|
ic32(offSellerPfx), ic32(8), ic32(offRead2), lget(2), call(fnLogPrefix),
|
||||||
|
|
||||||
|
// Log status
|
||||||
|
ic32(offStatBuf), i32Load8U(), ic32('a'), i32Ne(), if_(),
|
||||||
|
ic32(offStatBuf), i32Load8U(), ic32('d'), i32Ne(), if_(),
|
||||||
|
ic32(offStatBuf), i32Load8U(), ic32('r'), i32Ne(), if_(),
|
||||||
|
ic32(offStatusPfx), ic32(8), ic32(offStrRefunded), ic32(8), call(fnLogPrefix),
|
||||||
|
else_(),
|
||||||
|
ic32(offStatusPfx), ic32(8), ic32(offStrReleased), ic32(8), call(fnLogPrefix),
|
||||||
|
end_(),
|
||||||
|
else_(),
|
||||||
|
ic32(offStatusPfx), ic32(8), ic32(offStrDisputed), ic32(8), call(fnLogPrefix),
|
||||||
|
end_(),
|
||||||
|
else_(),
|
||||||
|
ic32(offStatusPfx), ic32(8), ic32(offStrActive), ic32(6), call(fnLogPrefix),
|
||||||
|
end_(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── main ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
// Types:
|
||||||
|
// 0: (i32,i32,i32)→(i32) get_arg_str, bytes_equal, buildField
|
||||||
|
// 1: (i32)→(i64) get_arg_u64
|
||||||
|
// 2: (i32,i32,i32,i32)→(i32) get_state
|
||||||
|
// 3: (i32,i32,i32,i32)→() set_state, log_prefix
|
||||||
|
// 4: (i32,i32)→() log
|
||||||
|
// 5: (i32,i32,i32,i32,i64)→(i32) transfer
|
||||||
|
// 6: (i32,i32)→(i32) get_caller, get_contract_treasury
|
||||||
|
// 7: (i32,i32,i64)→() put_u64
|
||||||
|
// 8: (i32,i32)→(i64) get_u64
|
||||||
|
// 9: ()→() exported methods
|
||||||
|
// 10: (i32,i32,i32)→() memcpy
|
||||||
|
typeSection := section(0x01, vec(
|
||||||
|
functype([]byte{tI32, tI32, tI32}, []byte{tI32}), // 0
|
||||||
|
functype([]byte{tI32}, []byte{tI64}), // 1
|
||||||
|
functype([]byte{tI32, tI32, tI32, tI32}, []byte{tI32}), // 2
|
||||||
|
functype([]byte{tI32, tI32, tI32, tI32}, []byte{}), // 3
|
||||||
|
functype([]byte{tI32, tI32}, []byte{}), // 4
|
||||||
|
functype([]byte{tI32, tI32, tI32, tI32, tI64}, []byte{tI32}), // 5
|
||||||
|
functype([]byte{tI32, tI32}, []byte{tI32}), // 6
|
||||||
|
functype([]byte{tI32, tI32, tI64}, []byte{}), // 7
|
||||||
|
functype([]byte{tI32, tI32}, []byte{tI64}), // 8
|
||||||
|
functype([]byte{}, []byte{}), // 9
|
||||||
|
functype([]byte{tI32, tI32, tI32}, []byte{}), // 10
|
||||||
|
))
|
||||||
|
|
||||||
|
importSection := section(0x02, vec(
|
||||||
|
importFunc("env", "get_arg_str", 0), // 0
|
||||||
|
importFunc("env", "get_arg_u64", 1), // 1
|
||||||
|
importFunc("env", "get_caller", 6), // 2
|
||||||
|
importFunc("env", "get_state", 2), // 3
|
||||||
|
importFunc("env", "set_state", 3), // 4
|
||||||
|
importFunc("env", "log", 4), // 5
|
||||||
|
importFunc("env", "transfer", 5), // 6
|
||||||
|
importFunc("env", "get_contract_treasury", 6), // 7
|
||||||
|
importFunc("env", "put_u64", 7), // 8
|
||||||
|
importFunc("env", "get_u64", 8), // 9
|
||||||
|
))
|
||||||
|
|
||||||
|
// 11 local functions
|
||||||
|
functionSection := section(0x03, vec(
|
||||||
|
u(0), // bytes_equal type 0
|
||||||
|
u(10), // memcpy type 10
|
||||||
|
u(3), // log_prefix type 3
|
||||||
|
u(0), // build_field type 0
|
||||||
|
u(9), // init type 9
|
||||||
|
u(9), // create type 9
|
||||||
|
u(9), // release type 9
|
||||||
|
u(9), // refund type 9
|
||||||
|
u(9), // dispute type 9
|
||||||
|
u(9), // resolve type 9
|
||||||
|
u(9), // info type 9
|
||||||
|
))
|
||||||
|
|
||||||
|
memorySection := section(0x05, vec(cat([]byte{0x00}, u(2)))) // 2 pages
|
||||||
|
|
||||||
|
exportSection := section(0x07, vec(
|
||||||
|
exportEntry("memory", 0x02, 0),
|
||||||
|
exportEntry("init", 0x00, fnInit),
|
||||||
|
exportEntry("create", 0x00, fnCreate),
|
||||||
|
exportEntry("release", 0x00, fnRelease),
|
||||||
|
exportEntry("refund", 0x00, fnRefund),
|
||||||
|
exportEntry("dispute", 0x00, fnDispute),
|
||||||
|
exportEntry("resolve", 0x00, fnResolve),
|
||||||
|
exportEntry("info", 0x00, fnInfo),
|
||||||
|
))
|
||||||
|
|
||||||
|
dataSection := section(0x0B, cat(
|
||||||
|
u(21),
|
||||||
|
dataSegment(offKeyAdmin, []byte("admin")),
|
||||||
|
dataSegment(offInitedPfx, []byte("initialized: ")),
|
||||||
|
dataSegment(offCreatedPfx, []byte("created: ")),
|
||||||
|
dataSegment(offReleasedPfx, []byte("released: ")),
|
||||||
|
dataSegment(offRefundedPfx, []byte("refunded: ")),
|
||||||
|
dataSegment(offDisputedPfx, []byte("disputed: ")),
|
||||||
|
dataSegment(offResolvedPfx, []byte("resolved: ")),
|
||||||
|
dataSegment(offUnauthPfx, []byte("unauthorized: ")),
|
||||||
|
dataSegment(offNotFoundPfx, []byte("not found: ")),
|
||||||
|
dataSegment(offAlreadyPfx, []byte("already init: ")),
|
||||||
|
dataSegment(offNotActivePfx,[]byte("not active: ")),
|
||||||
|
dataSegment(offNotDispPfx, []byte("not disputed: ")),
|
||||||
|
dataSegment(offBuyerPfx, []byte("buyer: ")),
|
||||||
|
dataSegment(offSellerPfx, []byte("seller: ")),
|
||||||
|
dataSegment(offStatusPfx, []byte("status: ")),
|
||||||
|
dataSegment(offStrActive, []byte("active")),
|
||||||
|
dataSegment(offStrDisputed, []byte("disputed")),
|
||||||
|
dataSegment(offStrReleased, []byte("released")),
|
||||||
|
dataSegment(offStrRefunded, []byte("refunded")),
|
||||||
|
dataSegment(offStrBuyer, []byte("buyer")),
|
||||||
|
dataSegment(offStrSeller, []byte("seller")),
|
||||||
|
))
|
||||||
|
|
||||||
|
codeSection := section(0x0A, cat(
|
||||||
|
u(11),
|
||||||
|
bytesEqualBody(),
|
||||||
|
memcpyBody(),
|
||||||
|
logPrefixBody(),
|
||||||
|
buildFieldBody(),
|
||||||
|
initBody(),
|
||||||
|
createBody(),
|
||||||
|
releaseBody(),
|
||||||
|
refundBody(),
|
||||||
|
disputeBody(),
|
||||||
|
resolveBody(),
|
||||||
|
infoBody(),
|
||||||
|
))
|
||||||
|
|
||||||
|
module := cat(
|
||||||
|
[]byte{0x00, 0x61, 0x73, 0x6d},
|
||||||
|
[]byte{0x01, 0x00, 0x00, 0x00},
|
||||||
|
typeSection,
|
||||||
|
importSection,
|
||||||
|
functionSection,
|
||||||
|
memorySection,
|
||||||
|
exportSection,
|
||||||
|
dataSection,
|
||||||
|
codeSection,
|
||||||
|
)
|
||||||
|
|
||||||
|
out := "contracts/escrow/escrow.wasm"
|
||||||
|
if err := os.WriteFile(out, module, 0644); err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, "write:", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
fmt.Printf("Written %s (%d bytes)\n", out, len(module))
|
||||||
|
}
|
||||||
538
contracts/governance/gen/main.go
Normal file
538
contracts/governance/gen/main.go
Normal file
@@ -0,0 +1,538 @@
|
|||||||
|
// gen generates contracts/governance/governance.wasm
|
||||||
|
// Run from repo root: go run ./contracts/governance/gen/
|
||||||
|
//
|
||||||
|
// Methods: init, propose, approve, reject, get, get_pending, set_admin
|
||||||
|
//
|
||||||
|
// State layout (all keys are raw UTF-8 strings):
|
||||||
|
// "admin" → admin address bytes
|
||||||
|
// "param:<key>" → current live value bytes
|
||||||
|
// "prop:<key>" → pending proposed value bytes
|
||||||
|
//
|
||||||
|
// Access control:
|
||||||
|
// approve / reject / set_admin → admin only
|
||||||
|
// propose / get / get_pending → anyone
|
||||||
|
// init → anyone (but only writes if admin not set)
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── LEB128 ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func u(v uint64) []byte {
|
||||||
|
var b []byte
|
||||||
|
for {
|
||||||
|
bt := byte(v & 0x7f)
|
||||||
|
v >>= 7
|
||||||
|
if v != 0 {
|
||||||
|
bt |= 0x80
|
||||||
|
}
|
||||||
|
b = append(b, bt)
|
||||||
|
if v == 0 {
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func s(v int64) []byte {
|
||||||
|
var b []byte
|
||||||
|
for {
|
||||||
|
bt := byte(v & 0x7f)
|
||||||
|
v >>= 7
|
||||||
|
sign := (bt & 0x40) != 0
|
||||||
|
if (v == 0 && !sign) || (v == -1 && sign) {
|
||||||
|
return append(b, bt)
|
||||||
|
}
|
||||||
|
b = append(b, bt|0x80)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func cat(slices ...[]byte) []byte {
|
||||||
|
var out []byte
|
||||||
|
for _, sl := range slices {
|
||||||
|
out = append(out, sl...)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func wstr(str string) []byte { return cat(u(uint64(len(str))), []byte(str)) }
|
||||||
|
|
||||||
|
func section(id byte, content []byte) []byte {
|
||||||
|
return cat([]byte{id}, u(uint64(len(content))), content)
|
||||||
|
}
|
||||||
|
|
||||||
|
func vec(items ...[]byte) []byte {
|
||||||
|
out := u(uint64(len(items)))
|
||||||
|
for _, it := range items {
|
||||||
|
out = append(out, it...)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func functype(params, results []byte) []byte {
|
||||||
|
return cat([]byte{0x60}, u(uint64(len(params))), params, u(uint64(len(results))), results)
|
||||||
|
}
|
||||||
|
|
||||||
|
func importFunc(mod, name string, typeIdx uint32) []byte {
|
||||||
|
return cat(wstr(mod), wstr(name), []byte{0x00}, u(uint64(typeIdx)))
|
||||||
|
}
|
||||||
|
|
||||||
|
func exportEntry(name string, kind byte, idx uint32) []byte {
|
||||||
|
return cat(wstr(name), []byte{kind}, u(uint64(idx)))
|
||||||
|
}
|
||||||
|
|
||||||
|
func dataSegment(offset int32, data []byte) []byte {
|
||||||
|
return cat(
|
||||||
|
[]byte{0x00},
|
||||||
|
[]byte{0x41}, s(int64(offset)), []byte{0x0B},
|
||||||
|
u(uint64(len(data))), data,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func funcBody(localDecls []byte, instrs ...[]byte) []byte {
|
||||||
|
inner := cat(localDecls)
|
||||||
|
for _, ins := range instrs {
|
||||||
|
inner = append(inner, ins...)
|
||||||
|
}
|
||||||
|
inner = append(inner, 0x0B)
|
||||||
|
return cat(u(uint64(len(inner))), inner)
|
||||||
|
}
|
||||||
|
|
||||||
|
var noLocals = u(0)
|
||||||
|
|
||||||
|
func localDecl(n uint32, typ byte) []byte { return cat(u(uint64(n)), []byte{typ}) }
|
||||||
|
func withLocals(decls ...[]byte) []byte {
|
||||||
|
return cat(u(uint64(len(decls))), cat(decls...))
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
tI32 byte = 0x7F
|
||||||
|
tI64 byte = 0x7E
|
||||||
|
)
|
||||||
|
|
||||||
|
func call(fn uint32) []byte { return cat([]byte{0x10}, u(uint64(fn))) }
|
||||||
|
func lget(i uint32) []byte { return cat([]byte{0x20}, u(uint64(i))) }
|
||||||
|
func lset(i uint32) []byte { return cat([]byte{0x21}, u(uint64(i))) }
|
||||||
|
func ic32(v int32) []byte { return cat([]byte{0x41}, s(int64(v))) }
|
||||||
|
func block_() []byte { return []byte{0x02, 0x40} }
|
||||||
|
func loop_() []byte { return []byte{0x03, 0x40} }
|
||||||
|
func if_() []byte { return []byte{0x04, 0x40} }
|
||||||
|
func ifI32() []byte { return []byte{0x04, tI32} }
|
||||||
|
func else_() []byte { return []byte{0x05} }
|
||||||
|
func end_() []byte { return []byte{0x0B} }
|
||||||
|
func br_(lbl uint32) []byte { return cat([]byte{0x0C}, u(uint64(lbl))) }
|
||||||
|
func brIf_(lbl uint32) []byte { return cat([]byte{0x0D}, u(uint64(lbl))) }
|
||||||
|
func return_() []byte { return []byte{0x0F} }
|
||||||
|
func i32Eqz() []byte { return []byte{0x45} }
|
||||||
|
func i32Ne() []byte { return []byte{0x47} }
|
||||||
|
func i32GtU() []byte { return []byte{0x4B} }
|
||||||
|
func i32GeU() []byte { return []byte{0x4F} }
|
||||||
|
func i32Add() []byte { return []byte{0x6A} }
|
||||||
|
func i32Load8U() []byte { return []byte{0x2D, 0x00, 0x00} }
|
||||||
|
func i32Store8() []byte { return []byte{0x3A, 0x00, 0x00} }
|
||||||
|
|
||||||
|
// ── Memory layout ─────────────────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// 0x000 64 arg[0] key buffer
|
||||||
|
// 0x040 256 arg[1] value buffer
|
||||||
|
// 0x140 128 caller buffer
|
||||||
|
// 0x1C0 128 admin state-read buffer
|
||||||
|
// 0x240 256 value state-read buffer
|
||||||
|
// 0x340 256 scratch (build_key writes here, log_prefix_name writes here)
|
||||||
|
//
|
||||||
|
// Constant strings:
|
||||||
|
// 0x500 6 "admin" (state key)
|
||||||
|
// 0x506 6 "param:" (state key prefix)
|
||||||
|
// 0x50D 5 "prop:" (state key prefix)
|
||||||
|
// 0x513 13 "initialized: "
|
||||||
|
// 0x521 9 "proposed: "
|
||||||
|
// 0x52B 10 "approved: "
|
||||||
|
// 0x536 10 "rejected: "
|
||||||
|
// 0x541 8 "value: "
|
||||||
|
// 0x549 11 "not set: "
|
||||||
|
// 0x555 9 "pending: "
|
||||||
|
// 0x55F 12 "no pending: "
|
||||||
|
// 0x56C 7 "admin: "
|
||||||
|
// 0x574 14 "unauthorized: "
|
||||||
|
// 0x583 14 "already init: "
|
||||||
|
|
||||||
|
const (
|
||||||
|
offArg0 int32 = 0x000
|
||||||
|
offArg1 int32 = 0x040
|
||||||
|
offCaller int32 = 0x140
|
||||||
|
offAdminRead int32 = 0x1C0
|
||||||
|
offValRead int32 = 0x240
|
||||||
|
offScratch int32 = 0x340
|
||||||
|
|
||||||
|
offKeyAdmin int32 = 0x500 // "admin" 5 bytes
|
||||||
|
offPfxParam int32 = 0x506 // "param:" 6 bytes
|
||||||
|
offPfxProp int32 = 0x50D // "prop:" 5 bytes
|
||||||
|
offInitedPfx int32 = 0x513 // "initialized: " 14
|
||||||
|
offProposedPfx int32 = 0x521 // "proposed: " 9 (key=val shown as arg0=arg1)
|
||||||
|
offApprovedPfx int32 = 0x52B // "approved: " 10
|
||||||
|
offRejectedPfx int32 = 0x536 // "rejected: " 10
|
||||||
|
offValuePfx int32 = 0x541 // "value: " 7
|
||||||
|
offNotSetPfx int32 = 0x549 // "not set: " 9
|
||||||
|
offPendingPfx int32 = 0x555 // "pending: " 9
|
||||||
|
offNoPendingPfx int32 = 0x55F // "no pending: " 12
|
||||||
|
offAdminPfx int32 = 0x56C // "admin: " 7
|
||||||
|
offUnauthPfx int32 = 0x574 // "unauthorized: " 14
|
||||||
|
offAlreadyPfx int32 = 0x583 // "already init" 12
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
fnGetArgStr = 0
|
||||||
|
fnGetCaller = 1
|
||||||
|
fnGetState = 2
|
||||||
|
fnSetState = 3
|
||||||
|
fnLog = 4
|
||||||
|
|
||||||
|
fnBytesEqual = 5
|
||||||
|
fnMemcpy = 6
|
||||||
|
fnLogPrefixName = 7
|
||||||
|
fnBuildKey = 8
|
||||||
|
fnInit = 9
|
||||||
|
fnPropose = 10
|
||||||
|
fnApprove = 11
|
||||||
|
fnReject = 12
|
||||||
|
fnGet = 13
|
||||||
|
fnGetPending = 14
|
||||||
|
fnSetAdmin = 15
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── Helper bodies ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func bytesEqualBody() []byte {
|
||||||
|
return funcBody(
|
||||||
|
withLocals(localDecl(2, tI32)),
|
||||||
|
ic32(1), lset(4), ic32(0), lset(3),
|
||||||
|
block_(), loop_(),
|
||||||
|
lget(3), lget(2), i32GeU(), brIf_(1),
|
||||||
|
lget(0), lget(3), i32Add(), i32Load8U(),
|
||||||
|
lget(1), lget(3), i32Add(), i32Load8U(),
|
||||||
|
i32Ne(), if_(), ic32(0), lset(4), br_(2), end_(),
|
||||||
|
lget(3), ic32(1), i32Add(), lset(3),
|
||||||
|
br_(0), end_(), end_(),
|
||||||
|
lget(4),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func memcpyBody() []byte {
|
||||||
|
return funcBody(
|
||||||
|
withLocals(localDecl(1, tI32)),
|
||||||
|
ic32(0), lset(3),
|
||||||
|
block_(), loop_(),
|
||||||
|
lget(3), lget(2), i32GeU(), brIf_(1),
|
||||||
|
lget(0), lget(3), i32Add(),
|
||||||
|
lget(1), lget(3), i32Add(), i32Load8U(),
|
||||||
|
i32Store8(),
|
||||||
|
lget(3), ic32(1), i32Add(), lset(3),
|
||||||
|
br_(0), end_(), end_(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func logPrefixNameBody() []byte {
|
||||||
|
return funcBody(
|
||||||
|
noLocals,
|
||||||
|
ic32(offScratch), lget(0), lget(1), call(fnMemcpy),
|
||||||
|
ic32(offScratch), lget(1), i32Add(), lget(2), lget(3), call(fnMemcpy),
|
||||||
|
ic32(offScratch), lget(1), lget(3), i32Add(), call(fnLog),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// $build_key(pfxOff, pfxLen, dataOff, dataLen i32) → keyLen i32
|
||||||
|
func buildKeyBody() []byte {
|
||||||
|
return funcBody(
|
||||||
|
noLocals,
|
||||||
|
ic32(offScratch), lget(0), lget(1), call(fnMemcpy),
|
||||||
|
ic32(offScratch), lget(1), i32Add(), lget(2), lget(3), call(fnMemcpy),
|
||||||
|
lget(1), lget(3), i32Add(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// isCallerAdmin: reads admin from state into offAdminRead, returns 1 if caller==admin.
|
||||||
|
// (callerLenLocal i32) → i32
|
||||||
|
// Leaves adminLen on stack as side effect (stored in local adminLenLocal).
|
||||||
|
// Emits code that: reads admin, compares with caller.
|
||||||
|
// Uses offAdminRead for the state read buffer.
|
||||||
|
func isCallerAdminCode(callerLenLocal, adminLenLocal uint32) []byte {
|
||||||
|
return cat(
|
||||||
|
// adminLen = get_state("admin", 5, offAdminRead, 128)
|
||||||
|
ic32(offKeyAdmin), ic32(5), ic32(offAdminRead), ic32(128), call(fnGetState), lset(adminLenLocal),
|
||||||
|
// if adminLen == 0: not initialized → return 0
|
||||||
|
lget(adminLenLocal), i32Eqz(),
|
||||||
|
ifI32(),
|
||||||
|
ic32(0),
|
||||||
|
else_(),
|
||||||
|
// bytes_equal(offCaller, offAdminRead, callerLen) if callerLen==adminLen else 0
|
||||||
|
lget(callerLenLocal), lget(adminLenLocal), i32Ne(),
|
||||||
|
ifI32(), ic32(0), else_(),
|
||||||
|
ic32(offCaller), ic32(offAdminRead), lget(callerLenLocal), call(fnBytesEqual),
|
||||||
|
end_(),
|
||||||
|
end_(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Contract method bodies ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// init() — set caller as admin (only if not already initialized)
|
||||||
|
func initBody() []byte {
|
||||||
|
return funcBody(
|
||||||
|
withLocals(localDecl(2, tI32)),
|
||||||
|
// callerLen = get_caller(offCaller, 128)
|
||||||
|
ic32(offCaller), ic32(128), call(fnGetCaller), lset(0),
|
||||||
|
// Check if admin already set
|
||||||
|
ic32(offKeyAdmin), ic32(5), ic32(offAdminRead), ic32(128), call(fnGetState), lset(1),
|
||||||
|
lget(1), ic32(0), i32GtU(), if_(),
|
||||||
|
ic32(offAlreadyPfx), ic32(12), ic32(offCaller), lget(0), call(fnLogPrefixName),
|
||||||
|
return_(),
|
||||||
|
end_(),
|
||||||
|
// set state["admin"] = caller
|
||||||
|
ic32(offKeyAdmin), ic32(5), ic32(offCaller), lget(0), call(fnSetState),
|
||||||
|
ic32(offInitedPfx), ic32(14), ic32(offCaller), lget(0), call(fnLogPrefixName),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// propose(key, value) — store pending proposal
|
||||||
|
// Locals: keyLen(0), valLen(1), keyBufLen(2)
|
||||||
|
func proposeBody() []byte {
|
||||||
|
return funcBody(
|
||||||
|
withLocals(localDecl(3, tI32)),
|
||||||
|
ic32(0), ic32(offArg0), ic32(64), call(fnGetArgStr), lset(0),
|
||||||
|
lget(0), i32Eqz(), if_(), return_(), end_(),
|
||||||
|
ic32(1), ic32(offArg1), ic32(256), call(fnGetArgStr), lset(1),
|
||||||
|
lget(1), i32Eqz(), if_(), return_(), end_(),
|
||||||
|
|
||||||
|
// set state["prop:<key>"] = value
|
||||||
|
ic32(offPfxProp), ic32(5), ic32(offArg0), lget(0), call(fnBuildKey), lset(2),
|
||||||
|
ic32(offScratch), lget(2), ic32(offArg1), lget(1), call(fnSetState),
|
||||||
|
|
||||||
|
// log "proposed: <key>"
|
||||||
|
ic32(offProposedPfx), ic32(9), ic32(offArg0), lget(0), call(fnLogPrefixName),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// approve(key) — admin: move prop:<key> → param:<key>
|
||||||
|
// Locals: keyLen(0), callerLen(1), adminLen(2), propLen(3), keyBufLen(4)
|
||||||
|
func approveBody() []byte {
|
||||||
|
return funcBody(
|
||||||
|
withLocals(localDecl(5, tI32)),
|
||||||
|
ic32(0), ic32(offArg0), ic32(64), call(fnGetArgStr), lset(0),
|
||||||
|
lget(0), i32Eqz(), if_(), return_(), end_(),
|
||||||
|
|
||||||
|
ic32(offCaller), ic32(128), call(fnGetCaller), lset(1),
|
||||||
|
isCallerAdminCode(1, 2),
|
||||||
|
i32Eqz(), if_(),
|
||||||
|
ic32(offUnauthPfx), ic32(14), ic32(offArg0), lget(0), call(fnLogPrefixName),
|
||||||
|
return_(),
|
||||||
|
end_(),
|
||||||
|
|
||||||
|
// Read pending value: get_state("prop:<key>", offValRead, 256)
|
||||||
|
ic32(offPfxProp), ic32(5), ic32(offArg0), lget(0), call(fnBuildKey), lset(4),
|
||||||
|
ic32(offScratch), lget(4), ic32(offValRead), ic32(256), call(fnGetState), lset(3),
|
||||||
|
lget(3), i32Eqz(), if_(),
|
||||||
|
ic32(offNoPendingPfx), ic32(12), ic32(offArg0), lget(0), call(fnLogPrefixName),
|
||||||
|
return_(),
|
||||||
|
end_(),
|
||||||
|
|
||||||
|
// set state["param:<key>"] = offValRead[0..propLen)
|
||||||
|
ic32(offPfxParam), ic32(6), ic32(offArg0), lget(0), call(fnBuildKey), lset(4),
|
||||||
|
ic32(offScratch), lget(4), ic32(offValRead), lget(3), call(fnSetState),
|
||||||
|
|
||||||
|
// delete pending: set state["prop:<key>"] = ""
|
||||||
|
ic32(offPfxProp), ic32(5), ic32(offArg0), lget(0), call(fnBuildKey), lset(4),
|
||||||
|
ic32(offScratch), lget(4), ic32(offScratch), ic32(0), call(fnSetState),
|
||||||
|
|
||||||
|
ic32(offApprovedPfx), ic32(10), ic32(offArg0), lget(0), call(fnLogPrefixName),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// reject(key) — admin: delete prop:<key>
|
||||||
|
// Locals: keyLen(0), callerLen(1), adminLen(2), keyBufLen(3)
|
||||||
|
func rejectBody() []byte {
|
||||||
|
return funcBody(
|
||||||
|
withLocals(localDecl(4, tI32)),
|
||||||
|
ic32(0), ic32(offArg0), ic32(64), call(fnGetArgStr), lset(0),
|
||||||
|
lget(0), i32Eqz(), if_(), return_(), end_(),
|
||||||
|
|
||||||
|
ic32(offCaller), ic32(128), call(fnGetCaller), lset(1),
|
||||||
|
isCallerAdminCode(1, 2),
|
||||||
|
i32Eqz(), if_(),
|
||||||
|
ic32(offUnauthPfx), ic32(14), ic32(offArg0), lget(0), call(fnLogPrefixName),
|
||||||
|
return_(),
|
||||||
|
end_(),
|
||||||
|
|
||||||
|
// delete pending
|
||||||
|
ic32(offPfxProp), ic32(5), ic32(offArg0), lget(0), call(fnBuildKey), lset(3),
|
||||||
|
ic32(offScratch), lget(3), ic32(offScratch), ic32(0), call(fnSetState),
|
||||||
|
ic32(offRejectedPfx), ic32(10), ic32(offArg0), lget(0), call(fnLogPrefixName),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// get(key) — read live parameter value
|
||||||
|
// Locals: keyLen(0), valLen(1), keyBufLen(2)
|
||||||
|
func getBody() []byte {
|
||||||
|
return funcBody(
|
||||||
|
withLocals(localDecl(3, tI32)),
|
||||||
|
ic32(0), ic32(offArg0), ic32(64), call(fnGetArgStr), lset(0),
|
||||||
|
lget(0), i32Eqz(), if_(), return_(), end_(),
|
||||||
|
|
||||||
|
ic32(offPfxParam), ic32(6), ic32(offArg0), lget(0), call(fnBuildKey), lset(2),
|
||||||
|
ic32(offScratch), lget(2), ic32(offValRead), ic32(256), call(fnGetState), lset(1),
|
||||||
|
lget(1), i32Eqz(), if_(),
|
||||||
|
ic32(offNotSetPfx), ic32(9), ic32(offArg0), lget(0), call(fnLogPrefixName),
|
||||||
|
return_(),
|
||||||
|
end_(),
|
||||||
|
ic32(offValuePfx), ic32(7), ic32(offValRead), lget(1), call(fnLogPrefixName),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// get_pending(key) — read pending proposal
|
||||||
|
// Locals: keyLen(0), valLen(1), keyBufLen(2)
|
||||||
|
func getPendingBody() []byte {
|
||||||
|
return funcBody(
|
||||||
|
withLocals(localDecl(3, tI32)),
|
||||||
|
ic32(0), ic32(offArg0), ic32(64), call(fnGetArgStr), lset(0),
|
||||||
|
lget(0), i32Eqz(), if_(), return_(), end_(),
|
||||||
|
|
||||||
|
ic32(offPfxProp), ic32(5), ic32(offArg0), lget(0), call(fnBuildKey), lset(2),
|
||||||
|
ic32(offScratch), lget(2), ic32(offValRead), ic32(256), call(fnGetState), lset(1),
|
||||||
|
lget(1), i32Eqz(), if_(),
|
||||||
|
ic32(offNoPendingPfx), ic32(12), ic32(offArg0), lget(0), call(fnLogPrefixName),
|
||||||
|
return_(),
|
||||||
|
end_(),
|
||||||
|
ic32(offPendingPfx), ic32(9), ic32(offValRead), lget(1), call(fnLogPrefixName),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// set_admin(new_admin) — transfer admin role
|
||||||
|
// Locals: newAdminLen(0), callerLen(1), adminLen(2)
|
||||||
|
func setAdminBody() []byte {
|
||||||
|
return funcBody(
|
||||||
|
withLocals(localDecl(3, tI32)),
|
||||||
|
ic32(0), ic32(offArg0), ic32(128), call(fnGetArgStr), lset(0),
|
||||||
|
lget(0), i32Eqz(), if_(), return_(), end_(),
|
||||||
|
|
||||||
|
ic32(offCaller), ic32(128), call(fnGetCaller), lset(1),
|
||||||
|
isCallerAdminCode(1, 2),
|
||||||
|
i32Eqz(), if_(),
|
||||||
|
ic32(offUnauthPfx), ic32(14), ic32(offArg0), lget(0), call(fnLogPrefixName),
|
||||||
|
return_(),
|
||||||
|
end_(),
|
||||||
|
|
||||||
|
ic32(offKeyAdmin), ic32(5), ic32(offArg0), lget(0), call(fnSetState),
|
||||||
|
ic32(offAdminPfx), ic32(7), ic32(offArg0), lget(0), call(fnLogPrefixName),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── main ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
// Types:
|
||||||
|
// 0: (i32,i32,i32)→(i32) get_arg_str, bytes_equal
|
||||||
|
// 1: (i32,i32)→(i32) get_caller
|
||||||
|
// 2: (i32,i32,i32,i32)→(i32) get_state, build_key
|
||||||
|
// 3: (i32,i32,i32,i32)→() set_state, log_prefix_name
|
||||||
|
// 4: (i32,i32)→() log
|
||||||
|
// 5: ()→() exported methods
|
||||||
|
// 6: (i32,i32,i32)→() memcpy
|
||||||
|
typeSection := section(0x01, vec(
|
||||||
|
functype([]byte{tI32, tI32, tI32}, []byte{tI32}), // 0
|
||||||
|
functype([]byte{tI32, tI32}, []byte{tI32}), // 1
|
||||||
|
functype([]byte{tI32, tI32, tI32, tI32}, []byte{tI32}), // 2
|
||||||
|
functype([]byte{tI32, tI32, tI32, tI32}, []byte{}), // 3
|
||||||
|
functype([]byte{tI32, tI32}, []byte{}), // 4
|
||||||
|
functype([]byte{}, []byte{}), // 5
|
||||||
|
functype([]byte{tI32, tI32, tI32}, []byte{}), // 6
|
||||||
|
))
|
||||||
|
|
||||||
|
importSection := section(0x02, vec(
|
||||||
|
importFunc("env", "get_arg_str", 0), // 0 type 0
|
||||||
|
importFunc("env", "get_caller", 1), // 1 type 1
|
||||||
|
importFunc("env", "get_state", 2), // 2 type 2
|
||||||
|
importFunc("env", "set_state", 3), // 3 type 3
|
||||||
|
importFunc("env", "log", 4), // 4 type 4
|
||||||
|
))
|
||||||
|
|
||||||
|
// 11 local functions
|
||||||
|
functionSection := section(0x03, vec(
|
||||||
|
u(0), // bytes_equal type 0
|
||||||
|
u(6), // memcpy type 6
|
||||||
|
u(3), // log_prefix_name type 3
|
||||||
|
u(2), // build_key type 2
|
||||||
|
u(5), // init type 5
|
||||||
|
u(5), // propose type 5
|
||||||
|
u(5), // approve type 5
|
||||||
|
u(5), // reject type 5
|
||||||
|
u(5), // get type 5
|
||||||
|
u(5), // get_pending type 5
|
||||||
|
u(5), // set_admin type 5
|
||||||
|
))
|
||||||
|
|
||||||
|
memorySection := section(0x05, vec(cat([]byte{0x00}, u(2)))) // 2 pages = 128KB
|
||||||
|
|
||||||
|
exportSection := section(0x07, vec(
|
||||||
|
exportEntry("memory", 0x02, 0),
|
||||||
|
exportEntry("init", 0x00, fnInit),
|
||||||
|
exportEntry("propose", 0x00, fnPropose),
|
||||||
|
exportEntry("approve", 0x00, fnApprove),
|
||||||
|
exportEntry("reject", 0x00, fnReject),
|
||||||
|
exportEntry("get", 0x00, fnGet),
|
||||||
|
exportEntry("get_pending", 0x00, fnGetPending),
|
||||||
|
exportEntry("set_admin", 0x00, fnSetAdmin),
|
||||||
|
))
|
||||||
|
|
||||||
|
dataSection := section(0x0B, cat(
|
||||||
|
u(14),
|
||||||
|
dataSegment(offKeyAdmin, []byte("admin")),
|
||||||
|
dataSegment(offPfxParam, []byte("param:")),
|
||||||
|
dataSegment(offPfxProp, []byte("prop:")),
|
||||||
|
dataSegment(offInitedPfx, []byte("initialized: ")),
|
||||||
|
dataSegment(offProposedPfx, []byte("proposed: ")),
|
||||||
|
dataSegment(offApprovedPfx, []byte("approved: ")),
|
||||||
|
dataSegment(offRejectedPfx, []byte("rejected: ")),
|
||||||
|
dataSegment(offValuePfx, []byte("value: ")),
|
||||||
|
dataSegment(offNotSetPfx, []byte("not set: ")),
|
||||||
|
dataSegment(offPendingPfx, []byte("pending: ")),
|
||||||
|
dataSegment(offNoPendingPfx, []byte("no pending: ")),
|
||||||
|
dataSegment(offAdminPfx, []byte("admin: ")),
|
||||||
|
dataSegment(offUnauthPfx, []byte("unauthorized: ")),
|
||||||
|
dataSegment(offAlreadyPfx, []byte("already init")),
|
||||||
|
))
|
||||||
|
|
||||||
|
codeSection := section(0x0A, cat(
|
||||||
|
u(11),
|
||||||
|
bytesEqualBody(),
|
||||||
|
memcpyBody(),
|
||||||
|
logPrefixNameBody(),
|
||||||
|
buildKeyBody(),
|
||||||
|
initBody(),
|
||||||
|
proposeBody(),
|
||||||
|
approveBody(),
|
||||||
|
rejectBody(),
|
||||||
|
getBody(),
|
||||||
|
getPendingBody(),
|
||||||
|
setAdminBody(),
|
||||||
|
))
|
||||||
|
|
||||||
|
module := cat(
|
||||||
|
[]byte{0x00, 0x61, 0x73, 0x6d},
|
||||||
|
[]byte{0x01, 0x00, 0x00, 0x00},
|
||||||
|
typeSection,
|
||||||
|
importSection,
|
||||||
|
functionSection,
|
||||||
|
memorySection,
|
||||||
|
exportSection,
|
||||||
|
dataSection,
|
||||||
|
codeSection,
|
||||||
|
)
|
||||||
|
|
||||||
|
out := "contracts/governance/governance.wasm"
|
||||||
|
if err := os.WriteFile(out, module, 0644); err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, "write:", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
fmt.Printf("Written %s (%d bytes)\n", out, len(module))
|
||||||
|
}
|
||||||
BIN
contracts/governance/governance.wasm
Normal file
BIN
contracts/governance/governance.wasm
Normal file
Binary file not shown.
55
contracts/governance/governance_abi.json
Normal file
55
contracts/governance/governance_abi.json
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
{
|
||||||
|
"contract": "governance",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "On-chain parameter governance. The deployer becomes the admin. Anyone can propose a parameter change; the admin approves or rejects it. Used to manage gas_price, messenger_entry_fee, relay_fee, etc.",
|
||||||
|
"methods": [
|
||||||
|
{
|
||||||
|
"name": "init",
|
||||||
|
"description": "Initialize the contract, setting the caller as the admin. Must be called once after deployment.",
|
||||||
|
"args": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "propose",
|
||||||
|
"description": "Submit a proposal to change a named parameter. Anyone can propose. Logs 'proposed: <key>=<value>'.",
|
||||||
|
"args": [
|
||||||
|
{"name": "key", "type": "string", "description": "Parameter name (max 64 chars)"},
|
||||||
|
{"name": "value", "type": "string", "description": "Proposed new value (max 256 chars)"}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "approve",
|
||||||
|
"description": "Admin approves the pending proposal for a key, committing it as the live value. Logs 'approved: <key>'.",
|
||||||
|
"args": [
|
||||||
|
{"name": "key", "type": "string", "description": "Parameter key to approve"}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "reject",
|
||||||
|
"description": "Admin rejects and removes the pending proposal for a key. Logs 'rejected: <key>'.",
|
||||||
|
"args": [
|
||||||
|
{"name": "key", "type": "string", "description": "Parameter key to reject"}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "get",
|
||||||
|
"description": "Read the current live value of a parameter. Logs 'value: <value>' or 'not set: <key>'.",
|
||||||
|
"args": [
|
||||||
|
{"name": "key", "type": "string", "description": "Parameter key"}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "get_pending",
|
||||||
|
"description": "Read the pending proposed value. Logs 'pending: <value>' or 'no pending: <key>'.",
|
||||||
|
"args": [
|
||||||
|
{"name": "key", "type": "string", "description": "Parameter key"}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "set_admin",
|
||||||
|
"description": "Transfer the admin role to a new address. Only the current admin may call this. Logs 'admin: <new_admin>'.",
|
||||||
|
"args": [
|
||||||
|
{"name": "new_admin", "type": "string", "description": "New admin address (hex pubkey)"}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
38
contracts/hello_go/hello_go_abi.json
Normal file
38
contracts/hello_go/hello_go_abi.json
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
{
|
||||||
|
"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"}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
104
contracts/hello_go/main.go
Normal file
104
contracts/hello_go/main.go
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
// 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() {}
|
||||||
458
contracts/name_registry/gen/main.go
Normal file
458
contracts/name_registry/gen/main.go
Normal file
@@ -0,0 +1,458 @@
|
|||||||
|
// gen generates contracts/name_registry/name_registry.wasm
|
||||||
|
// Run from the repo root: go run ./contracts/name_registry/gen/
|
||||||
|
//
|
||||||
|
// Contract methods: register, resolve, transfer, release
|
||||||
|
// Host imports from "env": get_arg_str, get_caller, get_state, set_state, log
|
||||||
|
//
|
||||||
|
// Every contract action emits a human-readable log entry visible in the
|
||||||
|
// block explorer, e.g.:
|
||||||
|
// "registered: alice"
|
||||||
|
// "name taken: alice"
|
||||||
|
// "not found: alice"
|
||||||
|
// "owner: <pubkey>"
|
||||||
|
// "transferred: alice"
|
||||||
|
// "unauthorized: alice"
|
||||||
|
// "released: alice"
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── LEB128 ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func u(v uint64) []byte {
|
||||||
|
var b []byte
|
||||||
|
for {
|
||||||
|
bt := byte(v & 0x7f)
|
||||||
|
v >>= 7
|
||||||
|
if v != 0 {
|
||||||
|
bt |= 0x80
|
||||||
|
}
|
||||||
|
b = append(b, bt)
|
||||||
|
if v == 0 {
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func s(v int64) []byte {
|
||||||
|
var b []byte
|
||||||
|
for {
|
||||||
|
bt := byte(v & 0x7f)
|
||||||
|
v >>= 7
|
||||||
|
sign := (bt & 0x40) != 0
|
||||||
|
if (v == 0 && !sign) || (v == -1 && sign) {
|
||||||
|
return append(b, bt)
|
||||||
|
}
|
||||||
|
b = append(b, bt|0x80)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Builders ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func cat(slices ...[]byte) []byte {
|
||||||
|
var out []byte
|
||||||
|
for _, sl := range slices {
|
||||||
|
out = append(out, sl...)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func wstr(str string) []byte { return cat(u(uint64(len(str))), []byte(str)) }
|
||||||
|
|
||||||
|
func section(id byte, content []byte) []byte {
|
||||||
|
return cat([]byte{id}, u(uint64(len(content))), content)
|
||||||
|
}
|
||||||
|
|
||||||
|
func vec(items ...[]byte) []byte {
|
||||||
|
out := u(uint64(len(items)))
|
||||||
|
for _, it := range items {
|
||||||
|
out = append(out, it...)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func functype(params, results []byte) []byte {
|
||||||
|
return cat([]byte{0x60}, u(uint64(len(params))), params, u(uint64(len(results))), results)
|
||||||
|
}
|
||||||
|
|
||||||
|
func importFunc(mod, name string, typeIdx uint32) []byte {
|
||||||
|
return cat(wstr(mod), wstr(name), []byte{0x00}, u(uint64(typeIdx)))
|
||||||
|
}
|
||||||
|
|
||||||
|
func exportEntry(name string, kind byte, idx uint32) []byte {
|
||||||
|
return cat(wstr(name), []byte{kind}, u(uint64(idx)))
|
||||||
|
}
|
||||||
|
|
||||||
|
func dataSegment(offset int32, data []byte) []byte {
|
||||||
|
return cat(
|
||||||
|
[]byte{0x00},
|
||||||
|
[]byte{0x41}, s(int64(offset)), []byte{0x0B},
|
||||||
|
u(uint64(len(data))), data,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func funcBody(localDecls []byte, instrs ...[]byte) []byte {
|
||||||
|
inner := cat(localDecls)
|
||||||
|
for _, ins := range instrs {
|
||||||
|
inner = append(inner, ins...)
|
||||||
|
}
|
||||||
|
inner = append(inner, 0x0B) // end
|
||||||
|
return cat(u(uint64(len(inner))), inner)
|
||||||
|
}
|
||||||
|
|
||||||
|
var noLocals = u(0)
|
||||||
|
|
||||||
|
func localDecl(n uint32, typ byte) []byte { return cat(u(uint64(n)), []byte{typ}) }
|
||||||
|
func withLocals(decls ...[]byte) []byte {
|
||||||
|
return cat(u(uint64(len(decls))), cat(decls...))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Instructions ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const (
|
||||||
|
tI32 byte = 0x7F
|
||||||
|
tI64 byte = 0x7E
|
||||||
|
)
|
||||||
|
|
||||||
|
func call(fn uint32) []byte { return cat([]byte{0x10}, u(uint64(fn))) }
|
||||||
|
func lget(i uint32) []byte { return cat([]byte{0x20}, u(uint64(i))) }
|
||||||
|
func lset(i uint32) []byte { return cat([]byte{0x21}, u(uint64(i))) }
|
||||||
|
func ic32(v int32) []byte { return cat([]byte{0x41}, s(int64(v))) }
|
||||||
|
func block_() []byte { return []byte{0x02, 0x40} }
|
||||||
|
func loop_() []byte { return []byte{0x03, 0x40} }
|
||||||
|
func if_() []byte { return []byte{0x04, 0x40} }
|
||||||
|
func ifI32() []byte { return []byte{0x04, tI32} } // if that returns i32
|
||||||
|
func else_() []byte { return []byte{0x05} }
|
||||||
|
func end_() []byte { return []byte{0x0B} }
|
||||||
|
func br_(lbl uint32) []byte { return cat([]byte{0x0C}, u(uint64(lbl))) }
|
||||||
|
func brIf_(lbl uint32) []byte { return cat([]byte{0x0D}, u(uint64(lbl))) }
|
||||||
|
func return_() []byte { return []byte{0x0F} }
|
||||||
|
func i32Eqz() []byte { return []byte{0x45} }
|
||||||
|
func i32Ne() []byte { return []byte{0x47} }
|
||||||
|
func i32GtU() []byte { return []byte{0x4B} }
|
||||||
|
func i32GeU() []byte { return []byte{0x4F} }
|
||||||
|
func i32Add() []byte { return []byte{0x6A} }
|
||||||
|
func i32Load8U() []byte { return []byte{0x2D, 0x00, 0x00} }
|
||||||
|
func i32Store8() []byte { return []byte{0x3A, 0x00, 0x00} }
|
||||||
|
|
||||||
|
// ── Memory layout ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const (
|
||||||
|
offArg0 = 0x000 // arg[0] name buffer (64 bytes)
|
||||||
|
offArg1 = 0x040 // arg[1] new_owner buffer (128 bytes)
|
||||||
|
offCaller = 0x0C0 // caller pubkey buffer (128 bytes)
|
||||||
|
offStateRead = 0x140 // existing owner buffer (128 bytes)
|
||||||
|
|
||||||
|
// Verbose prefix strings — each ends with ": " for readable log messages.
|
||||||
|
offRegisteredPfx = 0x200 // "registered: " 12 bytes
|
||||||
|
offNameTakenPfx = 0x20C // "name taken: " 12 bytes
|
||||||
|
offNotFoundPfx = 0x218 // "not found: " 11 bytes
|
||||||
|
offOwnerPfx = 0x224 // "owner: " 7 bytes
|
||||||
|
offTransferredPfx = 0x22C // "transferred: " 13 bytes
|
||||||
|
offUnauthPfx = 0x23A // "unauthorized: " 14 bytes
|
||||||
|
offReleasedPfx = 0x249 // "released: " 10 bytes
|
||||||
|
|
||||||
|
// Scratch buffer for building concatenated log messages.
|
||||||
|
offScratch = 0x300 // 256 bytes
|
||||||
|
)
|
||||||
|
|
||||||
|
// Import function indices (order must match importSection below)
|
||||||
|
const (
|
||||||
|
fnGetArgStr = 0 // get_arg_str(idx, dstPtr, dstLen i32) → i32
|
||||||
|
fnGetCaller = 1 // get_caller(bufPtr, bufLen i32) → i32
|
||||||
|
fnGetState = 2 // get_state(kP,kL,dP,dL i32) → i32
|
||||||
|
fnSetState = 3 // set_state(kP,kL,vP,vL i32)
|
||||||
|
fnLog = 4 // log(msgPtr, msgLen i32)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Local function indices (imports first, then locals in declaration order)
|
||||||
|
const (
|
||||||
|
fnBytesEqual = 5 // $bytes_equal(aPtr,bPtr,len i32) → i32
|
||||||
|
fnMemcpy = 6 // $memcpy(dst,src,len i32)
|
||||||
|
fnLogPrefixName = 7 // $log_prefix_name(pfxPtr,pfxLen,sfxPtr,sfxLen i32)
|
||||||
|
fnRegister = 8
|
||||||
|
fnResolve = 9
|
||||||
|
fnTransfer = 10
|
||||||
|
fnRelease = 11
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── $bytes_equal helper ───────────────────────────────────────────────────────
|
||||||
|
// (aPtr i32, bPtr i32, len i32) → i32
|
||||||
|
// locals: i(3), same(4)
|
||||||
|
func bytesEqualBody() []byte {
|
||||||
|
return funcBody(
|
||||||
|
withLocals(localDecl(2, tI32)), // locals 3=i, 4=same
|
||||||
|
// same = 1; i = 0
|
||||||
|
ic32(1), lset(4),
|
||||||
|
ic32(0), lset(3),
|
||||||
|
block_(),
|
||||||
|
loop_(),
|
||||||
|
lget(3), lget(2), i32GeU(), brIf_(1), // i >= len → exit block
|
||||||
|
// load mem[aPtr+i]
|
||||||
|
lget(0), lget(3), i32Add(), i32Load8U(),
|
||||||
|
// load mem[bPtr+i]
|
||||||
|
lget(1), lget(3), i32Add(), i32Load8U(),
|
||||||
|
i32Ne(), if_(),
|
||||||
|
ic32(0), lset(4),
|
||||||
|
br_(2), // exit block
|
||||||
|
end_(),
|
||||||
|
lget(3), ic32(1), i32Add(), lset(3),
|
||||||
|
br_(0), // next iteration
|
||||||
|
end_(),
|
||||||
|
end_(),
|
||||||
|
lget(4), // return same
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── $memcpy helper ────────────────────────────────────────────────────────────
|
||||||
|
// (dst i32, src i32, len i32) — copies len bytes from src to dst.
|
||||||
|
// locals: i(3)
|
||||||
|
func memcpyBody() []byte {
|
||||||
|
return funcBody(
|
||||||
|
withLocals(localDecl(1, tI32)), // local 3 = i
|
||||||
|
ic32(0), lset(3), // i = 0
|
||||||
|
block_(),
|
||||||
|
loop_(),
|
||||||
|
lget(3), lget(2), i32GeU(), brIf_(1), // if i >= len: exit
|
||||||
|
// mem[dst+i] = mem[src+i]
|
||||||
|
lget(0), lget(3), i32Add(), // dst+i (store address)
|
||||||
|
lget(1), lget(3), i32Add(), i32Load8U(), // load mem[src+i]
|
||||||
|
i32Store8(),
|
||||||
|
lget(3), ic32(1), i32Add(), lset(3), // i++
|
||||||
|
br_(0),
|
||||||
|
end_(),
|
||||||
|
end_(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── $log_prefix_name helper ───────────────────────────────────────────────────
|
||||||
|
// (prefixPtr i32, prefixLen i32, suffixPtr i32, suffixLen i32)
|
||||||
|
// Builds "prefix<suffix>" in scratch buffer and logs it.
|
||||||
|
func logPrefixNameBody() []byte {
|
||||||
|
return funcBody(
|
||||||
|
noLocals,
|
||||||
|
// memcpy(offScratch, prefixPtr, prefixLen)
|
||||||
|
ic32(offScratch), lget(0), lget(1), call(fnMemcpy),
|
||||||
|
// memcpy(offScratch + prefixLen, suffixPtr, suffixLen)
|
||||||
|
ic32(offScratch), lget(1), i32Add(), lget(2), lget(3), call(fnMemcpy),
|
||||||
|
// log(offScratch, prefixLen + suffixLen)
|
||||||
|
ic32(offScratch), lget(1), lget(3), i32Add(), call(fnLog),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── isOwner: shared caller-vs-existing check ──────────────────────────────────
|
||||||
|
// Assumes: caller is at offCaller with len callerLenLocal,
|
||||||
|
// existing is at offStateRead with len existingLenLocal.
|
||||||
|
// Returns instructions that leave i32 (1=same, 0=not) on stack.
|
||||||
|
func isOwnerCheck(callerLenLocal, existingLenLocal uint32) []byte {
|
||||||
|
return cat(
|
||||||
|
// if callerLen != existingLen → push 0
|
||||||
|
ifI32(),
|
||||||
|
ic32(0),
|
||||||
|
else_(),
|
||||||
|
// else call bytes_equal(offCaller, offStateRead, callerLen)
|
||||||
|
ic32(offCaller), ic32(offStateRead), lget(callerLenLocal),
|
||||||
|
call(fnBytesEqual),
|
||||||
|
end_(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
// ── Type section ──────────────────────────────────────────────────────────
|
||||||
|
// Type 0: (i32,i32,i32)→(i32) get_arg_str, bytes_equal
|
||||||
|
// Type 1: (i32,i32)→(i32) get_caller
|
||||||
|
// Type 2: (i32,i32,i32,i32)→(i32) get_state
|
||||||
|
// Type 3: (i32,i32,i32,i32)→() set_state, log_prefix_name
|
||||||
|
// Type 4: (i32,i32)→() log
|
||||||
|
// Type 5: ()→() register, resolve, transfer, release
|
||||||
|
// Type 6: (i32,i32,i32)→() memcpy
|
||||||
|
typeSection := section(0x01, vec(
|
||||||
|
functype([]byte{tI32, tI32, tI32}, []byte{tI32}), // 0
|
||||||
|
functype([]byte{tI32, tI32}, []byte{tI32}), // 1
|
||||||
|
functype([]byte{tI32, tI32, tI32, tI32}, []byte{tI32}), // 2
|
||||||
|
functype([]byte{tI32, tI32, tI32, tI32}, []byte{}), // 3
|
||||||
|
functype([]byte{tI32, tI32}, []byte{}), // 4
|
||||||
|
functype([]byte{}, []byte{}), // 5
|
||||||
|
functype([]byte{tI32, tI32, tI32}, []byte{}), // 6
|
||||||
|
))
|
||||||
|
|
||||||
|
// ── Import section ────────────────────────────────────────────────────────
|
||||||
|
importSection := section(0x02, vec(
|
||||||
|
importFunc("env", "get_arg_str", 0), // fnGetArgStr=0 type 0
|
||||||
|
importFunc("env", "get_caller", 1), // fnGetCaller=1 type 1
|
||||||
|
importFunc("env", "get_state", 2), // fnGetState=2 type 2
|
||||||
|
importFunc("env", "set_state", 3), // fnSetState=3 type 3
|
||||||
|
importFunc("env", "log", 4), // fnLog=4 type 4
|
||||||
|
))
|
||||||
|
|
||||||
|
// ── Function section: 7 local functions ──────────────────────────────────
|
||||||
|
functionSection := section(0x03, vec(
|
||||||
|
u(0), // bytes_equal → type 0
|
||||||
|
u(6), // memcpy → type 6
|
||||||
|
u(3), // log_prefix_name → type 3
|
||||||
|
u(5), // register → type 5
|
||||||
|
u(5), // resolve → type 5
|
||||||
|
u(5), // transfer → type 5
|
||||||
|
u(5), // release → type 5
|
||||||
|
))
|
||||||
|
|
||||||
|
// ── Memory section ────────────────────────────────────────────────────────
|
||||||
|
memorySection := section(0x05, vec(cat([]byte{0x00}, u(1))))
|
||||||
|
|
||||||
|
// ── Export section ────────────────────────────────────────────────────────
|
||||||
|
exportSection := section(0x07, vec(
|
||||||
|
exportEntry("memory", 0x02, 0),
|
||||||
|
exportEntry("register", 0x00, fnRegister),
|
||||||
|
exportEntry("resolve", 0x00, fnResolve),
|
||||||
|
exportEntry("transfer", 0x00, fnTransfer),
|
||||||
|
exportEntry("release", 0x00, fnRelease),
|
||||||
|
))
|
||||||
|
|
||||||
|
// ── Data section ──────────────────────────────────────────────────────────
|
||||||
|
dataSection := section(0x0B, cat(
|
||||||
|
u(7),
|
||||||
|
dataSegment(offRegisteredPfx, []byte("registered: ")),
|
||||||
|
dataSegment(offNameTakenPfx, []byte("name taken: ")),
|
||||||
|
dataSegment(offNotFoundPfx, []byte("not found: ")),
|
||||||
|
dataSegment(offOwnerPfx, []byte("owner: ")),
|
||||||
|
dataSegment(offTransferredPfx, []byte("transferred: ")),
|
||||||
|
dataSegment(offUnauthPfx, []byte("unauthorized: ")),
|
||||||
|
dataSegment(offReleasedPfx, []byte("released: ")),
|
||||||
|
))
|
||||||
|
|
||||||
|
// ── Code section ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// register(): locals nameLen(0), callerLen(1), existingLen(2)
|
||||||
|
registerBody := funcBody(
|
||||||
|
withLocals(localDecl(3, tI32)),
|
||||||
|
// nameLen = get_arg_str(0, offArg0, 64)
|
||||||
|
ic32(0), ic32(offArg0), ic32(64), call(fnGetArgStr), lset(0),
|
||||||
|
// if nameLen == 0: return
|
||||||
|
lget(0), i32Eqz(), if_(), return_(), end_(),
|
||||||
|
// existingLen = get_state(offArg0, nameLen, offStateRead, 128)
|
||||||
|
ic32(offArg0), lget(0), ic32(offStateRead), ic32(128), call(fnGetState), lset(2),
|
||||||
|
// if existingLen > 0: log("name taken: <name>"); return
|
||||||
|
lget(2), ic32(0), i32GtU(), if_(),
|
||||||
|
ic32(offNameTakenPfx), ic32(12), ic32(offArg0), lget(0), call(fnLogPrefixName),
|
||||||
|
return_(),
|
||||||
|
end_(),
|
||||||
|
// callerLen = get_caller(offCaller, 128)
|
||||||
|
ic32(offCaller), ic32(128), call(fnGetCaller), lset(1),
|
||||||
|
// set_state(offArg0, nameLen, offCaller, callerLen)
|
||||||
|
ic32(offArg0), lget(0), ic32(offCaller), lget(1), call(fnSetState),
|
||||||
|
// log("registered: <name>")
|
||||||
|
ic32(offRegisteredPfx), ic32(12), ic32(offArg0), lget(0), call(fnLogPrefixName),
|
||||||
|
)
|
||||||
|
|
||||||
|
// resolve(): locals nameLen(0), ownerLen(1)
|
||||||
|
resolveBody := funcBody(
|
||||||
|
withLocals(localDecl(2, tI32)),
|
||||||
|
// nameLen = get_arg_str(0, offArg0, 64)
|
||||||
|
ic32(0), ic32(offArg0), ic32(64), call(fnGetArgStr), lset(0),
|
||||||
|
lget(0), i32Eqz(), if_(), return_(), end_(),
|
||||||
|
// ownerLen = get_state(offArg0, nameLen, offStateRead, 128)
|
||||||
|
ic32(offArg0), lget(0), ic32(offStateRead), ic32(128), call(fnGetState), lset(1),
|
||||||
|
// if ownerLen == 0: log("not found: <name>"); return
|
||||||
|
lget(1), i32Eqz(), if_(),
|
||||||
|
ic32(offNotFoundPfx), ic32(11), ic32(offArg0), lget(0), call(fnLogPrefixName),
|
||||||
|
return_(),
|
||||||
|
end_(),
|
||||||
|
// log("owner: <pubkey>")
|
||||||
|
// The pubkey stored in state is the raw caller string (hex-encoded),
|
||||||
|
// so the log will display the human-readable address.
|
||||||
|
ic32(offOwnerPfx), ic32(7), ic32(offStateRead), lget(1), call(fnLogPrefixName),
|
||||||
|
)
|
||||||
|
|
||||||
|
// transfer(): locals nameLen(0), newOwnerLen(1), callerLen(2), existingLen(3)
|
||||||
|
transferBody := funcBody(
|
||||||
|
withLocals(localDecl(4, tI32)),
|
||||||
|
// nameLen = get_arg_str(0, offArg0, 64)
|
||||||
|
ic32(0), ic32(offArg0), ic32(64), call(fnGetArgStr), lset(0),
|
||||||
|
lget(0), i32Eqz(), if_(), return_(), end_(),
|
||||||
|
// newOwnerLen = get_arg_str(1, offArg1, 128)
|
||||||
|
ic32(1), ic32(offArg1), ic32(128), call(fnGetArgStr), lset(1),
|
||||||
|
lget(1), i32Eqz(), if_(), return_(), end_(),
|
||||||
|
// existingLen = get_state(offArg0, nameLen, offStateRead, 128)
|
||||||
|
ic32(offArg0), lget(0), ic32(offStateRead), ic32(128), call(fnGetState), lset(3),
|
||||||
|
// if existingLen == 0: not registered → anyone can claim
|
||||||
|
lget(3), i32Eqz(), if_(),
|
||||||
|
ic32(offArg0), lget(0), ic32(offArg1), lget(1), call(fnSetState),
|
||||||
|
ic32(offTransferredPfx), ic32(13), ic32(offArg0), lget(0), call(fnLogPrefixName),
|
||||||
|
return_(),
|
||||||
|
end_(),
|
||||||
|
// callerLen = get_caller(offCaller, 128)
|
||||||
|
ic32(offCaller), ic32(128), call(fnGetCaller), lset(2),
|
||||||
|
// isOwner = (callerLen == existingLen) ? bytes_equal(...) : 0
|
||||||
|
lget(2), lget(3), i32Ne(),
|
||||||
|
isOwnerCheck(2, 3),
|
||||||
|
// if !isOwner: log("unauthorized: <name>"); return
|
||||||
|
i32Eqz(), if_(),
|
||||||
|
ic32(offUnauthPfx), ic32(14), ic32(offArg0), lget(0), call(fnLogPrefixName),
|
||||||
|
return_(),
|
||||||
|
end_(),
|
||||||
|
// Authorized
|
||||||
|
ic32(offArg0), lget(0), ic32(offArg1), lget(1), call(fnSetState),
|
||||||
|
ic32(offTransferredPfx), ic32(13), ic32(offArg0), lget(0), call(fnLogPrefixName),
|
||||||
|
)
|
||||||
|
|
||||||
|
// release(): locals nameLen(0), callerLen(1), existingLen(2)
|
||||||
|
releaseBody := funcBody(
|
||||||
|
withLocals(localDecl(3, tI32)),
|
||||||
|
// nameLen = get_arg_str(0, offArg0, 64)
|
||||||
|
ic32(0), ic32(offArg0), ic32(64), call(fnGetArgStr), lset(0),
|
||||||
|
lget(0), i32Eqz(), if_(), return_(), end_(),
|
||||||
|
// existingLen = get_state(offArg0, nameLen, offStateRead, 128)
|
||||||
|
ic32(offArg0), lget(0), ic32(offStateRead), ic32(128), call(fnGetState), lset(2),
|
||||||
|
// if existingLen == 0: log("not found: <name>"); return
|
||||||
|
lget(2), i32Eqz(), if_(),
|
||||||
|
ic32(offNotFoundPfx), ic32(11), ic32(offArg0), lget(0), call(fnLogPrefixName),
|
||||||
|
return_(),
|
||||||
|
end_(),
|
||||||
|
// callerLen = get_caller(offCaller, 128)
|
||||||
|
ic32(offCaller), ic32(128), call(fnGetCaller), lset(1),
|
||||||
|
// isOwner check
|
||||||
|
lget(1), lget(2), i32Ne(),
|
||||||
|
isOwnerCheck(1, 2),
|
||||||
|
i32Eqz(), if_(),
|
||||||
|
ic32(offUnauthPfx), ic32(14), ic32(offArg0), lget(0), call(fnLogPrefixName),
|
||||||
|
return_(),
|
||||||
|
end_(),
|
||||||
|
// Store empty value → effectively releases the name
|
||||||
|
ic32(offArg0), lget(0), ic32(offArg0), ic32(0), call(fnSetState),
|
||||||
|
ic32(offReleasedPfx), ic32(10), ic32(offArg0), lget(0), call(fnLogPrefixName),
|
||||||
|
)
|
||||||
|
|
||||||
|
codeSection := section(0x0A, cat(
|
||||||
|
u(7),
|
||||||
|
bytesEqualBody(),
|
||||||
|
memcpyBody(),
|
||||||
|
logPrefixNameBody(),
|
||||||
|
registerBody,
|
||||||
|
resolveBody,
|
||||||
|
transferBody,
|
||||||
|
releaseBody,
|
||||||
|
))
|
||||||
|
|
||||||
|
// ── Assemble module ───────────────────────────────────────────────────────
|
||||||
|
module := cat(
|
||||||
|
[]byte{0x00, 0x61, 0x73, 0x6d}, // \0asm
|
||||||
|
[]byte{0x01, 0x00, 0x00, 0x00}, // version 1
|
||||||
|
typeSection,
|
||||||
|
importSection,
|
||||||
|
functionSection,
|
||||||
|
memorySection,
|
||||||
|
exportSection,
|
||||||
|
dataSection,
|
||||||
|
codeSection,
|
||||||
|
)
|
||||||
|
|
||||||
|
out := "contracts/name_registry/name_registry.wasm"
|
||||||
|
if err := os.WriteFile(out, module, 0644); err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, "write:", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
fmt.Printf("Written %s (%d bytes)\n", out, len(module))
|
||||||
|
}
|
||||||
BIN
contracts/name_registry/name_registry.wasm
Normal file
BIN
contracts/name_registry/name_registry.wasm
Normal file
Binary file not shown.
301
contracts/name_registry/name_registry.wat
Normal file
301
contracts/name_registry/name_registry.wat
Normal file
@@ -0,0 +1,301 @@
|
|||||||
|
(module
|
||||||
|
;; Name Registry smart contract
|
||||||
|
;;
|
||||||
|
;; Maps human-readable names → owner public keys on-chain.
|
||||||
|
;; Each name can only be registered once; only the current owner can
|
||||||
|
;; transfer or release it.
|
||||||
|
;;
|
||||||
|
;; Methods (all void, no WASM return values):
|
||||||
|
;; register(name string) — claim a name for the caller
|
||||||
|
;; resolve(name string) — log the current owner pubkey
|
||||||
|
;; transfer(name string, new_owner string) — give name to another pubkey
|
||||||
|
;; release(name string) — delete name registration
|
||||||
|
;;
|
||||||
|
;; State keys: the raw name bytes → owner pubkey bytes.
|
||||||
|
;; All args come in via env.get_arg_str / env.get_arg_u64.
|
||||||
|
|
||||||
|
;; ── imports ──────────────────────────────────────────────────────────────
|
||||||
|
(import "env" "get_arg_str"
|
||||||
|
(func $get_arg_str (param i32 i32 i32) (result i32)))
|
||||||
|
(import "env" "get_caller"
|
||||||
|
(func $get_caller (param i32 i32) (result i32)))
|
||||||
|
(import "env" "get_state"
|
||||||
|
(func $get_state (param i32 i32 i32 i32) (result i32)))
|
||||||
|
(import "env" "set_state"
|
||||||
|
(func $set_state (param i32 i32 i32 i32)))
|
||||||
|
(import "env" "log"
|
||||||
|
(func $log (param i32 i32)))
|
||||||
|
|
||||||
|
;; ── memory ───────────────────────────────────────────────────────────────
|
||||||
|
;; Offset Size Purpose
|
||||||
|
;; 0x000 64 arg[0] buffer — name (max 64 bytes)
|
||||||
|
;; 0x040 128 arg[1] buffer — new_owner pubkey (max 128 bytes)
|
||||||
|
;; 0x0C0 128 caller pubkey buffer
|
||||||
|
;; 0x140 128 state-read buffer (existing owner)
|
||||||
|
;; 0x200 ~128 verbose log prefix strings
|
||||||
|
;; 0x300 256 scratch buffer — build "prefix: name" log messages
|
||||||
|
(memory (export "memory") 1)
|
||||||
|
|
||||||
|
;; ── verbose log prefix strings ───────────────────────────────────────────
|
||||||
|
;; Each entry is a human-readable prefix ending with ": " so that the
|
||||||
|
;; log message becomes "prefix: <argument>" — readable in the explorer.
|
||||||
|
;;
|
||||||
|
;; "registered: " 12 bytes @ 0x200
|
||||||
|
;; "name taken: " 12 bytes @ 0x20C
|
||||||
|
;; "not found: " 11 bytes @ 0x218
|
||||||
|
;; "owner: " 7 bytes @ 0x224
|
||||||
|
;; "transferred: " 13 bytes @ 0x22C
|
||||||
|
;; "unauthorized: " 14 bytes @ 0x23A
|
||||||
|
;; "released: " 10 bytes @ 0x249
|
||||||
|
(data (i32.const 0x200) "registered: ")
|
||||||
|
(data (i32.const 0x20C) "name taken: ")
|
||||||
|
(data (i32.const 0x218) "not found: ")
|
||||||
|
(data (i32.const 0x224) "owner: ")
|
||||||
|
(data (i32.const 0x22C) "transferred: ")
|
||||||
|
(data (i32.const 0x23A) "unauthorized: ")
|
||||||
|
(data (i32.const 0x249) "released: ")
|
||||||
|
|
||||||
|
;; ── helpers ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
;; $memcpy: copy len bytes from src to dst
|
||||||
|
(func $memcpy (param $dst i32) (param $src i32) (param $len i32)
|
||||||
|
(local $i i32)
|
||||||
|
(local.set $i (i32.const 0))
|
||||||
|
(block $break
|
||||||
|
(loop $loop
|
||||||
|
(br_if $break (i32.ge_u (local.get $i) (local.get $len)))
|
||||||
|
(i32.store8
|
||||||
|
(i32.add (local.get $dst) (local.get $i))
|
||||||
|
(i32.load8_u (i32.add (local.get $src) (local.get $i))))
|
||||||
|
(local.set $i (i32.add (local.get $i) (i32.const 1)))
|
||||||
|
(br $loop)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
;; $log_prefix_name: build "<prefix><suffix>" in scratch buf 0x300 and log it.
|
||||||
|
;; prefixPtr / prefixLen — the prefix string (e.g. "registered: ", 12 bytes)
|
||||||
|
;; suffixPtr / suffixLen — the name / pubkey to append
|
||||||
|
(func $log_prefix_name
|
||||||
|
(param $prefixPtr i32) (param $prefixLen i32)
|
||||||
|
(param $suffixPtr i32) (param $suffixLen i32)
|
||||||
|
;; copy prefix → scratch[0]
|
||||||
|
(call $memcpy (i32.const 0x300) (local.get $prefixPtr) (local.get $prefixLen))
|
||||||
|
;; copy suffix → scratch[prefixLen]
|
||||||
|
(call $memcpy
|
||||||
|
(i32.add (i32.const 0x300) (local.get $prefixLen))
|
||||||
|
(local.get $suffixPtr)
|
||||||
|
(local.get $suffixLen))
|
||||||
|
;; log scratch[0 .. prefixLen+suffixLen)
|
||||||
|
(call $log
|
||||||
|
(i32.const 0x300)
|
||||||
|
(i32.add (local.get $prefixLen) (local.get $suffixLen)))
|
||||||
|
)
|
||||||
|
|
||||||
|
;; $bytes_equal: compare mem[aPtr..aPtr+len) with mem[bPtr..bPtr+len)
|
||||||
|
;; Params: aPtr(0) bPtr(1) len(2)
|
||||||
|
;; Result: i32 1 = equal, 0 = not equal
|
||||||
|
(func $bytes_equal (param i32 i32 i32) (result i32)
|
||||||
|
(local $i i32)
|
||||||
|
(local $same i32)
|
||||||
|
(local.set $same (i32.const 1))
|
||||||
|
(local.set $i (i32.const 0))
|
||||||
|
(block $break
|
||||||
|
(loop $loop
|
||||||
|
(br_if $break (i32.ge_u (local.get $i) (local.get 2)))
|
||||||
|
(if (i32.ne
|
||||||
|
(i32.load8_u (i32.add (local.get 0) (local.get $i)))
|
||||||
|
(i32.load8_u (i32.add (local.get 1) (local.get $i))))
|
||||||
|
(then (local.set $same (i32.const 0)) (br $break))
|
||||||
|
)
|
||||||
|
(local.set $i (i32.add (local.get $i) (i32.const 1)))
|
||||||
|
(br $loop)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
(local.get $same)
|
||||||
|
)
|
||||||
|
|
||||||
|
;; ── register(name) ────────────────────────────────────────────────────────
|
||||||
|
;; Claims `name` for the caller.
|
||||||
|
;; Logs "registered: <name>" on success, "name taken: <name>" on conflict.
|
||||||
|
(func (export "register")
|
||||||
|
(local $nameLen i32)
|
||||||
|
(local $callerLen i32)
|
||||||
|
(local $existingLen i32)
|
||||||
|
|
||||||
|
;; Read name into 0x000, max 64 bytes
|
||||||
|
(local.set $nameLen
|
||||||
|
(call $get_arg_str (i32.const 0) (i32.const 0x000) (i32.const 64)))
|
||||||
|
(if (i32.eqz (local.get $nameLen)) (then return))
|
||||||
|
|
||||||
|
;; Check if name is already taken
|
||||||
|
(local.set $existingLen
|
||||||
|
(call $get_state
|
||||||
|
(i32.const 0x000) (local.get $nameLen)
|
||||||
|
(i32.const 0x140) (i32.const 128)))
|
||||||
|
(if (i32.gt_u (local.get $existingLen) (i32.const 0))
|
||||||
|
(then
|
||||||
|
(call $log_prefix_name
|
||||||
|
(i32.const 0x20C) (i32.const 12) ;; "name taken: "
|
||||||
|
(i32.const 0x000) (local.get $nameLen))
|
||||||
|
return
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
;; Store: state[name] = caller_pubkey
|
||||||
|
(local.set $callerLen
|
||||||
|
(call $get_caller (i32.const 0x0C0) (i32.const 128)))
|
||||||
|
(call $set_state
|
||||||
|
(i32.const 0x000) (local.get $nameLen)
|
||||||
|
(i32.const 0x0C0) (local.get $callerLen))
|
||||||
|
|
||||||
|
(call $log_prefix_name
|
||||||
|
(i32.const 0x200) (i32.const 12) ;; "registered: "
|
||||||
|
(i32.const 0x000) (local.get $nameLen))
|
||||||
|
)
|
||||||
|
|
||||||
|
;; ── resolve(name) ─────────────────────────────────────────────────────────
|
||||||
|
;; Logs "owner: <pubkey>" for the registered name, or "not found: <name>".
|
||||||
|
(func (export "resolve")
|
||||||
|
(local $nameLen i32)
|
||||||
|
(local $ownerLen i32)
|
||||||
|
|
||||||
|
(local.set $nameLen
|
||||||
|
(call $get_arg_str (i32.const 0) (i32.const 0x000) (i32.const 64)))
|
||||||
|
(if (i32.eqz (local.get $nameLen)) (then return))
|
||||||
|
|
||||||
|
(local.set $ownerLen
|
||||||
|
(call $get_state
|
||||||
|
(i32.const 0x000) (local.get $nameLen)
|
||||||
|
(i32.const 0x140) (i32.const 128)))
|
||||||
|
(if (i32.eqz (local.get $ownerLen))
|
||||||
|
(then
|
||||||
|
(call $log_prefix_name
|
||||||
|
(i32.const 0x218) (i32.const 11) ;; "not found: "
|
||||||
|
(i32.const 0x000) (local.get $nameLen))
|
||||||
|
return
|
||||||
|
)
|
||||||
|
)
|
||||||
|
;; Log "owner: <pubkey bytes>"
|
||||||
|
;; The pubkey stored in state is the raw caller pubkey bytes that the
|
||||||
|
;; host wrote via get_caller — these are the hex-encoded public key
|
||||||
|
;; string bytes, so the log will show the readable hex address.
|
||||||
|
(call $log_prefix_name
|
||||||
|
(i32.const 0x224) (i32.const 7) ;; "owner: "
|
||||||
|
(i32.const 0x140) (local.get $ownerLen))
|
||||||
|
)
|
||||||
|
|
||||||
|
;; ── transfer(name, new_owner) ─────────────────────────────────────────────
|
||||||
|
;; Transfers ownership of `name` to `new_owner`.
|
||||||
|
;; Only the current owner may call this (or anyone if name is unregistered).
|
||||||
|
;; Logs "transferred: <name>" on success, "unauthorized: <name>" otherwise.
|
||||||
|
(func (export "transfer")
|
||||||
|
(local $nameLen i32)
|
||||||
|
(local $newOwnerLen i32)
|
||||||
|
(local $callerLen i32)
|
||||||
|
(local $existingLen i32)
|
||||||
|
|
||||||
|
(local.set $nameLen
|
||||||
|
(call $get_arg_str (i32.const 0) (i32.const 0x000) (i32.const 64)))
|
||||||
|
(if (i32.eqz (local.get $nameLen)) (then return))
|
||||||
|
|
||||||
|
(local.set $newOwnerLen
|
||||||
|
(call $get_arg_str (i32.const 1) (i32.const 0x040) (i32.const 128)))
|
||||||
|
(if (i32.eqz (local.get $newOwnerLen)) (then return))
|
||||||
|
|
||||||
|
;; Read existing owner into 0x140
|
||||||
|
(local.set $existingLen
|
||||||
|
(call $get_state
|
||||||
|
(i32.const 0x000) (local.get $nameLen)
|
||||||
|
(i32.const 0x140) (i32.const 128)))
|
||||||
|
|
||||||
|
;; If not registered, anyone can claim → register directly for new_owner
|
||||||
|
(if (i32.eqz (local.get $existingLen))
|
||||||
|
(then
|
||||||
|
(call $set_state
|
||||||
|
(i32.const 0x000) (local.get $nameLen)
|
||||||
|
(i32.const 0x040) (local.get $newOwnerLen))
|
||||||
|
(call $log_prefix_name
|
||||||
|
(i32.const 0x22C) (i32.const 13) ;; "transferred: "
|
||||||
|
(i32.const 0x000) (local.get $nameLen))
|
||||||
|
return
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
;; Verify caller == existing owner
|
||||||
|
(local.set $callerLen
|
||||||
|
(call $get_caller (i32.const 0x0C0) (i32.const 128)))
|
||||||
|
(if (i32.eqz
|
||||||
|
(call $bytes_equal
|
||||||
|
(i32.const 0x0C0) (i32.const 0x140)
|
||||||
|
(if (result i32) (i32.ne (local.get $callerLen) (local.get $existingLen))
|
||||||
|
(then (i32.const 0)) ;; length mismatch → not equal
|
||||||
|
(else (local.get $callerLen)))))
|
||||||
|
(then
|
||||||
|
(call $log_prefix_name
|
||||||
|
(i32.const 0x23A) (i32.const 14) ;; "unauthorized: "
|
||||||
|
(i32.const 0x000) (local.get $nameLen))
|
||||||
|
return
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
;; Authorized — update owner
|
||||||
|
(call $set_state
|
||||||
|
(i32.const 0x000) (local.get $nameLen)
|
||||||
|
(i32.const 0x040) (local.get $newOwnerLen))
|
||||||
|
(call $log_prefix_name
|
||||||
|
(i32.const 0x22C) (i32.const 13) ;; "transferred: "
|
||||||
|
(i32.const 0x000) (local.get $nameLen))
|
||||||
|
)
|
||||||
|
|
||||||
|
;; ── release(name) ─────────────────────────────────────────────────────────
|
||||||
|
;; Removes a name registration. Only the current owner may call this.
|
||||||
|
;; Logs "released: <name>" on success.
|
||||||
|
(func (export "release")
|
||||||
|
(local $nameLen i32)
|
||||||
|
(local $callerLen i32)
|
||||||
|
(local $existingLen i32)
|
||||||
|
|
||||||
|
(local.set $nameLen
|
||||||
|
(call $get_arg_str (i32.const 0) (i32.const 0x000) (i32.const 64)))
|
||||||
|
(if (i32.eqz (local.get $nameLen)) (then return))
|
||||||
|
|
||||||
|
(local.set $existingLen
|
||||||
|
(call $get_state
|
||||||
|
(i32.const 0x000) (local.get $nameLen)
|
||||||
|
(i32.const 0x140) (i32.const 128)))
|
||||||
|
(if (i32.eqz (local.get $existingLen))
|
||||||
|
(then
|
||||||
|
(call $log_prefix_name
|
||||||
|
(i32.const 0x218) (i32.const 11) ;; "not found: "
|
||||||
|
(i32.const 0x000) (local.get $nameLen))
|
||||||
|
return
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
;; Verify caller == owner
|
||||||
|
(local.set $callerLen
|
||||||
|
(call $get_caller (i32.const 0x0C0) (i32.const 128)))
|
||||||
|
(if (i32.eqz
|
||||||
|
(call $bytes_equal
|
||||||
|
(i32.const 0x0C0) (i32.const 0x140)
|
||||||
|
(if (result i32) (i32.ne (local.get $callerLen) (local.get $existingLen))
|
||||||
|
(then (i32.const 0))
|
||||||
|
(else (local.get $callerLen)))))
|
||||||
|
(then
|
||||||
|
(call $log_prefix_name
|
||||||
|
(i32.const 0x23A) (i32.const 14) ;; "unauthorized: "
|
||||||
|
(i32.const 0x000) (local.get $nameLen))
|
||||||
|
return
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
;; Store empty bytes → effectively deletes the record
|
||||||
|
(call $set_state
|
||||||
|
(i32.const 0x000) (local.get $nameLen)
|
||||||
|
(i32.const 0x000) (i32.const 0))
|
||||||
|
(call $log_prefix_name
|
||||||
|
(i32.const 0x249) (i32.const 10) ;; "released: "
|
||||||
|
(i32.const 0x000) (local.get $nameLen))
|
||||||
|
)
|
||||||
|
)
|
||||||
6
contracts/name_registry/name_registry_abi.json
Normal file
6
contracts/name_registry/name_registry_abi.json
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
{"methods":[
|
||||||
|
{"name":"register", "args":[{"name":"name","type":"string"}]},
|
||||||
|
{"name":"resolve", "args":[{"name":"name","type":"string"}]},
|
||||||
|
{"name":"transfer", "args":[{"name":"name","type":"string"},{"name":"new_owner","type":"string"}]},
|
||||||
|
{"name":"release", "args":[{"name":"name","type":"string"}]}
|
||||||
|
]}
|
||||||
210
contracts/sdk/dchain.go
Normal file
210
contracts/sdk/dchain.go
Normal file
@@ -0,0 +1,210 @@
|
|||||||
|
//go:build tinygo
|
||||||
|
|
||||||
|
// Package dchain is the DChain smart contract SDK for TinyGo.
|
||||||
|
//
|
||||||
|
// # Build a contract
|
||||||
|
//
|
||||||
|
// tinygo build -o mycontract.wasm -target wasip1 -no-debug ./mycontract
|
||||||
|
//
|
||||||
|
// # Deploy
|
||||||
|
//
|
||||||
|
// client deploy-contract --key key.json \
|
||||||
|
// --wasm mycontract.wasm --abi mycontract_abi.json \
|
||||||
|
// --node http://localhost:8081
|
||||||
|
//
|
||||||
|
// Each exported Go function becomes a callable contract method.
|
||||||
|
// All inputs come through Arg*/GetState; all outputs go through Log/SetState.
|
||||||
|
package dchain
|
||||||
|
|
||||||
|
import "unsafe"
|
||||||
|
|
||||||
|
// ── Argument accessors ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
//go:wasmimport env get_arg_str
|
||||||
|
func hostGetArgStr(idx uint32, ptr uintptr, maxLen uint32) uint32
|
||||||
|
|
||||||
|
//go:wasmimport env get_arg_u64
|
||||||
|
func hostGetArgU64(idx uint32) uint64
|
||||||
|
|
||||||
|
// ArgStr returns the idx-th call argument as a string (max maxLen bytes).
|
||||||
|
// Returns "" if the index is out of range.
|
||||||
|
func ArgStr(idx int, maxLen int) string {
|
||||||
|
buf := make([]byte, maxLen)
|
||||||
|
n := hostGetArgStr(uint32(idx), uintptr(unsafe.Pointer(&buf[0])), uint32(maxLen))
|
||||||
|
if n == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return string(buf[:n])
|
||||||
|
}
|
||||||
|
|
||||||
|
// ArgU64 returns the idx-th call argument as a uint64. Returns 0 if out of range.
|
||||||
|
func ArgU64(idx int) uint64 {
|
||||||
|
return hostGetArgU64(uint32(idx))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── State ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
//go:wasmimport env get_state
|
||||||
|
func hostGetState(kPtr uintptr, kLen uint32, dstPtr uintptr, dstLen uint32) uint32
|
||||||
|
|
||||||
|
//go:wasmimport env set_state
|
||||||
|
func hostSetState(kPtr uintptr, kLen uint32, vPtr uintptr, vLen uint32)
|
||||||
|
|
||||||
|
//go:wasmimport env get_u64
|
||||||
|
func hostGetU64(kPtr uintptr, kLen uint32) uint64
|
||||||
|
|
||||||
|
//go:wasmimport env put_u64
|
||||||
|
func hostPutU64(kPtr uintptr, kLen uint32, val uint64)
|
||||||
|
|
||||||
|
// GetState reads a value from contract state by key.
|
||||||
|
// Returns nil if the key does not exist.
|
||||||
|
func GetState(key string) []byte {
|
||||||
|
k := []byte(key)
|
||||||
|
buf := make([]byte, 1024)
|
||||||
|
n := hostGetState(
|
||||||
|
uintptr(unsafe.Pointer(&k[0])), uint32(len(k)),
|
||||||
|
uintptr(unsafe.Pointer(&buf[0])), uint32(len(buf)),
|
||||||
|
)
|
||||||
|
if n == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return buf[:n]
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetStateStr reads a contract state value as a string.
|
||||||
|
func GetStateStr(key string) string {
|
||||||
|
v := GetState(key)
|
||||||
|
if v == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return string(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetState writes a value to contract state.
|
||||||
|
// Passing an empty slice clears the key.
|
||||||
|
func SetState(key string, value []byte) {
|
||||||
|
k := []byte(key)
|
||||||
|
if len(value) == 0 {
|
||||||
|
// vPtr=0, vLen=0 → clear key
|
||||||
|
hostSetState(uintptr(unsafe.Pointer(&k[0])), uint32(len(k)), 0, 0)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
hostSetState(
|
||||||
|
uintptr(unsafe.Pointer(&k[0])), uint32(len(k)),
|
||||||
|
uintptr(unsafe.Pointer(&value[0])), uint32(len(value)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetStateStr writes a string value to contract state.
|
||||||
|
func SetStateStr(key, value string) {
|
||||||
|
SetState(key, []byte(value))
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetU64 reads a uint64 stored by PutU64 from contract state.
|
||||||
|
func GetU64(key string) uint64 {
|
||||||
|
k := []byte(key)
|
||||||
|
return hostGetU64(uintptr(unsafe.Pointer(&k[0])), uint32(len(k)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// PutU64 stores a uint64 in contract state as 8-byte big-endian.
|
||||||
|
func PutU64(key string, val uint64) {
|
||||||
|
k := []byte(key)
|
||||||
|
hostPutU64(uintptr(unsafe.Pointer(&k[0])), uint32(len(k)), val)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Caller & chain ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
//go:wasmimport env get_caller
|
||||||
|
func hostGetCaller(bufPtr uintptr, bufLen uint32) uint32
|
||||||
|
|
||||||
|
//go:wasmimport env get_block_height
|
||||||
|
func hostGetBlockHeight() uint64
|
||||||
|
|
||||||
|
//go:wasmimport env get_contract_treasury
|
||||||
|
func hostGetContractTreasury(bufPtr uintptr, bufLen uint32) uint32
|
||||||
|
|
||||||
|
// Caller returns the hex pubkey of the transaction sender (or parent contract ID).
|
||||||
|
func Caller() string {
|
||||||
|
buf := make([]byte, 128)
|
||||||
|
n := hostGetCaller(uintptr(unsafe.Pointer(&buf[0])), uint32(len(buf)))
|
||||||
|
return string(buf[:n])
|
||||||
|
}
|
||||||
|
|
||||||
|
// BlockHeight returns the height of the block currently being processed.
|
||||||
|
func BlockHeight() uint64 {
|
||||||
|
return hostGetBlockHeight()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Treasury returns the contract's ownerless escrow address.
|
||||||
|
// Derived as hex(sha256(contractID+":treasury")); no private key exists.
|
||||||
|
// Only this contract can spend from it via Transfer.
|
||||||
|
func Treasury() string {
|
||||||
|
buf := make([]byte, 64)
|
||||||
|
n := hostGetContractTreasury(uintptr(unsafe.Pointer(&buf[0])), uint32(len(buf)))
|
||||||
|
return string(buf[:n])
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Token operations ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
//go:wasmimport env get_balance
|
||||||
|
func hostGetBalance(pubPtr uintptr, pubLen uint32) int64
|
||||||
|
|
||||||
|
//go:wasmimport env transfer
|
||||||
|
func hostTransfer(fromPtr uintptr, fromLen uint32, toPtr uintptr, toLen uint32, amount uint64) uint32
|
||||||
|
|
||||||
|
// Balance returns the token balance of a hex pubkey address in µT.
|
||||||
|
func Balance(pubKey string) uint64 {
|
||||||
|
p := []byte(pubKey)
|
||||||
|
return uint64(hostGetBalance(uintptr(unsafe.Pointer(&p[0])), uint32(len(p))))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transfer sends amount µT from one address to another.
|
||||||
|
// Returns true on success, false if from has insufficient balance.
|
||||||
|
func Transfer(from, to string, amount uint64) bool {
|
||||||
|
f := []byte(from)
|
||||||
|
t := []byte(to)
|
||||||
|
return hostTransfer(
|
||||||
|
uintptr(unsafe.Pointer(&f[0])), uint32(len(f)),
|
||||||
|
uintptr(unsafe.Pointer(&t[0])), uint32(len(t)),
|
||||||
|
amount,
|
||||||
|
) == 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Inter-contract calls ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
//go:wasmimport env call_contract
|
||||||
|
func hostCallContract(cidPtr uintptr, cidLen uint32, mthPtr uintptr, mthLen uint32, argPtr uintptr, argLen uint32) uint32
|
||||||
|
|
||||||
|
// CallContract executes a method on another deployed contract.
|
||||||
|
// argsJSON must be a JSON array, e.g. `["alice", "100"]`.
|
||||||
|
// Caller of the sub-contract is set to this contract's ID.
|
||||||
|
// Gas is shared — sub-call consumes from the parent's gas budget.
|
||||||
|
// Returns true on success.
|
||||||
|
func CallContract(contractID, method, argsJSON string) bool {
|
||||||
|
cid := []byte(contractID)
|
||||||
|
mth := []byte(method)
|
||||||
|
if argsJSON == "" {
|
||||||
|
argsJSON = "[]"
|
||||||
|
}
|
||||||
|
arg := []byte(argsJSON)
|
||||||
|
return hostCallContract(
|
||||||
|
uintptr(unsafe.Pointer(&cid[0])), uint32(len(cid)),
|
||||||
|
uintptr(unsafe.Pointer(&mth[0])), uint32(len(mth)),
|
||||||
|
uintptr(unsafe.Pointer(&arg[0])), uint32(len(arg)),
|
||||||
|
) == 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Logging ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
//go:wasmimport env log
|
||||||
|
func hostLog(msgPtr uintptr, msgLen uint32)
|
||||||
|
|
||||||
|
// Log writes a message to the contract log.
|
||||||
|
// Logs are visible in the block explorer at /contract?id=<id> → Logs tab.
|
||||||
|
func Log(msg string) {
|
||||||
|
b := []byte(msg)
|
||||||
|
if len(b) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
hostLog(uintptr(unsafe.Pointer(&b[0])), uint32(len(b)))
|
||||||
|
}
|
||||||
53
contracts/sdk/dchain_stub.go
Normal file
53
contracts/sdk/dchain_stub.go
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
//go:build !tinygo
|
||||||
|
|
||||||
|
// Package dchain provides stub implementations for non-TinyGo builds.
|
||||||
|
// These allow go build / IDEs to compile contract code without TinyGo.
|
||||||
|
// The stubs panic at runtime — they are never executed in production.
|
||||||
|
package dchain
|
||||||
|
|
||||||
|
// ArgStr returns the idx-th call argument as a string.
|
||||||
|
func ArgStr(idx int, maxLen int) string { panic("dchain: ArgStr requires TinyGo (tinygo build -target wasip1)") }
|
||||||
|
|
||||||
|
// ArgU64 returns the idx-th call argument as a uint64.
|
||||||
|
func ArgU64(idx int) uint64 { panic("dchain: ArgU64 requires TinyGo") }
|
||||||
|
|
||||||
|
// GetState reads a value from contract state.
|
||||||
|
func GetState(key string) []byte { panic("dchain: GetState requires TinyGo") }
|
||||||
|
|
||||||
|
// GetStateStr reads a contract state value as a string.
|
||||||
|
func GetStateStr(key string) string { panic("dchain: GetStateStr requires TinyGo") }
|
||||||
|
|
||||||
|
// SetState writes a value to contract state.
|
||||||
|
func SetState(key string, value []byte) { panic("dchain: SetState requires TinyGo") }
|
||||||
|
|
||||||
|
// SetStateStr writes a string value to contract state.
|
||||||
|
func SetStateStr(key, value string) { panic("dchain: SetStateStr requires TinyGo") }
|
||||||
|
|
||||||
|
// GetU64 reads a uint64 from contract state.
|
||||||
|
func GetU64(key string) uint64 { panic("dchain: GetU64 requires TinyGo") }
|
||||||
|
|
||||||
|
// PutU64 stores a uint64 in contract state.
|
||||||
|
func PutU64(key string, val uint64) { panic("dchain: PutU64 requires TinyGo") }
|
||||||
|
|
||||||
|
// Caller returns the hex pubkey of the transaction sender.
|
||||||
|
func Caller() string { panic("dchain: Caller requires TinyGo") }
|
||||||
|
|
||||||
|
// BlockHeight returns the current block height.
|
||||||
|
func BlockHeight() uint64 { panic("dchain: BlockHeight requires TinyGo") }
|
||||||
|
|
||||||
|
// Treasury returns the contract's ownerless treasury address.
|
||||||
|
func Treasury() string { panic("dchain: Treasury requires TinyGo") }
|
||||||
|
|
||||||
|
// Balance returns the token balance of a hex pubkey in µT.
|
||||||
|
func Balance(pubKey string) uint64 { panic("dchain: Balance requires TinyGo") }
|
||||||
|
|
||||||
|
// Transfer sends amount µT from one address to another.
|
||||||
|
func Transfer(from, to string, amount uint64) bool { panic("dchain: Transfer requires TinyGo") }
|
||||||
|
|
||||||
|
// CallContract executes a method on another deployed contract.
|
||||||
|
func CallContract(contractID, method, argsJSON string) bool {
|
||||||
|
panic("dchain: CallContract requires TinyGo")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log writes a message to the contract log.
|
||||||
|
func Log(msg string) { panic("dchain: Log requires TinyGo") }
|
||||||
678
contracts/username_registry/gen/main.go
Normal file
678
contracts/username_registry/gen/main.go
Normal file
@@ -0,0 +1,678 @@
|
|||||||
|
// gen generates contracts/username_registry/username_registry.wasm
|
||||||
|
// Run from repo root: go run ./contracts/username_registry/gen/
|
||||||
|
//
|
||||||
|
// Methods: register, resolve, lookup, transfer, release, fee
|
||||||
|
//
|
||||||
|
// State layout:
|
||||||
|
// "name:<name>" → owner address bytes
|
||||||
|
// "addr:<address>" → name bytes
|
||||||
|
//
|
||||||
|
// Fee schedule (µT):
|
||||||
|
// len=1 → 10_000_000 len=2 → 1_000_000 len=3 → 100_000
|
||||||
|
// len=4 → 10_000 len=5 → 1_000 len≥6 → 100
|
||||||
|
//
|
||||||
|
// Fees are transferred from caller to the contract treasury address
|
||||||
|
// (sha256(contractID+":treasury") — ownerless, only the contract can spend).
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── LEB128 ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func u(v uint64) []byte {
|
||||||
|
var b []byte
|
||||||
|
for {
|
||||||
|
bt := byte(v & 0x7f)
|
||||||
|
v >>= 7
|
||||||
|
if v != 0 {
|
||||||
|
bt |= 0x80
|
||||||
|
}
|
||||||
|
b = append(b, bt)
|
||||||
|
if v == 0 {
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func s(v int64) []byte {
|
||||||
|
var b []byte
|
||||||
|
for {
|
||||||
|
bt := byte(v & 0x7f)
|
||||||
|
v >>= 7
|
||||||
|
sign := (bt & 0x40) != 0
|
||||||
|
if (v == 0 && !sign) || (v == -1 && sign) {
|
||||||
|
return append(b, bt)
|
||||||
|
}
|
||||||
|
b = append(b, bt|0x80)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Builders ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func cat(slices ...[]byte) []byte {
|
||||||
|
var out []byte
|
||||||
|
for _, sl := range slices {
|
||||||
|
out = append(out, sl...)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func wstr(str string) []byte { return cat(u(uint64(len(str))), []byte(str)) }
|
||||||
|
|
||||||
|
func section(id byte, content []byte) []byte {
|
||||||
|
return cat([]byte{id}, u(uint64(len(content))), content)
|
||||||
|
}
|
||||||
|
|
||||||
|
func vec(items ...[]byte) []byte {
|
||||||
|
out := u(uint64(len(items)))
|
||||||
|
for _, it := range items {
|
||||||
|
out = append(out, it...)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func functype(params, results []byte) []byte {
|
||||||
|
return cat([]byte{0x60}, u(uint64(len(params))), params, u(uint64(len(results))), results)
|
||||||
|
}
|
||||||
|
|
||||||
|
func importFunc(mod, name string, typeIdx uint32) []byte {
|
||||||
|
return cat(wstr(mod), wstr(name), []byte{0x00}, u(uint64(typeIdx)))
|
||||||
|
}
|
||||||
|
|
||||||
|
func exportEntry(name string, kind byte, idx uint32) []byte {
|
||||||
|
return cat(wstr(name), []byte{kind}, u(uint64(idx)))
|
||||||
|
}
|
||||||
|
|
||||||
|
func dataSegment(offset int32, data []byte) []byte {
|
||||||
|
return cat(
|
||||||
|
[]byte{0x00},
|
||||||
|
[]byte{0x41}, s(int64(offset)), []byte{0x0B},
|
||||||
|
u(uint64(len(data))), data,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func funcBody(localDecls []byte, instrs ...[]byte) []byte {
|
||||||
|
inner := cat(localDecls)
|
||||||
|
for _, ins := range instrs {
|
||||||
|
inner = append(inner, ins...)
|
||||||
|
}
|
||||||
|
inner = append(inner, 0x0B) // end
|
||||||
|
return cat(u(uint64(len(inner))), inner)
|
||||||
|
}
|
||||||
|
|
||||||
|
var noLocals = u(0)
|
||||||
|
|
||||||
|
func localDecl(n uint32, typ byte) []byte { return cat(u(uint64(n)), []byte{typ}) }
|
||||||
|
func withLocals(decls ...[]byte) []byte {
|
||||||
|
return cat(u(uint64(len(decls))), cat(decls...))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Instructions ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const (
|
||||||
|
tI32 byte = 0x7F
|
||||||
|
tI64 byte = 0x7E
|
||||||
|
)
|
||||||
|
|
||||||
|
func call(fn uint32) []byte { return cat([]byte{0x10}, u(uint64(fn))) }
|
||||||
|
func lget(i uint32) []byte { return cat([]byte{0x20}, u(uint64(i))) }
|
||||||
|
func lset(i uint32) []byte { return cat([]byte{0x21}, u(uint64(i))) }
|
||||||
|
func ic32(v int32) []byte { return cat([]byte{0x41}, s(int64(v))) }
|
||||||
|
func ic64(v int64) []byte { return cat([]byte{0x42}, s(v)) }
|
||||||
|
func block_() []byte { return []byte{0x02, 0x40} }
|
||||||
|
func loop_() []byte { return []byte{0x03, 0x40} }
|
||||||
|
func if_() []byte { return []byte{0x04, 0x40} }
|
||||||
|
func ifI32() []byte { return []byte{0x04, tI32} }
|
||||||
|
func else_() []byte { return []byte{0x05} }
|
||||||
|
func end_() []byte { return []byte{0x0B} }
|
||||||
|
func br_(lbl uint32) []byte { return cat([]byte{0x0C}, u(uint64(lbl))) }
|
||||||
|
func brIf_(lbl uint32) []byte { return cat([]byte{0x0D}, u(uint64(lbl))) }
|
||||||
|
func return_() []byte { return []byte{0x0F} }
|
||||||
|
func drop() []byte { return []byte{0x1A} }
|
||||||
|
func i32Eqz() []byte { return []byte{0x45} }
|
||||||
|
func i32Ne() []byte { return []byte{0x47} }
|
||||||
|
func i32GtU() []byte { return []byte{0x4B} }
|
||||||
|
func i32GeU() []byte { return []byte{0x4F} }
|
||||||
|
func i32LeU() []byte { return []byte{0x4D} }
|
||||||
|
func i32Add() []byte { return []byte{0x6A} }
|
||||||
|
func i64LtU() []byte { return []byte{0x54} }
|
||||||
|
func i64Eqz() []byte { return []byte{0x50} }
|
||||||
|
func i32Load8U() []byte { return []byte{0x2D, 0x00, 0x00} }
|
||||||
|
func i32Store8() []byte { return []byte{0x3A, 0x00, 0x00} }
|
||||||
|
|
||||||
|
// ── Memory layout ─────────────────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// 0x000 64 arg[0] name buffer
|
||||||
|
// 0x040 128 arg[1] new_owner buffer
|
||||||
|
// 0x0C0 128 caller buffer
|
||||||
|
// 0x140 64 contract treasury buffer
|
||||||
|
// 0x180 128 state-read result buffer
|
||||||
|
//
|
||||||
|
// Constant string data (read-only, written by data segments):
|
||||||
|
// 0x200 5 "name:"
|
||||||
|
// 0x206 5 "addr:"
|
||||||
|
// 0x20C 12 "registered: "
|
||||||
|
// 0x218 12 "name taken: "
|
||||||
|
// 0x224 11 "not found: "
|
||||||
|
// 0x230 7 "owner: "
|
||||||
|
// 0x238 13 "transferred: "
|
||||||
|
// 0x246 14 "unauthorized: "
|
||||||
|
// 0x255 10 "released: "
|
||||||
|
// 0x260 6 "name: "
|
||||||
|
// 0x267 9 "no name: "
|
||||||
|
// 0x271 14 "insufficient: "
|
||||||
|
// 0x280 13 "fee: 10000000"
|
||||||
|
// 0x28D 12 "fee: 1000000"
|
||||||
|
// 0x299 11 "fee: 100000"
|
||||||
|
// 0x2A4 10 "fee: 10000"
|
||||||
|
// 0x2AE 9 "fee: 1000"
|
||||||
|
// 0x2B7 8 "fee: 100"
|
||||||
|
//
|
||||||
|
// 0x300 256 scratch buffer (used by buildKey + logPrefixName)
|
||||||
|
|
||||||
|
const (
|
||||||
|
offArg0 int32 = 0x000
|
||||||
|
offArg1 int32 = 0x040
|
||||||
|
offCaller int32 = 0x0C0
|
||||||
|
offTreasury int32 = 0x140
|
||||||
|
offStateRead int32 = 0x180
|
||||||
|
|
||||||
|
offPfxName int32 = 0x200 // "name:" 5 bytes
|
||||||
|
offPfxAddr int32 = 0x206 // "addr:" 5 bytes
|
||||||
|
|
||||||
|
offRegisteredPfx int32 = 0x20C // "registered: " 12
|
||||||
|
offNameTakenPfx int32 = 0x218 // "name taken: " 12
|
||||||
|
offNotFoundPfx int32 = 0x224 // "not found: " 11
|
||||||
|
offOwnerPfx int32 = 0x230 // "owner: " 7
|
||||||
|
offTransferredPfx int32 = 0x238 // "transferred: " 13
|
||||||
|
offUnauthPfx int32 = 0x246 // "unauthorized: " 14
|
||||||
|
offReleasedPfx int32 = 0x255 // "released: " 10
|
||||||
|
offNamePfx int32 = 0x260 // "name: " 6
|
||||||
|
offNoNamePfx int32 = 0x267 // "no name: " 9
|
||||||
|
offInsuffPfx int32 = 0x271 // "insufficient: " 14
|
||||||
|
|
||||||
|
offFee1 int32 = 0x280 // "fee: 10000000" 13
|
||||||
|
offFee2 int32 = 0x28D // "fee: 1000000" 12
|
||||||
|
offFee3 int32 = 0x299 // "fee: 100000" 11
|
||||||
|
offFee4 int32 = 0x2A4 // "fee: 10000" 10
|
||||||
|
offFee5 int32 = 0x2AE // "fee: 1000" 9
|
||||||
|
offFee6 int32 = 0x2B7 // "fee: 100" 8
|
||||||
|
|
||||||
|
offScratch int32 = 0x300
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── Import / function indices ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const (
|
||||||
|
// imports (0-based)
|
||||||
|
fnGetArgStr = 0
|
||||||
|
fnGetCaller = 1
|
||||||
|
fnGetState = 2
|
||||||
|
fnSetState = 3
|
||||||
|
fnLog = 4
|
||||||
|
fnTransfer = 5
|
||||||
|
fnGetBalance = 6
|
||||||
|
fnGetContractTreasury = 7
|
||||||
|
|
||||||
|
// local functions (after 8 imports)
|
||||||
|
fnBytesEqual = 8
|
||||||
|
fnMemcpy = 9
|
||||||
|
fnLogPrefixName = 10
|
||||||
|
fnCalcFee = 11
|
||||||
|
fnBuildKey = 12
|
||||||
|
fnRegister = 13
|
||||||
|
fnResolve = 14
|
||||||
|
fnLookup = 15
|
||||||
|
fnTransferFn = 16
|
||||||
|
fnRelease = 17
|
||||||
|
fnFee = 18
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── Helper function bodies ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// $bytes_equal(aPtr, bPtr, len i32) → i32 (1=equal, 0=not)
|
||||||
|
// extra locals: i(3), same(4)
|
||||||
|
func bytesEqualBody() []byte {
|
||||||
|
return funcBody(
|
||||||
|
withLocals(localDecl(2, tI32)),
|
||||||
|
ic32(1), lset(4),
|
||||||
|
ic32(0), lset(3),
|
||||||
|
block_(),
|
||||||
|
loop_(),
|
||||||
|
lget(3), lget(2), i32GeU(), brIf_(1),
|
||||||
|
lget(0), lget(3), i32Add(), i32Load8U(),
|
||||||
|
lget(1), lget(3), i32Add(), i32Load8U(),
|
||||||
|
i32Ne(), if_(),
|
||||||
|
ic32(0), lset(4), br_(2),
|
||||||
|
end_(),
|
||||||
|
lget(3), ic32(1), i32Add(), lset(3),
|
||||||
|
br_(0),
|
||||||
|
end_(),
|
||||||
|
end_(),
|
||||||
|
lget(4),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// $memcpy(dst, src, len i32)
|
||||||
|
// extra local: i(3)
|
||||||
|
func memcpyBody() []byte {
|
||||||
|
return funcBody(
|
||||||
|
withLocals(localDecl(1, tI32)),
|
||||||
|
ic32(0), lset(3),
|
||||||
|
block_(),
|
||||||
|
loop_(),
|
||||||
|
lget(3), lget(2), i32GeU(), brIf_(1),
|
||||||
|
lget(0), lget(3), i32Add(),
|
||||||
|
lget(1), lget(3), i32Add(), i32Load8U(),
|
||||||
|
i32Store8(),
|
||||||
|
lget(3), ic32(1), i32Add(), lset(3),
|
||||||
|
br_(0),
|
||||||
|
end_(),
|
||||||
|
end_(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// $log_prefix_name(prefixPtr, prefixLen, suffixPtr, suffixLen i32)
|
||||||
|
// Concatenates prefix+suffix into scratch and logs the result.
|
||||||
|
func logPrefixNameBody() []byte {
|
||||||
|
return funcBody(
|
||||||
|
noLocals,
|
||||||
|
ic32(offScratch), lget(0), lget(1), call(fnMemcpy),
|
||||||
|
ic32(offScratch), lget(1), i32Add(), lget(2), lget(3), call(fnMemcpy),
|
||||||
|
ic32(offScratch), lget(1), lget(3), i32Add(), call(fnLog),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// $calc_fee(nameLen i32) → i64
|
||||||
|
// Fee tiers: 1→10M, 2→1M, 3→100K, 4→10K, 5→1K, ≥6→100 µT
|
||||||
|
func calcFeeBody() []byte {
|
||||||
|
return funcBody(
|
||||||
|
noLocals,
|
||||||
|
lget(0), ic32(1), i32LeU(), if_(),
|
||||||
|
ic64(10_000_000), return_(),
|
||||||
|
end_(),
|
||||||
|
lget(0), ic32(2), i32Ne(), if_(), // len>=3
|
||||||
|
lget(0), ic32(3), i32Ne(), if_(), // len>=4
|
||||||
|
lget(0), ic32(4), i32Ne(), if_(), // len>=5
|
||||||
|
lget(0), ic32(5), i32Ne(), if_(), // len>=6
|
||||||
|
ic64(100), return_(),
|
||||||
|
end_(),
|
||||||
|
ic64(1_000), return_(),
|
||||||
|
end_(),
|
||||||
|
ic64(10_000), return_(),
|
||||||
|
end_(),
|
||||||
|
ic64(100_000), return_(),
|
||||||
|
end_(),
|
||||||
|
ic64(1_000_000),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// $build_key(pfxOff, pfxLen, dataOff, dataLen i32) → keyLen i32
|
||||||
|
// Writes pfx+data into scratch (offScratch) and returns total length.
|
||||||
|
func buildKeyBody() []byte {
|
||||||
|
return funcBody(
|
||||||
|
noLocals,
|
||||||
|
ic32(offScratch), lget(0), lget(1), call(fnMemcpy),
|
||||||
|
ic32(offScratch), lget(1), i32Add(), lget(2), lget(3), call(fnMemcpy),
|
||||||
|
lget(1), lget(3), i32Add(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// isOwnerCheck emits code that, given two length locals, leaves i32(1=owner,0=not) on stack.
|
||||||
|
// Assumes caller bytes are at offCaller and existing owner bytes at offStateRead.
|
||||||
|
func isOwnerCheck(callerLenLocal, existingLenLocal uint32) []byte {
|
||||||
|
return cat(
|
||||||
|
lget(callerLenLocal), lget(existingLenLocal), i32Ne(),
|
||||||
|
ifI32(),
|
||||||
|
ic32(0),
|
||||||
|
else_(),
|
||||||
|
ic32(offCaller), ic32(offStateRead), lget(callerLenLocal), call(fnBytesEqual),
|
||||||
|
end_(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Contract method bodies ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// register(name string)
|
||||||
|
// Locals (i32): nameLen(0), callerLen(1), existingLen(2), treasuryLen(3), keyLen(4)
|
||||||
|
// Locals (i64): fee(5)
|
||||||
|
func registerBody() []byte {
|
||||||
|
return funcBody(
|
||||||
|
withLocals(localDecl(5, tI32), localDecl(1, tI64)),
|
||||||
|
|
||||||
|
// Read name arg
|
||||||
|
ic32(0), ic32(offArg0), ic32(64), call(fnGetArgStr), lset(0),
|
||||||
|
lget(0), i32Eqz(), if_(), return_(), end_(),
|
||||||
|
|
||||||
|
// Check name not already taken
|
||||||
|
ic32(offPfxName), ic32(5), ic32(offArg0), lget(0), call(fnBuildKey), lset(4),
|
||||||
|
ic32(offScratch), lget(4), ic32(offStateRead), ic32(128), call(fnGetState), lset(2),
|
||||||
|
lget(2), ic32(0), i32GtU(), if_(),
|
||||||
|
ic32(offNameTakenPfx), ic32(12), ic32(offArg0), lget(0), call(fnLogPrefixName),
|
||||||
|
return_(),
|
||||||
|
end_(),
|
||||||
|
|
||||||
|
// Get caller
|
||||||
|
ic32(offCaller), ic32(128), call(fnGetCaller), lset(1),
|
||||||
|
|
||||||
|
// Compute fee
|
||||||
|
lget(0), call(fnCalcFee), lset(5),
|
||||||
|
|
||||||
|
// Check balance >= fee
|
||||||
|
ic32(offCaller), lget(1), call(fnGetBalance),
|
||||||
|
lget(5), i64LtU(), if_(),
|
||||||
|
ic32(offInsuffPfx), ic32(14), ic32(offArg0), lget(0), call(fnLogPrefixName),
|
||||||
|
return_(),
|
||||||
|
end_(),
|
||||||
|
|
||||||
|
// Get treasury address
|
||||||
|
ic32(offTreasury), ic32(64), call(fnGetContractTreasury), lset(3),
|
||||||
|
|
||||||
|
// Transfer fee: caller → treasury
|
||||||
|
ic32(offCaller), lget(1), ic32(offTreasury), lget(3), lget(5), call(fnTransfer), drop(),
|
||||||
|
|
||||||
|
// Write name→owner
|
||||||
|
ic32(offPfxName), ic32(5), ic32(offArg0), lget(0), call(fnBuildKey), lset(4),
|
||||||
|
ic32(offScratch), lget(4), ic32(offCaller), lget(1), call(fnSetState),
|
||||||
|
|
||||||
|
// Write addr→name (reverse index)
|
||||||
|
ic32(offPfxAddr), ic32(5), ic32(offCaller), lget(1), call(fnBuildKey), lset(4),
|
||||||
|
ic32(offScratch), lget(4), ic32(offArg0), lget(0), call(fnSetState),
|
||||||
|
|
||||||
|
// Log success
|
||||||
|
ic32(offRegisteredPfx), ic32(12), ic32(offArg0), lget(0), call(fnLogPrefixName),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolve(name string)
|
||||||
|
// Locals: nameLen(0), ownerLen(1), keyLen(2)
|
||||||
|
func resolveBody() []byte {
|
||||||
|
return funcBody(
|
||||||
|
withLocals(localDecl(3, tI32)),
|
||||||
|
ic32(0), ic32(offArg0), ic32(64), call(fnGetArgStr), lset(0),
|
||||||
|
lget(0), i32Eqz(), if_(), return_(), end_(),
|
||||||
|
|
||||||
|
ic32(offPfxName), ic32(5), ic32(offArg0), lget(0), call(fnBuildKey), lset(2),
|
||||||
|
ic32(offScratch), lget(2), ic32(offStateRead), ic32(128), call(fnGetState), lset(1),
|
||||||
|
lget(1), i32Eqz(), if_(),
|
||||||
|
ic32(offNotFoundPfx), ic32(11), ic32(offArg0), lget(0), call(fnLogPrefixName),
|
||||||
|
return_(),
|
||||||
|
end_(),
|
||||||
|
ic32(offOwnerPfx), ic32(7), ic32(offStateRead), lget(1), call(fnLogPrefixName),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// lookup(address string) — reverse lookup address → name
|
||||||
|
// Locals: addrLen(0), nameLen(1), keyLen(2)
|
||||||
|
func lookupBody() []byte {
|
||||||
|
return funcBody(
|
||||||
|
withLocals(localDecl(3, tI32)),
|
||||||
|
ic32(0), ic32(offArg0), ic32(128), call(fnGetArgStr), lset(0),
|
||||||
|
lget(0), i32Eqz(), if_(), return_(), end_(),
|
||||||
|
|
||||||
|
ic32(offPfxAddr), ic32(5), ic32(offArg0), lget(0), call(fnBuildKey), lset(2),
|
||||||
|
ic32(offScratch), lget(2), ic32(offStateRead), ic32(128), call(fnGetState), lset(1),
|
||||||
|
lget(1), i32Eqz(), if_(),
|
||||||
|
ic32(offNoNamePfx), ic32(9), ic32(offArg0), lget(0), call(fnLogPrefixName),
|
||||||
|
return_(),
|
||||||
|
end_(),
|
||||||
|
ic32(offNamePfx), ic32(6), ic32(offStateRead), lget(1), call(fnLogPrefixName),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// transfer(name, new_owner) — transfer username ownership
|
||||||
|
// Locals: nameLen(0), newOwnerLen(1), callerLen(2), existingLen(3), keyLen(4)
|
||||||
|
func transferBody() []byte {
|
||||||
|
return funcBody(
|
||||||
|
withLocals(localDecl(5, tI32)),
|
||||||
|
ic32(0), ic32(offArg0), ic32(64), call(fnGetArgStr), lset(0),
|
||||||
|
lget(0), i32Eqz(), if_(), return_(), end_(),
|
||||||
|
ic32(1), ic32(offArg1), ic32(128), call(fnGetArgStr), lset(1),
|
||||||
|
lget(1), i32Eqz(), if_(), return_(), end_(),
|
||||||
|
|
||||||
|
// Look up existing owner
|
||||||
|
ic32(offPfxName), ic32(5), ic32(offArg0), lget(0), call(fnBuildKey), lset(4),
|
||||||
|
ic32(offScratch), lget(4), ic32(offStateRead), ic32(128), call(fnGetState), lset(3),
|
||||||
|
lget(3), i32Eqz(), if_(),
|
||||||
|
ic32(offNotFoundPfx), ic32(11), ic32(offArg0), lget(0), call(fnLogPrefixName),
|
||||||
|
return_(),
|
||||||
|
end_(),
|
||||||
|
|
||||||
|
// Verify caller is the current owner
|
||||||
|
ic32(offCaller), ic32(128), call(fnGetCaller), lset(2),
|
||||||
|
isOwnerCheck(2, 3),
|
||||||
|
i32Eqz(), if_(),
|
||||||
|
ic32(offUnauthPfx), ic32(14), ic32(offArg0), lget(0), call(fnLogPrefixName),
|
||||||
|
return_(),
|
||||||
|
end_(),
|
||||||
|
|
||||||
|
// Delete old reverse index: addr:<oldOwner> → ""
|
||||||
|
ic32(offPfxAddr), ic32(5), ic32(offStateRead), lget(3), call(fnBuildKey), lset(4),
|
||||||
|
ic32(offScratch), lget(4), ic32(offScratch), ic32(0), call(fnSetState),
|
||||||
|
|
||||||
|
// Update forward index: name:<name> → newOwner
|
||||||
|
ic32(offPfxName), ic32(5), ic32(offArg0), lget(0), call(fnBuildKey), lset(4),
|
||||||
|
ic32(offScratch), lget(4), ic32(offArg1), lget(1), call(fnSetState),
|
||||||
|
|
||||||
|
// Add new reverse index: addr:<newOwner> → name
|
||||||
|
ic32(offPfxAddr), ic32(5), ic32(offArg1), lget(1), call(fnBuildKey), lset(4),
|
||||||
|
ic32(offScratch), lget(4), ic32(offArg0), lget(0), call(fnSetState),
|
||||||
|
|
||||||
|
ic32(offTransferredPfx), ic32(13), ic32(offArg0), lget(0), call(fnLogPrefixName),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// release(name) — release a username registration
|
||||||
|
// Locals: nameLen(0), callerLen(1), existingLen(2), keyLen(3)
|
||||||
|
func releaseBody() []byte {
|
||||||
|
return funcBody(
|
||||||
|
withLocals(localDecl(4, tI32)),
|
||||||
|
ic32(0), ic32(offArg0), ic32(64), call(fnGetArgStr), lset(0),
|
||||||
|
lget(0), i32Eqz(), if_(), return_(), end_(),
|
||||||
|
|
||||||
|
ic32(offPfxName), ic32(5), ic32(offArg0), lget(0), call(fnBuildKey), lset(3),
|
||||||
|
ic32(offScratch), lget(3), ic32(offStateRead), ic32(128), call(fnGetState), lset(2),
|
||||||
|
lget(2), i32Eqz(), if_(),
|
||||||
|
ic32(offNotFoundPfx), ic32(11), ic32(offArg0), lget(0), call(fnLogPrefixName),
|
||||||
|
return_(),
|
||||||
|
end_(),
|
||||||
|
|
||||||
|
ic32(offCaller), ic32(128), call(fnGetCaller), lset(1),
|
||||||
|
isOwnerCheck(1, 2),
|
||||||
|
i32Eqz(), if_(),
|
||||||
|
ic32(offUnauthPfx), ic32(14), ic32(offArg0), lget(0), call(fnLogPrefixName),
|
||||||
|
return_(),
|
||||||
|
end_(),
|
||||||
|
|
||||||
|
// Delete reverse index: addr:<owner> → ""
|
||||||
|
ic32(offPfxAddr), ic32(5), ic32(offStateRead), lget(2), call(fnBuildKey), lset(3),
|
||||||
|
ic32(offScratch), lget(3), ic32(offScratch), ic32(0), call(fnSetState),
|
||||||
|
|
||||||
|
// Delete forward index: name:<name> → ""
|
||||||
|
ic32(offPfxName), ic32(5), ic32(offArg0), lget(0), call(fnBuildKey), lset(3),
|
||||||
|
ic32(offScratch), lget(3), ic32(offScratch), ic32(0), call(fnSetState),
|
||||||
|
|
||||||
|
ic32(offReleasedPfx), ic32(10), ic32(offArg0), lget(0), call(fnLogPrefixName),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// fee(name) — log the registration fee for a given name
|
||||||
|
// Uses pre-baked static strings; no arithmetic needed.
|
||||||
|
// Locals: nameLen(0)
|
||||||
|
func feeBody() []byte {
|
||||||
|
return funcBody(
|
||||||
|
withLocals(localDecl(1, tI32)),
|
||||||
|
ic32(0), ic32(offArg0), ic32(64), call(fnGetArgStr), lset(0),
|
||||||
|
lget(0), i32Eqz(), if_(), return_(), end_(),
|
||||||
|
|
||||||
|
// Branch on name length and log the matching static fee string.
|
||||||
|
lget(0), ic32(1), i32LeU(), if_(),
|
||||||
|
ic32(offFee1), ic32(13), call(fnLog), return_(),
|
||||||
|
end_(),
|
||||||
|
lget(0), ic32(2), i32Ne(), if_(), // len >= 3
|
||||||
|
lget(0), ic32(3), i32Ne(), if_(), // len >= 4
|
||||||
|
lget(0), ic32(4), i32Ne(), if_(), // len >= 5
|
||||||
|
lget(0), ic32(5), i32Ne(), if_(), // len >= 6
|
||||||
|
ic32(offFee6), ic32(8), call(fnLog), return_(),
|
||||||
|
end_(),
|
||||||
|
ic32(offFee5), ic32(9), call(fnLog), return_(),
|
||||||
|
end_(),
|
||||||
|
ic32(offFee4), ic32(10), call(fnLog), return_(),
|
||||||
|
end_(),
|
||||||
|
ic32(offFee3), ic32(11), call(fnLog), return_(),
|
||||||
|
end_(),
|
||||||
|
// len == 2
|
||||||
|
ic32(offFee2), ic32(12), call(fnLog),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── main ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
// ── Type table ────────────────────────────────────────────────────────────
|
||||||
|
// 0: (i32,i32,i32)→(i32) get_arg_str, bytes_equal
|
||||||
|
// 1: (i32,i32)→(i32) get_caller, get_contract_treasury
|
||||||
|
// 2: (i32,i32,i32,i32)→(i32) get_state, build_key
|
||||||
|
// 3: (i32,i32,i32,i32)→() set_state, log_prefix_name
|
||||||
|
// 4: (i32,i32)→() log
|
||||||
|
// 5: (i32,i32,i32,i32,i64)→(i32) transfer
|
||||||
|
// 6: (i32,i32)→(i64) get_balance
|
||||||
|
// 7: ()→() exported methods
|
||||||
|
// 8: (i32,i32,i32)→() memcpy
|
||||||
|
// 9: (i32)→(i64) calc_fee
|
||||||
|
typeSection := section(0x01, vec(
|
||||||
|
functype([]byte{tI32, tI32, tI32}, []byte{tI32}), // 0
|
||||||
|
functype([]byte{tI32, tI32}, []byte{tI32}), // 1
|
||||||
|
functype([]byte{tI32, tI32, tI32, tI32}, []byte{tI32}), // 2
|
||||||
|
functype([]byte{tI32, tI32, tI32, tI32}, []byte{}), // 3
|
||||||
|
functype([]byte{tI32, tI32}, []byte{}), // 4
|
||||||
|
functype([]byte{tI32, tI32, tI32, tI32, tI64}, []byte{tI32}), // 5
|
||||||
|
functype([]byte{tI32, tI32}, []byte{tI64}), // 6
|
||||||
|
functype([]byte{}, []byte{}), // 7
|
||||||
|
functype([]byte{tI32, tI32, tI32}, []byte{}), // 8
|
||||||
|
functype([]byte{tI32}, []byte{tI64}), // 9
|
||||||
|
))
|
||||||
|
|
||||||
|
importSection := section(0x02, vec(
|
||||||
|
importFunc("env", "get_arg_str", 0), // 0 type 0
|
||||||
|
importFunc("env", "get_caller", 1), // 1 type 1
|
||||||
|
importFunc("env", "get_state", 2), // 2 type 2
|
||||||
|
importFunc("env", "set_state", 3), // 3 type 3
|
||||||
|
importFunc("env", "log", 4), // 4 type 4
|
||||||
|
importFunc("env", "transfer", 5), // 5 type 5
|
||||||
|
importFunc("env", "get_balance", 6), // 6 type 6
|
||||||
|
importFunc("env", "get_contract_treasury", 1), // 7 type 1
|
||||||
|
))
|
||||||
|
|
||||||
|
// 11 local functions
|
||||||
|
functionSection := section(0x03, vec(
|
||||||
|
u(0), // bytes_equal type 0
|
||||||
|
u(8), // memcpy type 8
|
||||||
|
u(3), // log_prefix_name type 3
|
||||||
|
u(9), // calc_fee type 9
|
||||||
|
u(2), // build_key type 2
|
||||||
|
u(7), // register type 7
|
||||||
|
u(7), // resolve type 7
|
||||||
|
u(7), // lookup type 7
|
||||||
|
u(7), // transfer_fn type 7
|
||||||
|
u(7), // release type 7
|
||||||
|
u(7), // fee type 7
|
||||||
|
))
|
||||||
|
|
||||||
|
memorySection := section(0x05, vec(cat([]byte{0x00}, u(1))))
|
||||||
|
|
||||||
|
exportSection := section(0x07, vec(
|
||||||
|
exportEntry("memory", 0x02, 0),
|
||||||
|
exportEntry("register", 0x00, fnRegister),
|
||||||
|
exportEntry("resolve", 0x00, fnResolve),
|
||||||
|
exportEntry("lookup", 0x00, fnLookup),
|
||||||
|
exportEntry("transfer", 0x00, fnTransferFn),
|
||||||
|
exportEntry("release", 0x00, fnRelease),
|
||||||
|
exportEntry("fee", 0x00, fnFee),
|
||||||
|
))
|
||||||
|
|
||||||
|
dataSection := section(0x0B, cat(
|
||||||
|
u(19),
|
||||||
|
dataSegment(offPfxName, []byte("name:")),
|
||||||
|
dataSegment(offPfxAddr, []byte("addr:")),
|
||||||
|
dataSegment(offRegisteredPfx, []byte("registered: ")),
|
||||||
|
dataSegment(offNameTakenPfx, []byte("name taken: ")),
|
||||||
|
dataSegment(offNotFoundPfx, []byte("not found: ")),
|
||||||
|
dataSegment(offOwnerPfx, []byte("owner: ")),
|
||||||
|
dataSegment(offTransferredPfx, []byte("transferred: ")),
|
||||||
|
dataSegment(offUnauthPfx, []byte("unauthorized: ")),
|
||||||
|
dataSegment(offReleasedPfx, []byte("released: ")),
|
||||||
|
dataSegment(offNamePfx, []byte("name: ")),
|
||||||
|
dataSegment(offNoNamePfx, []byte("no name: ")),
|
||||||
|
dataSegment(offInsuffPfx, []byte("insufficient: ")),
|
||||||
|
dataSegment(offFee1, []byte("fee: 10000000")),
|
||||||
|
dataSegment(offFee2, []byte("fee: 1000000")),
|
||||||
|
dataSegment(offFee3, []byte("fee: 100000")),
|
||||||
|
dataSegment(offFee4, []byte("fee: 10000")),
|
||||||
|
dataSegment(offFee5, []byte("fee: 1000")),
|
||||||
|
dataSegment(offFee6, []byte("fee: 100")),
|
||||||
|
// pad entry to make count = 19 (u(19) above) — actually 18 strings above
|
||||||
|
// Let me count: pfxName, pfxAddr, registered, nameTaken, notFound, owner,
|
||||||
|
// transferred, unauth, released, name, noName, insuff, fee1..fee6 = 18
|
||||||
|
// Fix: change u(19) → u(18) — corrected below in final assembly.
|
||||||
|
))
|
||||||
|
// Recount: 18 data segments total — rebuild without the u() wrapper mismatch.
|
||||||
|
dataSection = section(0x0B, cat(
|
||||||
|
u(18),
|
||||||
|
dataSegment(offPfxName, []byte("name:")),
|
||||||
|
dataSegment(offPfxAddr, []byte("addr:")),
|
||||||
|
dataSegment(offRegisteredPfx, []byte("registered: ")),
|
||||||
|
dataSegment(offNameTakenPfx, []byte("name taken: ")),
|
||||||
|
dataSegment(offNotFoundPfx, []byte("not found: ")),
|
||||||
|
dataSegment(offOwnerPfx, []byte("owner: ")),
|
||||||
|
dataSegment(offTransferredPfx, []byte("transferred: ")),
|
||||||
|
dataSegment(offUnauthPfx, []byte("unauthorized: ")),
|
||||||
|
dataSegment(offReleasedPfx, []byte("released: ")),
|
||||||
|
dataSegment(offNamePfx, []byte("name: ")),
|
||||||
|
dataSegment(offNoNamePfx, []byte("no name: ")),
|
||||||
|
dataSegment(offInsuffPfx, []byte("insufficient: ")),
|
||||||
|
dataSegment(offFee1, []byte("fee: 10000000")),
|
||||||
|
dataSegment(offFee2, []byte("fee: 1000000")),
|
||||||
|
dataSegment(offFee3, []byte("fee: 100000")),
|
||||||
|
dataSegment(offFee4, []byte("fee: 10000")),
|
||||||
|
dataSegment(offFee5, []byte("fee: 1000")),
|
||||||
|
dataSegment(offFee6, []byte("fee: 100")),
|
||||||
|
))
|
||||||
|
|
||||||
|
codeSection := section(0x0A, cat(
|
||||||
|
u(11),
|
||||||
|
bytesEqualBody(),
|
||||||
|
memcpyBody(),
|
||||||
|
logPrefixNameBody(),
|
||||||
|
calcFeeBody(),
|
||||||
|
buildKeyBody(),
|
||||||
|
registerBody(),
|
||||||
|
resolveBody(),
|
||||||
|
lookupBody(),
|
||||||
|
transferBody(),
|
||||||
|
releaseBody(),
|
||||||
|
feeBody(),
|
||||||
|
))
|
||||||
|
|
||||||
|
module := cat(
|
||||||
|
[]byte{0x00, 0x61, 0x73, 0x6d},
|
||||||
|
[]byte{0x01, 0x00, 0x00, 0x00},
|
||||||
|
typeSection,
|
||||||
|
importSection,
|
||||||
|
functionSection,
|
||||||
|
memorySection,
|
||||||
|
exportSection,
|
||||||
|
dataSection,
|
||||||
|
codeSection,
|
||||||
|
)
|
||||||
|
|
||||||
|
out := "contracts/username_registry/username_registry.wasm"
|
||||||
|
if err := os.WriteFile(out, module, 0644); err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, "write:", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
fmt.Printf("Written %s (%d bytes)\n", out, len(module))
|
||||||
|
}
|
||||||
BIN
contracts/username_registry/username_registry.wasm
Normal file
BIN
contracts/username_registry/username_registry.wasm
Normal file
Binary file not shown.
50
contracts/username_registry/username_registry_abi.json
Normal file
50
contracts/username_registry/username_registry_abi.json
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
{
|
||||||
|
"contract": "username_registry",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Maps human-readable usernames to wallet addresses. Shorter names cost more to register. Fees go to the contract treasury.",
|
||||||
|
"methods": [
|
||||||
|
{
|
||||||
|
"name": "register",
|
||||||
|
"description": "Register a username for the caller. Fee = 10^(7 - min(len,6)) µT (1-char=10M, 2=1M, 3=100K, 4=10K, 5=1K, 6+=100). Caller must have sufficient balance.",
|
||||||
|
"args": [
|
||||||
|
{"name": "name", "type": "string", "description": "Username to register (max 64 chars, lowercase)"}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "resolve",
|
||||||
|
"description": "Look up the wallet address that owns a username. Logs 'owner: <address>' or 'not found: <name>'.",
|
||||||
|
"args": [
|
||||||
|
{"name": "name", "type": "string", "description": "Username to look up"}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "lookup",
|
||||||
|
"description": "Reverse lookup: find the username registered to a given address. Logs 'name: <username>' or 'no name: <address>'.",
|
||||||
|
"args": [
|
||||||
|
{"name": "address", "type": "string", "description": "Wallet address (hex pubkey)"}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "transfer",
|
||||||
|
"description": "Transfer ownership of a username to another address. Only the current owner may call this.",
|
||||||
|
"args": [
|
||||||
|
{"name": "name", "type": "string", "description": "Username to transfer"},
|
||||||
|
{"name": "new_owner", "type": "string", "description": "New owner address (hex pubkey)"}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "release",
|
||||||
|
"description": "Release a username registration. Only the current owner may call this.",
|
||||||
|
"args": [
|
||||||
|
{"name": "name", "type": "string", "description": "Username to release"}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "fee",
|
||||||
|
"description": "Query the registration fee for a given name length. Logs 'fee: <amount>' in µT.",
|
||||||
|
"args": [
|
||||||
|
{"name": "name", "type": "string", "description": "Name whose fee you want to check"}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
339
deploy/UPDATE_STRATEGY.md
Normal file
339
deploy/UPDATE_STRATEGY.md
Normal file
@@ -0,0 +1,339 @@
|
|||||||
|
# DChain node — update & seamless-upgrade strategy
|
||||||
|
|
||||||
|
Этот документ отвечает на два вопроса:
|
||||||
|
|
||||||
|
1. **Как оператор ноды обновляет её от git-сервера** (pull → build → restart) без
|
||||||
|
простоя и без потери данных.
|
||||||
|
2. **Как мы сохраняем бесшовную совместимость** между версиями ноды, чтобы не
|
||||||
|
пришлось "ломать" старых клиентов, чужие ноды или собственную историю.
|
||||||
|
|
||||||
|
Читается в связке с `deploy/single/README.md` (операционный runbook) и
|
||||||
|
`CHANGELOG.md` (что уже зашипплено).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Слои, которые надо развести
|
||||||
|
|
||||||
|
| Слой | Что ломает совместимость | Кто страдает | Как закрыто |
|
||||||
|
|---------------------|--------------------------------------------------------------------|-----------------------|----------------------|
|
||||||
|
| **Wire-протокол** | gossipsub topic name, tx encoding, PBFT message format | P2P-сеть целиком | §3. Versioned topics |
|
||||||
|
| **HTTP/WS API** | эндпоинт меняет схему, WS op исчезает | Клиенты (mobile, web) | §4. API versioning |
|
||||||
|
| **Chain state** | новый EventType в блоке, новое поле в TxRecord | Joiner'ы, валидаторы | §5. Chain upgrade |
|
||||||
|
| **Storage layout** | BadgerDB prefix переименован, ключи перемешались | Сам бинарь при старте | §6. DB migrations |
|
||||||
|
| **Docker image** | пересобрать образ, поменять флаги | Только локально | §2. Rolling restart |
|
||||||
|
|
||||||
|
**Главный принцип:** любое изменение проходит как **минимум два релиза** —
|
||||||
|
сначала *"понимаем оба формата, пишем новый"*, потом *"не умеем старый"*.
|
||||||
|
Между ними время, за которое оператор обновляется.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Rolling-restart от git-сервера (single-node)
|
||||||
|
|
||||||
|
### 2.1. Скрипт `deploy/single/update.sh`
|
||||||
|
|
||||||
|
Оператор ставит **один cron/systemd-timer** который дергает этот скрипт:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# deploy/single/update.sh — pull-and-restart update for a single DChain node.
|
||||||
|
# Safe to run unattended: no-op if git HEAD didn't move.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
REPO_DIR="${REPO_DIR:-/opt/dchain}"
|
||||||
|
IMAGE_TAG="${IMAGE_TAG:-dchain-node-slim}"
|
||||||
|
CONTAINER="${CONTAINER:-dchain_node}"
|
||||||
|
|
||||||
|
cd "$REPO_DIR"
|
||||||
|
git fetch --quiet origin main
|
||||||
|
local=$(git rev-parse HEAD)
|
||||||
|
remote=$(git rev-parse origin/main)
|
||||||
|
[[ "$local" = "$remote" ]] && { echo "up to date: $local"; exit 0; }
|
||||||
|
|
||||||
|
echo "updating $local → $remote"
|
||||||
|
|
||||||
|
# 1. rebuild
|
||||||
|
docker build --quiet -t "$IMAGE_TAG:$remote" -t "$IMAGE_TAG:latest" \
|
||||||
|
-f deploy/prod/Dockerfile.slim .
|
||||||
|
|
||||||
|
# 2. smoke-test new image BEFORE killing the running one
|
||||||
|
docker run --rm --entrypoint /usr/local/bin/node "$IMAGE_TAG:$remote" --version \
|
||||||
|
>/dev/null || { echo "new image fails smoke test"; exit 1; }
|
||||||
|
|
||||||
|
# 3. checkpoint the DB (cheap cp-on-write snapshot via badger)
|
||||||
|
curl -fs "http://127.0.0.1:8080/api/admin/checkpoint" \
|
||||||
|
-H "Authorization: Bearer $DCHAIN_API_TOKEN" \
|
||||||
|
|| echo "checkpoint failed, continuing anyway"
|
||||||
|
|
||||||
|
# 4. stop-start with the SAME volume + env
|
||||||
|
git log -1 --pretty='update: %h %s' > .last-update
|
||||||
|
docker compose -f deploy/single/docker-compose.yml up -d --force-recreate node
|
||||||
|
|
||||||
|
# 5. wait for health
|
||||||
|
for i in {1..30}; do
|
||||||
|
curl -fsS http://127.0.0.1:8080/api/netstats >/dev/null && { echo ok; exit 0; }
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
echo "new container did not become healthy"
|
||||||
|
docker logs "$CONTAINER" | tail -40
|
||||||
|
exit 1
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.2. systemd таймер
|
||||||
|
|
||||||
|
```ini
|
||||||
|
# /etc/systemd/system/dchain-update.service
|
||||||
|
[Unit]
|
||||||
|
Description=DChain node pull-and-restart
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
EnvironmentFile=/opt/dchain/deploy/single/node.env
|
||||||
|
ExecStart=/opt/dchain/deploy/single/update.sh
|
||||||
|
|
||||||
|
# /etc/systemd/system/dchain-update.timer
|
||||||
|
[Unit]
|
||||||
|
Description=Pull DChain updates hourly
|
||||||
|
[Timer]
|
||||||
|
OnCalendar=hourly
|
||||||
|
RandomizedDelaySec=15min
|
||||||
|
Persistent=true
|
||||||
|
[Install]
|
||||||
|
WantedBy=timers.target
|
||||||
|
```
|
||||||
|
|
||||||
|
`RandomizedDelaySec=15min` — чтобы куча нод на одной сети не перезапускалась
|
||||||
|
одновременно, иначе на момент обновления PBFT quorum может упасть.
|
||||||
|
|
||||||
|
### 2.3. Даунтайм одной ноды
|
||||||
|
|
||||||
|
| Шаг | Время | Можно ли слать tx? |
|
||||||
|
|-------------------|-------|--------------------|
|
||||||
|
| docker build | 30-90s| да (старая ещё работает) |
|
||||||
|
| docker compose up | 2-5s | нет (transition) |
|
||||||
|
| DB open + replay | 1-3s | нет |
|
||||||
|
| healthy | — | да |
|
||||||
|
|
||||||
|
**Итого ~5-8 секунд простоя на одну ноду.** Клиент (React Native) уже
|
||||||
|
реконнектится по WS автоматически (см. `client-app/lib/ws.ts` — retry loop,
|
||||||
|
max 30s backoff).
|
||||||
|
|
||||||
|
### 2.4. Multi-node rolling (для будущего кластера)
|
||||||
|
|
||||||
|
Когда появится 3+ валидаторов: update скрипт должен обновлять **по одному** с
|
||||||
|
паузой между ними больше, чем health-check interval. В `deploy/prod/` есть
|
||||||
|
`docker-compose.yml` с тремя нодами — там эквивалент выглядит как:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
for n in node1 node2 node3; do
|
||||||
|
docker compose up -d --force-recreate "$n"
|
||||||
|
for i in {1..30}; do
|
||||||
|
curl -fs "http://127.0.0.1:808${n: -1}/api/netstats" >/dev/null && break
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
done
|
||||||
|
```
|
||||||
|
|
||||||
|
Пока в сети 2/3 валидатора живы, PBFT quorum не падает и блоки продолжают
|
||||||
|
коммититься. Единственная нода, которая обновляется, пропустит 1-2 блока и
|
||||||
|
догонит их через gossip gap-fill (уже работает, см. `p2p/host.go` → GetBlocks).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Wire-протокол: versioned topics
|
||||||
|
|
||||||
|
Текущие gossipsub-топики:
|
||||||
|
|
||||||
|
```
|
||||||
|
dchain/tx/v1
|
||||||
|
dchain/blocks/v1
|
||||||
|
dchain/relay/v1
|
||||||
|
```
|
||||||
|
|
||||||
|
`/v1` суффикс — это не формальность, это **рельса под миграцию**. Когда
|
||||||
|
появится несовместимое изменение (напр. новый PBFT round format):
|
||||||
|
|
||||||
|
1. Релиз N: нода подписана на ОБА топика `dchain/blocks/v1` и `dchain/blocks/v2`.
|
||||||
|
Публикует в v2, читает из обоих.
|
||||||
|
2. Релиз N+1 (после того, как оператор видит в `/api/netstats` что все 100%
|
||||||
|
пиров ≥ N): нода перестаёт читать v1.
|
||||||
|
3. Релиз N+2: v1 удаляется из кода.
|
||||||
|
|
||||||
|
Между N и N+2 должно пройти **минимум 30 дней**. За это время у каждого оператора
|
||||||
|
хоть раз сработает auto-update.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. API versioning
|
||||||
|
|
||||||
|
### Уже есть:
|
||||||
|
|
||||||
|
- `/api/*` — v1, "explorer API", stable contract
|
||||||
|
- `/v2/chain/*` — специальная секция для tonapi-подобных клиентов (tonapi совместимость)
|
||||||
|
|
||||||
|
### Правило на будущее:
|
||||||
|
|
||||||
|
1. **Только добавляем поля** к существующим ответам. JSON-клиент не падает от
|
||||||
|
незнакомого поля — Go unmarshal игнорирует, TypeScript через `unknown` каст
|
||||||
|
тоже. Никогда не переименовываем и не удаляем.
|
||||||
|
2. Если нужна breaking-change — новый префикс. Например, если CreateChannelPayload
|
||||||
|
меняет формат, появляется `/v2/channels/*`. Старый `/api/channels/*` сохраняется
|
||||||
|
как read-only adapter поверх нового стораджа.
|
||||||
|
3. **Deprecation header:** когда старый эндпоинт переведён на адаптер, добавить
|
||||||
|
`Warning: 299 - "use /v2/channels/* instead, this will be removed 2026-06-01"`.
|
||||||
|
4. **Клиент сам определяет версию** через `/api/well-known-version`:
|
||||||
|
```json
|
||||||
|
{ "node_version": "0.5.0", "protocol_version": 3, "features": ["channels_v1", "fan_out"] }
|
||||||
|
```
|
||||||
|
Клиент в `client-app/lib/api.ts` кеширует ответ и знает, что можно звать.
|
||||||
|
Уже есть `/api/well-known-contracts` как прецедент; `/api/well-known-version`
|
||||||
|
добавляется одной функцией.
|
||||||
|
|
||||||
|
### Клиентская сторона — graceful degradation:
|
||||||
|
|
||||||
|
- WebSocket: если op `submit_tx` вернул `{error: "unknown_op"}`, fallback на
|
||||||
|
HTTP POST /api/tx.
|
||||||
|
- HTTP: fetch'и обёрнуты в try/catch в `api.ts`, 404 на новом эндпоинте →
|
||||||
|
скрыть фичу в UI (feature-flag), не падать.
|
||||||
|
- **Chain-ID check:** уже есть (`client-app/lib/api.ts` → `networkInfo()`),
|
||||||
|
если нода сменила chain_id — клиент очищает кеш и пересинкается.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Chain state upgrade
|
||||||
|
|
||||||
|
Самый болезненный слой: если блок N+1 содержит EventType, который старая нода
|
||||||
|
не умеет обрабатывать, она **отклонит** весь блок и отвалится от консенсуса.
|
||||||
|
|
||||||
|
### 5.1. Strict forward-compatibility правила для EventType
|
||||||
|
|
||||||
|
```go
|
||||||
|
// ApplyTx в blockchain/chain.go
|
||||||
|
switch ev.Type {
|
||||||
|
case EventTransfer: ...
|
||||||
|
case EventRegisterRelay: ...
|
||||||
|
case EventCreateChannel: ...
|
||||||
|
// ...
|
||||||
|
case EventFutureFeatureWeDontHave:
|
||||||
|
// ← НЕ возвращать error! ЭТО крашнет валидатор на своём же блоке
|
||||||
|
// иначе.
|
||||||
|
// Правило: неизвестный event type === no-op + warn. Tx включается в блок,
|
||||||
|
// fee списывается, результат = ничего не изменилось.
|
||||||
|
chain.log.Warn("unknown event type", "type", ev.Type, "tx", tx.ID)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Проверить:** сейчас `ApplyTx` в `blockchain/chain.go` падает на unknown event.
|
||||||
|
Это приоритетный fix для seamless — добавить в план.
|
||||||
|
|
||||||
|
### 5.2. Feature activation flags
|
||||||
|
|
||||||
|
Новый EventType добавляется в два этапа:
|
||||||
|
|
||||||
|
1. **Release A:** бинарь умеет `EventChannelBan`, но **не пропускает** его в
|
||||||
|
мемпул, пока не увидит в chain state запись `feature:channel_ban:enabled`.
|
||||||
|
Эту запись создаёт одна "activation tx" от валидаторов (multi-sig).
|
||||||
|
2. **Release B (через 30+ дней):** операторы, у которых автопуллится, получили
|
||||||
|
Release A. Один валидатор подаёт activation tx — она пишет в state, все
|
||||||
|
остальные validate её, ОК.
|
||||||
|
3. С этого момента `EventChannelBan` легален. Старые ноды (кто не обновился)
|
||||||
|
отклонят activation tx → отвалятся от консенсуса. Это сознательно: они и
|
||||||
|
так не понимают новый event, лучше явная ошибка "обновись", чем silent
|
||||||
|
divergence.
|
||||||
|
|
||||||
|
Прототип в `blockchain/types.go` уже есть — `chain.GovernanceContract` может
|
||||||
|
хранить feature flags. Нужен конкретный helper `chain.FeatureEnabled(name)`.
|
||||||
|
|
||||||
|
### 5.3. Genesis hash pin
|
||||||
|
|
||||||
|
Новая нода при `--join` скачивает `/api/network-info`, читает `genesis_hash`,
|
||||||
|
сравнивает со своим (пустым, т.к. чистый старт). Если в сети уже есть другой
|
||||||
|
genesis — ошибка `FATAL: genesis hash mismatch`. Это защита от случайного
|
||||||
|
фарка при опечатке в `DCHAIN_JOIN`. Работает сейчас, не трогать.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. DB migrations (BadgerDB)
|
||||||
|
|
||||||
|
Правила работы с префиксами:
|
||||||
|
|
||||||
|
```go
|
||||||
|
const (
|
||||||
|
prefixTx = "tx:"
|
||||||
|
prefixChannel = "chan:"
|
||||||
|
prefixSchemaVer = "schema:v" // ← meta-ключ, хранит текущую версию схемы
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
При старте:
|
||||||
|
|
||||||
|
```go
|
||||||
|
cur := chain.ReadSchemaVersion() // default 0 если ключ отсутствует
|
||||||
|
for cur < TargetSchemaVersion {
|
||||||
|
switch cur {
|
||||||
|
case 0:
|
||||||
|
// migration 0→1: rename prefix "member:" → "chan_mem:"
|
||||||
|
migrate_0_to_1(db)
|
||||||
|
case 1:
|
||||||
|
// migration 1→2: add x25519_pub column to IdentityInfo
|
||||||
|
migrate_1_to_2(db)
|
||||||
|
}
|
||||||
|
cur++
|
||||||
|
chain.WriteSchemaVersion(cur)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Свойства миграции:**
|
||||||
|
|
||||||
|
- Идемпотентна: если упала посередине, повторный старт доделает.
|
||||||
|
- Однонаправлена: downgrade → надо восстанавливать из backup. Это OK, документируется.
|
||||||
|
- Бэкап перед миграцией: `update.sh` из §2.1 делает `/api/admin/checkpoint` до
|
||||||
|
перезапуска. (Этот endpoint надо ещё реализовать — сейчас его нет.)
|
||||||
|
- Первая миграция, которую надо сделать — завести сам mechanism, даже если
|
||||||
|
`TargetSchemaVersion = 0`. Чтобы следующая breaking-change могла им
|
||||||
|
воспользоваться.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Что сделать сейчас, чтобы "не пришлось ничего ломать в будущем"
|
||||||
|
|
||||||
|
Минимальный чек-лист, **отсортирован по приоритету**:
|
||||||
|
|
||||||
|
### P0 (до следующего release):
|
||||||
|
|
||||||
|
- [ ] `ApplyTx`: unknown EventType → warn + no-op, НЕ error. (§5.1)
|
||||||
|
- [ ] `/api/well-known-version` endpoint (§4). Тривиально, 20 строк.
|
||||||
|
- [ ] Schema version meta-ключ в BadgerDB, даже если `current = 0`. (§6)
|
||||||
|
- [ ] `deploy/single/update.sh` + systemd timer примеры. (§2)
|
||||||
|
|
||||||
|
### P1 (до 1.0):
|
||||||
|
|
||||||
|
- [ ] `chain.FeatureEnabled(name)` helper + документ activation flow. (§5.2)
|
||||||
|
- [ ] `/api/admin/checkpoint` endpoint (за token-guard), делает `db.Flatten` +
|
||||||
|
создаёт snapshot в `/data/snapshots/<timestamp>/`. (§2.1)
|
||||||
|
- [ ] Deprecation-header механизм в HTTP middleware. (§4)
|
||||||
|
- [ ] CI smoke-test: "новый бинарь поверх старого volume" — проверяет что
|
||||||
|
миграции не ломают данные.
|
||||||
|
|
||||||
|
### P2 (nice-to-have):
|
||||||
|
|
||||||
|
- [ ] Multi-version e2e test в `cmd/loadtest`: два процесса на разных HEAD,
|
||||||
|
убедиться что они в консенсусе.
|
||||||
|
- [ ] `go-blockchain/pkg/migrate/` отдельный пакет с registry migrations.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Короткий ответ на вопрос
|
||||||
|
|
||||||
|
> надо подумать на счёт синхронизации и обновления ноды с гит сервера, а так
|
||||||
|
> же бесшовности, чтобы не пришлось ничего ломать в будущем
|
||||||
|
|
||||||
|
1. **Синхронизация с git:** `deploy/single/update.sh` + systemd timer раз в час,
|
||||||
|
~5-8 секунд даунтайма на single-node.
|
||||||
|
2. **Бесшовность:** 4 слоя, каждый со своим правилом расширения без
|
||||||
|
ломания — versioned topics, additive-only API, feature-flag activation
|
||||||
|
для новых EventType, schema-versioned БД.
|
||||||
|
3. **P0-тикеты выше** (4 штуки, маленькие) закрывают "семплинг worst case":
|
||||||
|
unknown event как no-op, version endpoint, schema-version key в БД,
|
||||||
|
update-скрипт. Этого достаточно чтобы следующие 3-5 релизов прошли без
|
||||||
|
breaking-change.
|
||||||
63
deploy/prod/Dockerfile.slim
Normal file
63
deploy/prod/Dockerfile.slim
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
# Production image for dchain-node.
|
||||||
|
#
|
||||||
|
# Differs from the repo-root Dockerfile in two ways:
|
||||||
|
# 1. No testdata / contract WASMs baked in — a fresh node uses the native
|
||||||
|
# username_registry (shipped in-binary) and starts with an empty keys
|
||||||
|
# directory; identities and optional WASM contracts come in via
|
||||||
|
# mounted volumes or docker-compose bind mounts.
|
||||||
|
# 2. Builds only `node` and `client` — no wallet/peerid helpers that
|
||||||
|
# aren't needed in production.
|
||||||
|
#
|
||||||
|
# The resulting image is ~20 MB vs ~60 MB for the dev one, and has no
|
||||||
|
# pre-installed keys that an attacker could exploit to impersonate a
|
||||||
|
# testnet validator.
|
||||||
|
|
||||||
|
# ---- build stage ----
|
||||||
|
FROM golang:1.24-alpine AS builder
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY go.mod go.sum ./
|
||||||
|
RUN go mod download
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Build-time version metadata. All four args are injected via -ldflags -X
|
||||||
|
# into go-blockchain/node/version so `node --version` and
|
||||||
|
# /api/well-known-version report the real commit, not the "dev" default.
|
||||||
|
# Callers pass these with `docker build --build-arg VERSION_TAG=... …`;
|
||||||
|
# the deploy/single/update.sh script derives them from git automatically.
|
||||||
|
ARG VERSION_TAG=dev
|
||||||
|
ARG VERSION_COMMIT=none
|
||||||
|
ARG VERSION_DATE=unknown
|
||||||
|
ARG VERSION_DIRTY=false
|
||||||
|
|
||||||
|
RUN LDFLAGS="-s -w \
|
||||||
|
-X go-blockchain/node/version.Tag=${VERSION_TAG} \
|
||||||
|
-X go-blockchain/node/version.Commit=${VERSION_COMMIT} \
|
||||||
|
-X go-blockchain/node/version.Date=${VERSION_DATE} \
|
||||||
|
-X go-blockchain/node/version.Dirty=${VERSION_DIRTY}" && \
|
||||||
|
CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="$LDFLAGS" -o /bin/node ./cmd/node && \
|
||||||
|
CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="$LDFLAGS" -o /bin/client ./cmd/client
|
||||||
|
|
||||||
|
# ---- runtime stage ----
|
||||||
|
FROM alpine:3.19
|
||||||
|
|
||||||
|
RUN apk add --no-cache ca-certificates tzdata
|
||||||
|
|
||||||
|
# Run as unprivileged user by default. Operators can override with --user root
|
||||||
|
# if they need to bind privileged ports (shouldn't be necessary behind Caddy).
|
||||||
|
RUN addgroup -S dchain && adduser -S -G dchain dchain
|
||||||
|
|
||||||
|
COPY --from=builder /bin/node /usr/local/bin/node
|
||||||
|
COPY --from=builder /bin/client /usr/local/bin/client
|
||||||
|
|
||||||
|
USER dchain
|
||||||
|
|
||||||
|
# Default data location; override in compose with a named volume.
|
||||||
|
VOLUME /data
|
||||||
|
|
||||||
|
# libp2p P2P port + HTTP (serves /api/*, /metrics, /api/ws).
|
||||||
|
EXPOSE 4001/tcp
|
||||||
|
EXPOSE 8080/tcp
|
||||||
|
|
||||||
|
ENTRYPOINT ["/usr/local/bin/node"]
|
||||||
163
deploy/prod/README.md
Normal file
163
deploy/prod/README.md
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
# DChain production deployment
|
||||||
|
|
||||||
|
Turn-key-ish stack: 3 validators + Caddy TLS edge + optional
|
||||||
|
Prometheus/Grafana, behind auto-HTTPS.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- Docker + Compose v2
|
||||||
|
- A public IP and open ports `80`, `443`, `4001` (libp2p) on every host
|
||||||
|
- DNS `A`-record pointing `DOMAIN` at the host running Caddy
|
||||||
|
- Basic familiarity with editing env files
|
||||||
|
|
||||||
|
## Layout (single-host pilot)
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─ Caddy :443 ── TLS terminate ──┬─ node1:8080 ──┐
|
||||||
|
internet ────────→│ ├─ node2:8080 │ round-robin /api/*
|
||||||
|
└─ Caddy :4001 (passthrough) └─ node3:8080 │ ip_hash /api/ws
|
||||||
|
...
|
||||||
|
Prometheus → node{1,2,3}:8080/metrics
|
||||||
|
Grafana ← Prometheus data source
|
||||||
|
```
|
||||||
|
|
||||||
|
For a real multi-datacentre deployment, copy this whole directory onto each
|
||||||
|
VPS, edit `docker-compose.yml` to keep only the node that runs there, and
|
||||||
|
put Caddy on one dedicated edge host (or none — point clients at one node
|
||||||
|
directly and accept the lower availability).
|
||||||
|
|
||||||
|
## First-boot procedure
|
||||||
|
|
||||||
|
1. **Generate keys** for each validator. Easiest way:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# On any box with the repo checked out
|
||||||
|
docker build -t dchain-node-slim -f deploy/prod/Dockerfile.slim .
|
||||||
|
mkdir -p deploy/prod/keys
|
||||||
|
for i in 1 2 3; do
|
||||||
|
docker run --rm -v "$PWD/deploy/prod/keys:/out" dchain-node-slim \
|
||||||
|
/usr/local/bin/client keygen --out /out/node$i.json
|
||||||
|
done
|
||||||
|
cat deploy/prod/keys/node*.json | jq -r .pub_key # → copy into DCHAIN_VALIDATORS
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Configure env files**. Copy `node.env.example` to `node1.env`,
|
||||||
|
`node2.env`, `node3.env`. Paste the three pubkeys from step 1 into
|
||||||
|
`DCHAIN_VALIDATORS` in ALL THREE files. Set `DOMAIN` to your public host.
|
||||||
|
|
||||||
|
3. **Start the network**:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
DOMAIN=dchain.example.com docker compose up -d
|
||||||
|
docker compose logs -f node1 # watch genesis + first blocks
|
||||||
|
```
|
||||||
|
|
||||||
|
First block is genesis (index 0), created only by `node1` because it has
|
||||||
|
the `--genesis` flag. After you see blocks #1, #2, #3… committing,
|
||||||
|
**edit `docker-compose.yml` and remove the `--genesis` flag from node1's
|
||||||
|
command section**, then `docker compose up -d node1` to re-create it
|
||||||
|
without that flag. Leaving `--genesis` in makes no-op on a non-empty DB
|
||||||
|
but is noise in the logs.
|
||||||
|
|
||||||
|
4. **Verify HTTPS** and HTTP-to-HTTPS redirect:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s https://$DOMAIN/api/netstats | jq
|
||||||
|
curl -s https://$DOMAIN/api/well-known-contracts | jq
|
||||||
|
```
|
||||||
|
|
||||||
|
Caddy should have issued a cert automatically from Let's Encrypt.
|
||||||
|
|
||||||
|
5. **(Optional) observability**:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
GRAFANA_ADMIN_PW=$(openssl rand -hex 24) docker compose --profile monitor up -d
|
||||||
|
# Grafana at http://<host>:3000, user admin, password from env
|
||||||
|
```
|
||||||
|
|
||||||
|
Add a "Prometheus" data source pointing at `http://prometheus:9090`,
|
||||||
|
then import a dashboard that graphs:
|
||||||
|
- `dchain_blocks_total` (rate)
|
||||||
|
- `dchain_tx_submit_accepted_total` / `rejected_total`
|
||||||
|
- `dchain_ws_connections`
|
||||||
|
- `dchain_peer_count_live`
|
||||||
|
- `rate(dchain_block_commit_seconds_sum[5m]) / rate(dchain_block_commit_seconds_count[5m])`
|
||||||
|
|
||||||
|
## Common tasks
|
||||||
|
|
||||||
|
### Add a 4th validator
|
||||||
|
|
||||||
|
The new node joins as an observer via `--join`, then an existing validator
|
||||||
|
promotes it on-chain:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# On the new box
|
||||||
|
docker run -d --name node4 \
|
||||||
|
--volumes chaindata:/data \
|
||||||
|
-e DCHAIN_ANNOUNCE=/ip4/<public-ip>/tcp/4001 \
|
||||||
|
dchain-node-slim \
|
||||||
|
--db=/data/chain --join=https://$DOMAIN --register-relay
|
||||||
|
```
|
||||||
|
|
||||||
|
Then from any existing validator:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose exec node1 /usr/local/bin/client add-validator \
|
||||||
|
--key /keys/node.json \
|
||||||
|
--node http://localhost:8080 \
|
||||||
|
--target <NEW_PUBKEY>
|
||||||
|
```
|
||||||
|
|
||||||
|
The new node starts signing as soon as it sees itself in the validator set
|
||||||
|
on-chain — no restart needed.
|
||||||
|
|
||||||
|
### Upgrade without downtime
|
||||||
|
|
||||||
|
PBFT tolerates `f` faulty nodes out of `3f+1`. For 3 validators that means
|
||||||
|
**zero** — any offline node halts consensus. So for 3-node clusters:
|
||||||
|
|
||||||
|
1. `docker compose pull && docker compose build` on all three hosts first.
|
||||||
|
2. Graceful one-at-a-time: `docker compose up -d --no-deps node1`, wait for
|
||||||
|
`/api/netstats` to show it catching up, then do node2, then node3.
|
||||||
|
|
||||||
|
For 4+ nodes you can afford one-at-a-time hot rolls.
|
||||||
|
|
||||||
|
### Back up the chain
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run --rm -v node1_data:/data -v "$PWD":/bak alpine \
|
||||||
|
tar czf /bak/dchain-backup-$(date +%F).tar.gz -C /data .
|
||||||
|
```
|
||||||
|
|
||||||
|
Restore by swapping the file back into a fresh named volume before node
|
||||||
|
startup.
|
||||||
|
|
||||||
|
### Remove a bad validator
|
||||||
|
|
||||||
|
Same as adding but with `remove-validator`. Only works if a majority of
|
||||||
|
CURRENT validators cosign the removal — intentional, keeps one rogue
|
||||||
|
validator from kicking others unilaterally (see ROADMAP P2.1).
|
||||||
|
|
||||||
|
## Security notes
|
||||||
|
|
||||||
|
- `/metrics` is firewalled to internal networks by Caddy. If you need
|
||||||
|
external scraping, add proper auth (Caddy `basicauth` or mTLS).
|
||||||
|
- All public endpoints are rate-limited per-IP via the node itself — see
|
||||||
|
`api_guards.go`. Adjust limits before releasing to the open internet.
|
||||||
|
- Each node runs as non-root inside a read-only rootfs container with all
|
||||||
|
capabilities dropped. If you need to exec into one, `docker compose exec
|
||||||
|
--user root nodeN sh`.
|
||||||
|
- The Ed25519 key files mounted at `/keys/node.json` are your validator
|
||||||
|
identities. Losing them means losing your ability to produce blocks; get
|
||||||
|
them onto the host via your normal secret-management (Vault, sealed-
|
||||||
|
secrets, encrypted tarball at deploy time). **Never commit them to git.**
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
| Symptom | Check |
|
||||||
|
|---------|-------|
|
||||||
|
| Caddy keeps issuing `failed to get certificate` | Is port 80 open? DNS A-record pointing here? `docker compose logs caddy` |
|
||||||
|
| New node can't sync: `FATAL: genesis hash mismatch` | The `--db` volume has data from a different chain. `docker volume rm nodeN_data` and re-up |
|
||||||
|
| Chain stops producing blocks | `docker compose logs nodeN \| tail -100`; look for `SLOW AddBlock` or validator silence |
|
||||||
|
| `/api/ws` returns 429 | Client opened > `WSMaxConnectionsPerIP` (default 10). Check `ws.go` for per-IP cap |
|
||||||
|
| Disk usage growing | Background vlog GC runs every 5 min. Manual: `docker compose exec nodeN /bin/sh -c 'kill -USR1 1'` (see `StartValueLogGC`) |
|
||||||
88
deploy/prod/caddy/Caddyfile
Normal file
88
deploy/prod/caddy/Caddyfile
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
# Caddy configuration for DChain prod.
|
||||||
|
#
|
||||||
|
# What this does:
|
||||||
|
# 1. Auto-HTTPS via Let's Encrypt (requires the DOMAIN envvar and
|
||||||
|
# a DNS A-record pointing at this host).
|
||||||
|
# 2. Round-robins HTTP /api/* across the three node backends. GETs are
|
||||||
|
# idempotent so round-robin is safe; POST /api/tx is accepted by any
|
||||||
|
# validator and gossiped to the rest — no stickiness needed.
|
||||||
|
# 3. Routes /api/ws (WebSocket upgrade) through with header
|
||||||
|
# preservation. Uses ip_hash (lb_policy client_ip) so one client
|
||||||
|
# sticks to one node — avoids re-doing the auth handshake on every
|
||||||
|
# subscribe.
|
||||||
|
# 4. Serves /metrics ONLY from localhost IPs so the Prometheus inside
|
||||||
|
# the stack can scrape it; public scrapers are refused.
|
||||||
|
#
|
||||||
|
# To use:
|
||||||
|
# - Set environment var DOMAIN before `docker compose up`:
|
||||||
|
# DOMAIN=dchain.example.com docker compose up -d
|
||||||
|
# - DNS must resolve DOMAIN → this host's public IP.
|
||||||
|
# - Port 80 must be reachable for ACME HTTP-01 challenge.
|
||||||
|
{
|
||||||
|
# Global options. `auto_https` is on by default — leave it alone.
|
||||||
|
email {$ACME_EMAIL:admin@example.com}
|
||||||
|
servers {
|
||||||
|
# Enable HTTP/3 for mobile clients.
|
||||||
|
protocols h1 h2 h3
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Public endpoint ────────────────────────────────────────────────────────
|
||||||
|
{$DOMAIN:localhost} {
|
||||||
|
# Compression for JSON / HTML responses.
|
||||||
|
encode zstd gzip
|
||||||
|
|
||||||
|
# ── WebSocket ──────────────────────────────────────────────────────
|
||||||
|
# Client-IP stickiness so reconnects land on the same node. This keeps
|
||||||
|
# per-subscription state local and avoids replaying every auth+subscribe
|
||||||
|
# to a cold node.
|
||||||
|
@ws path /api/ws
|
||||||
|
handle @ws {
|
||||||
|
reverse_proxy node1:8080 node2:8080 node3:8080 {
|
||||||
|
lb_policy ip_hash
|
||||||
|
# Health-check filters dead nodes out of the pool automatically.
|
||||||
|
health_uri /api/netstats
|
||||||
|
health_interval 15s
|
||||||
|
# Upgrade headers preserved by Caddy by default for WS path; no
|
||||||
|
# extra config needed.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── REST API ──────────────────────────────────────────────────────
|
||||||
|
handle /api/* {
|
||||||
|
reverse_proxy node1:8080 node2:8080 node3:8080 {
|
||||||
|
lb_policy least_conn
|
||||||
|
health_uri /api/netstats
|
||||||
|
health_interval 15s
|
||||||
|
# Soft fail open: if no node is healthy, return a clear 503.
|
||||||
|
fail_duration 30s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── /metrics — internal only ──────────────────────────────────────
|
||||||
|
# Refuse external scraping of Prometheus metrics. Inside the Docker
|
||||||
|
# network Prometheus hits node1:8080/metrics directly, bypassing Caddy.
|
||||||
|
@metricsPublic {
|
||||||
|
path /metrics
|
||||||
|
not remote_ip 127.0.0.1 ::1 172.16.0.0/12 192.168.0.0/16 10.0.0.0/8
|
||||||
|
}
|
||||||
|
handle @metricsPublic {
|
||||||
|
respond "forbidden" 403
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Everything else → explorer HTML ───────────────────────────────
|
||||||
|
handle {
|
||||||
|
reverse_proxy node1:8080 {
|
||||||
|
health_uri /api/netstats
|
||||||
|
health_interval 15s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Server-side logging; write JSON for easy log aggregation.
|
||||||
|
log {
|
||||||
|
output stdout
|
||||||
|
format json
|
||||||
|
level INFO
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
175
deploy/prod/docker-compose.yml
Normal file
175
deploy/prod/docker-compose.yml
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
name: dchain-prod
|
||||||
|
|
||||||
|
# ══════════════════════════════════════════════════════════════════════════
|
||||||
|
# DChain production stack.
|
||||||
|
#
|
||||||
|
# Layout:
|
||||||
|
# - 3 validator nodes, each with its own persistent volume and key file
|
||||||
|
# - Caddy reverse proxy on the edge: auto-HTTPS from Let's Encrypt,
|
||||||
|
# rewrites ws upgrades, round-robins /api/* across nodes
|
||||||
|
# - Prometheus + Grafana for observability (optional, profile=monitor)
|
||||||
|
#
|
||||||
|
# Quick start (1-host single-server):
|
||||||
|
# cp node.env.example node1.env # edit domain / pubkeys
|
||||||
|
# cp node.env.example node2.env
|
||||||
|
# cp node.env.example node3.env
|
||||||
|
# docker compose up -d # runs nodes + Caddy
|
||||||
|
# docker compose --profile monitor up -d # adds Prometheus + Grafana
|
||||||
|
#
|
||||||
|
# For multi-host (the realistic case), copy this file per VPS and remove
|
||||||
|
# the two nodes that aren't yours; Caddy can still live on one of them or
|
||||||
|
# on a dedicated edge box. Operators are expected to edit this file —
|
||||||
|
# it's a reference, not a magic turnkey.
|
||||||
|
#
|
||||||
|
# Key files:
|
||||||
|
# ./keys/node{1,2,3}.json — Ed25519 identity, bake in via bind mount
|
||||||
|
# ./caddy/Caddyfile — auto-HTTPS config
|
||||||
|
# ./node.env.example — ENV template
|
||||||
|
# ./prometheus.yml — scrape config
|
||||||
|
# ══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
networks:
|
||||||
|
internet:
|
||||||
|
name: dchain_internet
|
||||||
|
driver: bridge
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
node1_data:
|
||||||
|
node2_data:
|
||||||
|
node3_data:
|
||||||
|
caddy_data:
|
||||||
|
caddy_config:
|
||||||
|
prom_data:
|
||||||
|
grafana_data:
|
||||||
|
|
||||||
|
x-node-base: &node-base
|
||||||
|
build:
|
||||||
|
context: ../..
|
||||||
|
dockerfile: deploy/prod/Dockerfile.slim
|
||||||
|
restart: unless-stopped
|
||||||
|
networks: [internet]
|
||||||
|
# Drop all Linux capabilities — the node binary needs none.
|
||||||
|
cap_drop: [ALL]
|
||||||
|
# Read-only root FS; only /data is writable (volume-mounted).
|
||||||
|
read_only: true
|
||||||
|
tmpfs: [/tmp]
|
||||||
|
security_opt: [no-new-privileges:true]
|
||||||
|
# Health check hits /api/netstats through the local HTTP server.
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8080/api/netstats >/dev/null || exit 1"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 6
|
||||||
|
start_period: 15s
|
||||||
|
|
||||||
|
services:
|
||||||
|
node1:
|
||||||
|
<<: *node-base
|
||||||
|
container_name: dchain_node1
|
||||||
|
hostname: node1
|
||||||
|
env_file: ./node1.env
|
||||||
|
volumes:
|
||||||
|
- node1_data:/data
|
||||||
|
- ./keys/node1.json:/keys/node.json:ro
|
||||||
|
command:
|
||||||
|
- "--genesis" # drop --genesis after first boot
|
||||||
|
- "--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"
|
||||||
|
- "--heartbeat=true"
|
||||||
|
- "--register-relay"
|
||||||
|
|
||||||
|
node2:
|
||||||
|
<<: *node-base
|
||||||
|
container_name: dchain_node2
|
||||||
|
hostname: node2
|
||||||
|
env_file: ./node2.env
|
||||||
|
depends_on:
|
||||||
|
node1: { condition: service_healthy }
|
||||||
|
volumes:
|
||||||
|
- node2_data:/data
|
||||||
|
- ./keys/node2.json:/keys/node.json:ro
|
||||||
|
command:
|
||||||
|
- "--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"
|
||||||
|
- "--join=http://node1:8080" # bootstrap from node1
|
||||||
|
- "--register-relay"
|
||||||
|
|
||||||
|
node3:
|
||||||
|
<<: *node-base
|
||||||
|
container_name: dchain_node3
|
||||||
|
hostname: node3
|
||||||
|
env_file: ./node3.env
|
||||||
|
depends_on:
|
||||||
|
node1: { condition: service_healthy }
|
||||||
|
volumes:
|
||||||
|
- node3_data:/data
|
||||||
|
- ./keys/node3.json:/keys/node.json:ro
|
||||||
|
command:
|
||||||
|
- "--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"
|
||||||
|
- "--join=http://node1:8080"
|
||||||
|
- "--register-relay"
|
||||||
|
|
||||||
|
# ── Edge: Caddy with auto-HTTPS + WS upgrade + load-balancing ────────────
|
||||||
|
caddy:
|
||||||
|
image: caddy:2.8-alpine
|
||||||
|
container_name: dchain_caddy
|
||||||
|
restart: unless-stopped
|
||||||
|
networks: [internet]
|
||||||
|
ports:
|
||||||
|
- "80:80"
|
||||||
|
- "443:443"
|
||||||
|
- "443:443/udp" # HTTP/3 / QUIC
|
||||||
|
volumes:
|
||||||
|
- ./caddy/Caddyfile:/etc/caddy/Caddyfile:ro
|
||||||
|
- caddy_data:/data
|
||||||
|
- caddy_config:/config
|
||||||
|
depends_on:
|
||||||
|
node1: { condition: service_healthy }
|
||||||
|
|
||||||
|
# ── Observability ────────────────────────────────────────────────────────
|
||||||
|
# Start these only when needed: `docker compose --profile monitor up -d`
|
||||||
|
|
||||||
|
prometheus:
|
||||||
|
profiles: [monitor]
|
||||||
|
image: prom/prometheus:v2.53.0
|
||||||
|
container_name: dchain_prometheus
|
||||||
|
restart: unless-stopped
|
||||||
|
networks: [internet]
|
||||||
|
volumes:
|
||||||
|
- ./prometheus.yml:/etc/prometheus/prometheus.yml:ro
|
||||||
|
- prom_data:/prometheus
|
||||||
|
command:
|
||||||
|
- "--config.file=/etc/prometheus/prometheus.yml"
|
||||||
|
- "--storage.tsdb.retention.time=30d"
|
||||||
|
# No external port — exposed only to Grafana via internal network.
|
||||||
|
|
||||||
|
grafana:
|
||||||
|
profiles: [monitor]
|
||||||
|
image: grafana/grafana:11.1.0
|
||||||
|
container_name: dchain_grafana
|
||||||
|
restart: unless-stopped
|
||||||
|
networks: [internet]
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
|
depends_on: [prometheus]
|
||||||
|
environment:
|
||||||
|
GF_SECURITY_ADMIN_USER: admin
|
||||||
|
GF_SECURITY_ADMIN_PASSWORD: ${GRAFANA_ADMIN_PW:-change-me}
|
||||||
|
GF_USERS_ALLOW_SIGN_UP: "false"
|
||||||
|
volumes:
|
||||||
|
- grafana_data:/var/lib/grafana
|
||||||
|
- ./grafana/datasources:/etc/grafana/provisioning/datasources:ro
|
||||||
|
- ./grafana/dashboards:/etc/grafana/provisioning/dashboards:ro
|
||||||
36
deploy/prod/node.env.example
Normal file
36
deploy/prod/node.env.example
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
# DChain node environment — copy to node1.env / node2.env / node3.env and
|
||||||
|
# customise per-host. These values are read by the node binary via ENV
|
||||||
|
# fallback (flags still override).
|
||||||
|
#
|
||||||
|
# Required:
|
||||||
|
# DCHAIN_VALIDATORS Comma-separated Ed25519 pubkeys of the initial
|
||||||
|
# validator set. All three nodes must agree on
|
||||||
|
# this list at genesis; later additions happen
|
||||||
|
# on-chain via ADD_VALIDATOR.
|
||||||
|
# DCHAIN_ANNOUNCE Public libp2p multiaddr peers use to dial this
|
||||||
|
# node from the internet. e.g.
|
||||||
|
# /ip4/203.0.113.10/tcp/4001
|
||||||
|
#
|
||||||
|
# Optional:
|
||||||
|
# DCHAIN_PEERS Bootstrap peer multiaddrs. Auto-filled by
|
||||||
|
# --join if omitted.
|
||||||
|
# DCHAIN_GOVERNANCE_CONTRACT Deployed governance contract ID (hex).
|
||||||
|
# DCHAIN_RELAY_FEE µT per message when registering as a relay.
|
||||||
|
# ACME_EMAIL Email for Let's Encrypt (TLS expiry reminders).
|
||||||
|
# DOMAIN Public hostname — Caddy issues cert for this.
|
||||||
|
#
|
||||||
|
# Security:
|
||||||
|
# Key files are bind-mounted at runtime; do NOT put private keys in this
|
||||||
|
# file. Each node needs its own identity — generate with
|
||||||
|
# docker compose run --rm node1 /usr/local/bin/client keygen --out /keys/node.json
|
||||||
|
# and copy out with `docker cp`.
|
||||||
|
|
||||||
|
DCHAIN_VALIDATORS=PUT_FIRST_PUBKEY_HERE,PUT_SECOND_PUBKEY_HERE,PUT_THIRD_PUBKEY_HERE
|
||||||
|
DCHAIN_ANNOUNCE=/ip4/0.0.0.0/tcp/4001
|
||||||
|
|
||||||
|
# DCHAIN_PEERS=/ip4/203.0.113.10/tcp/4001/p2p/12D3Koo...
|
||||||
|
# DCHAIN_GOVERNANCE_CONTRACT=
|
||||||
|
# DCHAIN_RELAY_FEE=1000
|
||||||
|
# ACME_EMAIL=admin@example.com
|
||||||
|
# DOMAIN=dchain.example.com
|
||||||
|
# GRAFANA_ADMIN_PW=change-me-to-something-long
|
||||||
17
deploy/prod/prometheus.yml
Normal file
17
deploy/prod/prometheus.yml
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
# Prometheus scrape config for DChain prod.
|
||||||
|
# Mounted read-only into the prometheus container.
|
||||||
|
|
||||||
|
global:
|
||||||
|
scrape_interval: 15s
|
||||||
|
scrape_timeout: 5s
|
||||||
|
evaluation_interval: 30s
|
||||||
|
external_labels:
|
||||||
|
network: dchain-prod
|
||||||
|
|
||||||
|
scrape_configs:
|
||||||
|
- job_name: dchain-node
|
||||||
|
metrics_path: /metrics
|
||||||
|
static_configs:
|
||||||
|
- targets: [node1:8080, node2:8080, node3:8080]
|
||||||
|
labels:
|
||||||
|
group: validators
|
||||||
46
deploy/single/Caddyfile
Normal file
46
deploy/single/Caddyfile
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
# Single-node Caddy: TLS terminate + WS upgrade + internal-only /metrics.
|
||||||
|
#
|
||||||
|
# No load balancing — one node backend. Keeps the file short and easy to
|
||||||
|
# audit. For a multi-node deployment see deploy/prod/caddy/Caddyfile.
|
||||||
|
{
|
||||||
|
email {$ACME_EMAIL:admin@example.com}
|
||||||
|
servers {
|
||||||
|
protocols h1 h2 h3
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
{$DOMAIN:localhost} {
|
||||||
|
encode zstd gzip
|
||||||
|
|
||||||
|
# WebSocket (single backend; no stickiness concerns).
|
||||||
|
@ws path /api/ws
|
||||||
|
handle @ws {
|
||||||
|
reverse_proxy node:8080
|
||||||
|
}
|
||||||
|
|
||||||
|
# REST API.
|
||||||
|
handle /api/* {
|
||||||
|
reverse_proxy node:8080
|
||||||
|
}
|
||||||
|
|
||||||
|
# /metrics is for the operator's Prometheus only. Block external IPs.
|
||||||
|
@metricsPublic {
|
||||||
|
path /metrics
|
||||||
|
not remote_ip 127.0.0.1 ::1 172.16.0.0/12 192.168.0.0/16 10.0.0.0/8
|
||||||
|
}
|
||||||
|
handle @metricsPublic {
|
||||||
|
respond "forbidden" 403
|
||||||
|
}
|
||||||
|
|
||||||
|
# Anything else → explorer HTML from the node.
|
||||||
|
handle {
|
||||||
|
reverse_proxy node:8080
|
||||||
|
}
|
||||||
|
|
||||||
|
log {
|
||||||
|
output stdout
|
||||||
|
format json
|
||||||
|
level INFO
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
387
deploy/single/README.md
Normal file
387
deploy/single/README.md
Normal file
@@ -0,0 +1,387 @@
|
|||||||
|
# DChain single-node deployment
|
||||||
|
|
||||||
|
Один узел + опционально Caddy TLS + опционально Prometheus/Grafana.
|
||||||
|
Подходит под четыре основных сценария:
|
||||||
|
|
||||||
|
1. **личная нода** — публичная или приватная с токеном,
|
||||||
|
2. **первый узел новой сети** (genesis),
|
||||||
|
3. **присоединение к существующей сети** (relay / observer / validator),
|
||||||
|
4. **headless API-нода** для мобильных клиентов — без HTML-UI.
|
||||||
|
|
||||||
|
Для 3-валидаторного кластера смотри `../prod/`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Навигация
|
||||||
|
|
||||||
|
- [0. Что поднимается](#0-что-поднимается)
|
||||||
|
- [1. Быстрый старт](#1-быстрый-старт)
|
||||||
|
- [2. Сценарии конфигурации](#2-сценарии-конфигурации)
|
||||||
|
- [2.1. Публичная нода с UI и открытым Swagger](#21-публичная-нода-с-ui-и-открытым-swagger)
|
||||||
|
- [2.2. Headless API-нода (без UI, Swagger открыт)](#22-headless-api-нода-без-ui-swagger-открыт)
|
||||||
|
- [2.3. Полностью приватная (токен на всё, UI выключен)](#23-полностью-приватная-токен-на-всё-ui-выключен)
|
||||||
|
- [2.4. Только-API без Swagger](#24-только-api-без-swagger)
|
||||||
|
- [2.5. Первая нода новой сети (--genesis)](#25-первая-нода-новой-сети---genesis)
|
||||||
|
- [2.6. Присоединение к существующей сети (--join)](#26-присоединение-к-существующей-сети---join)
|
||||||
|
- [3. HTTP-поверхность](#3-http-поверхность)
|
||||||
|
- [4. Auto-update от Gitea](#4-auto-update-от-gitea)
|
||||||
|
- [5. Обновление / бэкап / восстановление](#5-обновление--бэкап--восстановление)
|
||||||
|
- [6. Troubleshooting](#6-troubleshooting)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0. Что поднимается
|
||||||
|
|
||||||
|
Базовый compose (`docker compose up -d`) поднимает:
|
||||||
|
|
||||||
|
| Сервис | Что это | Порты |
|
||||||
|
|--------|---------|-------|
|
||||||
|
| `node` | сама нода DChain (`dchain-node-slim` image) | `4001` (libp2p P2P, наружу), `8080` (HTTP/WS — только через Caddy) |
|
||||||
|
| `caddy` | TLS edge с auto-HTTPS (Let's Encrypt) | `80`, `443`, `443/udp` |
|
||||||
|
|
||||||
|
С `--profile monitor` добавляются:
|
||||||
|
|
||||||
|
| Сервис | Что это | Порты |
|
||||||
|
|--------|---------|-------|
|
||||||
|
| `prometheus` | метрики + TSDB (30 дней retention) | внутри сети |
|
||||||
|
| `grafana` | дашборды | `3000` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Быстрый старт
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Сгенерируй ключ ноды (один раз, храни в безопасности).
|
||||||
|
docker build -t dchain-node-slim -f ../prod/Dockerfile.slim ../..
|
||||||
|
mkdir -p keys
|
||||||
|
docker run --rm --entrypoint /usr/local/bin/client \
|
||||||
|
-v "$PWD/keys:/out" dchain-node-slim \
|
||||||
|
keygen --out /out/node.json
|
||||||
|
|
||||||
|
# 2. Скопируй env и отредактируй.
|
||||||
|
cp node.env.example node.env
|
||||||
|
$EDITOR node.env # минимум: DCHAIN_ANNOUNCE, DOMAIN, DCHAIN_API_TOKEN
|
||||||
|
|
||||||
|
# 3. Подними.
|
||||||
|
docker compose up -d
|
||||||
|
|
||||||
|
# 4. (опционально) Мониторинг.
|
||||||
|
GRAFANA_ADMIN_PW=$(openssl rand -hex 16) \
|
||||||
|
docker compose --profile monitor up -d
|
||||||
|
# → Grafana http://<host>:3000, источник http://prometheus:9090
|
||||||
|
|
||||||
|
# 5. Проверь живость.
|
||||||
|
curl -s https://$DOMAIN/api/netstats
|
||||||
|
curl -s https://$DOMAIN/api/well-known-version
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Windows:** если запускаете через Docker Desktop и Git Bash, добавляйте
|
||||||
|
> `MSYS_NO_PATHCONV=1` перед командами с `/out`, `/keys` и подобными Unix-путями
|
||||||
|
> — иначе Git Bash сконвертирует их в Windows-пути.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Сценарии конфигурации
|
||||||
|
|
||||||
|
Все сценарии отличаются только содержимым `node.env`. Пересоздавать
|
||||||
|
контейнер: `docker compose up -d --force-recreate node`.
|
||||||
|
|
||||||
|
### 2.1. Публичная нода с UI и открытым Swagger
|
||||||
|
|
||||||
|
**Когда подходит:** вы хотите показать Explorer всем (адрес для поиска
|
||||||
|
по pubkey, история блоков, список валидаторов), и оставить Swagger как
|
||||||
|
живую документацию API.
|
||||||
|
|
||||||
|
```ini
|
||||||
|
# node.env
|
||||||
|
DCHAIN_ANNOUNCE=/ip4/203.0.113.10/tcp/4001
|
||||||
|
DOMAIN=dchain.example.com
|
||||||
|
ACME_EMAIL=you@example.com
|
||||||
|
|
||||||
|
# никакого токена — публичный режим
|
||||||
|
# UI и Swagger зажгутся по умолчанию (флаги ниже не задаём)
|
||||||
|
```
|
||||||
|
|
||||||
|
Результат:
|
||||||
|
|
||||||
|
| URL | Что там |
|
||||||
|
|-----|---------|
|
||||||
|
| `https://$DOMAIN/` | Блок-эксплорер (главная) |
|
||||||
|
| `https://$DOMAIN/address?pub=…` | Баланс + история по pubkey |
|
||||||
|
| `https://$DOMAIN/tx?id=…` | Детали транзакции |
|
||||||
|
| `https://$DOMAIN/validators` | Список валидаторов |
|
||||||
|
| `https://$DOMAIN/tokens` | Зарегистрированные токены |
|
||||||
|
| `https://$DOMAIN/swagger` | **Swagger UI** — интерактивная OpenAPI спека |
|
||||||
|
| `https://$DOMAIN/swagger/openapi.json` | Сырой OpenAPI JSON — для codegen |
|
||||||
|
| `https://$DOMAIN/api/*` | Вся JSON-API поверхность |
|
||||||
|
| `https://$DOMAIN/metrics` | Prometheus exposition |
|
||||||
|
|
||||||
|
|
||||||
|
### 2.2. Headless API-нода (без UI, Swagger открыт)
|
||||||
|
|
||||||
|
**Когда подходит:** нода — это бэкенд для мобильного приложения,
|
||||||
|
HTML-эксплорер не нужен, но Swagger хочется оставить как доку для
|
||||||
|
разработчиков.
|
||||||
|
|
||||||
|
```ini
|
||||||
|
# node.env
|
||||||
|
DCHAIN_ANNOUNCE=/ip4/203.0.113.20/tcp/4001
|
||||||
|
DOMAIN=api.dchain.example.com
|
||||||
|
|
||||||
|
# Отключаем HTML-страницы эксплорера, но НЕ Swagger.
|
||||||
|
DCHAIN_DISABLE_UI=true
|
||||||
|
```
|
||||||
|
|
||||||
|
Эффект:
|
||||||
|
- `GET /` → `404 page not found`
|
||||||
|
- `GET /address`, `/tx`, `/validators`, `/tokens`, `/contract` и все
|
||||||
|
`/assets/explorer/*` → 404.
|
||||||
|
- `GET /swagger` → Swagger UI, работает (без изменений).
|
||||||
|
- `GET /api/*`, `GET /metrics`, `GET /api/ws` → работают.
|
||||||
|
|
||||||
|
Нода логирует:
|
||||||
|
```
|
||||||
|
[NODE] explorer UI: disabled (--disable-ui)
|
||||||
|
[NODE] swagger: http://0.0.0.0:8080/swagger
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
### 2.3. Полностью приватная (токен на всё, UI выключен)
|
||||||
|
|
||||||
|
**Когда подходит:** персональная нода под мессенджер, вы — единственный
|
||||||
|
пользователь, никому посторонним не должна быть видна даже статистика.
|
||||||
|
|
||||||
|
```ini
|
||||||
|
# node.env
|
||||||
|
DCHAIN_ANNOUNCE=/ip4/203.0.113.30/tcp/4001
|
||||||
|
DOMAIN=node.personal.example
|
||||||
|
|
||||||
|
DCHAIN_API_TOKEN=$(openssl rand -hex 32) # скопируйте в клиент
|
||||||
|
DCHAIN_API_PRIVATE=true # закрывает и read-эндпоинты
|
||||||
|
|
||||||
|
# UI вам не нужен, а кому бы и был — всё равно 401 без токена.
|
||||||
|
DCHAIN_DISABLE_UI=true
|
||||||
|
```
|
||||||
|
|
||||||
|
Эффект:
|
||||||
|
- Любой `/api/*` без `Authorization: Bearer <token>` → `401`.
|
||||||
|
- `/swagger` по-прежнему отдаётся (он не кастомизируется под токены,
|
||||||
|
а API-вызовы из Swagger UI будут возвращать 401 — это нормально).
|
||||||
|
- P2P порт `4001` остаётся открытым — без него нода не синкается с сетью.
|
||||||
|
|
||||||
|
Передать токен клиенту:
|
||||||
|
```ts
|
||||||
|
// client-app/lib/api.ts — в post()/get() добавить:
|
||||||
|
headers: { 'Authorization': 'Bearer ' + YOUR_TOKEN }
|
||||||
|
|
||||||
|
// для WebSocket — токен как query-параметр:
|
||||||
|
this.url = base.replace(/^http/, 'ws') + '/api/ws?token=' + YOUR_TOKEN;
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
### 2.4. Только-API без Swagger
|
||||||
|
|
||||||
|
**Когда подходит:** максимально hardened headless-нода. Даже описание
|
||||||
|
API поверхности не должно быть на виду.
|
||||||
|
|
||||||
|
```ini
|
||||||
|
DCHAIN_ANNOUNCE=/ip4/203.0.113.40/tcp/4001
|
||||||
|
DOMAIN=rpc.dchain.example.com
|
||||||
|
|
||||||
|
DCHAIN_DISABLE_UI=true
|
||||||
|
DCHAIN_DISABLE_SWAGGER=true
|
||||||
|
```
|
||||||
|
|
||||||
|
Эффект:
|
||||||
|
- `/` → 404, `/swagger` → 404, `/api/*` → работает.
|
||||||
|
- В логах:
|
||||||
|
```
|
||||||
|
[NODE] explorer UI: disabled (--disable-ui)
|
||||||
|
[NODE] swagger: disabled (--disable-swagger)
|
||||||
|
```
|
||||||
|
- Swagger спеку всё равно можно сгенерить локально: `go run ./cmd/node`
|
||||||
|
в dev-режиме → `http://localhost:8080/swagger/openapi.json` → сохранить.
|
||||||
|
|
||||||
|
|
||||||
|
### 2.5. Первая нода новой сети (`--genesis`)
|
||||||
|
|
||||||
|
Любой из сценариев выше + установить `DCHAIN_GENESIS=true` при самом
|
||||||
|
первом запуске. Нода создаст блок 0 со своим же pubkey как единственным
|
||||||
|
валидатором. После первого успешного старта удалите эту строку из
|
||||||
|
`node.env` (no-op, но шумит в логах).
|
||||||
|
|
||||||
|
```ini
|
||||||
|
DCHAIN_GENESIS=true
|
||||||
|
DCHAIN_ANNOUNCE=/ip4/203.0.113.10/tcp/4001
|
||||||
|
DOMAIN=dchain.example.com
|
||||||
|
```
|
||||||
|
|
||||||
|
Проверка:
|
||||||
|
```bash
|
||||||
|
curl -s https://$DOMAIN/api/netstats | jq .validator_count # → 1
|
||||||
|
curl -s https://$DOMAIN/api/network-info | jq .genesis_hash # сохраните
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
### 2.6. Присоединение к существующей сети (`--join`)
|
||||||
|
|
||||||
|
Любой из сценариев + `DCHAIN_JOIN` со списком HTTP URL-ов seed-нод.
|
||||||
|
Нода подтянет `chain_id`, genesis hash, список валидаторов и пиров
|
||||||
|
автоматически через `/api/network-info`. Запускается как **observer**
|
||||||
|
по умолчанию — применяет блоки и принимает tx, но не голосует.
|
||||||
|
|
||||||
|
```ini
|
||||||
|
DCHAIN_JOIN=https://seed1.dchain.example.com,https://seed2.dchain.example.com
|
||||||
|
DCHAIN_ANNOUNCE=/ip4/203.0.113.50/tcp/4001
|
||||||
|
DOMAIN=node2.example.com
|
||||||
|
```
|
||||||
|
|
||||||
|
Чтобы стать валидатором — существующий валидатор должен подать
|
||||||
|
`ADD_VALIDATOR` с мульти-подписями. См. `../prod/README.md` →
|
||||||
|
"Add a 4th validator".
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. HTTP-поверхность
|
||||||
|
|
||||||
|
Что отдаёт нода по умолчанию (все `/api/*` всегда включены, даже с `DCHAIN_DISABLE_UI=true`):
|
||||||
|
|
||||||
|
### Публичные health / discovery
|
||||||
|
| Endpoint | Назначение |
|
||||||
|
|----------|-----------|
|
||||||
|
| `/api/netstats` | tip height, total tx count, supply, validator count |
|
||||||
|
| `/api/network-info` | one-shot bootstrap payload для нового клиента/ноды |
|
||||||
|
| `/api/well-known-version` | node_version, protocol_version, features[], build{tag, commit, date, dirty} |
|
||||||
|
| `/api/well-known-contracts` | канонические contract_id → name map |
|
||||||
|
| `/api/update-check` | сравнивает свой commit с Gitea release (нужен `DCHAIN_UPDATE_SOURCE_URL`) |
|
||||||
|
| `/api/validators` | активный validator set |
|
||||||
|
| `/api/peers` | живые libp2p пиры + их версия (из gossip-топика `dchain/version/v1`) |
|
||||||
|
|
||||||
|
### Chain explorer JSON
|
||||||
|
| Endpoint | Назначение |
|
||||||
|
|----------|-----------|
|
||||||
|
| `/api/blocks?limit=N` | последние N блоков |
|
||||||
|
| `/api/block/{index}` | один блок |
|
||||||
|
| `/api/txs/recent?limit=N` | последние N tx |
|
||||||
|
| `/api/tx/{id}` | одна транзакция |
|
||||||
|
| `/api/address/{pubkey_or_DC-addr}` | баланс + история |
|
||||||
|
| `/api/identity/{pubkey_or_DC-addr}` | ed25519 ↔ x25519 binding |
|
||||||
|
| `/api/relays` | зарегистрированные relay-ноды |
|
||||||
|
| `/api/contracts` / `/api/contracts/{id}` / `/api/contracts/{id}/state/{key}` | контракты |
|
||||||
|
| `/api/tokens` / `/api/tokens/{id}` / `/api/nfts` | токены и NFT |
|
||||||
|
| `/api/channels/{id}` / `/api/channels/{id}/members` | каналы и члены (для fan-out) |
|
||||||
|
|
||||||
|
### Submit / Real-time
|
||||||
|
| Endpoint | Назначение |
|
||||||
|
|----------|-----------|
|
||||||
|
| `POST /api/tx` | submit подписанной tx (rate-limit + body-cap; token-gate если задан) |
|
||||||
|
| `GET /api/ws` | WebSocket (auth, topic subscribe, submit_tx, typing) |
|
||||||
|
| `GET /api/events` | SSE (односторонний legacy stream) |
|
||||||
|
|
||||||
|
### HTML (выключается `DCHAIN_DISABLE_UI=true`)
|
||||||
|
| Endpoint | Назначение |
|
||||||
|
|----------|-----------|
|
||||||
|
| `/` | главная эксплорера |
|
||||||
|
| `/address`, `/tx`, `/node`, `/relays`, `/validators`, `/contract`, `/tokens`, `/token` | страницы |
|
||||||
|
| `/assets/explorer/*.js|css` | статические ассеты |
|
||||||
|
|
||||||
|
### Swagger (выключается `DCHAIN_DISABLE_SWAGGER=true`)
|
||||||
|
| Endpoint | Назначение |
|
||||||
|
|----------|-----------|
|
||||||
|
| `/swagger` | Swagger UI (грузит swagger-ui-dist с unpkg) |
|
||||||
|
| `/swagger/openapi.json` | сырая OpenAPI 3.0 спека |
|
||||||
|
|
||||||
|
### Prometheus
|
||||||
|
| Endpoint | Назначение |
|
||||||
|
|----------|-----------|
|
||||||
|
| `/metrics` | exposition, всегда включён |
|
||||||
|
|
||||||
|
> **Защита `/metrics`:** у эндпоинта нет встроенной авторизации. В
|
||||||
|
> публичной деплое закройте его на уровне Caddy — пример в
|
||||||
|
> `Caddyfile`: рестрикт по IP/токену scrape-сервера.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Auto-update от Gitea
|
||||||
|
|
||||||
|
После поднятия проекта на Gitea:
|
||||||
|
|
||||||
|
```ini
|
||||||
|
# node.env
|
||||||
|
DCHAIN_UPDATE_SOURCE_URL=https://gitea.example.com/api/v1/repos/dchain/dchain/releases/latest
|
||||||
|
DCHAIN_UPDATE_SOURCE_TOKEN= # опционально, для приватных repo
|
||||||
|
UPDATE_ALLOW_MAJOR=false # блокирует v1.x → v2.y без явного согласия
|
||||||
|
```
|
||||||
|
|
||||||
|
Проверка:
|
||||||
|
```bash
|
||||||
|
curl -s https://$DOMAIN/api/update-check | jq .
|
||||||
|
# {
|
||||||
|
# "current": { "tag": "v0.5.0", "commit": "abc1234", ... },
|
||||||
|
# "latest": { "tag": "v0.5.1", "url": "https://gitea...", ... },
|
||||||
|
# "update_available": true,
|
||||||
|
# "checked_at": "2026-04-17T10:41:03Z"
|
||||||
|
# }
|
||||||
|
```
|
||||||
|
|
||||||
|
systemd-таймер для бесшумного hourly-обновления:
|
||||||
|
```bash
|
||||||
|
sudo cp systemd/dchain-update.{service,timer} /etc/systemd/system/
|
||||||
|
sudo systemctl daemon-reload
|
||||||
|
sudo systemctl enable --now dchain-update.timer
|
||||||
|
```
|
||||||
|
|
||||||
|
Скрипт `update.sh`:
|
||||||
|
1. спрашивает `/api/update-check` — если `update_available: false`, выходит;
|
||||||
|
2. делает `git fetch --tags`, checkout на новый тег;
|
||||||
|
3. **semver-guard**: блокирует major-скачок (vN.x → vN+1.y) если
|
||||||
|
`UPDATE_ALLOW_MAJOR != true`;
|
||||||
|
4. ребилдит образ с injected версией (`VERSION_TAG/COMMIT/DATE/DIRTY`);
|
||||||
|
5. smoke-test `node --version`;
|
||||||
|
6. `docker compose up -d --force-recreate node`;
|
||||||
|
7. polling `/api/netstats` — до 60 сек, fail loud если не ожил.
|
||||||
|
|
||||||
|
Подробнее — `../UPDATE_STRATEGY.md`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Обновление / бэкап / восстановление
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Ручное обновление (downtime ~5-8 сек):
|
||||||
|
docker compose pull
|
||||||
|
docker compose build
|
||||||
|
docker compose up -d --force-recreate node
|
||||||
|
|
||||||
|
# Проверить что новая версия поднялась:
|
||||||
|
docker exec dchain_node /usr/local/bin/node --version
|
||||||
|
curl -s https://$DOMAIN/api/well-known-version | jq .build
|
||||||
|
|
||||||
|
# Backup chain state:
|
||||||
|
docker run --rm -v dchain-single_node_data:/data -v "$PWD":/bak alpine \
|
||||||
|
tar czf /bak/dchain-$(date +%F).tar.gz -C /data .
|
||||||
|
|
||||||
|
# Восстановление:
|
||||||
|
docker compose stop node
|
||||||
|
docker run --rm -v dchain-single_node_data:/data -v "$PWD":/bak alpine \
|
||||||
|
sh -c "rm -rf /data/* && tar xzf /bak/dchain-2026-04-10.tar.gz -C /data"
|
||||||
|
docker compose up -d node
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Troubleshooting
|
||||||
|
|
||||||
|
| Симптом | Проверка |
|
||||||
|
|---------|----------|
|
||||||
|
| `failed to get certificate` в Caddy | DNS A-record на DOMAIN → этот хост? Порт 80 открыт? |
|
||||||
|
| `/api/tx` возвращает 401 | Токен в заголовке совпадает с `DCHAIN_API_TOKEN`? |
|
||||||
|
| Ноды не видят друг друга | Порт 4001 открыт? `DCHAIN_ANNOUNCE` = публичный IP? |
|
||||||
|
| Блоки не растут (validator mode) | `docker compose logs node | grep PBFT` — собирается ли quorum? |
|
||||||
|
| `/` возвращает 404 | `DCHAIN_DISABLE_UI=true` установлен — либо уберите, либо используйте `/api/*` |
|
||||||
|
| `/swagger` возвращает 404 | `DCHAIN_DISABLE_SWAGGER=true` — уберите, либо хостьте `openapi.json` отдельно |
|
||||||
|
| `update-check` возвращает 503 | `DCHAIN_UPDATE_SOURCE_URL` не задан или пустой |
|
||||||
|
| `update-check` возвращает 502 | Gitea недоступна или URL неверный — проверьте `curl $DCHAIN_UPDATE_SOURCE_URL` руками |
|
||||||
|
| `FATAL: genesis hash mismatch` | В volume чейн с другим genesis. `docker volume rm dchain-single_node_data` → `up -d` (потеря локальных данных) |
|
||||||
|
| Диск растёт | BadgerDB GC работает раз в 5 мин; для блокчейна с десятками тысяч блоков обычно < 500 MB |
|
||||||
|
| `--version` выдаёт `dev` | Образ собран без `--build-arg VERSION_*` — ребилдните через `update.sh` или `docker build --build-arg VERSION_TAG=...` вручную |
|
||||||
129
deploy/single/docker-compose.yml
Normal file
129
deploy/single/docker-compose.yml
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
name: dchain-single
|
||||||
|
|
||||||
|
# ══════════════════════════════════════════════════════════════════════════
|
||||||
|
# Single-node DChain deployment.
|
||||||
|
#
|
||||||
|
# One validator (or observer) + Caddy TLS edge + optional
|
||||||
|
# Prometheus/Grafana. Intended for:
|
||||||
|
# - Personal nodes: operator runs their own, optionally private.
|
||||||
|
# - Tail of a larger network: joins via --join, participates / observes.
|
||||||
|
# - First node of a brand-new network: starts with --genesis.
|
||||||
|
#
|
||||||
|
# Quick start:
|
||||||
|
# cp node.env.example node.env # edit DOMAIN / API_TOKEN / JOIN
|
||||||
|
# docker compose up -d # node + Caddy
|
||||||
|
# docker compose --profile monitor up -d
|
||||||
|
#
|
||||||
|
# For a multi-validator cluster see deploy/prod/ (3-of-3 PBFT setup).
|
||||||
|
# ══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
networks:
|
||||||
|
dchain:
|
||||||
|
name: dchain_single
|
||||||
|
driver: bridge
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
node_data:
|
||||||
|
caddy_data:
|
||||||
|
caddy_config:
|
||||||
|
prom_data:
|
||||||
|
grafana_data:
|
||||||
|
|
||||||
|
services:
|
||||||
|
# ── The node ──────────────────────────────────────────────────────────
|
||||||
|
# One process does everything: consensus (if validator), relay, HTTP,
|
||||||
|
# WebSocket, metrics. Three knobs are worth knowing before first boot:
|
||||||
|
#
|
||||||
|
# 1. DCHAIN_GENESIS=true → creates block 0 with THIS node's key as sole
|
||||||
|
# validator. Use only once, on the very first node of a fresh chain.
|
||||||
|
# Drop the flag on subsequent restarts (no-op but noisy).
|
||||||
|
# 2. DCHAIN_JOIN=http://...,http://... → fetch /api/network-info from
|
||||||
|
# the listed seeds, auto-populate --peers / --validators, sync chain.
|
||||||
|
# Use this when joining an existing network instead of --genesis.
|
||||||
|
# 3. DCHAIN_API_TOKEN=... → if set, gates POST /api/tx (and WS submit).
|
||||||
|
# With DCHAIN_API_PRIVATE=true, gates reads too. Empty = public.
|
||||||
|
node:
|
||||||
|
build:
|
||||||
|
context: ../..
|
||||||
|
dockerfile: deploy/prod/Dockerfile.slim
|
||||||
|
container_name: dchain_node
|
||||||
|
restart: unless-stopped
|
||||||
|
env_file: ./node.env
|
||||||
|
networks: [dchain]
|
||||||
|
volumes:
|
||||||
|
- node_data:/data
|
||||||
|
- ./keys/node.json:/keys/node.json:ro
|
||||||
|
# 4001 → libp2p P2P (MUST be publicly routable for federation)
|
||||||
|
# 8080 → HTTP + WebSocket, only exposed internally to Caddy by default
|
||||||
|
ports:
|
||||||
|
- "4001:4001"
|
||||||
|
expose:
|
||||||
|
- "8080"
|
||||||
|
cap_drop: [ALL]
|
||||||
|
read_only: true
|
||||||
|
tmpfs: [/tmp]
|
||||||
|
security_opt: [no-new-privileges:true]
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8080/api/netstats >/dev/null || exit 1"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 6
|
||||||
|
start_period: 15s
|
||||||
|
command:
|
||||||
|
- "--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"
|
||||||
|
# All other config comes via DCHAIN_* env vars from node.env.
|
||||||
|
|
||||||
|
# ── TLS edge ──────────────────────────────────────────────────────────
|
||||||
|
caddy:
|
||||||
|
image: caddy:2.8-alpine
|
||||||
|
container_name: dchain_caddy
|
||||||
|
restart: unless-stopped
|
||||||
|
networks: [dchain]
|
||||||
|
ports:
|
||||||
|
- "80:80"
|
||||||
|
- "443:443"
|
||||||
|
- "443:443/udp"
|
||||||
|
volumes:
|
||||||
|
- ./Caddyfile:/etc/caddy/Caddyfile:ro
|
||||||
|
- caddy_data:/data
|
||||||
|
- caddy_config:/config
|
||||||
|
environment:
|
||||||
|
DOMAIN: ${DOMAIN:-localhost}
|
||||||
|
ACME_EMAIL: ${ACME_EMAIL:-admin@example.com}
|
||||||
|
depends_on:
|
||||||
|
node: { condition: service_healthy }
|
||||||
|
|
||||||
|
# ── Observability (opt-in) ────────────────────────────────────────────
|
||||||
|
prometheus:
|
||||||
|
profiles: [monitor]
|
||||||
|
image: prom/prometheus:v2.53.0
|
||||||
|
container_name: dchain_prometheus
|
||||||
|
restart: unless-stopped
|
||||||
|
networks: [dchain]
|
||||||
|
volumes:
|
||||||
|
- ./prometheus.yml:/etc/prometheus/prometheus.yml:ro
|
||||||
|
- prom_data:/prometheus
|
||||||
|
command:
|
||||||
|
- "--config.file=/etc/prometheus/prometheus.yml"
|
||||||
|
- "--storage.tsdb.retention.time=30d"
|
||||||
|
|
||||||
|
grafana:
|
||||||
|
profiles: [monitor]
|
||||||
|
image: grafana/grafana:11.1.0
|
||||||
|
container_name: dchain_grafana
|
||||||
|
restart: unless-stopped
|
||||||
|
networks: [dchain]
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
|
depends_on: [prometheus]
|
||||||
|
environment:
|
||||||
|
GF_SECURITY_ADMIN_USER: admin
|
||||||
|
GF_SECURITY_ADMIN_PASSWORD: ${GRAFANA_ADMIN_PW:-change-me}
|
||||||
|
GF_USERS_ALLOW_SIGN_UP: "false"
|
||||||
|
volumes:
|
||||||
|
- grafana_data:/var/lib/grafana
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user