Bitcoin-Lite Node

hard · bitcoin, capstone, node

Capstone: Bitcoin-Lite Node

Build a minimal Bitcoin-like node with:

  • UTXO validation
  • P2PKH-lite script checking (HASH256)
  • Mempool selection by fee rate
  • Proof-of-work mining (fixed difficulty)
  • Chain selection and reorgs (longest chain)

This is a toy node but must preserve core invariants.

Types

type Outpoint struct {
    TxID  [32]byte
    Index uint32
}

type TxIn struct {
    Prev      Outpoint
    ScriptSig []byte
}

type TxOut struct {
    Value        uint64
    ScriptPubKey []byte
}

type Transaction struct {
    Version  int32
    Inputs   []TxIn
    Outputs  []TxOut
    LockTime uint32
}

type BlockHeader struct {
    Version    uint32
    PrevHash   [32]byte
    MerkleRoot [32]byte
    Timestamp  uint32
    Bits       uint32
    Nonce      uint32
}

type Block struct {
    Header BlockHeader
    Txs    []Transaction
}

type UTXOSet map[Outpoint]TxOut

type UndoEntry struct {
    Outpoint Outpoint
    Output   TxOut
}

type Node struct {
    Tip        [32]byte
    Blocks     map[[32]byte]Block
    Height     map[[32]byte]int
    UTXO       UTXOSet
    Undo       map[[32]byte][]UndoEntry
    Mempool    map[[32]byte]Transaction
    Bits       uint32
    Subsidy    uint64
    MaxBlockSz int
    MinerScript []byte
}

func NewNode(genesis Block, bits uint32, subsidy uint64, maxBlockSize int, minerScript []byte) *Node
func (n *Node) SubmitTx(tx Transaction) bool
func (n *Node) MineBlock(timestamp uint32, maxNonce uint32) (Block, bool)
func (n *Node) ReceiveBlock(block Block) bool

Initialization and model boundaries

NewNode receives a valid, trusted genesis with zero PrevHash; it initializes height zero and its outputs. The constructor does not revalidate genesis against the configured difficulty or subsidy. All received new blocks must have a known, nonzero parent. Configuration uses a positive byte budget, a fixed valid Bits value, and nonnegative chain heights representable by the API. Callers do not mutate accepted transactions/blocks, configuration, or the node's exposed maps. This is a single-threaded in-memory model; there is no network or disk API.

Canonical bytes and scripts

  • Serialize the earlier legacy transaction layout, using ffffffff for every input's Sequence (the capstone type omits that field). Values are uint64 LE; counts/script lengths use canonical CompactSize; txid is double-SHA256.
  • Merkle leaves are txids, without an extra leaf hash; duplicate the final node at odd levels. Header serialization is the earlier 80-byte layout.
  • Use the earlier unsigned 24-bit compact mantissa and raw big-endian hash integer for PoW. These are course conventions, not full Bitcoin consensus semantics.
  • Support direct byte pushes 1..75, DUP (76 hex), HASH256 (aa), EQUALVERIFY (88), and CHECKSIG (ac), rejecting malformed scripts, unsupported opcodes, and stack underflow. Evaluate ScriptSig then the referenced ScriptPubKey on one stack; its final top must be nonzero. CHECKSIG uses ASN.1 ECDSA with P-256, uncompressed 65-byte public keys, and this exact digest: doubleSHA256(serializeTxWithAllScriptSigsEmpty || uint32LE(inputIndex) || referencedScriptPubKey). Output scripts remain intact. This replaces the standalone script task's supplied-ID signing rule; a txid including the signature would be circular.

Transaction admission

SubmitTx accepts only ordinary transactions with at least one input. Every input must exist in the current confirmed UTXO map, appear once, and pass script validation. Checked uint64 input/output sums must satisfy inputs >= outputs. Reject duplicate mempool txids and conflicts with already admitted inputs. Admission changes only Mempool. Unconfirmed children are not admitted in this version. The fee is the checked input sum minus the checked output sum.

Mining

Sort eligible mempool candidates once by exact fee/serialized-size descending, then fee descending, then raw txid bytes lexicographically ascending. Avoid rounded division and overflowing cross-products. Scan once against an evolving private UTXO view; skip invalid or oversized candidates without side effects.

The budget is the sum of serialized transaction bytes, including the actual coinbase, excluding the block header and outer transaction count. Exact fits are allowed. Coinbase has Version=1, zero inputs, one output to MinerScript with value subsidy+selected fees, and LockTime equal to the new height. This height convention makes consecutive rewards distinct; LockTime has no scheduling semantics here. If height cannot fit uint32, or coinbase cannot fit the budget, mining fails. Skip candidates that would overflow cumulative fees or reward.

Mine nonces 0..maxNonce inclusively using configured Bits. On exhausted search return (Block{}, false) without changing node state. Pass a found candidate through the same acceptance path as a received block.

Block validation and branch selection

For a new block:

  1. Require fixed Bits, an indexed nonzero parent, matching Merkle root, valid PoW, a nonempty transaction list, and a transaction-byte sum within MaxBlockSz.
  2. Build a private UTXO view of its parent branch. Require coinbase first (zero inputs, at least one output), no duplicate txids in the block, and no output that overwrites a live outpoint in the parent view.
  3. Apply ordinary transactions in order with the same script/value checks. Outputs from earlier ordinary transactions are available. This block's coinbase is not available to its own transactions.
  4. Sum fees and coinbase outputs with checked uint64 arithmetic. Require total coinbase value <= subsidy+fees, also rejecting overflow. Then add coinbase outputs. There is no later coinbase-maturity delay in this model.

Failed acceptance leaves all node state unchanged. A known header returns true only when the supplied transaction body matches the stored body; a changed body under the same header returns false.

Store a valid side branch even when it does not win. Fixed Bits means longest chain determines preference; switch only for a strictly greater height, keeping the existing tip on ties. Publish Tip and UTXO for the same selected branch. Populate Undo for its blocks with spent-outpoint records. Replaying the selected path from genesis is an acceptable baseline; incremental undo/reapply is an optional optimization. Genesis is the trusted exception to reward checks.

After a tip change, remove confirmed transactions and mempool transactions that are invalid under the new confirmed view. Re-admission of transactions from a disconnected branch is outside this exercise. On one branch, ordinary input spends and coinbase rewards must never silently overwrite live value.

Run tests to see results
No issues detected
    Join Discord