chore: initial commit for v0.0.1

DChain single-node blockchain + React Native messenger client.

Core:
- PBFT consensus with multi-sig validator admission + equivocation slashing
- BadgerDB + schema migration scaffold (CurrentSchemaVersion=0)
- libp2p gossipsub (tx/v1, blocks/v1, relay/v1, version/v1)
- Native Go contracts (username_registry) alongside WASM (wazero)
- WebSocket gateway with topic-based fanout + Ed25519-nonce auth
- Relay mailbox with NaCl envelope encryption (X25519 + Ed25519)
- Prometheus /metrics, per-IP rate limit, body-size cap

Deployment:
- Single-node compose (deploy/single/) with Caddy TLS + optional Prometheus
- 3-node dev compose (docker-compose.yml) with mocked internet topology
- 3-validator prod compose (deploy/prod/) for federation
- Auto-update from Gitea via /api/update-check + systemd timer
- Build-time version injection (ldflags → node --version)
- UI / Swagger toggle flags (DCHAIN_DISABLE_UI, DCHAIN_DISABLE_SWAGGER)

Client (client-app/):
- Expo / React Native / NativeWind
- E2E NaCl encryption, typing indicator, contact requests
- Auto-discovery of canonical contracts, chain_id aware, WS reconnect on node switch

Documentation:
- README.md, CHANGELOG.md, CONTEXT.md
- deploy/single/README.md with 6 operator scenarios
- deploy/UPDATE_STRATEGY.md with 4-layer forward-compat design
- docs/contracts/*.md per contract
This commit is contained in:
vsecoder
2026-04-17 14:16:44 +03:00
commit 7e7393e4f8
196 changed files with 55947 additions and 0 deletions

34
.dockerignore Normal file
View 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
View 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
View 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
View 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)
- Финальность: 13 секунды
- Три фазы: 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
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

796
blockchain/chain_test.go Normal file
View 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
View 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
View 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
View 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
}

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

View 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
View 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
View File

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

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,332 @@
/**
* Add New Contact — DChain explorer design style.
* Sends CONTACT_REQUEST on-chain with correct amount/fee fields.
*/
import React, { useState } from 'react';
import {
View, Text, ScrollView, Alert, TouchableOpacity, TextInput,
} from 'react-native';
import { router } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { useStore } from '@/lib/store';
import { getIdentity, buildContactRequestTx, submitTx } from '@/lib/api';
import { shortAddr } from '@/lib/crypto';
import { formatAmount } from '@/lib/utils';
import { Avatar } from '@/components/ui/Avatar';
const C = {
bg: '#0b1220',
surface: '#111a2b',
surface2:'#162035',
line: '#1c2840',
text: '#e6edf9',
muted: '#98a7c2',
accent: '#7db5ff',
ok: '#41c98a',
warn: '#f0b35a',
err: '#ff7a87',
} as const;
const MIN_CONTACT_FEE = 5000;
const FEE_OPTIONS = [
{ label: '5 000 µT', value: 5_000, note: 'минимум' },
{ label: '10 000 µT', value: 10_000, note: 'стандарт' },
{ label: '50 000 µT', value: 50_000, note: 'приоритет' },
];
interface Resolved {
address: string;
nickname?: string;
x25519?: string;
}
export default function NewContactScreen() {
const keyFile = useStore(s => s.keyFile);
const settings = useStore(s => s.settings);
const balance = useStore(s => s.balance);
const insets = useSafeAreaInsets();
const [query, setQuery] = useState('');
const [intro, setIntro] = useState('');
const [fee, setFee] = useState(MIN_CONTACT_FEE);
const [resolved, setResolved] = useState<Resolved | null>(null);
const [searching, setSearching] = useState(false);
const [sending, setSending] = useState(false);
const [error, setError] = useState<string | null>(null);
async function search() {
const q = query.trim();
if (!q) return;
setSearching(true);
setResolved(null);
setError(null);
try {
let address = q;
// @username lookup via registry contract
if (q.startsWith('@') || (!q.match(/^[0-9a-f]{64}$/i) && !q.startsWith('DC'))) {
const name = q.replace('@', '');
const { resolveUsername } = await import('@/lib/api');
const addr = await resolveUsername(settings.contractId, name);
if (!addr) {
setError(`@${name} не зарегистрирован в сети.`);
return;
}
address = addr;
}
// Fetch identity to get nickname and x25519 key
const identity = await getIdentity(address);
setResolved({
address: identity?.pub_key ?? address,
nickname: identity?.nickname || undefined,
x25519: identity?.x25519_pub || undefined,
});
} catch (e: any) {
setError(e.message);
} finally {
setSearching(false);
}
}
async function sendRequest() {
if (!resolved || !keyFile) return;
if (balance < fee + 1000) {
Alert.alert('Недостаточно средств', `Нужно ${formatAmount(fee + 1000)} (fee + комиссия сети).`);
return;
}
setSending(true);
setError(null);
try {
const tx = buildContactRequestTx({
from: keyFile.pub_key,
to: resolved.address,
contactFee: fee,
intro: intro.trim() || undefined,
privKey: keyFile.priv_key,
});
await submitTx(tx);
Alert.alert(
'Запрос отправлен',
`Контакту ${resolved.nickname ? '@' + resolved.nickname : shortAddr(resolved.address)} отправлен запрос.`,
[{ text: 'OK', onPress: () => router.back() }],
);
} catch (e: any) {
setError(e.message);
} finally {
setSending(false);
}
}
const displayName = resolved
? (resolved.nickname ? `@${resolved.nickname}` : shortAddr(resolved.address))
: null;
return (
<ScrollView
style={{ flex: 1, backgroundColor: C.bg }}
contentContainerStyle={{
paddingHorizontal: 16,
paddingTop: insets.top + 8,
paddingBottom: Math.max(insets.bottom, 32),
}}
keyboardShouldPersistTaps="handled"
>
{/* Header */}
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 6, marginBottom: 24, marginLeft: -8 }}>
<TouchableOpacity
onPress={() => router.back()}
activeOpacity={0.6}
style={{ padding: 8 }}
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
>
<Ionicons name="chevron-back" size={24} color={C.accent} />
</TouchableOpacity>
<Text style={{ color: C.text, fontSize: 20, fontWeight: '700' }}>Добавить контакт</Text>
</View>
{/* Search */}
<Text style={{ color: C.muted, fontSize: 11, letterSpacing: 1, textTransform: 'uppercase', marginBottom: 8 }}>
Адрес или @username
</Text>
<View style={{ flexDirection: 'row', gap: 8, marginBottom: 16 }}>
<View style={{
flex: 1, backgroundColor: C.surface, borderRadius: 10,
paddingHorizontal: 12, paddingVertical: 2,
}}>
<TextInput
value={query}
onChangeText={t => { setQuery(t); setError(null); }}
onSubmitEditing={search}
placeholder="@username или 64-символьный hex"
placeholderTextColor={C.muted}
autoCapitalize="none"
autoCorrect={false}
style={{ color: C.text, fontSize: 14, height: 44 }}
/>
</View>
<TouchableOpacity
onPress={search}
disabled={searching || !query.trim()}
style={{
paddingHorizontal: 16, borderRadius: 10,
backgroundColor: searching || !query.trim() ? C.surface2 : C.accent,
alignItems: 'center', justifyContent: 'center',
}}
>
<Text style={{ color: searching || !query.trim() ? C.muted : '#0b1220', fontWeight: '700', fontSize: 14 }}>
{searching ? '…' : 'Найти'}
</Text>
</TouchableOpacity>
</View>
{/* Error */}
{error && (
<View style={{
backgroundColor: 'rgba(255,122,135,0.08)', borderRadius: 10,
paddingHorizontal: 12, paddingVertical: 10, marginBottom: 16,
}}>
<Text style={{ color: C.err, fontSize: 13 }}> {error}</Text>
</View>
)}
{/* Resolved contact card */}
{resolved && (
<View style={{
backgroundColor: C.surface, borderRadius: 12,
padding: 14, marginBottom: 20,
flexDirection: 'row', alignItems: 'center', gap: 12,
}}>
<Avatar name={displayName ?? resolved.address} size="md" />
<View style={{ flex: 1, minWidth: 0 }}>
{resolved.nickname && (
<Text style={{ color: C.text, fontWeight: '600', fontSize: 15 }}>
@{resolved.nickname}
</Text>
)}
<Text style={{ color: C.muted, fontFamily: 'monospace', fontSize: 11 }} numberOfLines={1}>
{resolved.address}
</Text>
{resolved.x25519 && (
<Text style={{ color: C.ok, fontSize: 11, marginTop: 2 }}>
Зашифрованные сообщения поддерживаются
</Text>
)}
</View>
<View style={{
paddingHorizontal: 8, paddingVertical: 3,
backgroundColor: 'rgba(65,201,138,0.12)', borderRadius: 999,
}}>
<Text style={{ color: C.ok, fontSize: 11, fontWeight: '600' }}>Найден</Text>
</View>
</View>
)}
{/* Intro + fee (shown after search succeeds) */}
{resolved && (
<>
{/* Intro */}
<Text style={{ color: C.muted, fontSize: 11, letterSpacing: 1, textTransform: 'uppercase', marginBottom: 8 }}>
Сообщение (необязательно)
</Text>
<View style={{
backgroundColor: C.surface, borderRadius: 10,
paddingHorizontal: 12, paddingVertical: 8, marginBottom: 20,
}}>
<TextInput
value={intro}
onChangeText={setIntro}
placeholder="Привет, хочу добавить тебя в контакты…"
placeholderTextColor={C.muted}
multiline
numberOfLines={3}
maxLength={280}
style={{
color: C.text, fontSize: 14, lineHeight: 20,
minHeight: 72, textAlignVertical: 'top',
}}
/>
<Text style={{ color: C.muted, fontSize: 11, textAlign: 'right', marginTop: 4 }}>
{intro.length}/280
</Text>
</View>
{/* Fee selector */}
<Text style={{ color: C.muted, fontSize: 11, letterSpacing: 1, textTransform: 'uppercase', marginBottom: 8 }}>
Антиспам-сбор (отправляется контакту)
</Text>
<View style={{ flexDirection: 'row', gap: 8, marginBottom: 8 }}>
{FEE_OPTIONS.map(opt => (
<TouchableOpacity
key={opt.value}
onPress={() => setFee(opt.value)}
style={{
flex: 1, borderRadius: 10, paddingVertical: 10, alignItems: 'center',
backgroundColor: fee === opt.value
? 'rgba(125,181,255,0.15)' : C.surface,
borderWidth: fee === opt.value ? 1 : 0,
borderColor: fee === opt.value ? C.accent : 'transparent',
}}
>
<Text style={{
color: fee === opt.value ? C.accent : C.text,
fontWeight: '600', fontSize: 13,
}}>
{opt.label}
</Text>
<Text style={{ color: C.muted, fontSize: 11, marginTop: 2 }}>{opt.note}</Text>
</TouchableOpacity>
))}
</View>
{/* Balance info */}
<View style={{
flexDirection: 'row', alignItems: 'center', gap: 8,
backgroundColor: C.surface, borderRadius: 8,
paddingHorizontal: 12, paddingVertical: 9, marginBottom: 20,
}}>
<Text style={{ color: C.muted, fontSize: 13 }}>Ваш баланс:</Text>
<Text style={{ color: balance >= fee + 1000 ? C.text : C.err, fontSize: 13, fontWeight: '600' }}>
{formatAmount(balance)}
</Text>
<Text style={{ color: C.muted, fontSize: 13, marginLeft: 'auto' as any }}>
Итого: {formatAmount(fee + 1000)}
</Text>
</View>
{/* Send button */}
<TouchableOpacity
onPress={sendRequest}
disabled={sending || balance < fee + 1000}
style={{
paddingVertical: 15, borderRadius: 10, alignItems: 'center',
backgroundColor: (sending || balance < fee + 1000) ? C.surface2 : C.accent,
}}
>
<Text style={{
color: (sending || balance < fee + 1000) ? C.muted : '#0b1220',
fontWeight: '700', fontSize: 15,
}}>
{sending ? 'Отправка…' : 'Отправить запрос'}
</Text>
</TouchableOpacity>
</>
)}
{/* Hint when no search yet */}
{!resolved && !error && (
<View style={{
backgroundColor: C.surface, borderRadius: 12,
padding: 16, marginTop: 8, alignItems: 'center',
}}>
<Text style={{ color: C.muted, fontSize: 14, textAlign: 'center', lineHeight: 21 }}>
Введите @username или вставьте 64-символьный hex-адрес пользователя DChain.
</Text>
</View>
)}
</ScrollView>
);
}

View File

@@ -0,0 +1,236 @@
/**
* Contact requests screen — DChain explorer design style.
*/
import React, { useState } from 'react';
import { View, Text, FlatList, Alert, TouchableOpacity } from 'react-native';
import { router } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { useStore } from '@/lib/store';
import { buildAcceptContactTx, submitTx, getIdentity } from '@/lib/api';
import { saveContact } from '@/lib/storage';
import { shortAddr } from '@/lib/crypto';
import { relativeTime } from '@/lib/utils';
import { Avatar } from '@/components/ui/Avatar';
import type { ContactRequest } from '@/lib/types';
const C = {
bg: '#0b1220',
surface: '#111a2b',
surface2:'#162035',
line: '#1c2840',
text: '#e6edf9',
muted: '#98a7c2',
accent: '#7db5ff',
ok: '#41c98a',
warn: '#f0b35a',
err: '#ff7a87',
} as const;
export default function RequestsScreen() {
const keyFile = useStore(s => s.keyFile);
const requests = useStore(s => s.requests);
const setRequests = useStore(s => s.setRequests);
const upsertContact = useStore(s => s.upsertContact);
const insets = useSafeAreaInsets();
const [accepting, setAccepting] = useState<string | null>(null);
async function accept(req: ContactRequest) {
if (!keyFile) return;
setAccepting(req.txHash);
try {
// Fetch requester's identity to get their x25519 key for messaging
const identity = await getIdentity(req.from);
const x25519Pub = identity?.x25519_pub ?? '';
const tx = buildAcceptContactTx({
from: keyFile.pub_key,
to: req.from,
privKey: keyFile.priv_key,
});
await submitTx(tx);
// Save contact with x25519 key (empty if they haven't registered one)
const contact = {
address: req.from,
x25519Pub,
username: req.username,
addedAt: Date.now(),
};
upsertContact(contact);
await saveContact(contact);
setRequests(requests.filter(r => r.txHash !== req.txHash));
Alert.alert('Принято', `${req.username ? '@' + req.username : shortAddr(req.from)} добавлен в контакты.`);
} catch (e: any) {
Alert.alert('Ошибка', e.message);
} finally {
setAccepting(null);
}
}
function decline(req: ContactRequest) {
Alert.alert(
'Отклонить запрос',
`Отклонить запрос от ${req.username ? '@' + req.username : shortAddr(req.from)}?`,
[
{ text: 'Отмена', style: 'cancel' },
{
text: 'Отклонить',
style: 'destructive',
onPress: () => setRequests(requests.filter(r => r.txHash !== req.txHash)),
},
],
);
}
return (
<View style={{ flex: 1, backgroundColor: C.bg }}>
{/* Header */}
<View style={{
flexDirection: 'row', alignItems: 'center', gap: 10,
paddingHorizontal: 12,
paddingTop: insets.top + 8, paddingBottom: 12,
borderBottomWidth: 1, borderBottomColor: C.line,
}}>
<TouchableOpacity
onPress={() => router.back()}
activeOpacity={0.6}
style={{ padding: 8 }}
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
>
<Ionicons name="chevron-back" size={24} color={C.accent} />
</TouchableOpacity>
<Text style={{ color: C.text, fontSize: 18, fontWeight: '700', flex: 1 }}>
Запросы контактов
</Text>
{requests.length > 0 && (
<View style={{
backgroundColor: C.accent, borderRadius: 999,
paddingHorizontal: 10, paddingVertical: 3,
minWidth: 24, alignItems: 'center',
}}>
<Text style={{ color: C.bg, fontSize: 12, fontWeight: '700' }}>
{requests.length}
</Text>
</View>
)}
</View>
{requests.length === 0 ? (
<View style={{ flex: 1, alignItems: 'center', justifyContent: 'center', paddingHorizontal: 32 }}>
<View style={{
width: 80, height: 80, borderRadius: 40,
backgroundColor: C.surface,
alignItems: 'center', justifyContent: 'center',
marginBottom: 16,
}}>
<Ionicons name="mail-outline" size={36} color={C.muted} />
</View>
<Text style={{ color: C.text, fontSize: 17, fontWeight: '600', marginBottom: 6 }}>
Нет входящих запросов
</Text>
<Text style={{ color: C.muted, fontSize: 13, textAlign: 'center', lineHeight: 20 }}>
Когда кто-то пришлёт вам запрос в контакты, он появится здесь.
</Text>
</View>
) : (
<FlatList
data={requests}
keyExtractor={r => r.txHash}
contentContainerStyle={{ padding: 14, paddingBottom: 24, gap: 12 }}
renderItem={({ item: req }) => (
<RequestCard
req={req}
isAccepting={accepting === req.txHash}
onAccept={() => accept(req)}
onDecline={() => decline(req)}
/>
)}
/>
)}
</View>
);
}
function RequestCard({
req, isAccepting, onAccept, onDecline,
}: {
req: ContactRequest;
isAccepting: boolean;
onAccept: () => void;
onDecline: () => void;
}) {
const displayName = req.username ? `@${req.username}` : shortAddr(req.from);
return (
<View style={{ backgroundColor: C.surface, borderRadius: 12, padding: 14 }}>
{/* Sender info */}
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 12, marginBottom: 12 }}>
<Avatar name={displayName} size="md" />
<View style={{ flex: 1, minWidth: 0 }}>
<Text style={{ color: C.text, fontWeight: '600', fontSize: 15 }} numberOfLines={1}>
{displayName}
</Text>
<Text style={{ color: C.muted, fontFamily: 'monospace', fontSize: 11 }} numberOfLines={1}>
{req.from}
</Text>
</View>
<Text style={{ color: C.muted, fontSize: 12 }}>{relativeTime(req.timestamp)}</Text>
</View>
{/* Intro message */}
{!!req.intro && (
<View style={{
backgroundColor: C.surface2, borderRadius: 8,
paddingHorizontal: 12, paddingVertical: 10, marginBottom: 12,
}}>
<Text style={{ color: C.muted, fontSize: 11, marginBottom: 4 }}>Приветствие</Text>
<Text style={{ color: C.text, fontSize: 13, lineHeight: 19 }}>{req.intro}</Text>
</View>
)}
{/* Divider */}
<View style={{ height: 1, backgroundColor: C.line, marginBottom: 12 }} />
{/* Actions */}
<View style={{ flexDirection: 'row', gap: 8 }}>
<TouchableOpacity
onPress={onAccept}
disabled={isAccepting}
activeOpacity={0.7}
style={{
flex: 1, paddingVertical: 11, borderRadius: 9,
flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 6,
backgroundColor: isAccepting ? C.surface2 : C.ok,
}}
>
<Ionicons
name={isAccepting ? 'hourglass-outline' : 'checkmark-outline'}
size={16}
color={isAccepting ? C.muted : C.bg}
/>
<Text style={{ color: isAccepting ? C.muted : C.bg, fontWeight: '700', fontSize: 14 }}>
{isAccepting ? 'Принятие…' : 'Принять'}
</Text>
</TouchableOpacity>
<TouchableOpacity
onPress={onDecline}
disabled={isAccepting}
activeOpacity={0.7}
style={{
flex: 1, paddingVertical: 11, borderRadius: 9,
flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 6,
backgroundColor: 'rgba(255,122,135,0.1)',
borderWidth: 1, borderColor: 'rgba(255,122,135,0.18)',
}}
>
<Ionicons name="close-outline" size={16} color={C.err} />
<Text style={{ color: C.err, fontWeight: '700', fontSize: 14 }}>Отклонить</Text>
</TouchableOpacity>
</View>
</View>
);
}

View File

@@ -0,0 +1,732 @@
/**
* Settings screen — DChain explorer style, inline styles, Russian locale.
*/
import React, { useState, useEffect } from 'react';
import {
View, Text, ScrollView, TextInput, Alert, TouchableOpacity,
} from 'react-native';
import * as Clipboard from 'expo-clipboard';
import * as FileSystem from 'expo-file-system';
import * as Sharing from 'expo-sharing';
import { router } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { useStore } from '@/lib/store';
import { saveSettings, deleteKeyFile } from '@/lib/storage';
import {
setNodeUrl, getNetStats, resolveUsername, reverseResolve,
buildCallContractTx, submitTx,
USERNAME_REGISTRATION_FEE, MIN_USERNAME_LENGTH, MAX_USERNAME_LENGTH,
humanizeTxError,
} from '@/lib/api';
import { shortAddr } from '@/lib/crypto';
import { formatAmount } from '@/lib/utils';
import { Avatar } from '@/components/ui/Avatar';
// ─── Design tokens ────────────────────────────────────────────────────────────
const C = {
bg: '#0b1220',
surface: '#111a2b',
surface2:'#162035',
surface3:'#1a2640',
line: '#1c2840',
text: '#e6edf9',
muted: '#98a7c2',
accent: '#7db5ff',
ok: '#41c98a',
warn: '#f0b35a',
err: '#ff7a87',
} as const;
// ─── Reusable sub-components ─────────────────────────────────────────────────
function SectionLabel({ children }: { children: string }) {
return (
<Text style={{
color: C.muted, fontSize: 11, letterSpacing: 1,
textTransform: 'uppercase', marginBottom: 8,
}}>
{children}
</Text>
);
}
function Card({ children, style }: { children: React.ReactNode; style?: object }) {
return (
<View style={{
backgroundColor: C.surface, borderRadius: 12,
overflow: 'hidden', marginBottom: 20, ...style,
}}>
{children}
</View>
);
}
function CardRow({
icon, label, value, first,
}: {
icon: keyof typeof Ionicons.glyphMap;
label: string;
value?: string;
first?: boolean;
}) {
return (
<View style={{
flexDirection: 'row', alignItems: 'center', gap: 12,
paddingHorizontal: 14, paddingVertical: 12,
borderTopWidth: first ? 0 : 1, borderTopColor: C.line,
}}>
<View style={{
width: 34, height: 34, borderRadius: 17,
backgroundColor: C.surface2,
alignItems: 'center', justifyContent: 'center', flexShrink: 0,
}}>
<Ionicons name={icon} size={16} color={C.muted} />
</View>
<View style={{ flex: 1, minWidth: 0 }}>
<Text style={{ color: C.muted, fontSize: 12 }}>{label}</Text>
{value !== undefined && (
<Text style={{ color: C.text, fontSize: 13, fontWeight: '500', marginTop: 1 }} numberOfLines={1}>
{value}
</Text>
)}
</View>
</View>
);
}
function FieldInput({
label, value, onChangeText, placeholder, keyboardType, autoCapitalize, autoCorrect,
}: {
label: string;
value: string;
onChangeText: (v: string) => void;
placeholder?: string;
keyboardType?: any;
autoCapitalize?: any;
autoCorrect?: boolean;
}) {
return (
<View style={{ paddingHorizontal: 14, paddingVertical: 10 }}>
<Text style={{ color: C.muted, fontSize: 11, marginBottom: 6 }}>{label}</Text>
<View style={{
backgroundColor: C.surface2, borderRadius: 8,
paddingHorizontal: 12, paddingVertical: 8,
borderWidth: 1, borderColor: C.line,
}}>
<TextInput
value={value}
onChangeText={onChangeText}
placeholder={placeholder}
placeholderTextColor={C.muted}
keyboardType={keyboardType}
autoCapitalize={autoCapitalize ?? 'none'}
autoCorrect={autoCorrect ?? false}
style={{ color: C.text, fontSize: 14, height: 36 }}
/>
</View>
</View>
);
}
// ─── Screen ───────────────────────────────────────────────────────────────────
export default function SettingsScreen() {
const keyFile = useStore(s => s.keyFile);
const settings = useStore(s => s.settings);
const setSettings = useStore(s => s.setSettings);
const username = useStore(s => s.username);
const setUsername = useStore(s => s.setUsername);
const setKeyFile = useStore(s => s.setKeyFile);
const balance = useStore(s => s.balance);
const [nodeUrl, setNodeUrlLocal] = useState(settings.nodeUrl);
const [contractId, setContractId] = useState(settings.contractId);
const [nodeStatus, setNodeStatus] = useState<'checking' | 'ok' | 'error'>('checking');
const [peerCount, setPeerCount] = useState<number | null>(null);
const [blockCount, setBlockCount] = useState<number | null>(null);
const [copied, setCopied] = useState(false);
const [showAdvanced, setShowAdvanced] = useState(false);
const insets = useSafeAreaInsets();
// Username registration state
const [nameInput, setNameInput] = useState('');
const [registering, setRegistering] = useState(false);
const [nameError, setNameError] = useState<string | null>(null);
// Sanitize: lowercase, only a-z 0-9 _ -, max 32 (contract limit)
function onNameInputChange(v: string) {
const cleaned = v.toLowerCase().replace(/[^a-z0-9_\-]/g, '').slice(0, MAX_USERNAME_LENGTH);
setNameInput(cleaned);
setNameError(null);
}
async function registerUsername() {
if (!keyFile) return;
const name = nameInput.trim();
// Mirror blockchain/native_username.go validateName so the UI gives
// immediate feedback without a round trip to the chain.
if (name.length < MIN_USERNAME_LENGTH) {
setNameError(`Минимум ${MIN_USERNAME_LENGTH} символа`);
return;
}
if (!/^[a-z]/.test(name)) {
setNameError('Имя должно начинаться с буквы a-z');
return;
}
if (!settings.contractId) {
setNameError('Не задан ID контракта реестра в настройках ноды.');
return;
}
const fee = USERNAME_REGISTRATION_FEE;
// Reserve for: registry fee (burned) + MIN_CALL_FEE (to validator)
// + small gas headroom (native contract is cheap, but gas is pre-charged).
const GAS_HEADROOM = 2_000;
const total = fee + 1000 + GAS_HEADROOM;
if (balance < total) {
setNameError(`Нужно ${formatAmount(total)} (с запасом на газ), доступно ${formatAmount(balance)}.`);
return;
}
// Check if name is already taken
try {
const existing = await resolveUsername(settings.contractId, name);
if (existing) {
setNameError(`@${name} уже зарегистрировано.`);
return;
}
} catch {
// ignore — lookup failure is OK, contract will reject on duplicate
}
Alert.alert(
'Купить @' + name + '?',
`Стоимость: ${formatAmount(fee)} + комиссия ${formatAmount(1000)}.\nИмя привязывается к вашему адресу навсегда (до release).`,
[
{ text: 'Отмена', style: 'cancel' },
{
text: 'Купить', onPress: async () => {
setRegistering(true);
setNameError(null);
try {
const tx = buildCallContractTx({
from: keyFile.pub_key,
contractId: settings.contractId,
method: 'register',
args: [name],
// Attach the registration fee in tx.amount — the contract
// requires exactly this much and burns it. Visible in the
// explorer so the user sees the real cost.
amount: USERNAME_REGISTRATION_FEE,
privKey: keyFile.priv_key,
});
await submitTx(tx);
Alert.alert(
'Отправлено',
`Транзакция покупки @${name} принята. Имя появится в профиле через несколько секунд.`,
);
setNameInput('');
// Poll every 2s for up to 20s until the address ↔ name binding is visible.
let attempts = 0;
const iv = setInterval(async () => {
attempts++;
const got = keyFile
? await reverseResolve(settings.contractId, keyFile.pub_key)
: null;
if (got) {
setUsername(got);
clearInterval(iv);
} else if (attempts >= 10) {
clearInterval(iv);
}
}, 2000);
} catch (e: any) {
setNameError(humanizeTxError(e));
} finally {
setRegistering(false);
}
},
},
],
);
}
// Flat fee — same for every name that passes validation.
const nameFee = USERNAME_REGISTRATION_FEE;
const nameIsValid = nameInput.length >= MIN_USERNAME_LENGTH && /^[a-z]/.test(nameInput);
useEffect(() => { checkNode(); }, []);
// Pick up auto-discovered contract IDs (useWellKnownContracts updates the
// store; reflect it into the local TextInput state so the UI stays consistent).
useEffect(() => {
setContractId(settings.contractId);
}, [settings.contractId]);
// When the registry contract becomes known (either via manual save or
// auto-discovery), look up the user's registered username reactively.
// Sets username unconditionally — a null result CLEARS the cached name,
// which matters when the user switches nodes / chains: a name on the
// previous chain should no longer show when connected to a chain where
// the same pubkey isn't registered.
useEffect(() => {
if (!settings.contractId || !keyFile) {
setUsername(null);
return;
}
(async () => {
const name = await reverseResolve(settings.contractId, keyFile.pub_key);
setUsername(name);
})();
}, [settings.contractId, keyFile, setUsername]);
async function checkNode() {
setNodeStatus('checking');
try {
const stats = await getNetStats();
setNodeStatus('ok');
setPeerCount(stats.peer_count);
setBlockCount(stats.total_blocks);
if (settings.contractId && keyFile) {
// Address → username: must use reverseResolve, not resolveUsername
// (resolveUsername goes username → address).
const name = await reverseResolve(settings.contractId, keyFile.pub_key);
if (name) setUsername(name);
}
} catch {
setNodeStatus('error');
}
}
async function saveNode() {
const url = nodeUrl.trim().replace(/\/$/, '');
setNodeUrl(url);
const next = { nodeUrl: url, contractId: contractId.trim() };
setSettings(next);
await saveSettings(next);
Alert.alert('Сохранено', 'Настройки ноды обновлены.');
checkNode();
}
async function exportKey() {
if (!keyFile) return;
try {
const json = JSON.stringify(keyFile, null, 2);
const path = FileSystem.cacheDirectory + 'dchain_key.json';
await FileSystem.writeAsStringAsync(path, json);
if (await Sharing.isAvailableAsync()) {
await Sharing.shareAsync(path, {
mimeType: 'application/json',
dialogTitle: 'Экспорт ключа DChain',
});
}
} catch (e: any) {
Alert.alert('Ошибка экспорта', e.message);
}
}
async function copyAddress() {
if (!keyFile) return;
await Clipboard.setStringAsync(keyFile.pub_key);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
function logout() {
Alert.alert(
'Удалить аккаунт',
'Ключ будет удалён с устройства. Убедитесь, что у вас есть резервная копия!',
[
{ text: 'Отмена', style: 'cancel' },
{
text: 'Удалить',
style: 'destructive',
onPress: async () => {
await deleteKeyFile();
setKeyFile(null);
router.replace('/');
},
},
],
);
}
const statusColor = nodeStatus === 'ok' ? C.ok : nodeStatus === 'error' ? C.err : C.warn;
const statusLabel = nodeStatus === 'ok' ? 'Подключена' : nodeStatus === 'error' ? 'Недоступна' : 'Проверка…';
return (
<ScrollView
style={{ flex: 1, backgroundColor: C.bg }}
contentContainerStyle={{
paddingHorizontal: 16,
paddingTop: insets.top + 12,
paddingBottom: Math.max(insets.bottom, 24) + 20,
}}
>
<Text style={{ color: C.text, fontSize: 22, fontWeight: '700', marginBottom: 24 }}>
Настройки
</Text>
{/* ── Профиль ── */}
<SectionLabel>Профиль</SectionLabel>
<Card>
{/* Avatar row */}
<View style={{
flexDirection: 'row', alignItems: 'center', gap: 14,
paddingHorizontal: 14, paddingVertical: 14,
}}>
<Avatar
name={username ? `@${username}` : (keyFile?.pub_key ?? '?')}
size="md"
/>
<View style={{ flex: 1, minWidth: 0 }}>
{username ? (
<Text style={{ color: C.text, fontWeight: '700', fontSize: 16 }}>@{username}</Text>
) : (
<Text style={{ color: C.muted, fontSize: 13 }}>Имя не зарегистрировано</Text>
)}
<Text style={{ color: C.muted, fontFamily: 'monospace', fontSize: 11 }} numberOfLines={1}>
{keyFile ? shortAddr(keyFile.pub_key, 10) : '—'}
</Text>
</View>
</View>
{/* Copy address */}
<View style={{ paddingHorizontal: 14, paddingBottom: 12, borderTopWidth: 1, borderTopColor: C.line, paddingTop: 10 }}>
<TouchableOpacity
onPress={copyAddress}
style={{
flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 8,
paddingVertical: 10, borderRadius: 8,
backgroundColor: copied ? 'rgba(65,201,138,0.12)' : C.surface2,
}}
>
<Ionicons
name={copied ? 'checkmark-outline' : 'copy-outline'}
size={15}
color={copied ? C.ok : C.muted}
/>
<Text style={{ color: copied ? C.ok : C.muted, fontSize: 13, fontWeight: '600' }}>
{copied ? 'Скопировано' : 'Скопировать адрес'}
</Text>
</TouchableOpacity>
</View>
</Card>
{/* ── Имя пользователя ── */}
<SectionLabel>Имя пользователя</SectionLabel>
<Card>
{username ? (
// Already registered
<View style={{
flexDirection: 'row', alignItems: 'center', gap: 12,
paddingHorizontal: 14, paddingVertical: 14,
}}>
<View style={{
width: 34, height: 34, borderRadius: 17,
backgroundColor: 'rgba(65,201,138,0.12)',
alignItems: 'center', justifyContent: 'center', flexShrink: 0,
}}>
<Ionicons name="at-outline" size={16} color={C.ok} />
</View>
<View style={{ flex: 1 }}>
<Text style={{ color: C.text, fontSize: 15, fontWeight: '700' }}>@{username}</Text>
<Text style={{ color: C.muted, fontSize: 12, marginTop: 2 }}>Привязано к вашему адресу</Text>
</View>
<View style={{
paddingHorizontal: 10, paddingVertical: 4,
borderRadius: 999, backgroundColor: 'rgba(65,201,138,0.12)',
}}>
<Text style={{ color: C.ok, fontSize: 11, fontWeight: '600' }}>Активно</Text>
</View>
</View>
) : (
// Register new
<>
<View style={{ paddingHorizontal: 14, paddingTop: 12, paddingBottom: 6 }}>
<Text style={{ color: C.text, fontSize: 14, fontWeight: '600', marginBottom: 3 }}>
Купить никнейм
</Text>
<Text style={{ color: C.muted, fontSize: 12, lineHeight: 17 }}>
Короткие имена дороже. Оплата идёт в казну контракта реестра.
</Text>
</View>
{/* Input */}
<View style={{ paddingHorizontal: 14, paddingTop: 10 }}>
<View style={{
flexDirection: 'row', alignItems: 'center',
backgroundColor: C.surface2, borderRadius: 8,
paddingHorizontal: 12,
borderWidth: 1, borderColor: nameError ? C.err : C.line,
}}>
<Text style={{ color: C.muted, fontSize: 15, marginRight: 4 }}>@</Text>
<TextInput
value={nameInput}
onChangeText={onNameInputChange}
placeholder="alice"
placeholderTextColor={C.muted}
autoCapitalize="none"
autoCorrect={false}
maxLength={64}
style={{ color: C.text, fontSize: 15, height: 40, flex: 1 }}
/>
{nameInput.length > 0 && (
<Text style={{ color: C.muted, fontSize: 11 }}>{nameInput.length}</Text>
)}
</View>
{nameError && (
<Text style={{ color: C.err, fontSize: 12, marginTop: 6 }}> {nameError}</Text>
)}
</View>
{/* Fee breakdown + rules */}
<View style={{ paddingHorizontal: 14, paddingTop: 10 }}>
<View style={{
backgroundColor: C.surface2, borderRadius: 8,
paddingHorizontal: 12, paddingVertical: 10,
}}>
{/* Primary cost line */}
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 8, marginBottom: 6 }}>
<Ionicons name="flame-outline" size={14} color={C.warn} />
<Text style={{ color: C.muted, fontSize: 12, flex: 1 }}>Плата за ник (сгорает)</Text>
<Text style={{ color: C.text, fontSize: 13, fontWeight: '600' }}>
{formatAmount(nameFee)}
</Text>
</View>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 8, marginBottom: 8 }}>
<Ionicons name="cube-outline" size={14} color={C.muted} />
<Text style={{ color: C.muted, fontSize: 12, flex: 1 }}>Комиссия сети (валидатору)</Text>
<Text style={{ color: C.muted, fontSize: 13 }}>
{formatAmount(1000)}
</Text>
</View>
{/* Total */}
<View style={{
flexDirection: 'row', alignItems: 'center', gap: 8,
paddingTop: 6,
borderTopWidth: 1, borderTopColor: C.line,
}}>
<Text style={{ color: C.text, fontSize: 12, fontWeight: '600', flex: 1 }}>Итого</Text>
<Text style={{ color: C.text, fontSize: 13, fontWeight: '700' }}>
{formatAmount(nameFee + 1000)}
</Text>
</View>
{/* Rules */}
<Text style={{ color: C.muted, fontSize: 11, lineHeight: 17, marginTop: 8 }}>
Минимум {MIN_USERNAME_LENGTH} символа, только{' '}
<Text style={{ color: C.text }}>a-z 0-9 _ -</Text>
, первый символ буква.
</Text>
</View>
</View>
{/* Register button */}
<View style={{ paddingHorizontal: 14, paddingVertical: 12 }}>
<TouchableOpacity
onPress={registerUsername}
disabled={registering || !nameIsValid || !settings.contractId}
style={{
flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 8,
paddingVertical: 12, borderRadius: 8,
backgroundColor: (registering || !nameIsValid || !settings.contractId)
? C.surface2 : C.accent,
}}
>
<Ionicons
name={registering ? 'hourglass-outline' : 'at-outline'}
size={16}
color={(registering || !nameIsValid || !settings.contractId) ? C.muted : C.bg}
/>
<Text style={{
color: (registering || !nameIsValid || !settings.contractId) ? C.muted : C.bg,
fontWeight: '700', fontSize: 14,
}}>
{registering ? 'Покупка…' : 'Купить никнейм'}
</Text>
</TouchableOpacity>
{!settings.contractId && (
<Text style={{ color: C.warn, fontSize: 11, marginTop: 6, textAlign: 'center' }}>
Укажите ID контракта реестра в настройках ноды ниже
</Text>
)}
</View>
</>
)}
</Card>
{/* ── Нода ── */}
<SectionLabel>Нода</SectionLabel>
<Card>
{/* Connection status */}
<View style={{
flexDirection: 'row', alignItems: 'center', gap: 12,
paddingHorizontal: 14, paddingVertical: 12,
}}>
<View style={{
width: 34, height: 34, borderRadius: 17,
backgroundColor: `${statusColor}15`,
alignItems: 'center', justifyContent: 'center',
}}>
<Ionicons
name={nodeStatus === 'ok' ? 'cloud-done-outline' : nodeStatus === 'error' ? 'cloud-offline-outline' : 'cloud-outline'}
size={16}
color={statusColor}
/>
</View>
<View style={{ flex: 1 }}>
<Text style={{ color: C.text, fontSize: 14, fontWeight: '600' }}>Подключение</Text>
<Text style={{ color: statusColor, fontSize: 12 }}>{statusLabel}</Text>
</View>
{nodeStatus === 'ok' && (
<View style={{ alignItems: 'flex-end', gap: 2 }}>
{peerCount !== null && (
<Text style={{ color: C.muted, fontSize: 11 }}>
<Text style={{ color: C.text, fontWeight: '600' }}>{peerCount}</Text> пиров
</Text>
)}
{blockCount !== null && (
<Text style={{ color: C.muted, fontSize: 11 }}>
<Text style={{ color: C.text, fontWeight: '600' }}>{blockCount.toLocaleString()}</Text> блоков
</Text>
)}
</View>
)}
</View>
{/* Node URL input */}
<View style={{ borderTopWidth: 1, borderTopColor: C.line }}>
<FieldInput
label="URL ноды"
value={nodeUrl}
onChangeText={setNodeUrlLocal}
keyboardType="url"
placeholder="http://localhost:8080"
/>
</View>
{/* Registry contract — auto-detected from node; manual override under advanced */}
<View style={{ borderTopWidth: 1, borderTopColor: C.line, paddingHorizontal: 14, paddingVertical: 12 }}>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 8, marginBottom: 6 }}>
<Ionicons
name={settings.contractId ? 'checkmark-circle' : 'help-circle-outline'}
size={14}
color={settings.contractId ? C.ok : C.warn}
/>
<Text style={{ color: C.muted, fontSize: 11, letterSpacing: 0.5 }}>
КОНТРАКТ РЕЕСТРА ИМЁН
</Text>
<Text style={{
marginLeft: 'auto' as any, color: settings.contractId ? C.ok : C.warn, fontSize: 11, fontWeight: '600',
}}>
{settings.contractId ? 'Авто-обнаружен' : 'Не найден'}
</Text>
</View>
<Text style={{ color: C.text, fontSize: 12, fontFamily: 'monospace' }} numberOfLines={1}>
{settings.contractId || '—'}
</Text>
<TouchableOpacity
onPress={() => setShowAdvanced(v => !v)}
style={{ marginTop: 8 }}
>
<Text style={{ color: C.accent, fontSize: 11 }}>
{showAdvanced ? '▾ Скрыть ручной ввод' : '▸ Указать вручную (не требуется)'}
</Text>
</TouchableOpacity>
{showAdvanced && (
<View style={{
marginTop: 8,
backgroundColor: C.surface2, borderRadius: 8,
paddingHorizontal: 12, paddingVertical: 8,
borderWidth: 1, borderColor: C.line,
}}>
<TextInput
value={contractId}
onChangeText={setContractId}
placeholder="hex contract ID"
placeholderTextColor={C.muted}
autoCapitalize="none"
autoCorrect={false}
style={{ color: C.text, fontSize: 13, fontFamily: 'monospace', height: 36 }}
/>
<Text style={{ color: C.muted, fontSize: 10, marginTop: 4 }}>
Оставьте пустым клиент запросит канонический контракт у ноды.
</Text>
</View>
)}
</View>
{/* Save button */}
<View style={{ paddingHorizontal: 14, paddingBottom: 12, paddingTop: 4, borderTopWidth: 1, borderTopColor: C.line }}>
<TouchableOpacity
onPress={saveNode}
style={{
flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 8,
paddingVertical: 11, borderRadius: 8, backgroundColor: C.accent,
}}
>
<Ionicons name="save-outline" size={16} color={C.bg} />
<Text style={{ color: C.bg, fontWeight: '700', fontSize: 14 }}>Сохранить и переподключиться</Text>
</TouchableOpacity>
</View>
</Card>
{/* ── Безопасность ── */}
<SectionLabel>Безопасность</SectionLabel>
<Card>
<View style={{
flexDirection: 'row', alignItems: 'center', gap: 12,
paddingHorizontal: 14, paddingVertical: 14,
}}>
<View style={{
width: 34, height: 34, borderRadius: 17,
backgroundColor: 'rgba(125,181,255,0.10)',
alignItems: 'center', justifyContent: 'center', flexShrink: 0,
}}>
<Ionicons name="shield-outline" size={16} color={C.accent} />
</View>
<View style={{ flex: 1 }}>
<Text style={{ color: C.text, fontSize: 14, fontWeight: '600' }}>Экспорт ключа</Text>
<Text style={{ color: C.muted, fontSize: 12, marginTop: 1 }}>Сохранить приватный ключ как key.json</Text>
</View>
<TouchableOpacity
onPress={exportKey}
style={{
paddingHorizontal: 14, paddingVertical: 8, borderRadius: 8,
backgroundColor: C.surface2, borderWidth: 1, borderColor: C.line,
}}
>
<Text style={{ color: C.text, fontSize: 13, fontWeight: '600' }}>Экспорт</Text>
</TouchableOpacity>
</View>
</Card>
{/* ── Опасная зона ── */}
<SectionLabel>Опасная зона</SectionLabel>
<Card style={{ backgroundColor: 'rgba(255,122,135,0.04)', borderWidth: 1, borderColor: 'rgba(255,122,135,0.20)' }}>
<View style={{ paddingHorizontal: 14, paddingVertical: 14 }}>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 10, marginBottom: 8 }}>
<Ionicons name="warning-outline" size={16} color={C.err} />
<Text style={{ color: C.err, fontSize: 14, fontWeight: '700' }}>Удалить аккаунт</Text>
</View>
<Text style={{ color: C.muted, fontSize: 13, lineHeight: 19, marginBottom: 12 }}>
Удаляет ключ с устройства. Онлайн-идентичность сохраняется, но доступ будет потерян без резервной копии.
</Text>
<TouchableOpacity
onPress={logout}
style={{
flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 8,
paddingVertical: 11, borderRadius: 8,
backgroundColor: 'rgba(255,122,135,0.12)',
}}
>
<Ionicons name="trash-outline" size={16} color={C.err} />
<Text style={{ color: C.err, fontWeight: '700', fontSize: 14 }}>Удалить с устройства</Text>
</TouchableOpacity>
</View>
</Card>
</ScrollView>
);
}

View File

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

View File

@@ -0,0 +1,82 @@
/**
* Create Account screen.
* Generates a new Ed25519 + X25519 keypair and saves it securely.
*/
import React, { useState } from 'react';
import { View, Text, ScrollView, Alert } from 'react-native';
import { router } from 'expo-router';
import { generateKeyFile } from '@/lib/crypto';
import { saveKeyFile } from '@/lib/storage';
import { useStore } from '@/lib/store';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
import { Card } from '@/components/ui/Card';
export default function CreateAccountScreen() {
const setKeyFile = useStore(s => s.setKeyFile);
const [loading, setLoading] = useState(false);
async function handleCreate() {
setLoading(true);
try {
const kf = generateKeyFile();
await saveKeyFile(kf);
setKeyFile(kf);
router.replace('/(auth)/created');
} catch (e: any) {
Alert.alert('Error', e.message);
} finally {
setLoading(false);
}
}
return (
<ScrollView
className="flex-1 bg-background"
contentContainerClassName="px-6 pt-16 pb-10"
>
{/* Header */}
<Button variant="ghost" size="sm" onPress={() => router.back()} className="self-start mb-6 -ml-2">
Back
</Button>
<Text className="text-white text-3xl font-bold mb-2">Create Account</Text>
<Text className="text-muted text-base mb-8 leading-6">
A new identity will be generated on your device.
Your private key never leaves this app.
</Text>
{/* Info cards */}
<Card className="mb-4 gap-3">
<InfoRow icon="🔑" label="Ed25519 signing key" desc="Your blockchain address and tx signing key" />
<InfoRow icon="🔒" label="X25519 encryption key" desc="End-to-end encryption for messages" />
<InfoRow icon="📱" label="Stored on device" desc="Keys are encrypted in the device secure store" />
</Card>
<Card className="mb-8 border-primary/30 bg-primary/10">
<Text className="text-accent text-sm font-semibold mb-1"> Important</Text>
<Text className="text-muted text-sm leading-5">
After creation, export and backup your key file.
If you lose it there is no recovery the blockchain has no password reset.
</Text>
</Card>
<Button onPress={handleCreate} loading={loading} size="lg">
Generate Keys & Create Account
</Button>
</ScrollView>
);
}
function InfoRow({ icon, label, desc }: { icon: string; label: string; desc: string }) {
return (
<View className="flex-row items-start gap-3">
<Text className="text-xl">{icon}</Text>
<View className="flex-1">
<Text className="text-white text-sm font-semibold">{label}</Text>
<Text className="text-muted text-xs mt-0.5">{desc}</Text>
</View>
</View>
);
}

View File

@@ -0,0 +1,118 @@
/**
* Account Created confirmation screen.
* Shows address, pubkeys, and export options.
*/
import React, { useState } from 'react';
import { View, Text, ScrollView, Alert, Share } from 'react-native';
import { router } from 'expo-router';
import * as Clipboard from 'expo-clipboard';
import * as FileSystem from 'expo-file-system';
import * as Sharing from 'expo-sharing';
import { useStore } from '@/lib/store';
import { shortAddr } from '@/lib/crypto';
import { Button } from '@/components/ui/Button';
import { Card } from '@/components/ui/Card';
import { Separator } from '@/components/ui/Separator';
export default function AccountCreatedScreen() {
const keyFile = useStore(s => s.keyFile);
const [copied, setCopied] = useState<string | null>(null);
if (!keyFile) {
router.replace('/');
return null;
}
async function copy(value: string, label: string) {
await Clipboard.setStringAsync(value);
setCopied(label);
setTimeout(() => setCopied(null), 2000);
}
async function exportKey() {
try {
const json = JSON.stringify(keyFile, null, 2);
const path = FileSystem.cacheDirectory + 'dchain_key.json';
await FileSystem.writeAsStringAsync(path, json);
if (await Sharing.isAvailableAsync()) {
await Sharing.shareAsync(path, {
mimeType: 'application/json',
dialogTitle: 'Save your DChain key file',
});
} else {
Alert.alert('Export', 'Sharing not available on this device.');
}
} catch (e: any) {
Alert.alert('Export failed', e.message);
}
}
return (
<ScrollView
className="flex-1 bg-background"
contentContainerClassName="px-6 pt-16 pb-10"
>
{/* Success header */}
<View className="items-center mb-8">
<View className="w-20 h-20 rounded-full bg-success/20 items-center justify-center mb-4">
<Text className="text-4xl"></Text>
</View>
<Text className="text-white text-2xl font-bold">Account Created!</Text>
<Text className="text-muted text-sm mt-2 text-center">
Your keys have been generated and stored securely.
</Text>
</View>
{/* Address card */}
<Card className="mb-4">
<Text className="text-muted text-xs uppercase tracking-widest mb-3 font-semibold">
Your Address (Ed25519)
</Text>
<Text className="text-white font-mono text-xs leading-5 mb-3">
{keyFile.pub_key}
</Text>
<Button
variant="secondary"
size="sm"
onPress={() => copy(keyFile.pub_key, 'address')}
>
{copied === 'address' ? '✓ Copied' : 'Copy Address'}
</Button>
</Card>
{/* X25519 key */}
<Card className="mb-4">
<Text className="text-muted text-xs uppercase tracking-widest mb-3 font-semibold">
Encryption Key (X25519)
</Text>
<Text className="text-white font-mono text-xs leading-5 mb-3">
{keyFile.x25519_pub}
</Text>
<Button
variant="secondary"
size="sm"
onPress={() => copy(keyFile.x25519_pub, 'x25519')}
>
{copied === 'x25519' ? '✓ Copied' : 'Copy Encryption Key'}
</Button>
</Card>
{/* Export warning */}
<Card className="mb-8 border-yellow-500/30 bg-yellow-500/10">
<Text className="text-yellow-400 text-sm font-semibold mb-2">🔐 Backup your key file</Text>
<Text className="text-muted text-xs leading-5 mb-3">
Export <Text className="text-white font-mono">dchain_key.json</Text> and store it safely.
This file contains your private keys keep it secret.
</Text>
<Button variant="outline" onPress={exportKey}>
Export key.json
</Button>
</Card>
<Button size="lg" onPress={() => router.replace('/(app)/chats')}>
Open Messenger
</Button>
</ScrollView>
);
}

View File

@@ -0,0 +1,301 @@
/**
* Import Existing Key screen.
* Two methods:
* 1. Paste JSON directly into a text field
* 2. Pick key.json file via document picker
*/
import React, { useState } from 'react';
import {
View, Text, ScrollView, TextInput,
TouchableOpacity, Alert, Pressable,
} from 'react-native';
import { router } from 'expo-router';
import * as DocumentPicker from 'expo-document-picker';
import * as Clipboard from 'expo-clipboard';
import { saveKeyFile } from '@/lib/storage';
import { useStore } from '@/lib/store';
import { Button } from '@/components/ui/Button';
import type { KeyFile } from '@/lib/types';
type Tab = 'paste' | 'file';
const REQUIRED_FIELDS: (keyof KeyFile)[] = ['pub_key', 'priv_key', 'x25519_pub', 'x25519_priv'];
function validateKeyFile(raw: string): KeyFile {
let parsed: any;
try {
parsed = JSON.parse(raw.trim());
} catch {
throw new Error('Invalid JSON — check that you copied the full key file contents.');
}
for (const field of REQUIRED_FIELDS) {
if (!parsed[field] || typeof parsed[field] !== 'string') {
throw new Error(`Missing or invalid field: "${field}"`);
}
if (!/^[0-9a-f]+$/i.test(parsed[field])) {
throw new Error(`Field "${field}" must be a hex string.`);
}
}
return parsed as KeyFile;
}
export default function ImportKeyScreen() {
const setKeyFile = useStore(s => s.setKeyFile);
const [tab, setTab] = useState<Tab>('paste');
const [jsonText, setJsonText] = useState('');
const [fileName, setFileName] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// ── Shared: save validated key and navigate ──────────────────────────────
async function applyKey(kf: KeyFile) {
setLoading(true);
setError(null);
try {
await saveKeyFile(kf);
setKeyFile(kf);
router.replace('/(app)/chats');
} catch (e: any) {
setError(e.message);
} finally {
setLoading(false);
}
}
// ── Method 1: paste JSON ─────────────────────────────────────────────────
async function handlePasteImport() {
setError(null);
const text = jsonText.trim();
if (!text) {
// Try reading clipboard if field is empty
const clip = await Clipboard.getStringAsync();
if (clip) setJsonText(clip);
return;
}
try {
const kf = validateKeyFile(text);
await applyKey(kf);
} catch (e: any) {
setError(e.message);
}
}
// ── Method 2: pick file ──────────────────────────────────────────────────
async function pickFile() {
setError(null);
try {
const result = await DocumentPicker.getDocumentAsync({
type: ['application/json', 'text/plain', '*/*'],
copyToCacheDirectory: true,
});
if (result.canceled) return;
const asset = result.assets[0];
setFileName(asset.name);
// Use fetch() — readAsStringAsync is deprecated in newer expo-file-system
const response = await fetch(asset.uri);
const raw = await response.text();
const kf = validateKeyFile(raw);
await applyKey(kf);
} catch (e: any) {
setError(e.message);
}
}
const tabStyle = (t: Tab) => ({
flex: 1 as const,
paddingVertical: 10,
alignItems: 'center' as const,
borderBottomWidth: 2,
borderBottomColor: tab === t ? '#2563eb' : 'transparent',
});
const tabTextStyle = (t: Tab) => ({
fontSize: 14,
fontWeight: '600' as const,
color: tab === t ? '#fff' : '#8b949e',
});
return (
<ScrollView
style={{ flex: 1, backgroundColor: '#0d1117' }}
contentContainerStyle={{ paddingHorizontal: 20, paddingTop: 60, paddingBottom: 40 }}
keyboardShouldPersistTaps="handled"
keyboardDismissMode="on-drag"
>
{/* Back */}
<Pressable onPress={() => router.back()} style={{ marginBottom: 24, alignSelf: 'flex-start' }}>
<Text style={{ color: '#2563eb', fontSize: 15 }}> Back</Text>
</Pressable>
<Text style={{ color: '#fff', fontSize: 28, fontWeight: '700', marginBottom: 6 }}>
Import Key
</Text>
<Text style={{ color: '#8b949e', fontSize: 15, lineHeight: 22, marginBottom: 24 }}>
Restore your account from an existing{' '}
<Text style={{ color: '#fff', fontFamily: 'monospace' }}>key.json</Text>.
</Text>
{/* Tabs */}
<View style={{
flexDirection: 'row',
borderBottomWidth: 1, borderBottomColor: '#30363d',
marginBottom: 24,
}}>
<TouchableOpacity style={tabStyle('paste')} onPress={() => setTab('paste')}>
<Text style={tabTextStyle('paste')}>📋 Paste JSON</Text>
</TouchableOpacity>
<TouchableOpacity style={tabStyle('file')} onPress={() => setTab('file')}>
<Text style={tabTextStyle('file')}>📁 Open File</Text>
</TouchableOpacity>
</View>
{/* ── Paste tab ── */}
{tab === 'paste' && (
<View style={{ gap: 12 }}>
<Text style={{ color: '#8b949e', fontSize: 12, fontWeight: '600',
textTransform: 'uppercase', letterSpacing: 1 }}>
Key JSON
</Text>
<View style={{
backgroundColor: '#161b22',
borderWidth: 1,
borderColor: error ? '#f85149' : jsonText ? '#2563eb' : '#30363d',
borderRadius: 12,
padding: 12,
}}>
<TextInput
value={jsonText}
onChangeText={t => { setJsonText(t); setError(null); }}
placeholder={'{\n "pub_key": "...",\n "priv_key": "...",\n "x25519_pub": "...",\n "x25519_priv": "..."\n}'}
placeholderTextColor="#8b949e"
multiline
numberOfLines={8}
autoCapitalize="none"
autoCorrect={false}
style={{
color: '#fff',
fontFamily: 'monospace',
fontSize: 12,
lineHeight: 18,
minHeight: 160,
textAlignVertical: 'top',
}}
/>
</View>
{/* Paste from clipboard shortcut */}
{!jsonText && (
<TouchableOpacity
onPress={async () => {
const clip = await Clipboard.getStringAsync();
if (clip) { setJsonText(clip); setError(null); }
else Alert.alert('Clipboard empty', 'Copy your key JSON first.');
}}
style={{
flexDirection: 'row', alignItems: 'center', gap: 8,
padding: 12, backgroundColor: '#161b22',
borderWidth: 1, borderColor: '#30363d', borderRadius: 12,
}}
>
<Text style={{ fontSize: 18 }}>📋</Text>
<Text style={{ color: '#8b949e', fontSize: 14 }}>Paste from clipboard</Text>
</TouchableOpacity>
)}
{error && (
<View style={{
backgroundColor: 'rgba(248,81,73,0.1)',
borderWidth: 1, borderColor: 'rgba(248,81,73,0.3)',
borderRadius: 10, padding: 12,
}}>
<Text style={{ color: '#f85149', fontSize: 13, lineHeight: 18 }}> {error}</Text>
</View>
)}
<Button
size="lg"
loading={loading}
disabled={!jsonText.trim()}
onPress={handlePasteImport}
>
Import Key
</Button>
</View>
)}
{/* ── File tab ── */}
{tab === 'file' && (
<View style={{ gap: 12 }}>
<TouchableOpacity
onPress={pickFile}
style={{
backgroundColor: '#161b22',
borderWidth: 2, borderColor: '#30363d',
borderRadius: 16, borderStyle: 'dashed',
padding: 32, alignItems: 'center', gap: 12,
}}
>
<Text style={{ fontSize: 40 }}>📂</Text>
<Text style={{ color: '#fff', fontSize: 16, fontWeight: '600' }}>
{fileName ?? 'Choose key.json'}
</Text>
<Text style={{ color: '#8b949e', fontSize: 13, textAlign: 'center' }}>
Tap to browse files
</Text>
</TouchableOpacity>
{fileName && (
<View style={{
flexDirection: 'row', alignItems: 'center', gap: 10,
backgroundColor: 'rgba(63,185,80,0.1)',
borderWidth: 1, borderColor: 'rgba(63,185,80,0.3)',
borderRadius: 10, padding: 12,
}}>
<Text style={{ fontSize: 18 }}>📄</Text>
<Text style={{ color: '#3fb950', fontSize: 13, flex: 1 }} numberOfLines={1}>
{fileName}
</Text>
</View>
)}
{error && (
<View style={{
backgroundColor: 'rgba(248,81,73,0.1)',
borderWidth: 1, borderColor: 'rgba(248,81,73,0.3)',
borderRadius: 10, padding: 12,
}}>
<Text style={{ color: '#f85149', fontSize: 13, lineHeight: 18 }}> {error}</Text>
</View>
)}
{loading && (
<Text style={{ color: '#8b949e', textAlign: 'center', fontSize: 14 }}>
Validating key
</Text>
)}
</View>
)}
{/* Format hint */}
<View style={{
marginTop: 28, padding: 14,
backgroundColor: '#161b22',
borderWidth: 1, borderColor: '#30363d', borderRadius: 12,
}}>
<Text style={{ color: '#8b949e', fontSize: 12, fontWeight: '600',
marginBottom: 8, textTransform: 'uppercase', letterSpacing: 1 }}>
Expected format
</Text>
<Text style={{ color: '#8b949e', fontFamily: 'monospace', fontSize: 11, lineHeight: 17 }}>
{`{\n "pub_key": "<64 hex chars>",\n "priv_key": "<128 hex chars>",\n "x25519_pub": "<64 hex chars>",\n "x25519_priv": "<64 hex chars>"\n}`}
</Text>
</View>
</ScrollView>
);
}

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

View File

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

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

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

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

File diff suppressed because it is too large Load Diff

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

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

View File

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

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

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

1564
cmd/client/main.go Normal file

File diff suppressed because it is too large Load Diff

402
cmd/loadtest/main.go Normal file
View 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

File diff suppressed because it is too large Load Diff

65
cmd/peerid/main.go Normal file
View 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
View 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
View 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
View 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)
}
}

Binary file not shown.

View 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"}
]
}
]
}

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

Binary file not shown.

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

View File

@@ -0,0 +1 @@
{"methods":[{"name":"increment","args":[]},{"name":"get","args":[]},{"name":"reset","args":[]}]}

View 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
View 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() {}

Binary file not shown.

View 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"}
]
}
]
}

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

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

Binary file not shown.

View 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)"}
]
}
]
}

View 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
View 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() {}

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

Binary file not shown.

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

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

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

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

Binary file not shown.

View 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
View 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.

View 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
View 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`) |

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

View 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

View 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

View 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
View 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
View 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=...` вручную |

View 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