// cmd/node — full validator node with stats HTTP API. // // Flags: // // --db BadgerDB directory (default: ./chaindata) // --listen libp2p listen multiaddr (default: /ip4/0.0.0.0/tcp/4001) // --announce comma-separated multiaddrs to advertise to peers // Required for internet deployment (VPS, Docker with fixed IP). // Example: /ip4/1.2.3.4/tcp/4001 // Without this flag libp2p tries UPnP/NAT-PMP auto-detection. // --peers comma-separated bootstrap peer multiaddrs // --validators comma-separated validator pub keys // --genesis create genesis block on first start // --key path to identity JSON file (default: ./node.json) // --stats-addr HTTP stats server address (default: :8080) // --wallet path to payout wallet JSON (optional) // --wallet-pass passphrase for wallet file (optional) // --heartbeat enable periodic heartbeats (default: true) package main import ( "context" "encoding/json" "flag" "fmt" "log" "log/slog" "net/http" "os" "os/signal" "path/filepath" "strings" "sync" "syscall" "time" libp2ppeer "github.com/libp2p/go-libp2p/core/peer" "github.com/multiformats/go-multiaddr" "go-blockchain/blockchain" "go-blockchain/consensus" "go-blockchain/economy" "go-blockchain/identity" "go-blockchain/node" "go-blockchain/node/version" "go-blockchain/p2p" "go-blockchain/relay" "go-blockchain/vm" "go-blockchain/wallet" ) const heartbeatFeeUT = 50_000 // 0.05 T // expectedGenesisHash is populated from the --join seed's /api/network-info // response. After sync completes we verify our local block 0 hashes to the // same value; mismatch aborts the node (unless --allow-genesis-mismatch). // Empty string means no --join: skip verification. var expectedGenesisHash string func main() { // Every flag below also reads a DCHAIN_* env var fallback (see envOr / // envBoolOr / envUint64Or). Flag on CLI wins; env on container deploys; // hard-coded default as last resort. Lets docker-compose drive the node // from an env_file without bake-in CLI strings. dbPath := flag.String("db", envOr("DCHAIN_DB", "./chaindata"), "BadgerDB directory (env: DCHAIN_DB)") listenAddr := flag.String("listen", envOr("DCHAIN_LISTEN", "/ip4/0.0.0.0/tcp/4001"), "libp2p listen multiaddr (env: DCHAIN_LISTEN)") announceFlag := flag.String("announce", envOr("DCHAIN_ANNOUNCE", ""), "comma-separated multiaddrs to advertise to peers (env: DCHAIN_ANNOUNCE)") peersFlag := flag.String("peers", envOr("DCHAIN_PEERS", ""), "comma-separated bootstrap peer multiaddrs (env: DCHAIN_PEERS)") validators := flag.String("validators", envOr("DCHAIN_VALIDATORS", ""), "comma-separated validator pub keys (env: DCHAIN_VALIDATORS)") genesisFlag := flag.Bool("genesis", envBoolOr("DCHAIN_GENESIS", false), "create genesis block (env: DCHAIN_GENESIS)") keyFile := flag.String("key", envOr("DCHAIN_KEY", "./node.json"), "path to identity JSON file (env: DCHAIN_KEY)") relayKeyFile := flag.String("relay-key", envOr("DCHAIN_RELAY_KEY", "./relay.json"), "path to relay X25519 keypair JSON (env: DCHAIN_RELAY_KEY)") statsAddr := flag.String("stats-addr", envOr("DCHAIN_STATS_ADDR", ":8080"), "HTTP stats server address (env: DCHAIN_STATS_ADDR)") walletFile := flag.String("wallet", envOr("DCHAIN_WALLET", ""), "payout wallet JSON (env: DCHAIN_WALLET)") walletPass := flag.String("wallet-pass", envOr("DCHAIN_WALLET_PASS", ""), "passphrase for wallet file (env: DCHAIN_WALLET_PASS)") heartbeatEnabled := flag.Bool("heartbeat", envBoolOr("DCHAIN_HEARTBEAT", true), "enable periodic heartbeat transactions (env: DCHAIN_HEARTBEAT)") registerRelay := flag.Bool("register-relay", envBoolOr("DCHAIN_REGISTER_RELAY", false), "submit REGISTER_RELAY tx on startup (env: DCHAIN_REGISTER_RELAY)") relayFee := flag.Uint64("relay-fee", envUint64Or("DCHAIN_RELAY_FEE", 1_000), "relay fee per message in µT (env: DCHAIN_RELAY_FEE)") mailboxDB := flag.String("mailbox-db", envOr("DCHAIN_MAILBOX_DB", "./mailboxdata"), "BadgerDB directory for relay mailbox (env: DCHAIN_MAILBOX_DB)") govContractID := flag.String("governance-contract", envOr("DCHAIN_GOVERNANCE_CONTRACT", ""), "governance contract ID for dynamic chain parameters (env: DCHAIN_GOVERNANCE_CONTRACT)") joinSeedURL := flag.String("join", envOr("DCHAIN_JOIN", ""), "bootstrap from a running node: comma-separated HTTP URLs (env: DCHAIN_JOIN)") // Observer mode: the node participates in the P2P network, applies // gossiped blocks, serves HTTP/WS, and forwards submitted txs — but // never proposes blocks or votes in PBFT. Use this for read-heavy // deployments: put N observers behind a load balancer so clients can // hammer them without hitting validators directly. Observers also // never need to be in the validator set. observerMode := flag.Bool("observer", envBoolOr("DCHAIN_OBSERVER", false), "observer-only mode: apply blocks + serve HTTP but don't propose or vote (env: DCHAIN_OBSERVER)") // Log format: `text` (default, human-readable) or `json` (production, // machine-parsable for Loki/ELK). Applies to both the global slog // handler and Go's std log package — existing log.Printf calls are // rerouted through slog so old and new log sites share a format. logFormat := flag.String("log-format", envOr("DCHAIN_LOG_FORMAT", "text"), "log format: `text` or `json` (env: DCHAIN_LOG_FORMAT)") // Access control for the HTTP/WS API. Empty token = fully public node // (default). Non-empty token gates /api/tx (and WS submit_tx) behind // Authorization: Bearer . Add --api-private to also gate every // read endpoint — useful for personal nodes where the operator // considers chat metadata private. See node/api_guards.go. apiToken := flag.String("api-token", envOr("DCHAIN_API_TOKEN", ""), "Bearer token required to submit transactions (env: DCHAIN_API_TOKEN). Empty = public node") apiPrivate := flag.Bool("api-private", envBoolOr("DCHAIN_API_PRIVATE", false), "also require the access token on READ endpoints (env: DCHAIN_API_PRIVATE)") updateSrcURL := flag.String("update-source-url", envOr("DCHAIN_UPDATE_SOURCE_URL", ""), "Gitea /api/v1/repos/{owner}/{repo}/releases/latest URL for /api/update-check (env: DCHAIN_UPDATE_SOURCE_URL)") updateSrcToken := flag.String("update-source-token", envOr("DCHAIN_UPDATE_SOURCE_TOKEN", ""), "optional Gitea PAT used when polling a private repo (env: DCHAIN_UPDATE_SOURCE_TOKEN)") disableUI := flag.Bool("disable-ui", envBoolOr("DCHAIN_DISABLE_UI", false), "do not register HTML block-explorer pages — JSON API and Swagger still work (env: DCHAIN_DISABLE_UI)") disableSwagger := flag.Bool("disable-swagger", envBoolOr("DCHAIN_DISABLE_SWAGGER", false), "do not register /swagger* endpoints (env: DCHAIN_DISABLE_SWAGGER)") // When --join is set, the seed's genesis_hash becomes the expected value // for our own block 0. After sync completes we compare; on mismatch we // refuse to start (fail loud) — otherwise a malicious or misconfigured // seed could silently trick us onto a parallel chain. Use --allow-genesis-mismatch // only for intentional migrations (e.g. importing data from another chain // into this network) — very dangerous. allowGenesisMismatch := flag.Bool("allow-genesis-mismatch", false, "skip the safety check that aborts when the local genesis hash differs from the seed's. Use only for explicit chain migration.") showVersion := flag.Bool("version", false, "print version info and exit") flag.Parse() if *showVersion { // Print one-line version summary then exit. Used by update.sh smoke // test and operators running `node --version`. fmt.Println(version.String()) return } // Configure structured logging. Must run before any log.Printf call // so subsequent logs inherit the format. setupLogging(*logFormat) // Wire API access-control. A non-empty token gates writes; adding // --api-private also gates reads. Logged up-front so the operator // sees what mode they're in. node.SetAPIAccess(*apiToken, *apiPrivate) node.SetUpdateSource(*updateSrcURL, *updateSrcToken) switch { case *apiToken == "": log.Printf("[NODE] API access: public (no token set)") case *apiPrivate: log.Printf("[NODE] API access: fully private (token required on all endpoints)") default: log.Printf("[NODE] API access: public reads, token-gated writes") } // --- Zero-config onboarding: --join fetches a seed node's network info // and fills in --peers / --validators from it, so operators adding a new // node to an existing network don't have to hunt down multiaddrs or // validator pubkeys by hand. // // Multi-seed: --join accepts a comma-separated list. We try each URL in // order until one succeeds. This way a dead seed doesn't orphan a new // node — just list a couple backups. // // Persistence: on first successful --join we write /seeds.json // with the tried URL list + live peer multiaddrs. On subsequent // restarts (no --join passed) we read this file and retry seeds // automatically, so the operator doesn't have to repeat the CLI flag // on every container restart. seedFile := filepath.Join(*dbPath, "seeds.json") seedURLs := parseSeedList(*joinSeedURL) if len(seedURLs) == 0 { // Fall back to persisted list from last run, if any. if persisted, err := loadSeedsFile(seedFile); err == nil && len(persisted.URLs) > 0 { seedURLs = persisted.URLs log.Printf("[NODE] loaded %d seed URL(s) from %s", len(seedURLs), seedFile) } } if len(seedURLs) > 0 { info, usedURL := tryJoinSeeds(seedURLs) if info == nil { log.Fatalf("[NODE] --join: all %d seeds failed. Check URLs and network.", len(seedURLs)) } log.Printf("[NODE] --join: bootstrapped from %s", usedURL) if *peersFlag == "" && len(info.Peers) > 0 { var addrs []string for _, p := range info.Peers { for _, a := range p.Addrs { if a != "" { addrs = append(addrs, a) break } } } *peersFlag = strings.Join(addrs, ",") log.Printf("[NODE] --join: learned %d peer multiaddrs from seed", len(addrs)) } if *validators == "" && len(info.Validators) > 0 { *validators = strings.Join(info.Validators, ",") log.Printf("[NODE] --join: learned %d validators from seed", len(info.Validators)) } if *govContractID == "" { if gov, ok := info.Contracts["governance"]; ok && gov.ContractID != "" { *govContractID = gov.ContractID log.Printf("[NODE] --join: governance contract = %s", gov.ContractID) } } if info.ChainID != "" { log.Printf("[NODE] --join: connecting to chain %s (tip=%d)", info.ChainID, info.TipHeight) } if info.GenesisHash != "" { expectedGenesisHash = info.GenesisHash log.Printf("[NODE] --join: expected genesis hash = %s", info.GenesisHash) } // Persist the successful URL first, then others, so retries pick // the known-good one first. Include the current peer list so the // next cold boot can bypass the HTTP layer entirely if any peer // is still up. var orderedURLs []string orderedURLs = append(orderedURLs, usedURL) for _, u := range seedURLs { if u != usedURL { orderedURLs = append(orderedURLs, u) } } if err := saveSeedsFile(seedFile, &persistedSeeds{ ChainID: info.ChainID, GenesisHash: info.GenesisHash, URLs: orderedURLs, Peers: *peersFlag, SavedAt: time.Now().UTC().Format(time.RFC3339), }); err != nil { log.Printf("[NODE] warn: could not persist seeds to %s: %v", seedFile, err) } } // --- Identity --- id := loadOrCreateIdentity(*keyFile) log.Printf("[NODE] pub_key: %s", id.PubKeyHex()) log.Printf("[NODE] address: %s", wallet.PubKeyToAddress(id.PubKeyHex())) // --- Optional payout wallet --- var payoutWallet *wallet.Wallet if *walletFile != "" { w, err := wallet.Load(*walletFile, *walletPass) if err != nil { log.Fatalf("[NODE] load wallet: %v", err) } payoutWallet = w log.Printf("[NODE] payout wallet: %s", w.Short()) } // --- Validator set --- var valSet []string if *validators != "" { for _, v := range strings.Split(*validators, ",") { if v = strings.TrimSpace(v); v != "" { valSet = append(valSet, v) } } } if len(valSet) == 0 { valSet = []string{id.PubKeyHex()} } log.Printf("[NODE] validator set (%d): %v", len(valSet), shortKeys(valSet)) // --- Chain --- chain, err := blockchain.NewChain(*dbPath) if err != nil { log.Fatalf("[NODE] open chain: %v", err) } defer chain.Close() log.Printf("[NODE] chain height: %d tip: %s", tipIndex(chain), tipHash(chain)) ctx, cancel := context.WithCancel(context.Background()) defer cancel() // One-time catch-up GC: reclaims any garbage that accumulated before // this version (which introduced the background GC loop). Nodes with // clean DBs finish in milliseconds; nodes upgraded from pre-GC builds // may take a minute or two to shrink their value log from multi-GB // back to actual live size. Safe to skip — the background loop will // eventually reclaim the same space — but doing it once up front // makes the upgrade visibly free disk. go func() { log.Printf("[NODE] running startup value-log compaction…") chain.CompactNow() log.Printf("[NODE] startup compaction done") }() // Background value-log garbage collector for the chain DB. // Without this, every overwrite of a hot key (`height`, `netstats`) leaves // the previous bytes pinned in BadgerDB's value log forever — the DB ends // up multiple gigabytes on disk even when live state is megabytes. Runs // every 5 minutes; stops automatically when ctx is cancelled. chain.StartValueLogGC(ctx) // Genesis-hash sanity check for --join'd nodes. Run in background so // startup isn't blocked while waiting for sync to complete on first // launch, but fatal-error the process if the local genesis eventually // diverges from what the seed reported. if expectedGenesisHash != "" { go startGenesisVerifier(ctx, chain, expectedGenesisHash, *allowGenesisMismatch) } // --- WASM VM --- contractVM := vm.NewVM(ctx) defer contractVM.Close(ctx) chain.SetVM(contractVM) // --- Native system contracts --- // Register built-in Go contracts so they're callable via CALL_CONTRACT // with the same on-chain semantics as WASM contracts, but without the // WASM VM in the hot path. The username registry lives here because // it's the most latency-sensitive service (every chat/contact-add // does a resolve/reverseResolve). chain.RegisterNative(blockchain.NewUsernameRegistry()) log.Printf("[NODE] registered native contract: %s", blockchain.UsernameRegistryID) // --- Governance contract (optional) --- if *govContractID != "" { chain.SetGovernanceContract(*govContractID) } // --- SSE event hub --- sseHub := node.NewSSEHub() // --- WebSocket gateway --- // Same event sources as SSE but pushed over a persistent ws:// connection // so mobile clients don't have to poll. Hello frame carries chain_id + // tip_height so the client can verify it's on the right chain. wsHub := node.NewWSHub( func() string { if g, err := chain.GetBlock(0); err == nil && g != nil { return "dchain-" + g.HashHex()[:12] } return "dchain-unknown" }, chain.TipIndex, ) // Event bus: one emit site per event; consumers (SSE, WS, future // indexers) are registered once below. Eliminates the duplicated // `go sseHub.EmitX / go wsHub.EmitX` pattern at every commit callsite. eventBus := node.NewEventBus() eventBus.Register(node.WrapSSE(sseHub)) eventBus.Register(node.WrapWS(wsHub)) // submit_tx handler is wired after `engine` and `h` are constructed — // see further down where `wsHub.SetSubmitTxHandler(...)` is called. // Auth binding: Ed25519 pubkey → X25519 pubkey from the identity registry. // Lets the hub enforce `inbox:` subscriptions so a client can // only listen to their OWN inbox, not anyone else's. wsHub.SetX25519ForPub(func(edHex string) (string, error) { info, err := chain.IdentityInfo(edHex) if err != nil || info == nil { return "", err } return info.X25519Pub, nil }) // Live peer-count gauge is registered below (after `h` is constructed). // --- Stats tracker --- stats := node.NewTracker() // --- Announce addresses (internet deployment) --- // Parse --announce flag into []multiaddr.Multiaddr. // When set, only these addresses are advertised to peers — libp2p's // auto-detected addresses (internal interfaces, loopback) are suppressed. // This is essential when running on a VPS or inside Docker with a fixed IP: // --announce /ip4/203.0.113.10/tcp/4001 var announceAddrs []multiaddr.Multiaddr if *announceFlag != "" { for _, s := range strings.Split(*announceFlag, ",") { s = strings.TrimSpace(s) if s == "" { continue } ma, err := multiaddr.NewMultiaddr(s) if err != nil { log.Fatalf("[NODE] bad --announce addr %q: %v", s, err) } announceAddrs = append(announceAddrs, ma) log.Printf("[NODE] announce addr: %s", s) } } // --- P2P --- h, err := p2p.NewHost(ctx, id, *listenAddr, announceAddrs) if err != nil { log.Fatalf("[NODE] p2p: %v", err) } defer h.Close() // Start peer-version gossip. Best-effort: errors here are logged but do // not block node startup — the version map is purely advisory. if err := h.StartVersionGossip(ctx, node.ProtocolVersion); err != nil { log.Printf("[NODE] peer-version gossip unavailable: %v", err) } // Wire peer connect/disconnect into stats // Live peer-count gauge for Prometheus — queried at scrape time. // Registered here (not earlier) because we need `h` to exist. node.NewGaugeFunc("dchain_peer_count_live", "Live libp2p peer count (queried on scrape)", func() int64 { return int64(h.PeerCount()) }) // engine captured below after creation; use a pointer-to-pointer pattern var engineRef *consensus.Engine h.OnPeerConnected(func(pid libp2ppeer.ID) { stats.PeerConnected(pid) eng := engineRef // capture current value go syncOnConnect(ctx, h, chain, stats, pid, func(next uint64) { if eng != nil { eng.SyncSeqNum(next) } }) }) // --- Genesis --- if *genesisFlag && chain.Tip() == nil { genesis := blockchain.GenesisBlock(id.PubKeyHex(), id.PrivKey) if err := chain.AddBlock(genesis); err != nil { log.Fatalf("[NODE] add genesis: %v", err) } log.Printf("[NODE] genesis block: %s", genesis.HashHex()) } // Seed the on-chain validator set from CLI flags (idempotent). if err := chain.InitValidators(valSet); err != nil { log.Fatalf("[NODE] init validators: %v", err) } // Prefer the on-chain set if it has been extended by ADD_VALIDATOR txs. if onChain, err := chain.ValidatorSet(); err == nil && len(onChain) > 0 { valSet = onChain log.Printf("[NODE] loaded %d validators from chain state", len(valSet)) } // --- Consensus engine --- var seqNum uint64 if tip := chain.Tip(); tip != nil { seqNum = tip.Index + 1 } engine := consensus.NewEngine( id, valSet, seqNum, func(b *blockchain.Block) { // Time the AddBlock call end-to-end so we can see slow commits // (typically contract calls) in the Prometheus histogram. commitStart := time.Now() if err := chain.AddBlock(b); err != nil { log.Printf("[NODE] add block #%d: %v", b.Index, err) return } node.MetricBlockCommitSeconds.Observe(time.Since(commitStart).Seconds()) node.MetricBlocksTotal.Inc() if len(b.Transactions) > 0 { node.MetricTxsTotal.Add(uint64(len(b.Transactions))) } // Remove committed transactions from our mempool so we don't // re-propose them in the next round (non-proposing validators // keep txs in their pending list until explicitly pruned). if engineRef != nil { engineRef.PruneTxs(b.Transactions) } stats.BlocksCommitted.Add(1) // Push live events to every registered bus consumer (SSE, // WS, future indexers). Single emit per event type. go eventBus.EmitBlockWithTxs(b) go emitContractLogsViaBus(eventBus, chain, b) r := economy.ComputeBlockReward(b) log.Printf("[NODE] %s", r.Summary()) // Show balance of whoever receives the reward rewardKey := b.Validator if binding, _ := chain.WalletBinding(b.Validator); binding != "" { rewardKey = binding } bal, _ := chain.Balance(rewardKey) log.Printf("[NODE] reward target %s balance: %s", wallet.PubKeyToAddress(rewardKey), economy.FormatTokens(bal)) // Hot-reload validator set if this block changed it. for _, tx := range b.Transactions { if tx.Type == blockchain.EventAddValidator || tx.Type == blockchain.EventRemoveValidator { if newSet, err := chain.ValidatorSet(); err == nil { if eng := engineRef; eng != nil { eng.UpdateValidators(newSet) } } break } } // Gossip committed block to peers if err := h.PublishBlock(b); err != nil { log.Printf("[NODE] publish block: %v", err) } stats.BlocksGossipSent.Add(1) }, func(msg *blockchain.ConsensusMsg) { if err := h.BroadcastConsensus(msg); err != nil { log.Printf("[NODE] broadcast consensus: %v", err) } stats.ConsensusMsgsSent.Add(1) }, ) // Assign engine to the ref captured by peer-connect handler engineRef = engine // Wire the WS submit_tx handler now that `engine` and `h` exist. // Mirrors the HTTP POST /api/tx path so clients get the same // validation regardless of transport. wsHub.SetSubmitTxHandler(func(txJSON []byte) (string, error) { var tx blockchain.Transaction if err := json.Unmarshal(txJSON, &tx); err != nil { return "", fmt.Errorf("invalid tx JSON: %w", err) } if err := node.ValidateTxTimestamp(&tx); err != nil { return "", fmt.Errorf("bad timestamp: %w", err) } if err := identity.VerifyTx(&tx); err != nil { return "", fmt.Errorf("invalid signature: %w", err) } if err := engine.AddTransaction(&tx); err != nil { return "", err } if err := h.PublishTx(&tx); err != nil { // Mempool accepted — gossip failure is non-fatal for the caller. log.Printf("[NODE] ws submit_tx: gossip publish failed (mempool has it): %v", err) } return tx.ID, nil }) // Wrap consensus stats engine.OnPropose(func() { stats.BlocksProposed.Add(1) }) engine.OnVote(func() { stats.VotesCast.Add(1) }) engine.OnViewChange(func() { stats.ViewChanges.Add(1) }) // Register direct-stream consensus handler h.SetConsensusMsgHandler(func(msg *blockchain.ConsensusMsg) { stats.ConsensusMsgsRecv.Add(1) engine.HandleMessage(msg) }) // --- Sync protocol handler --- h.SetSyncHandler( func(index uint64) (*blockchain.Block, error) { return chain.GetBlock(index) }, func() uint64 { if tip := chain.Tip(); tip != nil { return tip.Index + 1 } return 0 }, ) // --- Bootstrap peers --- if *peersFlag != "" { for _, addr := range strings.Split(*peersFlag, ",") { if addr = strings.TrimSpace(addr); addr != "" { go connectWithRetry(ctx, h, addr) } } } h.Advertise(ctx) h.DiscoverPeers(ctx) // --- Incoming gossip loops --- txMsgs := h.TxMsgs(ctx) blockMsgs := h.BlockMsgs(ctx) go func() { for tx := range txMsgs { stats.TxsGossipRecv.Add(1) if err := engine.AddTransaction(tx); err != nil { log.Printf("[NODE] rejected gossip tx %s: %v", tx.ID, err) continue } // Trigger an immediate block proposal so new transactions are // committed quickly instead of waiting for the next tick. if engine.IsLeader() { go engine.Propose(chain.Tip()) } } }() // gapFillRecent tracks the last time we asked a given peer to fill in // missing blocks, so a burst of out-of-order gossips can't cause a // sync storm. One sync request per peer per minute is plenty — // SyncFromPeerFull drains whatever is needed in a single call. gapFillRecent := make(map[libp2ppeer.ID]time.Time) var gapFillMu sync.Mutex go func() { for m := range blockMsgs { b := m.Block stats.BlocksGossipRecv.Add(1) tip := chain.Tip() if tip != nil && b.Index <= tip.Index { continue } // Gap detected — we received block N but our tip is N-k for k>1. // Gossipsub won't replay the missing blocks, so we have to ask // the gossiper (or any peer) to stream them to us explicitly. // Without this the node silently falls behind forever and never // catches up, even though the rest of the network is live. if tip != nil && b.Index > tip.Index+1 { gapFillMu.Lock() last := gapFillRecent[m.From] if time.Since(last) > 60*time.Second { gapFillRecent[m.From] = time.Now() gapFillMu.Unlock() log.Printf("[NODE] gap detected: gossiped #%d, tip=#%d — requesting sync from %s", b.Index, tip.Index, m.From) eng := engineRef go syncOnConnect(ctx, h, chain, stats, m.From, func(next uint64) { if eng != nil { eng.SyncSeqNum(next) } }) } else { gapFillMu.Unlock() log.Printf("[NODE] gap detected but sync from %s throttled (cooldown); dropping #%d", m.From, b.Index) } continue } if err := chain.AddBlock(b); err != nil { log.Printf("[NODE] add gossip block #%d: %v", b.Index, err) continue } engine.SyncSeqNum(b.Index + 1) log.Printf("[NODE] applied gossip block #%d hash=%s", b.Index, b.HashHex()[:8]) go eventBus.EmitBlockWithTxs(b) } }() // --- Relay mailbox --- mailbox, err := relay.OpenMailbox(*mailboxDB) if err != nil { log.Fatalf("[NODE] relay mailbox: %v", err) } defer mailbox.Close() go mailbox.RunGC() log.Printf("[NODE] relay mailbox: %s", *mailboxDB) // Push-notify bus consumers whenever a fresh envelope lands in the // mailbox. Clients subscribed to `inbox:` (via WS) get the // event immediately so they no longer need to poll /relay/inbox. // // We send a minimal summary (no ciphertext) — the client refetches from // /relay/inbox if it needs the full envelope. Keeps WS frames small and // avoids a fat push for every message. mailbox.SetOnStore(func(env *relay.Envelope) { sum, _ := json.Marshal(map[string]any{ "id": env.ID, "recipient_pub": env.RecipientPub, "sender_pub": env.SenderPub, "sent_at": env.SentAt, }) eventBus.EmitInbox(env.RecipientPub, sum) }) // --- Relay router --- relayKP, err := relay.LoadOrCreateKeyPair(*relayKeyFile) if err != nil { log.Fatalf("[NODE] relay keypair: %v", err) } relayRouter, err := relay.NewRouter( h.LibP2PHost(), h.GossipSub(), relayKP, id, mailbox, func(envID, senderPub string, msg []byte) { senderShort := senderPub if len(senderShort) > 8 { senderShort = senderShort[:8] } log.Printf("[RELAY] delivered envelope %s from %s (%d bytes)", envID, senderShort, len(msg)) }, func(tx *blockchain.Transaction) error { if err := engine.AddTransaction(tx); err != nil { return err } return h.PublishTx(tx) }, ) if err != nil { log.Fatalf("[NODE] relay router: %v", err) } go relayRouter.Run(ctx) log.Printf("[NODE] relay pub key: %s", relayRouter.RelayPubHex()) // --- Register relay service on-chain (optional) --- // Observers can still act as relays (they forward encrypted envelopes // and serve /relay/inbox the same way validators do) — but only if // the operator explicitly asks. Default-off for observers avoids // surprise on-chain registration txs. if *registerRelay && !*observerMode { go autoRegisterRelay(ctx, id, relayKP, *relayFee, h.AddrStrings(), chain, engine, h) } else if *registerRelay && *observerMode { log.Printf("[NODE] --register-relay ignored in observer mode; run with both flags off an observer to register as relay") } // --- Validator liveness metric updater --- // Engine tracks per-validator last-seen seqNum; we scan periodically // and publish the worst offender into Prometheus so alerts can fire. go func() { t := time.NewTicker(15 * time.Second) defer t.Stop() for { select { case <-ctx.Done(): return case <-t.C: report := engine.LivenessReport() var worst uint64 for _, missed := range report { if missed > worst { worst = missed } } node.MetricMaxMissedBlocks.Set(int64(worst)) } } }() // --- Periodic heartbeat transaction --- // Observers don't heartbeat — they're not validators, their uptime // doesn't affect consensus, and heartbeat is an on-chain fee-paying // tx that would waste tokens. if *heartbeatEnabled && !*observerMode { go heartbeatLoop(ctx, id, chain, h, stats) } else if *observerMode { log.Printf("[NODE] heartbeat disabled (observer mode)") } else { log.Printf("[NODE] heartbeat disabled by config") } // --- Bind wallet at startup if provided and not yet bound --- if payoutWallet != nil { go autoBindWallet(ctx, id, payoutWallet, chain, engine) } // --- Block production loop (leader only) --- // // Two tiers: // fastTicker (500 ms) — proposes only when the mempool is non-empty. // Keeps tx latency low without flooding empty blocks. // idleTicker (5 s) — heartbeat: proposes even if the mempool is empty // so the genesis block is produced quickly on startup // and the chain height advances so peers can sync. // Observer mode skips the producer entirely — they never propose or // vote. They still receive blocks via gossipsub and forward txs, so // this keeps them light-weight + certain not to participate in // consensus even if erroneously listed as a validator. if *observerMode { log.Printf("[NODE] observer mode: block producer disabled") } go func() { if *observerMode { return } fastTicker := time.NewTicker(500 * time.Millisecond) idleTicker := time.NewTicker(5 * time.Second) // heartbeatTicker: independent of tip-reads and mempool. Its sole job // is to prove this producer goroutine is actually alive so we can // tell "chain frozen because consensus halted" apart from "chain // frozen because producer deadlocked on c.mu". Logs at most once per // minute; should appear continuously on any live node. lifeTicker := time.NewTicker(60 * time.Second) defer fastTicker.Stop() defer idleTicker.Stop() defer lifeTicker.Stop() lastProposedHeight := uint64(0) for { select { case <-ctx.Done(): return case <-fastTicker.C: if engine.IsLeader() && engine.HasPendingTxs() { tip := chain.Tip() engine.Propose(tip) if tip != nil { lastProposedHeight = tip.Index } } case <-idleTicker.C: if engine.IsLeader() { tip := chain.Tip() engine.Propose(tip) // heartbeat block, may be empty if tip != nil { lastProposedHeight = tip.Index } } case <-lifeTicker.C: // Proof of life + last proposal height so we can see from // logs whether the loop is running AND reaching the chain. log.Printf("[NODE] producer alive (leader=%v lastProposed=%d tip=%d)", engine.IsLeader(), lastProposedHeight, chain.TipIndex()) } } }() // --- Stats HTTP server --- statsQuery := node.QueryFunc{ PubKey: func() string { return id.PubKeyHex() }, PeerID: func() string { return h.PeerID() }, PeersCount: func() int { return h.PeerCount() }, ChainTip: func() *blockchain.Block { return chain.Tip() }, Balance: func(pk string) uint64 { b, _ := chain.Balance(pk) return b }, WalletBinding: func(pk string) string { binding, _ := chain.WalletBinding(pk) if binding == "" { return "" } return wallet.PubKeyToAddress(binding) }, Reputation: func(pk string) blockchain.RepStats { r, _ := chain.Reputation(pk) return r }, } explorerQuery := node.ExplorerQuery{ GetBlock: chain.GetBlock, GetTx: chain.TxByID, AddressToPubKey: chain.AddressToPubKey, Balance: func(pk string) (uint64, error) { return chain.Balance(pk) }, Reputation: func(pk string) (blockchain.RepStats, error) { return chain.Reputation(pk) }, WalletBinding: func(pk string) (string, error) { return chain.WalletBinding(pk) }, TxsByAddress: func(pk string, limit, offset int) ([]*blockchain.TxRecord, error) { return chain.TxsByAddress(pk, limit, offset) }, RecentBlocks: chain.RecentBlocks, RecentTxs: chain.RecentTxs, NetStats: chain.NetworkStats, RegisteredRelays: chain.RegisteredRelays, IdentityInfo: func(pubKeyOrAddr string) (*blockchain.IdentityInfo, error) { return chain.IdentityInfo(pubKeyOrAddr) }, ValidatorSet: chain.ValidatorSet, SubmitTx: func(tx *blockchain.Transaction) error { if err := engine.AddTransaction(tx); err != nil { return err } return h.PublishTx(tx) }, GetContract: chain.GetContract, GetContracts: chain.Contracts, GetContractState: chain.GetContractState, GetContractLogs: chain.ContractLogs, Stake: chain.Stake, GetToken: chain.Token, GetTokens: chain.Tokens, TokenBalance: chain.TokenBalance, GetNFT: chain.NFT, GetNFTs: chain.NFTs, NFTsByOwner: chain.NFTsByOwner, GetChannel: chain.Channel, GetChannelMembers: chain.ChannelMembers, Events: sseHub, WS: wsHub, // Onboarding: expose libp2p peers + chain_id so new nodes/clients can // fetch /api/network-info to bootstrap without static configuration. ConnectedPeers: func() []node.ConnectedPeerRef { if h == nil { return nil } src := h.ConnectedPeers() // Pull peer-version map once per call so the N peers share a // consistent snapshot rather than a racing one. versions := h.PeerVersions() out := make([]node.ConnectedPeerRef, len(src)) for i, p := range src { ref := node.ConnectedPeerRef{ID: p.ID, Addrs: p.Addrs} if pv, ok := versions[p.ID]; ok { ref.Version = &node.PeerVersionRef{ Tag: pv.Tag, Commit: pv.Commit, ProtocolVersion: pv.ProtocolVersion, Timestamp: pv.Timestamp, ReceivedAt: pv.ReceivedAt.Format(time.RFC3339), } } out[i] = ref } return out }, ChainID: func() string { // Derive from genesis block so every node with the same genesis // reports the same chain_id. Fall back to a static string if the // chain has no genesis yet (fresh DB, pre-sync). if g, err := chain.GetBlock(0); err == nil && g != nil { return "dchain-" + g.HashHex()[:12] } return "dchain-unknown" }, // Native contracts registered with the chain are exposed through the // well-known endpoint so clients don't have to know which ones are // native vs WASM. They use the contract_id transparently. NativeContracts: func() []node.NativeContractInfo { all := chain.NativeContracts() out := make([]node.NativeContractInfo, len(all)) for i, nc := range all { out[i] = node.NativeContractInfo{ ContractID: nc.ID(), ABIJson: nc.ABI(), } } return out }, } relayConfig := node.RelayConfig{ Mailbox: mailbox, Send: func(recipientPubHex string, msg []byte) (string, error) { return relayRouter.Send(recipientPubHex, msg, 0) }, Broadcast: func(env *relay.Envelope) error { return relayRouter.Broadcast(env) }, ContactRequests: func(pubKey string) ([]blockchain.ContactInfo, error) { return chain.ContactRequests(pubKey) }, } go func() { log.Printf("[NODE] stats API: http://0.0.0.0%s/stats", *statsAddr) if *disableUI { log.Printf("[NODE] explorer UI: disabled (--disable-ui)") } else { log.Printf("[NODE] explorer UI: http://0.0.0.0%s/", *statsAddr) } if *disableSwagger { log.Printf("[NODE] swagger: disabled (--disable-swagger)") } else { log.Printf("[NODE] swagger: http://0.0.0.0%s/swagger", *statsAddr) } log.Printf("[NODE] relay inbox: http://0.0.0.0%s/relay/inbox?pub=", *statsAddr) routeFlags := node.ExplorerRouteFlags{ DisableUI: *disableUI, DisableSwagger: *disableSwagger, } if err := stats.ListenAndServe(*statsAddr, statsQuery, func(mux *http.ServeMux) { node.RegisterExplorerRoutes(mux, explorerQuery, routeFlags) node.RegisterRelayRoutes(mux, relayConfig) // POST /api/governance/link — link deployed contracts at runtime. // Body: {"governance": ""} // Allows deploy script to wire governance without node restart. mux.HandleFunc("/api/governance/link", func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed) return } var body struct { Governance string `json:"governance"` } if err := json.NewDecoder(r.Body).Decode(&body); err != nil { http.Error(w, `{"error":"bad JSON"}`, http.StatusBadRequest) return } if body.Governance != "" { chain.SetGovernanceContract(body.Governance) } w.Header().Set("Content-Type", "application/json") fmt.Fprintf(w, `{"status":"ok","governance":%q}`, body.Governance) }) }); err != nil { log.Printf("[NODE] stats server error: %v", err) } }() log.Printf("[NODE] running — peer ID: %s", h.PeerID()) log.Printf("[NODE] addrs: %v", h.AddrStrings()) sig := make(chan os.Signal, 1) signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM) <-sig log.Println("[NODE] shutting down...") } // emitContractLogsViaBus scans b's CALL_CONTRACT transactions and emits a // contract_log event for each log entry through the event bus. Bus fans // it out to every registered consumer (SSE, WS, future indexers) — no // hub-by-hub emit calls here. func emitContractLogsViaBus(bus *node.EventBus, chain *blockchain.Chain, b *blockchain.Block) { for _, tx := range b.Transactions { if tx.Type != blockchain.EventCallContract { continue } var p blockchain.CallContractPayload if err := json.Unmarshal(tx.Payload, &p); err != nil || p.ContractID == "" { continue } // Fetch only logs from this block (limit=50 to avoid loading all history). logs, err := chain.ContractLogs(p.ContractID, 50) if err != nil { continue } for _, entry := range logs { if entry.BlockHeight == b.Index && entry.TxID == tx.ID { bus.EmitContractLog(entry) } } } } // heartbeatLoop publishes a HEARTBEAT transaction once per hour. func heartbeatLoop(ctx context.Context, id *identity.Identity, chain *blockchain.Chain, h *p2p.Host, stats *node.Tracker) { ticker := time.NewTicker(60 * time.Minute) defer ticker.Stop() for { select { case <-ctx.Done(): return case <-ticker.C: bal, err := chain.Balance(id.PubKeyHex()) if err != nil { log.Printf("[NODE] heartbeat balance check failed: %v", err) continue } if bal < heartbeatFeeUT { log.Printf("[NODE] heartbeat skipped: balance %s below required fee %s", economy.FormatTokens(bal), economy.FormatTokens(heartbeatFeeUT)) continue } var height uint64 if tip := chain.Tip(); tip != nil { height = tip.Index } payload := blockchain.HeartbeatPayload{ PubKey: id.PubKeyHex(), ChainHeight: height, PeerCount: h.PeerCount(), Version: "0.1.0", } pb, _ := json.Marshal(payload) tx := &blockchain.Transaction{ ID: fmt.Sprintf("hb-%s-%d", id.PubKeyHex()[:8], time.Now().Unix()), Type: blockchain.EventHeartbeat, From: id.PubKeyHex(), Amount: 0, Payload: pb, Fee: heartbeatFeeUT, Memo: "Heartbeat: liveness proof", Timestamp: time.Now().UTC(), } tx.Signature = id.Sign(identity.TxSignBytes(tx)) if err := h.PublishTx(tx); err != nil { log.Printf("[NODE] heartbeat publish: %v", err) } else { stats.TxsGossipSent.Add(1) log.Printf("[NODE] heartbeat sent (height=%d peers=%d)", height, h.PeerCount()) } } } } // autoBindWallet submits a BIND_WALLET tx if the node doesn't have one yet. func autoBindWallet(ctx context.Context, id *identity.Identity, w *wallet.Wallet, chain *blockchain.Chain, engine *consensus.Engine) { // Wait a bit for chain to sync time.Sleep(5 * time.Second) binding, err := chain.WalletBinding(id.PubKeyHex()) if err != nil || binding == w.ID.PubKeyHex() { return // already bound or error } payload := blockchain.BindWalletPayload{ WalletPubKey: w.ID.PubKeyHex(), WalletAddr: w.Address, } pb, _ := json.Marshal(payload) tx := &blockchain.Transaction{ ID: fmt.Sprintf("bind-%d", time.Now().UnixNano()), Type: blockchain.EventBindWallet, From: id.PubKeyHex(), To: w.ID.PubKeyHex(), Payload: pb, Fee: blockchain.MinFee, Memo: "Bind payout wallet", Timestamp: time.Now().UTC(), } tx.Signature = id.Sign(identity.TxSignBytes(tx)) if err := engine.AddTransaction(tx); err != nil { log.Printf("[NODE] BIND_WALLET tx rejected: %v", err) return } log.Printf("[NODE] queued BIND_WALLET → %s", w.Address) } // startGenesisVerifier watches the local chain and aborts the node if, once // block 0 is present, its hash differs from what the --join seed advertised. // // Lifecycle: // - Polls every 500 ms for up to 2 minutes waiting for sync to produce // block 0. On a fresh-db joiner this may take tens of seconds while // SyncFromPeerFull streams blocks. // - Once the block exists, compares HashHex() with `expected`: // match → log success, exit goroutine // mismatch → log.Fatal, killing the process (unless `allowMismatch` // is set, in which case we just warn and return) // - If the window expires without a genesis, we assume something went // wrong with peer connectivity and abort — otherwise PBFT would // helpfully produce its OWN genesis, permanently forking us from the // target network. func startGenesisVerifier(ctx context.Context, chain *blockchain.Chain, expected string, allowMismatch bool) { deadline := time.NewTimer(2 * time.Minute) defer deadline.Stop() tick := time.NewTicker(500 * time.Millisecond) defer tick.Stop() for { select { case <-ctx.Done(): return case <-deadline.C: if allowMismatch { log.Printf("[NODE] genesis verifier: no genesis after 2m — giving up (allow-genesis-mismatch set)") return } log.Fatalf("[NODE] genesis verifier: could not load block 0 within 2 minutes; seed expected hash %s. Check network connectivity to peers.", expected) case <-tick.C: g, err := chain.GetBlock(0) if err != nil || g == nil { continue // still syncing } actual := g.HashHex() if actual == expected { log.Printf("[NODE] genesis verified: hash %s matches seed", actual) return } if allowMismatch { log.Printf("[NODE] WARNING: genesis hash mismatch (local=%s seed=%s) — continuing because --allow-genesis-mismatch is set", actual, expected) return } log.Fatalf("[NODE] FATAL: genesis hash mismatch — local=%s seed=%s. Either the seed at --join is on a different chain, or your local DB was created against a different genesis. Wipe --db and retry, or pass --allow-genesis-mismatch if you know what you're doing.", actual, expected) } } } // syncOnConnect syncs chain from a newly connected peer. // engineSyncFn is called with next seqNum after a successful sync so the // consensus engine knows which block to propose/commit next. func syncOnConnect(ctx context.Context, h *p2p.Host, chain *blockchain.Chain, stats *node.Tracker, pid libp2ppeer.ID, engineSyncFn func(uint64)) { time.Sleep(500 * time.Millisecond) syncCtx, cancel := context.WithTimeout(ctx, 30*time.Second) defer cancel() var localCount uint64 if tip := chain.Tip(); tip != nil { localCount = tip.Index + 1 } n, err := h.SyncFromPeerFull(syncCtx, pid, localCount, func(b *blockchain.Block) error { if tip := chain.Tip(); tip != nil && b.Index <= tip.Index { return nil } if err := chain.AddBlock(b); err != nil { return err } log.Printf("[SYNC] applied block #%d hash=%s", b.Index, b.HashHex()[:8]) return nil }) if err != nil { log.Printf("[SYNC] sync from %s: %v", pid, err) return } if n > 0 { stats.RecordSyncFrom(pid, n) if tip := chain.Tip(); tip != nil { engineSyncFn(tip.Index + 1) log.Printf("[SYNC] synced %d blocks from %s, chain now at #%d", n, pid, tip.Index) } } else if localCount == 0 { // No new blocks but we confirmed our genesis is current — still notify engine if tip := chain.Tip(); tip != nil { engineSyncFn(tip.Index + 1) } } } // connectWithRetry dials a bootstrap peer and keeps the connection alive. // // Behaviour: // - Retries indefinitely with exponential backoff (1 s → 2 s → … → 30 s max). // - After a successful connection it re-dials every 30 s. // libp2p.Connect is idempotent — if the peer is still connected the call // returns immediately. If it dropped, this transparently reconnects it. // - Stops only when ctx is cancelled (node shutdown). // // This ensures that even if a bootstrap peer restarts or is temporarily // unreachable the node will reconnect without manual intervention. func connectWithRetry(ctx context.Context, h *p2p.Host, addr string) { const ( minBackoff = 1 * time.Second maxBackoff = 30 * time.Second keepAlive = 30 * time.Second ) backoff := minBackoff for { select { case <-ctx.Done(): return default: } if err := h.Connect(ctx, addr); err != nil { log.Printf("[P2P] bootstrap %s unavailable: %v (retry in %s)", addr, err, backoff) select { case <-ctx.Done(): return case <-time.After(backoff): } if backoff < maxBackoff { backoff *= 2 if backoff > maxBackoff { backoff = maxBackoff } } continue } // Connected (or already connected). Log only on first connect or reconnect. if backoff > minBackoff { log.Printf("[P2P] reconnected to bootstrap %s", addr) } else { log.Printf("[P2P] connected to bootstrap %s", addr) } backoff = minBackoff // reset for next reconnection cycle // Keep-alive: re-check every 30 s so we detect and repair drops. select { case <-ctx.Done(): return case <-time.After(keepAlive): } } } // --- helpers --- type keyJSON struct { PubKey string `json:"pub_key"` PrivKey string `json:"priv_key"` X25519Pub string `json:"x25519_pub,omitempty"` X25519Priv string `json:"x25519_priv,omitempty"` } func loadOrCreateIdentity(keyFile string) *identity.Identity { if data, err := os.ReadFile(keyFile); err == nil { var kj keyJSON if err := json.Unmarshal(data, &kj); err == nil { if id, err := identity.FromHexFull(kj.PubKey, kj.PrivKey, kj.X25519Pub, kj.X25519Priv); err == nil { // If the file is missing X25519 keys, backfill and re-save. if kj.X25519Pub == "" { kj.X25519Pub = id.X25519PubHex() kj.X25519Priv = id.X25519PrivHex() if out, err2 := json.MarshalIndent(kj, "", " "); err2 == nil { _ = os.WriteFile(keyFile, out, 0600) } } log.Printf("[NODE] loaded identity from %s", keyFile) return id } } } id, err := identity.Generate() if err != nil { log.Fatalf("generate identity: %v", err) } kj := keyJSON{ PubKey: id.PubKeyHex(), PrivKey: id.PrivKeyHex(), X25519Pub: id.X25519PubHex(), X25519Priv: id.X25519PrivHex(), } data, _ := json.MarshalIndent(kj, "", " ") if err := os.WriteFile(keyFile, data, 0600); err != nil { log.Printf("[NODE] warning: could not save key: %v", err) } else { log.Printf("[NODE] new identity saved to %s", keyFile) } return id } func tipIndex(c *blockchain.Chain) uint64 { if tip := c.Tip(); tip != nil { return tip.Index } return 0 } func tipHash(c *blockchain.Chain) string { if tip := c.Tip(); tip != nil { return tip.HashHex()[:12] } return "(empty)" } // autoRegisterRelay submits a REGISTER_RELAY tx and retries until the registration // is confirmed on-chain. This handles the common case where the initial gossip has // no peers (node2 hasn't started yet) or the leader hasn't included the tx yet. func autoRegisterRelay( ctx context.Context, id *identity.Identity, relayKP *relay.KeyPair, feePerMsgUT uint64, addrs []string, chain *blockchain.Chain, engine *consensus.Engine, h *p2p.Host, ) { var multiaddr string if len(addrs) > 0 { multiaddr = addrs[0] } payload := blockchain.RegisterRelayPayload{ X25519PubKey: relayKP.PubHex(), FeePerMsgUT: feePerMsgUT, Multiaddr: multiaddr, } pb, _ := json.Marshal(payload) ticker := time.NewTicker(10 * time.Second) defer ticker.Stop() // First attempt after 5 seconds; subsequent retries every 10 seconds. select { case <-ctx.Done(): return case <-time.After(5 * time.Second): } for { // Stop if already confirmed on-chain. if relays, err := chain.RegisteredRelays(); err == nil { for _, r := range relays { if r.PubKey == id.PubKeyHex() && r.Relay.X25519PubKey == relayKP.PubHex() { log.Printf("[NODE] relay registration confirmed on-chain (x25519=%s)", relayKP.PubHex()[:16]) return } } } // Build and sign a fresh tx each attempt (unique ID, fresh timestamp). now := time.Now().UTC() tx := &blockchain.Transaction{ ID: fmt.Sprintf("relay-reg-%d", now.UnixNano()), Type: blockchain.EventRegisterRelay, From: id.PubKeyHex(), // Pay the standard MinFee. The original code set this to 0 with the // note "REGISTER_RELAY is free"; that's incompatible with validateTx // in consensus/pbft.go which rejects any tx below MinFee, causing // the auto-register retry loop to spam the logs forever without // ever getting the tx into the mempool. Fee: blockchain.MinFee, Payload: pb, Memo: "Register relay service", Timestamp: now, } tx.Signature = id.Sign(identity.TxSignBytes(tx)) if err := engine.AddTransaction(tx); err != nil { log.Printf("[NODE] REGISTER_RELAY tx rejected: %v", err) } else if err := h.PublishTx(tx); err != nil { log.Printf("[NODE] REGISTER_RELAY publish failed: %v", err) } else { log.Printf("[NODE] submitted REGISTER_RELAY (x25519=%s fee=%dµT), waiting for commit...", relayKP.PubHex()[:16], feePerMsgUT) } select { case <-ctx.Done(): return case <-ticker.C: } } } func shortKeys(keys []string) []string { out := make([]string, len(keys)) for i, k := range keys { if len(k) > 8 { out[i] = k[:8] + "…" } else { out[i] = k } } return out } // setupLogging initialises the slog handler and routes Go's std log through // it so existing log.Printf calls get the chosen format without a // file-by-file migration. // // "text" (default) is handler-default human-readable format, same as bare // log.Printf. "json" emits one JSON object per line with `time/level/msg` // + any key=value attrs — what Loki/ELK ingest natively. func setupLogging(format string) { var handler slog.Handler switch strings.ToLower(format) { case "json": handler = slog.NewJSONHandler(os.Stderr, &slog.HandlerOptions{ Level: slog.LevelInfo, }) default: handler = slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{ Level: slog.LevelInfo, }) } slog.SetDefault(slog.New(handler)) // Reroute Go's standard log package through slog so `log.Printf("…")` // calls elsewhere in the codebase land in the same stream/format. log.SetFlags(0) log.SetOutput(slogWriter{slog.Default()}) } // slogWriter adapts an io.Writer-style byte stream onto slog. Each line // becomes one Info-level record. The whole line is stored in `msg` — we // don't try to parse out attrs; that can happen at callsite by switching // from log.Printf to slog.Info. type slogWriter struct{ l *slog.Logger } func (w slogWriter) Write(p []byte) (int, error) { msg := strings.TrimRight(string(p), "\r\n") w.l.Info(msg) return len(p), nil } // ─── Env-var helpers for flag defaults ────────────────────────────────────── // Pattern: `flag.String(name, envOr("DCHAIN_X", default), help)` lets the // same binary be driven by either CLI flags (dev) or env-vars (Docker) with // CLI winning. Flag.Parse() is called once; by that time os.Getenv has // already populated the fallback via these helpers. func envOr(key, def string) string { if v := os.Getenv(key); v != "" { return v } return def } func envBoolOr(key string, def bool) bool { v := strings.ToLower(strings.TrimSpace(os.Getenv(key))) switch v { case "": return def case "1", "true", "yes", "on": return true case "0", "false", "no", "off": return false default: log.Printf("[NODE] warn: env %s=%q is not a valid bool, using default %v", key, v, def) return def } } func envUint64Or(key string, def uint64) uint64 { v := os.Getenv(key) if v == "" { return def } var out uint64 if _, err := fmt.Sscanf(v, "%d", &out); err != nil { log.Printf("[NODE] warn: env %s=%q is not a valid uint64, using default %d", key, v, def) return def } return out } // ─── Multi-seed --join support ────────────────────────────────────────────── // persistedSeeds is the on-disk shape of /seeds.json. Kept minimal so // format churn doesn't break old files; unknown fields are ignored on read. type persistedSeeds struct { ChainID string `json:"chain_id,omitempty"` GenesisHash string `json:"genesis_hash,omitempty"` URLs []string `json:"urls"` Peers string `json:"peers,omitempty"` // comma-separated multiaddrs SavedAt string `json:"saved_at,omitempty"` } // parseSeedList splits a comma-separated --join value into trimmed, non-empty // URL entries. Preserves order so the first is tried first. func parseSeedList(raw string) []string { if raw == "" { return nil } var out []string for _, s := range strings.Split(raw, ",") { if t := strings.TrimSpace(s); t != "" { out = append(out, t) } } return out } // tryJoinSeeds walks the list and returns the first seedNetworkInfo that // responded. The matching URL is returned alongside so the caller can // persist it as the known-good at the head of the list. func tryJoinSeeds(urls []string) (*seedNetworkInfo, string) { for _, u := range urls { info, err := fetchNetworkInfo(u) if err != nil { log.Printf("[NODE] --join: %s failed: %v (trying next)", u, err) continue } return info, u } return nil, "" } func loadSeedsFile(path string) (*persistedSeeds, error) { b, err := os.ReadFile(path) if err != nil { return nil, err } var p persistedSeeds if err := json.Unmarshal(b, &p); err != nil { return nil, err } return &p, nil } func saveSeedsFile(path string, p *persistedSeeds) error { if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { return err } b, err := json.MarshalIndent(p, "", " ") if err != nil { return err } return os.WriteFile(path, b, 0o600) // 0600: may contain private IPs of peers } // seedNetworkInfo is a minimal subset of /api/network-info needed for // bootstrap. We intentionally DO NOT import the node package's response // types here to keep the struct stable across schema evolution; missing // fields simply unmarshal to zero values. type seedNetworkInfo struct { ChainID string `json:"chain_id"` GenesisHash string `json:"genesis_hash"` TipHeight uint64 `json:"tip_height"` Validators []string `json:"validators"` Peers []struct { ID string `json:"id"` Addrs []string `json:"addrs"` } `json:"peers"` Contracts map[string]struct { ContractID string `json:"contract_id"` Name string `json:"name"` Version string `json:"version"` } `json:"contracts"` } // fetchNetworkInfo performs a GET /api/network-info on the given HTTP base // URL (e.g. "http://seed.example:8080") and returns a parsed seedNetworkInfo. // Times out after 10s so a misconfigured --join URL fails fast instead of // hanging node startup. func fetchNetworkInfo(baseURL string) (*seedNetworkInfo, error) { baseURL = strings.TrimRight(baseURL, "/") client := &http.Client{Timeout: 10 * time.Second} resp, err := client.Get(baseURL + "/api/network-info") if err != nil { return nil, fmt.Errorf("fetch: %w", err) } defer resp.Body.Close() if resp.StatusCode != 200 { return nil, fmt.Errorf("status %d", resp.StatusCode) } var info seedNetworkInfo if err := json.NewDecoder(resp.Body).Decode(&info); err != nil { return nil, fmt.Errorf("decode: %w", err) } return &info, nil }