Files
dchain/blockchain/chain.go
vsecoder 7e7393e4f8 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
2026-04-17 14:16:44 +03:00

2590 lines
88 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package blockchain
import (
"context"
"crypto/ed25519"
"crypto/sha256"
"encoding/base64"
"encoding/binary"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"log"
"strings"
"sync"
"time"
badger "github.com/dgraph-io/badger/v4"
)
// RelayHeartbeatTTL is how long a relay registration stays "live" without a
// refresh. Clients pick from the live list in /api/relays; anything with
// last-heartbeat older than this is omitted.
//
// Set to 2 hours so a validator that heartbeats hourly (the default
// heartbeatLoop interval) can miss ONE beat without being delisted —
// tolerating a brief restart or network glitch.
const RelayHeartbeatTTL int64 = 2 * 3600 // seconds
// ErrTxFailed is a sentinel wrapped around any business-logic rejection inside
// applyTx (bad fee, insufficient balance, missing fields, etc.).
// AddBlock uses errors.Is(err, ErrTxFailed) to skip the individual transaction
// rather than rejecting the entire block, preventing chain stalls caused by
// a single malformed or untimely transaction.
var ErrTxFailed = errors.New("tx failed")
// Key prefixes in BadgerDB
const (
prefixBlock = "block:" // block:<index 20-digit> → Block JSON
prefixHeight = "height" // height → uint64
prefixBalance = "balance:" // balance:<pubkey> → uint64
prefixIdentity = "id:" // id:<pubkey> → RegisterKeyPayload JSON
prefixChannel = "chan:" // chan:<channelID> → CreateChannelPayload JSON
prefixChanMember = "chan-member:" // chan-member:<channelID>:<memberPubKey> → "" (presence = member)
prefixWalletBind = "walletbind:" // walletbind:<node_pubkey> → wallet_pubkey (string)
prefixReputation = "rep:" // rep:<pubkey> → RepStats JSON
prefixPayChan = "paychan:" // paychan:<channelID> → PayChanState JSON
prefixRelay = "relay:" // relay:<node_pubkey> → RegisterRelayPayload JSON
prefixRelayHB = "relayhb:" // relayhb:<node_pubkey> → unix seconds (int64) of last HB
prefixContactIn = "contact_in:" // contact_in:<targetPub>:<requesterPub> → contactRecord JSON
prefixValidator = "validator:" // validator:<pubkey> → "" (presence = active)
prefixContract = "contract:" // contract:<contractID> → ContractRecord JSON
prefixContractState = "cstate:" // cstate:<contractID>:<key> → raw bytes
prefixContractLog = "clog:" // clog:<contractID>:<blockHeight_20d>:<seq_05d> → ContractLogEntry JSON
prefixStake = "stake:" // stake:<pubkey> → uint64 staked amount
prefixToken = "token:" // token:<tokenID> → TokenRecord JSON
prefixTokenBal = "tokbal:" // tokbal:<tokenID>:<pubkey> → uint64 token balance
prefixNFT = "nft:" // nft:<nftID> → NFTRecord JSON
prefixNFTOwner = "nftowner:" // nftowner:<owner>:<nftID> → "" (index by owner)
// prefixTxChron gives O(limit) recent-tx scans without walking empty blocks.
// Key layout: txchron:<block_index 20-digit>:<seq 04-digit> → tx_id (string).
// Writes happen in indexBlock for every non-synthetic tx.
prefixTxChron = "txchron:" // txchron:<block20d>:<seq04d> → tx_id
)
// ContractVM is the interface used by applyTx to execute WASM contracts.
// The vm package provides the concrete implementation; the interface lives here
// to avoid a circular import (vm imports blockchain/types, not blockchain/chain).
type ContractVM interface {
// Validate compiles the WASM bytes and returns an error if they are invalid.
// Called during DEPLOY_CONTRACT to reject bad modules before storing them.
Validate(ctx context.Context, wasmBytes []byte) error
// Call executes the named method of a deployed contract.
// wasmBytes is the compiled WASM; env provides host function callbacks.
// Returns gas consumed. Returns ErrOutOfGas (wrapping ErrTxFailed) on exhaustion.
Call(ctx context.Context, contractID string, wasmBytes []byte, method string, argsJSON []byte, gasLimit uint64, env VMHostEnv) (gasUsed uint64, err error)
}
// VMHostEnv is the callback interface passed to ContractVM.Call.
// Implementations are created per-transaction and wrap the live badger.Txn.
type VMHostEnv interface {
GetState(key []byte) ([]byte, error)
SetState(key, value []byte) error
GetBalance(pubKeyHex string) (uint64, error)
Transfer(from, to string, amount uint64) error
GetCaller() string
GetBlockHeight() uint64
GetContractTreasury() string
Log(msg string)
// CallContract executes a method on another deployed contract (inter-contract call).
// The caller of the sub-contract is set to the current contract's ID.
// gasLimit caps the sub-call; actual gas consumed is returned.
// Returns ErrTxFailed if the target contract is not found or the call fails.
CallContract(contractID, method string, argsJSON []byte, gasLimit uint64) (uint64, error)
}
// RepStats are stored per public key and updated as blocks are committed.
type RepStats struct {
BlocksProduced uint64 `json:"blocks_produced"`
RelayProofs uint64 `json:"relay_proofs"`
SlashCount uint64 `json:"slash_count"`
Heartbeats uint64 `json:"heartbeats"`
// Score is re-computed on every read; stored for fast API queries.
Score int64 `json:"score"`
}
// ComputeScore calculates the reputation score from raw counters.
func (r RepStats) ComputeScore() int64 {
return int64(r.BlocksProduced)*10 +
int64(r.RelayProofs)*1 +
int64(r.Heartbeats)/10 -
int64(r.SlashCount)*500
}
// Rank returns a human-readable tier string.
func (r RepStats) Rank() string {
switch s := r.ComputeScore(); {
case s >= 1000:
return "Validator"
case s >= 100:
return "Trusted"
case s >= 10:
return "Active"
default:
return "Observer"
}
}
// Chain is the canonical state machine backed by BadgerDB.
type Chain struct {
db *badger.DB
mu sync.RWMutex
tip *Block
vm ContractVM // optional; set via SetVM before processing contract txs
// govContractID and any other live-tunable config live under configMu,
// NOT c.mu. Chain-config reads happen inside applyTx (e.g.
// GetEffectiveGasPrice for CALL_CONTRACT), which runs under c.mu.Lock()
// held by AddBlock. Re-locking c.mu for read would deadlock because
// sync.RWMutex is not re-entrant on the same goroutine.
configMu sync.RWMutex
govContractID string
// native maps contract ID → in-process Go handler. Registered via
// RegisterNative once at startup (genesis or on-disk reload). When a
// CALL_CONTRACT tx references an ID in this map, the dispatcher skips
// the WASM VM entirely and calls the Go handler directly.
//
// Protected by its own mutex for the same reason as configMu above:
// lookupNative is called from applyTx under c.mu.Lock(), and we must
// not re-acquire c.mu.
native map[string]NativeContract
nativeMu sync.RWMutex
}
// SetVM wires a ContractVM implementation into the chain.
// Must be called before any DEPLOY_CONTRACT or CALL_CONTRACT transactions are processed.
func (c *Chain) SetVM(vm ContractVM) {
c.mu.Lock()
defer c.mu.Unlock()
c.vm = vm
}
// SetGovernanceContract configures the governance contract ID used for
// dynamic chain parameters (gas_price, relay_fee, etc.). Safe to call at any time.
// Uses configMu (not c.mu) so it never blocks against in-flight AddBlock.
func (c *Chain) SetGovernanceContract(id string) {
c.configMu.Lock()
defer c.configMu.Unlock()
c.govContractID = id
log.Printf("[CHAIN] governance contract linked: %s", id)
}
// GetGovParam reads a live parameter from the governance contract's state.
// Returns ("", false) if no governance contract is configured or the key is not set.
// Uses configMu so it's safe to call from within applyTx (where c.mu is held).
func (c *Chain) GetGovParam(key string) (string, bool) {
c.configMu.RLock()
id := c.govContractID
c.configMu.RUnlock()
if id == "" {
return "", false
}
var val []byte
err := c.db.View(func(txn *badger.Txn) error {
dbKey := []byte(prefixContractState + id + ":param:" + key)
item, err := txn.Get(dbKey)
if err != nil {
return err
}
return item.Value(func(v []byte) error {
val = make([]byte, len(v))
copy(val, v)
return nil
})
})
if err != nil {
return "", false
}
return string(val), true
}
// GetEffectiveGasPrice returns the current gas price in µT per gas unit.
// If a governance contract is configured and has set gas_price, that value is used.
// Otherwise falls back to the DefaultGasPrice constant.
func (c *Chain) GetEffectiveGasPrice() uint64 {
if val, ok := c.GetGovParam("gas_price"); ok {
var p uint64
if _, err := fmt.Sscanf(val, "%d", &p); err == nil && p > 0 {
return p
}
}
return GasPrice
}
// NewChain opens (or creates) the BadgerDB at dbPath and returns a Chain.
//
// Storage tuning rationale:
//
// - `WithValueLogFileSize(64 MiB)` — default is 1 GiB, which means every
// value-log file reserves a full gigabyte on disk even when nearly
// empty. On a low-traffic chain (tens of thousands of mostly-empty
// blocks) that produced multi-GB databases that would never shrink.
// 64 MiB files rotate more often so value-log GC can reclaim space.
//
// - `WithNumVersionsToKeep(1)` — we never read historical versions of a
// key; every write overwrites the previous one. Telling Badger this
// lets L0 compaction discard stale versions immediately instead of
// waiting for the versions-kept quota to fill.
//
// - `WithCompactL0OnClose(true)` — finish outstanding compaction on a
// clean shutdown so the next startup reads a tidy LSM.
//
// The caller SHOULD start the background value-log GC loop via
// Chain.StartValueLogGC(ctx) — without it, reclaimable vlog bytes are never
// actually freed and the DB grows monotonically.
func NewChain(dbPath string) (*Chain, error) {
opts := badger.DefaultOptions(dbPath).
WithLogger(nil).
WithValueLogFileSize(64 << 20). // 64 MiB per vlog (default 1 GiB)
WithNumVersionsToKeep(1). // no multi-version reads, drop old
WithCompactL0OnClose(true)
db, err := badger.Open(opts)
if err != nil {
return nil, fmt.Errorf("open badger: %w", err)
}
// Run any pending schema migrations BEFORE loadTip — migrations may
// rewrite the very keys loadTip reads. See schema_migrations.go for the
// versioning contract.
if err := runMigrations(db); err != nil {
_ = db.Close()
return nil, fmt.Errorf("schema migrations: %w", err)
}
c := &Chain{db: db}
tip, err := c.loadTip()
if err != nil {
return nil, err
}
c.tip = tip
return c, nil
}
// CompactNow runs a one-shot aggressive value-log GC and L0 compaction.
// Intended to be called at startup on nodes upgraded from a version that
// had no background GC, so accumulated garbage (potentially gigabytes) can
// be reclaimed without waiting for the periodic loop.
//
// Uses a lower discard ratio (0.25 vs 0.5 for the periodic loop) so even
// mildly-fragmented vlog files get rewritten. Capped at 64 iterations so we
// can never loop indefinitely — a 4 GiB DB at 64 MiB vlog-file-size has at
// most 64 files, so this caps at the true theoretical maximum.
func (c *Chain) CompactNow() {
const maxPasses = 64
passes := 0
start := time.Now()
for c.db.RunValueLogGC(0.25) == nil {
passes++
if passes >= maxPasses {
log.Printf("[CHAIN] CompactNow: reached pass cap (%d) after %s", maxPasses, time.Since(start))
return
}
}
if passes > 0 {
log.Printf("[CHAIN] CompactNow: reclaimed %d vlog file(s) in %s", passes, time.Since(start))
}
}
// StartValueLogGC runs Badger's value-log garbage collector in a background
// goroutine for the lifetime of ctx.
//
// Without this the chain DB grows monotonically: every overwrite of a
// small hot key like `height` or `netstats` leaves the old value pinned
// in the active value-log file until GC reclaims it. After enough block
// commits a node ends up multiple GB on disk even though actual live
// chain state is a few megabytes.
//
// The loop runs every 5 minutes and drains GC cycles until Badger says
// there is nothing more worth rewriting. `0.5` is the discard ratio:
// Badger rewrites a vlog file only if at least 50% of its bytes are
// garbage, which balances I/O cost against space reclamation.
func (c *Chain) StartValueLogGC(ctx context.Context) {
go func() {
t := time.NewTicker(5 * time.Minute)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
// RunValueLogGC returns nil when it successfully rewrote
// one file; keep draining until it returns an error
// (typically badger.ErrNoRewrite).
for c.db.RunValueLogGC(0.5) == nil {
}
}
}
}()
}
// Close closes the underlying BadgerDB.
func (c *Chain) Close() error { return c.db.Close() }
// Height returns index of the latest block (0 if empty).
func (c *Chain) Height() uint64 {
c.mu.RLock()
defer c.mu.RUnlock()
if c.tip == nil {
return 0
}
return c.tip.Index
}
// Tip returns the latest block or nil if chain is empty.
func (c *Chain) Tip() *Block {
c.mu.RLock()
defer c.mu.RUnlock()
return c.tip
}
// TipIndex reads the committed tip height directly from BadgerDB, bypassing
// the chain mutex. Returns 0 if the chain is uninitialized.
//
// Use this from read-only API handlers (e.g. /api/blocks, /api/txs/recent)
// that must not hang when AddBlock is holding the write lock — for example
// during a slow contract call or an extended consensus round. A slightly
// stale height is better than a stuck explorer.
func (c *Chain) TipIndex() uint64 {
var h uint64
_ = c.db.View(func(txn *badger.Txn) error {
item, err := txn.Get([]byte(prefixHeight))
if err != nil {
return nil // 0 is a valid "empty chain" result
}
return item.Value(func(val []byte) error {
return json.Unmarshal(val, &h)
})
})
return h
}
// AddBlock validates and appends a finalized block to the chain,
// applying all state mutations atomically.
//
// Logs a warning if apply takes longer than slowApplyThreshold so we can see
// in the logs exactly which block/tx is causing the chain to stall — a slow
// CALL_CONTRACT that exhausts gas, a very large DEPLOY_CONTRACT, or genuine
// BadgerDB contention.
const slowApplyThreshold = 2 * time.Second
func (c *Chain) AddBlock(b *Block) error {
started := time.Now()
c.mu.Lock()
defer func() {
c.mu.Unlock()
if dt := time.Since(started); dt > slowApplyThreshold {
log.Printf("[CHAIN] SLOW AddBlock idx=%d txs=%d took=%s — investigate applyTx path",
b.Index, len(b.Transactions), dt)
}
}()
var prevHash []byte
if c.tip != nil {
prevHash = c.tip.Hash
} else {
if b.Index != 0 {
return errors.New("chain is empty but received non-genesis block")
}
prevHash = b.PrevHash
}
if err := b.Validate(prevHash); err != nil {
return fmt.Errorf("block validation: %w", err)
}
if err := c.db.Update(func(txn *badger.Txn) error {
// Persist block
val, err := json.Marshal(b)
if err != nil {
return err
}
if err := txn.Set([]byte(blockKey(b.Index)), val); err != nil {
return err
}
// Update height
hv, err := json.Marshal(b.Index)
if err != nil {
return err
}
if err := txn.Set([]byte(prefixHeight), hv); err != nil {
return err
}
// Apply transactions.
// Business-logic failures (ErrTxFailed) skip the individual tx so that
// a single bad transaction never causes the block — and the entire chain
// height — to stall. Infrastructure failures (DB errors) still abort.
// Only fees of SUCCESSFULLY applied txs are credited to the validator;
// skipped txs contribute nothing (avoids minting tokens from thin air).
var collectedFees uint64
gasUsedByTx := make(map[string]uint64)
seenInBlock := make(map[string]bool, len(b.Transactions))
for _, tx := range b.Transactions {
// Guard against duplicate tx IDs within the same block or already
// committed in a previous block (defense-in-depth for mempool bugs).
if seenInBlock[tx.ID] {
log.Printf("[CHAIN] block %d: duplicate tx %s in same block — skipped", b.Index, tx.ID)
continue
}
seenInBlock[tx.ID] = true
if _, err := txn.Get([]byte(prefixTxRecord + tx.ID)); err == nil {
log.Printf("[CHAIN] block %d: tx %s already committed — skipped", b.Index, tx.ID)
continue
}
gasUsed, err := c.applyTx(txn, tx)
if err != nil {
if errors.Is(err, ErrTxFailed) {
senderBal, _ := c.readBalance(txn, tx.From)
log.Printf("[CHAIN] block %d: tx %s (%s) skipped — %v [sender %s balance: %d µT]",
b.Index, tx.ID, tx.Type, err,
tx.From[:min(8, len(tx.From))], senderBal)
continue
}
return fmt.Errorf("apply tx %s: %w", tx.ID, err)
}
if gasUsed > 0 {
gasUsedByTx[tx.ID] = gasUsed
}
collectedFees += tx.Fee
}
// Credit validator (or their bound wallet).
// Genesis block (index 0): one-time allocation of fixed supply.
// All other blocks: validator earns only the transaction fees — no minting.
rewardTarget, err := c.resolveRewardTarget(txn, b.Validator)
if err != nil {
return fmt.Errorf("resolve reward target: %w", err)
}
if b.Index == 0 {
if err := c.creditBalance(txn, rewardTarget, GenesisAllocation); err != nil {
return fmt.Errorf("genesis allocation: %w", err)
}
} else if collectedFees > 0 {
if err := c.creditBalance(txn, rewardTarget, collectedFees); err != nil {
return fmt.Errorf("credit validator fees: %w", err)
}
}
// Update validator reputation
if err := c.incrementRep(txn, b.Validator, func(r *RepStats) {
r.BlocksProduced++
}); err != nil {
return err
}
// Index transactions and update network stats
if err := c.indexBlock(txn, b, gasUsedByTx); err != nil {
return err
}
return nil
}); err != nil {
return err
}
c.tip = b
return nil
}
// GetBlock returns the block at the given index.
func (c *Chain) GetBlock(index uint64) (*Block, error) {
var b Block
err := c.db.View(func(txn *badger.Txn) error {
item, err := txn.Get([]byte(blockKey(index)))
if err != nil {
return err
}
return item.Value(func(val []byte) error {
return json.Unmarshal(val, &b)
})
})
if err != nil {
return nil, err
}
return &b, nil
}
// Balance returns µT balance for a public key.
func (c *Chain) Balance(pubKeyHex string) (uint64, error) {
var bal uint64
err := c.db.View(func(txn *badger.Txn) error {
item, err := txn.Get([]byte(prefixBalance + pubKeyHex))
if errors.Is(err, badger.ErrKeyNotFound) {
return nil
}
if err != nil {
return err
}
return item.Value(func(val []byte) error {
return json.Unmarshal(val, &bal)
})
})
return bal, err
}
// Identity returns the RegisterKeyPayload for a public key, or nil.
func (c *Chain) Identity(pubKeyHex string) (*RegisterKeyPayload, error) {
var p RegisterKeyPayload
err := c.db.View(func(txn *badger.Txn) error {
item, err := txn.Get([]byte(prefixIdentity + pubKeyHex))
if err != nil {
return err
}
return item.Value(func(val []byte) error {
return json.Unmarshal(val, &p)
})
})
if errors.Is(err, badger.ErrKeyNotFound) {
return nil, nil
}
return &p, err
}
// Channel returns the CreateChannelPayload for a channel ID, or nil.
func (c *Chain) Channel(channelID string) (*CreateChannelPayload, error) {
var p CreateChannelPayload
err := c.db.View(func(txn *badger.Txn) error {
item, err := txn.Get([]byte(prefixChannel + channelID))
if err != nil {
return err
}
return item.Value(func(val []byte) error {
return json.Unmarshal(val, &p)
})
})
if errors.Is(err, badger.ErrKeyNotFound) {
return nil, nil
}
return &p, err
}
// ChannelMembers returns the public keys of all members added to channelID.
func (c *Chain) ChannelMembers(channelID string) ([]string, error) {
prefix := []byte(fmt.Sprintf("%s%s:", prefixChanMember, channelID))
var members []string
err := c.db.View(func(txn *badger.Txn) error {
opts := badger.DefaultIteratorOptions
opts.PrefetchValues = false
opts.Prefix = prefix
it := txn.NewIterator(opts)
defer it.Close()
for it.Rewind(); it.Valid(); it.Next() {
key := string(it.Item().Key())
// key = "chan-member:<channelID>:<memberPubKey>"
parts := strings.SplitN(key, ":", 3)
if len(parts) == 3 {
members = append(members, parts[2])
}
}
return nil
})
return members, err
}
// WalletBinding returns the payout wallet pub key bound to a node, or "" if none.
func (c *Chain) WalletBinding(nodePubKey string) (string, error) {
var walletPubKey string
err := c.db.View(func(txn *badger.Txn) error {
item, err := txn.Get([]byte(prefixWalletBind + nodePubKey))
if errors.Is(err, badger.ErrKeyNotFound) {
return nil
}
if err != nil {
return err
}
return item.Value(func(val []byte) error {
walletPubKey = string(val)
return nil
})
})
return walletPubKey, err
}
// PayChannel returns the PayChanState for a channel ID, or nil if not found.
func (c *Chain) PayChannel(channelID string) (*PayChanState, error) {
var state PayChanState
err := c.db.View(func(txn *badger.Txn) error {
item, err := txn.Get([]byte(prefixPayChan + channelID))
if err != nil {
return err
}
return item.Value(func(val []byte) error {
return json.Unmarshal(val, &state)
})
})
if errors.Is(err, badger.ErrKeyNotFound) {
return nil, nil
}
return &state, err
}
// PayChanSigPayload returns the canonical bytes both parties sign to open a channel.
// Use this from the wallet CLI to produce SigB before submitting an OPEN_PAY_CHAN tx.
func PayChanSigPayload(channelID, partyA, partyB string, depositA, depositB, expiryBlock uint64) []byte {
return payChanSigPayload(channelID, partyA, partyB, depositA, depositB, expiryBlock)
}
// PayChanCloseSigPayload returns the canonical bytes both parties sign to close a channel.
func PayChanCloseSigPayload(channelID string, balanceA, balanceB, nonce uint64) []byte {
return payChanCloseSigPayload(channelID, balanceA, balanceB, nonce)
}
// Reputation returns the reputation stats for a public key.
func (c *Chain) Reputation(pubKeyHex string) (RepStats, error) {
var r RepStats
err := c.db.View(func(txn *badger.Txn) error {
item, err := txn.Get([]byte(prefixReputation + pubKeyHex))
if errors.Is(err, badger.ErrKeyNotFound) {
return nil
}
if err != nil {
return err
}
return item.Value(func(val []byte) error {
return json.Unmarshal(val, &r)
})
})
if err != nil {
return RepStats{}, err
}
r.Score = r.ComputeScore()
return r, nil
}
// --- internal ---
func blockKey(index uint64) string {
return fmt.Sprintf("%s%020d", prefixBlock, index)
}
func (c *Chain) loadTip() (*Block, error) {
var height uint64
err := c.db.View(func(txn *badger.Txn) error {
item, err := txn.Get([]byte(prefixHeight))
if errors.Is(err, badger.ErrKeyNotFound) {
return nil
}
if err != nil {
return err
}
return item.Value(func(val []byte) error {
return json.Unmarshal(val, &height)
})
})
if err != nil {
return nil, err
}
if height == 0 {
// Check if genesis exists
var genesis Block
err2 := c.db.View(func(txn *badger.Txn) error {
item, err := txn.Get([]byte(blockKey(0)))
if errors.Is(err, badger.ErrKeyNotFound) {
return nil
}
if err != nil {
return err
}
return item.Value(func(val []byte) error {
return json.Unmarshal(val, &genesis)
})
})
if err2 != nil {
return nil, err2
}
if genesis.Hash != nil {
return &genesis, nil
}
return nil, nil
}
return c.GetBlock(height)
}
// resolveRewardTarget returns the wallet pub key to credit for a validator.
// If the validator has a bound wallet, returns that; otherwise returns their own pub key.
func (c *Chain) resolveRewardTarget(txn *badger.Txn, validatorPubKey string) (string, error) {
item, err := txn.Get([]byte(prefixWalletBind + validatorPubKey))
if errors.Is(err, badger.ErrKeyNotFound) {
return validatorPubKey, nil
}
if err != nil {
return "", err
}
var target string
err = item.Value(func(val []byte) error {
target = string(val)
return nil
})
if err != nil || target == "" {
return validatorPubKey, nil
}
return target, nil
}
// applyTx applies one transaction within txn.
// Returns (gasUsed, error); gasUsed is non-zero only for CALL_CONTRACT.
func (c *Chain) applyTx(txn *badger.Txn, tx *Transaction) (uint64, error) {
switch tx.Type {
case EventRegisterKey:
var p RegisterKeyPayload
if err := json.Unmarshal(tx.Payload, &p); err != nil {
return 0, fmt.Errorf("%w: REGISTER_KEY bad payload: %v", ErrTxFailed, err)
}
if tx.Fee < RegistrationFee {
return 0, fmt.Errorf("%w: REGISTER_KEY fee %d µT below minimum %d µT",
ErrTxFailed, tx.Fee, RegistrationFee)
}
if err := c.debitBalance(txn, tx.From, tx.Fee); err != nil {
return 0, fmt.Errorf("REGISTER_KEY debit: %w", err)
}
val, _ := json.Marshal(p)
if err := txn.Set([]byte(prefixIdentity+tx.From), val); err != nil {
return 0, err
}
case EventCreateChannel:
var p CreateChannelPayload
if err := json.Unmarshal(tx.Payload, &p); err != nil {
return 0, fmt.Errorf("%w: CREATE_CHANNEL bad payload: %v", ErrTxFailed, err)
}
if err := c.debitBalance(txn, tx.From, tx.Fee); err != nil {
return 0, fmt.Errorf("CREATE_CHANNEL debit: %w", err)
}
val, _ := json.Marshal(p)
if err := txn.Set([]byte(prefixChannel+p.ChannelID), val); err != nil {
return 0, err
}
case EventAddMember:
var p AddMemberPayload
if err := json.Unmarshal(tx.Payload, &p); err != nil {
return 0, fmt.Errorf("%w: ADD_MEMBER bad payload: %v", ErrTxFailed, err)
}
if p.ChannelID == "" {
return 0, fmt.Errorf("%w: ADD_MEMBER: channel_id required", ErrTxFailed)
}
if _, err := txn.Get([]byte(prefixChannel + p.ChannelID)); err != nil {
if errors.Is(err, badger.ErrKeyNotFound) {
return 0, fmt.Errorf("%w: ADD_MEMBER: channel %q not found", ErrTxFailed, p.ChannelID)
}
return 0, err
}
if err := c.debitBalance(txn, tx.From, tx.Fee); err != nil {
return 0, fmt.Errorf("ADD_MEMBER debit: %w", err)
}
member := tx.To
if member == "" {
member = tx.From
}
if err := txn.Set([]byte(fmt.Sprintf("%s%s:%s", prefixChanMember, p.ChannelID, member)), []byte{}); err != nil {
return 0, err
}
case EventTransfer:
senderBal, _ := c.readBalance(txn, tx.From)
log.Printf("[CHAIN] TRANSFER %s→%s amount=%d fee=%d senderBal=%d",
tx.From[:min(8, len(tx.From))], tx.To[:min(8, len(tx.To))],
tx.Amount, tx.Fee, senderBal)
if err := c.debitBalance(txn, tx.From, tx.Amount+tx.Fee); err != nil {
return 0, fmt.Errorf("TRANSFER debit: %w", err)
}
if err := c.creditBalance(txn, tx.To, tx.Amount); err != nil {
return 0, fmt.Errorf("credit recipient: %w", err)
}
case EventRelayProof:
var p RelayProofPayload
if err := json.Unmarshal(tx.Payload, &p); err != nil {
return 0, fmt.Errorf("%w: RELAY_PROOF bad payload: %v", ErrTxFailed, err)
}
if p.SenderPubKey == "" || p.FeeUT == 0 || len(p.FeeSig) == 0 {
return 0, fmt.Errorf("%w: relay proof missing fee authorization fields", ErrTxFailed)
}
authBytes := FeeAuthBytes(p.EnvelopeID, p.FeeUT)
ok, err := verifyEd25519(p.SenderPubKey, authBytes, p.FeeSig)
if err != nil || !ok {
return 0, fmt.Errorf("%w: invalid relay fee authorization signature", ErrTxFailed)
}
if err := c.debitBalance(txn, p.SenderPubKey, p.FeeUT); err != nil {
return 0, fmt.Errorf("RELAY_PROOF debit: %w", err)
}
target, err := c.resolveRewardTarget(txn, p.RelayPubKey)
if err != nil {
return 0, err
}
if err := c.creditBalance(txn, target, p.FeeUT); err != nil {
return 0, fmt.Errorf("credit relay fee: %w", err)
}
if err := c.incrementRep(txn, p.RelayPubKey, func(r *RepStats) {
r.RelayProofs++
}); err != nil {
return 0, err
}
case EventBindWallet:
var p BindWalletPayload
if err := json.Unmarshal(tx.Payload, &p); err != nil {
return 0, fmt.Errorf("%w: BIND_WALLET bad payload: %v", ErrTxFailed, err)
}
if err := c.debitBalance(txn, tx.From, tx.Fee); err != nil {
return 0, fmt.Errorf("BIND_WALLET debit: %w", err)
}
if err := txn.Set([]byte(prefixWalletBind+tx.From), []byte(p.WalletPubKey)); err != nil {
return 0, err
}
case EventSlash:
var p SlashPayload
if err := json.Unmarshal(tx.Payload, &p); err != nil {
return 0, fmt.Errorf("%w: SLASH bad payload: %v", ErrTxFailed, err)
}
if p.OffenderPubKey == "" {
return 0, fmt.Errorf("%w: SLASH: offender_pub_key required", ErrTxFailed)
}
// Sender must be a validator — non-validators can't trigger slashing
// without gumming up the chain with spurious reports.
fromIsValidator, err := c.isValidatorTxn(txn, tx.From)
if err != nil {
return 0, err
}
if !fromIsValidator {
return 0, fmt.Errorf("%w: SLASH: sender is not a current validator", ErrTxFailed)
}
// Only "equivocation" is cryptographically verifiable on-chain;
// reject other reasons until we implement their proofs (downtime
// is handled via auto-removal, not slashing).
if p.Reason != "equivocation" {
return 0, fmt.Errorf("%w: SLASH: only reason=equivocation is supported on-chain, got %q",
ErrTxFailed, p.Reason)
}
var ev EquivocationEvidence
if err := json.Unmarshal(p.Evidence, &ev); err != nil {
return 0, fmt.Errorf("%w: SLASH: bad evidence: %v", ErrTxFailed, err)
}
if err := ValidateEquivocation(p.OffenderPubKey, &ev); err != nil {
return 0, fmt.Errorf("%w: SLASH: %v", ErrTxFailed, err)
}
// Pay the sender's tx fee (they did work to produce the evidence).
if err := c.debitBalance(txn, tx.From, tx.Fee); err != nil {
return 0, fmt.Errorf("SLASH fee debit: %w", err)
}
// Burn offender's stake (preferred — bonded amount), fall back to
// balance if stake < SlashAmount. Either way, the tokens are
// destroyed — not redirected to the reporter, to keep incentives
// clean (reporters profit only from healthier chain, not bounties).
stake := c.readStake(txn, p.OffenderPubKey)
if stake >= SlashAmount {
if err := c.writeStake(txn, p.OffenderPubKey, stake-SlashAmount); err != nil {
return 0, fmt.Errorf("SLASH stake burn: %w", err)
}
} else {
if stake > 0 {
if err := c.writeStake(txn, p.OffenderPubKey, 0); err != nil {
return 0, fmt.Errorf("SLASH stake burn: %w", err)
}
}
// Burn the rest from liquid balance (best-effort; ignore
// insufficient-balance error so the slash still counts).
remaining := SlashAmount - stake
_ = c.debitBalance(txn, p.OffenderPubKey, remaining)
}
// Eject from the validator set — slashed validators are off the
// committee permanently (re-admission requires a fresh
// ADD_VALIDATOR with stake).
if err := txn.Delete([]byte(prefixValidator + p.OffenderPubKey)); err != nil && err != badger.ErrKeyNotFound {
return 0, fmt.Errorf("SLASH remove validator: %w", err)
}
if err := c.incrementRep(txn, p.OffenderPubKey, func(r *RepStats) {
r.SlashCount++
}); err != nil {
return 0, err
}
log.Printf("[CHAIN] SLASH: offender=%s reason=%s reporter=%s amount=%d µT",
p.OffenderPubKey[:min(8, len(p.OffenderPubKey))], p.Reason,
tx.From[:min(8, len(tx.From))], SlashAmount)
case EventHeartbeat:
var p HeartbeatPayload
if err := json.Unmarshal(tx.Payload, &p); err != nil {
return 0, fmt.Errorf("%w: HEARTBEAT bad payload: %v", ErrTxFailed, err)
}
if err := c.debitBalance(txn, tx.From, tx.Fee); err != nil {
return 0, fmt.Errorf("HEARTBEAT debit: %w", err)
}
if err := c.incrementRep(txn, tx.From, func(r *RepStats) {
r.Heartbeats++
}); err != nil {
return 0, err
}
// Also refresh the relay-heartbeat timestamp if the sender is a
// registered relay. This reuses the existing hourly HEARTBEAT tx
// so relay-only nodes don't need to pay for a dedicated keep-
// alive; one tx serves both purposes.
if _, err := txn.Get([]byte(prefixRelay + tx.From)); err == nil {
if err := c.writeRelayHeartbeat(txn, tx.From, tx.Timestamp.Unix()); err != nil {
return 0, err
}
}
case EventRegisterRelay:
var p RegisterRelayPayload
if err := json.Unmarshal(tx.Payload, &p); err != nil {
return 0, fmt.Errorf("%w: REGISTER_RELAY bad payload: %v", ErrTxFailed, err)
}
if p.X25519PubKey == "" {
return 0, fmt.Errorf("%w: REGISTER_RELAY: x25519_pub_key is required", ErrTxFailed)
}
val, _ := json.Marshal(p)
if err := txn.Set([]byte(prefixRelay+tx.From), val); err != nil {
return 0, err
}
// Seed the heartbeat so the relay is immediately reachable via
// /api/relays. Without this a fresh relay wouldn't appear until
// its first heartbeat tx commits (~1 hour default), making the
// register tx look silent.
if err := c.writeRelayHeartbeat(txn, tx.From, tx.Timestamp.Unix()); err != nil {
return 0, err
}
case EventContactRequest:
var p ContactRequestPayload
if err := json.Unmarshal(tx.Payload, &p); err != nil {
return 0, fmt.Errorf("%w: CONTACT_REQUEST bad payload: %v", ErrTxFailed, err)
}
if tx.To == "" {
return 0, fmt.Errorf("%w: CONTACT_REQUEST: recipient (to) is required", ErrTxFailed)
}
if tx.Amount < MinContactFee {
return 0, fmt.Errorf("%w: CONTACT_REQUEST: amount %d < MinContactFee %d",
ErrTxFailed, tx.Amount, MinContactFee)
}
if err := c.debitBalance(txn, tx.From, tx.Amount+tx.Fee); err != nil {
return 0, fmt.Errorf("CONTACT_REQUEST debit: %w", err)
}
if err := c.creditBalance(txn, tx.To, tx.Amount); err != nil {
return 0, fmt.Errorf("credit contact target: %w", err)
}
rec := contactRecord{
Status: string(ContactPending),
Intro: p.Intro,
FeeUT: tx.Amount,
TxID: tx.ID,
CreatedAt: tx.Timestamp.Unix(),
}
val, _ := json.Marshal(rec)
key := prefixContactIn + tx.To + ":" + tx.From
if err := txn.Set([]byte(key), val); err != nil {
return 0, fmt.Errorf("store contact record: %w", err)
}
case EventAcceptContact:
if tx.To == "" {
return 0, fmt.Errorf("%w: ACCEPT_CONTACT: requester (to) is required", ErrTxFailed)
}
if err := c.debitBalance(txn, tx.From, tx.Fee); err != nil {
return 0, fmt.Errorf("ACCEPT_CONTACT debit: %w", err)
}
key := prefixContactIn + tx.From + ":" + tx.To
if err := c.updateContactStatus(txn, key, ContactAccepted); err != nil {
return 0, fmt.Errorf("%w: accept contact: %v", ErrTxFailed, err)
}
case EventBlockContact:
if tx.To == "" {
return 0, fmt.Errorf("%w: BLOCK_CONTACT: sender (to) is required", ErrTxFailed)
}
if err := c.debitBalance(txn, tx.From, tx.Fee); err != nil {
return 0, fmt.Errorf("BLOCK_CONTACT debit: %w", err)
}
key := prefixContactIn + tx.From + ":" + tx.To
var rec contactRecord
item, err := txn.Get([]byte(key))
if err == nil {
_ = item.Value(func(val []byte) error { return json.Unmarshal(val, &rec) })
}
rec.Status = string(ContactBlocked)
val, _ := json.Marshal(rec)
if err := txn.Set([]byte(key), val); err != nil {
return 0, fmt.Errorf("store block record: %w", err)
}
case EventAddValidator:
if tx.To == "" {
return 0, fmt.Errorf("%w: ADD_VALIDATOR: target pub key (to) is required", ErrTxFailed)
}
fromIsValidator, err := c.isValidatorTxn(txn, tx.From)
if err != nil {
return 0, err
}
if !fromIsValidator {
return 0, fmt.Errorf("%w: ADD_VALIDATOR: %s is not a current validator", ErrTxFailed, tx.From)
}
// Decode admission payload early so we can read CoSignatures.
var admitP AddValidatorPayload
if len(tx.Payload) > 0 {
if err := json.Unmarshal(tx.Payload, &admitP); err != nil {
return 0, fmt.Errorf("%w: ADD_VALIDATOR bad payload: %v", ErrTxFailed, err)
}
}
// ── Stake gate ─────────────────────────────────────────────────
// Candidate must have locked at least MinValidatorStake before the
// admission tx is accepted. Prevents sybil admissions.
if stake := c.readStake(txn, tx.To); stake < MinValidatorStake {
return 0, fmt.Errorf("%w: ADD_VALIDATOR: candidate has %d µT staked, need %d µT",
ErrTxFailed, stake, MinValidatorStake)
}
// ── Multi-sig gate ─────────────────────────────────────────────
// Count approvals: the sender (a validator, checked above) is 1.
// Each valid CoSignature from a DISTINCT current validator adds 1.
// Require ⌈2/3⌉ of the current validator set to admit.
currentSet, err := c.validatorSetTxn(txn)
if err != nil {
return 0, err
}
required := (2*len(currentSet) + 2) / 3 // ceil(2N/3)
if required < 1 {
required = 1
}
digest := AdmitDigest(tx.To)
approvers := map[string]struct{}{tx.From: {}}
for _, cs := range admitP.CoSignatures {
// Reject cosigs from non-validators or signatures that don't
// verify. Silently duplicates are dropped.
if _, alreadyIn := approvers[cs.PubKey]; alreadyIn {
continue
}
if !contains(currentSet, cs.PubKey) {
continue
}
pubBytes, err := hex.DecodeString(cs.PubKey)
if err != nil || len(pubBytes) != ed25519.PublicKeySize {
continue
}
if !ed25519.Verify(ed25519.PublicKey(pubBytes), digest, cs.Signature) {
continue
}
approvers[cs.PubKey] = struct{}{}
}
if len(approvers) < required {
return 0, fmt.Errorf("%w: ADD_VALIDATOR: %d of %d approvals (need %d = ceil(2/3) of %d validators)",
ErrTxFailed, len(approvers), len(currentSet), required, len(currentSet))
}
if err := c.debitBalance(txn, tx.From, tx.Fee); err != nil {
return 0, fmt.Errorf("ADD_VALIDATOR debit: %w", err)
}
if err := txn.Set([]byte(prefixValidator+tx.To), []byte{}); err != nil {
return 0, fmt.Errorf("store validator: %w", err)
}
log.Printf("[CHAIN] ADD_VALIDATOR: admitted %s (%d/%d approvals)",
tx.To[:min(8, len(tx.To))], len(approvers), len(currentSet))
case EventRemoveValidator:
if tx.To == "" {
return 0, fmt.Errorf("%w: REMOVE_VALIDATOR: target pub key (to) is required", ErrTxFailed)
}
fromIsValidator, err := c.isValidatorTxn(txn, tx.From)
if err != nil {
return 0, err
}
if !fromIsValidator {
return 0, fmt.Errorf("%w: REMOVE_VALIDATOR: %s is not a current validator", ErrTxFailed, tx.From)
}
// Self-removal is always allowed — a validator should be able to
// leave the set gracefully without needing peers' approval.
selfRemove := tx.From == tx.To
if !selfRemove {
// Forced removal requires ⌈2/3⌉ cosigs on RemoveDigest(target).
// Same shape as ADD_VALIDATOR; keeps governance symmetric.
var rmP RemoveValidatorPayload
if len(tx.Payload) > 0 {
if err := json.Unmarshal(tx.Payload, &rmP); err != nil {
return 0, fmt.Errorf("%w: REMOVE_VALIDATOR bad payload: %v", ErrTxFailed, err)
}
}
currentSet, err := c.validatorSetTxn(txn)
if err != nil {
return 0, err
}
required := (2*len(currentSet) + 2) / 3
if required < 1 {
required = 1
}
digest := RemoveDigest(tx.To)
approvers := map[string]struct{}{tx.From: {}}
for _, cs := range rmP.CoSignatures {
if _, already := approvers[cs.PubKey]; already {
continue
}
if !contains(currentSet, cs.PubKey) {
continue
}
pubBytes, err := hex.DecodeString(cs.PubKey)
if err != nil || len(pubBytes) != ed25519.PublicKeySize {
continue
}
if !ed25519.Verify(ed25519.PublicKey(pubBytes), digest, cs.Signature) {
continue
}
approvers[cs.PubKey] = struct{}{}
}
if len(approvers) < required {
return 0, fmt.Errorf("%w: REMOVE_VALIDATOR: %d of %d approvals (need %d = ceil(2/3))",
ErrTxFailed, len(approvers), len(currentSet), required)
}
}
if err := c.debitBalance(txn, tx.From, tx.Fee); err != nil {
return 0, fmt.Errorf("REMOVE_VALIDATOR debit: %w", err)
}
if err := txn.Delete([]byte(prefixValidator + tx.To)); err != nil && err != badger.ErrKeyNotFound {
return 0, fmt.Errorf("remove validator: %w", err)
}
if selfRemove {
log.Printf("[CHAIN] REMOVE_VALIDATOR: %s self-removed", tx.To[:min(8, len(tx.To))])
} else {
log.Printf("[CHAIN] REMOVE_VALIDATOR: removed %s (multi-sig)", tx.To[:min(8, len(tx.To))])
}
case EventOpenPayChan:
if err := c.applyOpenPayChan(txn, tx); err != nil {
return 0, fmt.Errorf("%w: open paychan: %v", ErrTxFailed, err)
}
case EventClosePayChan:
if err := c.applyClosePayChan(txn, tx); err != nil {
return 0, fmt.Errorf("%w: close paychan: %v", ErrTxFailed, err)
}
case EventDeployContract:
if c.vm == nil {
return 0, fmt.Errorf("%w: DEPLOY_CONTRACT: VM not configured on this node", ErrTxFailed)
}
var p DeployContractPayload
if err := json.Unmarshal(tx.Payload, &p); err != nil {
return 0, fmt.Errorf("%w: DEPLOY_CONTRACT bad payload: %v", ErrTxFailed, err)
}
if p.WASMBase64 == "" || p.ABIJson == "" {
return 0, fmt.Errorf("%w: DEPLOY_CONTRACT: wasm_b64 and abi_json are required", ErrTxFailed)
}
if tx.Fee < MinDeployFee {
return 0, fmt.Errorf("%w: DEPLOY_CONTRACT fee %d < MinDeployFee %d",
ErrTxFailed, tx.Fee, MinDeployFee)
}
import64 := func(s string) ([]byte, error) {
buf := make([]byte, len(s))
n, err := decodeBase64(s, buf)
return buf[:n], err
}
wasmBytes, err := import64(p.WASMBase64)
if err != nil {
return 0, fmt.Errorf("%w: DEPLOY_CONTRACT: invalid base64 wasm: %v", ErrTxFailed, err)
}
if err := c.vm.Validate(context.Background(), wasmBytes); err != nil {
return 0, fmt.Errorf("%w: DEPLOY_CONTRACT: invalid WASM: %v", ErrTxFailed, err)
}
contractID := computeContractID(tx.From, wasmBytes)
if _, dbErr := txn.Get([]byte(prefixContract + contractID)); dbErr == nil {
return 0, fmt.Errorf("%w: DEPLOY_CONTRACT: contract %s already deployed", ErrTxFailed, contractID)
}
if err := c.debitBalance(txn, tx.From, tx.Fee); err != nil {
return 0, fmt.Errorf("DEPLOY_CONTRACT debit: %w", err)
}
var height uint64
if item, hErr := txn.Get([]byte(prefixHeight)); hErr == nil {
_ = item.Value(func(val []byte) error { return json.Unmarshal(val, &height) })
}
rec := ContractRecord{
ContractID: contractID,
WASMBytes: wasmBytes,
ABIJson: p.ABIJson,
DeployerPub: tx.From,
DeployedAt: height,
}
val, _ := json.Marshal(rec)
if err := txn.Set([]byte(prefixContract+contractID), val); err != nil {
return 0, fmt.Errorf("store contract: %w", err)
}
log.Printf("[CHAIN] DEPLOY_CONTRACT id=%s deployer=%s height=%d wasmSize=%d",
contractID, tx.From[:min(8, len(tx.From))], height, len(wasmBytes))
case EventCallContract:
var p CallContractPayload
if err := json.Unmarshal(tx.Payload, &p); err != nil {
return 0, fmt.Errorf("%w: CALL_CONTRACT bad payload: %v", ErrTxFailed, err)
}
if p.ContractID == "" || p.Method == "" {
return 0, fmt.Errorf("%w: CALL_CONTRACT: contract_id and method are required", ErrTxFailed)
}
if p.GasLimit == 0 {
return 0, fmt.Errorf("%w: CALL_CONTRACT: gas_limit must be > 0", ErrTxFailed)
}
// ── Native dispatch ──────────────────────────────────────────────
// System contracts (username_registry etc.) implemented in Go run
// here, bypassing wazero entirely. This eliminates a whole class
// of VM-hang bugs and cuts per-call latency ~100×.
if nc := c.lookupNative(p.ContractID); nc != nil {
gasPrice := c.GetEffectiveGasPrice()
maxGasCost := p.GasLimit * gasPrice
if err := c.debitBalance(txn, tx.From, tx.Fee+maxGasCost); err != nil {
return 0, fmt.Errorf("CALL_CONTRACT debit: %w", err)
}
var height uint64
if hi, hErr := txn.Get([]byte(prefixHeight)); hErr == nil {
_ = hi.Value(func(val []byte) error { return json.Unmarshal(val, &height) })
}
nctx := &NativeContext{
Txn: txn,
ContractID: p.ContractID,
Caller: tx.From,
TxID: tx.ID,
BlockHeight: height,
TxAmount: tx.Amount, // payment attached to this call
chain: c,
}
gasUsed, callErr := nc.Call(nctx, p.Method, []byte(p.ArgsJSON))
if gasUsed > p.GasLimit {
gasUsed = p.GasLimit
}
if callErr != nil {
// Refund unused gas but keep fee debited — prevents spam.
if refund := (p.GasLimit - gasUsed) * gasPrice; refund > 0 {
_ = c.creditBalance(txn, tx.From, refund)
}
return 0, fmt.Errorf("%w: CALL_CONTRACT %s.%s: %v", ErrTxFailed, p.ContractID, p.Method, callErr)
}
// Success: refund remaining gas.
if refund := (p.GasLimit - gasUsed) * gasPrice; refund > 0 {
if err := c.creditBalance(txn, tx.From, refund); err != nil {
log.Printf("[CHAIN] CALL_CONTRACT native gas refund failed: %v", err)
}
}
log.Printf("[CHAIN] native CALL_CONTRACT id=%s method=%s caller=%s gasUsed=%d",
p.ContractID, p.Method, tx.From[:min(8, len(tx.From))], gasUsed)
return gasUsed, nil
}
// ── WASM path ────────────────────────────────────────────────────
if c.vm == nil {
return 0, fmt.Errorf("%w: CALL_CONTRACT: VM not configured on this node", ErrTxFailed)
}
item, err := txn.Get([]byte(prefixContract + p.ContractID))
if err != nil {
if errors.Is(err, badger.ErrKeyNotFound) {
return 0, fmt.Errorf("%w: CALL_CONTRACT: contract %s not found", ErrTxFailed, p.ContractID)
}
return 0, err
}
var rec ContractRecord
if err := item.Value(func(val []byte) error { return json.Unmarshal(val, &rec) }); err != nil {
return 0, fmt.Errorf("%w: CALL_CONTRACT: corrupt contract record: %v", ErrTxFailed, err)
}
// Use effective gas price (may be overridden by governance contract).
gasPrice := c.GetEffectiveGasPrice()
// Pre-charge fee + maximum possible gas cost upfront.
maxGasCost := p.GasLimit * gasPrice
if err := c.debitBalance(txn, tx.From, tx.Fee+maxGasCost); err != nil {
return 0, fmt.Errorf("CALL_CONTRACT debit: %w", err)
}
var height uint64
if hi, hErr := txn.Get([]byte(prefixHeight)); hErr == nil {
_ = hi.Value(func(val []byte) error { return json.Unmarshal(val, &height) })
}
env := newChainHostEnv(txn, p.ContractID, tx.From, tx.ID, height, c)
// Hard wall-clock budget per contract call. Even if gas metering
// fails or the contract dodges the function-listener (tight loop of
// unhooked opcodes), WithCloseOnContextDone(true) on the runtime
// will abort the call once the deadline fires. Prevents a single
// bad tx from freezing the entire chain — as happened with the
// username_registry.register hang.
callCtx, callCancel := context.WithTimeout(context.Background(), 30*time.Second)
gasUsed, callErr := c.vm.Call(
callCtx,
p.ContractID, rec.WASMBytes,
p.Method, []byte(p.ArgsJSON),
p.GasLimit, env,
)
callCancel()
if callErr != nil {
// Refund unused gas even on error (gas already consumed stays charged).
if refund := (p.GasLimit - gasUsed) * gasPrice; refund > 0 {
_ = c.creditBalance(txn, tx.From, refund)
}
return 0, fmt.Errorf("%w: CALL_CONTRACT %s.%s: %v", ErrTxFailed, p.ContractID, p.Method, callErr)
}
// Refund unused gas back to caller.
if refund := (p.GasLimit - gasUsed) * gasPrice; refund > 0 {
if err := c.creditBalance(txn, tx.From, refund); err != nil {
log.Printf("[CHAIN] CALL_CONTRACT gas refund failed (refund=%d µT): %v", refund, err)
}
}
log.Printf("[CHAIN] CALL_CONTRACT id=%s method=%s caller=%s gasUsed=%d/%d gasCost=%d µT refund=%d µT",
p.ContractID, p.Method, tx.From[:min(8, len(tx.From))],
gasUsed, p.GasLimit, gasUsed*gasPrice, (p.GasLimit-gasUsed)*gasPrice)
return gasUsed, nil
case EventStake:
if tx.Amount == 0 {
return 0, fmt.Errorf("%w: STAKE: amount must be > 0", ErrTxFailed)
}
if err := c.debitBalance(txn, tx.From, tx.Amount+tx.Fee); err != nil {
return 0, fmt.Errorf("STAKE debit: %w", err)
}
current := c.readStake(txn, tx.From)
if err := c.writeStake(txn, tx.From, current+tx.Amount); err != nil {
return 0, fmt.Errorf("STAKE write: %w", err)
}
log.Printf("[CHAIN] STAKE pubkey=%s amount=%d µT total=%d µT",
tx.From[:min(8, len(tx.From))], tx.Amount, current+tx.Amount)
case EventUnstake:
staked := c.readStake(txn, tx.From)
if staked == 0 {
return 0, fmt.Errorf("%w: UNSTAKE: no active stake", ErrTxFailed)
}
if err := c.debitBalance(txn, tx.From, tx.Fee); err != nil {
return 0, fmt.Errorf("UNSTAKE fee debit: %w", err)
}
if err := c.writeStake(txn, tx.From, 0); err != nil {
return 0, fmt.Errorf("UNSTAKE write: %w", err)
}
if err := c.creditBalance(txn, tx.From, staked); err != nil {
return 0, fmt.Errorf("UNSTAKE credit: %w", err)
}
log.Printf("[CHAIN] UNSTAKE pubkey=%s returned=%d µT",
tx.From[:min(8, len(tx.From))], staked)
case EventIssueToken:
var p IssueTokenPayload
if err := json.Unmarshal(tx.Payload, &p); err != nil {
return 0, fmt.Errorf("%w: ISSUE_TOKEN bad payload: %v", ErrTxFailed, err)
}
if p.Name == "" || p.Symbol == "" {
return 0, fmt.Errorf("%w: ISSUE_TOKEN: name and symbol are required", ErrTxFailed)
}
if p.TotalSupply == 0 {
return 0, fmt.Errorf("%w: ISSUE_TOKEN: total_supply must be > 0", ErrTxFailed)
}
if tx.Fee < MinIssueTokenFee {
return 0, fmt.Errorf("%w: ISSUE_TOKEN fee %d < MinIssueTokenFee %d",
ErrTxFailed, tx.Fee, MinIssueTokenFee)
}
tokenID := computeTokenID(tx.From, p.Symbol)
if _, dbErr := txn.Get([]byte(prefixToken + tokenID)); dbErr == nil {
return 0, fmt.Errorf("%w: ISSUE_TOKEN: token %s already exists", ErrTxFailed, tokenID)
}
if err := c.debitBalance(txn, tx.From, tx.Fee); err != nil {
return 0, fmt.Errorf("ISSUE_TOKEN debit: %w", err)
}
var height uint64
if hi, hErr := txn.Get([]byte(prefixHeight)); hErr == nil {
_ = hi.Value(func(val []byte) error { return json.Unmarshal(val, &height) })
}
tokenRec := TokenRecord{
TokenID: tokenID,
Name: p.Name,
Symbol: p.Symbol,
Decimals: p.Decimals,
TotalSupply: p.TotalSupply,
Issuer: tx.From,
IssuedAt: height,
}
val, _ := json.Marshal(tokenRec)
if err := txn.Set([]byte(prefixToken+tokenID), val); err != nil {
return 0, fmt.Errorf("store token record: %w", err)
}
if err := c.creditTokenBalance(txn, tokenID, tx.From, p.TotalSupply); err != nil {
return 0, fmt.Errorf("ISSUE_TOKEN credit: %w", err)
}
log.Printf("[CHAIN] ISSUE_TOKEN id=%s symbol=%s supply=%d issuer=%s",
tokenID, p.Symbol, p.TotalSupply, tx.From[:min(8, len(tx.From))])
case EventTransferToken:
var p TransferTokenPayload
if err := json.Unmarshal(tx.Payload, &p); err != nil {
return 0, fmt.Errorf("%w: TRANSFER_TOKEN bad payload: %v", ErrTxFailed, err)
}
if p.TokenID == "" || p.Amount == 0 {
return 0, fmt.Errorf("%w: TRANSFER_TOKEN: token_id and amount are required", ErrTxFailed)
}
if tx.To == "" {
return 0, fmt.Errorf("%w: TRANSFER_TOKEN: recipient (to) is required", ErrTxFailed)
}
if _, dbErr := txn.Get([]byte(prefixToken + p.TokenID)); dbErr != nil {
return 0, fmt.Errorf("%w: TRANSFER_TOKEN: token %s not found", ErrTxFailed, p.TokenID)
}
if err := c.debitBalance(txn, tx.From, tx.Fee); err != nil {
return 0, fmt.Errorf("TRANSFER_TOKEN fee debit: %w", err)
}
if err := c.debitTokenBalance(txn, p.TokenID, tx.From, p.Amount); err != nil {
return 0, fmt.Errorf("TRANSFER_TOKEN debit: %w", err)
}
if err := c.creditTokenBalance(txn, p.TokenID, tx.To, p.Amount); err != nil {
return 0, fmt.Errorf("TRANSFER_TOKEN credit: %w", err)
}
case EventBurnToken:
var p BurnTokenPayload
if err := json.Unmarshal(tx.Payload, &p); err != nil {
return 0, fmt.Errorf("%w: BURN_TOKEN bad payload: %v", ErrTxFailed, err)
}
if p.TokenID == "" || p.Amount == 0 {
return 0, fmt.Errorf("%w: BURN_TOKEN: token_id and amount are required", ErrTxFailed)
}
tokenItem, dbErr := txn.Get([]byte(prefixToken + p.TokenID))
if dbErr != nil {
return 0, fmt.Errorf("%w: BURN_TOKEN: token %s not found", ErrTxFailed, p.TokenID)
}
var tokenRec TokenRecord
if err := tokenItem.Value(func(v []byte) error { return json.Unmarshal(v, &tokenRec) }); err != nil {
return 0, fmt.Errorf("%w: BURN_TOKEN: corrupt token record", ErrTxFailed)
}
if err := c.debitBalance(txn, tx.From, tx.Fee); err != nil {
return 0, fmt.Errorf("BURN_TOKEN fee debit: %w", err)
}
if err := c.debitTokenBalance(txn, p.TokenID, tx.From, p.Amount); err != nil {
return 0, fmt.Errorf("BURN_TOKEN debit: %w", err)
}
// Reduce total supply.
if tokenRec.TotalSupply >= p.Amount {
tokenRec.TotalSupply -= p.Amount
} else {
tokenRec.TotalSupply = 0
}
val, _ := json.Marshal(tokenRec)
if err := txn.Set([]byte(prefixToken+p.TokenID), val); err != nil {
return 0, fmt.Errorf("BURN_TOKEN update supply: %w", err)
}
log.Printf("[CHAIN] BURN_TOKEN id=%s amount=%d newSupply=%d burner=%s",
p.TokenID, p.Amount, tokenRec.TotalSupply, tx.From[:min(8, len(tx.From))])
case EventMintNFT:
var p MintNFTPayload
if err := json.Unmarshal(tx.Payload, &p); err != nil {
return 0, fmt.Errorf("%w: MINT_NFT bad payload: %v", ErrTxFailed, err)
}
if p.Name == "" {
return 0, fmt.Errorf("%w: MINT_NFT: name is required", ErrTxFailed)
}
if tx.Fee < MinMintNFTFee {
return 0, fmt.Errorf("%w: MINT_NFT fee %d < MinMintNFTFee %d",
ErrTxFailed, tx.Fee, MinMintNFTFee)
}
nftID := computeNFTID(tx.From, tx.ID)
if _, dbErr := txn.Get([]byte(prefixNFT + nftID)); dbErr == nil {
return 0, fmt.Errorf("%w: MINT_NFT: NFT %s already exists", ErrTxFailed, nftID)
}
if err := c.debitBalance(txn, tx.From, tx.Fee); err != nil {
return 0, fmt.Errorf("MINT_NFT debit: %w", err)
}
var height uint64
if hi, hErr := txn.Get([]byte(prefixHeight)); hErr == nil {
_ = hi.Value(func(val []byte) error { return json.Unmarshal(val, &height) })
}
nft := NFTRecord{
NFTID: nftID,
Name: p.Name,
Description: p.Description,
URI: p.URI,
Attributes: p.Attributes,
Owner: tx.From,
Issuer: tx.From,
MintedAt: height,
}
val, _ := json.Marshal(nft)
if err := txn.Set([]byte(prefixNFT+nftID), val); err != nil {
return 0, fmt.Errorf("store NFT: %w", err)
}
if err := txn.Set([]byte(prefixNFTOwner+tx.From+":"+nftID), []byte{}); err != nil {
return 0, fmt.Errorf("index NFT owner: %w", err)
}
log.Printf("[CHAIN] MINT_NFT id=%s name=%q owner=%s",
nftID, p.Name, tx.From[:min(8, len(tx.From))])
case EventTransferNFT:
var p TransferNFTPayload
if err := json.Unmarshal(tx.Payload, &p); err != nil {
return 0, fmt.Errorf("%w: TRANSFER_NFT bad payload: %v", ErrTxFailed, err)
}
if p.NFTID == "" {
return 0, fmt.Errorf("%w: TRANSFER_NFT: nft_id is required", ErrTxFailed)
}
if tx.To == "" {
return 0, fmt.Errorf("%w: TRANSFER_NFT: recipient (to) is required", ErrTxFailed)
}
nftItem, dbErr := txn.Get([]byte(prefixNFT + p.NFTID))
if dbErr != nil {
return 0, fmt.Errorf("%w: TRANSFER_NFT: NFT %s not found", ErrTxFailed, p.NFTID)
}
var nft NFTRecord
if err := nftItem.Value(func(v []byte) error { return json.Unmarshal(v, &nft) }); err != nil {
return 0, fmt.Errorf("%w: TRANSFER_NFT: corrupt NFT record", ErrTxFailed)
}
if nft.Burned {
return 0, fmt.Errorf("%w: TRANSFER_NFT: NFT %s is burned", ErrTxFailed, p.NFTID)
}
if nft.Owner != tx.From {
return 0, fmt.Errorf("%w: TRANSFER_NFT: %s is not the owner of NFT %s",
ErrTxFailed, tx.From[:min(8, len(tx.From))], p.NFTID)
}
if err := c.debitBalance(txn, tx.From, tx.Fee); err != nil {
return 0, fmt.Errorf("TRANSFER_NFT fee debit: %w", err)
}
// Remove old owner index, add new one.
_ = txn.Delete([]byte(prefixNFTOwner + tx.From + ":" + p.NFTID))
if err := txn.Set([]byte(prefixNFTOwner+tx.To+":"+p.NFTID), []byte{}); err != nil {
return 0, fmt.Errorf("index new NFT owner: %w", err)
}
nft.Owner = tx.To
val, _ := json.Marshal(nft)
if err := txn.Set([]byte(prefixNFT+p.NFTID), val); err != nil {
return 0, fmt.Errorf("update NFT owner: %w", err)
}
case EventBurnNFT:
var p BurnNFTPayload
if err := json.Unmarshal(tx.Payload, &p); err != nil {
return 0, fmt.Errorf("%w: BURN_NFT bad payload: %v", ErrTxFailed, err)
}
if p.NFTID == "" {
return 0, fmt.Errorf("%w: BURN_NFT: nft_id is required", ErrTxFailed)
}
nftItem, dbErr := txn.Get([]byte(prefixNFT + p.NFTID))
if dbErr != nil {
return 0, fmt.Errorf("%w: BURN_NFT: NFT %s not found", ErrTxFailed, p.NFTID)
}
var nft NFTRecord
if err := nftItem.Value(func(v []byte) error { return json.Unmarshal(v, &nft) }); err != nil {
return 0, fmt.Errorf("%w: BURN_NFT: corrupt NFT record", ErrTxFailed)
}
if nft.Owner != tx.From {
return 0, fmt.Errorf("%w: BURN_NFT: %s is not the owner",
ErrTxFailed, tx.From[:min(8, len(tx.From))])
}
if err := c.debitBalance(txn, tx.From, tx.Fee); err != nil {
return 0, fmt.Errorf("BURN_NFT fee debit: %w", err)
}
_ = txn.Delete([]byte(prefixNFTOwner + tx.From + ":" + p.NFTID))
nft.Burned = true
nft.Owner = ""
val, _ := json.Marshal(nft)
if err := txn.Set([]byte(prefixNFT+p.NFTID), val); err != nil {
return 0, fmt.Errorf("BURN_NFT update: %w", err)
}
log.Printf("[CHAIN] BURN_NFT id=%s burner=%s", p.NFTID, tx.From[:min(8, len(tx.From))])
case EventBlockReward:
return 0, fmt.Errorf("%w: BLOCK_REWARD is a synthetic event and cannot be included in blocks",
ErrTxFailed)
default:
// Forward-compatibility: a tx with an EventType this binary doesn't
// recognise is treated as a no-op rather than a hard error. This
// lets newer clients include newer event kinds in blocks without
// splitting the validator set every time a feature lands.
//
// Still charge the fee so the tx isn't a free spam vector: if an
// attacker sends bogus-type txs, they pay for each one like any
// other tx. The validator pockets the fee via the outer AddBlock
// loop (collectedFees += tx.Fee).
if err := c.debitBalance(txn, tx.From, tx.Fee); err != nil {
return 0, fmt.Errorf("%w: unknown-type fee debit: %v", ErrTxFailed, err)
}
log.Printf("[CHAIN] unknown event type %q in tx %s — applied as no-op (binary is older than this tx)",
tx.Type, tx.ID)
}
return 0, nil
}
func (c *Chain) applyOpenPayChan(txn *badger.Txn, tx *Transaction) error {
var p OpenPayChanPayload
if err := json.Unmarshal(tx.Payload, &p); err != nil {
return err
}
if p.ChannelID == "" || p.PartyA == "" || p.PartyB == "" {
return fmt.Errorf("missing channel fields")
}
if tx.From != p.PartyA {
return fmt.Errorf("tx.From must be PartyA")
}
if p.DepositA+p.DepositB == 0 {
return fmt.Errorf("at least one deposit must be > 0")
}
// Verify PartyB's counter-signature over the channel parameters.
sigPayload := payChanSigPayload(p.ChannelID, p.PartyA, p.PartyB, p.DepositA, p.DepositB, p.ExpiryBlock)
if ok, err := verifyEd25519(p.PartyB, sigPayload, p.SigB); err != nil || !ok {
return fmt.Errorf("invalid PartyB signature")
}
// Check channel does not already exist.
if _, existErr := txn.Get([]byte(prefixPayChan + p.ChannelID)); existErr == nil {
return fmt.Errorf("channel %s already exists", p.ChannelID)
}
// Lock deposits.
if p.DepositA > 0 {
if err := c.debitBalance(txn, p.PartyA, p.DepositA+tx.Fee); err != nil {
return fmt.Errorf("debit PartyA: %w", err)
}
} else {
if err := c.debitBalance(txn, p.PartyA, tx.Fee); err != nil {
return fmt.Errorf("debit PartyA fee: %w", err)
}
}
if p.DepositB > 0 {
if err := c.debitBalance(txn, p.PartyB, p.DepositB); err != nil {
return fmt.Errorf("debit PartyB: %w", err)
}
}
// Read current block height for OpenedBlock.
var height uint64
item, err := txn.Get([]byte(prefixHeight))
if err == nil {
_ = item.Value(func(val []byte) error { return json.Unmarshal(val, &height) })
}
state := PayChanState{
ChannelID: p.ChannelID,
PartyA: p.PartyA,
PartyB: p.PartyB,
DepositA: p.DepositA,
DepositB: p.DepositB,
ExpiryBlock: p.ExpiryBlock,
OpenedBlock: height,
Nonce: 0,
}
val, err := json.Marshal(state)
if err != nil {
return err
}
return txn.Set([]byte(prefixPayChan+p.ChannelID), val)
}
func (c *Chain) applyClosePayChan(txn *badger.Txn, tx *Transaction) error {
var p ClosePayChanPayload
if err := json.Unmarshal(tx.Payload, &p); err != nil {
return err
}
// Load channel state.
item, err := txn.Get([]byte(prefixPayChan + p.ChannelID))
if err != nil {
return fmt.Errorf("channel %s not found", p.ChannelID)
}
var state PayChanState
if err := item.Value(func(val []byte) error {
return json.Unmarshal(val, &state)
}); err != nil {
return err
}
if state.Closed {
return fmt.Errorf("channel %s already closed", p.ChannelID)
}
if p.Nonce < state.Nonce {
return fmt.Errorf("stale state: nonce %d < current %d", p.Nonce, state.Nonce)
}
total := state.DepositA + state.DepositB
if p.BalanceA+p.BalanceB != total {
return fmt.Errorf("balance sum %d != total deposits %d", p.BalanceA+p.BalanceB, total)
}
// Verify both parties' signatures over the final state.
sigPayload := payChanCloseSigPayload(p.ChannelID, p.BalanceA, p.BalanceB, p.Nonce)
if okA, err := verifyEd25519(state.PartyA, sigPayload, p.SigA); err != nil || !okA {
return fmt.Errorf("invalid PartyA close signature")
}
if okB, err := verifyEd25519(state.PartyB, sigPayload, p.SigB); err != nil || !okB {
return fmt.Errorf("invalid PartyB close signature")
}
// Distribute balances.
if p.BalanceA > 0 {
if err := c.creditBalance(txn, state.PartyA, p.BalanceA); err != nil {
return fmt.Errorf("credit PartyA: %w", err)
}
}
if p.BalanceB > 0 {
if err := c.creditBalance(txn, state.PartyB, p.BalanceB); err != nil {
return fmt.Errorf("credit PartyB: %w", err)
}
}
// Deduct fee from submitter.
if err := c.debitBalance(txn, tx.From, tx.Fee); err != nil {
return fmt.Errorf("debit closer fee: %w", err)
}
// Mark channel closed.
state.Closed = true
state.Nonce = p.Nonce
val, err := json.Marshal(state)
if err != nil {
return err
}
return txn.Set([]byte(prefixPayChan+p.ChannelID), val)
}
// payChanSigPayload returns the bytes both parties sign when agreeing to open a channel.
func payChanSigPayload(channelID, partyA, partyB string, depositA, depositB, expiryBlock uint64) []byte {
data, _ := json.Marshal(struct {
ChannelID string `json:"channel_id"`
PartyA string `json:"party_a"`
PartyB string `json:"party_b"`
DepositA uint64 `json:"deposit_a_ut"`
DepositB uint64 `json:"deposit_b_ut"`
ExpiryBlock uint64 `json:"expiry_block"`
}{channelID, partyA, partyB, depositA, depositB, expiryBlock})
return data
}
// payChanCloseSigPayload returns the bytes both parties sign to close a channel.
func payChanCloseSigPayload(channelID string, balanceA, balanceB, nonce uint64) []byte {
data, _ := json.Marshal(struct {
ChannelID string `json:"channel_id"`
BalanceA uint64 `json:"balance_a_ut"`
BalanceB uint64 `json:"balance_b_ut"`
Nonce uint64 `json:"nonce"`
}{channelID, balanceA, balanceB, nonce})
return data
}
// incrementRep reads, modifies, and writes a RepStats entry.
func (c *Chain) incrementRep(txn *badger.Txn, pubKeyHex string, fn func(*RepStats)) error {
key := []byte(prefixReputation + pubKeyHex)
var r RepStats
item, err := txn.Get(key)
if err != nil && !errors.Is(err, badger.ErrKeyNotFound) {
return err
}
if err == nil {
_ = item.Value(func(val []byte) error {
return json.Unmarshal(val, &r)
})
}
fn(&r)
r.Score = r.ComputeScore()
val, err := json.Marshal(r)
if err != nil {
return err
}
return txn.Set(key, val)
}
func (c *Chain) readBalance(txn *badger.Txn, pubKeyHex string) (uint64, error) {
item, err := txn.Get([]byte(prefixBalance + pubKeyHex))
if errors.Is(err, badger.ErrKeyNotFound) {
return 0, nil
}
if err != nil {
return 0, err
}
var bal uint64
err = item.Value(func(val []byte) error {
return json.Unmarshal(val, &bal)
})
return bal, err
}
func (c *Chain) writeBalance(txn *badger.Txn, pubKeyHex string, bal uint64) error {
val, err := json.Marshal(bal)
if err != nil {
return err
}
return txn.Set([]byte(prefixBalance+pubKeyHex), val)
}
func (c *Chain) creditBalance(txn *badger.Txn, pubKeyHex string, amount uint64) error {
bal, err := c.readBalance(txn, pubKeyHex)
if err != nil {
return err
}
return c.writeBalance(txn, pubKeyHex, bal+amount)
}
func (c *Chain) debitBalance(txn *badger.Txn, pubKeyHex string, amount uint64) error {
bal, err := c.readBalance(txn, pubKeyHex)
if err != nil {
return err // DB error — not ErrTxFailed
}
if bal < amount {
return fmt.Errorf("%w: insufficient balance for %s: have %d µT, need %d µT",
ErrTxFailed, pubKeyHex[:min(8, len(pubKeyHex))], bal, amount)
}
return c.writeBalance(txn, pubKeyHex, bal-amount)
}
func min(a, b int) int {
if a < b {
return a
}
return b
}
// ── Stake helpers ─────────────────────────────────────────────────────────────
func (c *Chain) readStake(txn *badger.Txn, pubKeyHex string) uint64 {
item, err := txn.Get([]byte(prefixStake + pubKeyHex))
if err != nil {
return 0
}
var amount uint64
_ = item.Value(func(val []byte) error { return json.Unmarshal(val, &amount) })
return amount
}
func (c *Chain) writeStake(txn *badger.Txn, pubKeyHex string, amount uint64) error {
if amount == 0 {
err := txn.Delete([]byte(prefixStake + pubKeyHex))
if errors.Is(err, badger.ErrKeyNotFound) {
return nil
}
return err
}
val, _ := json.Marshal(amount)
return txn.Set([]byte(prefixStake+pubKeyHex), val)
}
// Stake returns the staked amount for a public key (public query).
func (c *Chain) Stake(pubKeyHex string) (uint64, error) {
var amount uint64
err := c.db.View(func(txn *badger.Txn) error {
amount = c.readStake(txn, pubKeyHex)
return nil
})
return amount, err
}
// ── Token balance helpers ──────────────────────────────────────────────────────
func tokenBalKey(tokenID, pubKeyHex string) []byte {
return []byte(prefixTokenBal + tokenID + ":" + pubKeyHex)
}
func (c *Chain) readTokenBalance(txn *badger.Txn, tokenID, pubKeyHex string) uint64 {
item, err := txn.Get(tokenBalKey(tokenID, pubKeyHex))
if err != nil {
return 0
}
var bal uint64
_ = item.Value(func(val []byte) error { return json.Unmarshal(val, &bal) })
return bal
}
func (c *Chain) writeTokenBalance(txn *badger.Txn, tokenID, pubKeyHex string, bal uint64) error {
if bal == 0 {
err := txn.Delete(tokenBalKey(tokenID, pubKeyHex))
if errors.Is(err, badger.ErrKeyNotFound) {
return nil
}
return err
}
val, _ := json.Marshal(bal)
return txn.Set(tokenBalKey(tokenID, pubKeyHex), val)
}
func (c *Chain) creditTokenBalance(txn *badger.Txn, tokenID, pubKeyHex string, amount uint64) error {
bal := c.readTokenBalance(txn, tokenID, pubKeyHex)
return c.writeTokenBalance(txn, tokenID, pubKeyHex, bal+amount)
}
func (c *Chain) debitTokenBalance(txn *badger.Txn, tokenID, pubKeyHex string, amount uint64) error {
bal := c.readTokenBalance(txn, tokenID, pubKeyHex)
if bal < amount {
return fmt.Errorf("%w: insufficient token balance for %s: have %d, need %d",
ErrTxFailed, pubKeyHex[:min(8, len(pubKeyHex))], bal, amount)
}
return c.writeTokenBalance(txn, tokenID, pubKeyHex, bal-amount)
}
// TokenBalance returns the token balance for a public key (public query).
func (c *Chain) TokenBalance(tokenID, pubKeyHex string) (uint64, error) {
var bal uint64
err := c.db.View(func(txn *badger.Txn) error {
bal = c.readTokenBalance(txn, tokenID, pubKeyHex)
return nil
})
return bal, err
}
// Token returns a TokenRecord by ID.
func (c *Chain) Token(tokenID string) (*TokenRecord, error) {
var rec TokenRecord
err := c.db.View(func(txn *badger.Txn) error {
item, err := txn.Get([]byte(prefixToken + tokenID))
if errors.Is(err, badger.ErrKeyNotFound) {
return nil
}
if err != nil {
return err
}
return item.Value(func(val []byte) error { return json.Unmarshal(val, &rec) })
})
if err != nil || rec.TokenID == "" {
return nil, err
}
return &rec, nil
}
// Tokens returns all issued tokens.
func (c *Chain) Tokens() ([]TokenRecord, error) {
var out []TokenRecord
err := c.db.View(func(txn *badger.Txn) error {
prefix := []byte(prefixToken)
opts := badger.DefaultIteratorOptions
opts.Prefix = prefix
it := txn.NewIterator(opts)
defer it.Close()
for it.Rewind(); it.Valid(); it.Next() {
var rec TokenRecord
if err := it.Item().Value(func(val []byte) error {
return json.Unmarshal(val, &rec)
}); err == nil {
out = append(out, rec)
}
}
return nil
})
return out, err
}
// computeTokenID derives a deterministic token ID from issuer pubkey + symbol.
func computeTokenID(issuerPub, symbol string) string {
h := sha256.Sum256([]byte("token:" + issuerPub + ":" + symbol))
return hex.EncodeToString(h[:16])
}
// computeNFTID derives a deterministic NFT ID from minter pubkey + tx ID.
func computeNFTID(minterPub, txID string) string {
h := sha256.Sum256([]byte("nft:" + minterPub + ":" + txID))
return hex.EncodeToString(h[:16])
}
// NFT returns an NFTRecord by ID.
func (c *Chain) NFT(nftID string) (*NFTRecord, error) {
var rec NFTRecord
err := c.db.View(func(txn *badger.Txn) error {
item, err := txn.Get([]byte(prefixNFT + nftID))
if errors.Is(err, badger.ErrKeyNotFound) {
return nil
}
if err != nil {
return err
}
return item.Value(func(val []byte) error { return json.Unmarshal(val, &rec) })
})
if err != nil || rec.NFTID == "" {
return nil, err
}
return &rec, nil
}
// NFTsByOwner returns all NFTs owned by a public key (excluding burned).
func (c *Chain) NFTsByOwner(ownerPub string) ([]NFTRecord, error) {
var out []NFTRecord
err := c.db.View(func(txn *badger.Txn) error {
prefix := []byte(prefixNFTOwner + ownerPub + ":")
opts := badger.DefaultIteratorOptions
opts.Prefix = prefix
opts.PrefetchValues = false
it := txn.NewIterator(opts)
defer it.Close()
for it.Rewind(); it.Valid(); it.Next() {
key := string(it.Item().Key())
nftID := strings.TrimPrefix(key, prefixNFTOwner+ownerPub+":")
item, err := txn.Get([]byte(prefixNFT + nftID))
if err != nil {
continue
}
var rec NFTRecord
if err := item.Value(func(val []byte) error { return json.Unmarshal(val, &rec) }); err == nil && !rec.Burned {
out = append(out, rec)
}
}
return nil
})
return out, err
}
// NFTs returns all minted NFTs (including burned for history).
func (c *Chain) NFTs() ([]NFTRecord, error) {
var out []NFTRecord
err := c.db.View(func(txn *badger.Txn) error {
prefix := []byte(prefixNFT)
opts := badger.DefaultIteratorOptions
opts.Prefix = prefix
it := txn.NewIterator(opts)
defer it.Close()
for it.Rewind(); it.Valid(); it.Next() {
var rec NFTRecord
if err := it.Item().Value(func(val []byte) error {
return json.Unmarshal(val, &rec)
}); err == nil {
out = append(out, rec)
}
}
return nil
})
return out, err
}
// RegisteredRelayInfo wraps a relay's Ed25519 pub key with its registration payload.
type RegisteredRelayInfo struct {
PubKey string `json:"pub_key"`
Address string `json:"address"`
Relay RegisterRelayPayload `json:"relay"`
LastHeartbeat int64 `json:"last_heartbeat,omitempty"` // unix seconds
}
// writeRelayHeartbeat stores the given unix timestamp as the relay's
// last-heartbeat marker. Called from REGISTER_RELAY and from HEARTBEAT
// txs originating from registered relays.
func (c *Chain) writeRelayHeartbeat(txn *badger.Txn, nodePub string, unixSec int64) error {
var buf [8]byte
binary.BigEndian.PutUint64(buf[:], uint64(unixSec))
return txn.Set([]byte(prefixRelayHB+nodePub), buf[:])
}
// readRelayHeartbeat returns the stored unix seconds, or 0 if missing.
func (c *Chain) readRelayHeartbeat(txn *badger.Txn, nodePub string) int64 {
item, err := txn.Get([]byte(prefixRelayHB + nodePub))
if err != nil {
return 0
}
var out int64
_ = item.Value(func(val []byte) error {
if len(val) != 8 {
return nil
}
out = int64(binary.BigEndian.Uint64(val))
return nil
})
return out
}
// RegisteredRelays returns every relay node that has submitted EventRegisterRelay
// AND whose last heartbeat is within RelayHeartbeatTTL. Dead relays (node
// died, network went away) are filtered out so clients don't waste
// bandwidth trying to deliver through them.
//
// A relay without any recorded heartbeat is treated as live — this covers
// historical data from nodes upgraded to this version; they'll start
// recording heartbeats on the next HEARTBEAT tx.
func (c *Chain) RegisteredRelays() ([]RegisteredRelayInfo, error) {
var out []RegisteredRelayInfo
now := time.Now().Unix()
err := c.db.View(func(txn *badger.Txn) error {
prefix := []byte(prefixRelay)
opts := badger.DefaultIteratorOptions
opts.Prefix = prefix
it := txn.NewIterator(opts)
defer it.Close()
for it.Rewind(); it.Valid(); it.Next() {
item := it.Item()
key := string(item.Key())
pubKey := key[len(prefixRelay):]
var p RegisterRelayPayload
if err := item.Value(func(val []byte) error {
return json.Unmarshal(val, &p)
}); err != nil {
continue
}
hb := c.readRelayHeartbeat(txn, pubKey)
if hb > 0 && now-hb > RelayHeartbeatTTL {
continue // stale, delist
}
out = append(out, RegisteredRelayInfo{
PubKey: pubKey,
Address: pubKeyToAddr(pubKey),
Relay: p,
LastHeartbeat: hb,
})
}
return nil
})
return out, err
}
// contactRecord is the on-chain storage representation of a contact relationship.
type contactRecord struct {
Status string `json:"status"`
Intro string `json:"intro,omitempty"`
FeeUT uint64 `json:"fee_ut"`
TxID string `json:"tx_id"`
CreatedAt int64 `json:"created_at"`
}
// updateContactStatus updates the status of an existing contact record.
// Only Pending records may be transitioned — re-accepting an already-accepted
// or blocked request is a no-op error so an attacker cannot spam state changes
// on another user's contact list by replaying ACCEPT_CONTACT txs.
func (c *Chain) updateContactStatus(txn *badger.Txn, key string, status ContactStatus) error {
item, err := txn.Get([]byte(key))
if err != nil {
return fmt.Errorf("contact record not found")
}
var rec contactRecord
if err := item.Value(func(val []byte) error { return json.Unmarshal(val, &rec) }); err != nil {
return err
}
// Allowed transitions:
// Pending -> Accepted (ACCEPT_CONTACT)
// Pending -> Blocked (BLOCK_CONTACT)
// Accepted -> Blocked (BLOCK_CONTACT, recipient changes their mind)
// Everything else is rejected.
cur := ContactStatus(rec.Status)
switch status {
case ContactAccepted:
if cur != ContactPending {
return fmt.Errorf("cannot accept contact: current status is %q, must be %q",
cur, ContactPending)
}
case ContactBlocked:
if cur != ContactPending && cur != ContactAccepted {
return fmt.Errorf("cannot block contact: current status is %q", cur)
}
}
rec.Status = string(status)
val, _ := json.Marshal(rec)
return txn.Set([]byte(key), val)
}
// ContactRequests returns all incoming contact records for the given Ed25519 pubkey.
// Results include pending, accepted, and blocked records.
func (c *Chain) ContactRequests(targetPub string) ([]ContactInfo, error) {
prefix := []byte(prefixContactIn + targetPub + ":")
var out []ContactInfo
err := c.db.View(func(txn *badger.Txn) error {
opts := badger.DefaultIteratorOptions
opts.Prefix = prefix
it := txn.NewIterator(opts)
defer it.Close()
for it.Rewind(); it.Valid(); it.Next() {
item := it.Item()
key := string(item.Key())
// key format: contact_in:<targetPub>:<requesterPub>
requesterPub := key[len(prefixContactIn)+len(targetPub)+1:]
var rec contactRecord
if err := item.Value(func(val []byte) error {
return json.Unmarshal(val, &rec)
}); err != nil {
continue
}
out = append(out, ContactInfo{
RequesterPub: requesterPub,
RequesterAddr: pubKeyToAddr(requesterPub),
Status: ContactStatus(rec.Status),
Intro: rec.Intro,
FeeUT: rec.FeeUT,
TxID: rec.TxID,
CreatedAt: rec.CreatedAt,
})
}
return nil
})
return out, err
}
// IdentityInfo returns identity information for the given Ed25519 public key.
// It works even if the key has never submitted a REGISTER_KEY transaction.
func (c *Chain) IdentityInfo(pubKey string) (*IdentityInfo, error) {
info := &IdentityInfo{
PubKey: pubKey,
Address: pubKeyToAddr(pubKey),
}
err := c.db.View(func(txn *badger.Txn) error {
item, err := txn.Get([]byte(prefixIdentity + pubKey))
if err != nil {
return nil // not registered — return defaults
}
return item.Value(func(val []byte) error {
var p RegisterKeyPayload
if err := json.Unmarshal(val, &p); err != nil {
return err
}
info.Registered = true
info.Nickname = p.Nickname
info.X25519Pub = p.X25519PubKey
return nil
})
})
return info, err
}
// InitValidators replaces the on-chain validator set with the given pub keys.
// Any stale keys from previous runs are deleted first so that old Docker
// volumes or leftover DB state can never inject phantom validators into PBFT.
// Dynamic ADD_VALIDATOR / REMOVE_VALIDATOR transactions layer on top of this
// base set for the lifetime of the running node.
func (c *Chain) InitValidators(pubKeys []string) error {
return c.db.Update(func(txn *badger.Txn) error {
// Collect and delete all existing validator keys.
opts := badger.DefaultIteratorOptions
opts.PrefetchValues = false
prefix := []byte(prefixValidator)
it := txn.NewIterator(opts)
var toDelete [][]byte
for it.Seek(prefix); it.ValidForPrefix(prefix); it.Next() {
k := make([]byte, len(it.Item().Key()))
copy(k, it.Item().Key())
toDelete = append(toDelete, k)
}
it.Close()
for _, k := range toDelete {
if err := txn.Delete(k); err != nil {
return err
}
}
// Write the authoritative set from CLI flags.
for _, pk := range pubKeys {
if err := txn.Set([]byte(prefixValidator+pk), []byte{}); err != nil {
return err
}
}
return nil
})
}
// ValidatorSet returns the current active validator pub keys (sorted by insertion order).
func (c *Chain) ValidatorSet() ([]string, error) {
var validators []string
err := c.db.View(func(txn *badger.Txn) error {
opts := badger.DefaultIteratorOptions
opts.PrefetchValues = false
it := txn.NewIterator(opts)
defer it.Close()
prefix := []byte(prefixValidator)
for it.Seek(prefix); it.ValidForPrefix(prefix); it.Next() {
key := string(it.Item().Key())
validators = append(validators, key[len(prefixValidator):])
}
return nil
})
return validators, err
}
// validatorSetTxn returns the full current validator set inside the given
// txn. Used by ADD_VALIDATOR to compute the ⌈2/3⌉ approval threshold.
// Ordering is iteration order (BadgerDB key order), stable enough for
// threshold math.
func (c *Chain) validatorSetTxn(txn *badger.Txn) ([]string, error) {
prefix := []byte(prefixValidator)
opts := badger.DefaultIteratorOptions
opts.PrefetchValues = false
opts.Prefix = prefix
it := txn.NewIterator(opts)
defer it.Close()
var out []string
for it.Rewind(); it.Valid(); it.Next() {
out = append(out, string(it.Item().Key()[len(prefix):]))
}
return out, nil
}
// contains is a tiny generic-free helper: true if s is in haystack.
func contains(haystack []string, s string) bool {
for _, h := range haystack {
if h == s {
return true
}
}
return false
}
// isValidatorTxn checks if pubKey is an active validator inside a read/write txn.
func (c *Chain) isValidatorTxn(txn *badger.Txn, pubKey string) (bool, error) {
_, err := txn.Get([]byte(prefixValidator + pubKey))
if err == badger.ErrKeyNotFound {
return false, nil
}
if err != nil {
return false, err
}
return true, nil
}
// verifyEd25519 verifies an Ed25519 signature without importing the identity package
// (which would create a circular dependency).
func verifyEd25519(pubKeyHex string, msg, sig []byte) (bool, error) {
pubBytes, err := hex.DecodeString(pubKeyHex)
if err != nil {
return false, fmt.Errorf("invalid pub key hex: %w", err)
}
return ed25519.Verify(ed25519.PublicKey(pubBytes), msg, sig), nil
}
// --- contract helpers ---
// computeContractID returns hex(sha256(deployerPub || wasmBytes)[:16]).
// Stable and deterministic: same deployer + same WASM → same contract ID.
func computeContractID(deployerPub string, wasmBytes []byte) string {
import256 := sha256.New()
import256.Write([]byte(deployerPub))
import256.Write(wasmBytes)
return hex.EncodeToString(import256.Sum(nil)[:16])
}
// decodeBase64 decodes a standard or raw base64 string into dst.
// Returns the number of bytes written.
func decodeBase64(s string, dst []byte) (int, error) {
import64 := base64.StdEncoding
// Try standard encoding first, then raw (no padding).
n, err := import64.Decode(dst, []byte(s))
if err != nil {
n, err = base64.RawStdEncoding.Decode(dst, []byte(s))
}
return n, err
}
// Contracts returns all deployed contracts (WASM bytes omitted).
func (c *Chain) Contracts() ([]ContractRecord, error) {
var out []ContractRecord
err := c.db.View(func(txn *badger.Txn) error {
prefix := []byte(prefixContract)
opts := badger.DefaultIteratorOptions
opts.Prefix = prefix
it := txn.NewIterator(opts)
defer it.Close()
for it.Rewind(); it.Valid(); it.Next() {
var rec ContractRecord
if err := it.Item().Value(func(v []byte) error {
return json.Unmarshal(v, &rec)
}); err != nil {
continue
}
rec.WASMBytes = nil // strip bytes from list response
out = append(out, rec)
}
return nil
})
return out, err
}
// GetContract returns the ContractRecord for the given contract ID, or nil if not found.
func (c *Chain) GetContract(contractID string) (*ContractRecord, error) {
var rec ContractRecord
err := c.db.View(func(txn *badger.Txn) error {
item, err := txn.Get([]byte(prefixContract + contractID))
if err != nil {
return err
}
return item.Value(func(val []byte) error {
return json.Unmarshal(val, &rec)
})
})
if errors.Is(err, badger.ErrKeyNotFound) {
return nil, nil
}
return &rec, err
}
// GetContractState returns the raw state value for a contract key, or nil if not set.
func (c *Chain) GetContractState(contractID, key string) ([]byte, error) {
var result []byte
err := c.db.View(func(txn *badger.Txn) error {
dbKey := []byte(prefixContractState + contractID + ":" + key)
item, err := txn.Get(dbKey)
if err != nil {
return err
}
return item.Value(func(val []byte) error {
result = make([]byte, len(val))
copy(result, val)
return nil
})
})
if errors.Is(err, badger.ErrKeyNotFound) {
return nil, nil
}
return result, err
}
// ContractLogs returns the most recent log entries for a contract, newest first.
// limit <= 0 returns up to 100 entries.
func (c *Chain) ContractLogs(contractID string, limit int) ([]ContractLogEntry, error) {
if limit <= 0 || limit > 100 {
limit = 100
}
prefix := []byte(prefixContractLog + contractID + ":")
var entries []ContractLogEntry
err := c.db.View(func(txn *badger.Txn) error {
opts := badger.DefaultIteratorOptions
opts.Reverse = true
opts.Prefix = prefix
it := txn.NewIterator(opts)
defer it.Close()
// Seek to the end of the prefix range (prefix + 0xFF).
seekKey := append(append([]byte{}, prefix...), 0xFF)
for it.Seek(seekKey); it.ValidForPrefix(prefix) && len(entries) < limit; it.Next() {
var entry ContractLogEntry
if err := it.Item().Value(func(val []byte) error {
return json.Unmarshal(val, &entry)
}); err == nil {
entries = append(entries, entry)
}
}
return nil
})
return entries, err
}
// maxContractCallDepth is the maximum nesting depth for inter-contract calls.
// Prevents infinite recursion and stack overflows.
const maxContractCallDepth = 8
// chainHostEnv implements VMHostEnv backed by a live badger.Txn.
type chainHostEnv struct {
txn *badger.Txn
contractID string
caller string
blockHeight uint64
txID string
logSeq int
chain *Chain
depth int // inter-contract call nesting depth (0 = top-level)
}
func newChainHostEnv(txn *badger.Txn, contractID, caller, txID string, blockHeight uint64, chain *Chain) *chainHostEnv {
return newChainHostEnvDepth(txn, contractID, caller, txID, blockHeight, chain, 0)
}
func newChainHostEnvDepth(txn *badger.Txn, contractID, caller, txID string, blockHeight uint64, chain *Chain, depth int) *chainHostEnv {
// Count existing log entries for this contract+block so that logs from
// multiple TXs within the same block get unique sequence numbers and don't
// overwrite each other.
startSeq := countContractLogsInBlock(txn, contractID, blockHeight)
return &chainHostEnv{
txn: txn,
contractID: contractID,
caller: caller,
txID: txID,
blockHeight: blockHeight,
chain: chain,
logSeq: startSeq,
depth: depth,
}
}
// countContractLogsInBlock counts how many log entries already exist for the
// given contract at the given block height (used to pick the starting logSeq).
func countContractLogsInBlock(txn *badger.Txn, contractID string, blockHeight uint64) int {
prefix := []byte(fmt.Sprintf("%s%s:%020d:", prefixContractLog, contractID, blockHeight))
opts := badger.DefaultIteratorOptions
opts.PrefetchValues = false
opts.Prefix = prefix
it := txn.NewIterator(opts)
defer it.Close()
n := 0
for it.Seek(prefix); it.ValidForPrefix(prefix); it.Next() {
n++
}
return n
}
func (e *chainHostEnv) GetState(key []byte) ([]byte, error) {
dbKey := []byte(prefixContractState + e.contractID + ":" + string(key))
item, err := e.txn.Get(dbKey)
if errors.Is(err, badger.ErrKeyNotFound) {
return nil, nil
}
if err != nil {
return nil, err
}
var val []byte
err = item.Value(func(v []byte) error {
val = make([]byte, len(v))
copy(val, v)
return nil
})
return val, err
}
func (e *chainHostEnv) SetState(key, value []byte) error {
dbKey := []byte(prefixContractState + e.contractID + ":" + string(key))
return e.txn.Set(dbKey, value)
}
func (e *chainHostEnv) GetBalance(pubKeyHex string) (uint64, error) {
return e.chain.readBalance(e.txn, pubKeyHex)
}
func (e *chainHostEnv) Transfer(from, to string, amount uint64) error {
if err := e.chain.debitBalance(e.txn, from, amount); err != nil {
return err
}
return e.chain.creditBalance(e.txn, to, amount)
}
func (e *chainHostEnv) GetCaller() string { return e.caller }
func (e *chainHostEnv) GetBlockHeight() uint64 { return e.blockHeight }
// GetContractTreasury returns a deterministic ownerless address for this
// contract derived as hex(sha256(contractID + ":treasury")).
// No private key exists for this address; only the contract itself can spend
// from it via the transfer host function.
func (e *chainHostEnv) GetContractTreasury() string {
h := sha256.Sum256([]byte(e.contractID + ":treasury"))
return hex.EncodeToString(h[:])
}
func (e *chainHostEnv) Log(msg string) {
log.Printf("[CONTRACT %s] %s", e.contractID[:8], msg)
entry := ContractLogEntry{
ContractID: e.contractID,
BlockHeight: e.blockHeight,
TxID: e.txID,
Seq: e.logSeq,
Message: msg,
}
val, _ := json.Marshal(entry)
// Key: clog:<contractID>:<blockHeight_20d>:<seq_05d>
key := fmt.Sprintf("%s%s:%020d:%05d", prefixContractLog, e.contractID, e.blockHeight, e.logSeq)
_ = e.txn.Set([]byte(key), val)
e.logSeq++
}
// CallContract executes a method on another deployed contract from within
// the current contract execution. The caller seen by the sub-contract is
// the current contract's ID. State changes share the same badger.Txn so
// they are all committed or rolled back atomically with the parent call.
func (e *chainHostEnv) CallContract(contractID, method string, argsJSON []byte, gasLimit uint64) (uint64, error) {
if e.depth >= maxContractCallDepth {
return 0, fmt.Errorf("%w: inter-contract call depth limit (%d) exceeded",
ErrTxFailed, maxContractCallDepth)
}
if e.chain.vm == nil {
return 0, fmt.Errorf("%w: VM not available for inter-contract call", ErrTxFailed)
}
item, err := e.txn.Get([]byte(prefixContract + contractID))
if err != nil {
if errors.Is(err, badger.ErrKeyNotFound) {
return 0, fmt.Errorf("%w: contract %s not found", ErrTxFailed, contractID)
}
return 0, err
}
var rec ContractRecord
if err := item.Value(func(val []byte) error { return json.Unmarshal(val, &rec) }); err != nil {
return 0, fmt.Errorf("%w: corrupt contract record for inter-contract call: %v", ErrTxFailed, err)
}
// Sub-contract sees the current contract as its caller.
subEnv := newChainHostEnvDepth(e.txn, contractID, e.contractID, e.txID, e.blockHeight, e.chain, e.depth+1)
// Same timeout guard as the top-level CALL_CONTRACT path; protects
// against recursive contract calls that never return.
subCtx, subCancel := context.WithTimeout(context.Background(), 30*time.Second)
gasUsed, callErr := e.chain.vm.Call(
subCtx,
contractID, rec.WASMBytes,
method, argsJSON,
gasLimit, subEnv,
)
subCancel()
if callErr != nil {
return gasUsed, fmt.Errorf("%w: sub-call %s.%s: %v", ErrTxFailed, contractID[:min(8, len(contractID))], method, callErr)
}
log.Printf("[CHAIN] inter-contract %s→%s.%s gasUsed=%d",
e.contractID[:min(8, len(e.contractID))], contractID[:min(8, len(contractID))], method, gasUsed)
return gasUsed, nil
}