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

265
relay/mailbox.go Normal file
View File

@@ -0,0 +1,265 @@
package relay
import (
"encoding/json"
"errors"
"fmt"
"strings"
"time"
badger "github.com/dgraph-io/badger/v4"
)
const (
// mailboxTTL is how long undelivered envelopes are retained.
mailboxTTL = 7 * 24 * time.Hour
// mailboxPrefix is the BadgerDB key prefix for stored envelopes.
// Key format: mail:<recipientX25519Hex>:<sentAt_20d>:<envelopeID>
mailboxPrefix = "mail:"
// MailboxMaxLimit caps the number of envelopes returned per single query.
MailboxMaxLimit = 200
// MailboxPerRecipientCap is the maximum number of envelopes stored per
// recipient key. When the cap is reached, the oldest envelope is evicted
// before the new one is written (sliding window, FIFO).
// At 64 KB max per envelope, one recipient occupies at most ~32 MB.
MailboxPerRecipientCap = 500
// MailboxMaxEnvelopeSize is the maximum allowed ciphertext length in bytes.
// Rejects oversized envelopes before writing to disk.
MailboxMaxEnvelopeSize = 64 * 1024 // 64 KB
)
// ErrEnvelopeTooLarge is returned by Store when the envelope exceeds the size limit.
var ErrEnvelopeTooLarge = errors.New("envelope ciphertext exceeds maximum allowed size")
// ErrMailboxFull is never returned externally (oldest entry is evicted instead),
// but kept as a sentinel for internal logic.
var errMailboxFull = errors.New("recipient mailbox is at capacity")
// Mailbox is a BadgerDB-backed store for relay envelopes awaiting pickup.
// Every received envelope is stored with a 7-day TTL regardless of whether
// the recipient is currently online. Recipients poll GET /relay/inbox to fetch
// and DELETE /relay/inbox/{id} to acknowledge delivery.
//
// Anti-spam guarantees:
// - Envelopes larger than MailboxMaxEnvelopeSize (64 KB) are rejected.
// - At most MailboxPerRecipientCap (500) envelopes per recipient are stored;
// when the cap is hit the oldest entry is silently evicted (FIFO).
// - All entries expire automatically after 7 days (BadgerDB TTL).
//
// Messages are stored encrypted — the relay cannot read their contents.
type Mailbox struct {
db *badger.DB
// onStore, if set, is invoked after every successful Store. Used by the
// node to push a WebSocket `inbox` event to subscribers of the
// recipient's x25519 pubkey so the mobile client stops polling
// /relay/inbox every 3 seconds.
//
// The callback MUST NOT block — it runs on the writer goroutine. Long
// work should be fanned out to a goroutine by the callback itself.
onStore func(*Envelope)
}
// SetOnStore registers a post-Store hook. Pass nil to clear. Safe to call
// before accepting traffic (wired once at node startup in main.go).
func (m *Mailbox) SetOnStore(cb func(*Envelope)) {
m.onStore = cb
}
// NewMailbox creates a Mailbox backed by the given BadgerDB instance.
func NewMailbox(db *badger.DB) *Mailbox {
return &Mailbox{db: db}
}
// OpenMailbox opens (or creates) a dedicated BadgerDB at dbPath for the mailbox.
//
// Storage tuning matches blockchain/chain.NewChain — 64 MiB vlog files
// (instead of 1 GiB default) so GC can actually shrink the DB, and single
// version retention since envelopes are either present or deleted.
func OpenMailbox(dbPath string) (*Mailbox, error) {
opts := badger.DefaultOptions(dbPath).
WithLogger(nil).
WithValueLogFileSize(64 << 20).
WithNumVersionsToKeep(1).
WithCompactL0OnClose(true)
db, err := badger.Open(opts)
if err != nil {
return nil, fmt.Errorf("open mailbox db: %w", err)
}
return &Mailbox{db: db}, nil
}
// Close closes the underlying database.
func (m *Mailbox) Close() error { return m.db.Close() }
// Store persists an envelope with a 7-day TTL.
//
// Anti-spam checks (in order):
// 1. Ciphertext > MailboxMaxEnvelopeSize → returns ErrEnvelopeTooLarge.
// 2. Duplicate envelope ID → silently overwritten (idempotent).
// 3. Recipient already has MailboxPerRecipientCap entries → oldest evicted first.
func (m *Mailbox) Store(env *Envelope) error {
if len(env.Ciphertext) > MailboxMaxEnvelopeSize {
return ErrEnvelopeTooLarge
}
key := mailboxKey(env.RecipientPub, env.SentAt, env.ID)
val, err := json.Marshal(env)
if err != nil {
return fmt.Errorf("marshal envelope: %w", err)
}
// Track whether this was a fresh insert (vs. duplicate) so we can skip
// firing the WS hook for idempotent resubmits — otherwise a misbehaving
// sender could amplify events by spamming the same envelope ID.
fresh := false
err = m.db.Update(func(txn *badger.Txn) error {
// Check if this exact envelope is already stored (idempotent).
if _, err := txn.Get([]byte(key)); err == nil {
return nil // already present, no-op
}
// Count existing envelopes for this recipient and collect the oldest key.
prefix := []byte(fmt.Sprintf("%s%s:", mailboxPrefix, env.RecipientPub))
opts := badger.DefaultIteratorOptions
opts.PrefetchValues = false
opts.Prefix = prefix
var count int
var oldestKey []byte
it := txn.NewIterator(opts)
for it.Rewind(); it.Valid(); it.Next() {
if count == 0 {
oldestKey = it.Item().KeyCopy(nil) // first = oldest (sorted by sentAt)
}
count++
}
it.Close()
// Evict the oldest envelope if cap is reached.
if count >= MailboxPerRecipientCap && oldestKey != nil {
if err := txn.Delete(oldestKey); err != nil {
return fmt.Errorf("evict oldest envelope: %w", err)
}
}
e := badger.NewEntry([]byte(key), val).WithTTL(mailboxTTL)
if err := txn.SetEntry(e); err != nil {
return err
}
fresh = true
return nil
})
if err == nil && fresh && m.onStore != nil {
m.onStore(env)
}
return err
}
// List returns up to limit envelopes for recipientPubHex, ordered oldest-first.
// Pass since > 0 to skip envelopes with SentAt < since (unix timestamp).
func (m *Mailbox) List(recipientPubHex string, since int64, limit int) ([]*Envelope, error) {
if limit <= 0 || limit > MailboxMaxLimit {
limit = MailboxMaxLimit
}
prefix := []byte(fmt.Sprintf("%s%s:", mailboxPrefix, recipientPubHex))
var out []*Envelope
err := m.db.View(func(txn *badger.Txn) error {
opts := badger.DefaultIteratorOptions
opts.Prefix = prefix
it := txn.NewIterator(opts)
defer it.Close()
for it.Rewind(); it.Valid() && len(out) < limit; it.Next() {
if err := it.Item().Value(func(val []byte) error {
var env Envelope
if err := json.Unmarshal(val, &env); err != nil {
return nil // skip corrupt entries
}
if since > 0 && env.SentAt < since {
return nil
}
out = append(out, &env)
return nil
}); err != nil {
return err
}
}
return nil
})
return out, err
}
// Delete removes an envelope by ID.
// It scans the recipient's prefix to locate the full key (sentAt is not known by caller).
// Returns nil if the envelope is not found (already expired or never stored).
func (m *Mailbox) Delete(recipientPubHex, envelopeID string) error {
prefix := []byte(fmt.Sprintf("%s%s:", mailboxPrefix, recipientPubHex))
var found []byte
err := m.db.View(func(txn *badger.Txn) error {
opts := badger.DefaultIteratorOptions
opts.PrefetchValues = false
opts.Prefix = prefix
it := txn.NewIterator(opts)
defer it.Close()
suffix := ":" + envelopeID
for it.Rewind(); it.Valid(); it.Next() {
key := it.Item().KeyCopy(nil)
if strings.HasSuffix(string(key), suffix) {
found = key
return nil
}
}
return nil
})
if err != nil || found == nil {
return err
}
return m.db.Update(func(txn *badger.Txn) error {
return txn.Delete(found)
})
}
// Count returns the number of stored envelopes for a recipient.
func (m *Mailbox) Count(recipientPubHex string) (int, error) {
prefix := []byte(fmt.Sprintf("%s%s:", mailboxPrefix, recipientPubHex))
count := 0
err := m.db.View(func(txn *badger.Txn) error {
opts := badger.DefaultIteratorOptions
opts.PrefetchValues = false
opts.Prefix = prefix
it := txn.NewIterator(opts)
defer it.Close()
for it.Rewind(); it.Valid(); it.Next() {
count++
}
return nil
})
return count, err
}
// RunGC periodically runs BadgerDB value log garbage collection.
// Call in a goroutine — blocks until cancelled via channel close or process exit.
func (m *Mailbox) RunGC() {
ticker := time.NewTicker(5 * time.Minute)
defer ticker.Stop()
for range ticker.C {
for m.db.RunValueLogGC(0.5) == nil {
// drain until nothing left to collect
}
}
}
func mailboxKey(recipientPubHex string, sentAt int64, envelopeID string) string {
// Zero-padded sentAt keeps lexicographic order == chronological order.
// Oldest entry = first key in iterator — used for FIFO eviction.
return fmt.Sprintf("%s%s:%020d:%s", mailboxPrefix, recipientPubHex, sentAt, envelopeID)
}