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
372 lines
13 KiB
Go
372 lines
13 KiB
Go
// 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
|
|
}
|