// 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:: by NativeContext helpers): // // name: → owner pubkey (raw hex bytes, 64 chars) // addr: → 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:") // - write caller → name mapping (key "addr:") // - emit `registered: ` 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 }