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

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