Alexander Chepurnoy

The Web of Mind

Scorex 2.0: A Full-Node View

| Comments

In this article we will go through state replication mechanism implementation abstractions of a full node in a blockchain system.

Full node is a node which holds at least some state enough to check whether an arbitrary transaction is valid against it and so applicable to it or not. We can define such a state, minimal state, as an abstract component with just one basic operation :

trait MinimalState[TX <: Transaction] {
  def apply(transaction: TX): Try[MinimalState[TX]]
}

Result of the apply function is whether an updated state or an error if transaction is not applicable: Success[MinimalState[TX]] | Failure(...) (Try[A] is just a sum type for these two options).

Even with such a minimalistic definition, we can formulate a first law: we cannot apply the same transaction twice. In form of ScalaCheck-based property test that could be like

forAll { (minState, tx) =>
  minstate.apply(tx).flatMap(_.apply(tx)).isFailure
}

In Bitcoin minimal state is about unspent transaction output set, and a successful transaction application is about removing outputs spent by the transaction and adding outputs from it. It is obviously impossible to apply the transaction again.

We need some starting point to start applying transactions from. We call it the genesis state. For Bitcoin, genesis state is just an empty set.

Now we want all the nodes in an open network to have the same minimal state eventually. For that, we need to save a log of transformations and be sure the log is eventually the same on all the honest nodes in presence of Byzantine adversaries.

It is achieved via blockchain log structure. We pack transactions into blocks and fix the order with hashchain-like structure. Block application to a minimal state is deterministic, so starting with the same hard-coded genesis state all the honest nodes are getting the same minimal state after applying the same blockchain. Thus they share the same view on validity of an arbitrary transaction.

Things are not so simple though. As nodes are equal and there is no any arbiter in the network some consensus protocol working in a decentralized environment is needed to append new blocks. Sometimes collisions occur, and while Bitcoin protocol is trying to ignore them, some alternatives to it (GHOST/SPECTRE) are taking explicit blocktree model to the account. Rollerchain proposal (http://arxiv.org/abs/1603.07926) is aiming to achieve fullnode security if a node applying state snapshot and then some numbers of full blocks to it. Bitcoin-NG and ByzCoin are splitting blocks into empty blocks created with Proof-of-Work followed by microblocks with transactions. We generalize the notion of a log member calling it a persistent node view modifier:

trait PersistentNodeViewModifier[TX <: Transaction extends NodeViewModifier {

  // with Dotty is would be Seq[TX] | Nothing
  def transactions: Option[Seq[TX]]
}

and then we define abstract history

trait History[TX <: Transaction, PM <: PersistentNodeViewModifier[TX]] {
  type ApplicationResult = Try[(History[TX, PM], Option[RollbackTo[PM]])]

  def contains(block: PM): Boolean = contains(block.id())

  def append(block: PM): ApplicationResult

  ...
}

Note that appending a block has an optional additional side-effect that is some information about rollback performed during an append. We skip details for now.

In addition to the state modifiers log and the minimal state, a fullnode also contains two more entities. Memory pool contains transactions not yet included into blocks(and there’s no guarantee of inclusion for them). Vault contains some node-specific information a node is extracting from the log. For example, it could contain values encoded in some or all OP_RETURN instructions, or all the transactions for specific addresses. The well-known example of vault is wallet which contains private keys as well as transaction associated with their public images.

With the four entities being defined we can explicitly state a node view type now:

type NodeView = (History[TX, PMOD], MinimalState[TX, PMOD], Vault[TX], MemoryPool[TX])

And by having the compound entity we can ensure rules of its modification:

  • an offchain transaction modifies vault and memory pool. Atomicity in this update is not critical.
  • for a persistent node view modifier (blockheader, full block, key block, microblock, state snapshot) atomicity for an update is strictly needed! If history is producing rollback sude-effect, other parts must handle it properly before applying an update. This sounds trivial, but in fact many implementation are spending years fighting with bugs related to inconsistency and read-when-update issues.

That is all for now! To be continued!

P.S. Please note the real entities in Scorex 2.0 Core have more complex type signatures.

P.P.S. SPV nodes do not hold a sufficiently rich state to validate an arbitrary transaction.

Comments