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

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
}